* return 200 asap to paddle and queue webhook received

* paddle ids to int
This commit is contained in:
m5r 2021-10-01 23:04:12 +02:00
parent 188c028667
commit 771fea4d7b
15 changed files with 370 additions and 496 deletions

View File

@ -0,0 +1,42 @@
import { NotFoundError } from "blitz";
import { Queue } from "quirrel/blitz";
import type { PaddleSdkSubscriptionCancelledEvent } from "@devoxa/paddle-sdk";
import db from "db";
import appLogger from "integrations/logger";
import { translateSubscriptionStatus } from "integrations/paddle";
const logger = appLogger.child({ queue: "subscription-cancelled" });
type Payload = {
event: PaddleSdkSubscriptionCancelledEvent<{ organizationId: string }>;
};
export const subscriptionCancelledQueue = Queue<Payload>("api/queue/subscription-cancelled", async ({ event }) => {
const paddleSubscriptionId = event.subscriptionId;
const subscription = await db.subscription.findFirst({ where: { paddleSubscriptionId } });
if (!subscription) {
throw new NotFoundError();
}
const lastEventTime = event.eventTime;
const isEventOlderThanLastUpdate = subscription.lastEventTime > lastEventTime;
if (isEventOlderThanLastUpdate) {
return;
}
await db.subscription.update({
where: { paddleSubscriptionId },
data: {
paddleSubscriptionId,
paddlePlanId: event.productId,
paddleCheckoutId: event.checkoutId,
status: translateSubscriptionStatus(event.status),
lastEventTime,
currency: event.currency,
unitPrice: event.unitPrice,
},
});
});
export default subscriptionCancelledQueue;

View File

@ -0,0 +1,99 @@
import { NotFoundError } from "blitz";
import { Queue } from "quirrel/blitz";
import type { PaddleSdkSubscriptionCreatedEvent } from "@devoxa/paddle-sdk";
import db, { MembershipRole } from "db";
import appLogger from "integrations/logger";
import { sendEmail } from "integrations/ses";
import { translateSubscriptionStatus } from "integrations/paddle";
const logger = appLogger.child({ queue: "subscription-created" });
type Payload = {
event: PaddleSdkSubscriptionCreatedEvent<{ organizationId: string }>;
};
export const subscriptionCreatedQueue = Queue<Payload>("api/queue/subscription-created", async ({ event }) => {
const { organizationId } = event.metadata;
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 = event.checkoutId;
const paddleSubscriptionId = event.subscriptionId;
const planId = event.productId;
const nextBillDate = event.nextPaymentDate;
const status = translateSubscriptionStatus(event.status);
const lastEventTime = event.eventTime;
const updateUrl = event.updateUrl;
const cancelUrl = event.cancelUrl;
const currency = event.currency;
const unitPrice = event.unitPrice;
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);
});
} 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);
});
}
});
export default subscriptionCreatedQueue;

View File

@ -0,0 +1,47 @@
import { NotFoundError } from "blitz";
import { Queue } from "quirrel/blitz";
import { PaddleSdkSubscriptionPaymentSucceededEvent } from "@devoxa/paddle-sdk";
import db from "db";
import appLogger from "integrations/logger";
import type { Metadata } from "integrations/paddle";
import { translateSubscriptionStatus } from "integrations/paddle";
const logger = appLogger.child({ queue: "subscription-payment-succeeded" });
type Payload = {
event: PaddleSdkSubscriptionPaymentSucceededEvent<Metadata>;
};
export const subscriptionPaymentSucceededQueue = Queue<Payload>(
"api/queue/subscription-payment-succeeded",
async ({ event }) => {
const paddleSubscriptionId = event.subscriptionId;
const subscription = await db.subscription.findFirst({ where: { paddleSubscriptionId } });
if (!subscription) {
throw new NotFoundError();
}
const lastEventTime = event.eventTime;
const isEventOlderThanLastUpdate = subscription.lastEventTime > lastEventTime;
if (isEventOlderThanLastUpdate) {
return;
}
await db.subscription.update({
where: { paddleSubscriptionId },
data: {
paddleSubscriptionId,
paddlePlanId: event.productId,
paddleCheckoutId: event.checkoutId,
nextBillDate: event.nextPaymentDate,
status: translateSubscriptionStatus(event.status),
lastEventTime,
currency: event.currency,
unitPrice: event.unitPrice,
},
});
},
);
export default subscriptionPaymentSucceededQueue;

View File

@ -0,0 +1,71 @@
import { NotFoundError } from "blitz";
import { Queue } from "quirrel/blitz";
import { PaddleSdkSubscriptionUpdatedEvent } from "@devoxa/paddle-sdk";
import db, { MembershipRole } from "db";
import appLogger from "integrations/logger";
import { sendEmail } from "integrations/ses";
import type { Metadata } from "integrations/paddle";
import { translateSubscriptionStatus } from "integrations/paddle";
const logger = appLogger.child({ module: "subscription-updated" });
type Payload = {
event: PaddleSdkSubscriptionUpdatedEvent<Metadata>;
};
export const subscriptionUpdatedQueue = Queue<Payload>("api/queue/subscription-updated", async ({ event }) => {
const paddleSubscriptionId = event.subscriptionId;
const subscription = await db.subscription.findFirst({
where: { paddleSubscriptionId },
include: {
organization: {
include: {
memberships: {
include: { user: true },
},
},
},
},
});
if (!subscription) {
throw new NotFoundError();
}
const lastEventTime = event.eventTime;
const isEventOlderThanLastUpdate = subscription.lastEventTime > lastEventTime;
if (isEventOlderThanLastUpdate) {
return;
}
const orgOwner = subscription.organization!.memberships.find(
(membership) => membership.role === MembershipRole.OWNER,
);
const email = orgOwner!.user!.email;
const planId = event.productId;
await db.subscription.update({
where: { paddleSubscriptionId },
data: {
paddleSubscriptionId,
paddlePlanId: planId,
paddleCheckoutId: event.checkoutId,
nextBillDate: event.nextPaymentDate,
status: translateSubscriptionStatus(event.status),
lastEventTime,
updateUrl: event.updateUrl,
cancelUrl: event.cancelUrl,
currency: event.currency,
unitPrice: event.unitPrice,
},
});
sendEmail({
subject: "Thanks for your purchase",
body: "Thanks for your purchase",
recipients: [email],
}).catch((error) => {
logger.error(error);
});
});
export default subscriptionUpdatedQueue;

View File

@ -1,47 +1,39 @@
import type { BlitzApiHandler, BlitzApiRequest, BlitzApiResponse } from "blitz"; import type { BlitzApiHandler } from "blitz";
import { getConfig } from "blitz"; import type { Queue } from "quirrel/blitz";
import { PaddleSdk, stringifyMetadata } from "@devoxa/paddle-sdk"; import type {
PaddleSdkSubscriptionCancelledEvent,
PaddleSdkSubscriptionCreatedEvent,
PaddleSdkSubscriptionPaymentSucceededEvent,
PaddleSdkSubscriptionUpdatedEvent,
} from "@devoxa/paddle-sdk";
import { PaddleSdkWebhookEventType } from "@devoxa/paddle-sdk";
import type { ApiError } from "../../../core/types"; import type { ApiError } from "app/core/types";
import { subscriptionCreatedHandler } from "../../webhook-handlers/subscription-created"; import subscriptionCreatedQueue from "../queue/subscription-created";
import { subscriptionPaymentSucceededHandler } from "../../webhook-handlers/subscription-payment-succeeded"; import subscriptionPaymentSucceededQueue from "../queue/subscription-payment-succeeded";
import { subscriptionCancelled } from "../../webhook-handlers/subscription-cancelled"; import subscriptionCancelledQueue from "../queue/subscription-cancelled";
import { subscriptionUpdated } from "../../webhook-handlers/subscription-updated"; import subscriptionUpdatedQueue from "../queue/subscription-updated";
import appLogger from "../../../../integrations/logger"; import appLogger from "integrations/logger";
import { paddleSdk } from "integrations/paddle";
type SupportedWebhook = type Events<TMetadata = { organizationId: string }> =
| "subscription_created" | PaddleSdkSubscriptionCreatedEvent<TMetadata>
| "subscription_cancelled" | PaddleSdkSubscriptionUpdatedEvent<TMetadata>
| "subscription_payment_succeeded" | PaddleSdkSubscriptionCancelledEvent<TMetadata>
| "subscription_updated"; | PaddleSdkSubscriptionPaymentSucceededEvent<TMetadata>;
const supportedWebhooks: SupportedWebhook[] = [
"subscription_created",
"subscription_cancelled",
"subscription_payment_succeeded",
"subscription_updated",
];
const handlers: Record<SupportedWebhook, BlitzApiHandler> = { type SupportedEventType = Events["eventType"];
subscription_created: subscriptionCreatedHandler,
subscription_payment_succeeded: subscriptionPaymentSucceededHandler, const queues: Record<SupportedEventType, Queue<{ event: Events }>> = {
subscription_cancelled: subscriptionCancelled, [PaddleSdkWebhookEventType.SUBSCRIPTION_CREATED]: subscriptionCreatedQueue,
subscription_updated: subscriptionUpdated, [PaddleSdkWebhookEventType.SUBSCRIPTION_PAYMENT_SUCCEEDED]: subscriptionPaymentSucceededQueue,
[PaddleSdkWebhookEventType.SUBSCRIPTION_CANCELLED]: subscriptionCancelledQueue,
[PaddleSdkWebhookEventType.SUBSCRIPTION_UPDATED]: subscriptionUpdatedQueue,
}; };
function isSupportedWebhook(webhook: any): webhook is SupportedWebhook {
return supportedWebhooks.includes(webhook);
}
const logger = appLogger.child({ route: "/api/subscription/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) { const webhook: BlitzApiHandler = async (req, res) => {
if (req.method !== "POST") { if (req.method !== "POST") {
const statusCode = 405; const statusCode = 405;
const apiError: ApiError = { const apiError: ApiError = {
@ -55,23 +47,21 @@ export default async function webhook(req: BlitzApiRequest, res: BlitzApiRespons
return; return;
} }
if (!paddleSdk.verifyWebhookEvent(req.body)) { const event = paddleSdk.parseWebhookEvent(req.body);
const statusCode = 500; const alertName = event.eventType;
const apiError: ApiError = {
statusCode,
errorMessage: "Webhook event is invalid",
};
logger.error(apiError);
return res.status(statusCode).send(apiError);
}
const alertName = req.body.alert_name;
logger.info(`Received ${alertName} webhook`); logger.info(`Received ${alertName} webhook`);
logger.info(req.body); logger.info(event);
if (isSupportedWebhook(alertName)) { if (isSupportedWebhook(alertName)) {
return handlers[alertName](req, res); await queues[alertName].enqueue({ event: event as Events }, { id: event.eventId.toString() });
return res.status(200).end();
} }
return res.status(400).end(); return res.status(400).end();
};
export default webhook;
function isSupportedWebhook(eventType: PaddleSdkWebhookEventType): eventType is SupportedEventType {
return Object.keys(queues).includes(eventType);
} }

View File

@ -11,7 +11,7 @@ export default function Plans() {
<div className="mt-6 flex flex-row-reverse flex-wrap-reverse gap-x-4"> <div className="mt-6 flex flex-row-reverse flex-wrap-reverse gap-x-4">
{pricing.tiers.map((tier) => { {pricing.tiers.map((tier) => {
const isCurrentTier = const isCurrentTier =
!subscription?.paddlePlanId && tier.planId === "free" !subscription?.paddlePlanId && tier.planId === -1
? true ? true
: subscription?.paddlePlanId === tier.planId; : subscription?.paddlePlanId === tier.planId;
const cta = isCurrentTier ? "Current plan" : !!subscription ? `Switch to ${tier.title}` : "Subscribe"; const cta = isCurrentTier ? "Current plan" : !!subscription ? `Switch to ${tier.title}` : "Subscribe";
@ -94,7 +94,7 @@ const pricing = {
tiers: [ tiers: [
{ {
title: "Free", title: "Free",
planId: "free", planId: -1,
price: 0, price: 0,
frequency: "", frequency: "",
description: "The essentials to let you try Shellphone.", description: "The essentials to let you try Shellphone.",
@ -105,7 +105,7 @@ const pricing = {
}, },
{ {
title: "Monthly", title: "Monthly",
planId: "727540", planId: 727540,
price: 15, price: 15,
frequency: "/month", frequency: "/month",
description: "Text and call anyone, anywhere in the world.", description: "Text and call anyone, anywhere in the world.",
@ -116,7 +116,7 @@ const pricing = {
}, },
{ {
title: "Yearly", title: "Yearly",
planId: "727544", planId: 727544,
price: 12.5, price: 12.5,
frequency: "/month", frequency: "/month",
description: "Text and call anyone, anywhere in the world, all year long.", description: "Text and call anyone, anywhere in the world, all year long.",

View File

@ -37,7 +37,7 @@ export default function useSubscription({ initialData }: Params = {}) {
}, []); }, []);
type BuyParams = { type BuyParams = {
planId: string; planId: number;
coupon?: string; coupon?: string;
}; };
@ -81,7 +81,7 @@ export default function useSubscription({ initialData }: Params = {}) {
} }
type ChangePlanParams = { type ChangePlanParams = {
planId: string; planId: number;
}; };
async function changePlan({ planId }: ChangePlanParams) { async function changePlan({ planId }: ChangePlanParams) {

View File

@ -1,11 +1,11 @@
import { NotFoundError, resolver } from "blitz"; import { NotFoundError, resolver } from "blitz";
import { z } from "zod"; import { z } from "zod";
import { updateSubscriptionPlan } from "../../../integrations/paddle"; import db from "db";
import db from "../../../db"; import { updateSubscriptionPlan } from "integrations/paddle";
const Body = z.object({ const Body = z.object({
planId: z.string(), planId: z.number(),
}); });
export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ planId }, ctx) => { export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ planId }, ctx) => {

View File

@ -1,79 +0,0 @@
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

@ -1,132 +0,0 @@
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

@ -1,100 +0,0 @@
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

@ -1,118 +0,0 @@
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(),
});

View File

@ -0,0 +1,22 @@
/*
Warnings:
- The primary key for the `Subscription` table will be changed. If it partially fails, the table could be left without primary key constraint.
- A unique constraint covering the columns `[paddleSubscriptionId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
- Changed the type of `paddleSubscriptionId` on the `Subscription` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
- Changed the type of `paddlePlanId` on the `Subscription` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
- Changed the type of `unitPrice` on the `Subscription` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- AlterTable
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_pkey",
DROP COLUMN "paddleSubscriptionId",
ADD COLUMN "paddleSubscriptionId" INTEGER NOT NULL,
DROP COLUMN "paddlePlanId",
ADD COLUMN "paddlePlanId" INTEGER NOT NULL,
DROP COLUMN "unitPrice",
ADD COLUMN "unitPrice" INTEGER NOT NULL,
ADD CONSTRAINT "Subscription_pkey" PRIMARY KEY ("paddleSubscriptionId");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_paddleSubscriptionId_key" ON "Subscription"("paddleSubscriptionId");

View File

@ -39,14 +39,14 @@ model Subscription {
createdAt DateTime @default(now()) @db.Timestamptz createdAt DateTime @default(now()) @db.Timestamptz
updatedAt DateTime @updatedAt @db.Timestamptz updatedAt DateTime @updatedAt @db.Timestamptz
paddleSubscriptionId String @id @unique paddleSubscriptionId Int @id @unique
paddlePlanId String paddlePlanId Int
paddleCheckoutId String paddleCheckoutId String
status SubscriptionStatus status SubscriptionStatus
updateUrl String updateUrl String
cancelUrl String cancelUrl String
currency String currency String
unitPrice String unitPrice Int
nextBillDate DateTime @db.Date nextBillDate DateTime @db.Date
lastEventTime DateTime @db.Timestamp lastEventTime DateTime @db.Timestamp

View File

@ -1,11 +1,43 @@
import { getConfig } from "blitz"; import { getConfig } from "blitz";
import got from "got"; import got from "got";
import type { PaddleSdkSubscriptionCreatedEvent } from "@devoxa/paddle-sdk";
import { PaddleSdk, PaddleSdkSubscriptionStatus, stringifyMetadata } from "@devoxa/paddle-sdk";
import { SubscriptionStatus } from "db";
const { publicRuntimeConfig, serverRuntimeConfig } = getConfig(); const { publicRuntimeConfig, serverRuntimeConfig } = getConfig();
const vendor_id = publicRuntimeConfig.paddle.vendorId; const vendor_id = publicRuntimeConfig.paddle.vendorId;
const vendor_auth_code = serverRuntimeConfig.paddle.apiKey; const vendor_auth_code = serverRuntimeConfig.paddle.apiKey;
export const paddleSdk = new PaddleSdk({
publicKey: serverRuntimeConfig.paddle.publicKey,
vendorId: vendor_id,
vendorAuthCode: vendor_auth_code,
metadataCodec: stringifyMetadata(),
});
export type Metadata = { organizationId: string };
export function translateSubscriptionStatus(
status: PaddleSdkSubscriptionCreatedEvent<unknown>["status"],
): SubscriptionStatus {
switch (status) {
case PaddleSdkSubscriptionStatus.ACTIVE:
return SubscriptionStatus.active;
case PaddleSdkSubscriptionStatus.CANCELLED:
return SubscriptionStatus.deleted;
case PaddleSdkSubscriptionStatus.PAUSED:
return SubscriptionStatus.paused;
case PaddleSdkSubscriptionStatus.PAST_DUE:
return SubscriptionStatus.past_due;
case PaddleSdkSubscriptionStatus.TRIALING:
return SubscriptionStatus.trialing;
default:
throw new Error("unreachable");
}
}
const client = got.extend({ const client = got.extend({
prefixUrl: "https://vendors.paddle.com/api/2.0", prefixUrl: "https://vendors.paddle.com/api/2.0",
}); });
@ -22,7 +54,7 @@ async function request<T>(path: string, data: any) {
} }
type GetPaymentsParams = { type GetPaymentsParams = {
subscriptionId: string; subscriptionId: number;
}; };
export async function getPayments({ subscriptionId }: GetPaymentsParams) { export async function getPayments({ subscriptionId }: GetPaymentsParams) {
@ -62,8 +94,8 @@ export async function getPayments({ subscriptionId }: GetPaymentsParams) {
} }
type UpdateSubscriptionPlanParams = { type UpdateSubscriptionPlanParams = {
subscriptionId: string; subscriptionId: number;
planId: string; planId: number;
prorate?: boolean; prorate?: boolean;
}; };
@ -77,7 +109,7 @@ export async function updateSubscriptionPlan({ subscriptionId, planId, prorate =
return body; return body;
} }
export async function cancelPaddleSubscription({ subscriptionId }: { subscriptionId: string }) { export async function cancelPaddleSubscription({ subscriptionId }: { subscriptionId: number }) {
const { body } = await request("subscription/users_cancel", { subscription_id: subscriptionId }); const { body } = await request("subscription/users_cancel", { subscription_id: subscriptionId });
return body; return body;