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
52 changes: 50 additions & 2 deletions backend/__tests__/services/pgRetentionService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
Expand Down Expand Up @@ -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' }];
Expand Down
42 changes: 42 additions & 0 deletions backend/__tests__/unit/services/billingService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
6 changes: 6 additions & 0 deletions backend/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -210,6 +214,8 @@ const userSchema = new Schema<IUser>({
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
Expand Down
1 change: 0 additions & 1 deletion backend/node_modules

This file was deleted.

23 changes: 23 additions & 0 deletions backend/services/billingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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();

Expand Down
32 changes: 31 additions & 1 deletion backend/services/pgRetentionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -111,7 +124,24 @@ async function vacuumMessages(): Promise<void> {
* night of cleanup costs nothing.
*/
export async function resolveProtectedPodIds(): Promise<string[]> {
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({
Expand Down
Loading