Skip to content

Commit

Permalink
✨ Keep payment methods synchronized (#1569)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukevella authored Feb 23, 2025
1 parent 5e356af commit ca46b18
Show file tree
Hide file tree
Showing 20 changed files with 566 additions and 346 deletions.
57 changes: 57 additions & 0 deletions apps/web/src/app/api/stripe/webhook/handlers/checkout/completed.ts
Original file line number Diff line number Diff line change
@@ -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",
},
},
});
}
69 changes: 69 additions & 0 deletions apps/web/src/app/api/stripe/webhook/handlers/checkout/expired.ts
Original file line number Diff line number Diff line change
@@ -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: "[email protected]",
},
props: {
name: session.customer_details?.name ?? undefined,
discount: 20,
couponCode: "GETPRO1Y20",
recoveryUrl,
},
},
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./completed";
export * from "./expired";
Original file line number Diff line number Diff line change
@@ -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),
},
},
},
});
}
Original file line number Diff line number Diff line change
@@ -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",
},
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./created";
export * from "./deleted";
export * from "./updated";
Original file line number Diff line number Diff line change
@@ -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 changes: 36 additions & 0 deletions apps/web/src/app/api/stripe/webhook/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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,
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./attached";
export * from "./detached";
export * from "./updated";
Original file line number Diff line number Diff line change
@@ -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,
},
});
}
Loading

0 comments on commit ca46b18

Please sign in to comment.