integrate backend with paddle
This commit is contained in:
parent
9cec49f255
commit
0f2c3daf77
@ -16,11 +16,6 @@ export default function useRequireOnboarding() {
|
|||||||
throw router.push(Routes.StepTwo());
|
throw router.push(Routes.StepTwo());
|
||||||
}
|
}
|
||||||
|
|
||||||
/*if (!user.paddleCustomerId || !user.paddleSubscriptionId) {
|
|
||||||
throw router.push(Routes.StepTwo());
|
|
||||||
return;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
if (!phoneNumber) {
|
if (!phoneNumber) {
|
||||||
throw router.push(Routes.StepThree());
|
throw router.push(Routes.StepThree());
|
||||||
}
|
}
|
||||||
|
4
app/core/types.ts
Normal file
4
app/core/types.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export type ApiError = {
|
||||||
|
statusCode: number;
|
||||||
|
errorMessage: string;
|
||||||
|
};
|
@ -1,19 +1,13 @@
|
|||||||
import type { BlitzApiRequest, BlitzApiResponse } from "blitz";
|
import type { BlitzApiRequest, BlitzApiResponse } from "blitz";
|
||||||
import { getConfig } from "blitz";
|
|
||||||
import twilio from "twilio";
|
import twilio from "twilio";
|
||||||
|
|
||||||
import appLogger from "../../../../integrations/logger";
|
import appLogger from "../../../../integrations/logger";
|
||||||
import db from "../../../../db";
|
import db from "../../../../db";
|
||||||
import insertIncomingMessageQueue from "../queue/insert-incoming-message";
|
import insertIncomingMessageQueue from "../queue/insert-incoming-message";
|
||||||
import { smsUrl } from "../../../../integrations/twilio";
|
import { smsUrl } from "../../../../integrations/twilio";
|
||||||
|
import type { ApiError } from "../../../core/types";
|
||||||
type ApiError = {
|
|
||||||
statusCode: number;
|
|
||||||
errorMessage: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const logger = appLogger.child({ route: "/api/webhook/incoming-message" });
|
const logger = appLogger.child({ route: "/api/webhook/incoming-message" });
|
||||||
const { serverRuntimeConfig } = getConfig();
|
|
||||||
|
|
||||||
export default async function incomingMessageHandler(req: BlitzApiRequest, res: BlitzApiResponse) {
|
export default async function incomingMessageHandler(req: BlitzApiRequest, res: BlitzApiResponse) {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
|
@ -5,14 +5,10 @@ import db, { Direction } from "../../../../db";
|
|||||||
import appLogger from "../../../../integrations/logger";
|
import appLogger from "../../../../integrations/logger";
|
||||||
import { translateCallStatus, voiceUrl } from "../../../../integrations/twilio";
|
import { translateCallStatus, voiceUrl } from "../../../../integrations/twilio";
|
||||||
import updateCallDurationQueue from "../queue/update-call-duration";
|
import updateCallDurationQueue from "../queue/update-call-duration";
|
||||||
|
import type { ApiError } from "../../../core/types";
|
||||||
|
|
||||||
const logger = appLogger.child({ route: "/api/webhook/call" });
|
const logger = appLogger.child({ route: "/api/webhook/call" });
|
||||||
|
|
||||||
type ApiError = {
|
|
||||||
statusCode: number;
|
|
||||||
errorMessage: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function incomingCallHandler(req: BlitzApiRequest, res: BlitzApiResponse) {
|
export default async function incomingCallHandler(req: BlitzApiRequest, res: BlitzApiResponse) {
|
||||||
console.log("req.body", req.body);
|
console.log("req.body", req.body);
|
||||||
|
|
||||||
|
75
app/settings/api/webhook/subscription.ts
Normal file
75
app/settings/api/webhook/subscription.ts
Normal 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();
|
||||||
|
}
|
20
app/settings/mutations/update-subscription.ts
Normal file
20
app/settings/mutations/update-subscription.ts
Normal 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);
|
||||||
|
});
|
79
app/settings/webhook-handlers/subscription-cancelled.ts
Normal file
79
app/settings/webhook-handlers/subscription-cancelled.ts
Normal 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(),
|
||||||
|
});
|
132
app/settings/webhook-handlers/subscription-created.ts
Normal file
132
app/settings/webhook-handlers/subscription-created.ts
Normal 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(),
|
||||||
|
});
|
100
app/settings/webhook-handlers/subscription-payment-succeeded.ts
Normal file
100
app/settings/webhook-handlers/subscription-payment-succeeded.ts
Normal 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(),
|
||||||
|
});
|
118
app/settings/webhook-handlers/subscription-updated.ts
Normal file
118
app/settings/webhook-handlers/subscription-updated.ts
Normal 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(),
|
||||||
|
});
|
@ -18,8 +18,6 @@ export default async function getCurrentUser(_ = null, { session }: Ctx) {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
encryptionKey: true,
|
encryptionKey: true,
|
||||||
paddleCustomerId: true,
|
|
||||||
paddleSubscriptionId: true,
|
|
||||||
twilioAccountSid: true,
|
twilioAccountSid: true,
|
||||||
twilioAuthToken: true,
|
twilioAuthToken: true,
|
||||||
twilioApiKey: true,
|
twilioApiKey: true,
|
||||||
|
41
db/migrations/20210926210806_add_subscription/migration.sql
Normal file
41
db/migrations/20210926210806_add_subscription/migration.sql
Normal 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;
|
@ -13,12 +13,10 @@ generator client {
|
|||||||
// --------------------------------------
|
// --------------------------------------
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz
|
createdAt DateTime @default(now()) @db.Timestamptz
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz
|
updatedAt DateTime @updatedAt @db.Timestamptz
|
||||||
encryptionKey String
|
encryptionKey String
|
||||||
paddleCustomerId String?
|
|
||||||
paddleSubscriptionId String?
|
|
||||||
|
|
||||||
twilioAccountSid String?
|
twilioAccountSid String?
|
||||||
twilioAuthToken String? // TODO: encrypt it with encryptionKey
|
twilioAuthToken String? // TODO: encrypt it with encryptionKey
|
||||||
@ -32,10 +30,38 @@ model Organization {
|
|||||||
messages Message[]
|
messages Message[]
|
||||||
phoneCalls PhoneCall[]
|
phoneCalls PhoneCall[]
|
||||||
processingPhoneNumbers ProcessingPhoneNumber[]
|
processingPhoneNumbers ProcessingPhoneNumber[]
|
||||||
|
subscription Subscription?
|
||||||
|
|
||||||
@@unique([id, twilioAccountSid])
|
@@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 {
|
model Membership {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
role MembershipRole
|
role MembershipRole
|
||||||
|
41
integrations/paddle.ts
Normal file
41
integrations/paddle.ts
Normal 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
34
package-lock.json
generated
@ -1765,6 +1765,35 @@
|
|||||||
"protobufjs": "^6.10.2"
|
"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": {
|
"@eslint/eslintrc": {
|
||||||
"version": "0.4.3",
|
"version": "0.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
"integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
|
"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": {
|
"picomatch": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
|
||||||
|
@ -33,6 +33,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@devoxa/paddle-sdk": "0.2.1",
|
||||||
"@headlessui/react": "1.4.1",
|
"@headlessui/react": "1.4.1",
|
||||||
"@hookform/resolvers": "2.8.1",
|
"@hookform/resolvers": "2.8.1",
|
||||||
"@panelbear/panelbear-js": "1.3.2",
|
"@panelbear/panelbear-js": "1.3.2",
|
||||||
|
Loading…
Reference in New Issue
Block a user