-
-
Notifications
You must be signed in to change notification settings - Fork 397
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ Keep payment methods synchronized (#1569)
- Loading branch information
Showing
20 changed files
with
566 additions
and
346 deletions.
There are no files selected for viewing
57 changes: 57 additions & 0 deletions
57
apps/web/src/app/api/stripe/webhook/handlers/checkout/completed.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
69
apps/web/src/app/api/stripe/webhook/handlers/checkout/expired.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
}, | ||
); | ||
} | ||
} |
2 changes: 2 additions & 0 deletions
2
apps/web/src/app/api/stripe/webhook/handlers/checkout/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from "./completed"; | ||
export * from "./expired"; |
49 changes: 49 additions & 0 deletions
49
apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/created.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
}, | ||
}, | ||
}, | ||
}); | ||
} |
44 changes: 44 additions & 0 deletions
44
apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/deleted.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}, | ||
}, | ||
}); | ||
} |
3 changes: 3 additions & 0 deletions
3
apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export * from "./created"; | ||
export * from "./deleted"; | ||
export * from "./updated"; |
60 changes: 60 additions & 0 deletions
60
apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/updated.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}, | ||
}, | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
27 changes: 27 additions & 0 deletions
27
apps/web/src/app/api/stripe/webhook/handlers/payment-method/attached.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
13 changes: 13 additions & 0 deletions
13
apps/web/src/app/api/stripe/webhook/handlers/payment-method/detached.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
}); | ||
} |
3 changes: 3 additions & 0 deletions
3
apps/web/src/app/api/stripe/webhook/handlers/payment-method/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export * from "./attached"; | ||
export * from "./detached"; | ||
export * from "./updated"; |
21 changes: 21 additions & 0 deletions
21
apps/web/src/app/api/stripe/webhook/handlers/payment-method/updated.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
}); | ||
} |
Oops, something went wrong.