Skip to content

Commit 0c5f98b

Browse files
author
alexatsejames-alt
committed
fix(#1036): eliminate billing-cycle date drift with date-fns
- Add weekly and annual cases to calculateNextRenewal using addWeeks/addYears - Extend billing_cycle union type to include 'weekly' | 'annual' (was silently falling through to default, returning unchanged date on every renewal) - Add property-style tests covering month-end clamping (Jan 31 -> Feb 28/29), quarterly end clamping (Mar 31 -> Jun 30), and no-drift assertions over 12 monthly and 52 weekly iterations - Fix misleading test description 'add 365 days' -> calendar-aware addYears
1 parent 10a9ba1 commit 0c5f98b

3 files changed

Lines changed: 60 additions & 12 deletions

File tree

backend/src/services/simulation-service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
SimulationSummary,
88
RiskAssessment,
99
} from '../types/simulation';
10-
import { addMonths, addQuarters, addYears } from 'date-fns';
10+
import { addMonths, addQuarters, addWeeks, addYears } from 'date-fns';
1111

1212
/**
1313
* Simulation service for projecting subscription renewals
@@ -19,14 +19,17 @@ export class SimulationService {
1919
*/
2020
calculateNextRenewal(
2121
currentDate: Date,
22-
billingCycle: 'monthly' | 'quarterly' | 'yearly'
22+
billingCycle: 'monthly' | 'quarterly' | 'yearly' | 'weekly' | 'annual'
2323
): Date {
2424
switch (billingCycle) {
25+
case 'weekly':
26+
return addWeeks(currentDate, 1);
2527
case 'monthly':
2628
return addMonths(currentDate, 1);
2729
case 'quarterly':
2830
return addQuarters(currentDate, 1);
2931
case 'yearly':
32+
case 'annual':
3033
return addYears(currentDate, 1);
3134
default:
3235
return currentDate;

backend/src/types/subscription.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface Subscription {
77
provider: string;
88
price: number;
99
currency: string;
10-
billing_cycle: "monthly" | "yearly" | "quarterly";
10+
billing_cycle: "monthly" | "yearly" | "quarterly" | "weekly" | "annual";
1111
status: "active" | "cancelled" | "paused" | "trial" | "expired";
1212
next_billing_date: string | null;
1313
category: string | null;
@@ -42,7 +42,7 @@ export interface SubscriptionCreateInput {
4242
merchant_id?: string;
4343
price: number;
4444
currency?: string;
45-
billing_cycle: "monthly" | "yearly" | "quarterly";
45+
billing_cycle: "monthly" | "yearly" | "quarterly" | "weekly" | "annual";
4646
status?: "active" | "cancelled" | "paused" | "trial" | "expired";
4747
next_billing_date?: string;
4848
category?: string;
@@ -66,7 +66,7 @@ export interface SubscriptionUpdateInput {
6666
merchant_id?: string;
6767
price?: number;
6868
currency?: string;
69-
billing_cycle?: "monthly" | "yearly" | "quarterly";
69+
billing_cycle?: "monthly" | "yearly" | "quarterly" | "weekly" | "annual";
7070
status?: "active" | "cancelled" | "paused" | "trial" | "expired";
7171
next_billing_date?: string;
7272
category?: string;

backend/tests/simulation-service.test.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,74 @@ describe("SimulationService", () => {
99
});
1010

1111
describe("calculateNextRenewal", () => {
12+
it("should add 1 week for weekly billing cycle", () => {
13+
const currentDate = new Date("2024-01-01T00:00:00.000Z");
14+
const nextDate = service.calculateNextRenewal(currentDate, "weekly");
15+
expect(nextDate.toISOString()).toBe(new Date("2024-01-08T00:00:00.000Z").toISOString());
16+
});
17+
1218
it("should add 1 month for monthly billing cycle", () => {
1319
const currentDate = new Date("2024-01-01T00:00:00.000Z");
1420
const nextDate = service.calculateNextRenewal(currentDate, "monthly");
15-
1621
expect(nextDate.toISOString()).toBe(new Date("2024-02-01T00:00:00.000Z").toISOString());
1722
});
1823

1924
it("should add 1 quarter for quarterly billing cycle", () => {
2025
const currentDate = new Date("2024-01-01T00:00:00.000Z");
2126
const nextDate = service.calculateNextRenewal(currentDate, "quarterly");
22-
2327
expect(nextDate.toISOString()).toBe(new Date("2024-04-01T00:00:00.000Z").toISOString());
2428
});
2529

26-
27-
it("should add 365 days for yearly billing cycle", () => {
28-
const currentDate = new Date("2024-01-01");
30+
it("should add 1 year for yearly billing cycle", () => {
31+
const currentDate = new Date("2024-01-01T00:00:00.000Z");
2932
const nextDate = service.calculateNextRenewal(currentDate, "yearly");
33+
expect(nextDate.toISOString()).toBe(new Date("2025-01-01T00:00:00.000Z").toISOString());
34+
});
3035

31-
expect(nextDate.toISOString()).toBe(
32-
new Date("2025-01-01").toISOString()
36+
it("should treat annual the same as yearly", () => {
37+
const d = new Date("2024-06-15T00:00:00.000Z");
38+
expect(service.calculateNextRenewal(d, "annual").toISOString()).toBe(
39+
service.calculateNextRenewal(d, "yearly").toISOString()
3340
);
3441
});
42+
43+
// Month-end edge cases — the whole point of using date-fns over manual arithmetic
44+
it("should clamp Jan 31 + 1 month to Feb 29 on a leap year", () => {
45+
const jan31 = new Date("2024-01-31T00:00:00.000Z"); // 2024 is a leap year
46+
const next = service.calculateNextRenewal(jan31, "monthly");
47+
expect(next.toISOString()).toBe(new Date("2024-02-29T00:00:00.000Z").toISOString());
48+
});
49+
50+
it("should clamp Jan 31 + 1 month to Feb 28 on a non-leap year", () => {
51+
const jan31 = new Date("2023-01-31T00:00:00.000Z");
52+
const next = service.calculateNextRenewal(jan31, "monthly");
53+
expect(next.toISOString()).toBe(new Date("2023-02-28T00:00:00.000Z").toISOString());
54+
});
55+
56+
it("should clamp Mar 31 + 1 quarter to Jun 30", () => {
57+
const mar31 = new Date("2024-03-31T00:00:00.000Z");
58+
const next = service.calculateNextRenewal(mar31, "quarterly");
59+
expect(next.toISOString()).toBe(new Date("2024-06-30T00:00:00.000Z").toISOString());
60+
});
61+
62+
it("no drift: applying monthly 12 times from Jan 31 lands on Jan 31 the next year", () => {
63+
let date = new Date("2024-01-31T00:00:00.000Z");
64+
for (let i = 0; i < 12; i++) {
65+
date = service.calculateNextRenewal(date, "monthly");
66+
}
67+
expect(date.getUTCDate()).toBe(31);
68+
expect(date.getUTCMonth()).toBe(0); // January
69+
expect(date.getUTCFullYear()).toBe(2025);
70+
});
71+
72+
it("no drift: applying weekly 52 times from a given date returns the same weekday one year later", () => {
73+
const start = new Date("2024-01-01T00:00:00.000Z"); // Monday
74+
let date = start;
75+
for (let i = 0; i < 52; i++) {
76+
date = service.calculateNextRenewal(date, "weekly");
77+
}
78+
expect(date.getUTCDay()).toBe(start.getUTCDay());
79+
});
3580
});
3681

3782
describe("projectSubscriptionRenewals", () => {

0 commit comments

Comments
 (0)