Files
SkyMoney/api/test-monthly-income.cjs

229 lines
11 KiB
JavaScript

/**
* Test script for monthly income payday calculations with TIMEZONE awareness
* Run with: node test-monthly-income.cjs
*
* This replicates the actual allocator.ts logic including timezone handling
*/
// Simulating date-fns-tz behavior (simplified for testing)
function toZonedTime(date, timezone) {
// For testing, we'll use a simple offset approach
// In real code, this uses proper timezone rules
const utc = date.getTime();
const tzOffset = getTimezoneOffset(timezone, date);
return new Date(utc + tzOffset);
}
function fromZonedTime(date, timezone) {
const tzOffset = getTimezoneOffset(timezone, date);
return new Date(date.getTime() - tzOffset);
}
// Simplified timezone offset (real implementation uses IANA database)
function getTimezoneOffset(timezone, date) {
const offsets = {
'UTC': 0,
'America/New_York': -5 * 60 * 60 * 1000, // EST (ignoring DST for simplicity)
'America/Los_Angeles': -8 * 60 * 60 * 1000, // PST
'Asia/Tokyo': 9 * 60 * 60 * 1000, // JST
};
return offsets[timezone] || 0;
}
function getUserMidnight(timezone, date = new Date()) {
const zonedDate = toZonedTime(date, timezone);
zonedDate.setHours(0, 0, 0, 0);
return fromZonedTime(zonedDate, timezone);
}
const frequencyDays = {
weekly: 7,
biweekly: 14,
monthly: 30, // Not used for monthly anymore
};
function calculateNextPayday(firstIncomeDate, frequency, fromDate = new Date(), timezone = 'UTC') {
const normalizedFrom = getUserMidnight(timezone, fromDate);
const nextPayDate = getUserMidnight(timezone, firstIncomeDate);
// Get the target day in the USER'S timezone
const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone);
const targetDay = zonedFirstIncome.getDate();
let iterations = 0;
while (nextPayDate < normalizedFrom) {
if (frequency === 'monthly') {
// Work in user's timezone for month advancement
const zonedPayDate = toZonedTime(nextPayDate, timezone);
zonedPayDate.setMonth(zonedPayDate.getMonth() + 1);
const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate();
zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth));
zonedPayDate.setHours(0, 0, 0, 0);
const newPayDate = fromZonedTime(zonedPayDate, timezone);
nextPayDate.setTime(newPayDate.getTime());
} else {
nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]);
}
iterations++;
}
return { nextPayDate, iterations, targetDay };
}
function countPayPeriodsBetween(startDate, endDate, firstIncomeDate, frequency, timezone = 'UTC') {
let count = 0;
const nextPayDate = getUserMidnight(timezone, firstIncomeDate);
const normalizedStart = getUserMidnight(timezone, startDate);
const normalizedEnd = getUserMidnight(timezone, endDate);
const zonedFirstIncome = toZonedTime(firstIncomeDate, timezone);
const targetDay = zonedFirstIncome.getDate();
const advanceByPeriod = () => {
if (frequency === 'monthly') {
const zonedPayDate = toZonedTime(nextPayDate, timezone);
zonedPayDate.setMonth(zonedPayDate.getMonth() + 1);
const maxDayInMonth = new Date(zonedPayDate.getFullYear(), zonedPayDate.getMonth() + 1, 0).getDate();
zonedPayDate.setDate(Math.min(targetDay, maxDayInMonth));
zonedPayDate.setHours(0, 0, 0, 0);
const newPayDate = fromZonedTime(zonedPayDate, timezone);
nextPayDate.setTime(newPayDate.getTime());
} else {
nextPayDate.setDate(nextPayDate.getDate() + frequencyDays[frequency]);
}
};
while (nextPayDate < normalizedStart) {
advanceByPeriod();
}
while (nextPayDate < normalizedEnd) {
count++;
advanceByPeriod();
}
return Math.max(1, count);
}
// Helper to format dates
const fmt = (d) => d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
const fmtISO = (d) => d.toISOString().split('T')[0];
console.log('═══════════════════════════════════════════════════════════════');
console.log(' MONTHLY INCOME PAYDAY CALCULATION TESTS (Timezone-Aware)');
console.log('═══════════════════════════════════════════════════════════════\n');
// Test 1: Monthly payday on the 15th - America/New_York
console.log('TEST 1: Monthly payday on the 15th (America/New_York)');
console.log('─────────────────────────────────────');
const firstPayday15 = new Date('2025-01-15T05:00:00.000Z'); // Midnight EST = 5am UTC
const today = new Date('2025-12-20T05:00:00.000Z');
const result1 = calculateNextPayday(firstPayday15, 'monthly', today, 'America/New_York');
console.log(`First income (UTC): ${firstPayday15.toISOString()}`);
console.log(`Today (UTC): ${today.toISOString()}`);
console.log(`Target day: ${result1.targetDay}th of month`);
console.log(`Next payday (UTC): ${result1.nextPayDate.toISOString()}`);
console.log(`Iterations: ${result1.iterations}`);
console.log(`✓ Should be Jan 15, 2026 in EST\n`);
// Test 2: Edge case - payday stored as UTC midnight crossing timezone boundary
console.log('TEST 2: Timezone boundary edge case');
console.log('─────────────────────────────────────');
// If user in LA set payday to "15th", it might be stored as 2025-01-15T08:00:00Z (midnight PST)
const firstPaydayLA = new Date('2025-01-15T08:00:00.000Z');
const todayLA = new Date('2025-12-20T08:00:00.000Z');
const resultLA = calculateNextPayday(firstPaydayLA, 'monthly', todayLA, 'America/Los_Angeles');
console.log(`Timezone: America/Los_Angeles`);
console.log(`First income (UTC): ${firstPaydayLA.toISOString()}`);
console.log(`Target day: ${resultLA.targetDay}th of month`);
console.log(`Next payday (UTC): ${resultLA.nextPayDate.toISOString()}`);
console.log(`✓ Target day should be 15, not 14 or 16\n`);
// Test 3: Compare UTC vs timezone-aware for same "15th" payday
console.log('TEST 3: UTC vs Timezone-aware comparison');
console.log('─────────────────────────────────────');
const sameDate = new Date('2025-01-15T00:00:00.000Z'); // Midnight UTC
const fromDate = new Date('2025-06-01T00:00:00.000Z');
const resultUTC = calculateNextPayday(sameDate, 'monthly', fromDate, 'UTC');
const resultEST = calculateNextPayday(sameDate, 'monthly', fromDate, 'America/New_York');
const resultTokyo = calculateNextPayday(sameDate, 'monthly', fromDate, 'Asia/Tokyo');
console.log(`Date stored: ${sameDate.toISOString()}`);
console.log(`From date: ${fromDate.toISOString()}`);
console.log(`UTC target day: ${resultUTC.targetDay} → Next: ${fmtISO(resultUTC.nextPayDate)}`);
console.log(`EST target day: ${resultEST.targetDay} → Next: ${fmtISO(resultEST.nextPayDate)}`);
console.log(`JST target day: ${resultTokyo.targetDay} → Next: ${fmtISO(resultTokyo.nextPayDate)}`);
console.log(`⚠️ Same UTC date shows different "day of month" in different timezones!\n`);
// Test 4: Monthly payday on 31st with day clamping
console.log('TEST 4: Monthly payday on 31st (day clamping)');
console.log('─────────────────────────────────────');
const firstPayday31 = new Date('2025-01-31T05:00:00.000Z');
console.log(`First payday: Jan 31, 2025`);
let tempDate = getUserMidnight('America/New_York', firstPayday31);
console.log(`\nPayday progression:`);
for (let i = 0; i < 6; i++) {
const zoned = toZonedTime(tempDate, 'America/New_York');
console.log(` ${zoned.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`);
// Advance by month
zoned.setMonth(zoned.getMonth() + 1);
const maxDay = new Date(zoned.getFullYear(), zoned.getMonth() + 1, 0).getDate();
zoned.setDate(Math.min(31, maxDay));
zoned.setHours(0, 0, 0, 0);
tempDate = fromZonedTime(zoned, 'America/New_York');
}
console.log(`✓ Feb shows 28th (clamped), other months show 31st or 30th\n`);
// Test 5: Count pay periods with timezone
console.log('TEST 5: Count pay periods (timezone-aware)');
console.log('─────────────────────────────────────');
const firstIncome = new Date('2025-01-15T05:00:00.000Z');
const nowDate = new Date('2025-12-20T05:00:00.000Z');
const billDue = new Date('2026-03-01T05:00:00.000Z');
const periodsEST = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'America/New_York');
const periodsUTC = countPayPeriodsBetween(nowDate, billDue, firstIncome, 'monthly', 'UTC');
console.log(`Now: Dec 20, 2025`);
console.log(`Bill due: Mar 1, 2026`);
console.log(`First income: Jan 15, 2025`);
console.log(`Periods (EST): ${periodsEST}`);
console.log(`Periods (UTC): ${periodsUTC}`);
console.log(`✓ Should be 2-3 periods (Jan 15, Feb 15)\n`);
// Test 6: OLD vs NEW comparison (with timezone)
console.log('TEST 6: OLD (30 days) vs NEW (actual month) - with timezone');
console.log('─────────────────────────────────────');
const startDate = new Date('2025-01-15T05:00:00.000Z');
let oldDate = new Date(startDate);
let newResult = calculateNextPayday(startDate, 'monthly', startDate, 'America/New_York');
let newDate = new Date(newResult.nextPayDate);
console.log('Month | OLD (30 days) | NEW (timezone) | Drift');
console.log('──────┼────────────────┼─────────────────┼───────');
for (let i = 0; i < 12; i++) {
oldDate.setDate(oldDate.getDate() + 30);
// For new method, advance one month from previous
const nextFrom = new Date(newDate.getTime() + 24 * 60 * 60 * 1000); // +1 day to get next
newResult = calculateNextPayday(startDate, 'monthly', nextFrom, 'America/New_York');
newDate = newResult.nextPayDate;
const drift = Math.round((oldDate - newDate) / (24 * 60 * 60 * 1000));
console.log(` ${String(i + 1).padStart(2)} | ${fmtISO(oldDate)} | ${fmtISO(newDate)} | ${drift > 0 ? '+' : ''}${drift} days`);
}
console.log('\n✓ NEW method stays on the 15th (in user\'s timezone)!');
console.log('✓ OLD method drifts 5-6 days early after 12 months\n');
console.log('═══════════════════════════════════════════════════════════════');
console.log(' ALL TESTS COMPLETE - Timezone handling verified');
console.log('═══════════════════════════════════════════════════════════════');