integrate backend with paddle

This commit is contained in:
m5r
2021-09-27 06:08:02 +08:00
parent 9cec49f255
commit 0f2c3daf77
16 changed files with 679 additions and 25 deletions

View File

@ -0,0 +1,75 @@
import type { BlitzApiHandler, BlitzApiRequest, BlitzApiResponse } from "blitz";
import { getConfig } from "blitz";
import { PaddleSdk, stringifyMetadata } from "@devoxa/paddle-sdk";
import type { ApiError } from "../../../core/types";
import { subscriptionCreatedHandler } from "../../webhook-handlers/subscription-created";
import { subscriptionPaymentSucceededHandler } from "../../webhook-handlers/subscription-payment-succeeded";
import { subscriptionCancelled } from "../../webhook-handlers/subscription-cancelled";
import { subscriptionUpdated } from "../../webhook-handlers/subscription-updated";
import appLogger from "../../../../integrations/logger";
type SupportedWebhook =
| "subscription_created"
| "subscription_cancelled"
| "subscription_payment_succeeded"
| "subscription_updated";
const supportedWebhooks: SupportedWebhook[] = [
"subscription_created",
"subscription_cancelled",
"subscription_payment_succeeded",
"subscription_updated",
];
const handlers: Record<SupportedWebhook, BlitzApiHandler> = {
subscription_created: subscriptionCreatedHandler,
subscription_payment_succeeded: subscriptionPaymentSucceededHandler,
subscription_cancelled: subscriptionCancelled,
subscription_updated: subscriptionUpdated,
};
function isSupportedWebhook(webhook: any): webhook is SupportedWebhook {
return supportedWebhooks.includes(webhook);
}
const logger = appLogger.child({ route: "/api/subscription/webhook" });
const { publicRuntimeConfig, serverRuntimeConfig } = getConfig();
const paddleSdk = new PaddleSdk({
publicKey: serverRuntimeConfig.paddle.publicKey,
vendorId: publicRuntimeConfig.paddle.vendorId,
vendorAuthCode: serverRuntimeConfig.paddle.apiKey,
metadataCodec: stringifyMetadata(),
});
export default async function webhook(req: BlitzApiRequest, res: BlitzApiResponse) {
if (req.method !== "POST") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["POST"]);
res.status(statusCode).send(apiError);
return;
}
if (!paddleSdk.verifyWebhookEvent(req.body)) {
const statusCode = 500;
const apiError: ApiError = {
statusCode,
errorMessage: "Webhook event is invalid",
};
logger.error(apiError);
return res.status(statusCode).send(apiError);
}
const alertName = req.body.alert_name;
if (isSupportedWebhook(alertName)) {
return handlers[alertName](req, res);
}
return res.status(400).end();
}

View File

@ -0,0 +1,20 @@
import { NotFoundError, resolver } from "blitz";
import { z } from "zod";
import { updateSubscriptionPlan } from "../../../integrations/paddle";
import db from "../../../db";
const Body = z.object({
planId: z.string(),
});
export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ planId }, ctx) => {
const subscription = await db.subscription.findFirst({ where: { organizationId: ctx.session.orgId } });
if (!subscription) {
throw new NotFoundError();
}
const subscriptionId = subscription.paddleSubscriptionId;
const result = await updateSubscriptionPlan({ planId, subscriptionId });
console.log("result", result);
});

View File

@ -0,0 +1,79 @@
import type { BlitzApiHandler } from "blitz";
import { NotFoundError } from "blitz";
import { z } from "zod";
import type { ApiError } from "../../core/types";
import db from "db";
import appLogger from "../../../integrations/logger";
const logger = appLogger.child({ module: "subscription-cancelled" });
export const subscriptionCancelled: BlitzApiHandler = async (req, res) => {
const validationResult = bodySchema.safeParse(req.body);
if (!validationResult.success) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationResult.error, "/api/subscription/webhook");
res.status(statusCode).send(apiError);
return;
}
const body = validationResult.data;
const paddleSubscriptionId = body.subscription_id;
const subscription = await db.subscription.findFirst({ where: { paddleSubscriptionId } });
if (!subscription) {
throw new NotFoundError();
}
const lastEventTime = new Date(body.event_time);
const isEventOlderThanLastUpdate = subscription.lastEventTime > lastEventTime;
if (isEventOlderThanLastUpdate) {
res.status(200).end();
return;
}
const paddleCheckoutId = body.checkout_id;
const planId = body.subscription_plan_id;
const status = body.status;
const currency = body.currency;
const unitPrice = body.unit_price;
await db.subscription.update({
where: { paddleSubscriptionId },
data: {
paddleSubscriptionId,
paddlePlanId: planId,
paddleCheckoutId,
status,
lastEventTime,
currency,
unitPrice,
},
});
return res.status(200).end();
};
const bodySchema = z.object({
alert_id: z.string(),
alert_name: z.string(),
cancellation_effective_date: z.string(),
checkout_id: z.string(),
currency: z.string(),
email: z.string(),
event_time: z.string(),
linked_subscriptions: z.string(),
marketing_consent: z.string(),
passthrough: z.string(),
quantity: z.string(),
status: z.enum(["active", "trialing", "past_due", "paused", "deleted"]),
subscription_id: z.string(),
subscription_plan_id: z.string(),
unit_price: z.string(),
user_id: z.string(),
p_signature: z.string(),
});

View File

@ -0,0 +1,132 @@
import type { BlitzApiHandler } from "blitz";
import { NotFoundError } from "blitz";
import { z } from "zod";
import type { ApiError } from "../../core/types";
import db, { MembershipRole } from "db";
import appLogger from "../../../integrations/logger";
import { sendEmail } from "../../../integrations/ses";
const logger = appLogger.child({ module: "subscription-created" });
export const subscriptionCreatedHandler: BlitzApiHandler = async (req, res) => {
const validationResult = bodySchema.safeParse(req.body);
if (!validationResult.success) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationResult.error, "/api/subscription/webhook");
res.status(statusCode).send(apiError);
return;
}
const body = validationResult.data;
const { organizationId } = JSON.parse(body.passthrough);
const organization = await db.organization.findFirst({
where: { id: organizationId },
include: {
subscription: true,
memberships: {
include: { user: true },
},
},
});
if (!organization) {
throw new NotFoundError();
}
const orgOwner = organization.memberships.find((membership) => membership.role === MembershipRole.OWNER);
const email = orgOwner!.user!.email;
const paddleCheckoutId = body.checkout_id;
const paddleSubscriptionId = body.subscription_id;
const planId = body.subscription_plan_id;
const nextBillDate = new Date(body.next_bill_date);
const status = body.status;
const lastEventTime = new Date(body.event_time);
const updateUrl = body.update_url;
const cancelUrl = body.cancel_url;
const currency = body.currency;
const unitPrice = body.unit_price;
if (!!organization.subscription) {
await db.subscription.update({
where: { paddleSubscriptionId: organization.subscription.paddleSubscriptionId },
data: {
paddleSubscriptionId,
paddlePlanId: planId,
paddleCheckoutId,
nextBillDate,
status,
lastEventTime,
updateUrl,
cancelUrl,
currency,
unitPrice,
},
});
sendEmail({
subject: "Welcome back to Shellphone",
body: "Welcome back to Shellphone",
recipients: [email],
}).catch((error) => {
logger.error(error, "/api/subscription/webhook");
});
} else {
await db.organization.update({
where: { id: organizationId },
data: {
subscription: {
create: {
paddleSubscriptionId,
paddlePlanId: planId,
paddleCheckoutId,
nextBillDate,
status,
lastEventTime,
updateUrl,
cancelUrl,
currency,
unitPrice,
},
},
},
});
sendEmail({
subject: "Welcome to Shellphone",
body: `Welcome to Shellphone`,
recipients: [email],
}).catch((error) => {
logger.error(error, "/api/webhook/subscription");
});
}
return res.status(200).end();
};
const bodySchema = z.object({
alert_id: z.string(),
alert_name: z.string(),
cancel_url: z.string(),
checkout_id: z.string(),
currency: z.string(),
email: z.string(),
event_time: z.string(),
linked_subscriptions: z.string(),
marketing_consent: z.string(),
next_bill_date: z.string(),
passthrough: z.string(),
quantity: z.string(),
source: z.string(),
status: z.enum(["active", "trialing", "past_due", "paused", "deleted"]),
subscription_id: z.string(),
subscription_plan_id: z.string(),
unit_price: z.string(),
update_url: z.string(),
user_id: z.string(),
p_signature: z.string(),
});

View File

@ -0,0 +1,100 @@
import type { BlitzApiHandler } from "blitz";
import { NotFoundError } from "blitz";
import { z } from "zod";
import type { ApiError } from "../../core/types";
import db from "db";
import appLogger from "../../../integrations/logger";
const logger = appLogger.child({ module: "subscription-payment-succeeded" });
export const subscriptionPaymentSucceededHandler: BlitzApiHandler = async (req, res) => {
const validationResult = bodySchema.safeParse(req.body);
if (!validationResult.success) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationResult.error, "/api/subscription/webhook");
res.status(statusCode).send(apiError);
return;
}
const body = validationResult.data;
const paddleSubscriptionId = body.subscription_id;
const subscription = await db.subscription.findFirst({ where: { paddleSubscriptionId } });
if (!subscription) {
throw new NotFoundError();
}
const lastEventTime = new Date(body.event_time);
const isEventOlderThanLastUpdate = subscription.lastEventTime > lastEventTime;
if (isEventOlderThanLastUpdate) {
res.status(200).end();
return;
}
const paddleCheckoutId = body.checkout_id;
const planId = body.subscription_plan_id;
const nextBillDate = new Date(body.next_bill_date);
const status = body.status;
const currency = body.currency;
const unitPrice = body.unit_price;
await db.subscription.update({
where: { paddleSubscriptionId },
data: {
paddleSubscriptionId,
paddlePlanId: planId,
paddleCheckoutId,
nextBillDate,
status,
lastEventTime,
currency,
unitPrice,
},
});
return res.status(200).end();
};
const bodySchema = z.object({
alert_id: z.string(),
alert_name: z.string(),
balance_currency: z.string(),
balance_earnings: z.string(),
balance_fee: z.string(),
balance_gross: z.string(),
balance_tax: z.string(),
checkout_id: z.string(),
country: z.string(),
coupon: z.string(),
currency: z.string(),
customer_name: z.string(),
earnings: z.string(),
email: z.string(),
event_time: z.string(),
fee: z.string(),
initial_payment: z.string(),
instalments: z.string(),
marketing_consent: z.string(),
next_bill_date: z.string(),
next_payment_amount: z.string(),
order_id: z.string(),
passthrough: z.string(),
payment_method: z.string(),
payment_tax: z.string(),
plan_name: z.string(),
quantity: z.string(),
receipt_url: z.string(),
sale_gross: z.string(),
status: z.enum(["active", "trialing", "past_due", "paused", "deleted"]),
subscription_id: z.string(),
subscription_payment_id: z.string(),
subscription_plan_id: z.string(),
unit_price: z.string(),
user_id: z.string(),
p_signature: z.string(),
});

View File

@ -0,0 +1,118 @@
import type { BlitzApiHandler } from "blitz";
import { NotFoundError } from "blitz";
import { z } from "zod";
import type { ApiError } from "../../core/types";
import db, { MembershipRole } from "db";
import appLogger from "../../../integrations/logger";
import { sendEmail } from "../../../integrations/ses";
const logger = appLogger.child({ module: "subscription-updated" });
export const subscriptionUpdated: BlitzApiHandler = async (req, res) => {
const validationResult = bodySchema.safeParse(req.body);
if (!validationResult.success) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationResult.error, "/api/subscription/webhook");
res.status(statusCode).send(apiError);
return;
}
const body = validationResult.data;
const paddleSubscriptionId = body.subscription_id;
const subscription = await db.subscription.findFirst({
where: { paddleSubscriptionId },
include: {
organization: {
include: {
memberships: {
include: { user: true },
},
},
},
},
});
if (!subscription) {
throw new NotFoundError();
}
const lastEventTime = new Date(body.event_time);
const isEventOlderThanLastUpdate = subscription.lastEventTime > lastEventTime;
if (isEventOlderThanLastUpdate) {
res.status(200).end();
return;
}
const orgOwner = subscription.organization!.memberships.find(
(membership) => membership.role === MembershipRole.OWNER,
);
const email = orgOwner!.user!.email;
const paddleCheckoutId = body.checkout_id;
const planId = body.subscription_plan_id;
const nextBillDate = new Date(body.next_bill_date);
const status = body.status;
const updateUrl = body.update_url;
const cancelUrl = body.cancel_url;
const currency = body.currency;
const unitPrice = body.new_unit_price;
await db.subscription.update({
where: { paddleSubscriptionId },
data: {
paddleSubscriptionId,
paddlePlanId: planId,
paddleCheckoutId,
nextBillDate,
status,
lastEventTime,
updateUrl,
cancelUrl,
currency,
unitPrice,
},
});
sendEmail({
subject: "Thanks for your purchase",
body: "Thanks for your purchase",
recipients: [email],
}).catch((error) => {
logger.error(error, "/api/subscription/webhook");
});
return res.status(200).end();
};
const bodySchema = z.object({
alert_id: z.string(),
alert_name: z.string(),
cancel_url: z.string(),
checkout_id: z.string(),
currency: z.string(),
email: z.string(),
event_time: z.string(),
linked_subscriptions: z.string(),
marketing_consent: z.string(),
new_price: z.string(),
new_quantity: z.string(),
new_unit_price: z.string(),
next_bill_date: z.string(),
old_next_bill_date: z.string(),
old_price: z.string(),
old_quantity: z.string(),
old_status: z.string(),
old_subscription_plan_id: z.string(),
old_unit_price: z.string(),
passthrough: z.string(),
status: z.enum(["active", "trialing", "past_due", "paused", "deleted"]),
subscription_id: z.string(),
subscription_plan_id: z.string(),
update_url: z.string(),
user_id: z.string(),
p_signature: z.string(),
});