diff --git a/backend/__tests__/services/pgRetentionService.test.js b/backend/__tests__/services/pgRetentionService.test.js index b723cc25..825017e2 100644 --- a/backend/__tests__/services/pgRetentionService.test.js +++ b/backend/__tests__/services/pgRetentionService.test.js @@ -10,12 +10,13 @@ jest.mock('node-cron', () => ({ schedule: jest.fn(() => ({ start: jest.fn(), sto const mockChain = (result) => ({ select: () => ({ lean: () => result }) }); const mockProUsers = { value: [] }; const mockProPods = { value: [] }; +const mockUserQuery = { value: null }; jest.mock('../../models/User', () => ({ - find: jest.fn(() => mockChain( + find: jest.fn((q) => { mockUserQuery.value = q; return mockChain( mockProUsers.value instanceof Error ? Promise.reject(mockProUsers.value) : Promise.resolve(mockProUsers.value), - )), + ); }), })); jest.mock('../../models/Pod', () => ({ find: jest.fn(() => mockChain(Promise.resolve(mockProPods.value))), @@ -188,6 +189,53 @@ describe('pgRetentionService.runMessageRetention', () => { * customer's messages on exactly the free-tier schedule, and the step-down * under storage pressure could take that to a single day. */ + /* + * Losing Pro stops the FEATURES at once, but deleting the history the same + * night means a single failed card payment is unrecoverable. The grace + * window is the win-back runway. + */ + describe('lapsed-Pro data grace', () => { + const runAndGetQuery = async () => { + mockSizeQueries([1, 1]); + Message.deleteOlderThan.mockResolvedValue({ deleted: 0 }); + await runMessageRetention(); + return mockUserQuery.value; + }; + + it('protects accounts still inside the grace window', async () => { + const q = await runAndGetQuery(); + const clause = q.$or.find((c) => c['billing.proEndedAt']); + expect(clause).toBeDefined(); + const cutoff = clause['billing.proEndedAt'].$gte; + const daysAgo = (Date.now() - cutoff.getTime()) / 86400000; + expect(daysAgo).toBeGreaterThan(29.9); + expect(daysAgo).toBeLessThan(30.1); + }); + + it('still protects currently-active Pro accounts', async () => { + const q = await runAndGetQuery(); + expect(q.$or).toContainEqual({ 'entitlements.pro': true }); + }); + + it('honours PRO_DATA_GRACE_DAYS', async () => { + process.env.PRO_DATA_GRACE_DAYS = '60'; + const q = await runAndGetQuery(); + const clause = q.$or.find((c) => c['billing.proEndedAt']); + const daysAgo = (Date.now() - clause['billing.proEndedAt'].$gte.getTime()) / 86400000; + expect(daysAgo).toBeGreaterThan(59.9); + }); + + // A malformed env must not collapse the window to zero and delete a + // lapsed customer's history tonight. + it('a bad PRO_DATA_GRACE_DAYS falls back to 30 rather than 0', async () => { + process.env.PRO_DATA_GRACE_DAYS = 'nonsense'; + const q = await runAndGetQuery(); + const clause = q.$or.find((c) => c['billing.proEndedAt']); + const daysAgo = (Date.now() - clause['billing.proEndedAt'].$gte.getTime()) / 86400000; + expect(daysAgo).toBeGreaterThan(29.9); + }); + }); + describe('Pro-protected pods', () => { it('passes the protected pod ids to the delete', async () => { mockProUsers.value = [{ _id: 'u-pro' }]; diff --git a/backend/__tests__/unit/services/billingService.test.js b/backend/__tests__/unit/services/billingService.test.js index 023a0b4c..6331b913 100644 --- a/backend/__tests__/unit/services/billingService.test.js +++ b/backend/__tests__/unit/services/billingService.test.js @@ -180,4 +180,46 @@ describe('billingService', () => { expect(res.outcome).toBe('ignored'); expect(save).not.toHaveBeenCalled(); }); + /* + * When Pro ends the features stop at once, but the DATA gets a grace window + * (pgRetentionService.PRO_DATA_GRACE_DAYS). `proEndedAt` is that clock. + */ + describe('proEndedAt — the data grace clock', () => { + test('is stamped when pro goes true -> false', async () => { + users.byCustomer = mkUser({ entitlements: { pro: true } }); + await handleEvent(evt('customer.subscription.updated', { customer: 'cus_1', id: 'sub_1', status: 'canceled' })); + expect(users.byCustomer.billing.proEndedAt).toBeInstanceOf(Date); + }); + + // Stripe retries for three days and dunning fires repeatedly. If every + // past_due re-stamped, the deadline would walk forward forever and the + // history would never be reclaimed. + test('is NOT re-stamped while already lapsed', async () => { + const earlier = new Date('2026-01-01T00:00:00Z'); + users.byCustomer = mkUser({ entitlements: { pro: false }, billing: { customerId: 'cus_1', proEndedAt: earlier } }); + await handleEvent(evt('customer.subscription.updated', { customer: 'cus_1', id: 'sub_1', status: 'past_due' })); + expect(users.byCustomer.billing.proEndedAt).toEqual(earlier); + }); + + test('is cleared on re-subscribe so a later lapse gets a fresh window', async () => { + users.byCustomer = mkUser({ entitlements: { pro: false }, billing: { customerId: 'cus_1', proEndedAt: new Date('2026-01-01T00:00:00Z') } }); + await handleEvent(evt('customer.subscription.updated', { customer: 'cus_1', id: 'sub_1', status: 'active' })); + expect(users.byCustomer.entitlements.pro).toBe(true); + expect(users.byCustomer.billing.proEndedAt).toBeUndefined(); + }); + + test('a never-pro user is not stamped by an unrelated event', async () => { + users.byCustomer = mkUser({ entitlements: { pro: false } }); + await handleEvent(evt('customer.subscription.updated', { customer: 'cus_1', id: 'sub_1', status: 'incomplete' })); + expect(users.byCustomer.billing.proEndedAt).toBeUndefined(); + }); + + // The feature/data split: losing Pro must still lock the paid features + // immediately. The grace covers bytes, not entitlements. + test('the entitlement still goes false immediately', async () => { + users.byCustomer = mkUser({ entitlements: { pro: true } }); + await handleEvent(evt('customer.subscription.deleted', { customer: 'cus_1', id: 'sub_1', status: 'canceled' })); + expect(users.byCustomer.entitlements.pro).toBe(false); + }); + }); }); diff --git a/backend/models/User.ts b/backend/models/User.ts index 991c4d15..75fec4cf 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -98,6 +98,10 @@ export interface IUser extends Document { subscriptionStatus?: string; currentPeriodEnd?: Date; cancelAtPeriodEnd?: boolean; + // When Pro last ended. Features stop at once; the retention cron keeps + // this account's history for PRO_DATA_GRACE_DAYS past this instant, so a + // failed card payment does not destroy history overnight. + proEndedAt?: Date; }; apiToken?: string; apiTokenCreatedAt?: Date; @@ -210,6 +214,8 @@ const userSchema = new Schema({ subscriptionStatus: { type: String }, currentPeriodEnd: { type: Date }, cancelAtPeriodEnd: { type: Boolean, default: false }, + // Indexed: the nightly retention run queries lapsed-but-in-grace accounts. + proEndedAt: { type: Date, index: true, sparse: true }, }, // `select: false` so a live bearer credential can never ride along on an // incidental `findById().select('-password')` or a populate(). Queries that diff --git a/backend/node_modules b/backend/node_modules deleted file mode 120000 index e8b72f38..00000000 --- a/backend/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/xcjsam/Documents/workspace/commonly/.claude/worktrees/sprint-2026-05-24-installable-projection/backend/node_modules \ No newline at end of file diff --git a/backend/services/billingService.ts b/backend/services/billingService.ts index 04eca88a..6cc9ba18 100644 --- a/backend/services/billingService.ts +++ b/backend/services/billingService.ts @@ -83,6 +83,28 @@ export const applySubscriptionState = async ({ if (!user) return { outcome: 'unmapped' }; const pro = statusGrantsPro(status); + const wasPro = user.entitlements?.pro === true; + + /* + * When Pro ends, the FEATURES stop immediately but the DATA gets a grace + * window — see pgRetentionService.PRO_DATA_GRACE_DAYS. + * + * `proEndedAt` is the clock for that. Without it, a single failed card + * payment flips status to `past_due`, and the retention cron that night + * permanently deletes everything older than 30 days — before the customer + * has even seen the dunning email, and with no way back if they fix the card + * the next morning. + * + * Stamped only on a true -> false EDGE, so repeated `past_due` webhooks (and + * Stripe's three-day retries) do not keep pushing the deadline outward. + * Cleared on re-subscribe so a later cancellation gets a fresh window rather + * than an expired one. + */ + const proEndedAt = (() => { + if (pro) return undefined; + if (wasPro) return new Date(); + return user.billing?.proEndedAt; + })(); user.entitlements = { ...(user.entitlements || {}), pro }; user.billing = { @@ -92,6 +114,7 @@ export const applySubscriptionState = async ({ subscriptionStatus: status || undefined, currentPeriodEnd: currentPeriodEnd ? new Date(currentPeriodEnd * 1000) : user.billing?.currentPeriodEnd, cancelAtPeriodEnd: Boolean(cancelAtPeriodEnd), + proEndedAt, }; await user.save(); diff --git a/backend/services/pgRetentionService.ts b/backend/services/pgRetentionService.ts index e6fca9e1..01acf349 100644 --- a/backend/services/pgRetentionService.ts +++ b/backend/services/pgRetentionService.ts @@ -35,6 +35,11 @@ const DEFAULT_CAPACITY_BYTES = 8 * 1024 * 1024 * 1024; // Cloud SQL tier: 8 GiB const DEFAULT_USAGE_TARGET_PCT = 75; const DEFAULT_STEP_DAYS = 1; const FLOOR_DAYS = 1; +// How long a lapsed Pro account keeps its history after the entitlement ends. +// Deliberately NOT tied to PG_MESSAGE_RETENTION_DAYS: that window is the free +// tier's product, this one is a win-back runway, and squeezing free-tier +// storage should never shorten it. +const DEFAULT_PRO_GRACE_DAYS = 30; interface CronJob { start(): void; @@ -64,6 +69,14 @@ function resolveUsageTargetPct(): number { return pct; } +function resolveGraceDays(): number { + const days = resolvePositiveNumber(process.env.PRO_DATA_GRACE_DAYS, DEFAULT_PRO_GRACE_DAYS); + // A bad env value must not silently shorten the window to zero and delete a + // lapsed customer's history — fall back to the documented default. + if (!Number.isFinite(days) || days <= 0) return DEFAULT_PRO_GRACE_DAYS; + return days; +} + function resolveStepDays(): number { const step = resolvePositiveNumber(process.env.PG_RETENTION_STEP_DAYS, DEFAULT_STEP_DAYS); if (!Number.isFinite(step) || step <= 0) return DEFAULT_STEP_DAYS; @@ -111,7 +124,24 @@ async function vacuumMessages(): Promise { * night of cleanup costs nothing. */ export async function resolveProtectedPodIds(): Promise { - const proUsers = await User.find({ 'entitlements.pro': true }).select('_id').lean(); + /* + * Lapsed accounts keep their DATA for a grace window after their FEATURES + * stop. Otherwise one failed card payment flips the subscription to + * `past_due` and that night's run deletes everything older than 30 days — + * before the customer has seen the dunning email, and irreversibly if they + * fix the card the next morning. Winning them back is impossible once their + * history is gone; holding bytes for a month is nearly free. + * + * `billing.proEndedAt` is stamped on the true -> false edge by + * billingService.applySubscriptionState. + */ + const graceCutoff = new Date(Date.now() - resolveGraceDays() * 24 * 60 * 60 * 1000); + const proUsers = await User.find({ + $or: [ + { 'entitlements.pro': true }, + { 'billing.proEndedAt': { $gte: graceCutoff } }, + ], + }).select('_id').lean(); if (proUsers.length === 0) return []; const proIds = proUsers.map((u: { _id: unknown }) => u._id); const pods = await Pod.find({