diff --git a/modules/billing/models/billing.subscription.model.mongoose.js b/modules/billing/models/billing.subscription.model.mongoose.js index c9414b8b8..31eae837a 100644 --- a/modules/billing/models/billing.subscription.model.mongoose.js +++ b/modules/billing/models/billing.subscription.model.mongoose.js @@ -123,6 +123,25 @@ const SubscriptionMongoose = new Schema( ref: 'User', default: null, }, + + // ── Pending cancellation ───────────────────────────────────────────────── + /** + * Whether the subscription is set to cancel at the end of the current period. + * Populated from Stripe's cancel_at_period_end flag on customer.subscription.updated. + * Does NOT change `plan` — the sub stays on the current plan until cancel_at. + */ + cancelAtPeriodEnd: { + type: Boolean, + default: null, + }, + /** + * Date when the subscription will actually be cancelled (null when no pending cancel). + * Sourced from Stripe's cancel_at (Unix seconds) → converted to Date in the webhook handler. + */ + cancelAt: { + type: Date, + default: null, + }, }, { timestamps: true, diff --git a/modules/billing/models/billing.subscription.schema.js b/modules/billing/models/billing.subscription.schema.js index c88ed0935..d0f4f1464 100644 --- a/modules/billing/models/billing.subscription.schema.js +++ b/modules/billing/models/billing.subscription.schema.js @@ -32,6 +32,9 @@ const baseShape = { // Admin override audit trail adminUpdatedAt: z.coerce.date().nullable().optional(), adminUpdatedBy: z.string().regex(objectIdRegex).nullable().optional(), + // Pending cancellation — populated when customer schedules cancel-at-period-end + cancelAtPeriodEnd: z.boolean().optional(), + cancelAt: z.coerce.date().nullable().optional(), }; const Subscription = z.object({ diff --git a/modules/billing/tests/billing.subscription.schema.unit.tests.js b/modules/billing/tests/billing.subscription.schema.unit.tests.js new file mode 100644 index 000000000..5a59c47ba --- /dev/null +++ b/modules/billing/tests/billing.subscription.schema.unit.tests.js @@ -0,0 +1,138 @@ +/** + * Module dependencies. + */ +import schema from '../models/billing.subscription.schema.js'; + +/** + * Unit tests — cancelAtPeriodEnd + cancelAt lifecycle fields + */ +describe('Billing subscription schema — cancelAt lifecycle fields:', () => { + let subscription; + + beforeEach(() => { + subscription = { + organization: '507f1f77bcf86cd799439011', + plan: 'free', + status: 'active', + }; + }); + + describe('cancelAtPeriodEnd', () => { + test('should accept true', () => { + subscription.cancelAtPeriodEnd = true; + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAtPeriodEnd).toBe(true); + }); + + test('should accept false', () => { + subscription.cancelAtPeriodEnd = false; + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAtPeriodEnd).toBe(false); + }); + + test('should be optional (omitted → undefined)', () => { + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAtPeriodEnd).toBeUndefined(); + }); + + test('should reject a string value', () => { + subscription.cancelAtPeriodEnd = 'yes'; + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeDefined(); + }); + + test('should reject a number value', () => { + subscription.cancelAtPeriodEnd = 1; + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeDefined(); + }); + + test('SubscriptionUpdate should allow patching cancelAtPeriodEnd alone', () => { + const update = { cancelAtPeriodEnd: true }; + const result = schema.SubscriptionUpdate.safeParse(update); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAtPeriodEnd).toBe(true); + }); + }); + + describe('cancelAt', () => { + test('should accept a Date object', () => { + subscription.cancelAt = new Date('2026-06-30T00:00:00.000Z'); + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAt).toBeInstanceOf(Date); + }); + + test('should coerce an ISO string to Date', () => { + subscription.cancelAt = '2026-06-30T00:00:00.000Z'; + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAt).toBeInstanceOf(Date); + expect(result.data.cancelAt.toISOString()).toBe('2026-06-30T00:00:00.000Z'); + }); + + test('should coerce a Number to Date (z.coerce.date treats numbers as ms, not seconds)', () => { + // z.coerce.date() treats numbers as MILLISECONDS — Stripe's cancel_at is Unix seconds. + // The webhook handler (C.2) must multiply by 1000: new Date(stripeEvent.cancel_at * 1000). + // This test confirms the schema accepts a number and coerces to Date (whatever the value). + subscription.cancelAt = 1751241600 * 1000; // ms → correct date: 2025-06-30 + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAt).toBeInstanceOf(Date); + expect(result.data.cancelAt.toISOString()).toBe('2025-06-30T00:00:00.000Z'); + }); + + test('should accept null (no pending cancellation)', () => { + subscription.cancelAt = null; + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAt).toBeNull(); + }); + + test('should be optional (omitted → undefined)', () => { + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAt).toBeUndefined(); + }); + + test('SubscriptionUpdate should allow patching cancelAt alone', () => { + const update = { cancelAt: '2026-07-15T00:00:00.000Z' }; + const result = schema.SubscriptionUpdate.safeParse(update); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAt).toBeInstanceOf(Date); + }); + }); + + describe('both fields together', () => { + test('should accept both fields populated (pending cancel state)', () => { + subscription.cancelAtPeriodEnd = true; + subscription.cancelAt = '2026-06-30T00:00:00.000Z'; + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAtPeriodEnd).toBe(true); + expect(result.data.cancelAt).toBeInstanceOf(Date); + }); + + test('should reject null cancelAtPeriodEnd (not nullable — use undefined/omit to clear)', () => { + // cancelAtPeriodEnd is z.boolean().optional(), NOT .nullable(). + // Passing null is rejected — callers must omit the field or pass true/false. + // cancelAt IS nullable (z.coerce.date().nullable().optional()) so null passes for it. + subscription.cancelAtPeriodEnd = null; + subscription.cancelAt = null; + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeDefined(); + }); + + test('should accept cancelAt null + cancelAtPeriodEnd false (cancel revoked)', () => { + subscription.cancelAtPeriodEnd = false; + subscription.cancelAt = null; + const result = schema.Subscription.safeParse(subscription); + expect(result.error).toBeFalsy(); + expect(result.data.cancelAtPeriodEnd).toBe(false); + expect(result.data.cancelAt).toBeNull(); + }); + }); +});