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 type { Stripe } from "@rallly/billing";
|
||||||
import { stripe } from "@rallly/billing";
|
import { stripe } from "@rallly/billing";
|
||||||
import { prisma } from "@rallly/database";
|
import { withPosthog } from "@rallly/posthog/server";
|
||||||
import { posthog } from "@rallly/posthog/server";
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { waitUntil } from "@vercel/functions";
|
|
||||||
import { kv } from "@vercel/kv";
|
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } 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({
|
export const POST = withPosthog(async (request: NextRequest) => {
|
||||||
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) {
|
|
||||||
const body = await request.text();
|
const body = await request.text();
|
||||||
const sig = request.headers.get("stripe-signature")!;
|
const sig = request.headers.get("stripe-signature")!;
|
||||||
const stripeSigningSecret = process.env.STRIPE_SIGNING_SECRET;
|
const stripeSigningSecret = process.env.STRIPE_SIGNING_SECRET;
|
||||||
|
|
||||||
if (!stripeSigningSecret) {
|
if (!stripeSigningSecret) {
|
||||||
Sentry.captureException(new Error("STRIPE_SIGNING_SECRET is not set"));
|
Sentry.captureException(new Error("STRIPE_SIGNING_SECRET is not set"));
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "STRIPE_SIGNING_SECRET is not set" },
|
{ error: "STRIPE_SIGNING_SECRET is not set" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
@ -50,284 +33,26 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (event.type) {
|
const handler = getEventHandler(event.type);
|
||||||
case "checkout.session.completed": {
|
|
||||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
|
||||||
|
|
||||||
if (checkoutSession.subscription === null) {
|
if (!handler) {
|
||||||
// This is a one-time payment (probably for Rallly Self-Hosted)
|
Sentry.captureException(new Error(`Unhandled event type: ${event.type}`));
|
||||||
break;
|
return NextResponse.json(
|
||||||
}
|
{ error: "Unhandled event type" },
|
||||||
|
{ status: 400 },
|
||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await handler(event);
|
||||||
|
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error =
|
|
||||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
|
||||||
Sentry.captureException(err);
|
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:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:reset": "prisma migrate reset",
|
"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: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",
|
"docker:down": "docker compose -f docker-compose.dev.yml down --volumes --remove-orphans",
|
||||||
"test:integration": "turbo test:integration",
|
"test:integration": "turbo test:integration",
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
"normalize-subscription-metadata": "dotenv -e ../../.env -- tsx ./src/scripts/normalize-metadata.ts",
|
"normalize-subscription-metadata": "dotenv -e ../../.env -- tsx ./src/scripts/normalize-metadata.ts",
|
||||||
"checkout-expiry": "dotenv -e ../../.env -- tsx ./src/scripts/checkout-expiry.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",
|
"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",
|
"type-check": "tsc --pretty --noEmit",
|
||||||
"lint": "eslint ./src"
|
"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")
|
customerId String? @map("customer_id")
|
||||||
subscriptionId String? @unique @map("subscription_id")
|
subscriptionId String? @unique @map("subscription_id")
|
||||||
|
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
polls Poll[]
|
polls Poll[]
|
||||||
watcher Watcher[]
|
watcher Watcher[]
|
||||||
events Event[]
|
events Event[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
|
paymentMethods PaymentMethod[]
|
||||||
|
|
||||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull)
|
subscription Subscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@ -82,6 +83,19 @@ enum SubscriptionInterval {
|
||||||
@@map("subscription_interval")
|
@@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 {
|
model UserPaymentData {
|
||||||
userId String @id @map("user_id")
|
userId String @id @map("user_id")
|
||||||
subscriptionId String @map("subscription_id")
|
subscriptionId String @map("subscription_id")
|
||||||
|
|
Loading…
Add table
Reference in a new issue