Skip to content

Commit

Permalink
✨ Send recovery url to users with expired checkout sessions (#1555)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukevella authored Feb 10, 2025
1 parent 5437b91 commit 9fdd5f3
Show file tree
Hide file tree
Showing 13 changed files with 341 additions and 23 deletions.
7 changes: 7 additions & 0 deletions apps/web/src/app/api/stripe/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/app/api/stripe/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/utils/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/billing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
39 changes: 39 additions & 0 deletions packages/billing/src/scripts/checkout-expiry.ts
Original file line number Diff line number Diff line change
@@ -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();
11 changes: 10 additions & 1 deletion packages/emails/locales/en/emails.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,"
}
2 changes: 1 addition & 1 deletion packages/emails/src/components/email-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 21 additions & 17 deletions packages/emails/src/components/email-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { darkTextColor, fontFamily, Link, Text } from "./styled-components";
export interface EmailLayoutProps {
preview: string;
ctx: EmailContext;
poweredBy?: boolean;
}

const containerStyles = {
Expand All @@ -30,6 +31,7 @@ export const EmailLayout = ({
preview,
children,
ctx,
poweredBy = true,
}: React.PropsWithChildren<EmailLayoutProps>) => {
const { logoUrl } = ctx;
return (
Expand All @@ -48,23 +50,25 @@ export const EmailLayout = ({
alt="Rallly Logo"
/>
{children}
<Section style={{ marginTop: 32 }}>
<Text light={true}>
<Trans
i18n={ctx.i18n}
t={ctx.t}
i18nKey="common_poweredBy"
ns="emails"
defaults="Powered by <a>{{domain}}</a>"
values={{ domain: "rallly.co" }}
components={{
a: (
<Link href="https://rallly.co?utm_source=email&utm_medium=transactional" />
),
}}
/>
</Text>
</Section>
{poweredBy ? (
<Section>
<Text light={true}>
<Trans
i18n={ctx.i18n}
t={ctx.t}
i18nKey="common_poweredBy"
ns="emails"
defaults="Powered by <a>{{domain}}</a>"
values={{ domain: "rallly.co" }}
components={{
a: (
<Link href="https://rallly.co?utm_source=email&utm_medium=transactional" />
),
}}
/>
</Text>
</Section>
) : null}
</Container>
</Body>
</Html>
Expand Down
36 changes: 34 additions & 2 deletions packages/emails/src/components/styled-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
) => {
Expand Down Expand Up @@ -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",
}}
/>
Expand Down Expand Up @@ -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,
};
Expand Down
12 changes: 12 additions & 0 deletions packages/emails/src/previews/abandoned-checkout.tsx
Original file line number Diff line number Diff line change
@@ -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"
/>
);
}
6 changes: 5 additions & 1 deletion packages/emails/src/send-email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
};
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/emails/src/templates.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -19,6 +20,7 @@ const templates = {
NewPollEmail,
RegisterEmail,
ChangeEmailRequest,
AbandonedCheckoutEmail,
};

export const emailTemplates = Object.keys(templates) as TemplateName[];
Expand Down
Loading

0 comments on commit 9fdd5f3

Please sign in to comment.