From 439f5a44c97c539e974d6bc08ae98df2bac46019 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 3 Apr 2026 22:26:58 +0200 Subject: [PATCH] feat: implement billing account management and cycle synchronization logic with accompanying tests --- .../billingTimestampNormalization.test.js | 45 ++++++++++++++++ server/lib/billing.js | 51 ++++++++++--------- 2 files changed, 73 insertions(+), 23 deletions(-) create mode 100644 __tests__/server/billingTimestampNormalization.test.js diff --git a/__tests__/server/billingTimestampNormalization.test.js b/__tests__/server/billingTimestampNormalization.test.js new file mode 100644 index 0000000..28d0d69 --- /dev/null +++ b/__tests__/server/billingTimestampNormalization.test.js @@ -0,0 +1,45 @@ +jest.mock('../../server/lib/postgres', () => ({ + get: jest.fn(), + run: jest.fn(), +})); + +const { get, run } = require('../../server/lib/postgres'); +const { syncRevenueCatCustomerInfo } = require('../../server/lib/billing'); + +describe('server billing timestamp normalization', () => { + beforeEach(() => { + jest.clearAllMocks(); + run.mockResolvedValue({ lastId: null, changes: 1, rows: [] }); + }); + + it('upserts ISO timestamps when postgres returns Date objects', async () => { + get.mockResolvedValueOnce({ + userId: 'usr_mnjcdwpo_ax9lf68b', + plan: 'free', + provider: 'revenuecat', + cycleStartedAt: new Date('2026-04-01T00:00:00.000Z'), + cycleEndsAt: new Date('2026-05-01T00:00:00.000Z'), + monthlyAllowance: 15, + usedThisCycle: 0, + topupBalance: 0, + renewsAt: null, + updatedAt: new Date('2026-04-02T12:00:00.000Z'), + }); + + await syncRevenueCatCustomerInfo( + {}, + 'usr_mnjcdwpo_ax9lf68b', + { entitlements: { active: {} }, nonSubscriptions: {} }, + { source: 'topup_purchase' }, + ); + + const upsertCall = run.mock.calls.find(([, sql]) => typeof sql === 'string' && sql.includes('INSERT INTO billing_accounts')); + expect(upsertCall).toBeTruthy(); + + const params = upsertCall[2]; + expect(params[3]).toBe('2026-04-01T00:00:00.000Z'); + expect(params[4]).toBe('2026-05-01T00:00:00.000Z'); + expect(params[3]).not.toContain('Coordinated Universal Time'); + expect(params[4]).not.toContain('Coordinated Universal Time'); + }); +}); diff --git a/server/lib/billing.js b/server/lib/billing.js index 11012e4..16be92e 100644 --- a/server/lib/billing.js +++ b/server/lib/billing.js @@ -18,6 +18,28 @@ const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_m const nowIso = () => new Date().toISOString(); +const asIsoDate = (value) => { + if (value == null || value === '') return null; + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + if (typeof value === 'number' && Number.isFinite(value)) { + return new Date(value).toISOString(); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + if (/^\d+$/.test(trimmed)) { + return new Date(Number(trimmed)).toISOString(); + } + const parsed = new Date(trimmed); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + } + return null; +}; + const startOfUtcMonth = (date) => { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0)); }; @@ -73,17 +95,19 @@ const runInTransaction = async (db, worker) => { const normalizeAccountRow = (row) => { if (!row) return null; + const now = new Date(); + const { cycleStartedAt: defaultCycleStartedAt, cycleEndsAt: defaultCycleEndsAt } = getCycleBounds(now); return { userId: String(row.userId), plan: row.plan === 'pro' ? 'pro' : 'free', provider: typeof row.provider === 'string' && row.provider ? row.provider : 'revenuecat', - cycleStartedAt: String(row.cycleStartedAt), - cycleEndsAt: String(row.cycleEndsAt), + cycleStartedAt: asIsoDate(row.cycleStartedAt) || defaultCycleStartedAt.toISOString(), + cycleEndsAt: asIsoDate(row.cycleEndsAt) || defaultCycleEndsAt.toISOString(), monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS, usedThisCycle: Number(row.usedThisCycle) || 0, topupBalance: Number(row.topupBalance) || 0, - renewsAt: row.renewsAt ? String(row.renewsAt) : null, - updatedAt: row.updatedAt ? String(row.updatedAt) : nowIso(), + renewsAt: asIsoDate(row.renewsAt), + updatedAt: asIsoDate(row.updatedAt) || now.toISOString(), }; }; @@ -238,25 +262,6 @@ const buildBillingSummary = (account) => { }; }; -const asIsoDate = (value) => { - if (value == null || value === '') return null; - if (typeof value === 'number' && Number.isFinite(value)) { - return new Date(value).toISOString(); - } - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) return null; - if (/^\d+$/.test(trimmed)) { - return new Date(Number(trimmed)).toISOString(); - } - const parsed = new Date(trimmed); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - return null; -}; - const isSupportedTopupProduct = (productId) => { return typeof productId === 'string' && productId.startsWith('topup_')