* 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 { getConfig } from "blitz";
import { PaddleSdk, stringifyMetadata } from "@devoxa/paddle-sdk";
import type { BlitzApiHandler } from "blitz";
import type { Queue } from "quirrel/blitz";
import type {
PaddleSdkSubscriptionCancelledEvent,
PaddleSdkSubscriptionCreatedEvent,
PaddleSdkSubscriptionPaymentSucceededEvent,
PaddleSdkSubscriptionUpdatedEvent,
} from "@devoxa/paddle-sdk";
import { PaddleSdkWebhookEventType } 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";
import type { ApiError } from "app/core/types";
import subscriptionCreatedQueue from "../queue/subscription-created";
import subscriptionPaymentSucceededQueue from "../queue/subscription-payment-succeeded";
import subscriptionCancelledQueue from "../queue/subscription-cancelled";
import subscriptionUpdatedQueue from "../queue/subscription-updated";
import appLogger from "integrations/logger";
import { paddleSdk } from "integrations/paddle";
type SupportedWebhook =
| "subscription_created"
| "subscription_cancelled"
| "subscription_payment_succeeded"
| "subscription_updated";
const supportedWebhooks: SupportedWebhook[] = [
"subscription_created",
"subscription_cancelled",
"subscription_payment_succeeded",
"subscription_updated",
];
type Events<TMetadata = { organizationId: string }> =
| PaddleSdkSubscriptionCreatedEvent<TMetadata>
| PaddleSdkSubscriptionUpdatedEvent<TMetadata>
| PaddleSdkSubscriptionCancelledEvent<TMetadata>
| PaddleSdkSubscriptionPaymentSucceededEvent<TMetadata>;
const handlers: Record<SupportedWebhook, BlitzApiHandler> = {
subscription_created: subscriptionCreatedHandler,
subscription_payment_succeeded: subscriptionPaymentSucceededHandler,
subscription_cancelled: subscriptionCancelled,
subscription_updated: subscriptionUpdated,
type SupportedEventType = Events["eventType"];
const queues: Record<SupportedEventType, Queue<{ event: Events }>> = {
[PaddleSdkWebhookEventType.SUBSCRIPTION_CREATED]: subscriptionCreatedQueue,
[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 { 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") {
const statusCode = 405;
const apiError: ApiError = {
@ -55,23 +47,21 @@ export default async function webhook(req: BlitzApiRequest, res: BlitzApiRespons
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;
const event = paddleSdk.parseWebhookEvent(req.body);
const alertName = event.eventType;
logger.info(`Received ${alertName} webhook`);
logger.info(req.body);
logger.info(event);
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();
};
export default webhook;
function isSupportedWebhook(eventType: PaddleSdkWebhookEventType): eventType is SupportedEventType {
return Object.keys(queues).includes(eventType);
}