Skip to content

Commit 889626a

Browse files
feat(billing): subscription cancelAt + cancelAtPeriodEnd lifecycle fields (#3741)
* feat(billing): add cancelAt + cancelAtPeriodEnd subscription lifecycle fields Port pending-cancellation fields verbatim from trawl_node into devkit so every downstream gets them. Required gate for the customer.subscription.updated webhook handler (C.2) which writes these fields on Stripe cancel_at_period_end events. Closes #3740 * test(billing): fix misleading test names in cancelAt schema unit tests - Rename Unix-seconds test to clarify z.coerce.date() treats numbers as ms (not seconds); add exact date assertion to make the ×1000 contract explicit - Rename null-rejection test so name matches assertion (was "accept both null", now "reject null cancelAtPeriodEnd — not nullable") * docs(billing): clarify cancelAt JSDoc — Date not "Unix date" Copilot review: "Unix date" was ambiguous. Changed to "Date (converted from Stripe's cancel_at Unix seconds)" for clarity.
1 parent 5f757e6 commit 889626a

3 files changed

Lines changed: 160 additions & 0 deletions

File tree

modules/billing/models/billing.subscription.model.mongoose.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,25 @@ const SubscriptionMongoose = new Schema(
123123
ref: 'User',
124124
default: null,
125125
},
126+
127+
// ── Pending cancellation ─────────────────────────────────────────────────
128+
/**
129+
* Whether the subscription is set to cancel at the end of the current period.
130+
* Populated from Stripe's cancel_at_period_end flag on customer.subscription.updated.
131+
* Does NOT change `plan` — the sub stays on the current plan until cancel_at.
132+
*/
133+
cancelAtPeriodEnd: {
134+
type: Boolean,
135+
default: null,
136+
},
137+
/**
138+
* Date when the subscription will actually be cancelled (null when no pending cancel).
139+
* Sourced from Stripe's cancel_at (Unix seconds) → converted to Date in the webhook handler.
140+
*/
141+
cancelAt: {
142+
type: Date,
143+
default: null,
144+
},
126145
},
127146
{
128147
timestamps: true,

modules/billing/models/billing.subscription.schema.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ const baseShape = {
3232
// Admin override audit trail
3333
adminUpdatedAt: z.coerce.date().nullable().optional(),
3434
adminUpdatedBy: z.string().regex(objectIdRegex).nullable().optional(),
35+
// Pending cancellation — populated when customer schedules cancel-at-period-end
36+
cancelAtPeriodEnd: z.boolean().optional(),
37+
cancelAt: z.coerce.date().nullable().optional(),
3538
};
3639

3740
const Subscription = z.object({
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import schema from '../models/billing.subscription.schema.js';
5+
6+
/**
7+
* Unit tests — cancelAtPeriodEnd + cancelAt lifecycle fields
8+
*/
9+
describe('Billing subscription schema — cancelAt lifecycle fields:', () => {
10+
let subscription;
11+
12+
beforeEach(() => {
13+
subscription = {
14+
organization: '507f1f77bcf86cd799439011',
15+
plan: 'free',
16+
status: 'active',
17+
};
18+
});
19+
20+
describe('cancelAtPeriodEnd', () => {
21+
test('should accept true', () => {
22+
subscription.cancelAtPeriodEnd = true;
23+
const result = schema.Subscription.safeParse(subscription);
24+
expect(result.error).toBeFalsy();
25+
expect(result.data.cancelAtPeriodEnd).toBe(true);
26+
});
27+
28+
test('should accept false', () => {
29+
subscription.cancelAtPeriodEnd = false;
30+
const result = schema.Subscription.safeParse(subscription);
31+
expect(result.error).toBeFalsy();
32+
expect(result.data.cancelAtPeriodEnd).toBe(false);
33+
});
34+
35+
test('should be optional (omitted → undefined)', () => {
36+
const result = schema.Subscription.safeParse(subscription);
37+
expect(result.error).toBeFalsy();
38+
expect(result.data.cancelAtPeriodEnd).toBeUndefined();
39+
});
40+
41+
test('should reject a string value', () => {
42+
subscription.cancelAtPeriodEnd = 'yes';
43+
const result = schema.Subscription.safeParse(subscription);
44+
expect(result.error).toBeDefined();
45+
});
46+
47+
test('should reject a number value', () => {
48+
subscription.cancelAtPeriodEnd = 1;
49+
const result = schema.Subscription.safeParse(subscription);
50+
expect(result.error).toBeDefined();
51+
});
52+
53+
test('SubscriptionUpdate should allow patching cancelAtPeriodEnd alone', () => {
54+
const update = { cancelAtPeriodEnd: true };
55+
const result = schema.SubscriptionUpdate.safeParse(update);
56+
expect(result.error).toBeFalsy();
57+
expect(result.data.cancelAtPeriodEnd).toBe(true);
58+
});
59+
});
60+
61+
describe('cancelAt', () => {
62+
test('should accept a Date object', () => {
63+
subscription.cancelAt = new Date('2026-06-30T00:00:00.000Z');
64+
const result = schema.Subscription.safeParse(subscription);
65+
expect(result.error).toBeFalsy();
66+
expect(result.data.cancelAt).toBeInstanceOf(Date);
67+
});
68+
69+
test('should coerce an ISO string to Date', () => {
70+
subscription.cancelAt = '2026-06-30T00:00:00.000Z';
71+
const result = schema.Subscription.safeParse(subscription);
72+
expect(result.error).toBeFalsy();
73+
expect(result.data.cancelAt).toBeInstanceOf(Date);
74+
expect(result.data.cancelAt.toISOString()).toBe('2026-06-30T00:00:00.000Z');
75+
});
76+
77+
test('should coerce a Number to Date (z.coerce.date treats numbers as ms, not seconds)', () => {
78+
// z.coerce.date() treats numbers as MILLISECONDS — Stripe's cancel_at is Unix seconds.
79+
// The webhook handler (C.2) must multiply by 1000: new Date(stripeEvent.cancel_at * 1000).
80+
// This test confirms the schema accepts a number and coerces to Date (whatever the value).
81+
subscription.cancelAt = 1751241600 * 1000; // ms → correct date: 2025-06-30
82+
const result = schema.Subscription.safeParse(subscription);
83+
expect(result.error).toBeFalsy();
84+
expect(result.data.cancelAt).toBeInstanceOf(Date);
85+
expect(result.data.cancelAt.toISOString()).toBe('2025-06-30T00:00:00.000Z');
86+
});
87+
88+
test('should accept null (no pending cancellation)', () => {
89+
subscription.cancelAt = null;
90+
const result = schema.Subscription.safeParse(subscription);
91+
expect(result.error).toBeFalsy();
92+
expect(result.data.cancelAt).toBeNull();
93+
});
94+
95+
test('should be optional (omitted → undefined)', () => {
96+
const result = schema.Subscription.safeParse(subscription);
97+
expect(result.error).toBeFalsy();
98+
expect(result.data.cancelAt).toBeUndefined();
99+
});
100+
101+
test('SubscriptionUpdate should allow patching cancelAt alone', () => {
102+
const update = { cancelAt: '2026-07-15T00:00:00.000Z' };
103+
const result = schema.SubscriptionUpdate.safeParse(update);
104+
expect(result.error).toBeFalsy();
105+
expect(result.data.cancelAt).toBeInstanceOf(Date);
106+
});
107+
});
108+
109+
describe('both fields together', () => {
110+
test('should accept both fields populated (pending cancel state)', () => {
111+
subscription.cancelAtPeriodEnd = true;
112+
subscription.cancelAt = '2026-06-30T00:00:00.000Z';
113+
const result = schema.Subscription.safeParse(subscription);
114+
expect(result.error).toBeFalsy();
115+
expect(result.data.cancelAtPeriodEnd).toBe(true);
116+
expect(result.data.cancelAt).toBeInstanceOf(Date);
117+
});
118+
119+
test('should reject null cancelAtPeriodEnd (not nullable — use undefined/omit to clear)', () => {
120+
// cancelAtPeriodEnd is z.boolean().optional(), NOT .nullable().
121+
// Passing null is rejected — callers must omit the field or pass true/false.
122+
// cancelAt IS nullable (z.coerce.date().nullable().optional()) so null passes for it.
123+
subscription.cancelAtPeriodEnd = null;
124+
subscription.cancelAt = null;
125+
const result = schema.Subscription.safeParse(subscription);
126+
expect(result.error).toBeDefined();
127+
});
128+
129+
test('should accept cancelAt null + cancelAtPeriodEnd false (cancel revoked)', () => {
130+
subscription.cancelAtPeriodEnd = false;
131+
subscription.cancelAt = null;
132+
const result = schema.Subscription.safeParse(subscription);
133+
expect(result.error).toBeFalsy();
134+
expect(result.data.cancelAtPeriodEnd).toBe(false);
135+
expect(result.data.cancelAt).toBeNull();
136+
});
137+
});
138+
});

0 commit comments

Comments
 (0)