diff --git a/lib/utils/payday.test.ts b/lib/utils/payday.test.ts new file mode 100644 index 00000000..beaecd0e --- /dev/null +++ b/lib/utils/payday.test.ts @@ -0,0 +1,355 @@ +import { getPayday, countDaysBeforePayday, countDaysAfterPayday, countDaysAfterBlock } from './payday'; +import { WorkspaceDBScheme } from '@hawk.so/types'; +import { ObjectId } from 'mongodb'; + +/** + * Mock the Date constructor to allow controlling "now" + */ +let mockedNow: number | null = null; + +const setMockedNow = (date: Date): void => { + mockedNow = date.getTime(); +}; + +const resetMockedNow = (): void => { + mockedNow = null; +}; + +// Override Date constructor +const RealDate = Date; +global.Date = class extends RealDate { + /** + * Constructor for mocked Date class + * @param args - arguments passed to Date constructor + */ + constructor(...args: unknown[]) { + if (args.length === 0 && mockedNow !== null) { + super(mockedNow); + } else { + super(...(args as [])); + } + } + + public static now(): number { + return mockedNow !== null ? mockedNow : RealDate.now(); + } +} as DateConstructor; + +describe('Payday utility functions', () => { + afterEach(() => { + resetMockedNow(); + }); + + describe('getPayday', () => { + it('should return paidUntil date when provided', () => { + const lastChargeDate = new Date('2025-11-01'); + const paidUntil = new Date('2025-12-15'); + + const result = getPayday(lastChargeDate, paidUntil); + + expect(result).toEqual(paidUntil); + }); + + it('should calculate payday as one month after lastChargeDate when paidUntil is not provided', () => { + const lastChargeDate = new Date('2025-11-01'); + + const result = getPayday(lastChargeDate); + + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(11); // December (0-indexed) + expect(result.getDate()).toBe(1); + }); + + it('should handle year transition correctly', () => { + const lastChargeDate = new Date('2025-12-15'); + + const result = getPayday(lastChargeDate); + + expect(result.getFullYear()).toBe(2026); + expect(result.getMonth()).toBe(0); // January (0-indexed) + expect(result.getDate()).toBe(15); + }); + + it('should add one day when isDebug is true', () => { + const lastChargeDate = new Date('2025-12-01'); + + const result = getPayday(lastChargeDate, null, true); + + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(11); // December (0-indexed) + expect(result.getDate()).toBe(2); + }); + + it('should prioritize paidUntil over debug mode', () => { + const lastChargeDate = new Date('2025-11-01'); + const paidUntil = new Date('2025-12-15'); + + const result = getPayday(lastChargeDate, paidUntil, true); + + expect(result).toEqual(paidUntil); + }); + + it('should handle end of month dates correctly', () => { + const lastChargeDate = new Date('2025-01-31'); + + const result = getPayday(lastChargeDate); + + // JavaScript will adjust to the last day of February + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(2); // March (0-indexed) + expect(result.getDate()).toBe(3); // Adjusted from Feb 31 to Mar 3 + }); + }); + + describe('countDaysBeforePayday', () => { + it('should return positive days when payday is in the future', () => { + const now = new Date('2025-12-01'); + const lastChargeDate = new Date('2025-11-20'); + + setMockedNow(now); + + const result = countDaysBeforePayday(lastChargeDate); + + expect(result).toBe(19); // Dec 20 - Dec 1 = 19 days + }); + + it('should return 0 when payday is today', () => { + // Payday is calculated as one month after lastChargeDate, so Dec 20 12pm + const now = new Date('2025-12-20T12:00:00.000Z'); + const lastChargeDate = new Date('2025-11-20T12:00:00.000Z'); + + setMockedNow(now); + + const result = countDaysBeforePayday(lastChargeDate); + + expect(result).toBe(0); + }); + + it('should return negative days when payday has passed', () => { + const now = new Date('2025-12-25'); + const lastChargeDate = new Date('2025-11-20'); + + setMockedNow(now); + + const result = countDaysBeforePayday(lastChargeDate); + + expect(result).toBe(-5); // Dec 20 - Dec 25 = -5 days + }); + + it('should use paidUntil when provided', () => { + const now = new Date('2025-12-01'); + const lastChargeDate = new Date('2025-10-01'); + const paidUntil = new Date('2025-12-15'); + + setMockedNow(now); + + const result = countDaysBeforePayday(lastChargeDate, paidUntil); + + expect(result).toBe(14); // Dec 15 - Dec 1 = 14 days + }); + + it('should work correctly in debug mode', () => { + const now = new Date('2025-12-01T00:00:00Z'); + const lastChargeDate = new Date('2025-11-30T00:00:00Z'); + + setMockedNow(now); + + const result = countDaysBeforePayday(lastChargeDate, null, true); + + expect(result).toBe(0); // Next day is Dec 1, same as now + }); + + it('should handle cross-year payday correctly', () => { + const now = new Date('2025-12-20'); + const lastChargeDate = new Date('2025-12-15'); + + setMockedNow(now); + + const result = countDaysBeforePayday(lastChargeDate); + + expect(result).toBe(26); // Jan 15, 2026 - Dec 20, 2025 = 26 days + }); + }); + + describe('countDaysAfterPayday', () => { + it('should return 0 when payday is today', () => { + const now = new Date('2025-12-20T12:00:00Z'); + const lastChargeDate = new Date('2025-11-20T00:00:00Z'); + + setMockedNow(now); + + const result = countDaysAfterPayday(lastChargeDate); + + expect(result).toBe(0); + }); + + it('should return positive days when payday has passed', () => { + const now = new Date('2025-12-25'); + const lastChargeDate = new Date('2025-11-20'); + + setMockedNow(now); + + const result = countDaysAfterPayday(lastChargeDate); + + expect(result).toBe(5); // Dec 25 - Dec 20 = 5 days + }); + + it('should return negative days when payday is in the future', () => { + const now = new Date('2025-12-01'); + const lastChargeDate = new Date('2025-11-20'); + + setMockedNow(now); + + const result = countDaysAfterPayday(lastChargeDate); + + expect(result).toBe(-19); // Dec 1 - Dec 20 = -19 days + }); + + it('should use paidUntil when provided', () => { + const now = new Date('2025-12-20'); + const lastChargeDate = new Date('2025-10-01'); + const paidUntil = new Date('2025-12-15'); + + setMockedNow(now); + + const result = countDaysAfterPayday(lastChargeDate, paidUntil); + + expect(result).toBe(5); // Dec 20 - Dec 15 = 5 days + }); + + it('should work correctly in debug mode', () => { + const now = new Date('2025-12-03T00:00:00Z'); + const lastChargeDate = new Date('2025-12-01T00:00:00Z'); + + setMockedNow(now); + + const result = countDaysAfterPayday(lastChargeDate, null, true); + + expect(result).toBe(1); // Dec 3 - Dec 2 = 1 day + }); + + it('should be the inverse of countDaysBeforePayday', () => { + const now = new Date('2025-12-15'); + const lastChargeDate = new Date('2025-11-20'); + + setMockedNow(now); + + const daysBefore = countDaysBeforePayday(lastChargeDate); + const daysAfter = countDaysAfterPayday(lastChargeDate); + + expect(daysBefore).toBe(-daysAfter); + }); + }); + + describe('countDaysAfterBlock', () => { + it('should return undefined when blockedDate is not set', () => { + const workspace: WorkspaceDBScheme = { + _id: new ObjectId(), + name: 'Test Workspace', + inviteHash: 'test-hash', + tariffPlanId: new ObjectId(), + billingPeriodEventsCount: 0, + lastChargeDate: new Date(), + accountId: 'test-account', + balance: 0, + blockedDate: null, + }; + + const result = countDaysAfterBlock(workspace); + + expect(result).toBeUndefined(); + }); + + it('should return 0 when workspace was blocked today', () => { + const now = new Date('2025-12-18T12:00:00Z'); + const blockedDate = new Date('2025-12-18T00:00:00Z'); + + setMockedNow(now); + + const workspace: WorkspaceDBScheme = { + _id: new ObjectId(), + name: 'Test Workspace', + inviteHash: 'test-hash', + tariffPlanId: new ObjectId(), + billingPeriodEventsCount: 0, + lastChargeDate: new Date(), + accountId: 'test-account', + balance: 0, + blockedDate, + }; + + const result = countDaysAfterBlock(workspace); + + expect(result).toBe(0); + }); + + it('should return correct number of days after block', () => { + const now = new Date('2025-12-18'); + const blockedDate = new Date('2025-12-10'); + + setMockedNow(now); + + const workspace: WorkspaceDBScheme = { + _id: new ObjectId(), + name: 'Test Workspace', + inviteHash: 'test-hash', + tariffPlanId: new ObjectId(), + billingPeriodEventsCount: 0, + lastChargeDate: new Date(), + accountId: 'test-account', + balance: 0, + blockedDate, + }; + + const result = countDaysAfterBlock(workspace); + + expect(result).toBe(8); // Dec 18 - Dec 10 = 8 days + }); + + it('should handle cross-month blocks correctly', () => { + const now = new Date('2025-12-05'); + const blockedDate = new Date('2025-11-28'); + + setMockedNow(now); + + const workspace: WorkspaceDBScheme = { + _id: new ObjectId(), + name: 'Test Workspace', + inviteHash: 'test-hash', + tariffPlanId: new ObjectId(), + billingPeriodEventsCount: 0, + lastChargeDate: new Date(), + accountId: 'test-account', + balance: 0, + blockedDate, + }; + + const result = countDaysAfterBlock(workspace); + + expect(result).toBe(7); // Dec 5 - Nov 28 = 7 days + }); + + it('should handle cross-year blocks correctly', () => { + const now = new Date('2026-01-05'); + const blockedDate = new Date('2025-12-28'); + + setMockedNow(now); + + const workspace: WorkspaceDBScheme = { + _id: new ObjectId(), + name: 'Test Workspace', + inviteHash: 'test-hash', + tariffPlanId: new ObjectId(), + billingPeriodEventsCount: 0, + lastChargeDate: new Date(), + accountId: 'test-account', + balance: 0, + blockedDate, + }; + + const result = countDaysAfterBlock(workspace); + + expect(result).toBe(8); // Jan 5, 2026 - Dec 28, 2025 = 8 days + }); + }); +}); diff --git a/lib/utils/payday.ts b/lib/utils/payday.ts index ad2edda9..09bfaff0 100644 --- a/lib/utils/payday.ts +++ b/lib/utils/payday.ts @@ -7,23 +7,44 @@ import { HOURS_IN_DAY, MINUTES_IN_HOUR, SECONDS_IN_MINUTE, MS_IN_SEC } from './c const MILLISECONDS_IN_DAY = HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE * MS_IN_SEC; /** - * Returns difference between now and payday in days + * Returns expected payday date * * Pay day is calculated by formula: paidUntil date or last charge date + 1 month * - * @param date - last charge date + * @param lastChargeDate - last charge date * @param paidUntil - paid until date * @param isDebug - flag for debug purposes */ -export function countDaysBeforePayday(date: Date, paidUntil: Date = null, isDebug = false): number { - const expectedPayDay = paidUntil ? new Date(paidUntil) : new Date(date); +export function getPayday(lastChargeDate: Date, paidUntil: Date = null, isDebug = false): Date { + let expectedPayDay: Date; - if (isDebug) { - expectedPayDay.setDate(date.getDate() + 1); - } else if (!paidUntil) { - expectedPayDay.setMonth(date.getMonth() + 1); + if (paidUntil) { + // If paidUntil is provided, use it as the payday + expectedPayDay = new Date(paidUntil); + } else { + // Otherwise calculate from lastChargeDate + expectedPayDay = new Date(lastChargeDate); + if (isDebug) { + expectedPayDay.setDate(lastChargeDate.getDate() + 1); + } else { + expectedPayDay.setMonth(lastChargeDate.getMonth() + 1); + } } + return expectedPayDay; +} + +/** + * Returns difference between now and payday in days + * + * Pay day is calculated by formula: paidUntil date or last charge date + 1 month + * + * @param lastChargeDate - last charge date + * @param paidUntil - paid until date + * @param isDebug - flag for debug purposes + */ +export function countDaysBeforePayday(lastChargeDate: Date, paidUntil: Date = null, isDebug = false): number { + const expectedPayDay = getPayday(lastChargeDate, paidUntil, isDebug); const now = new Date().getTime(); return Math.floor((expectedPayDay.getTime() - now) / MILLISECONDS_IN_DAY); @@ -34,19 +55,12 @@ export function countDaysBeforePayday(date: Date, paidUntil: Date = null, isDebu * * Pay day is calculated by formula: paidUntil date or last charge date + 1 month * - * @param date - last charge date + * @param lastChargeDate - last charge date * @param paidUntil - paid until date * @param isDebug - flag for debug purposes */ -export function countDaysAfterPayday(date: Date, paidUntil: Date = null, isDebug = false): number { - const expectedPayDay = paidUntil ? new Date(paidUntil) : new Date(date); - - if (isDebug) { - expectedPayDay.setDate(date.getDate() + 1); - } else if (!paidUntil) { - expectedPayDay.setMonth(date.getMonth() + 1); - } - +export function countDaysAfterPayday(lastChargeDate: Date, paidUntil: Date = null, isDebug = false): number { + const expectedPayDay = getPayday(lastChargeDate, paidUntil, isDebug); const now = new Date().getTime(); return Math.floor((now - expectedPayDay.getTime()) / MILLISECONDS_IN_DAY); diff --git a/workers/paymaster/src/index.ts b/workers/paymaster/src/index.ts index bde8e284..333a3911 100644 --- a/workers/paymaster/src/index.ts +++ b/workers/paymaster/src/index.ts @@ -26,7 +26,7 @@ const DAYS_AFTER_PAYDAY_TO_TRY_PAYING = 3; * List of days left number to notify admins about upcoming payment */ // eslint-disable-next-line @typescript-eslint/no-magic-numbers -const DAYS_LEFT_ALERT = [3, 2, 1, 0]; +const DAYS_LEFT_ALERT = [3, 2, 1]; /** * Days after block to remind admins about blocked workspace @@ -267,6 +267,10 @@ export default class PaymasterWorker extends Worker { */ if (!isTimeToPay) { /** + * [USED FOR PREPAID WORKSPACES] + * "Recharge" — to reset limits for the new billing period (month). + * It should be done even for prepaid workspaces that do not need to pay anything today. + * * If it is time to recharge workspace limits, but not time to pay * Start new month - recharge billing period events count and update last charge date */ diff --git a/workers/paymaster/tests/index.test.ts b/workers/paymaster/tests/index.test.ts index ef8f68d5..8ad43b4d 100644 --- a/workers/paymaster/tests/index.test.ts +++ b/workers/paymaster/tests/index.test.ts @@ -738,11 +738,12 @@ describe('PaymasterWorker', () => { /** * Assert */ - const updatedWorkspace = await workspacesCollection.findOne({ _id: workspace._id }); + const addTaskSpy = jest.spyOn(worker, 'addTask'); - expect(updatedWorkspace.lastChargeDate).toEqual(currentDate); - expect(updatedWorkspace.billingPeriodEventsCount).toEqual(0); - expect(updatedWorkspace.isBlocked).toEqual(false); + expect(addTaskSpy).toHaveBeenCalledWith('cron-tasks/limiter', { + type: 'unblock-workspace', + workspaceId: workspace._id.toString(), + }); await worker.finish(); MockDate.reset();