diff --git a/app/settings/api/queue/subscription-cancelled.ts b/app/settings/api/queue/subscription-cancelled.ts new file mode 100644 index 0000000..7fea3fa --- /dev/null +++ b/app/settings/api/queue/subscription-cancelled.ts @@ -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("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; diff --git a/app/settings/api/queue/subscription-created.ts b/app/settings/api/queue/subscription-created.ts new file mode 100644 index 0000000..5aab521 --- /dev/null +++ b/app/settings/api/queue/subscription-created.ts @@ -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("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; diff --git a/app/settings/api/queue/subscription-payment-succeeded.ts b/app/settings/api/queue/subscription-payment-succeeded.ts new file mode 100644 index 0000000..f92b159 --- /dev/null +++ b/app/settings/api/queue/subscription-payment-succeeded.ts @@ -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; +}; + +export const subscriptionPaymentSucceededQueue = Queue( + "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; diff --git a/app/settings/api/queue/subscription-updated.ts b/app/settings/api/queue/subscription-updated.ts new file mode 100644 index 0000000..dfddaaf --- /dev/null +++ b/app/settings/api/queue/subscription-updated.ts @@ -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; +}; + +export const subscriptionUpdatedQueue = Queue("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; diff --git a/app/settings/api/webhook/subscription.ts b/app/settings/api/webhook/subscription.ts index 1edb515..63cf1ca 100644 --- a/app/settings/api/webhook/subscription.ts +++ b/app/settings/api/webhook/subscription.ts @@ -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 = + | PaddleSdkSubscriptionCreatedEvent + | PaddleSdkSubscriptionUpdatedEvent + | PaddleSdkSubscriptionCancelledEvent + | PaddleSdkSubscriptionPaymentSucceededEvent; -const handlers: Record = { - subscription_created: subscriptionCreatedHandler, - subscription_payment_succeeded: subscriptionPaymentSucceededHandler, - subscription_cancelled: subscriptionCancelled, - subscription_updated: subscriptionUpdated, +type SupportedEventType = Events["eventType"]; + +const queues: Record> = { + [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); } diff --git a/app/settings/components/billing/plans.tsx b/app/settings/components/billing/plans.tsx index b69be83..b3b42b0 100644 --- a/app/settings/components/billing/plans.tsx +++ b/app/settings/components/billing/plans.tsx @@ -11,7 +11,7 @@ export default function Plans() {
{pricing.tiers.map((tier) => { const isCurrentTier = - !subscription?.paddlePlanId && tier.planId === "free" + !subscription?.paddlePlanId && tier.planId === -1 ? true : subscription?.paddlePlanId === tier.planId; const cta = isCurrentTier ? "Current plan" : !!subscription ? `Switch to ${tier.title}` : "Subscribe"; @@ -94,7 +94,7 @@ const pricing = { tiers: [ { title: "Free", - planId: "free", + planId: -1, price: 0, frequency: "", description: "The essentials to let you try Shellphone.", @@ -105,7 +105,7 @@ const pricing = { }, { title: "Monthly", - planId: "727540", + planId: 727540, price: 15, frequency: "/month", description: "Text and call anyone, anywhere in the world.", @@ -116,7 +116,7 @@ const pricing = { }, { title: "Yearly", - planId: "727544", + planId: 727544, price: 12.5, frequency: "/month", description: "Text and call anyone, anywhere in the world, all year long.", diff --git a/app/settings/hooks/use-subscription.ts b/app/settings/hooks/use-subscription.ts index af88a6e..ece4264 100644 --- a/app/settings/hooks/use-subscription.ts +++ b/app/settings/hooks/use-subscription.ts @@ -37,7 +37,7 @@ export default function useSubscription({ initialData }: Params = {}) { }, []); type BuyParams = { - planId: string; + planId: number; coupon?: string; }; @@ -81,7 +81,7 @@ export default function useSubscription({ initialData }: Params = {}) { } type ChangePlanParams = { - planId: string; + planId: number; }; async function changePlan({ planId }: ChangePlanParams) { diff --git a/app/settings/mutations/update-subscription.ts b/app/settings/mutations/update-subscription.ts index a3680ac..bc70271 100644 --- a/app/settings/mutations/update-subscription.ts +++ b/app/settings/mutations/update-subscription.ts @@ -1,11 +1,11 @@ import { NotFoundError, resolver } from "blitz"; 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({ - planId: z.string(), + planId: z.number(), }); export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ planId }, ctx) => { diff --git a/app/settings/webhook-handlers/subscription-cancelled.ts b/app/settings/webhook-handlers/subscription-cancelled.ts deleted file mode 100644 index 3729d05..0000000 --- a/app/settings/webhook-handlers/subscription-cancelled.ts +++ /dev/null @@ -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(), -}); diff --git a/app/settings/webhook-handlers/subscription-created.ts b/app/settings/webhook-handlers/subscription-created.ts deleted file mode 100644 index 6318691..0000000 --- a/app/settings/webhook-handlers/subscription-created.ts +++ /dev/null @@ -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(), -}); diff --git a/app/settings/webhook-handlers/subscription-payment-succeeded.ts b/app/settings/webhook-handlers/subscription-payment-succeeded.ts deleted file mode 100644 index 9502f48..0000000 --- a/app/settings/webhook-handlers/subscription-payment-succeeded.ts +++ /dev/null @@ -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(), -}); diff --git a/app/settings/webhook-handlers/subscription-updated.ts b/app/settings/webhook-handlers/subscription-updated.ts deleted file mode 100644 index 5d2704a..0000000 --- a/app/settings/webhook-handlers/subscription-updated.ts +++ /dev/null @@ -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(), -}); diff --git a/db/migrations/20211001210138_paddle_ids_to_int/migration.sql b/db/migrations/20211001210138_paddle_ids_to_int/migration.sql new file mode 100644 index 0000000..2bcb97c --- /dev/null +++ b/db/migrations/20211001210138_paddle_ids_to_int/migration.sql @@ -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"); diff --git a/db/schema.prisma b/db/schema.prisma index 01c1753..985a073 100644 --- a/db/schema.prisma +++ b/db/schema.prisma @@ -39,14 +39,14 @@ model Subscription { createdAt DateTime @default(now()) @db.Timestamptz updatedAt DateTime @updatedAt @db.Timestamptz - paddleSubscriptionId String @id @unique - paddlePlanId String + paddleSubscriptionId Int @id @unique + paddlePlanId Int paddleCheckoutId String status SubscriptionStatus updateUrl String cancelUrl String currency String - unitPrice String + unitPrice Int nextBillDate DateTime @db.Date lastEventTime DateTime @db.Timestamp diff --git a/integrations/paddle.ts b/integrations/paddle.ts index e04d82e..34cb492 100644 --- a/integrations/paddle.ts +++ b/integrations/paddle.ts @@ -1,11 +1,43 @@ import { getConfig } from "blitz"; 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 vendor_id = publicRuntimeConfig.paddle.vendorId; 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["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({ prefixUrl: "https://vendors.paddle.com/api/2.0", }); @@ -22,7 +54,7 @@ async function request(path: string, data: any) { } type GetPaymentsParams = { - subscriptionId: string; + subscriptionId: number; }; export async function getPayments({ subscriptionId }: GetPaymentsParams) { @@ -62,8 +94,8 @@ export async function getPayments({ subscriptionId }: GetPaymentsParams) { } type UpdateSubscriptionPlanParams = { - subscriptionId: string; - planId: string; + subscriptionId: number; + planId: number; prorate?: boolean; }; @@ -77,7 +109,7 @@ export async function updateSubscriptionPlan({ subscriptionId, planId, prorate = 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 }); return body;