mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-30 01:06:22 +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 });
|
||||
}
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue