Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions projects/hutch/src/infra/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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],
},
Expand Down
1 change: 1 addition & 0 deletions projects/hutch/src/runtime/cancel-subscription.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand All @@ -31,6 +32,7 @@ interface HandlerDeps {
scheduleCancellationAtPeriodEnd: ScheduleCancellationAtPeriodEnd;
createDeferredCancellationSchedule: CreateDeferredCancellationSchedule;
deleteTrialEndSchedule: DeleteTrialEndSchedule;
deleteTrialReminderSchedule: DeleteTrialReminderSchedule;
publishSubscriptionCancellationScheduled: PublishSubscriptionCancellationScheduled;
publishSubscriptionCancelled: PublishSubscriptionCancelled;
logger: HutchLogger;
Expand Down Expand Up @@ -76,6 +78,10 @@ function buildBranches(deps: HandlerDeps): Record<SubscriptionStatus, CancelBran
// drives the final cancelled flip — no Stripe webhook fires for
// trial users (no Stripe subscription exists).
await deps.deleteTrialEndSchedule({ userId: row.userId });
// A cancelling trialist must not receive the pre-expiry subscribe
// nudge. The handler's status guard would noop anyway; deleting the
// schedule is defence-in-depth plus scheduler hygiene.
await deps.deleteTrialReminderSchedule({ userId: row.userId });
await deps.createDeferredCancellationSchedule({
userId: row.userId,
firesAt: addOneHour(row.trialEndsAt),
Expand All @@ -84,7 +90,7 @@ function buildBranches(deps: HandlerDeps): Record<SubscriptionStatus, CancelBran
userId: row.userId,
cancellationEffectiveAt: row.trialEndsAt,
});
deps.logger.info("[cancel-subscription] trialing → trial-end schedule deleted + deferred schedule + SubscriptionCancellationScheduled", {
deps.logger.info("[cancel-subscription] trialing → trial-end + trial-reminder schedules deleted + deferred schedule + SubscriptionCancellationScheduled", {
userId: row.userId,
cancellationEffectiveAt: row.trialEndsAt,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import assert from "node:assert/strict";
import { trialReminderFiresAt } from "./stripe-trial-config";

describe("trialReminderFiresAt", () => {
it("subtracts exactly two days from trialEndsAt", () => {
assert.equal(
trialReminderFiresAt("2026-07-19T10:00:00.000Z"),
"2026-07-17T10:00:00.000Z",
);
});
});
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
UpdateExpression?: string;
ConditionExpression?: string;
ExpressionAttributeValues?: Record<string, unknown>;
};
};
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(() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -26,6 +27,7 @@ export function initDynamoDbSubscriptionWrites(deps: {
markCancelledByUserId: MarkSubscriptionCancelledByUserId;
markActive: MarkSubscriptionActive;
markTrialFeedbackEmailSent: MarkTrialFeedbackEmailSent;
markTrialReminderEmailSent: MarkTrialReminderEmailSent;
} {
const table = defineDynamoTable({
client: deps.client,
Expand Down Expand Up @@ -126,12 +128,25 @@ 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,
markPendingCancellation,
markCancelledByUserId,
markActive,
markTrialFeedbackEmailSent,
markTrialReminderEmailSent,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -336,4 +336,109 @@ describe("initAwsTrialScheduler", () => {
);
});
});

describe("createTrialReminderSchedule", () => {
it("issues a CreateScheduleCommand with name=trial-reminder-<userId>, 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-<userId> + 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/,
);
});
});
});
Loading
Loading