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} diff --git a/packages/emails/src/components/styled-components.tsx b/packages/emails/src/components/styled-components.tsx index 620941602..9c6ebbbdc 100644 --- a/packages/emails/src/components/styled-components.tsx +++ b/packages/emails/src/components/styled-components.tsx @@ -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) => { 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 ( +
+ + Luke Vella + + + Founder + + Luke Vella +
+ ); +}; + export const trackingWide = { letterSpacing: 2, }; diff --git a/packages/emails/src/previews/abandoned-checkout.tsx b/packages/emails/src/previews/abandoned-checkout.tsx new file mode 100644 index 000000000..fcb64cc26 --- /dev/null +++ b/packages/emails/src/previews/abandoned-checkout.tsx @@ -0,0 +1,12 @@ +import { previewEmailContext } from "../components/email-context"; +import { AbandonedCheckoutEmail } from "../templates/abandoned-checkout"; + +export default function AbandonedCheckoutEmailPreview() { + return ( + + ); +} diff --git a/packages/emails/src/send-email.tsx b/packages/emails/src/send-email.tsx index 2e694e6cd..cc7146770 100644 --- a/packages/emails/src/send-email.tsx +++ b/packages/emails/src/send-email.tsx @@ -12,6 +12,10 @@ import type { TemplateComponent, TemplateName, TemplateProps } from "./types"; type SendEmailOptions = { to: string; + from?: { + name: string; + address: string; + }; props: TemplateProps; 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, diff --git a/packages/emails/src/templates.ts b/packages/emails/src/templates.ts index cd07832cf..741f004bc 100644 --- a/packages/emails/src/templates.ts +++ b/packages/emails/src/templates.ts @@ -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[]; diff --git a/packages/emails/src/templates/abandoned-checkout.tsx b/packages/emails/src/templates/abandoned-checkout.tsx new file mode 100644 index 000000000..147ca745a --- /dev/null +++ b/packages/emails/src/templates/abandoned-checkout.tsx @@ -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 ( + + {name ? ( + + + + ) : ( + + + + )} + + , + }} + /> + + + , + }} + /> + +
+ + + GETPRO1Y20 + + + +
+
+ + + +
+
+ + + +
+ +
+ ); +}; + +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;