From f1227b0b885055c66a659d520c12f4901a70ce47 Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Mon, 6 Jul 2026 01:03:54 +1000 Subject: [PATCH] feat(conversion): pre-expiry trial reminder email Add a founder-voice reminder email that fires two days before trialEndsAt for card-free trials, reusing the existing send-trial-feedback-email Lambda and EventBridge Scheduler pipeline (no new Lambda, queue, event rule, or IAM). SendTrialFeedbackEmailCommand gains an optional kind ("feedback"|"reminder", absent=feedback for backward compatibility). A deterministic one-shot trial-reminder- targets the same DetailType and carries Input {userId, kind:"reminder"}. Schedules are created at all three trial-signup sites (email/Google/Apple) and recreated on /account reactivation when >2d remain; deleted at checkout success and the cancel-subscription trialing branch. Idempotence via a new trialReminderEmailSentAt row flag; the reminder path re-checks status==="trialing" with a future trialEndsAt before sending. CTA links to /account with utm_source=trial-reminder for attribution. Only infra delta is APP_ORIGIN on the send-trial-feedback-email Lambda. Claude-Session: https://claude.ai/code/session_01En2uUiiwQoRQKWenCnkPi9 --- projects/hutch/src/infra/index.ts | 14 +- .../src/runtime/cancel-subscription.main.ts | 1 + .../cancel-subscription-handler.test.ts | 4 + .../cancel-subscription-handler.ts | 8 +- .../domain/stripe/stripe-trial-config.test.ts | 11 ++ .../domain/stripe/stripe-trial-config.ts | 8 + .../dynamodb-subscription-providers.test.ts | 41 +++++ .../dynamodb-subscription-writes.ts | 17 +- .../aws-trial-scheduler.test.ts | 105 +++++++++++ .../trial-scheduler/aws-trial-scheduler.ts | 59 +++++++ .../runtime/send-trial-feedback-email.main.ts | 3 + .../send-trial-feedback-email-handler.test.ts | 164 +++++++++++++++++- .../send-trial-feedback-email-handler.ts | 87 +++++++++- projects/hutch/src/runtime/server.ts | 9 + projects/hutch/src/runtime/test-app.ts | 2 + .../src/runtime/web/auth/apple-auth.page.ts | 22 ++- .../runtime/web/auth/apple-auth.route.test.ts | 9 +- .../hutch/src/runtime/web/auth/auth.page.ts | 21 ++- .../src/runtime/web/auth/auth.route.test.ts | 32 ++++ .../web/auth/checkout-success.route.test.ts | 3 + .../src/runtime/web/auth/google-auth.page.ts | 22 ++- .../web/auth/google-auth.route.test.ts | 9 +- .../auth/trial-reminder-email.template.html | 38 ++++ .../web/auth/trial-reminder-email.test.ts | 98 +++++++++++ .../runtime/web/auth/trial-reminder-email.ts | 62 +++++++ .../runtime/web/pages/account/account.page.ts | 7 + .../web/pages/account/account.route.test.ts | 32 ++++ .../hutch-infra-components/src/events.ts | 17 +- .../src/subscription-providers.ts | 6 + .../provider-contracts/src/trial-scheduler.ts | 9 + .../src/subscription-provider-row.test.ts | 3 + .../src/subscription-provider-row.ts | 4 + .../in-memory-subscription-providers.test.ts | 22 +++ .../in-memory-subscription-providers.ts | 16 ++ .../subscription-providers.types.ts | 1 + .../in-memory-trial-scheduler.test.ts | 58 +++++++ .../in-memory-trial-scheduler.ts | 31 ++++ .../web-test-harness/src/bundle.types.ts | 7 + 38 files changed, 1040 insertions(+), 22 deletions(-) create mode 100644 projects/hutch/src/runtime/domain/stripe/stripe-trial-config.test.ts create mode 100644 projects/hutch/src/runtime/web/auth/trial-reminder-email.template.html create mode 100644 projects/hutch/src/runtime/web/auth/trial-reminder-email.test.ts create mode 100644 projects/hutch/src/runtime/web/auth/trial-reminder-email.ts diff --git a/projects/hutch/src/infra/index.ts b/projects/hutch/src/infra/index.ts index 08187621f..7184cda71 100644 --- a/projects/hutch/src/infra/index.ts +++ b/projects/hutch/src/infra/index.ts @@ -1294,11 +1294,14 @@ eventBus.subscribe(SubscriptionCancelledEvent, scheduleTrialFeedbackEmailWithSQS }); // --- Send Trial Feedback Email --- -// SQS-backed Lambda invoked by the EventBridge Scheduler one-shot created -// above. Re-reads the subscription row to confirm the user is still cancelled -// (reactivation guard) and hasn't already received the email (sent-flag), -// then sends Fayner's "what was missing?" research email with a single -// personalised clause derived from the count of articles the user saved. +// SQS-backed Lambda invoked by the EventBridge Scheduler one-shots that target +// SendTrialFeedbackEmailCommand. It handles two email kinds keyed on the event +// detail's `kind`: the post-cancellation "what was missing?" feedback email +// (absent/'feedback' — re-reads the row to confirm the user is still cancelled +// and hasn't already been emailed), and the pre-expiry trial reminder +// (kind='reminder' — created at trial signup, re-checks the user is still +// trialing with a future trialEndsAt before nudging them to subscribe). Both +// personalise a single clause from the count of articles the user saved. const sendTrialFeedbackEmailDynamodb = new HutchDynamoDBAccess( "send-trial-feedback-email-dynamodb", @@ -1339,6 +1342,7 @@ const sendTrialFeedbackEmailLambda = new HutchLambda( DYNAMODB_USER_ARTICLES_TABLE: storage.userArticlesTable.name, RESEND_API_KEY: requireEnv("RESEND_API_KEY"), STATIC_BASE_URL: staticAssets.baseUrl, + APP_ORIGIN: appOrigin, }, policies: [...sendTrialFeedbackEmailDynamodb.policies], }, diff --git a/projects/hutch/src/runtime/cancel-subscription.main.ts b/projects/hutch/src/runtime/cancel-subscription.main.ts index 73196ffe0..8b36b943f 100644 --- a/projects/hutch/src/runtime/cancel-subscription.main.ts +++ b/projects/hutch/src/runtime/cancel-subscription.main.ts @@ -50,6 +50,7 @@ export const handler = initCancelSubscriptionHandler({ scheduleCancellationAtPeriodEnd: stripeSubscriptions.scheduleCancellationAtPeriodEnd, createDeferredCancellationSchedule: trialScheduler.createDeferredCancellationSchedule, deleteTrialEndSchedule: trialScheduler.deleteTrialEndSchedule, + deleteTrialReminderSchedule: trialScheduler.deleteTrialReminderSchedule, publishSubscriptionCancellationScheduled, publishSubscriptionCancelled, logger: HutchLogger.from(consoleLogger), diff --git a/projects/hutch/src/runtime/cancel-subscription/cancel-subscription-handler.test.ts b/projects/hutch/src/runtime/cancel-subscription/cancel-subscription-handler.test.ts index 5d9973312..e5180bebf 100644 --- a/projects/hutch/src/runtime/cancel-subscription/cancel-subscription-handler.test.ts +++ b/projects/hutch/src/runtime/cancel-subscription/cancel-subscription-handler.test.ts @@ -57,6 +57,7 @@ function buildSubject(opts?: { scheduleCancellationAtPeriodEndReturns?: string } scheduleCancellationAtPeriodEnd: stripeSubscriptions.scheduleCancellationAtPeriodEnd, createDeferredCancellationSchedule: trialScheduler.createDeferredCancellationSchedule, deleteTrialEndSchedule: trialScheduler.deleteTrialEndSchedule, + deleteTrialReminderSchedule: trialScheduler.deleteTrialReminderSchedule, publishSubscriptionCancellationScheduled, publishSubscriptionCancelled, logger: HutchLogger.from(noopLogger), @@ -130,6 +131,8 @@ describe("cancel-subscription handler", () => { assert.deepEqual(subject.stripeSubscriptions.scheduledCancellations(), []); // Trial-end auto-charge schedule deleted (no charge after cancel). assert.deepEqual(subject.trialScheduler.deleteCalls(), [USER_ID]); + // Trial-reminder schedule also deleted (no subscribe nudge after cancel). + assert.deepEqual(subject.trialScheduler.trialReminderDeleteCalls(), [USER_ID]); // Deferred-cancellation schedule created at trialEndsAt + 1h. assert.deepEqual(subject.trialScheduler.allDeferredCancellationSchedules(), [ { userId: USER_ID, firesAt: "2026-06-05T01:00:00.000Z" }, @@ -260,6 +263,7 @@ describe("cancel-subscription handler", () => { }, createDeferredCancellationSchedule: trialScheduler.createDeferredCancellationSchedule, deleteTrialEndSchedule: trialScheduler.deleteTrialEndSchedule, + deleteTrialReminderSchedule: trialScheduler.deleteTrialReminderSchedule, publishSubscriptionCancellationScheduled: async () => {}, publishSubscriptionCancelled: async () => {}, logger: HutchLogger.from(noopLogger), diff --git a/projects/hutch/src/runtime/cancel-subscription/cancel-subscription-handler.ts b/projects/hutch/src/runtime/cancel-subscription/cancel-subscription-handler.ts index 4f018834c..f177b58fe 100644 --- a/projects/hutch/src/runtime/cancel-subscription/cancel-subscription-handler.ts +++ b/projects/hutch/src/runtime/cancel-subscription/cancel-subscription-handler.ts @@ -22,6 +22,7 @@ import type { ScheduleCancellationAtPeriodEnd } from "@packages/provider-contrac import type { CreateDeferredCancellationSchedule, DeleteTrialEndSchedule, + DeleteTrialReminderSchedule, } from "@packages/provider-contracts/trial-scheduler"; type CancelBranch = (row: SubscriptionRecord) => Promise; @@ -31,6 +32,7 @@ interface HandlerDeps { scheduleCancellationAtPeriodEnd: ScheduleCancellationAtPeriodEnd; createDeferredCancellationSchedule: CreateDeferredCancellationSchedule; deleteTrialEndSchedule: DeleteTrialEndSchedule; + deleteTrialReminderSchedule: DeleteTrialReminderSchedule; publishSubscriptionCancellationScheduled: PublishSubscriptionCancellationScheduled; publishSubscriptionCancelled: PublishSubscriptionCancelled; logger: HutchLogger; @@ -76,6 +78,10 @@ function buildBranches(deps: HandlerDeps): Record { + it("subtracts exactly two days from trialEndsAt", () => { + assert.equal( + trialReminderFiresAt("2026-07-19T10:00:00.000Z"), + "2026-07-17T10:00:00.000Z", + ); + }); +}); diff --git a/projects/hutch/src/runtime/domain/stripe/stripe-trial-config.ts b/projects/hutch/src/runtime/domain/stripe/stripe-trial-config.ts index 6dc9d22e9..6b9d1b00d 100644 --- a/projects/hutch/src/runtime/domain/stripe/stripe-trial-config.ts +++ b/projects/hutch/src/runtime/domain/stripe/stripe-trial-config.ts @@ -1 +1,9 @@ export const STRIPE_TRIAL_PERIOD_DAYS = 14; + +export const TRIAL_REMINDER_LEAD_DAYS = 2; + +export function trialReminderFiresAt(trialEndsAt: string): string { + return new Date( + Date.parse(trialEndsAt) - TRIAL_REMINDER_LEAD_DAYS * 86_400_000, + ).toISOString(); +} diff --git a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts index f83537010..7d53d0552 100644 --- a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts +++ b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-providers.test.ts @@ -403,6 +403,47 @@ describe("initDynamoDbSubscriptionProviders", () => { }); }); + describe("markTrialReminderEmailSent", () => { + it("issues a guarded Update that records trialReminderEmailSentAt and bumps updatedAt", async () => { + let received: unknown; + const client = createFakeClient((input) => { + received = input; + return {}; + }); + const subs = initDynamoDbSubscriptionProviders({ + client: client as DynamoDBDocumentClient, + tableName: TABLE, + now: NOW, + }); + + await subs.markTrialReminderEmailSent({ + userId: USER_ID, + sentAt: "2026-07-17T00:00:00.000Z", + }); + + const command = received as { + input: { + Key?: Record; + UpdateExpression?: string; + ConditionExpression?: string; + ExpressionAttributeValues?: Record; + }; + }; + expect(command.input.Key).toEqual({ userId: USER_ID }); + expect(command.input.UpdateExpression).toContain( + "trialReminderEmailSentAt = :sentAt", + ); + expect(command.input.UpdateExpression).toContain("updatedAt = :now"); + expect(command.input.ConditionExpression).toContain("attribute_exists(userId)"); + expect(command.input.ExpressionAttributeValues?.[":sentAt"]).toBe( + "2026-07-17T00:00:00.000Z", + ); + expect(command.input.ExpressionAttributeValues?.[":now"]).toBe( + "2026-05-22T10:00:00.000Z", + ); + }); + }); + describe("findByUserId with trialFeedbackEmailSentAt", () => { it("parses a cancelled trial row that carries trialFeedbackEmailSentAt", async () => { const client = createFakeClient(() => ({ diff --git a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-writes.ts b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-writes.ts index 675f65fe5..f0288cc5f 100644 --- a/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-writes.ts +++ b/projects/hutch/src/runtime/providers/subscription-providers/dynamodb-subscription-writes.ts @@ -7,12 +7,13 @@ import type { MarkSubscriptionCancelledByUserId, MarkSubscriptionPendingCancellation, MarkTrialFeedbackEmailSent, + MarkTrialReminderEmailSent, UpsertActiveSubscription, UpsertTrialingSubscription, } from "@packages/provider-contracts/subscription-providers"; import { SubscriptionProviderRow } from "@packages/subscription-access"; -/** The write half of the subscription table — the six mutations, wired +/** The write half of the subscription table — the seven mutations, wired * independently from the read half. The save gate composes write access from * the read half alone, so no save path ever depends on a mutation. */ export function initDynamoDbSubscriptionWrites(deps: { @@ -26,6 +27,7 @@ export function initDynamoDbSubscriptionWrites(deps: { markCancelledByUserId: MarkSubscriptionCancelledByUserId; markActive: MarkSubscriptionActive; markTrialFeedbackEmailSent: MarkTrialFeedbackEmailSent; + markTrialReminderEmailSent: MarkTrialReminderEmailSent; } { const table = defineDynamoTable({ client: deps.client, @@ -126,6 +128,18 @@ export function initDynamoDbSubscriptionWrites(deps: { }); }; + const markTrialReminderEmailSent: MarkTrialReminderEmailSent = async ({ userId, sentAt }) => { + await table.update({ + Key: { userId }, + UpdateExpression: "SET trialReminderEmailSentAt = :sentAt, updatedAt = :now", + ConditionExpression: "attribute_exists(userId)", + ExpressionAttributeValues: { + ":sentAt": sentAt, + ":now": deps.now().toISOString(), + }, + }); + }; + return { upsertTrialing, upsertActive, @@ -133,5 +147,6 @@ export function initDynamoDbSubscriptionWrites(deps: { markCancelledByUserId, markActive, markTrialFeedbackEmailSent, + markTrialReminderEmailSent, }; } diff --git a/projects/hutch/src/runtime/providers/trial-scheduler/aws-trial-scheduler.test.ts b/projects/hutch/src/runtime/providers/trial-scheduler/aws-trial-scheduler.test.ts index a9188d798..da26daad2 100644 --- a/projects/hutch/src/runtime/providers/trial-scheduler/aws-trial-scheduler.test.ts +++ b/projects/hutch/src/runtime/providers/trial-scheduler/aws-trial-scheduler.test.ts @@ -336,4 +336,109 @@ describe("initAwsTrialScheduler", () => { ); }); }); + + describe("createTrialReminderSchedule", () => { + it("issues a CreateScheduleCommand with name=trial-reminder-, group, at(...) expression, and SendTrialFeedbackEmailCommand target carrying kind=reminder", async () => { + const { client, captured } = buildFakeClient(); + const scheduler = initAwsTrialScheduler({ + client, + scheduleGroupName: GROUP_NAME, + schedulerRoleArn: ROLE_ARN, + eventBusArn: EVENT_BUS_ARN, + }); + + await scheduler.createTrialReminderSchedule({ + userId: USER_ID, + firesAt: "2026-07-17T10:00:00.000Z", + }); + + assert.equal(captured.commands.length, 1); + const cmd = captured.commands[0]; + assert.ok(cmd instanceof CreateScheduleCommand); + const input = cmd.input; + assert.equal(input.Name, `trial-reminder-${USER_ID}`); + assert.equal(input.GroupName, GROUP_NAME); + assert.equal(input.ScheduleExpression, "at(2026-07-17T10:00:00)"); + assert.equal(input.FlexibleTimeWindow?.Mode, "OFF"); + assert.equal(input.ActionAfterCompletion, "DELETE"); + assert.equal(input.State, "ENABLED"); + assert.equal(input.Target?.Arn, EVENT_BUS_ARN); + assert.equal(input.Target?.RoleArn, ROLE_ARN); + assert.equal(input.Target?.EventBridgeParameters?.Source, "hutch.subscriptions"); + assert.equal( + input.Target?.EventBridgeParameters?.DetailType, + "SendTrialFeedbackEmailCommand", + ); + assert.deepEqual(JSON.parse(input.Target?.Input ?? "{}"), { + userId: USER_ID, + kind: "reminder", + }); + }); + + it("strips fractional seconds and the trailing Z from firesAt", async () => { + const { client, captured } = buildFakeClient(); + const scheduler = initAwsTrialScheduler({ + client, + scheduleGroupName: GROUP_NAME, + schedulerRoleArn: ROLE_ARN, + eventBusArn: EVENT_BUS_ARN, + }); + + await scheduler.createTrialReminderSchedule({ + userId: USER_ID, + firesAt: "2026-07-17T10:00:00Z", + }); + + const cmd = captured.commands[0]; + assert.ok(cmd instanceof CreateScheduleCommand); + assert.equal(cmd.input.ScheduleExpression, "at(2026-07-17T10:00:00)"); + }); + }); + + describe("deleteTrialReminderSchedule", () => { + it("issues a DeleteScheduleCommand with name=trial-reminder- + group", async () => { + const { client, captured } = buildFakeClient(); + const scheduler = initAwsTrialScheduler({ + client, + scheduleGroupName: GROUP_NAME, + }); + + await scheduler.deleteTrialReminderSchedule({ userId: USER_ID }); + + assert.equal(captured.commands.length, 1); + const cmd = captured.commands[0]; + assert.ok(cmd instanceof DeleteScheduleCommand); + assert.equal(cmd.input.Name, `trial-reminder-${USER_ID}`); + assert.equal(cmd.input.GroupName, GROUP_NAME); + }); + + it("swallows ResourceNotFoundException — delete is idempotent", async () => { + const notFound = new Error("Schedule not found"); + notFound.name = "ResourceNotFoundException"; + const { client } = buildFakeClient({ deleteThrows: notFound }); + const scheduler = initAwsTrialScheduler({ + client, + scheduleGroupName: GROUP_NAME, + }); + + await assert.doesNotReject( + scheduler.deleteTrialReminderSchedule({ userId: USER_ID }), + ); + }); + + it("re-throws any other error", async () => { + const wrenchInGears = new Error("Internal failure"); + wrenchInGears.name = "InternalServerException"; + const { client } = buildFakeClient({ deleteThrows: wrenchInGears }); + const scheduler = initAwsTrialScheduler({ + client, + scheduleGroupName: GROUP_NAME, + }); + + await assert.rejects( + scheduler.deleteTrialReminderSchedule({ userId: USER_ID }), + /Internal failure/, + ); + }); + }); }); diff --git a/projects/hutch/src/runtime/providers/trial-scheduler/aws-trial-scheduler.ts b/projects/hutch/src/runtime/providers/trial-scheduler/aws-trial-scheduler.ts index f9623b938..b0887ff1c 100644 --- a/projects/hutch/src/runtime/providers/trial-scheduler/aws-trial-scheduler.ts +++ b/projects/hutch/src/runtime/providers/trial-scheduler/aws-trial-scheduler.ts @@ -9,9 +9,11 @@ import type { CreateDeferredCancellationSchedule, CreateTrialEndSchedule, CreateTrialFeedbackEmailSchedule, + CreateTrialReminderSchedule, DeleteDeferredCancellationSchedule, DeleteTrialEndSchedule, DeleteTrialFeedbackEmailSchedule, + DeleteTrialReminderSchedule, } from "@packages/provider-contracts/trial-scheduler"; /** EventBridge Scheduler's `at()` does not accept fractional seconds or a @@ -40,6 +42,13 @@ function trialFeedbackEmailScheduleName(userId: UserId): string { return `trial-feedback-${userId}`; } +/** Deterministic name keyed by userId, distinct from the feedback schedule so + * the pre-expiry reminder and post-cancellation feedback one-shots can coexist + * for the same user. Both target the same DetailType; `Input.kind` disambiguates. */ +function trialReminderScheduleName(userId: UserId): string { + return `trial-reminder-${userId}`; +} + export function initAwsTrialScheduler(deps: { client: Pick; scheduleGroupName: string; @@ -52,6 +61,8 @@ export function initAwsTrialScheduler(deps: { deleteDeferredCancellationSchedule: DeleteDeferredCancellationSchedule; createTrialFeedbackEmailSchedule: CreateTrialFeedbackEmailSchedule; deleteTrialFeedbackEmailSchedule: DeleteTrialFeedbackEmailSchedule; + createTrialReminderSchedule: CreateTrialReminderSchedule; + deleteTrialReminderSchedule: DeleteTrialReminderSchedule; } { const createTrialEndSchedule: CreateTrialEndSchedule = async ({ userId, firesAt }) => { assert(deps.eventBusArn, "eventBusArn is required for createTrialEndSchedule"); @@ -192,6 +203,52 @@ export function initAwsTrialScheduler(deps: { } }; + const createTrialReminderSchedule: CreateTrialReminderSchedule = async ({ + userId, + firesAt, + }) => { + assert(deps.eventBusArn, "eventBusArn is required for createTrialReminderSchedule"); + assert( + deps.schedulerRoleArn, + "schedulerRoleArn is required for createTrialReminderSchedule", + ); + await deps.client.send( + new CreateScheduleCommand({ + Name: trialReminderScheduleName(userId), + GroupName: deps.scheduleGroupName, + ScheduleExpression: `at(${toNaiveSeconds(firesAt)})`, + FlexibleTimeWindow: { Mode: "OFF" }, + ActionAfterCompletion: "DELETE", + State: "ENABLED", + Target: { + Arn: deps.eventBusArn, + RoleArn: deps.schedulerRoleArn, + EventBridgeParameters: { + Source: "hutch.subscriptions", + DetailType: "SendTrialFeedbackEmailCommand", + }, + Input: JSON.stringify({ userId, kind: "reminder" }), + }, + }), + ); + }; + + const deleteTrialReminderSchedule: DeleteTrialReminderSchedule = async ({ userId }) => { + try { + await deps.client.send( + new DeleteScheduleCommand({ + Name: trialReminderScheduleName(userId), + GroupName: deps.scheduleGroupName, + }), + ); + } catch (err) { + if (err instanceof Error && err.name === "ResourceNotFoundException") { + return; + } + throw err; + } + }; + return { createTrialEndSchedule, deleteTrialEndSchedule, @@ -199,5 +256,7 @@ export function initAwsTrialScheduler(deps: { deleteDeferredCancellationSchedule, createTrialFeedbackEmailSchedule, deleteTrialFeedbackEmailSchedule, + createTrialReminderSchedule, + deleteTrialReminderSchedule, }; } diff --git a/projects/hutch/src/runtime/send-trial-feedback-email.main.ts b/projects/hutch/src/runtime/send-trial-feedback-email.main.ts index c70ffd72c..b43e44bb2 100644 --- a/projects/hutch/src/runtime/send-trial-feedback-email.main.ts +++ b/projects/hutch/src/runtime/send-trial-feedback-email.main.ts @@ -17,6 +17,7 @@ const subscriptionProvidersTable = requireEnv( ); const resendApiKey = requireEnv("RESEND_API_KEY"); const staticBaseUrl = requireEnv("STATIC_BASE_URL"); +const appOrigin = requireEnv("APP_ORIGIN"); const dynamoClient = createDynamoDocumentClient(); @@ -46,8 +47,10 @@ export const handler = initSendTrialFeedbackEmailHandler({ findEmailByUserId: auth.findEmailByUserId, findArticlesByUser: articleStore.findArticlesByUser, markTrialFeedbackEmailSent: subscriptionProviders.markTrialFeedbackEmailSent, + markTrialReminderEmailSent: subscriptionProviders.markTrialReminderEmailSent, sendEmail, founderAvatarUrl: `${staticBaseUrl}/fayner-brack.jpg`, + appOrigin, now: () => new Date(), logger: HutchLogger.from(consoleLogger), }); diff --git a/projects/hutch/src/runtime/send-trial-feedback-email/send-trial-feedback-email-handler.test.ts b/projects/hutch/src/runtime/send-trial-feedback-email/send-trial-feedback-email-handler.test.ts index 776683bd9..82b1359d8 100644 --- a/projects/hutch/src/runtime/send-trial-feedback-email/send-trial-feedback-email-handler.test.ts +++ b/projects/hutch/src/runtime/send-trial-feedback-email/send-trial-feedback-email-handler.test.ts @@ -19,6 +19,10 @@ function buildEventBridgeBody(userId: string): string { return JSON.stringify({ detail: { userId } }); } +function buildReminderBody(userId: string): string { + return JSON.stringify({ detail: { userId, kind: "reminder" } }); +} + function fakeFindArticlesByUser(total: number): FindArticlesByUser { return async () => ({ articles: [], @@ -31,6 +35,7 @@ function fakeFindArticlesByUser(total: number): FindArticlesByUser { interface SubjectOverrides { findEmail?: (userId: string) => Promise; articlesTotal?: number; + now?: Date; } function buildSubject(overrides: SubjectOverrides = {}) { @@ -39,14 +44,17 @@ function buildSubject(overrides: SubjectOverrides = {}) { }); const email = initInMemoryEmail(); const findEmailByUserId = overrides.findEmail ?? (async () => "user@example.com"); + const now = overrides.now ?? SENT_AT; const deps: SendTrialFeedbackEmailDeps = { findSubscriptionByUserId: providers.findByUserId, findEmailByUserId, findArticlesByUser: fakeFindArticlesByUser(overrides.articlesTotal ?? 0), markTrialFeedbackEmailSent: providers.markTrialFeedbackEmailSent, + markTrialReminderEmailSent: providers.markTrialReminderEmailSent, sendEmail: email.sendEmail, founderAvatarUrl: FOUNDER_AVATAR_URL, - now: () => SENT_AT, + appOrigin: "https://readplace.com", + now: () => now, logger: HutchLogger.from(noopLogger), }; const handler = initSendTrialFeedbackEmailHandler(deps); @@ -236,10 +244,12 @@ describe("send-trial-feedback-email handler", () => { findEmailByUserId: async () => "user@example.com", findArticlesByUser: fakeFindArticlesByUser(2), markTrialFeedbackEmailSent: providers.markTrialFeedbackEmailSent, + markTrialReminderEmailSent: providers.markTrialReminderEmailSent, sendEmail: async () => { throw new Error("Resend rejected"); }, founderAvatarUrl: FOUNDER_AVATAR_URL, + appOrigin: "https://readplace.com", now: () => SENT_AT, logger: HutchLogger.from(noopLogger), }); @@ -286,4 +296,156 @@ describe("send-trial-feedback-email handler", () => { assert(result); assert.equal(result.batchItemFailures.length, 1); }); + + describe("kind='reminder' — pre-expiry trial reminder", () => { + async function seedFutureTrial( + providers: ReturnType, + ): Promise { + await providers.upsertTrialing({ + userId: USER_ID, + trialEndsAt: "2026-06-20T00:00:00.000Z", + }); + } + + it("sends the reminder and marks the reminder flag without touching the feedback flag", async () => { + const subject = buildSubject({ articlesTotal: 3 }); + await seedFutureTrial(subject.providers); + + const result = await subject.handler( + buildSqsEvent([{ messageId: "msg-rem", body: buildReminderBody(USER_ID) }]), + buildLambdaContext(), + () => {}, + ); + + assert(result); + assert.equal(result.batchItemFailures.length, 0); + assert.equal(subject.email.getSentEmails().length, 1); + const sent = subject.email.getSentEmails()[0]; + assert.equal(sent.to, "user@example.com"); + assert.equal(sent.from, "Fayner from Readplace "); + assert.equal(sent.replyTo, "fayner@readplace.com"); + assert.equal(sent.bcc, "readplace+trial_reminder@readplace.com"); + assert.equal(sent.subject, "your Readplace trial ends in 2 days"); + assert.ok(sent.text); + assert.ok(sent.text.includes("/account?utm_source=trial-reminder")); + assert.ok(sent.html.includes("trial-reminder")); + + const row = await subject.providers.findByUserId(USER_ID); + assert(row, "row must still exist"); + assert.equal(row.trialReminderEmailSentAt, SENT_AT.toISOString()); + assert.equal(row.trialFeedbackEmailSentAt, undefined); + }); + + it("noops when the user is no longer trialing (cancelled)", async () => { + const subject = buildSubject({ articlesTotal: 3 }); + await seedCancelledTrial(subject.providers); + + await subject.handler( + buildSqsEvent([{ messageId: "msg-rem-cancelled", body: buildReminderBody(USER_ID) }]), + buildLambdaContext(), + () => {}, + ); + + assert.equal(subject.email.getSentEmails().length, 0); + }); + + it("noops when trialEndsAt is already in the past (clock skew / stale schedule)", async () => { + const subject = buildSubject({ + articlesTotal: 3, + now: new Date("2026-06-21T00:00:00.000Z"), + }); + await seedFutureTrial(subject.providers); + + await subject.handler( + buildSqsEvent([{ messageId: "msg-rem-past", body: buildReminderBody(USER_ID) }]), + buildLambdaContext(), + () => {}, + ); + + assert.equal(subject.email.getSentEmails().length, 0); + const row = await subject.providers.findByUserId(USER_ID); + assert(row); + assert.equal(row.trialReminderEmailSentAt, undefined); + }); + + it("noops when the reminder was already sent — at most one reminder", async () => { + const subject = buildSubject({ articlesTotal: 3 }); + await seedFutureTrial(subject.providers); + + await subject.handler( + buildSqsEvent([{ messageId: "msg-rem-1", body: buildReminderBody(USER_ID) }]), + buildLambdaContext(), + () => {}, + ); + await subject.handler( + buildSqsEvent([{ messageId: "msg-rem-2", body: buildReminderBody(USER_ID) }]), + buildLambdaContext(), + () => {}, + ); + + assert.equal(subject.email.getSentEmails().length, 1); + }); + + it("noops when there is no subscription row at all", async () => { + const subject = buildSubject({ articlesTotal: 3 }); + + await subject.handler( + buildSqsEvent([{ messageId: "msg-rem-missing", body: buildReminderBody(USER_ID) }]), + buildLambdaContext(), + () => {}, + ); + + assert.equal(subject.email.getSentEmails().length, 0); + }); + + it("noops when there is no email on file", async () => { + const subject = buildSubject({ articlesTotal: 3, findEmail: async () => null }); + await seedFutureTrial(subject.providers); + + await subject.handler( + buildSqsEvent([{ messageId: "msg-rem-no-email", body: buildReminderBody(USER_ID) }]), + buildLambdaContext(), + () => {}, + ); + + assert.equal(subject.email.getSentEmails().length, 0); + const row = await subject.providers.findByUserId(USER_ID); + assert(row); + assert.equal(row.trialReminderEmailSentAt, undefined); + }); + + it("omits the saved-articles clause when the user saved zero", async () => { + const subject = buildSubject({ articlesTotal: 0 }); + await seedFutureTrial(subject.providers); + + await subject.handler( + buildSqsEvent([{ messageId: "msg-rem-zero", body: buildReminderBody(USER_ID) }]), + buildLambdaContext(), + () => {}, + ); + + const sent = subject.email.getSentEmails()[0]; + assert.ok(sent.text); + assert.ok(!sent.text.includes("stay readable either way")); + }); + + it("a body WITHOUT kind still runs the feedback path (backward compatible)", async () => { + const subject = buildSubject({ articlesTotal: 4 }); + await seedCancelledTrial(subject.providers); + + await subject.handler( + buildSqsEvent([{ messageId: "msg-nokind", body: buildEventBridgeBody(USER_ID) }]), + buildLambdaContext(), + () => {}, + ); + + assert.equal(subject.email.getSentEmails().length, 1); + const sent = subject.email.getSentEmails()[0]; + assert.equal(sent.subject, "you tried Readplace — what was missing?"); + const row = await subject.providers.findByUserId(USER_ID); + assert(row); + assert.equal(row.trialFeedbackEmailSentAt, SENT_AT.toISOString()); + assert.equal(row.trialReminderEmailSentAt, undefined); + }); + }); }); diff --git a/projects/hutch/src/runtime/send-trial-feedback-email/send-trial-feedback-email-handler.ts b/projects/hutch/src/runtime/send-trial-feedback-email/send-trial-feedback-email-handler.ts index 0452e7e49..26bd963cc 100644 --- a/projects/hutch/src/runtime/send-trial-feedback-email/send-trial-feedback-email-handler.ts +++ b/projects/hutch/src/runtime/send-trial-feedback-email/send-trial-feedback-email-handler.ts @@ -14,23 +14,31 @@ import type { SendEmail } from "@packages/provider-contracts/email"; import type { FindSubscriptionByUserId, MarkTrialFeedbackEmailSent, + MarkTrialReminderEmailSent, } from "@packages/provider-contracts/subscription-providers"; import { TrialFeedbackEmail, TRIAL_FEEDBACK_EMAIL_SUBJECT, } from "../web/auth/trial-feedback-email"; +import { + TrialReminderEmail, + TRIAL_REMINDER_EMAIL_SUBJECT, +} from "../web/auth/trial-reminder-email"; const EMAIL_FROM = "Fayner from Readplace "; const EMAIL_REPLY_TO = "fayner@readplace.com"; const EMAIL_BCC = "readplace+trial_feedback@readplace.com"; +const REMINDER_EMAIL_BCC = "readplace+trial_reminder@readplace.com"; export interface SendTrialFeedbackEmailDeps { findSubscriptionByUserId: FindSubscriptionByUserId; findEmailByUserId: FindEmailByUserId; findArticlesByUser: FindArticlesByUser; markTrialFeedbackEmailSent: MarkTrialFeedbackEmailSent; + markTrialReminderEmailSent: MarkTrialReminderEmailSent; sendEmail: SendEmail; founderAvatarUrl: string; + appOrigin: string; now: () => Date; logger: HutchLogger; } @@ -50,7 +58,11 @@ export function initSendTrialFeedbackEmailHandler( envelope.detail, ); const userId = UserIdSchema.parse(detail.userId); - await processCommand(userId, deps); + if (detail.kind === "reminder") { + await processReminder(userId, deps); + } else { + await processCommand(userId, deps); + } } catch (error) { deps.logger.error("[send-trial-feedback-email] record failed", { messageId: record.messageId, @@ -128,3 +140,76 @@ async function processCommand( sentAt, }); } + +async function processReminder( + userId: ReturnType, + deps: SendTrialFeedbackEmailDeps, +): Promise { + const row = await deps.findSubscriptionByUserId(userId); + if (!row) { + deps.logger.info( + "[send-trial-feedback-email] reminder: no subscription row — noop", + { userId }, + ); + return; + } + if (row.status !== "trialing") { + deps.logger.info( + "[send-trial-feedback-email] reminder: user no longer trialing — noop", + { userId, status: row.status }, + ); + return; + } + if (!row.trialEndsAt || Date.parse(row.trialEndsAt) <= deps.now().getTime()) { + deps.logger.info( + "[send-trial-feedback-email] reminder: trial already ended — noop", + { userId }, + ); + return; + } + if (row.trialReminderEmailSentAt) { + deps.logger.info( + "[send-trial-feedback-email] reminder: already sent — noop", + { userId, sentAt: row.trialReminderEmailSentAt }, + ); + return; + } + + const email = await deps.findEmailByUserId(userId); + if (!email) { + deps.logger.info( + "[send-trial-feedback-email] reminder: no email on file — noop", + { userId }, + ); + return; + } + + const { total } = await deps.findArticlesByUser({ + userId, + excludeContent: true, + }); + + const component = TrialReminderEmail({ + founderAvatarUrl: deps.founderAvatarUrl, + savedArticlesCount: total, + ctaUrl: `${deps.appOrigin}/account?utm_source=trial-reminder&utm_medium=email&utm_campaign=trial-preexpiry`, + }); + + await deps.sendEmail({ + from: EMAIL_FROM, + to: email, + bcc: REMINDER_EMAIL_BCC, + replyTo: EMAIL_REPLY_TO, + subject: TRIAL_REMINDER_EMAIL_SUBJECT, + html: component.to("text/html"), + text: component.to("text/plain"), + }); + + const sentAt = deps.now().toISOString(); + await deps.markTrialReminderEmailSent({ userId, sentAt }); + deps.logger.info("[send-trial-feedback-email] reminder sent", { + userId, + savedArticlesCount: total, + sentAt, + }); +} diff --git a/projects/hutch/src/runtime/server.ts b/projects/hutch/src/runtime/server.ts index 9fb76dfba..e90ec9e69 100644 --- a/projects/hutch/src/runtime/server.ts +++ b/projects/hutch/src/runtime/server.ts @@ -44,8 +44,10 @@ import type { } from "@packages/provider-contracts/subscription-providers"; import type { CreateTrialEndSchedule, + CreateTrialReminderSchedule, DeleteDeferredCancellationSchedule, DeleteTrialEndSchedule, + DeleteTrialReminderSchedule, } from "@packages/provider-contracts/trial-scheduler"; import type { PublishCancelSubscriptionCommand, @@ -305,6 +307,8 @@ interface AppDependencies { createTrialEndSchedule: CreateTrialEndSchedule; deleteTrialEndSchedule: DeleteTrialEndSchedule; deleteDeferredCancellationSchedule: DeleteDeferredCancellationSchedule; + createTrialReminderSchedule: CreateTrialReminderSchedule; + deleteTrialReminderSchedule: DeleteTrialReminderSchedule; }; createSubscriptionOnExistingCustomer: CreateSubscriptionOnExistingCustomer; reverseScheduledCancellation: ReverseScheduledCancellation; @@ -871,6 +875,8 @@ export function createApp(dependencies: AppDependencies): Express { trialScheduler: { createTrialEndSchedule: deps.trialScheduler.createTrialEndSchedule, deleteTrialEndSchedule: deps.trialScheduler.deleteTrialEndSchedule, + createTrialReminderSchedule: deps.trialScheduler.createTrialReminderSchedule, + deleteTrialReminderSchedule: deps.trialScheduler.deleteTrialReminderSchedule, }, baseUrl: deps.baseUrl, staticBaseUrl, @@ -907,6 +913,7 @@ export function createApp(dependencies: AppDependencies): Express { exchangeGoogleCode: deps.googleAuth.exchangeGoogleCode, upsertTrialing: deps.subscriptionProviders.upsertTrialing, createTrialEndSchedule: deps.trialScheduler.createTrialEndSchedule, + createTrialReminderSchedule: deps.trialScheduler.createTrialReminderSchedule, sendEmail: deps.sendEmail, logError: deps.logError, now: deps.now, @@ -931,6 +938,7 @@ export function createApp(dependencies: AppDependencies): Express { exchangeAppleCode: deps.appleAuth.exchangeAppleCode, upsertTrialing: deps.subscriptionProviders.upsertTrialing, createTrialEndSchedule: deps.trialScheduler.createTrialEndSchedule, + createTrialReminderSchedule: deps.trialScheduler.createTrialReminderSchedule, sendEmail: deps.sendEmail, logError: deps.logError, now: deps.now, @@ -1124,6 +1132,7 @@ export function createApp(dependencies: AppDependencies): Express { setPrimaryCard: deps.paymentMethods.setPrimaryCard, stripePublishableKey: deps.stripePublishableKey, createTrialEndSchedule: deps.trialScheduler.createTrialEndSchedule, + createTrialReminderSchedule: deps.trialScheduler.createTrialReminderSchedule, deleteDeferredCancellationSchedule: deps.trialScheduler.deleteDeferredCancellationSchedule, storePendingSignup: deps.storePendingSignup, diff --git a/projects/hutch/src/runtime/test-app.ts b/projects/hutch/src/runtime/test-app.ts index f7ed4b4b5..5519243e8 100644 --- a/projects/hutch/src/runtime/test-app.ts +++ b/projects/hutch/src/runtime/test-app.ts @@ -191,6 +191,8 @@ function flattenFixtureToAppDependencies( deleteTrialEndSchedule: fixture.trialScheduler.deleteTrialEndSchedule, deleteDeferredCancellationSchedule: fixture.trialScheduler.deleteDeferredCancellationSchedule, + createTrialReminderSchedule: fixture.trialScheduler.createTrialReminderSchedule, + deleteTrialReminderSchedule: fixture.trialScheduler.deleteTrialReminderSchedule, }, createSubscriptionOnExistingCustomer: fixture.stripeSubscriptions.createSubscriptionOnExistingCustomer, diff --git a/projects/hutch/src/runtime/web/auth/apple-auth.page.ts b/projects/hutch/src/runtime/web/auth/apple-auth.page.ts index 72e6cfb65..54e8013ab 100644 --- a/projects/hutch/src/runtime/web/auth/apple-auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/apple-auth.page.ts @@ -14,8 +14,14 @@ import type { import type { SendEmail } from "@packages/provider-contracts/email"; import type { ExchangeAppleCode } from "@packages/provider-contracts/apple-auth"; import type { UpsertTrialingSubscription } from "@packages/provider-contracts/subscription-providers"; -import type { CreateTrialEndSchedule } from "@packages/provider-contracts/trial-scheduler"; -import { STRIPE_TRIAL_PERIOD_DAYS } from "../../domain/stripe/stripe-trial-config"; +import type { + CreateTrialEndSchedule, + CreateTrialReminderSchedule, +} from "@packages/provider-contracts/trial-scheduler"; +import { + STRIPE_TRIAL_PERIOD_DAYS, + trialReminderFiresAt, +} from "../../domain/stripe/stripe-trial-config"; import type { FoundingAllocation } from "../shared/founding-progress/founding-allocation"; import { initSendWelcomeEmail } from "./send-welcome-email"; import { Base } from "../base.component"; @@ -72,6 +78,7 @@ interface AppleAuthDependencies { exchangeAppleCode: ExchangeAppleCode; upsertTrialing: UpsertTrialingSubscription; createTrialEndSchedule: CreateTrialEndSchedule; + createTrialReminderSchedule: CreateTrialReminderSchedule; sendEmail: SendEmail; logError: (message: string, error?: Error) => void; now: () => Date; @@ -297,6 +304,17 @@ export const initAppleAuthRoutes = (deps: AppleAuthDependencies): Router => { err instanceof Error ? err : new Error(String(err)), ); } + try { + await deps.createTrialReminderSchedule({ + userId: created.userId, + firesAt: trialReminderFiresAt(trialEndsAt), + }); + } catch (err) { + deps.logError( + "[Apple Auth] Trial-reminder schedule creation failed — continuing without schedule", + err instanceof Error ? err : new Error(String(err)), + ); + } const sessionId = await deps.createSession({ userId: created.userId, emailVerified: true }); res.cookie(SESSION_COOKIE_NAME, sessionId, sessionCookieOptions); diff --git a/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts b/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts index 5b0eac9f8..8d29954c6 100644 --- a/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/apple-auth.route.test.ts @@ -354,7 +354,7 @@ describe("Apple auth routes", () => { it("creates the Apple user with a trialing subscription_providers row when the founding allocation is exhausted", async () => { const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); const harness = useApp({ ...fixture, apple: appleWith(stubExchange({ email: "brand-new@example.com" })) }); - const { auth, subscriptionProviders, conversions } = harness; + const { auth, subscriptionProviders, conversions, trialScheduler } = harness; for (let i = 0; i < TEST_FOUNDING_MEMBER_LIMIT; i++) { await auth.createUser({ email: `seed${i}@test.com`, password: "password123" }); } @@ -380,6 +380,13 @@ describe("Apple auth routes", () => { expect(trialMs).toBeGreaterThan(13 * 86_400_000); expect(trialMs).toBeLessThan(15 * 86_400_000); + // The pre-expiry reminder schedule fires two days before trialEndsAt. + const reminderFiresAt = trialScheduler.getTrialReminderSchedule(lookup.userId); + assert(reminderFiresAt, "Apple trial signup must create a trial-reminder schedule"); + expect(new Date(reminderFiresAt).getTime()).toBe( + new Date(subRow.trialEndsAt).getTime() - 2 * 86_400_000, + ); + const conversionEvent = conversions.events.find((e) => e.method === "apple" && e.tier === "trial"); assert(conversionEvent, "Apple trial signup must emit a user_created conversion event with tier=trial"); }, 30000); diff --git a/projects/hutch/src/runtime/web/auth/auth.page.ts b/projects/hutch/src/runtime/web/auth/auth.page.ts index 73f4b9548..0d26bcebe 100644 --- a/projects/hutch/src/runtime/web/auth/auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/auth.page.ts @@ -29,7 +29,9 @@ import type { } from "@packages/provider-contracts/subscription-providers"; import type { CreateTrialEndSchedule, + CreateTrialReminderSchedule, DeleteTrialEndSchedule, + DeleteTrialReminderSchedule, } from "@packages/provider-contracts/trial-scheduler"; import { CheckoutSessionIdSchema, @@ -41,7 +43,10 @@ import type { } from "@packages/provider-contracts/rate-limit"; import { createRateLimitMiddleware, sendRateLimited } from "../middleware/rate-limit"; import { normalizeEmail } from "@packages/domain/user"; -import { STRIPE_TRIAL_PERIOD_DAYS } from "../../domain/stripe/stripe-trial-config"; +import { + STRIPE_TRIAL_PERIOD_DAYS, + trialReminderFiresAt, +} from "../../domain/stripe/stripe-trial-config"; import { Base } from "../base.component"; import { bannerStateFromRequest, sendComponent } from "@packages/web-shell"; import type { BuildBannerState } from "../banner-state"; @@ -96,6 +101,8 @@ interface AuthDependencies { trialScheduler: { createTrialEndSchedule: CreateTrialEndSchedule; deleteTrialEndSchedule: DeleteTrialEndSchedule; + createTrialReminderSchedule: CreateTrialReminderSchedule; + deleteTrialReminderSchedule: DeleteTrialReminderSchedule; }; baseUrl: string; staticBaseUrl: string; @@ -341,6 +348,17 @@ export function initAuthRoutes(deps: AuthDependencies): Router { err instanceof Error ? err : new Error(String(err)), ); } + try { + await deps.trialScheduler.createTrialReminderSchedule({ + userId: created.userId, + firesAt: trialReminderFiresAt(trialEndsAt), + }); + } catch (err) { + deps.logError( + "[Auth] Trial-reminder schedule creation failed — continuing without schedule", + err instanceof Error ? err : new Error(String(err)), + ); + } const sessionId = await deps.createSession({ userId: created.userId, emailVerified: false }); res.cookie(SESSION_COOKIE_NAME, sessionId, sessionCookieOptions); @@ -416,6 +434,7 @@ export function initAuthRoutes(deps: AuthDependencies): Router { customerId, }); await deps.trialScheduler.deleteTrialEndSchedule({ userId: pending.userId }); + await deps.trialScheduler.deleteTrialReminderSchedule({ userId: pending.userId }); res.redirect(303, parseReturnUrl({ return: pending.returnUrl })); }); diff --git a/projects/hutch/src/runtime/web/auth/auth.route.test.ts b/projects/hutch/src/runtime/web/auth/auth.route.test.ts index 0264574b1..7ba7de0f1 100644 --- a/projects/hutch/src/runtime/web/auth/auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/auth.route.test.ts @@ -407,6 +407,13 @@ describe("Auth routes", () => { assert(schedule, "trial signup must create a trial-end schedule"); expect(schedule).toBe(subRow.trialEndsAt); + // Trial-reminder schedule must be created at trialEndsAt minus 2 days. + const reminderSchedule = trialScheduler.getTrialReminderSchedule(lookup.userId); + assert(reminderSchedule, "trial signup must create a trial-reminder schedule"); + expect(new Date(reminderSchedule).getTime()).toBe( + new Date(subRow.trialEndsAt).getTime() - 2 * 86_400_000, + ); + const conversionEvent = conversions.events.find((e) => e.method === "email" && e.tier === "trial"); assert(conversionEvent, "trial signup must emit a user_created conversion event with tier=trial"); @@ -444,6 +451,31 @@ describe("Auth routes", () => { expect(subRow.status).toBe("trialing"); }, 30000); + it("completes trial signup even when the trial-reminder scheduler fails", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + fixture.trialScheduler.createTrialReminderSchedule = async () => { + throw new Error("Scheduler outage"); + }; + const harness = useApp(fixture); + for (let i = 0; i < TEST_FOUNDING_MEMBER_LIMIT; i++) { + await harness.auth.createUser({ email: `seed${i}@test.com`, password: "password123" }); + } + + const response = await request(harness.server).post("/signup").type("form").send({ + email: "reminder-fail@example.com", + password: "password123", + confirmPassword: "password123", + loadedAt: freshLoadedAt(), + }); + + expect(response.status).toBe(303); + const lookup = await harness.auth.findUserByEmail("reminder-fail@example.com"); + assert(lookup, "user row must persist"); + const subRow = await harness.subscriptionProviders.findByUserId(lookup.userId); + assert(subRow, "trial subscription row must persist"); + expect(subRow.status).toBe("trialing"); + }, 30000); + it("should fall back to free signup after a manual deletion drops the count below the limit", async () => { const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); const { auth } = harness; diff --git a/projects/hutch/src/runtime/web/auth/checkout-success.route.test.ts b/projects/hutch/src/runtime/web/auth/checkout-success.route.test.ts index 938d22f69..92ca093f1 100644 --- a/projects/hutch/src/runtime/web/auth/checkout-success.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/checkout-success.route.test.ts @@ -134,5 +134,8 @@ describe("GET /auth/checkout/success", () => { // Schedule delete is idempotent so it's safe to call even when no // schedule exists (first-time-paid signup). expect(trialScheduler.deleteCalls()).toContain(lookup.userId); + // The pre-expiry reminder schedule is cleared too — a paid user must + // never receive the "your trial ends soon" nudge. + expect(trialScheduler.trialReminderDeleteCalls()).toContain(lookup.userId); }); }); diff --git a/projects/hutch/src/runtime/web/auth/google-auth.page.ts b/projects/hutch/src/runtime/web/auth/google-auth.page.ts index 32a4efa20..92b36be3d 100644 --- a/projects/hutch/src/runtime/web/auth/google-auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/google-auth.page.ts @@ -14,8 +14,14 @@ import type { import type { SendEmail } from "@packages/provider-contracts/email"; import type { ExchangeGoogleCode } from "@packages/provider-contracts/google-auth"; import type { UpsertTrialingSubscription } from "@packages/provider-contracts/subscription-providers"; -import type { CreateTrialEndSchedule } from "@packages/provider-contracts/trial-scheduler"; -import { STRIPE_TRIAL_PERIOD_DAYS } from "../../domain/stripe/stripe-trial-config"; +import type { + CreateTrialEndSchedule, + CreateTrialReminderSchedule, +} from "@packages/provider-contracts/trial-scheduler"; +import { + STRIPE_TRIAL_PERIOD_DAYS, + trialReminderFiresAt, +} from "../../domain/stripe/stripe-trial-config"; import type { FoundingAllocation } from "../shared/founding-progress/founding-allocation"; import { initSendWelcomeEmail } from "./send-welcome-email"; import { Base } from "../base.component"; @@ -61,6 +67,7 @@ interface GoogleAuthDependencies { exchangeGoogleCode: ExchangeGoogleCode; upsertTrialing: UpsertTrialingSubscription; createTrialEndSchedule: CreateTrialEndSchedule; + createTrialReminderSchedule: CreateTrialReminderSchedule; sendEmail: SendEmail; logError: (message: string, error?: Error) => void; now: () => Date; @@ -250,6 +257,17 @@ export const initGoogleAuthRoutes = (deps: GoogleAuthDependencies): Router => { err instanceof Error ? err : new Error(String(err)), ); } + try { + await deps.createTrialReminderSchedule({ + userId: created.userId, + firesAt: trialReminderFiresAt(trialEndsAt), + }); + } catch (err) { + deps.logError( + "[Google Auth] Trial-reminder schedule creation failed — continuing without schedule", + err instanceof Error ? err : new Error(String(err)), + ); + } const sessionId = await deps.createSession({ userId: created.userId, emailVerified: true }); res.cookie(SESSION_COOKIE_NAME, sessionId, sessionCookieOptions); diff --git a/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts b/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts index 2b2afe454..877e726bd 100644 --- a/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/google-auth.route.test.ts @@ -353,7 +353,7 @@ describe("Google auth routes", () => { clientSecret: "test-google-client-secret", }, }); - const { auth, subscriptionProviders, conversions } = harness; + const { auth, subscriptionProviders, conversions, trialScheduler } = harness; for (let i = 0; i < TEST_FOUNDING_MEMBER_LIMIT; i++) { await auth.createUser({ email: `seed${i}@test.com`, password: "password123" }); } @@ -378,6 +378,13 @@ describe("Google auth routes", () => { expect(trialMs).toBeGreaterThan(13 * 86_400_000); expect(trialMs).toBeLessThan(15 * 86_400_000); + // The pre-expiry reminder schedule fires two days before trialEndsAt. + const reminderFiresAt = trialScheduler.getTrialReminderSchedule(lookup.userId); + assert(reminderFiresAt, "Google trial signup must create a trial-reminder schedule"); + expect(new Date(reminderFiresAt).getTime()).toBe( + new Date(subRow.trialEndsAt).getTime() - 2 * 86_400_000, + ); + const conversionEvent = conversions.events.find((e) => e.method === "google" && e.tier === "trial"); assert(conversionEvent, "Google trial signup must emit a user_created conversion event with tier=trial"); }, 30000); diff --git a/projects/hutch/src/runtime/web/auth/trial-reminder-email.template.html b/projects/hutch/src/runtime/web/auth/trial-reminder-email.template.html new file mode 100644 index 000000000..dd5e7124f --- /dev/null +++ b/projects/hutch/src/runtime/web/auth/trial-reminder-email.template.html @@ -0,0 +1,38 @@ + + + + + + Your Readplace trial ends soon + + + + + + +
+ + + + + + + +
+ Fayner Brack +
+ {{#each paragraphs}} +

{{{this}}}

+ {{/each}} + + + + +
+ Keep using Readplace +
+

{{{signoff}}}

+
+
+ + diff --git a/projects/hutch/src/runtime/web/auth/trial-reminder-email.test.ts b/projects/hutch/src/runtime/web/auth/trial-reminder-email.test.ts new file mode 100644 index 000000000..f2c926fd9 --- /dev/null +++ b/projects/hutch/src/runtime/web/auth/trial-reminder-email.test.ts @@ -0,0 +1,98 @@ +import { + TrialReminderEmail, + TRIAL_REMINDER_EMAIL_SUBJECT, +} from "./trial-reminder-email"; + +const CTA_URL = + "https://readplace.com/account?utm_source=trial-reminder&utm_medium=email&utm_campaign=trial-preexpiry"; + +const baseParams = { + founderAvatarUrl: "https://readplace.com/fayner-brack.jpg", + savedArticlesCount: 9, + ctaUrl: CTA_URL, +}; + +describe("TrialReminderEmail", () => { + describe("subject", () => { + it("names the two-day lead time and carries no exclamation marks", () => { + expect(TRIAL_REMINDER_EMAIL_SUBJECT).toBe( + "your Readplace trial ends in 2 days", + ); + expect(TRIAL_REMINDER_EMAIL_SUBJECT).not.toContain("!"); + }); + }); + + describe("text/plain body", () => { + it("contains no exclamation marks and is first-person", () => { + const text = TrialReminderEmail(baseParams).to("text/plain"); + expect(text).not.toContain("!"); + expect(text).toMatch(/\bI\b/); + expect(text).not.toMatch(/\bwe\b/i); + }); + + it("includes the subscribe CTA URL with the utm attribution", () => { + const text = TrialReminderEmail(baseParams).to("text/plain"); + expect(text).toContain(`Subscribe: ${CTA_URL}`); + expect(text).toContain("utm_source=trial-reminder"); + }); + + it("signs off with '— Fayner' and nothing after it", () => { + const text = TrialReminderEmail(baseParams).to("text/plain"); + expect(text.trimEnd().endsWith("— Fayner")).toBe(true); + }); + + it("mentions the saved-articles reassurance when count > 0", () => { + const text = TrialReminderEmail({ ...baseParams, savedArticlesCount: 9 }).to( + "text/plain", + ); + expect(text).toContain("the 9 articles you've saved stay readable either way"); + }); + + it("singularises to '1 article' when the user saved one", () => { + const text = TrialReminderEmail({ ...baseParams, savedArticlesCount: 1 }).to( + "text/plain", + ); + expect(text).toContain("the 1 article you've saved stay readable either way"); + }); + + it("omits the saved-articles clause entirely when count is zero", () => { + const text = TrialReminderEmail({ ...baseParams, savedArticlesCount: 0 }).to( + "text/plain", + ); + expect(text).not.toContain("stay readable either way"); + expect(text).toContain("your account goes read-only."); + }); + }); + + describe("text/html body", () => { + it("renders the founder avatar with the absolute URL", () => { + const html = TrialReminderEmail(baseParams).to("text/html"); + expect(html).toContain('src="https://readplace.com/fayner-brack.jpg"'); + expect(html).toContain('alt="Fayner Brack"'); + }); + + it("renders the subscribe CTA button pointing at the ctaUrl", () => { + const html = TrialReminderEmail(baseParams).to("text/html"); + // Handlebars entity-escapes '=' (→ =) and '&' (→ &) inside the + // href; the recipient's client decodes them back to the real URL. + expect(html).toContain("readplace.com/account?utm_source"); + expect(html).toContain("trial-reminder"); + expect(html).toContain("trial-preexpiry"); + expect(html).toContain(">Keep using Readplace"); + }); + + it("produces a complete HTML document with the trial-ends title", () => { + const html = TrialReminderEmail(baseParams).to("text/html"); + expect(html).toContain(""); + expect(html).toContain(""); + expect(html).toContain("Your Readplace trial ends soon"); + }); + + it("omits the saved-articles clause entirely when count is zero", () => { + const html = TrialReminderEmail({ ...baseParams, savedArticlesCount: 0 }).to( + "text/html", + ); + expect(html).not.toContain("stay readable either way"); + }); + }); +}); diff --git a/projects/hutch/src/runtime/web/auth/trial-reminder-email.ts b/projects/hutch/src/runtime/web/auth/trial-reminder-email.ts new file mode 100644 index 000000000..f51768412 --- /dev/null +++ b/projects/hutch/src/runtime/web/auth/trial-reminder-email.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { EMAIL_COLORS } from "../email-colors"; +import { render } from "@packages/web-shell"; +import { TRIAL_REMINDER_LEAD_DAYS } from "../../domain/stripe/stripe-trial-config"; + +const TEMPLATE = readFileSync( + join(__dirname, "trial-reminder-email.template.html"), + "utf-8", +); + +export const TRIAL_REMINDER_EMAIL_SUBJECT = `your Readplace trial ends in ${TRIAL_REMINDER_LEAD_DAYS} days`; + +const SIGNOFF = "— Fayner"; + +interface TrialReminderEmailParams { + founderAvatarUrl: string; + savedArticlesCount: number; + ctaUrl: string; +} + +interface TrialReminderEmailComponent { + to: (mediaType: "text/html" | "text/plain") => string; +} + +/** Returns the reassurance clause about already-saved articles that follows + * "your account goes read-only" in the first paragraph. Returns an empty string + * when the user saved zero articles so the sentence never fabricates usage. */ +function usageClause(count: number): string { + if (count === 0) return ""; + const noun = count === 1 ? "article" : "articles"; + return ` the ${count} ${noun} you've saved stay readable either way`; +} + +function bodyParagraphs(clause: string): string[] { + return [ + `Your trial of Readplace ends in ${TRIAL_REMINDER_LEAD_DAYS} days. I don't ask for a card up front, so nothing gets charged — when the trial ends your account goes read-only${clause ? `, but${clause}.` : "."}`, + "If Readplace has earned a place in how you read, you can subscribe from your account page — it takes about a minute.", + "If it hasn't, no action needed. Either way, I'd genuinely like to know what would make it worth keeping: just reply. I respond to all replies personally.", + ]; +} + +export function TrialReminderEmail( + params: TrialReminderEmailParams, +): TrialReminderEmailComponent { + const paragraphs = bodyParagraphs(usageClause(params.savedArticlesCount)); + return { + to(mediaType) { + if (mediaType === "text/html") { + return render(TEMPLATE, { + founderAvatarUrl: params.founderAvatarUrl, + paragraphs, + ctaUrl: params.ctaUrl, + signoff: SIGNOFF, + colors: EMAIL_COLORS, + }); + } + + return [...paragraphs, `Subscribe: ${params.ctaUrl}`, SIGNOFF].join("\n\n"); + }, + }; +} diff --git a/projects/hutch/src/runtime/web/pages/account/account.page.ts b/projects/hutch/src/runtime/web/pages/account/account.page.ts index e238cb120..d8bdb5847 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.page.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.page.ts @@ -30,9 +30,11 @@ import { } from "@packages/provider-contracts/payment-methods"; import type { CreateTrialEndSchedule, + CreateTrialReminderSchedule, DeleteDeferredCancellationSchedule, } from "@packages/provider-contracts/trial-scheduler"; import type { StorePendingSignup } from "@packages/provider-contracts/pending-signup"; +import { trialReminderFiresAt } from "../../../domain/stripe/stripe-trial-config"; import { Base } from "../../base.component"; import type { BuildBannerState } from "../../banner-state"; import { HxRedirectPage } from "../../hx-redirect-page"; @@ -73,6 +75,7 @@ interface AccountDependencies { setPrimaryCard: SetPrimaryCard; stripePublishableKey: string | undefined; createTrialEndSchedule: CreateTrialEndSchedule; + createTrialReminderSchedule: CreateTrialReminderSchedule; deleteDeferredCancellationSchedule: DeleteDeferredCancellationSchedule; storePendingSignup: StorePendingSignup; stripePriceId: string; @@ -357,6 +360,10 @@ export function initAccountRoutes(deps: AccountDependencies): Router { "trial pending_cancellation row must have trialEndsAt", ); await deps.createTrialEndSchedule({ userId, firesAt: row.trialEndsAt }); + const reminderFiresAt = trialReminderFiresAt(row.trialEndsAt); + if (Date.parse(reminderFiresAt) > deps.now().getTime()) { + await deps.createTrialReminderSchedule({ userId, firesAt: reminderFiresAt }); + } await deps.upsertTrialingSubscription({ userId, trialEndsAt: row.trialEndsAt }); await deps.publishSubscriptionReactivated({ userId }); res.redirect(303, buildAccountUrl()); diff --git a/projects/hutch/src/runtime/web/pages/account/account.route.test.ts b/projects/hutch/src/runtime/web/pages/account/account.route.test.ts index 80b277946..4460abb1c 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.route.test.ts @@ -751,6 +751,38 @@ describe("POST /account/reactivate", () => { expect(row.subscriptionId).toBeUndefined(); // SubscriptionReactivated emitted without subscriptionId. expect(reactivatedEvents).toEqual([{ userId }]); + // Trial-reminder schedule recreated at trialEndsAt minus 2 days (>2d remain). + const reminderFiresAt = trialScheduler.getTrialReminderSchedule(userId); + assert(reminderFiresAt, "reminder schedule must be recreated when >2d remain"); + expect(new Date(reminderFiresAt).getTime()).toBe( + new Date(trialEndsAt).getTime() - 2 * ONE_DAY_MS, + ); + }); + + it("trial reactivate — does NOT recreate the reminder when trialEndsAt is under two days away", async () => { + const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN); + fixture.events.publishSubscriptionReactivated = async () => {}; + const harness = useApp(fixture); + const { subscriptionProviders, trialScheduler } = harness; + const { agent, userId } = await loginUser(harness, "reactivate-trial-soon@example.com"); + const trialEndsAt = new Date(Date.now() + ONE_DAY_MS).toISOString(); + await subscriptionProviders.upsertTrialing({ userId, trialEndsAt }); + await subscriptionProviders.markPendingCancellation({ + userId, + cancellationEffectiveAt: trialEndsAt, + }); + + const response = await agent.post("/account/reactivate"); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/account"); + // Trial-end schedule still recreated, but the reminder would fire in the + // past (EventBridge rejects at() in the past) so it is skipped. + expect(trialScheduler.getSchedule(userId)).toBe(trialEndsAt); + expect(trialScheduler.getTrialReminderSchedule(userId)).toBeUndefined(); + const row = await subscriptionProviders.findByUserId(userId); + assert(row, "row must exist"); + expect(row.status).toBe("trialing"); }); it("noop for an already-active user (double-click race or stale form) — 303 /account, no Stripe call, no event, no schedule mutation", async () => { diff --git a/src/packages/hutch-infra-components/src/events.ts b/src/packages/hutch-infra-components/src/events.ts index 577d9e706..c8a56ad9d 100644 --- a/src/packages/hutch-infra-components/src/events.ts +++ b/src/packages/hutch-infra-components/src/events.ts @@ -589,18 +589,23 @@ export type SubscriptionChargeFailedDetail = z.infer< typeof SubscriptionChargeFailedEvent.detailSchema >; -/** Delayed trial-feedback research email request. Published by the EventBridge - * Scheduler one-shot created by `schedule-trial-feedback-email` when a - * `SubscriptionCancelledEvent` with `reason='user_initiated_trial'` arrives. - * Consumed by `send-trial-feedback-email` which re-reads the row to confirm - * the user is still cancelled (reactivation guard) before sending Fayner's - * "what was missing?" research email. */ +/** Trial-lifecycle email request carrying two email kinds that share one + * Lambda (`send-trial-feedback-email`). `kind` absent or `'feedback'`: the + * post-cancellation research email, scheduled by `schedule-trial-feedback-email` + * when a `SubscriptionCancelledEvent` with `reason='user_initiated_trial'` + * arrives; the handler re-reads the row to confirm the user is still cancelled + * (reactivation guard) before sending Fayner's "what was missing?" email. + * `kind='reminder'`: the pre-expiry trial reminder, a one-shot created at trial + * signup that fires at `trialEndsAt - 2d`; the handler re-checks the user is + * still trialing before sending. Absent `kind` means feedback for backward + * compatibility with in-flight schedules whose Input is `{userId}` only. */ export const SendTrialFeedbackEmailCommand = defineEvent({ name: "send-trial-feedback-email-command", source: "hutch.subscriptions", detailType: "SendTrialFeedbackEmailCommand", detailSchema: z.object({ userId: z.string(), + kind: z.enum(["feedback", "reminder"]).optional(), }), }); export type SendTrialFeedbackEmailDetail = z.infer< diff --git a/src/packages/provider-contracts/src/subscription-providers.ts b/src/packages/provider-contracts/src/subscription-providers.ts index 72c147551..b4d555ffb 100644 --- a/src/packages/provider-contracts/src/subscription-providers.ts +++ b/src/packages/provider-contracts/src/subscription-providers.ts @@ -15,6 +15,7 @@ export interface SubscriptionRecord { trialEndsAt?: string; cancellationEffectiveAt?: string; trialFeedbackEmailSentAt?: string; + trialReminderEmailSentAt?: string; createdAt: string; updatedAt: string; } @@ -53,3 +54,8 @@ export type MarkTrialFeedbackEmailSent = (input: { userId: UserId; sentAt: string; }) => Promise; + +export type MarkTrialReminderEmailSent = (input: { + userId: UserId; + sentAt: string; +}) => Promise; diff --git a/src/packages/provider-contracts/src/trial-scheduler.ts b/src/packages/provider-contracts/src/trial-scheduler.ts index c7d99eabd..b4c88ab2f 100644 --- a/src/packages/provider-contracts/src/trial-scheduler.ts +++ b/src/packages/provider-contracts/src/trial-scheduler.ts @@ -26,3 +26,12 @@ export type CreateTrialFeedbackEmailSchedule = (input: { export type DeleteTrialFeedbackEmailSchedule = (input: { userId: UserId; }) => Promise; + +export type CreateTrialReminderSchedule = (input: { + userId: UserId; + firesAt: string; +}) => Promise; + +export type DeleteTrialReminderSchedule = (input: { + userId: UserId; +}) => Promise; diff --git a/src/packages/subscription-access/src/subscription-provider-row.test.ts b/src/packages/subscription-access/src/subscription-provider-row.test.ts index 57adcf7c9..6033b1944 100644 --- a/src/packages/subscription-access/src/subscription-provider-row.test.ts +++ b/src/packages/subscription-access/src/subscription-provider-row.test.ts @@ -15,6 +15,7 @@ describe("toRecord", () => { trialEndsAt: "2026-06-05T00:00:00.000Z", cancellationEffectiveAt: "2026-06-22T00:00:00.000Z", trialFeedbackEmailSentAt: "2026-06-04T00:00:00.000Z", + trialReminderEmailSentAt: "2026-06-03T00:00:00.000Z", createdAt: "2026-05-20T10:00:00.000Z", updatedAt: "2026-05-22T10:00:00.000Z", }); @@ -28,6 +29,7 @@ describe("toRecord", () => { trialEndsAt: "2026-06-05T00:00:00.000Z", cancellationEffectiveAt: "2026-06-22T00:00:00.000Z", trialFeedbackEmailSentAt: "2026-06-04T00:00:00.000Z", + trialReminderEmailSentAt: "2026-06-03T00:00:00.000Z", createdAt: "2026-05-20T10:00:00.000Z", updatedAt: "2026-05-22T10:00:00.000Z", }); @@ -56,6 +58,7 @@ describe("toRecord", () => { assert.equal("subscriptionId" in record, false); assert.equal("cancellationEffectiveAt" in record, false); assert.equal("trialFeedbackEmailSentAt" in record, false); + assert.equal("trialReminderEmailSentAt" in record, false); }); it("maps an active row with Stripe ids and no trial date", () => { diff --git a/src/packages/subscription-access/src/subscription-provider-row.ts b/src/packages/subscription-access/src/subscription-provider-row.ts index 453e2e3ce..6be6770fc 100644 --- a/src/packages/subscription-access/src/subscription-provider-row.ts +++ b/src/packages/subscription-access/src/subscription-provider-row.ts @@ -12,6 +12,7 @@ export const SubscriptionProviderRow = z.object({ trialEndsAt: dynamoField(z.string()), cancellationEffectiveAt: dynamoField(z.string()), trialFeedbackEmailSentAt: dynamoField(z.string()), + trialReminderEmailSentAt: dynamoField(z.string()), createdAt: z.string(), updatedAt: z.string(), }); @@ -30,6 +31,9 @@ export function toRecord(row: z.infer): Subscrip ...(row.trialFeedbackEmailSentAt !== undefined ? { trialFeedbackEmailSentAt: row.trialFeedbackEmailSentAt } : {}), + ...(row.trialReminderEmailSentAt !== undefined + ? { trialReminderEmailSentAt: row.trialReminderEmailSentAt } + : {}), createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/src/packages/test-fixtures/src/providers/subscription-providers/in-memory-subscription-providers.test.ts b/src/packages/test-fixtures/src/providers/subscription-providers/in-memory-subscription-providers.test.ts index dad15e8f2..3f9f2fb2d 100644 --- a/src/packages/test-fixtures/src/providers/subscription-providers/in-memory-subscription-providers.test.ts +++ b/src/packages/test-fixtures/src/providers/subscription-providers/in-memory-subscription-providers.test.ts @@ -172,6 +172,28 @@ describe("initInMemorySubscriptionProviders", () => { ).rejects.toThrow(/No subscription row/); }); + it("markTrialReminderEmailSent records the sentAt timestamp on the row", async () => { + const clock = { iso: "2026-05-22T00:00:00.000Z" }; + const subs = initInMemorySubscriptionProviders({ now: () => new Date(clock.iso) }); + await subs.upsertTrialing({ userId, trialEndsAt: "2026-06-05T00:00:00.000Z" }); + + clock.iso = "2026-06-03T00:00:00.000Z"; + await subs.markTrialReminderEmailSent({ userId, sentAt: "2026-06-03T00:00:00.000Z" }); + + const row = await subs.findByUserId(userId); + assert(row, "row must exist"); + expect(row.trialReminderEmailSentAt).toBe("2026-06-03T00:00:00.000Z"); + expect(row.status).toBe("trialing"); + expect(row.updatedAt).toBe("2026-06-03T00:00:00.000Z"); + }); + + it("throws when markTrialReminderEmailSent is called for an unknown user", async () => { + const subs = initInMemorySubscriptionProviders({ now: fixedNow("2026-05-22T00:00:00.000Z") }); + await expect( + subs.markTrialReminderEmailSent({ userId, sentAt: "2026-06-03T00:00:00.000Z" }), + ).rejects.toThrow(/No subscription row/); + }); + it("seedRow lets tests inject hypothetical row shapes (e.g. trialing with customerId)", async () => { const subs = initInMemorySubscriptionProviders({ now: fixedNow("2026-05-22T00:00:00.000Z") }); diff --git a/src/packages/test-fixtures/src/providers/subscription-providers/in-memory-subscription-providers.ts b/src/packages/test-fixtures/src/providers/subscription-providers/in-memory-subscription-providers.ts index 47c990d3d..58f483483 100644 --- a/src/packages/test-fixtures/src/providers/subscription-providers/in-memory-subscription-providers.ts +++ b/src/packages/test-fixtures/src/providers/subscription-providers/in-memory-subscription-providers.ts @@ -7,6 +7,7 @@ import type { MarkSubscriptionCancelledByUserId, MarkSubscriptionPendingCancellation, MarkTrialFeedbackEmailSent, + MarkTrialReminderEmailSent, SubscriptionRecord, UpsertActiveSubscription, UpsertTrialingSubscription, @@ -23,6 +24,7 @@ export function initInMemorySubscriptionProviders(opts: { markCancelledByUserId: MarkSubscriptionCancelledByUserId; markActive: MarkSubscriptionActive; markTrialFeedbackEmailSent: MarkTrialFeedbackEmailSent; + markTrialReminderEmailSent: MarkTrialReminderEmailSent; seedRow: (row: SubscriptionRecord) => void; } { const rows = new Map(); @@ -109,6 +111,19 @@ export function initInMemorySubscriptionProviders(opts: { }); }; + const markTrialReminderEmailSent: MarkTrialReminderEmailSent = async ({ + userId, + sentAt, + }) => { + const existing = rows.get(userId); + assert(existing, `No subscription row for user ${userId}`); + rows.set(userId, { + ...existing, + trialReminderEmailSentAt: sentAt, + updatedAt: opts.now().toISOString(), + }); + }; + /** Test-only escape hatch for seeding hypothetical row shapes (e.g. a * trialing row that also has a customerId — production paths never write * this combination, but the trial-end charge handler must still cover the @@ -126,6 +141,7 @@ export function initInMemorySubscriptionProviders(opts: { markCancelledByUserId, markActive, markTrialFeedbackEmailSent, + markTrialReminderEmailSent, seedRow, }; } diff --git a/src/packages/test-fixtures/src/providers/subscription-providers/subscription-providers.types.ts b/src/packages/test-fixtures/src/providers/subscription-providers/subscription-providers.types.ts index 53d53fd06..195d1ba81 100644 --- a/src/packages/test-fixtures/src/providers/subscription-providers/subscription-providers.types.ts +++ b/src/packages/test-fixtures/src/providers/subscription-providers/subscription-providers.types.ts @@ -5,6 +5,7 @@ export type { MarkSubscriptionCancelledByUserId, MarkSubscriptionPendingCancellation, MarkTrialFeedbackEmailSent, + MarkTrialReminderEmailSent, SubscriptionRecord, SubscriptionStatus, UpsertActiveSubscription, diff --git a/src/packages/test-fixtures/src/providers/trial-scheduler/in-memory-trial-scheduler.test.ts b/src/packages/test-fixtures/src/providers/trial-scheduler/in-memory-trial-scheduler.test.ts index 82f50c9c8..f85f9905a 100644 --- a/src/packages/test-fixtures/src/providers/trial-scheduler/in-memory-trial-scheduler.test.ts +++ b/src/packages/test-fixtures/src/providers/trial-scheduler/in-memory-trial-scheduler.test.ts @@ -179,4 +179,62 @@ describe("initInMemoryTrialScheduler", () => { ); assert.equal(scheduler.getTrialFeedbackEmailSchedule(userId), undefined); }); + + it("records create + delete trial-reminder calls independently of the other schedule kinds", async () => { + const userIdA = UserIdSchema.parse("6".repeat(32)); + const userIdB = UserIdSchema.parse("7".repeat(32)); + const scheduler = initInMemoryTrialScheduler(); + + await scheduler.createTrialReminderSchedule({ + userId: userIdA, + firesAt: "2026-07-17T00:00:00.000Z", + }); + await scheduler.createTrialReminderSchedule({ + userId: userIdB, + firesAt: "2026-07-18T00:00:00.000Z", + }); + + assert.equal( + scheduler.getTrialReminderSchedule(userIdA), + "2026-07-17T00:00:00.000Z", + ); + assert.deepEqual(scheduler.allTrialReminderSchedules(), [ + { userId: userIdA, firesAt: "2026-07-17T00:00:00.000Z" }, + { userId: userIdB, firesAt: "2026-07-18T00:00:00.000Z" }, + ]); + // The other schedule kinds remain untouched. + assert.deepEqual(scheduler.allSchedules(), []); + assert.deepEqual(scheduler.allDeferredCancellationSchedules(), []); + assert.deepEqual(scheduler.allTrialFeedbackEmailSchedules(), []); + + await scheduler.deleteTrialReminderSchedule({ userId: userIdA }); + + assert.equal(scheduler.getTrialReminderSchedule(userIdA), undefined); + assert.deepEqual(scheduler.trialReminderDeleteCalls(), [userIdA]); + }); + + it("deleteTrialReminderSchedule is idempotent on a missing schedule", async () => { + const userId = UserIdSchema.parse("0".repeat(32)); + const scheduler = initInMemoryTrialScheduler(); + + await assert.doesNotReject(scheduler.deleteTrialReminderSchedule({ userId })); + assert.deepEqual(scheduler.trialReminderDeleteCalls(), [userId]); + }); + + it("createTrialReminderSchedule throws when configured to fail", async () => { + const userId = UserIdSchema.parse("d".repeat(32)); + const scheduler = initInMemoryTrialScheduler({ + createTrialReminderFails: true, + }); + + await assert.rejects( + () => + scheduler.createTrialReminderSchedule({ + userId, + firesAt: "2026-07-17T00:00:00.000Z", + }), + /In-memory trial-reminder create failure/, + ); + assert.equal(scheduler.getTrialReminderSchedule(userId), undefined); + }); }); diff --git a/src/packages/test-fixtures/src/providers/trial-scheduler/in-memory-trial-scheduler.ts b/src/packages/test-fixtures/src/providers/trial-scheduler/in-memory-trial-scheduler.ts index 8ebebcd4f..2d857d3b6 100644 --- a/src/packages/test-fixtures/src/providers/trial-scheduler/in-memory-trial-scheduler.ts +++ b/src/packages/test-fixtures/src/providers/trial-scheduler/in-memory-trial-scheduler.ts @@ -3,15 +3,18 @@ import type { CreateDeferredCancellationSchedule, CreateTrialEndSchedule, CreateTrialFeedbackEmailSchedule, + CreateTrialReminderSchedule, DeleteDeferredCancellationSchedule, DeleteTrialEndSchedule, DeleteTrialFeedbackEmailSchedule, + DeleteTrialReminderSchedule, } from "@packages/provider-contracts/trial-scheduler"; export function initInMemoryTrialScheduler(opts?: { createFails?: boolean; createDeferredCancellationFails?: boolean; createTrialFeedbackEmailFails?: boolean; + createTrialReminderFails?: boolean; }): { createTrialEndSchedule: CreateTrialEndSchedule; deleteTrialEndSchedule: DeleteTrialEndSchedule; @@ -19,6 +22,8 @@ export function initInMemoryTrialScheduler(opts?: { deleteDeferredCancellationSchedule: DeleteDeferredCancellationSchedule; createTrialFeedbackEmailSchedule: CreateTrialFeedbackEmailSchedule; deleteTrialFeedbackEmailSchedule: DeleteTrialFeedbackEmailSchedule; + createTrialReminderSchedule: CreateTrialReminderSchedule; + deleteTrialReminderSchedule: DeleteTrialReminderSchedule; getSchedule: (userId: UserId) => string | undefined; allSchedules: () => readonly { userId: UserId; firesAt: string }[]; deleteCalls: () => readonly UserId[]; @@ -28,6 +33,9 @@ export function initInMemoryTrialScheduler(opts?: { getTrialFeedbackEmailSchedule: (userId: UserId) => string | undefined; allTrialFeedbackEmailSchedules: () => readonly { userId: UserId; firesAt: string }[]; trialFeedbackEmailDeleteCalls: () => readonly UserId[]; + getTrialReminderSchedule: (userId: UserId) => string | undefined; + allTrialReminderSchedules: () => readonly { userId: UserId; firesAt: string }[]; + trialReminderDeleteCalls: () => readonly UserId[]; } { const trialEndSchedules = new Map(); const trialEndDeletes: UserId[] = []; @@ -35,6 +43,8 @@ export function initInMemoryTrialScheduler(opts?: { const deferredCancellationDeletes: UserId[] = []; const trialFeedbackEmailSchedules = new Map(); const trialFeedbackEmailDeletes: UserId[] = []; + const trialReminderSchedules = new Map(); + const trialReminderDeletes: UserId[] = []; const createTrialEndSchedule: CreateTrialEndSchedule = async ({ userId, firesAt }) => { if (opts?.createFails) { @@ -82,6 +92,21 @@ export function initInMemoryTrialScheduler(opts?: { trialFeedbackEmailSchedules.delete(userId); }; + const createTrialReminderSchedule: CreateTrialReminderSchedule = async ({ + userId, + firesAt, + }) => { + if (opts?.createTrialReminderFails) { + throw new Error("In-memory trial-reminder create failure"); + } + trialReminderSchedules.set(userId, firesAt); + }; + + const deleteTrialReminderSchedule: DeleteTrialReminderSchedule = async ({ userId }) => { + trialReminderDeletes.push(userId); + trialReminderSchedules.delete(userId); + }; + return { createTrialEndSchedule, deleteTrialEndSchedule, @@ -89,6 +114,8 @@ export function initInMemoryTrialScheduler(opts?: { deleteDeferredCancellationSchedule, createTrialFeedbackEmailSchedule, deleteTrialFeedbackEmailSchedule, + createTrialReminderSchedule, + deleteTrialReminderSchedule, getSchedule: (userId) => trialEndSchedules.get(userId), allSchedules: () => Array.from(trialEndSchedules.entries()).map(([userId, firesAt]) => ({ userId, firesAt })), deleteCalls: () => [...trialEndDeletes], @@ -100,5 +127,9 @@ export function initInMemoryTrialScheduler(opts?: { allTrialFeedbackEmailSchedules: () => Array.from(trialFeedbackEmailSchedules.entries()).map(([userId, firesAt]) => ({ userId, firesAt })), trialFeedbackEmailDeleteCalls: () => [...trialFeedbackEmailDeletes], + getTrialReminderSchedule: (userId) => trialReminderSchedules.get(userId), + allTrialReminderSchedules: () => + Array.from(trialReminderSchedules.entries()).map(([userId, firesAt]) => ({ userId, firesAt })), + trialReminderDeleteCalls: () => [...trialReminderDeletes], }; } diff --git a/src/packages/web-test-harness/src/bundle.types.ts b/src/packages/web-test-harness/src/bundle.types.ts index 458ece90d..3b418d365 100644 --- a/src/packages/web-test-harness/src/bundle.types.ts +++ b/src/packages/web-test-harness/src/bundle.types.ts @@ -24,12 +24,14 @@ import type { CreateSession, CreateSubscriptionOnExistingCustomer, CreateTrialEndSchedule, + CreateTrialReminderSchedule, CreateUser, CreateUserWithPasswordHash, CreateVerificationToken, DeleteArticle, DeleteDeferredCancellationSchedule, DeleteTrialEndSchedule, + DeleteTrialReminderSchedule, DestroySession, DestroyUserSessions, EmailMessage, @@ -173,12 +175,17 @@ export interface TrialSchedulerBundle { deleteTrialEndSchedule: DeleteTrialEndSchedule; createDeferredCancellationSchedule: CreateDeferredCancellationSchedule; deleteDeferredCancellationSchedule: DeleteDeferredCancellationSchedule; + createTrialReminderSchedule: CreateTrialReminderSchedule; + deleteTrialReminderSchedule: DeleteTrialReminderSchedule; getSchedule: (userId: UserId) => string | undefined; allSchedules: () => readonly { userId: UserId; firesAt: string }[]; deleteCalls: () => readonly UserId[]; getDeferredCancellationSchedule: (userId: UserId) => string | undefined; allDeferredCancellationSchedules: () => readonly { userId: UserId; firesAt: string }[]; deferredCancellationDeleteCalls: () => readonly UserId[]; + getTrialReminderSchedule: (userId: UserId) => string | undefined; + allTrialReminderSchedules: () => readonly { userId: UserId; firesAt: string }[]; + trialReminderDeleteCalls: () => readonly UserId[]; } export interface PaymentMethodsBundle {