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: { automatic_tax: {
enabled: true, 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) { if (session.url) {

View file

@ -4,10 +4,13 @@ import { prisma } from "@rallly/database";
import { posthog } from "@rallly/posthog/server"; import { posthog } from "@rallly/posthog/server";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { waitUntil } from "@vercel/functions"; import { waitUntil } from "@vercel/functions";
import { kv } from "@vercel/kv";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { getEmailClient } from "@/utils/emails";
const checkoutMetadataSchema = z.object({ const checkoutMetadataSchema = z.object({
userId: z.string(), userId: z.string(),
}); });
@ -206,6 +209,71 @@ export async function POST(request: NextRequest) {
break; 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: default:
Sentry.captureException(new Error(`Unhandled event type: ${event.type}`)); Sentry.captureException(new Error(`Unhandled event type: ${event.type}`));
// Unexpected event type // Unexpected event type

View file

@ -20,7 +20,7 @@ export const getEmailClient = (locale?: string) => {
config: { config: {
logoUrl: isSelfHosted logoUrl: isSelfHosted
? absoluteUrl("/images/rallly-logo-mark.png") ? 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(), baseUrl: absoluteUrl(),
domain: absoluteUrl().replace(/(^\w+:|^)\/\//, ""), domain: absoluteUrl().replace(/(^\w+:|^)\/\//, ""),
supportEmail: env.SUPPORT_EMAIL, supportEmail: env.SUPPORT_EMAIL,

View file

@ -8,6 +8,7 @@
}, },
"scripts": { "scripts": {
"normalize-subscription-metadata": "dotenv -e ../../.env -- tsx ./src/scripts/normalize-metadata.ts", "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", "type-check": "tsc --pretty --noEmit",
"lint": "eslint ./src" "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_button": "Verify Email Address",
"changeEmailRequest_subject": "Verify your new 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_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 = { 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", baseUrl: "https://rallly.co",
domain: "rallly.co", domain: "rallly.co",
supportEmail: "support@rallly.co", supportEmail: "support@rallly.co",

View file

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

View file

@ -16,6 +16,7 @@ import type { EmailContext } from "../types";
export const lightTextColor = "#4B5563"; export const lightTextColor = "#4B5563";
export const darkTextColor = "#1F2937"; export const darkTextColor = "#1F2937";
export const borderColor = "#E2E8F0"; export const borderColor = "#E2E8F0";
export const Text = ( export const Text = (
props: TextProps & { light?: boolean; small?: boolean }, props: TextProps & { light?: boolean; small?: boolean },
) => { ) => {
@ -48,14 +49,15 @@ export const Button = (props: React.ComponentProps<typeof UnstyledButton>) => {
style={{ style={{
backgroundColor: "#4F46E5", backgroundColor: "#4F46E5",
borderRadius: "4px", borderRadius: "4px",
padding: "12px 14px", padding: "14px",
fontFamily, fontFamily,
boxSizing: "border-box", boxSizing: "border-box",
display: "block", display: "block",
width: "100%", width: "100%",
maxWidth: "100%", maxWidth: "100%",
textAlign: "center", textAlign: "center",
fontSize: "16px", fontSize: "14px",
fontWeight: "bold",
color: "white", 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 = { export const trackingWide = {
letterSpacing: 2, 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> = { type SendEmailOptions<T extends TemplateName> = {
to: string; to: string;
from?: {
name: string;
address: string;
};
props: TemplateProps<T>; props: TemplateProps<T>;
attachments?: Mail.Options["attachments"]; attachments?: Mail.Options["attachments"];
}; };
@ -106,7 +110,7 @@ export class EmailClient {
try { try {
await this.sendEmail({ await this.sendEmail({
from: this.config.mail.from, from: options.from || this.config.mail.from,
to: options.to, to: options.to,
subject, subject,
html, html,

View file

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