From 0f2c3daf77d0a2ed2252776e399f5fa649e7b9b5 Mon Sep 17 00:00:00 2001 From: m5r Date: Mon, 27 Sep 2021 06:08:02 +0800 Subject: [PATCH] integrate backend with paddle --- app/core/hooks/use-require-onboarding.ts | 5 - app/core/types.ts | 4 + app/messages/api/webhook/incoming-message.ts | 8 +- app/phone-calls/api/webhook/call.ts | 6 +- app/settings/api/webhook/subscription.ts | 75 ++++++++++ app/settings/mutations/update-subscription.ts | 20 +++ .../subscription-cancelled.ts | 79 +++++++++++ .../webhook-handlers/subscription-created.ts | 132 ++++++++++++++++++ .../subscription-payment-succeeded.ts | 100 +++++++++++++ .../webhook-handlers/subscription-updated.ts | 118 ++++++++++++++++ app/users/queries/get-current-user.ts | 2 - .../migration.sql | 41 ++++++ db/schema.prisma | 38 ++++- integrations/paddle.ts | 41 ++++++ package-lock.json | 34 +++++ package.json | 1 + 16 files changed, 679 insertions(+), 25 deletions(-) create mode 100644 app/core/types.ts create mode 100644 app/settings/api/webhook/subscription.ts create mode 100644 app/settings/mutations/update-subscription.ts create mode 100644 app/settings/webhook-handlers/subscription-cancelled.ts create mode 100644 app/settings/webhook-handlers/subscription-created.ts create mode 100644 app/settings/webhook-handlers/subscription-payment-succeeded.ts create mode 100644 app/settings/webhook-handlers/subscription-updated.ts create mode 100644 db/migrations/20210926210806_add_subscription/migration.sql create mode 100644 integrations/paddle.ts diff --git a/app/core/hooks/use-require-onboarding.ts b/app/core/hooks/use-require-onboarding.ts index 6718326..8e75d40 100644 --- a/app/core/hooks/use-require-onboarding.ts +++ b/app/core/hooks/use-require-onboarding.ts @@ -16,11 +16,6 @@ export default function useRequireOnboarding() { throw router.push(Routes.StepTwo()); } - /*if (!user.paddleCustomerId || !user.paddleSubscriptionId) { - throw router.push(Routes.StepTwo()); - return; - }*/ - if (!phoneNumber) { throw router.push(Routes.StepThree()); } diff --git a/app/core/types.ts b/app/core/types.ts new file mode 100644 index 0000000..b528718 --- /dev/null +++ b/app/core/types.ts @@ -0,0 +1,4 @@ +export type ApiError = { + statusCode: number; + errorMessage: string; +}; diff --git a/app/messages/api/webhook/incoming-message.ts b/app/messages/api/webhook/incoming-message.ts index 2118a30..fa950d1 100644 --- a/app/messages/api/webhook/incoming-message.ts +++ b/app/messages/api/webhook/incoming-message.ts @@ -1,19 +1,13 @@ import type { BlitzApiRequest, BlitzApiResponse } from "blitz"; -import { getConfig } from "blitz"; import twilio from "twilio"; import appLogger from "../../../../integrations/logger"; import db from "../../../../db"; import insertIncomingMessageQueue from "../queue/insert-incoming-message"; import { smsUrl } from "../../../../integrations/twilio"; - -type ApiError = { - statusCode: number; - errorMessage: string; -}; +import type { ApiError } from "../../../core/types"; const logger = appLogger.child({ route: "/api/webhook/incoming-message" }); -const { serverRuntimeConfig } = getConfig(); export default async function incomingMessageHandler(req: BlitzApiRequest, res: BlitzApiResponse) { if (req.method !== "POST") { diff --git a/app/phone-calls/api/webhook/call.ts b/app/phone-calls/api/webhook/call.ts index 5e6beaa..25c73c5 100644 --- a/app/phone-calls/api/webhook/call.ts +++ b/app/phone-calls/api/webhook/call.ts @@ -5,14 +5,10 @@ import db, { Direction } from "../../../../db"; import appLogger from "../../../../integrations/logger"; import { translateCallStatus, voiceUrl } from "../../../../integrations/twilio"; import updateCallDurationQueue from "../queue/update-call-duration"; +import type { ApiError } from "../../../core/types"; const logger = appLogger.child({ route: "/api/webhook/call" }); -type ApiError = { - statusCode: number; - errorMessage: string; -}; - export default async function incomingCallHandler(req: BlitzApiRequest, res: BlitzApiResponse) { console.log("req.body", req.body); diff --git a/app/settings/api/webhook/subscription.ts b/app/settings/api/webhook/subscription.ts new file mode 100644 index 0000000..86f9c17 --- /dev/null +++ b/app/settings/api/webhook/subscription.ts @@ -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 = { + 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(); +} diff --git a/app/settings/mutations/update-subscription.ts b/app/settings/mutations/update-subscription.ts new file mode 100644 index 0000000..a3680ac --- /dev/null +++ b/app/settings/mutations/update-subscription.ts @@ -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); +}); diff --git a/app/settings/webhook-handlers/subscription-cancelled.ts b/app/settings/webhook-handlers/subscription-cancelled.ts new file mode 100644 index 0000000..3729d05 --- /dev/null +++ b/app/settings/webhook-handlers/subscription-cancelled.ts @@ -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(), +}); diff --git a/app/settings/webhook-handlers/subscription-created.ts b/app/settings/webhook-handlers/subscription-created.ts new file mode 100644 index 0000000..6318691 --- /dev/null +++ b/app/settings/webhook-handlers/subscription-created.ts @@ -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(), +}); diff --git a/app/settings/webhook-handlers/subscription-payment-succeeded.ts b/app/settings/webhook-handlers/subscription-payment-succeeded.ts new file mode 100644 index 0000000..9502f48 --- /dev/null +++ b/app/settings/webhook-handlers/subscription-payment-succeeded.ts @@ -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(), +}); diff --git a/app/settings/webhook-handlers/subscription-updated.ts b/app/settings/webhook-handlers/subscription-updated.ts new file mode 100644 index 0000000..5d2704a --- /dev/null +++ b/app/settings/webhook-handlers/subscription-updated.ts @@ -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(), +}); diff --git a/app/users/queries/get-current-user.ts b/app/users/queries/get-current-user.ts index a1f7bf0..45ed51c 100644 --- a/app/users/queries/get-current-user.ts +++ b/app/users/queries/get-current-user.ts @@ -18,8 +18,6 @@ export default async function getCurrentUser(_ = null, { session }: Ctx) { select: { id: true, encryptionKey: true, - paddleCustomerId: true, - paddleSubscriptionId: true, twilioAccountSid: true, twilioAuthToken: true, twilioApiKey: true, diff --git a/db/migrations/20210926210806_add_subscription/migration.sql b/db/migrations/20210926210806_add_subscription/migration.sql new file mode 100644 index 0000000..05ba337 --- /dev/null +++ b/db/migrations/20210926210806_add_subscription/migration.sql @@ -0,0 +1,41 @@ +/* + Warnings: + + - You are about to drop the column `paddleCustomerId` on the `Organization` table. All the data in the column will be lost. + - You are about to drop the column `paddleSubscriptionId` on the `Organization` table. All the data in the column will be lost. + +*/ +-- CreateEnum +CREATE TYPE "SubscriptionStatus" AS ENUM ('active', 'trialing', 'past_due', 'paused', 'deleted'); + +-- AlterTable +ALTER TABLE "Organization" DROP COLUMN "paddleCustomerId", +DROP COLUMN "paddleSubscriptionId"; + +-- CreateTable +CREATE TABLE "Subscription" ( + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ NOT NULL, + "paddleSubscriptionId" TEXT NOT NULL, + "paddlePlanId" TEXT NOT NULL, + "paddleCheckoutId" TEXT NOT NULL, + "status" "SubscriptionStatus" NOT NULL, + "updateUrl" TEXT NOT NULL, + "cancelUrl" TEXT NOT NULL, + "currency" TEXT NOT NULL, + "unitPrice" TEXT NOT NULL, + "nextBillDate" DATE NOT NULL, + "lastEventTime" TIMESTAMP NOT NULL, + "organizationId" TEXT, + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("paddleSubscriptionId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_paddleSubscriptionId_key" ON "Subscription"("paddleSubscriptionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_organizationId_unique" ON "Subscription"("organizationId"); + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/db/schema.prisma b/db/schema.prisma index f52df53..01c1753 100644 --- a/db/schema.prisma +++ b/db/schema.prisma @@ -13,12 +13,10 @@ generator client { // -------------------------------------- model Organization { - id String @id @default(uuid()) - createdAt DateTime @default(now()) @db.Timestamptz - updatedAt DateTime @updatedAt @db.Timestamptz - encryptionKey String - paddleCustomerId String? - paddleSubscriptionId String? + id String @id @default(uuid()) + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz + encryptionKey String twilioAccountSid String? twilioAuthToken String? // TODO: encrypt it with encryptionKey @@ -32,10 +30,38 @@ model Organization { messages Message[] phoneCalls PhoneCall[] processingPhoneNumbers ProcessingPhoneNumber[] + subscription Subscription? @@unique([id, twilioAccountSid]) } +model Subscription { + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz + + paddleSubscriptionId String @id @unique + paddlePlanId String + paddleCheckoutId String + status SubscriptionStatus + updateUrl String + cancelUrl String + currency String + unitPrice String + nextBillDate DateTime @db.Date + lastEventTime DateTime @db.Timestamp + + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String? +} + +enum SubscriptionStatus { + active + trialing + past_due + paused + deleted +} + model Membership { id String @id @default(uuid()) role MembershipRole diff --git a/integrations/paddle.ts b/integrations/paddle.ts new file mode 100644 index 0000000..75972ea --- /dev/null +++ b/integrations/paddle.ts @@ -0,0 +1,41 @@ +import { getConfig } from "blitz"; +import got from "got"; + +const { publicRuntimeConfig, serverRuntimeConfig } = getConfig(); + +const vendor_id = publicRuntimeConfig.paddle.vendorId; +const vendor_auth_code = serverRuntimeConfig.paddle.apiKey; + +const client = got.extend({ + prefixUrl: "https://vendors.paddle.com/api/2.0", +}); + +async function request(path: string, data: any) { + return client.post(path, { + ...data, + vendor_id, + vendor_auth_code, + }); +} + +type UpdateSubscriptionPlanParams = { + subscriptionId: string; + planId: string; + prorate?: boolean; +}; + +export async function updateSubscriptionPlan({ subscriptionId, planId, prorate = true }: UpdateSubscriptionPlanParams) { + const { body } = await request("/subscription/users/update", { + subscription_id: subscriptionId, + plan_id: planId, + prorate, + }); + + return body; +} + +export async function cancelPaddleSubscription({ subscriptionId }: { subscriptionId: string }) { + const { body } = await request("/subscription/users_cancel", { subscription_id: subscriptionId }); + + return body; +} diff --git a/package-lock.json b/package-lock.json index c99981c..cf0372b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1765,6 +1765,35 @@ "protobufjs": "^6.10.2" } }, + "@devoxa/aes-encryption": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@devoxa/aes-encryption/-/aes-encryption-1.0.3.tgz", + "integrity": "sha512-oSNSRenW0QDnEtq7yf/Rw/2BtWkPkKdHDLGZk6srsO966twt3t5u4bOrZnjqnRo/ZCqXyFas2loJz6SMUZrCNA==" + }, + "@devoxa/paddle-sdk": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@devoxa/paddle-sdk/-/paddle-sdk-0.2.1.tgz", + "integrity": "sha512-GXcrMa4+Sy7zG29QN5liZNT+GzNkZsuxe8nXn7vg0LNpw+cbjPRNuz8ow83ZzKo/nlG7Kfi5e7koRFJk3ke8pQ==", + "requires": { + "@devoxa/aes-encryption": "^1.0.2", + "dayjs": "^1.8.33", + "form-data": "^4.0.0", + "node-fetch": "^2.6.0", + "php-serialize": "^4.0.2" + }, + "dependencies": { + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } + } + }, "@eslint/eslintrc": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", @@ -16095,6 +16124,11 @@ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, + "php-serialize": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/php-serialize/-/php-serialize-4.0.2.tgz", + "integrity": "sha512-73K9MqCnRn07sXxOht6kVLg+fg1lf/VYpecKy4n9ABcw1PJIAWfaxuQKML27EjolGHWxlXTy3rfh59AGrcUvIA==" + }, "picomatch": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", diff --git a/package.json b/package.json index 277ef7b..511fdf6 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ ] }, "dependencies": { + "@devoxa/paddle-sdk": "0.2.1", "@headlessui/react": "1.4.1", "@hookform/resolvers": "2.8.1", "@panelbear/panelbear-js": "1.3.2",