Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Send recovery url to users with expired checkout sessions #1555

Merged
merged 10 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: "[email protected]",
},
props: {
name: session.customer_details?.name ?? undefined,
recoveryUrl,
},
},
);
}
lukevella marked this conversation as resolved.
Show resolved Hide resolved

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: "[email protected]",
lukevella marked this conversation as resolved.
Show resolved Hide resolved
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: "[email protected]",
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
Loading