feat(auth): add per-username rate limiter, migrate dates to local-time utils (batch 0.38.1)

This commit is contained in:
null 2026-06-10 19:42:51 -05:00
parent 38c8bbd472
commit 947fa3bdf8
17 changed files with 80 additions and 27 deletions

View File

@ -21,6 +21,25 @@ const loginLimiter = makeLimiter(
'Too many login attempts. Please try again in 15 minutes.', 'Too many login attempts. Please try again in 15 minutes.',
); );
// 5 FAILED login attempts per 15 minutes per username — layered on top of the
// per-IP limiter so a distributed attacker (or many clients behind one NAT/proxy
// sharing an IP bucket) cannot brute-force a single account. Successful logins
// don't count toward the limit, so legitimate users are unaffected.
const loginUsernameLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: 'draft-7',
legacyHeaders: false,
skipSuccessfulRequests: true,
keyGenerator: (req) => {
const username = String(req.body?.username || '').trim().toLowerCase();
return username ? `user:${username}` : ipKeyGenerator(req);
},
handler(req, res) {
res.status(429).json({ error: 'Too many failed login attempts for this account. Please try again in 15 minutes.' });
},
});
// 5 password-change attempts per 15 minutes per IP // 5 password-change attempts per 15 minutes per IP
const passwordLimiter = makeLimiter( const passwordLimiter = makeLimiter(
5, 15 * 60 * 1000, 5, 15 * 60 * 1000,
@ -78,6 +97,7 @@ const syncLimiter = rateLimit({
// ── Export all limiters plus reset function ──────────────────────────────────── // ── Export all limiters plus reset function ────────────────────────────────────
const allLimiters = [ const allLimiters = [
loginLimiter, loginLimiter,
loginUsernameLimiter,
passwordLimiter, passwordLimiter,
importLimiter, importLimiter,
exportLimiter, exportLimiter,
@ -98,6 +118,7 @@ function resetStores() {
module.exports = { module.exports = {
loginLimiter, loginLimiter,
loginUsernameLimiter,
passwordLimiter, passwordLimiter,
importLimiter, importLimiter,
exportLimiter, exportLimiter,

View File

@ -21,6 +21,7 @@ const {
accountingActiveSql, accountingActiveSql,
applyBankPaymentAsSourceOfTruth, applyBankPaymentAsSourceOfTruth,
} = require('../services/paymentAccountingService'); } = require('../services/paymentAccountingService');
const { localDateString, todayLocal } = require('../utils/dates');
// ── GET /api/bills ──────────────────────────────────────────────────────────── // ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => { router.get('/', (req, res) => {
@ -139,7 +140,7 @@ router.post('/:id/snooze-drift', (req, res) => {
if (!bill || bill.user_id !== req.user.id) return res.status(404).json({ error: 'Not found' }); if (!bill || bill.user_id !== req.user.id) return res.status(404).json({ error: 'Not found' });
const until = new Date(); const until = new Date();
until.setDate(until.getDate() + 30); until.setDate(until.getDate() + 30);
const untilStr = until.toISOString().slice(0, 10); const untilStr = localDateString(until);
db.prepare('UPDATE bills SET drift_snoozed_until = ? WHERE id = ?').run(untilStr, id); db.prepare('UPDATE bills SET drift_snoozed_until = ? WHERE id = ?').run(untilStr, id);
res.json({ ok: true, drift_snoozed_until: untilStr }); res.json({ ok: true, drift_snoozed_until: untilStr });
}); });
@ -455,7 +456,7 @@ router.put('/:id', (req, res) => {
const inactiveReason = typeof req.body.inactive_reason === 'string' ? req.body.inactive_reason.trim() || null : null; const inactiveReason = typeof req.body.inactive_reason === 'string' ? req.body.inactive_reason.trim() || null : null;
const wasActive = existing.active === 1; const wasActive = existing.active === 1;
const nowActive = normalized.active === 1; const nowActive = normalized.active === 1;
const inactivatedAt = (!wasActive || nowActive) ? existing.inactivated_at : (inactiveReason ? new Date().toISOString().slice(0, 10) : existing.inactivated_at); const inactivatedAt = (!wasActive || nowActive) ? existing.inactivated_at : (inactiveReason ? todayLocal() : existing.inactivated_at);
db.prepare(` db.prepare(`
UPDATE bills SET UPDATE bills SET
@ -726,7 +727,7 @@ router.post('/:id/toggle-paid', (req, res) => {
const day = Math.min(Math.max(Number(bill.due_day), 1), daysInMonth); const day = Math.min(Math.max(Number(bill.due_day), 1), daysInMonth);
paidDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; paidDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
} else if (!paidDate) { } else if (!paidDate) {
paidDate = new Date().toISOString().slice(0, 10); paidDate = todayLocal();
} }
const method = req.body.method || null; const method = req.body.method || null;
@ -1239,7 +1240,7 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => {
if (dom <= 5) { if (dom <= 5) {
const prevEnd = new Date(paid.getFullYear(), paid.getMonth(), 0); const prevEnd = new Date(paid.getFullYear(), paid.getMonth(), 0);
if (rules2.due_day <= prevEnd.getDate()) { if (rules2.due_day <= prevEnd.getDate()) {
const suggested = prevEnd.toISOString().slice(0, 10); const suggested = localDateString(prevEnd);
if (insertedPayment) { if (insertedPayment) {
lateAttributions.push({ payment_id: insertedPayment.id, bill_name: bill.name, original_date: paidDate, suggested_date: suggested, amount }); lateAttributions.push({ payment_id: insertedPayment.id, bill_name: bill.name, original_date: paidDate, suggested_date: suggested, amount });
} }

View File

@ -13,6 +13,7 @@ const {
regenerateToken, regenerateToken,
revokeToken, revokeToken,
} = require('../services/calendarFeedService'); } = require('../services/calendarFeedService');
const { localDateString } = require('../utils/dates');
function clampDay(year, month, day) { function clampDay(year, month, day) {
const daysInMonth = new Date(year, month, 0).getDate(); const daysInMonth = new Date(year, month, 0).getDate();
@ -106,7 +107,7 @@ router.get('/', (req, res) => {
return res.status(400).json(standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month')); return res.status(400).json(standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'));
} }
const today = now.toISOString().slice(0, 10); const today = localDateString(now);
const userSettings = getUserSettings(req.user.id); const userSettings = getUserSettings(req.user.id);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days }; const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const daysInMonth = new Date(year, month, 0).getDate(); const daysInMonth = new Date(year, month, 0).getDate();

View File

@ -7,6 +7,7 @@ const {
rejectMatchSuggestion, rejectMatchSuggestion,
} = require('../services/matchSuggestionService'); } = require('../services/matchSuggestionService');
const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService'); const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService');
const { todayLocal } = require('../utils/dates');
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') { function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {
if (err.status) { if (err.status) {
@ -55,7 +56,7 @@ router.post('/confirm', (req, res) => {
const existing = db.prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL').get(txId); const existing = db.prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL').get(txId);
if (existing) return res.status(409).json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH')); if (existing) return res.status(409).json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH'));
const paidDate = tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : new Date().toISOString().slice(0, 10)); const paidDate = tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal());
const amount = Math.round(Math.abs(tx.amount)) / 100; // cents → dollars const amount = Math.round(Math.abs(tx.amount)) / 100; // cents → dollars
try { try {

View File

@ -9,6 +9,7 @@ const {
markProvisionalManualPaymentsOverridden, markProvisionalManualPaymentsOverridden,
reactivatePaymentsOverriddenBy, reactivatePaymentsOverriddenBy,
} = require('../services/paymentAccountingService'); } = require('../services/paymentAccountingService');
const { todayLocal } = require('../utils/dates');
// SQL_NOT_DELETED is a compile-time constant SQL fragment, never user-supplied. // SQL_NOT_DELETED is a compile-time constant SQL fragment, never user-supplied.
// It cannot be a bind parameter (SQL fragments are not parameterisable — only // It cannot be a bind parameter (SQL fragments are not parameterisable — only
@ -230,7 +231,7 @@ router.post('/quick', (req, res) => {
const paymentValidation = validatePaymentInput( const paymentValidation = validatePaymentInput(
{ {
amount: amount != null ? amount : bill.expected_amount, amount: amount != null ? amount : bill.expected_amount,
paid_date: paid_date || new Date().toISOString().slice(0, 10), paid_date: paid_date || todayLocal(),
payment_source: payment_source ?? 'manual', payment_source: payment_source ?? 'manual',
}, },
{ requireBillId: false }, { requireBillId: false },
@ -267,7 +268,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month); const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month);
if (context.error) return res.status(context.status).json(context.error); if (context.error) return res.status(context.status).json(context.error);
const { bill, dueDate, amount } = context; const { bill, dueDate, amount } = context;
if (dueDate > new Date().toISOString().slice(0, 10)) { if (dueDate > todayLocal()) {
return res.status(400).json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date')); return res.status(400).json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date'));
} }
const paymentValidation = validatePaymentInput( const paymentValidation = validatePaymentInput(
@ -574,7 +575,7 @@ router.patch('/:id/attribute-to-month', (req, res) => {
return res.status(400).json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date')); return res.status(400).json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date'));
} }
// Validate it is a real calendar date // Validate it is a real calendar date
const newDate = new Date(paid_date + 'T00:00:00'); const newDate = new Date(paid_date + 'T00:00:00Z');
if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) { if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) {
return res.status(400).json(standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date')); return res.status(400).json(standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date'));
} }

View File

@ -10,6 +10,7 @@ const { checkForUpdates } = require('../services/updateCheckService');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
const { getBankSyncConfig } = require('../services/bankSyncConfigService'); const { getBankSyncConfig } = require('../services/bankSyncConfigService');
const { accountingActiveSql } = require('../services/paymentAccountingService'); const { accountingActiveSql } = require('../services/paymentAccountingService');
const { localDateString } = require('../utils/dates');
const startTime = Date.now(); const startTime = Date.now();
let pkg; let pkg;
@ -339,7 +340,7 @@ router.get('/', async (req, res) => {
ok: true, ok: true,
time: now.toISOString(), time: now.toISOString(),
now: now.toISOString(), now: now.toISOString(),
today: now.toISOString().slice(0, 10), today: localDateString(now),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || process.env.TZ || null, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || process.env.TZ || null,
utc_offset: -now.getTimezoneOffset() / 60, utc_offset: -now.getTimezoneOffset() / 60,
env: process.env.NODE_ENV || 'development', env: process.env.NODE_ENV || 'development',

View File

@ -14,6 +14,7 @@ const {
unmatchTransaction, unmatchTransaction,
} = require('../services/transactionMatchService'); } = require('../services/transactionMatchService');
const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService'); const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService');
const { todayLocal } = require('../utils/dates');
const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']); const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']);
const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']); const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']);
@ -28,7 +29,7 @@ const TEXT_FIELDS = {
}; };
function todayStr() { function todayStr() {
return new Date().toISOString().slice(0, 10); return todayLocal();
} }
function cleanText(value, maxLength) { function cleanText(value, maxLength) {

View File

@ -8,7 +8,7 @@ const { recordError } = require('./services/statusRu
const { securityHeaders } = require('./middleware/securityHeaders'); const { securityHeaders } = require('./middleware/securityHeaders');
const { logAudit } = require('./services/auditService'); const { logAudit } = require('./services/auditService');
const { errorFormatter } = require('./middleware/errorFormatter'); const { errorFormatter } = require('./middleware/errorFormatter');
const { importLimiter, exportLimiter, adminActionLimiter, oidcLimiter, loginLimiter, passwordLimiter, backupOperationLimiter } = const { importLimiter, exportLimiter, adminActionLimiter, oidcLimiter, loginLimiter, loginUsernameLimiter, passwordLimiter, backupOperationLimiter } =
require('./middleware/rateLimiter'); require('./middleware/rateLimiter');
const { csrfMiddleware, csrfTokenProvider } = require('./middleware/csrf'); const { csrfMiddleware, csrfTokenProvider } = require('./middleware/csrf');
@ -86,8 +86,12 @@ function skipRateLimitIfNoUsers(limiter) {
} }
// Mount login router with conditional rate limiting // Mount login router with conditional rate limiting
// If no users exist, rate limit is bypassed; otherwise it applies // If no users exist, rate limit is bypassed; otherwise it applies.
// Two layers: per-IP (all attempts) and per-username (failed attempts only),
// so one IP can't burn 10 tries against every account, and a distributed
// attacker can't brute-force a single account from many IPs.
app.use('/api/auth/login', skipRateLimitIfNoUsers(loginLimiter)); app.use('/api/auth/login', skipRateLimitIfNoUsers(loginLimiter));
app.use('/api/auth/login', skipRateLimitIfNoUsers(loginUsernameLimiter));
// Login skips CSRF inside routes/auth because no authenticated session exists yet. // Login skips CSRF inside routes/auth because no authenticated session exists yet.
// Authenticated state-changing auth routes, including logout-all and password // Authenticated state-changing auth routes, including logout-all and password
// changes, require the SPA's x-csrf-token header like other mutating requests. // changes, require the SPA's x-csrf-token header like other mutating requests.

View File

@ -3,6 +3,7 @@
const { normalizeMerchant } = require('./subscriptionService'); const { normalizeMerchant } = require('./subscriptionService');
const { getUserSettings } = require('./userSettings'); const { getUserSettings } = require('./userSettings');
const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService'); const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService');
const { localDateString } = require('../utils/dates');
// Word-boundary merchant match — requires the rule to appear as complete word(s) // Word-boundary merchant match — requires the rule to appear as complete word(s)
// within the transaction string (or vice versa), not just as a substring. // within the transaction string (or vice versa), not just as a substring.
@ -25,7 +26,7 @@ function lateAttributionCandidate(paidDateStr, dueDayOfMonth, graceDays = 5) {
if (dayOfMonth > graceDays) return null; if (dayOfMonth > graceDays) return null;
const prevMonthLastDay = new Date(paid.getFullYear(), paid.getMonth(), 0); const prevMonthLastDay = new Date(paid.getFullYear(), paid.getMonth(), 0);
if (dueDayOfMonth > prevMonthLastDay.getDate()) return null; if (dueDayOfMonth > prevMonthLastDay.getDate()) return null;
return prevMonthLastDay.toISOString().slice(0, 10); // suggested prior-month date return localDateString(prevMonthLastDay); // suggested prior-month date
} }
// Persist a merchant→bill rule so future synced transactions auto-match. // Persist a merchant→bill rule so future synced transactions auto-match.

View File

@ -1,3 +1,4 @@
const { monthKey } = require('../utils/dates');
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none']; const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
const TEMPLATE_FIELDS = [ const TEMPLATE_FIELDS = [
@ -478,7 +479,7 @@ function computeBalanceDelta(bill, paymentAmount) {
if (!Number.isFinite(bal) || bal <= 0) return null; if (!Number.isFinite(bal) || bal <= 0) return null;
if (!Number.isFinite(amt) || amt <= 0) return null; if (!Number.isFinite(amt) || amt <= 0) return null;
const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM" const currentMonth = monthKey(); // "YYYY-MM" (local time)
const applyInterest = rate > 0 && bill.interest_accrued_month !== currentMonth; const applyInterest = rate > 0 && bill.interest_accrued_month !== currentMonth;
const interestDelta = applyInterest ? Math.round(bal * (rate / 100 / 12) * 100) / 100 : 0; const interestDelta = applyInterest ? Math.round(bal * (rate / 100 / 12) * 100) / 100 : 0;

View File

@ -4,6 +4,7 @@ const { getDb } = require('../db/database');
const { getCycleRange } = require('./statusService'); const { getCycleRange } = require('./statusService');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { getUserSettings } = require('./userSettings'); const { getUserSettings } = require('./userSettings');
const { localDateString } = require('../utils/dates');
const MONTHS_BACK = 3; const MONTHS_BACK = 3;
const MIN_PAID_MONTHS = 2; const MIN_PAID_MONTHS = 2;
@ -37,7 +38,7 @@ function getDriftReport(userId, now = new Date()) {
WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
`).all(userId); `).all(userId);
const todayStr = now.toISOString().slice(0, 10); const todayStr = localDateString(now);
const drifted = []; const drifted = [];
const mbsStmt = db.prepare( const mbsStmt = db.prepare(

View File

@ -7,6 +7,7 @@ const {
markNotificationSuccess, markNotificationSuccess,
markNotificationTestSuccess, markNotificationTestSuccess,
} = require('./statusRuntime'); } = require('./statusRuntime');
const { localDateString } = require('../utils/dates');
// ── Push notification channels ──────────────────────────────────────────────── // ── Push notification channels ────────────────────────────────────────────────
@ -279,7 +280,7 @@ async function runNotifications() {
const now = new Date(); const now = new Date();
const year = now.getFullYear(); const year = now.getFullYear();
const month = now.getMonth() + 1; const month = now.getMonth() + 1;
const today = now.toISOString().slice(0, 10); const today = localDateString(now);
const { getCycleRange, resolveDueDate } = require('./statusService'); const { getCycleRange, resolveDueDate } = require('./statusService');
@ -315,7 +316,7 @@ async function runNotifications() {
// and the per-bill cycle check happens in memory below. // and the per-bill cycle check happens in memory below.
const billIds = bills.map(b => b.id); const billIds = bills.map(b => b.id);
const monthStart = `${year}-${String(month).padStart(2, '0')}-01`; const monthStart = `${year}-${String(month).padStart(2, '0')}-01`;
const monthEnd = new Date(year, month, 0).toISOString().slice(0, 10); const monthEnd = localDateString(new Date(year, month, 0));
const paidMap = new Map(); const paidMap = new Map();
if (billIds.length > 0) { if (billIds.length > 0) {
const placeholders = billIds.map(() => '?').join(','); const placeholders = billIds.map(() => '?').join(',');
@ -504,7 +505,7 @@ async function runDriftNotifications() {
const now = new Date(); const now = new Date();
const year = now.getFullYear(); const year = now.getFullYear();
const month = now.getMonth() + 1; const month = now.getMonth() + 1;
const today = now.toISOString().slice(0, 10); const today = localDateString(now);
const allowUserConfig = getSetting('notify_allow_user_config') === 'true'; const allowUserConfig = getSetting('notify_allow_user_config') === 'true';
const globalRecipient = getSetting('notify_global_recipient'); const globalRecipient = getSetting('notify_global_recipient');

View File

@ -1,5 +1,6 @@
'use strict'; 'use strict';
// SimpleFIN consumer client. // SimpleFIN consumer client.
// //
// This module handles the protocol-level work: claiming tokens, fetching // This module handles the protocol-level work: claiming tokens, fetching
@ -170,6 +171,10 @@ function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, accou
const amount = Math.round(parseFloat(rawTx.amount) * 100); const amount = Math.round(parseFloat(rawTx.amount) * 100);
// Pending transactions report posted = 0 (or omit it) until they settle. // Pending transactions report posted = 0 (or omit it) until they settle.
const isPending = rawTx.pending === true || rawTx.pending === 1; const isPending = rawTx.pending === true || rawTx.pending === 1;
// NOTE: deliberately a UTC slice, not localDateString(). SimpleFIN encodes
// the posted *date* as an epoch at UTC midnight, so the UTC calendar day IS
// the bank's posting date; converting to server-local time would shift it
// back a day for any timezone west of UTC.
const postedDate = (rawTx.posted && !isPending) const postedDate = (rawTx.posted && !isPending)
? new Date(rawTx.posted * 1000).toISOString().slice(0, 10) ? new Date(rawTx.posted * 1000).toISOString().slice(0, 10)
: null; : null;

View File

@ -1,6 +1,7 @@
'use strict'; 'use strict';
const { normalizeMerchant } = require('./subscriptionService'); const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates');
// Spending = unmatched outflows (amount < 0) that haven't been ignored. // Spending = unmatched outflows (amount < 0) that haven't been ignored.
// Bill-matched transactions are excluded so there's no double-counting. // Bill-matched transactions are excluded so there's no double-counting.
@ -13,7 +14,7 @@ const SPENDING_WHERE = `
function monthRange(year, month) { function monthRange(year, month) {
const start = `${year}-${String(month).padStart(2, '0')}-01`; const start = `${year}-${String(month).padStart(2, '0')}-01`;
const end = new Date(year, month, 0).toISOString().slice(0, 10); // last day const end = localDateString(new Date(year, month, 0)); // last day
return { start, end }; return { start, end };
} }

View File

@ -1,6 +1,7 @@
'use strict'; 'use strict';
const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService'); const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService');
const { localDateString, todayLocal } = require('../utils/dates');
const SUBSCRIPTION_TYPES = [ const SUBSCRIPTION_TYPES = [
'streaming', 'software', 'cloud', 'music', 'news', 'streaming', 'software', 'cloud', 'music', 'news',
@ -488,7 +489,7 @@ function nextDueDate(bill, now = new Date()) {
date = new Date(date.getFullYear(), date.getMonth() + step, dueDay); date = new Date(date.getFullYear(), date.getMonth() + step, dueDay);
} }
} }
return date.toISOString().slice(0, 10); return localDateString(date);
} }
function decorateSubscription(bill) { function decorateSubscription(bill) {
@ -1024,7 +1025,7 @@ function searchSubscriptionTransactions(db, userId, query = {}) {
} }
function createSubscriptionFromRecommendation(db, userId, payload = {}) { function createSubscriptionFromRecommendation(db, userId, payload = {}) {
const seenDate = payload.last_seen_date || new Date().toISOString().slice(0, 10); const seenDate = payload.last_seen_date || todayLocal();
const source = payload.catalog_match const source = payload.catalog_match
? 'catalog_match' ? 'catalog_match'
: 'simplefin_recommendation'; : 'simplefin_recommendation';

View File

@ -7,6 +7,7 @@ const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeAmountSuggestion } = require('./amountSuggestionService'); const { computeAmountSuggestion } = require('./amountSuggestionService');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { normalizeMerchant } = require('./subscriptionService'); const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates');
const DEFAULT_PENDING_DAYS = 3; const DEFAULT_PENDING_DAYS = 3;
@ -414,7 +415,7 @@ function getTracker(userId, query = {}, now = new Date()) {
const db = getDb(); const db = getDb();
const { year, month } = parsed; const { year, month } = parsed;
const todayStr = now.toISOString().slice(0, 10); const todayStr = localDateString(now);
const userSettings = getUserSettings(userId); const userSettings = getUserSettings(userId);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days }; const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const { start, end } = getCycleRange(year, month); const { start, end } = getCycleRange(year, month);
@ -593,14 +594,14 @@ function getTracker(userId, query = {}, now = new Date()) {
function getUpcomingBills(userId, query = {}, now = new Date()) { function getUpcomingBills(userId, query = {}, now = new Date()) {
const db = getDb(); const db = getDb();
const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365)); const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365));
const todayStr = now.toISOString().slice(0, 10); const todayStr = localDateString(now);
const userSettings = getUserSettings(userId); const userSettings = getUserSettings(userId);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days }; const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const bills = fetchActiveBills(db, userId, 'id'); const bills = fetchActiveBills(db, userId, 'id');
const cutoff = new Date(now); const cutoff = new Date(now);
cutoff.setDate(cutoff.getDate() + days); cutoff.setDate(cutoff.getDate() + days);
const cutoffStr = cutoff.toISOString().slice(0, 10); const cutoffStr = localDateString(cutoff);
const upcoming = []; const upcoming = [];
const seen = new Set(); const seen = new Set();
const monthCount = (cutoff.getFullYear() - now.getFullYear()) * 12 const monthCount = (cutoff.getFullYear() - now.getFullYear()) * 12
@ -645,7 +646,7 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
function getOverdueCount(userId, now = new Date()) { function getOverdueCount(userId, now = new Date()) {
const db = getDb(); const db = getDb();
const todayStr = now.toISOString().slice(0, 10); const todayStr = localDateString(now);
const year = now.getFullYear(); const year = now.getFullYear();
const month = now.getMonth() + 1; const month = now.getMonth() + 1;
const monthStr = String(month).padStart(2, '0'); const monthStr = String(month).padStart(2, '0');

View File

@ -30,4 +30,14 @@ function localDateStringDaysAgo(days, from = new Date()) {
return localDateString(d); return localDateString(d);
} }
module.exports = { localDateString, localYearMonth, localDateStringDaysAgo }; /** Today as YYYY-MM-DD in local time. Alias of localDateString(). */
function todayLocal() {
return localDateString(new Date());
}
/** YYYY-MM month key in local time (e.g. "2026-06"). */
function monthKey(date = new Date()) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
module.exports = { localDateString, localYearMonth, localDateStringDaysAgo, todayLocal, monthKey };