Skip to content

Commit 09faddb

Browse files
authored
Calculate payday correctly (#495)
* Refactor payday calculation logic for clarity Reorganized the logic in countDaysBeforePayday and countDaysAfterPayday to consistently handle the paidUntil and isDebug parameters. Added a @todo note to remove the isDebug feature in the future as it complicates the logic. * Update payday.ts * Refactor payday calculation logic Extracted payday date calculation into a new getPayday function and updated countDaysBeforePayday and countDaysAfterPayday to use it. This improves code reuse and clarity. * try to add tests * Update payday.test.ts * Rename 'date' param to 'lastChargeDate' in payday utils Updated function parameter names and related references from 'date' to 'lastChargeDate' in payday utility functions for improved clarity and consistency. * Remove 0 from DAYS_LEFT_ALERT notifications Admins will now only be notified 3, 2, and 1 days before payment, not on the due day itself. This reduces redundant notifications. * Remove integration scenario tests from payday utility Deleted the 'Integration scenarios' test suite from payday.test.ts to reduce test coverage to unit-level only. This streamlines the test file and focuses on isolated function testing. * Add comment for prepaid workspace recharge logic Added a clarifying comment explaining the recharge process for prepaid workspaces, noting that limits should be reset even if no payment is due. This improves code readability and understanding of billing logic. * Update test to check addTask call for unblocking Modified the PaymasterWorker test to assert that the 'addTask' method is called with the correct parameters for unblocking a workspace, instead of checking workspace fields directly.
1 parent d605208 commit 09faddb

File tree

4 files changed

+397
-23
lines changed

4 files changed

+397
-23
lines changed

lib/utils/payday.test.ts

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
import { getPayday, countDaysBeforePayday, countDaysAfterPayday, countDaysAfterBlock } from './payday';
2+
import { WorkspaceDBScheme } from '@hawk.so/types';
3+
import { ObjectId } from 'mongodb';
4+
5+
/**
6+
* Mock the Date constructor to allow controlling "now"
7+
*/
8+
let mockedNow: number | null = null;
9+
10+
const setMockedNow = (date: Date): void => {
11+
mockedNow = date.getTime();
12+
};
13+
14+
const resetMockedNow = (): void => {
15+
mockedNow = null;
16+
};
17+
18+
// Override Date constructor
19+
const RealDate = Date;
20+
global.Date = class extends RealDate {
21+
/**
22+
* Constructor for mocked Date class
23+
* @param args - arguments passed to Date constructor
24+
*/
25+
constructor(...args: unknown[]) {
26+
if (args.length === 0 && mockedNow !== null) {
27+
super(mockedNow);
28+
} else {
29+
super(...(args as []));
30+
}
31+
}
32+
33+
public static now(): number {
34+
return mockedNow !== null ? mockedNow : RealDate.now();
35+
}
36+
} as DateConstructor;
37+
38+
describe('Payday utility functions', () => {
39+
afterEach(() => {
40+
resetMockedNow();
41+
});
42+
43+
describe('getPayday', () => {
44+
it('should return paidUntil date when provided', () => {
45+
const lastChargeDate = new Date('2025-11-01');
46+
const paidUntil = new Date('2025-12-15');
47+
48+
const result = getPayday(lastChargeDate, paidUntil);
49+
50+
expect(result).toEqual(paidUntil);
51+
});
52+
53+
it('should calculate payday as one month after lastChargeDate when paidUntil is not provided', () => {
54+
const lastChargeDate = new Date('2025-11-01');
55+
56+
const result = getPayday(lastChargeDate);
57+
58+
expect(result.getFullYear()).toBe(2025);
59+
expect(result.getMonth()).toBe(11); // December (0-indexed)
60+
expect(result.getDate()).toBe(1);
61+
});
62+
63+
it('should handle year transition correctly', () => {
64+
const lastChargeDate = new Date('2025-12-15');
65+
66+
const result = getPayday(lastChargeDate);
67+
68+
expect(result.getFullYear()).toBe(2026);
69+
expect(result.getMonth()).toBe(0); // January (0-indexed)
70+
expect(result.getDate()).toBe(15);
71+
});
72+
73+
it('should add one day when isDebug is true', () => {
74+
const lastChargeDate = new Date('2025-12-01');
75+
76+
const result = getPayday(lastChargeDate, null, true);
77+
78+
expect(result.getFullYear()).toBe(2025);
79+
expect(result.getMonth()).toBe(11); // December (0-indexed)
80+
expect(result.getDate()).toBe(2);
81+
});
82+
83+
it('should prioritize paidUntil over debug mode', () => {
84+
const lastChargeDate = new Date('2025-11-01');
85+
const paidUntil = new Date('2025-12-15');
86+
87+
const result = getPayday(lastChargeDate, paidUntil, true);
88+
89+
expect(result).toEqual(paidUntil);
90+
});
91+
92+
it('should handle end of month dates correctly', () => {
93+
const lastChargeDate = new Date('2025-01-31');
94+
95+
const result = getPayday(lastChargeDate);
96+
97+
// JavaScript will adjust to the last day of February
98+
expect(result.getFullYear()).toBe(2025);
99+
expect(result.getMonth()).toBe(2); // March (0-indexed)
100+
expect(result.getDate()).toBe(3); // Adjusted from Feb 31 to Mar 3
101+
});
102+
});
103+
104+
describe('countDaysBeforePayday', () => {
105+
it('should return positive days when payday is in the future', () => {
106+
const now = new Date('2025-12-01');
107+
const lastChargeDate = new Date('2025-11-20');
108+
109+
setMockedNow(now);
110+
111+
const result = countDaysBeforePayday(lastChargeDate);
112+
113+
expect(result).toBe(19); // Dec 20 - Dec 1 = 19 days
114+
});
115+
116+
it('should return 0 when payday is today', () => {
117+
// Payday is calculated as one month after lastChargeDate, so Dec 20 12pm
118+
const now = new Date('2025-12-20T12:00:00.000Z');
119+
const lastChargeDate = new Date('2025-11-20T12:00:00.000Z');
120+
121+
setMockedNow(now);
122+
123+
const result = countDaysBeforePayday(lastChargeDate);
124+
125+
expect(result).toBe(0);
126+
});
127+
128+
it('should return negative days when payday has passed', () => {
129+
const now = new Date('2025-12-25');
130+
const lastChargeDate = new Date('2025-11-20');
131+
132+
setMockedNow(now);
133+
134+
const result = countDaysBeforePayday(lastChargeDate);
135+
136+
expect(result).toBe(-5); // Dec 20 - Dec 25 = -5 days
137+
});
138+
139+
it('should use paidUntil when provided', () => {
140+
const now = new Date('2025-12-01');
141+
const lastChargeDate = new Date('2025-10-01');
142+
const paidUntil = new Date('2025-12-15');
143+
144+
setMockedNow(now);
145+
146+
const result = countDaysBeforePayday(lastChargeDate, paidUntil);
147+
148+
expect(result).toBe(14); // Dec 15 - Dec 1 = 14 days
149+
});
150+
151+
it('should work correctly in debug mode', () => {
152+
const now = new Date('2025-12-01T00:00:00Z');
153+
const lastChargeDate = new Date('2025-11-30T00:00:00Z');
154+
155+
setMockedNow(now);
156+
157+
const result = countDaysBeforePayday(lastChargeDate, null, true);
158+
159+
expect(result).toBe(0); // Next day is Dec 1, same as now
160+
});
161+
162+
it('should handle cross-year payday correctly', () => {
163+
const now = new Date('2025-12-20');
164+
const lastChargeDate = new Date('2025-12-15');
165+
166+
setMockedNow(now);
167+
168+
const result = countDaysBeforePayday(lastChargeDate);
169+
170+
expect(result).toBe(26); // Jan 15, 2026 - Dec 20, 2025 = 26 days
171+
});
172+
});
173+
174+
describe('countDaysAfterPayday', () => {
175+
it('should return 0 when payday is today', () => {
176+
const now = new Date('2025-12-20T12:00:00Z');
177+
const lastChargeDate = new Date('2025-11-20T00:00:00Z');
178+
179+
setMockedNow(now);
180+
181+
const result = countDaysAfterPayday(lastChargeDate);
182+
183+
expect(result).toBe(0);
184+
});
185+
186+
it('should return positive days when payday has passed', () => {
187+
const now = new Date('2025-12-25');
188+
const lastChargeDate = new Date('2025-11-20');
189+
190+
setMockedNow(now);
191+
192+
const result = countDaysAfterPayday(lastChargeDate);
193+
194+
expect(result).toBe(5); // Dec 25 - Dec 20 = 5 days
195+
});
196+
197+
it('should return negative days when payday is in the future', () => {
198+
const now = new Date('2025-12-01');
199+
const lastChargeDate = new Date('2025-11-20');
200+
201+
setMockedNow(now);
202+
203+
const result = countDaysAfterPayday(lastChargeDate);
204+
205+
expect(result).toBe(-19); // Dec 1 - Dec 20 = -19 days
206+
});
207+
208+
it('should use paidUntil when provided', () => {
209+
const now = new Date('2025-12-20');
210+
const lastChargeDate = new Date('2025-10-01');
211+
const paidUntil = new Date('2025-12-15');
212+
213+
setMockedNow(now);
214+
215+
const result = countDaysAfterPayday(lastChargeDate, paidUntil);
216+
217+
expect(result).toBe(5); // Dec 20 - Dec 15 = 5 days
218+
});
219+
220+
it('should work correctly in debug mode', () => {
221+
const now = new Date('2025-12-03T00:00:00Z');
222+
const lastChargeDate = new Date('2025-12-01T00:00:00Z');
223+
224+
setMockedNow(now);
225+
226+
const result = countDaysAfterPayday(lastChargeDate, null, true);
227+
228+
expect(result).toBe(1); // Dec 3 - Dec 2 = 1 day
229+
});
230+
231+
it('should be the inverse of countDaysBeforePayday', () => {
232+
const now = new Date('2025-12-15');
233+
const lastChargeDate = new Date('2025-11-20');
234+
235+
setMockedNow(now);
236+
237+
const daysBefore = countDaysBeforePayday(lastChargeDate);
238+
const daysAfter = countDaysAfterPayday(lastChargeDate);
239+
240+
expect(daysBefore).toBe(-daysAfter);
241+
});
242+
});
243+
244+
describe('countDaysAfterBlock', () => {
245+
it('should return undefined when blockedDate is not set', () => {
246+
const workspace: WorkspaceDBScheme = {
247+
_id: new ObjectId(),
248+
name: 'Test Workspace',
249+
inviteHash: 'test-hash',
250+
tariffPlanId: new ObjectId(),
251+
billingPeriodEventsCount: 0,
252+
lastChargeDate: new Date(),
253+
accountId: 'test-account',
254+
balance: 0,
255+
blockedDate: null,
256+
};
257+
258+
const result = countDaysAfterBlock(workspace);
259+
260+
expect(result).toBeUndefined();
261+
});
262+
263+
it('should return 0 when workspace was blocked today', () => {
264+
const now = new Date('2025-12-18T12:00:00Z');
265+
const blockedDate = new Date('2025-12-18T00:00:00Z');
266+
267+
setMockedNow(now);
268+
269+
const workspace: WorkspaceDBScheme = {
270+
_id: new ObjectId(),
271+
name: 'Test Workspace',
272+
inviteHash: 'test-hash',
273+
tariffPlanId: new ObjectId(),
274+
billingPeriodEventsCount: 0,
275+
lastChargeDate: new Date(),
276+
accountId: 'test-account',
277+
balance: 0,
278+
blockedDate,
279+
};
280+
281+
const result = countDaysAfterBlock(workspace);
282+
283+
expect(result).toBe(0);
284+
});
285+
286+
it('should return correct number of days after block', () => {
287+
const now = new Date('2025-12-18');
288+
const blockedDate = new Date('2025-12-10');
289+
290+
setMockedNow(now);
291+
292+
const workspace: WorkspaceDBScheme = {
293+
_id: new ObjectId(),
294+
name: 'Test Workspace',
295+
inviteHash: 'test-hash',
296+
tariffPlanId: new ObjectId(),
297+
billingPeriodEventsCount: 0,
298+
lastChargeDate: new Date(),
299+
accountId: 'test-account',
300+
balance: 0,
301+
blockedDate,
302+
};
303+
304+
const result = countDaysAfterBlock(workspace);
305+
306+
expect(result).toBe(8); // Dec 18 - Dec 10 = 8 days
307+
});
308+
309+
it('should handle cross-month blocks correctly', () => {
310+
const now = new Date('2025-12-05');
311+
const blockedDate = new Date('2025-11-28');
312+
313+
setMockedNow(now);
314+
315+
const workspace: WorkspaceDBScheme = {
316+
_id: new ObjectId(),
317+
name: 'Test Workspace',
318+
inviteHash: 'test-hash',
319+
tariffPlanId: new ObjectId(),
320+
billingPeriodEventsCount: 0,
321+
lastChargeDate: new Date(),
322+
accountId: 'test-account',
323+
balance: 0,
324+
blockedDate,
325+
};
326+
327+
const result = countDaysAfterBlock(workspace);
328+
329+
expect(result).toBe(7); // Dec 5 - Nov 28 = 7 days
330+
});
331+
332+
it('should handle cross-year blocks correctly', () => {
333+
const now = new Date('2026-01-05');
334+
const blockedDate = new Date('2025-12-28');
335+
336+
setMockedNow(now);
337+
338+
const workspace: WorkspaceDBScheme = {
339+
_id: new ObjectId(),
340+
name: 'Test Workspace',
341+
inviteHash: 'test-hash',
342+
tariffPlanId: new ObjectId(),
343+
billingPeriodEventsCount: 0,
344+
lastChargeDate: new Date(),
345+
accountId: 'test-account',
346+
balance: 0,
347+
blockedDate,
348+
};
349+
350+
const result = countDaysAfterBlock(workspace);
351+
352+
expect(result).toBe(8); // Jan 5, 2026 - Dec 28, 2025 = 8 days
353+
});
354+
});
355+
});

0 commit comments

Comments
 (0)