integrate backend with paddle

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

View File

@ -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());
}

4
app/core/types.ts Normal file
View File

@ -0,0 +1,4 @@
export type ApiError = {
statusCode: number;
errorMessage: string;
};

View File

@ -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") {

View File

@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -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;

View File

@ -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

41
integrations/paddle.ts Normal file
View File

@ -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<T>(path: string, data: any) {
return client.post<T>(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;
}

34
package-lock.json generated
View File

@ -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",

View File

@ -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",