diff --git a/apps/web/src/app/api/stripe/checkout/route.ts b/apps/web/src/app/api/stripe/checkout/route.ts
index 443517d6c..65f5799e8 100644
--- a/apps/web/src/app/api/stripe/checkout/route.ts
+++ b/apps/web/src/app/api/stripe/checkout/route.ts
@@ -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) {
diff --git a/apps/web/src/app/api/stripe/webhook/route.ts b/apps/web/src/app/api/stripe/webhook/route.ts
index 49d9d9e99..5b742a393 100644
--- a/apps/web/src/app/api/stripe/webhook/route.ts
+++ b/apps/web/src/app/api/stripe/webhook/route.ts
@@ -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
diff --git a/apps/web/src/utils/emails.ts b/apps/web/src/utils/emails.ts
index ccac9626b..becb10eea 100644
--- a/apps/web/src/utils/emails.ts
+++ b/apps/web/src/utils/emails.ts
@@ -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,
diff --git a/packages/billing/package.json b/packages/billing/package.json
index 0d1c47a85..850f7b9d3 100644
--- a/packages/billing/package.json
+++ b/packages/billing/package.json
@@ -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"
},
diff --git a/packages/billing/src/scripts/checkout-expiry.ts b/packages/billing/src/scripts/checkout-expiry.ts
new file mode 100644
index 000000000..b7ca97172
--- /dev/null
+++ b/packages/billing/src/scripts/checkout-expiry.ts
@@ -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();
diff --git a/packages/emails/locales/en/emails.json b/packages/emails/locales/en/emails.json
index e307af781..0fae12eea 100644
--- a/packages/emails/locales/en/emails.json
+++ b/packages/emails/locales/en/emails.json
@@ -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 {{fromEmail}} to {{toEmail}}."
+ "changeEmailRequest_text1": "We've received a request to change the email address for your account from {{fromEmail}} to {{toEmail}}.",
+ "abandoned_checkout_name": "Hey {{name}},",
+ "abandoned_checkout_noname": "Hey there,",
+ "abandoned_checkout_content": "I noticed you were checking out Rallly Pro 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 {{discount}}% off 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,"
}
diff --git a/packages/emails/src/components/email-context.tsx b/packages/emails/src/components/email-context.tsx
index 37c1d422d..75532204d 100644
--- a/packages/emails/src/components/email-context.tsx
+++ b/packages/emails/src/components/email-context.tsx
@@ -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",
diff --git a/packages/emails/src/components/email-layout.tsx b/packages/emails/src/components/email-layout.tsx
index 1690b7165..bfa259648 100644
--- a/packages/emails/src/components/email-layout.tsx
+++ b/packages/emails/src/components/email-layout.tsx
@@ -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) => {
const { logoUrl } = ctx;
return (
@@ -48,23 +50,25 @@ export const EmailLayout = ({
alt="Rallly Logo"
/>
{children}
-
+ {poweredBy ? (
+
+ ) : null}