mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-03 20:26:03 +02:00
✨ Keep payment methods synchronized (#1569)
This commit is contained in:
parent
5e356afab6
commit
ca46b18f3a
20 changed files with 566 additions and 346 deletions
|
@ -0,0 +1,57 @@
|
|||
import { type Stripe, stripe } from "@rallly/billing";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { posthog } from "@rallly/posthog/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createOrUpdatePaymentMethod } from "../utils";
|
||||
|
||||
const checkoutMetadataSchema = z.object({
|
||||
userId: z.string(),
|
||||
});
|
||||
|
||||
export async function onCheckoutSessionCompleted(event: Stripe.Event) {
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
if (checkoutSession.subscription === null) {
|
||||
// This is a one-time payment (probably for Rallly Self-Hosted)
|
||||
return;
|
||||
}
|
||||
|
||||
const { userId } = checkoutMetadataSchema.parse(checkoutSession.metadata);
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = checkoutSession.customer as string;
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
const paymentMethods = await stripe.customers.listPaymentMethods(customerId);
|
||||
|
||||
const [paymentMethod] = paymentMethods.data;
|
||||
|
||||
await createOrUpdatePaymentMethod(userId, paymentMethod);
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
checkoutSession.subscription as string,
|
||||
);
|
||||
|
||||
posthog?.capture({
|
||||
distinctId: userId,
|
||||
event: "upgrade",
|
||||
properties: {
|
||||
interval: subscription.items.data[0].price.recurring?.interval,
|
||||
$set: {
|
||||
tier: "pro",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import type { Stripe } from "@rallly/billing";
|
||||
import { prisma } from "@rallly/database";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { kv } from "@vercel/kv";
|
||||
|
||||
import { getEmailClient } from "@/utils/emails";
|
||||
|
||||
export async function onCheckoutSessionExpired(event: Stripe.Event) {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
// When a Checkout Session expires, the customer's email isn't returned in
|
||||
// the webhook payload unless they give consent for promotional content
|
||||
const email = session.customer_details?.email;
|
||||
const recoveryUrl = session.after_expiration?.recovery?.url;
|
||||
const userId = session.metadata?.userId;
|
||||
|
||||
if (!userId) {
|
||||
Sentry.captureMessage("No user ID found in Checkout Session metadata");
|
||||
return;
|
||||
}
|
||||
|
||||
// Do nothing if the Checkout Session has no email or recovery URL
|
||||
if (!email || !recoveryUrl) {
|
||||
Sentry.captureMessage("No email or recovery URL found in Checkout Session");
|
||||
return;
|
||||
}
|
||||
|
||||
const promoEmailKey = `promo_email_sent:${email}`;
|
||||
// Track that a promotional email opportunity has been shown to this user
|
||||
const hasReceivedPromo = await kv.get(promoEmailKey);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
locale: true,
|
||||
subscription: {
|
||||
select: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isPro = !!user?.subscription?.active;
|
||||
|
||||
// Avoid spamming people who abandon Checkout multiple times
|
||||
if (user && !hasReceivedPromo && !isPro) {
|
||||
console.info("Sending abandoned checkout email");
|
||||
// Set the flag with a 30-day expiration (in seconds)
|
||||
await kv.set(promoEmailKey, 1, { ex: 30 * 24 * 60 * 60, nx: true });
|
||||
getEmailClient(user.locale ?? undefined).sendTemplate(
|
||||
"AbandonedCheckoutEmail",
|
||||
{
|
||||
to: email,
|
||||
from: {
|
||||
name: "Luke from Rallly",
|
||||
address: "luke@rallly.co",
|
||||
},
|
||||
props: {
|
||||
name: session.customer_details?.name ?? undefined,
|
||||
discount: 20,
|
||||
couponCode: "GETPRO1Y20",
|
||||
recoveryUrl,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./completed";
|
||||
export * from "./expired";
|
|
@ -0,0 +1,49 @@
|
|||
import type { Stripe } from "@rallly/billing";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
import {
|
||||
getExpandedSubscription,
|
||||
getSubscriptionDetails,
|
||||
isSubscriptionActive,
|
||||
subscriptionMetadataSchema,
|
||||
toDate,
|
||||
} from "../utils";
|
||||
|
||||
export async function onCustomerSubscriptionCreated(event: Stripe.Event) {
|
||||
const subscription = await getExpandedSubscription(
|
||||
(event.data.object as Stripe.Subscription).id,
|
||||
);
|
||||
|
||||
const isActive = isSubscriptionActive(subscription);
|
||||
const { priceId, currency, interval, amount } =
|
||||
getSubscriptionDetails(subscription);
|
||||
|
||||
const res = subscriptionMetadataSchema.safeParse(subscription.metadata);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error("Missing user ID");
|
||||
}
|
||||
|
||||
// Create and update user
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: res.data.userId,
|
||||
},
|
||||
data: {
|
||||
subscription: {
|
||||
create: {
|
||||
id: subscription.id,
|
||||
active: isActive,
|
||||
priceId,
|
||||
currency,
|
||||
interval,
|
||||
amount,
|
||||
status: subscription.status,
|
||||
createdAt: toDate(subscription.created),
|
||||
periodStart: toDate(subscription.current_period_start),
|
||||
periodEnd: toDate(subscription.current_period_end),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import type { Stripe } from "@rallly/billing";
|
||||
import { stripe } from "@rallly/billing";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { posthog } from "@rallly/posthog/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const subscriptionMetadataSchema = z.object({
|
||||
userId: z.string(),
|
||||
});
|
||||
|
||||
export async function onCustomerSubscriptionDeleted(event: Stripe.Event) {
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
(event.data.object as Stripe.Subscription).id,
|
||||
);
|
||||
|
||||
// void any unpaid invoices
|
||||
const invoices = await stripe.invoices.list({
|
||||
subscription: subscription.id,
|
||||
status: "open",
|
||||
});
|
||||
|
||||
for (const invoice of invoices.data) {
|
||||
await stripe.invoices.voidInvoice(invoice.id);
|
||||
}
|
||||
|
||||
// delete the subscription from the database
|
||||
await prisma.subscription.delete({
|
||||
where: {
|
||||
id: subscription.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { userId } = subscriptionMetadataSchema.parse(subscription.metadata);
|
||||
|
||||
posthog?.capture({
|
||||
distinctId: userId,
|
||||
event: "subscription cancel",
|
||||
properties: {
|
||||
$set: {
|
||||
tier: "hobby",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./created";
|
||||
export * from "./deleted";
|
||||
export * from "./updated";
|
|
@ -0,0 +1,60 @@
|
|||
import type { Stripe } from "@rallly/billing";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { posthog } from "@rallly/posthog/server";
|
||||
|
||||
import {
|
||||
getExpandedSubscription,
|
||||
getSubscriptionDetails,
|
||||
isSubscriptionActive,
|
||||
subscriptionMetadataSchema,
|
||||
toDate,
|
||||
} from "../utils";
|
||||
|
||||
export async function onCustomerSubscriptionUpdated(event: Stripe.Event) {
|
||||
if (event.type !== "customer.subscription.updated") {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await getExpandedSubscription(
|
||||
(event.data.object as Stripe.Subscription).id,
|
||||
);
|
||||
|
||||
const isActive = isSubscriptionActive(subscription);
|
||||
const { priceId, currency, interval, amount } =
|
||||
getSubscriptionDetails(subscription);
|
||||
|
||||
const res = subscriptionMetadataSchema.safeParse(subscription.metadata);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error("Missing user ID");
|
||||
}
|
||||
|
||||
// Update the subscription in the database
|
||||
await prisma.subscription.update({
|
||||
where: {
|
||||
id: subscription.id,
|
||||
},
|
||||
data: {
|
||||
active: isActive,
|
||||
priceId,
|
||||
currency,
|
||||
interval,
|
||||
amount,
|
||||
status: subscription.status,
|
||||
periodStart: toDate(subscription.current_period_start),
|
||||
periodEnd: toDate(subscription.current_period_end),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
|
||||
posthog?.capture({
|
||||
distinctId: res.data.userId,
|
||||
event: "subscription change",
|
||||
properties: {
|
||||
type: event.type,
|
||||
$set: {
|
||||
tier: isActive ? "pro" : "hobby",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
36
apps/web/src/app/api/stripe/webhook/handlers/index.ts
Normal file
36
apps/web/src/app/api/stripe/webhook/handlers/index.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import type { Stripe } from "@rallly/billing";
|
||||
|
||||
import { onCheckoutSessionCompleted } from "./checkout/completed";
|
||||
import { onCheckoutSessionExpired } from "./checkout/expired";
|
||||
import { onCustomerSubscriptionCreated } from "./customer-subscription/created";
|
||||
import { onCustomerSubscriptionDeleted } from "./customer-subscription/deleted";
|
||||
import { onCustomerSubscriptionUpdated } from "./customer-subscription/updated";
|
||||
import {
|
||||
onPaymentMethodAttached,
|
||||
onPaymentMethodDetached,
|
||||
onPaymentMethodUpdated,
|
||||
} from "./payment-method";
|
||||
|
||||
export function getEventHandler(eventType: Stripe.Event["type"]) {
|
||||
switch (eventType) {
|
||||
case "checkout.session.completed":
|
||||
return onCheckoutSessionCompleted;
|
||||
case "checkout.session.expired":
|
||||
return onCheckoutSessionExpired;
|
||||
case "customer.subscription.created":
|
||||
return onCustomerSubscriptionCreated;
|
||||
case "customer.subscription.deleted":
|
||||
return onCustomerSubscriptionDeleted;
|
||||
case "customer.subscription.updated":
|
||||
return onCustomerSubscriptionUpdated;
|
||||
case "payment_method.attached":
|
||||
return onPaymentMethodAttached;
|
||||
case "payment_method.detached":
|
||||
return onPaymentMethodDetached;
|
||||
case "payment_method.automatically_updated":
|
||||
case "payment_method.updated":
|
||||
return onPaymentMethodUpdated;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import type { Stripe } from "@rallly/billing";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
import { createOrUpdatePaymentMethod } from "../utils";
|
||||
|
||||
export async function onPaymentMethodAttached(event: Stripe.Event) {
|
||||
const paymentMethod = event.data.object as Stripe.PaymentMethod;
|
||||
|
||||
// Only handle payment methods that are attached to a customer
|
||||
if (!paymentMethod.customer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the user associated with this customer
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
customerId: paymentMethod.customer as string,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`No user found for customer ${paymentMethod.customer}`);
|
||||
}
|
||||
|
||||
// Upsert the payment method in our database
|
||||
await createOrUpdatePaymentMethod(user.id, paymentMethod);
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import type { Stripe } from "@rallly/billing";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
export async function onPaymentMethodDetached(event: Stripe.Event) {
|
||||
const paymentMethod = event.data.object as Stripe.PaymentMethod;
|
||||
|
||||
// Delete the payment method from our database
|
||||
await prisma.paymentMethod.delete({
|
||||
where: {
|
||||
id: paymentMethod.id,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./attached";
|
||||
export * from "./detached";
|
||||
export * from "./updated";
|
|
@ -0,0 +1,21 @@
|
|||
import type { Stripe } from "@rallly/billing";
|
||||
import { type Prisma, prisma } from "@rallly/database";
|
||||
|
||||
export async function onPaymentMethodUpdated(event: Stripe.Event) {
|
||||
const paymentMethod = event.data.object as Stripe.PaymentMethod;
|
||||
|
||||
if (!paymentMethod.customer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the payment method data in our database
|
||||
await prisma.paymentMethod.update({
|
||||
where: {
|
||||
id: paymentMethod.id,
|
||||
},
|
||||
data: {
|
||||
type: paymentMethod.type,
|
||||
data: paymentMethod[paymentMethod.type] as Prisma.JsonObject,
|
||||
},
|
||||
});
|
||||
}
|
72
apps/web/src/app/api/stripe/webhook/handlers/utils.ts
Normal file
72
apps/web/src/app/api/stripe/webhook/handlers/utils.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import type { Stripe } from "@rallly/billing";
|
||||
import { stripe } from "@rallly/billing";
|
||||
import type { Prisma } from "@rallly/database";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { z } from "zod";
|
||||
|
||||
export const subscriptionMetadataSchema = z.object({
|
||||
userId: z.string(),
|
||||
});
|
||||
|
||||
export function toDate(date: number) {
|
||||
return new Date(date * 1000);
|
||||
}
|
||||
|
||||
export async function getExpandedSubscription(subscriptionId: string) {
|
||||
return stripe.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ["items.data.price.currency_options"],
|
||||
});
|
||||
}
|
||||
|
||||
export function isSubscriptionActive(subscription: Stripe.Subscription) {
|
||||
return (
|
||||
subscription.status === "active" ||
|
||||
subscription.status === "trialing" ||
|
||||
subscription.status === "past_due"
|
||||
);
|
||||
}
|
||||
|
||||
export function getSubscriptionDetails(subscription: Stripe.Subscription) {
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
const interval = subscriptionItem.price.recurring?.interval;
|
||||
const currency = subscription.currency;
|
||||
const amount =
|
||||
subscriptionItem.price.currency_options?.[currency]?.unit_amount ??
|
||||
subscriptionItem.price.unit_amount;
|
||||
|
||||
if (!interval) {
|
||||
throw new Error(`Missing interval in subscription ${subscription.id}`);
|
||||
}
|
||||
|
||||
if (!amount) {
|
||||
throw new Error(`Missing amount in subscription ${subscription.id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
interval,
|
||||
currency,
|
||||
amount,
|
||||
priceId: subscriptionItem.price.id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOrUpdatePaymentMethod(
|
||||
userId: string,
|
||||
paymentMethod: Stripe.PaymentMethod,
|
||||
) {
|
||||
await prisma.paymentMethod.upsert({
|
||||
where: {
|
||||
id: paymentMethod.id,
|
||||
},
|
||||
create: {
|
||||
id: paymentMethod.id,
|
||||
userId,
|
||||
type: paymentMethod.type,
|
||||
data: paymentMethod[paymentMethod.type] as Prisma.JsonObject,
|
||||
},
|
||||
update: {
|
||||
type: paymentMethod.type,
|
||||
data: paymentMethod[paymentMethod.type] as Prisma.JsonObject,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,36 +1,19 @@
|
|||
import type { Stripe } from "@rallly/billing";
|
||||
import { stripe } from "@rallly/billing";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { posthog } from "@rallly/posthog/server";
|
||||
import { withPosthog } from "@rallly/posthog/server";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { kv } from "@vercel/kv";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getEmailClient } from "@/utils/emails";
|
||||
import { getEventHandler } from "./handlers";
|
||||
|
||||
const checkoutMetadataSchema = z.object({
|
||||
userId: z.string(),
|
||||
});
|
||||
|
||||
const subscriptionMetadataSchema = z.object({
|
||||
userId: z.string(),
|
||||
});
|
||||
|
||||
function toDate(date: number) {
|
||||
return new Date(date * 1000);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
export const POST = withPosthog(async (request: NextRequest) => {
|
||||
const body = await request.text();
|
||||
const sig = request.headers.get("stripe-signature")!;
|
||||
const stripeSigningSecret = process.env.STRIPE_SIGNING_SECRET;
|
||||
|
||||
if (!stripeSigningSecret) {
|
||||
Sentry.captureException(new Error("STRIPE_SIGNING_SECRET is not set"));
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "STRIPE_SIGNING_SECRET is not set" },
|
||||
{ status: 500 },
|
||||
|
@ -50,284 +33,26 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||
const handler = getEventHandler(event.type);
|
||||
|
||||
if (checkoutSession.subscription === null) {
|
||||
// This is a one-time payment (probably for Rallly Self-Hosted)
|
||||
break;
|
||||
}
|
||||
|
||||
const { userId } = checkoutMetadataSchema.parse(
|
||||
checkoutSession.metadata,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing client reference ID" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
customerId: checkoutSession.customer as string,
|
||||
},
|
||||
});
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
checkoutSession.subscription as string,
|
||||
);
|
||||
|
||||
posthog?.capture({
|
||||
distinctId: userId,
|
||||
event: "upgrade",
|
||||
properties: {
|
||||
interval: subscription.items.data[0].price.recurring?.interval,
|
||||
$set: {
|
||||
tier: "pro",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.deleted": {
|
||||
const { id } = event.data.object as Stripe.Subscription;
|
||||
const subscription = await stripe.subscriptions.retrieve(id);
|
||||
|
||||
// void any unpaid invoices
|
||||
const invoices = await stripe.invoices.list({
|
||||
subscription: subscription.id,
|
||||
status: "open",
|
||||
});
|
||||
|
||||
for (const invoice of invoices.data) {
|
||||
await stripe.invoices.voidInvoice(invoice.id);
|
||||
}
|
||||
|
||||
// delete the subscription from the database
|
||||
await prisma.subscription.delete({
|
||||
where: {
|
||||
id: subscription.id,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { userId } = subscriptionMetadataSchema.parse(
|
||||
subscription.metadata,
|
||||
);
|
||||
|
||||
posthog?.capture({
|
||||
distinctId: userId,
|
||||
event: "subscription cancel",
|
||||
properties: {
|
||||
$set: {
|
||||
tier: "hobby",
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
Sentry.captureException(e);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.updated":
|
||||
case "customer.subscription.created": {
|
||||
const { id } = event.data.object as Stripe.Subscription;
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(id, {
|
||||
expand: ["items.data.price.currency_options"],
|
||||
});
|
||||
|
||||
// check if the subscription is active
|
||||
const isActive =
|
||||
subscription.status === "active" ||
|
||||
subscription.status === "trialing" ||
|
||||
subscription.status === "past_due";
|
||||
|
||||
// get the subscription price details
|
||||
const lineItem = subscription.items.data[0];
|
||||
|
||||
// update/create the subscription in the database
|
||||
const { price } = lineItem;
|
||||
|
||||
const res = subscriptionMetadataSchema.safeParse(subscription.metadata);
|
||||
|
||||
if (!res.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing user ID" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
const interval = subscriptionItem.price.recurring?.interval;
|
||||
const currency = subscription.currency;
|
||||
const amount =
|
||||
subscriptionItem.price.currency_options?.[currency]?.unit_amount ??
|
||||
subscriptionItem.price.unit_amount;
|
||||
|
||||
if (!interval) {
|
||||
throw new Error(
|
||||
`Missing interval in subscription ${subscription.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!amount) {
|
||||
throw new Error(`Missing amount in subscription ${subscription.id}`);
|
||||
}
|
||||
|
||||
// create or update the subscription in the database
|
||||
await prisma.subscription.upsert({
|
||||
where: {
|
||||
id: subscription.id,
|
||||
},
|
||||
create: {
|
||||
id: subscription.id,
|
||||
active: isActive,
|
||||
priceId: price.id,
|
||||
currency,
|
||||
interval,
|
||||
amount,
|
||||
status: subscription.status,
|
||||
createdAt: toDate(subscription.created),
|
||||
periodStart: toDate(subscription.current_period_start),
|
||||
periodEnd: toDate(subscription.current_period_end),
|
||||
},
|
||||
update: {
|
||||
active: isActive,
|
||||
priceId: price.id,
|
||||
currency,
|
||||
interval,
|
||||
amount,
|
||||
status: subscription.status,
|
||||
createdAt: toDate(subscription.created),
|
||||
periodStart: toDate(subscription.current_period_start),
|
||||
periodEnd: toDate(subscription.current_period_end),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
|
||||
// update the user with the subscription id
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: res.data.userId,
|
||||
},
|
||||
data: {
|
||||
subscriptionId: subscription.id,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
posthog?.capture({
|
||||
distinctId: res.data.userId,
|
||||
event: "subscription change",
|
||||
properties: {
|
||||
type: event.type,
|
||||
$set: {
|
||||
tier: isActive ? "pro" : "hobby",
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
Sentry.captureException(e);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "checkout.session.expired": {
|
||||
console.info("Checkout session expired");
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
// When a Checkout Session expires, the customer's email isn't returned in
|
||||
// the webhook payload unless they give consent for promotional content
|
||||
const email = session.customer_details?.email;
|
||||
const recoveryUrl = session.after_expiration?.recovery?.url;
|
||||
const userId = session.metadata?.userId;
|
||||
if (!userId) {
|
||||
console.info("No user ID found in Checkout Session metadata");
|
||||
Sentry.captureMessage(
|
||||
"No user ID found in Checkout Session metadata",
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Do nothing if the Checkout Session has no email or recovery URL
|
||||
if (!email || !recoveryUrl) {
|
||||
console.info("No email or recovery URL found in Checkout Session");
|
||||
Sentry.captureMessage(
|
||||
"No email or recovery URL found in Checkout Session",
|
||||
);
|
||||
break;
|
||||
}
|
||||
const promoEmailKey = `promo_email_sent:${email}`;
|
||||
// Track that a promotional email opportunity has been shown to this user
|
||||
const hasReceivedPromo = await kv.get(promoEmailKey);
|
||||
console.info("Has received promo", hasReceivedPromo);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
locale: true,
|
||||
subscription: {
|
||||
select: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isPro = !!user?.subscription?.active;
|
||||
|
||||
// Avoid spamming people who abandon Checkout multiple times
|
||||
if (user && !hasReceivedPromo && !isPro) {
|
||||
console.info("Sending abandoned checkout email");
|
||||
// Set the flag with a 30-day expiration (in seconds)
|
||||
await kv.set(promoEmailKey, 1, { ex: 30 * 24 * 60 * 60, nx: true });
|
||||
getEmailClient(user.locale ?? undefined).sendTemplate(
|
||||
"AbandonedCheckoutEmail",
|
||||
{
|
||||
to: email,
|
||||
from: {
|
||||
name: "Luke from Rallly",
|
||||
address: "luke@rallly.co",
|
||||
},
|
||||
props: {
|
||||
name: session.customer_details?.name ?? undefined,
|
||||
discount: 20,
|
||||
couponCode: "GETPRO1Y20",
|
||||
recoveryUrl,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
Sentry.captureException(
|
||||
new Error(`Unhandled event type: ${event.type}`),
|
||||
);
|
||||
// Unexpected event type
|
||||
return NextResponse.json(
|
||||
{ error: "Unhandled event type" },
|
||||
{ status: 400 },
|
||||
);
|
||||
if (!handler) {
|
||||
Sentry.captureException(new Error(`Unhandled event type: ${event.type}`));
|
||||
return NextResponse.json(
|
||||
{ error: "Unhandled event type" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await handler(event);
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
} catch (err) {
|
||||
const error =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
Sentry.captureException(err);
|
||||
return NextResponse.json({ error }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Webhook Error: ${err instanceof Error ? err.message : "Unknown error"}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
waitUntil(Promise.all([posthog?.shutdown()]));
|
||||
|
||||
return NextResponse.json({ received: true }, { status: 200 });
|
||||
}
|
||||
});
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:reset": "prisma migrate reset",
|
||||
"db:push": "prisma db push",
|
||||
"docker:up": "docker compose -f docker-compose.dev.yml up -d && wait-on --timeout 60000 tcp:localhost:5450",
|
||||
"docker:down": "docker compose -f docker-compose.dev.yml down --volumes --remove-orphans",
|
||||
"test:integration": "turbo test:integration",
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"normalize-subscription-metadata": "dotenv -e ../../.env -- tsx ./src/scripts/normalize-metadata.ts",
|
||||
"checkout-expiry": "dotenv -e ../../.env -- tsx ./src/scripts/checkout-expiry.ts",
|
||||
"subscription-data-sync": "dotenv -e ../../.env -- tsx ./src/scripts/subscription-data-sync.ts",
|
||||
"sync-payment-methods": "dotenv -e ../../.env -- tsx ./src/scripts/sync-payment-methods.ts",
|
||||
"type-check": "tsc --pretty --noEmit",
|
||||
"lint": "eslint ./src"
|
||||
},
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
|
||||
import { stripe } from "../lib/stripe";
|
||||
|
||||
(async function syncCancelAtPeriodEnd() {
|
||||
let processed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const userSubscriptions = await prisma.subscription.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.info(`🚀 Syncing ${userSubscriptions.length} subscriptions...`);
|
||||
|
||||
for (const userSubscription of userSubscriptions) {
|
||||
try {
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
userSubscription.id,
|
||||
);
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: {
|
||||
id: subscription.id,
|
||||
},
|
||||
data: {
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
|
||||
console.info(`✅ Subscription ${subscription.id} synced`);
|
||||
processed++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Failed to sync subscription ${userSubscription.id}:`,
|
||||
error,
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`📊 Sync complete: ${processed} processed, ${failed} failed`);
|
||||
})();
|
53
packages/billing/src/scripts/sync-payment-methods.ts
Normal file
53
packages/billing/src/scripts/sync-payment-methods.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import type { Prisma } from "@rallly/database";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
import { stripe } from "../lib/stripe";
|
||||
|
||||
(async function syncPaymentMethods() {
|
||||
let processed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
customerId: true,
|
||||
email: true,
|
||||
},
|
||||
where: {
|
||||
customerId: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.info(`🚀 Syncing ${users.length} users...`);
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.customerId) continue;
|
||||
try {
|
||||
const paymentMethods = await stripe.customers.listPaymentMethods(
|
||||
user.customerId,
|
||||
);
|
||||
|
||||
await prisma.paymentMethod.createMany({
|
||||
data: paymentMethods.data.map((paymentMethod) => ({
|
||||
id: paymentMethod.id,
|
||||
userId: user.id,
|
||||
type: paymentMethod.type,
|
||||
data: paymentMethod[paymentMethod.type] as Prisma.JsonObject,
|
||||
})),
|
||||
});
|
||||
|
||||
console.info(`✅ Payment methods synced for user ${user.email}`);
|
||||
processed++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Failed to sync payment methods for user ${user.email}:`,
|
||||
error,
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`📊 Sync complete: ${processed} processed, ${failed} failed`);
|
||||
})();
|
|
@ -0,0 +1,14 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "payment_methods" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "payment_methods_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payment_methods" ADD CONSTRAINT "payment_methods_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -51,12 +51,13 @@ model User {
|
|||
customerId String? @map("customer_id")
|
||||
subscriptionId String? @unique @map("subscription_id")
|
||||
|
||||
comments Comment[]
|
||||
polls Poll[]
|
||||
watcher Watcher[]
|
||||
events Event[]
|
||||
accounts Account[]
|
||||
participants Participant[]
|
||||
comments Comment[]
|
||||
polls Poll[]
|
||||
watcher Watcher[]
|
||||
events Event[]
|
||||
accounts Account[]
|
||||
participants Participant[]
|
||||
paymentMethods PaymentMethod[]
|
||||
|
||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull)
|
||||
|
||||
|
@ -82,6 +83,19 @@ enum SubscriptionInterval {
|
|||
@@map("subscription_interval")
|
||||
}
|
||||
|
||||
model PaymentMethod {
|
||||
id String @id
|
||||
userId String @map("user_id")
|
||||
type String
|
||||
data Json
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("payment_methods")
|
||||
}
|
||||
|
||||
model UserPaymentData {
|
||||
userId String @id @map("user_id")
|
||||
subscriptionId String @map("subscription_id")
|
||||
|
|
Loading…
Add table
Reference in a new issue