Send recovery url to users with expired checkout sessions (#1555)

This commit is contained in:
Luke Vella 2025-02-10 13:15:59 +07:00 committed by GitHub
parent 5437b91c10
commit 9fdd5f3ea3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 341 additions and 23 deletions

View file

@ -103,6 +103,13 @@ export async function POST(request: NextRequest) {
automatic_tax: {
enabled: true,
},
expires_at: Math.floor(Date.now() / 1000) + 30 * 60, // 30 minutes
after_expiration: {
recovery: {
enabled: true,
allow_promotion_codes: true,
},
},
});
if (session.url) {

View file

@ -4,10 +4,13 @@ import { prisma } from "@rallly/database";
import { posthog } 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";
const checkoutMetadataSchema = z.object({
userId: z.string(),
});
@ -206,6 +209,71 @@ export async function POST(request: NextRequest) {
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,
recoveryUrl,
},
},
);
}
break;
}
default:
Sentry.captureException(new Error(`Unhandled event type: ${event.type}`));
// Unexpected event type

View file

@ -20,7 +20,7 @@ export const getEmailClient = (locale?: string) => {
config: {
logoUrl: isSelfHosted
? absoluteUrl("/images/rallly-logo-mark.png")
: "https://rallly-public.s3.amazonaws.com/images/rallly-logo-mark.png",
: "https://d39ixtfgglw55o.cloudfront.net/images/rallly-logo-mark.png",
baseUrl: absoluteUrl(),
domain: absoluteUrl().replace(/(^\w+:|^)\/\//, ""),
supportEmail: env.SUPPORT_EMAIL,