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,

View file

@ -8,6 +8,7 @@
},
"scripts": {
"normalize-subscription-metadata": "dotenv -e ../../.env -- tsx ./src/scripts/normalize-metadata.ts",
"checkout-expiry": "dotenv -e ../../.env -- tsx ./src/scripts/checkout-expiry.ts",
"type-check": "tsc --pretty --noEmit",
"lint": "eslint ./src"
},

View file

@ -0,0 +1,39 @@
import { getProPricing, stripe } from "../lib/stripe";
async function createAndExpireCheckout() {
const pricingData = await getProPricing();
console.info("📝 Creating checkout session...");
const session = await stripe.checkout.sessions.create({
success_url: "http://localhost:3000/success",
cancel_url: "http://localhost:3000/cancel",
mode: "subscription",
customer_email: "dev@rallly.co",
line_items: [
{
price: pricingData.monthly.id,
quantity: 1,
},
],
metadata: {
userId: "free-user",
},
expires_at: Math.floor(Date.now() / 1000) + 30 * 60,
after_expiration: {
recovery: {
enabled: true,
allow_promotion_codes: true,
},
},
});
console.info("💳 Checkout session created:", session.id);
console.info("🔗 Checkout URL:", session.url);
console.info("⏳ Expiring checkout session...");
await stripe.checkout.sessions.expire(session.id);
console.info("✨ Done! Check Stripe Dashboard for events");
console.info("🔍 Dashboard URL: https://dashboard.stripe.com/test/events");
}
createAndExpireCheckout();

View file

@ -53,5 +53,14 @@
"changeEmailRequest_button": "Verify Email Address",
"changeEmailRequest_subject": "Verify your new email address",
"changeEmailRequest_text3": "This link will expire in 10 minutes. If you did not request this change, please ignore this email.",
"changeEmailRequest_text1": "We've received a request to change the email address for your account from <b>{{fromEmail}}</b> to <b>{{toEmail}}</b>."
"changeEmailRequest_text1": "We've received a request to change the email address for your account from <b>{{fromEmail}}</b> to <b>{{toEmail}}</b>.",
"abandoned_checkout_name": "Hey {{name}},",
"abandoned_checkout_noname": "Hey there,",
"abandoned_checkout_content": "I noticed you were checking out <b>Rallly Pro</b> earlier. I wanted to reach out to see if you had any questions or needed help with anything.",
"abandoned_checkout_offer": "To help you get started, you can get <b>{{discount}}% off</b> your first year. Just use the code below when you check out:",
"abandoned_checkout_button": "Upgrade to Rallly Pro",
"abandoned_checkout_support": "If you have any questions about Rallly Pro or need help with anything at all, just reply to this email. I'm here to help!",
"abandoned_checkout_preview": "Exclusive offer: Get 20% off your first year of Rallly Pro!",
"abandoned_checkout_subject": "Get 20% off your first year of Rallly Pro",
"abandoned_checkout_signoff": "Best regards,"
}

View file

@ -7,7 +7,7 @@ i18nInstance.init({
});
export const previewEmailContext: EmailContext = {
logoUrl: "https://rallly-public.s3.amazonaws.com/images/rallly-logo-mark.png",
logoUrl: "https://d39ixtfgglw55o.cloudfront.net/images/rallly-logo-mark.png",
baseUrl: "https://rallly.co",
domain: "rallly.co",
supportEmail: "support@rallly.co",

View file

@ -15,6 +15,7 @@ import { darkTextColor, fontFamily, Link, Text } from "./styled-components";
export interface EmailLayoutProps {
preview: string;
ctx: EmailContext;
poweredBy?: boolean;
}
const containerStyles = {
@ -30,6 +31,7 @@ export const EmailLayout = ({
preview,
children,
ctx,
poweredBy = true,
}: React.PropsWithChildren<EmailLayoutProps>) => {
const { logoUrl } = ctx;
return (
@ -48,7 +50,8 @@ export const EmailLayout = ({
alt="Rallly Logo"
/>
{children}
<Section style={{ marginTop: 32 }}>
{poweredBy ? (
<Section>
<Text light={true}>
<Trans
i18n={ctx.i18n}
@ -65,6 +68,7 @@ export const EmailLayout = ({
/>
</Text>
</Section>
) : null}
</Container>
</Body>
</Html>

View file

@ -16,6 +16,7 @@ import type { EmailContext } from "../types";
export const lightTextColor = "#4B5563";
export const darkTextColor = "#1F2937";
export const borderColor = "#E2E8F0";
export const Text = (
props: TextProps & { light?: boolean; small?: boolean },
) => {
@ -48,14 +49,15 @@ export const Button = (props: React.ComponentProps<typeof UnstyledButton>) => {
style={{
backgroundColor: "#4F46E5",
borderRadius: "4px",
padding: "12px 14px",
padding: "14px",
fontFamily,
boxSizing: "border-box",
display: "block",
width: "100%",
maxWidth: "100%",
textAlign: "center",
fontSize: "16px",
fontSize: "14px",
fontWeight: "bold",
color: "white",
}}
/>
@ -150,6 +152,36 @@ export const Card = (props: SectionProps) => {
);
};
export const Signature = () => {
return (
<Section>
<UnstyledText
style={{
fontSize: 16,
margin: 0,
fontWeight: "bold",
color: darkTextColor,
fontFamily,
}}
>
Luke Vella
</UnstyledText>
<UnstyledText
style={{ fontSize: 16, margin: 0, color: lightTextColor, fontFamily }}
>
Founder
</UnstyledText>
<img
src="https://d39ixtfgglw55o.cloudfront.net/images/luke.jpg"
alt="Luke Vella"
style={{ borderRadius: "50%", marginTop: 16 }}
width={48}
height={48}
/>
</Section>
);
};
export const trackingWide = {
letterSpacing: 2,
};

View file

@ -0,0 +1,12 @@
import { previewEmailContext } from "../components/email-context";
import { AbandonedCheckoutEmail } from "../templates/abandoned-checkout";
export default function AbandonedCheckoutEmailPreview() {
return (
<AbandonedCheckoutEmail
ctx={previewEmailContext}
recoveryUrl="https://example.com"
name="John Doe"
/>
);
}

View file

@ -12,6 +12,10 @@ import type { TemplateComponent, TemplateName, TemplateProps } from "./types";
type SendEmailOptions<T extends TemplateName> = {
to: string;
from?: {
name: string;
address: string;
};
props: TemplateProps<T>;
attachments?: Mail.Options["attachments"];
};
@ -106,7 +110,7 @@ export class EmailClient {
try {
await this.sendEmail({
from: this.config.mail.from,
from: options.from || this.config.mail.from,
to: options.to,
subject,
html,

View file

@ -1,3 +1,4 @@
import { AbandonedCheckoutEmail } from "./templates/abandoned-checkout";
import { ChangeEmailRequest } from "./templates/change-email-request";
import { FinalizeHostEmail } from "./templates/finalized-host";
import { FinalizeParticipantEmail } from "./templates/finalized-participant";
@ -19,6 +20,7 @@ const templates = {
NewPollEmail,
RegisterEmail,
ChangeEmailRequest,
AbandonedCheckoutEmail,
};
export const emailTemplates = Object.keys(templates) as TemplateName[];

View file

@ -0,0 +1,140 @@
import { Section } from "@react-email/components";
import { Trans } from "react-i18next/TransWithoutContext";
import { EmailLayout } from "../components/email-layout";
import { Button, Card, Signature, Text } from "../components/styled-components";
import type { EmailContext } from "../types";
interface AbandonedCheckoutEmailProps {
recoveryUrl: string;
name?: string;
ctx: EmailContext;
}
export const AbandonedCheckoutEmail = ({
recoveryUrl,
name,
ctx,
}: AbandonedCheckoutEmailProps) => {
return (
<EmailLayout
ctx={ctx}
poweredBy={false}
preview={ctx.t("abandoned_checkout_preview", {
defaultValue:
"Exclusive offer: Get 20% off your first year of Rallly Pro!",
ns: "emails",
})}
>
{name ? (
<Text>
<Trans
t={ctx.t}
i18n={ctx.i18n}
i18nKey="abandoned_checkout_name"
defaults="Hey {{name}},"
values={{ name }}
ns="emails"
/>
</Text>
) : (
<Text>
<Trans
t={ctx.t}
i18n={ctx.i18n}
i18nKey="abandoned_checkout_noname"
defaults="Hey there,"
ns="emails"
/>
</Text>
)}
<Text>
<Trans
t={ctx.t}
i18n={ctx.i18n}
i18nKey="abandoned_checkout_content"
defaults="I noticed you were exploring <b>Rallly Pro</b> and wanted to personally reach out. I'd love to hear what features caught your interest and answer any questions you might have."
ns="emails"
components={{
b: <b />,
}}
/>
</Text>
<Text>
<Trans
t={ctx.t}
i18n={ctx.i18n}
i18nKey="abandoned_checkout_offer"
defaults="To help you get started, I'd like to offer you <b>{{discount}}% off your first year</b> with Rallly Pro. Simply use this code during checkout:"
ns="emails"
values={{
discount: 20,
}}
components={{
b: <b />,
}}
/>
</Text>
<Section style={{ marginTop: 16, marginBottom: 16 }}>
<Card>
<Text
style={{
textAlign: "center",
fontFamily: "monospace",
fontWeight: "bold",
}}
>
GETPRO1Y20
</Text>
</Card>
<Button href={recoveryUrl} id="recoveryUrl">
<Trans
i18n={ctx.i18n}
t={ctx.t}
i18nKey="abandoned_checkout_button"
defaults="Upgrade to Rallly Pro"
ns="emails"
/>
</Button>
</Section>
<Section>
<Text>
<Trans
i18n={ctx.i18n}
t={ctx.t}
i18nKey="abandoned_checkout_support"
defaults="Have questions or need assistance? Just reply to this email."
ns="emails"
/>
</Text>
</Section>
<Section>
<Text>
<Trans
i18n={ctx.i18n}
t={ctx.t}
i18nKey="abandoned_checkout_signoff"
defaults="Best regards,"
ns="emails"
/>
</Text>
</Section>
<Signature />
</EmailLayout>
);
};
AbandonedCheckoutEmail.getSubject = (
props: AbandonedCheckoutEmailProps,
ctx: EmailContext,
) => {
return (
"🎉 " +
ctx.t("abandoned_checkout_subject", {
defaultValue: "Get 20% off your first year of Rallly Pro",
ns: "emails",
})
);
};
export default AbandonedCheckoutEmail;