feat(auth): add per-username rate limiter, migrate dates to local-time utils (batch 0.38.1)
This commit is contained in:
parent
38c8bbd472
commit
947fa3bdf8
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue