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
19 changes: 19 additions & 0 deletions modules/billing/models/billing.subscription.model.mongoose.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions modules/billing/models/billing.subscription.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
138 changes: 138 additions & 0 deletions modules/billing/tests/billing.subscription.schema.unit.tests.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading