feat(money): cents migration stage 2 — schema flip to integer cents (batch 0.38.4)
This commit is contained in:
parent
bf66ab1ee6
commit
d6639f1385
|
|
@ -3367,6 +3367,46 @@ function runMigrations() {
|
||||||
console.log('[v1.02] users.geolocation_enabled added');
|
console.log('[v1.02] users.geolocation_enabled added');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.03',
|
||||||
|
description: 'money columns: dollars (REAL) -> integer cents',
|
||||||
|
run() {
|
||||||
|
const conv = [
|
||||||
|
['bills', ['expected_amount', 'current_balance', 'minimum_payment']],
|
||||||
|
['payments', ['amount', 'balance_delta', 'interest_delta']],
|
||||||
|
['monthly_bill_state', ['actual_amount']],
|
||||||
|
['monthly_starting_amounts', ['first_amount', 'fifteenth_amount', 'other_amount']],
|
||||||
|
['monthly_income', ['amount']],
|
||||||
|
['spending_budgets', ['amount']],
|
||||||
|
['snowball_plans', ['extra_payment']],
|
||||||
|
['users', ['snowball_extra_payment']],
|
||||||
|
];
|
||||||
|
for (const [table, cols] of conv) {
|
||||||
|
for (const col of cols) {
|
||||||
|
db.exec(`UPDATE ${table} SET ${col} = CAST(ROUND(${col} * 100) AS INTEGER) WHERE ${col} IS NOT NULL`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('[v1.03] money columns converted to integer cents');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.04',
|
||||||
|
description: 'bill_templates.data JSON: money fields dollars -> integer cents',
|
||||||
|
run() {
|
||||||
|
// v1.03 converted table columns but not money values embedded in the
|
||||||
|
// bill_templates.data JSON blob. Templates saved before v1.03 hold
|
||||||
|
// dollars; the template code now reads cents (serializeTemplateData).
|
||||||
|
for (const field of ['expected_amount', 'current_balance', 'minimum_payment']) {
|
||||||
|
db.exec(`
|
||||||
|
UPDATE bill_templates
|
||||||
|
SET data = json_set(data, '$.${field}',
|
||||||
|
CAST(ROUND(json_extract(data, '$.${field}') * 100) AS INTEGER))
|
||||||
|
WHERE json_extract(data, '$.${field}') IS NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
console.log('[v1.04] bill_templates.data money fields converted to integer cents');
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── users: notification columns ───────────────────────────────────────────
|
// ── users: notification columns ───────────────────────────────────────────
|
||||||
|
|
@ -3711,6 +3751,33 @@ function getDbPath() {
|
||||||
|
|
||||||
// Rollback SQL definitions
|
// Rollback SQL definitions
|
||||||
const ROLLBACK_SQL_MAP = {
|
const ROLLBACK_SQL_MAP = {
|
||||||
|
'v1.04': {
|
||||||
|
description: 'bill_templates.data JSON: money fields dollars -> integer cents',
|
||||||
|
sql: [
|
||||||
|
"UPDATE bill_templates SET data = json_set(data, '$.expected_amount', ROUND(json_extract(data, '$.expected_amount') / 100.0, 2)) WHERE json_extract(data, '$.expected_amount') IS NOT NULL",
|
||||||
|
"UPDATE bill_templates SET data = json_set(data, '$.current_balance', ROUND(json_extract(data, '$.current_balance') / 100.0, 2)) WHERE json_extract(data, '$.current_balance') IS NOT NULL",
|
||||||
|
"UPDATE bill_templates SET data = json_set(data, '$.minimum_payment', ROUND(json_extract(data, '$.minimum_payment') / 100.0, 2)) WHERE json_extract(data, '$.minimum_payment') IS NOT NULL",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'v1.03': {
|
||||||
|
description: 'money columns: dollars (REAL) -> integer cents',
|
||||||
|
sql: [
|
||||||
|
'UPDATE bills SET expected_amount = ROUND(expected_amount / 100.0, 2) WHERE expected_amount IS NOT NULL',
|
||||||
|
'UPDATE bills SET current_balance = ROUND(current_balance / 100.0, 2) WHERE current_balance IS NOT NULL',
|
||||||
|
'UPDATE bills SET minimum_payment = ROUND(minimum_payment / 100.0, 2) WHERE minimum_payment IS NOT NULL',
|
||||||
|
'UPDATE payments SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL',
|
||||||
|
'UPDATE payments SET balance_delta = ROUND(balance_delta / 100.0, 2) WHERE balance_delta IS NOT NULL',
|
||||||
|
'UPDATE payments SET interest_delta = ROUND(interest_delta / 100.0, 2) WHERE interest_delta IS NOT NULL',
|
||||||
|
'UPDATE monthly_bill_state SET actual_amount = ROUND(actual_amount / 100.0, 2) WHERE actual_amount IS NOT NULL',
|
||||||
|
'UPDATE monthly_starting_amounts SET first_amount = ROUND(first_amount / 100.0, 2) WHERE first_amount IS NOT NULL',
|
||||||
|
'UPDATE monthly_starting_amounts SET fifteenth_amount = ROUND(fifteenth_amount / 100.0, 2) WHERE fifteenth_amount IS NOT NULL',
|
||||||
|
'UPDATE monthly_starting_amounts SET other_amount = ROUND(other_amount / 100.0, 2) WHERE other_amount IS NOT NULL',
|
||||||
|
'UPDATE monthly_income SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL',
|
||||||
|
'UPDATE spending_budgets SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL',
|
||||||
|
'UPDATE snowball_plans SET extra_payment = ROUND(extra_payment / 100.0, 2) WHERE extra_payment IS NOT NULL',
|
||||||
|
'UPDATE users SET snowball_extra_payment = ROUND(snowball_extra_payment / 100.0, 2) WHERE snowball_extra_payment IS NOT NULL',
|
||||||
|
]
|
||||||
|
},
|
||||||
'v0.98': {
|
'v0.98': {
|
||||||
description: 'payments: bank override metadata for provisional manual payments',
|
description: 'payments: bank override metadata for provisional manual payments',
|
||||||
sql: [
|
sql: [
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS bills (
|
||||||
due_day INTEGER NOT NULL CHECK(due_day BETWEEN 1 AND 31),
|
due_day INTEGER NOT NULL CHECK(due_day BETWEEN 1 AND 31),
|
||||||
override_due_date TEXT,
|
override_due_date TEXT,
|
||||||
bucket TEXT CHECK(bucket IN ('1st', '15th')),
|
bucket TEXT CHECK(bucket IN ('1st', '15th')),
|
||||||
expected_amount REAL NOT NULL DEFAULT 0,
|
expected_amount INTEGER NOT NULL DEFAULT 0, -- cents
|
||||||
interest_rate REAL CHECK(interest_rate IS NULL OR (interest_rate >= 0 AND interest_rate <= 100)),
|
interest_rate REAL CHECK(interest_rate IS NULL OR (interest_rate >= 0 AND interest_rate <= 100)),
|
||||||
billing_cycle TEXT DEFAULT 'monthly' CHECK(billing_cycle IN ('monthly', 'quarterly', 'annually', 'irregular')),
|
billing_cycle TEXT DEFAULT 'monthly' CHECK(billing_cycle IN ('monthly', 'quarterly', 'annually', 'irregular')),
|
||||||
cycle_type TEXT NOT NULL DEFAULT 'monthly' CHECK(cycle_type IN ('monthly', 'weekly', 'biweekly', 'quarterly', 'annual')),
|
cycle_type TEXT NOT NULL DEFAULT 'monthly' CHECK(cycle_type IN ('monthly', 'weekly', 'biweekly', 'quarterly', 'annual')),
|
||||||
|
|
@ -32,8 +32,8 @@ CREATE TABLE IF NOT EXISTS bills (
|
||||||
account_info TEXT,
|
account_info TEXT,
|
||||||
has_2fa INTEGER NOT NULL DEFAULT 0,
|
has_2fa INTEGER NOT NULL DEFAULT 0,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
current_balance REAL,
|
current_balance INTEGER, -- cents
|
||||||
minimum_payment REAL,
|
minimum_payment INTEGER, -- cents
|
||||||
snowball_order INTEGER,
|
snowball_order INTEGER,
|
||||||
sort_order INTEGER,
|
sort_order INTEGER,
|
||||||
snowball_include INTEGER NOT NULL DEFAULT 0,
|
snowball_include INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
@ -53,11 +53,11 @@ CREATE TABLE IF NOT EXISTS bills (
|
||||||
CREATE TABLE IF NOT EXISTS payments (
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
||||||
amount REAL NOT NULL,
|
amount INTEGER NOT NULL, -- cents
|
||||||
paid_date TEXT NOT NULL,
|
paid_date TEXT NOT NULL,
|
||||||
method TEXT,
|
method TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
balance_delta REAL,
|
balance_delta INTEGER, -- cents
|
||||||
payment_source TEXT NOT NULL DEFAULT 'manual',
|
payment_source TEXT NOT NULL DEFAULT 'manual',
|
||||||
transaction_id INTEGER,
|
transaction_id INTEGER,
|
||||||
accounting_excluded INTEGER NOT NULL DEFAULT 0,
|
accounting_excluded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
@ -85,7 +85,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||||
is_default_admin INTEGER NOT NULL DEFAULT 0,
|
is_default_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||||
first_login INTEGER NOT NULL DEFAULT 1,
|
first_login INTEGER NOT NULL DEFAULT 1,
|
||||||
snowball_extra_payment REAL NOT NULL DEFAULT 0,
|
snowball_extra_payment INTEGER NOT NULL DEFAULT 0, -- cents
|
||||||
notify_amount_change INTEGER NOT NULL DEFAULT 1,
|
notify_amount_change INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
|
@ -228,7 +228,7 @@ CREATE TABLE IF NOT EXISTS monthly_bill_state (
|
||||||
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
||||||
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
||||||
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
||||||
actual_amount REAL, -- NULL = use bill.expected_amount for this month
|
actual_amount INTEGER, -- cents; NULL = use bill.expected_amount for this month
|
||||||
notes TEXT, -- month-specific notes, NULL = no notes
|
notes TEXT, -- month-specific notes, NULL = no notes
|
||||||
is_skipped INTEGER NOT NULL DEFAULT 0, -- 1 = hidden/removed for this month only
|
is_skipped INTEGER NOT NULL DEFAULT 0, -- 1 = hidden/removed for this month only
|
||||||
snoozed_until TEXT, -- ISO date: hide from overdue command center until this date
|
snoozed_until TEXT, -- ISO date: hide from overdue command center until this date
|
||||||
|
|
@ -291,7 +291,7 @@ CREATE TABLE IF NOT EXISTS snowball_plans (
|
||||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
paused_at TEXT,
|
paused_at TEXT,
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
extra_payment REAL NOT NULL DEFAULT 0,
|
extra_payment INTEGER NOT NULL DEFAULT 0, -- cents
|
||||||
plan_snapshot TEXT NOT NULL,
|
plan_snapshot TEXT NOT NULL,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const {
|
||||||
auditBillsForUser,
|
auditBillsForUser,
|
||||||
categoryBelongsToUser,
|
categoryBelongsToUser,
|
||||||
insertBill,
|
insertBill,
|
||||||
|
serializeBill,
|
||||||
parseTemplateData,
|
parseTemplateData,
|
||||||
sanitizeTemplateData,
|
sanitizeTemplateData,
|
||||||
validateBillData,
|
validateBillData,
|
||||||
|
|
@ -13,7 +14,7 @@ const {
|
||||||
} = require('../services/billsService');
|
} = require('../services/billsService');
|
||||||
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
|
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
const { validatePaymentInput } = require('../services/paymentValidation');
|
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation');
|
||||||
const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService');
|
const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService');
|
||||||
const { normalizeMerchant } = require('../services/subscriptionService');
|
const { normalizeMerchant } = require('../services/subscriptionService');
|
||||||
const { decorateTransaction } = require('../services/transactionService');
|
const { decorateTransaction } = require('../services/transactionService');
|
||||||
|
|
@ -22,7 +23,7 @@ const {
|
||||||
applyBankPaymentAsSourceOfTruth,
|
applyBankPaymentAsSourceOfTruth,
|
||||||
} = require('../services/paymentAccountingService');
|
} = require('../services/paymentAccountingService');
|
||||||
const { localDateString, todayLocal } = require('../utils/dates');
|
const { localDateString, todayLocal } = require('../utils/dates');
|
||||||
const { roundMoney, sumMoney } = require('../utils/money');
|
const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
// ── GET /api/bills ────────────────────────────────────────────────────────────
|
// ── GET /api/bills ────────────────────────────────────────────────────────────
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
|
@ -45,7 +46,7 @@ router.get('/', (req, res) => {
|
||||||
${includeInactive ? '' : 'AND b.active = 1'}
|
${includeInactive ? '' : 'AND b.active = 1'}
|
||||||
ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC
|
ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
res.json(bills);
|
res.json(bills.map(serializeBill));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT /api/bills/reorder ───────────────────────────────────────────────────
|
// ── PUT /api/bills/reorder ───────────────────────────────────────────────────
|
||||||
|
|
@ -92,7 +93,7 @@ router.put('/reorder', (req, res) => {
|
||||||
ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC
|
ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
|
|
||||||
res.json({ success: true, bills });
|
res.json({ success: true, bills: bills.map(serializeBill) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET /api/bills/audit?inactive=true ───────────────────────────────────────
|
// ── GET /api/bills/audit?inactive=true ───────────────────────────────────────
|
||||||
|
|
@ -146,6 +147,18 @@ router.post('/:id/snooze-drift', (req, res) => {
|
||||||
res.json({ ok: true, drift_snoozed_until: untilStr });
|
res.json({ ok: true, drift_snoozed_until: untilStr });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bill templates store money fields (expected_amount, current_balance, minimum_payment)
|
||||||
|
// in integer cents, matching validateBillData's normalized output. Convert back to
|
||||||
|
// dollars for API responses, mirroring serializeBill.
|
||||||
|
function serializeTemplateData(data) {
|
||||||
|
if (!data) return data;
|
||||||
|
const out = { ...data };
|
||||||
|
if (out.expected_amount != null) out.expected_amount = fromCents(out.expected_amount);
|
||||||
|
if (out.current_balance != null) out.current_balance = fromCents(out.current_balance);
|
||||||
|
if (out.minimum_payment != null) out.minimum_payment = fromCents(out.minimum_payment);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// ── GET /api/bills/templates ─────────────────────────────────────────────────
|
// ── GET /api/bills/templates ─────────────────────────────────────────────────
|
||||||
router.get('/templates', (req, res) => {
|
router.get('/templates', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
@ -158,7 +171,7 @@ router.get('/templates', (req, res) => {
|
||||||
|
|
||||||
res.json(rows.map(row => ({
|
res.json(rows.map(row => ({
|
||||||
...row,
|
...row,
|
||||||
data: parseTemplateData(row.data),
|
data: serializeTemplateData(parseTemplateData(row.data)),
|
||||||
})));
|
})));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -200,7 +213,7 @@ router.post('/templates', (req, res) => {
|
||||||
|
|
||||||
res.status(result.changes > 0 ? 201 : 200).json({
|
res.status(result.changes > 0 ? 201 : 200).json({
|
||||||
...template,
|
...template,
|
||||||
data: parseTemplateData(template.data),
|
data: serializeTemplateData(parseTemplateData(template.data)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -228,7 +241,7 @@ router.post('/:id/duplicate', (req, res) => {
|
||||||
if (!source) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!source) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const draft = {
|
const draft = {
|
||||||
...sanitizeTemplateData(source),
|
...sanitizeTemplateData(serializeBill(source)),
|
||||||
...sanitizeTemplateData(body),
|
...sanitizeTemplateData(body),
|
||||||
name: String(body.name || `${source.name} (Copy)`).trim(),
|
name: String(body.name || `${source.name} (Copy)`).trim(),
|
||||||
};
|
};
|
||||||
|
|
@ -242,7 +255,7 @@ router.post('/:id/duplicate', (req, res) => {
|
||||||
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).json(insertBill(db, req.user.id, normalized));
|
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
|
// ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
|
||||||
|
|
@ -267,7 +280,7 @@ router.get('/:id/monthly-state', (req, res) => {
|
||||||
bill_id: billId,
|
bill_id: billId,
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
actual_amount: mbs?.actual_amount ?? null,
|
actual_amount: fromCents(mbs?.actual_amount),
|
||||||
notes: mbs?.notes ?? null,
|
notes: mbs?.notes ?? null,
|
||||||
is_skipped: !!(mbs?.is_skipped),
|
is_skipped: !!(mbs?.is_skipped),
|
||||||
});
|
});
|
||||||
|
|
@ -306,7 +319,7 @@ router.put('/:id/monthly-state', (req, res) => {
|
||||||
'SELECT actual_amount, notes, is_skipped, snoozed_until FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
|
'SELECT actual_amount, notes, is_skipped, snoozed_until FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
|
||||||
).get(billId, y, m);
|
).get(billId, y, m);
|
||||||
|
|
||||||
const amt = actual_amount !== undefined ? (actual_amount === null ? null : parseFloat(actual_amount)) : (existing?.actual_amount ?? null);
|
const amt = actual_amount !== undefined ? (actual_amount === null ? null : toCents(actual_amount)) : (existing?.actual_amount ?? null);
|
||||||
const noteVal = notes !== undefined ? (notes || null) : (existing?.notes ?? null);
|
const noteVal = notes !== undefined ? (notes || null) : (existing?.notes ?? null);
|
||||||
const skipVal = is_skipped !== undefined ? (is_skipped ? 1 : 0) : (existing?.is_skipped ?? 0);
|
const skipVal = is_skipped !== undefined ? (is_skipped ? 1 : 0) : (existing?.is_skipped ?? 0);
|
||||||
const snoozeVal = snoozed_until !== undefined ? (snoozed_until || null) : (existing?.snoozed_until ?? null);
|
const snoozeVal = snoozed_until !== undefined ? (snoozed_until || null) : (existing?.snoozed_until ?? null);
|
||||||
|
|
@ -330,7 +343,7 @@ router.put('/:id/monthly-state', (req, res) => {
|
||||||
bill_id: saved.bill_id,
|
bill_id: saved.bill_id,
|
||||||
year: saved.year,
|
year: saved.year,
|
||||||
month: saved.month,
|
month: saved.month,
|
||||||
actual_amount: saved.actual_amount,
|
actual_amount: fromCents(saved.actual_amount),
|
||||||
notes: saved.notes,
|
notes: saved.notes,
|
||||||
is_skipped: !!saved.is_skipped,
|
is_skipped: !!saved.is_skipped,
|
||||||
snoozed_until: saved.snoozed_until ?? null,
|
snoozed_until: saved.snoozed_until ?? null,
|
||||||
|
|
@ -377,7 +390,7 @@ router.get('/:id', (req, res) => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ ...bill, autopay_stats });
|
res.json({ ...serializeBill(bill), autopay_stats });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── POST /api/bills/:id/verify-autopay ───────────────────────────────────────
|
// ── POST /api/bills/:id/verify-autopay ───────────────────────────────────────
|
||||||
|
|
@ -411,7 +424,7 @@ router.post('/', (req, res) => {
|
||||||
const source = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(sourceBillId, req.user.id);
|
const source = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(sourceBillId, req.user.id);
|
||||||
if (!source) return res.status(404).json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id'));
|
if (!source) return res.status(404).json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id'));
|
||||||
payload = {
|
payload = {
|
||||||
...sanitizeTemplateData(source),
|
...sanitizeTemplateData(serializeBill(source)),
|
||||||
...sanitizeTemplateData(body),
|
...sanitizeTemplateData(body),
|
||||||
name: String(body.name || `${source.name} (Copy)`).trim(),
|
name: String(body.name || `${source.name} (Copy)`).trim(),
|
||||||
};
|
};
|
||||||
|
|
@ -431,7 +444,7 @@ router.post('/', (req, res) => {
|
||||||
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).json(insertBill(db, req.user.id, normalized));
|
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
|
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
|
||||||
|
|
@ -508,7 +521,7 @@ router.put('/:id', (req, res) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
|
const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
|
||||||
res.json(updated);
|
res.json(serializeBill(updated));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT /api/bills/:id/archived ──────────────────────────────────────────────
|
// ── PUT /api/bills/:id/archived ──────────────────────────────────────────────
|
||||||
|
|
@ -526,7 +539,7 @@ router.put('/:id/archived', (req, res) => {
|
||||||
.run(archived ? 0 : 1, id, req.user.id);
|
.run(archived ? 0 : 1, id, req.user.id);
|
||||||
|
|
||||||
const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(id, req.user.id);
|
const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(id, req.user.id);
|
||||||
res.json({ ...updated, archived: !updated.active });
|
res.json({ ...serializeBill(updated), archived: !updated.active });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE /api/bills/:id — soft delete for 30-day recovery ───────────────────
|
// ── DELETE /api/bills/:id — soft delete for 30-day recovery ───────────────────
|
||||||
|
|
@ -555,7 +568,7 @@ router.post('/:id/restore', (req, res) => {
|
||||||
db.prepare("UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
db.prepare("UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
||||||
.run(req.params.id, req.user.id);
|
.run(req.params.id, req.user.id);
|
||||||
|
|
||||||
res.json(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id));
|
res.json(serializeBill(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/bills/:id/sync-simplefin-payments
|
// POST /api/bills/:id/sync-simplefin-payments
|
||||||
|
|
@ -600,7 +613,7 @@ router.get('/:id/payments', (req, res) => {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
pages: Math.ceil(total / limit),
|
pages: Math.ceil(total / limit),
|
||||||
payments: items,
|
payments: items.map(serializePayment),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -647,7 +660,7 @@ router.get('/:id/transactions', (req, res) => {
|
||||||
...row,
|
...row,
|
||||||
linked_payment: row.linked_payment_id ? {
|
linked_payment: row.linked_payment_id ? {
|
||||||
id: row.linked_payment_id,
|
id: row.linked_payment_id,
|
||||||
amount: row.linked_payment_amount,
|
amount: fromCents(row.linked_payment_amount),
|
||||||
paid_date: row.linked_payment_date,
|
paid_date: row.linked_payment_date,
|
||||||
payment_source: row.linked_payment_source,
|
payment_source: row.linked_payment_source,
|
||||||
method: row.linked_payment_method,
|
method: row.linked_payment_method,
|
||||||
|
|
@ -702,7 +715,7 @@ router.post('/:id/toggle-paid', (req, res) => {
|
||||||
if (currentPayment.balance_delta != null) {
|
if (currentPayment.balance_delta != null) {
|
||||||
const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId);
|
const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId);
|
||||||
if (freshBill?.current_balance != null) {
|
if (freshBill?.current_balance != null) {
|
||||||
const restored = Math.max(0, roundMoney(freshBill.current_balance - currentPayment.balance_delta));
|
const restored = Math.max(0, freshBill.current_balance - currentPayment.balance_delta);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, billId);
|
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, billId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -718,7 +731,7 @@ router.post('/:id/toggle-paid', (req, res) => {
|
||||||
|
|
||||||
// If unpaid, create payment → Paid
|
// If unpaid, create payment → Paid
|
||||||
// Use expected_amount if no amount provided
|
// Use expected_amount if no amount provided
|
||||||
const amount = req.body.amount !== undefined ? req.body.amount : bill.expected_amount;
|
const amount = req.body.amount !== undefined ? req.body.amount : fromCents(bill.expected_amount);
|
||||||
|
|
||||||
// Determine paid_date
|
// Determine paid_date
|
||||||
let paidDate = req.body.paid_date;
|
let paidDate = req.body.paid_date;
|
||||||
|
|
@ -755,7 +768,7 @@ router.post('/:id/toggle-paid', (req, res) => {
|
||||||
success: true,
|
success: true,
|
||||||
isPaid: true,
|
isPaid: true,
|
||||||
action: 'created_payment',
|
action: 'created_payment',
|
||||||
payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid),
|
payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -886,9 +899,9 @@ router.get('/:id/amortization', (req, res) => {
|
||||||
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
||||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
const balance = Number(bill.current_balance);
|
const balance = fromCents(Number(bill.current_balance));
|
||||||
const apr = Number(bill.interest_rate) || 0;
|
const apr = Number(bill.interest_rate) || 0;
|
||||||
const minPmt = Number(bill.minimum_payment) || 0;
|
const minPmt = fromCents(Number(bill.minimum_payment) || 0);
|
||||||
|
|
||||||
// Optional override: ?payment=X lets callers model "what if I pay more?"
|
// Optional override: ?payment=X lets callers model "what if I pay more?"
|
||||||
let payment = minPmt;
|
let payment = minPmt;
|
||||||
|
|
@ -912,7 +925,7 @@ router.get('/:id/amortization', (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = amortizationSchedule(balance, apr, payment, maxMonths);
|
const schedule = amortizationSchedule(balance, apr, payment, maxMonths);
|
||||||
const apr_snapshot = debtAprSnapshot(bill);
|
const apr_snapshot = debtAprSnapshot({ ...bill, current_balance: balance, minimum_payment: minPmt });
|
||||||
const total_interest = schedule.reduce((s, r) => s + r.interest, 0);
|
const total_interest = schedule.reduce((s, r) => s + r.interest, 0);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|
@ -968,7 +981,7 @@ router.patch('/:id/balance', (req, res) => {
|
||||||
val = roundMoney(val);
|
val = roundMoney(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(val, billId);
|
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(toCents(val), billId);
|
||||||
res.json({ id: billId, current_balance: val });
|
res.json({ id: billId, current_balance: val });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1221,10 +1234,11 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => {
|
||||||
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
||||||
if (!paidDate) continue;
|
if (!paidDate) continue;
|
||||||
|
|
||||||
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
const amountCents = Math.round(Math.abs(tx.amount));
|
||||||
|
const amount = fromCents(amountCents);
|
||||||
const billRow = getBill.get(billId);
|
const billRow = getBill.get(billId);
|
||||||
|
|
||||||
const result = insertPayment.run(billId, amount, paidDate, txId);
|
const result = insertPayment.run(billId, amountCents, paidDate, txId);
|
||||||
if (result.changes > 0) {
|
if (result.changes > 0) {
|
||||||
const insertedPayment = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(txId, billId);
|
const insertedPayment = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(txId, billId);
|
||||||
updateTx.run(billId, txId);
|
updateTx.run(billId, txId);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const {
|
||||||
revokeToken,
|
revokeToken,
|
||||||
} = require('../services/calendarFeedService');
|
} = require('../services/calendarFeedService');
|
||||||
const { localDateString } = require('../utils/dates');
|
const { localDateString } = require('../utils/dates');
|
||||||
const { roundMoney, sumMoney } = require('../utils/money');
|
const { roundMoney, sumMoney, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
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();
|
||||||
|
|
@ -161,16 +161,17 @@ router.get('/', (req, res) => {
|
||||||
for (const payment of payments) {
|
for (const payment of payments) {
|
||||||
const day = dayByDate.get(payment.paid_date);
|
const day = dayByDate.get(payment.paid_date);
|
||||||
if (day) {
|
if (day) {
|
||||||
|
const amount = fromCents(payment.amount);
|
||||||
day.payments.push({
|
day.payments.push({
|
||||||
payment_id: payment.payment_id,
|
payment_id: payment.payment_id,
|
||||||
bill_id: payment.bill_id,
|
bill_id: payment.bill_id,
|
||||||
bill_name: payment.bill_name,
|
bill_name: payment.bill_name,
|
||||||
amount: payment.amount,
|
amount,
|
||||||
paid_date: payment.paid_date,
|
paid_date: payment.paid_date,
|
||||||
method: payment.method || null,
|
method: payment.method || null,
|
||||||
notes: payment.notes || null,
|
notes: payment.notes || null,
|
||||||
});
|
});
|
||||||
day.status_summary.total_paid += payment.amount || 0;
|
day.status_summary.total_paid += amount || 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +184,7 @@ router.get('/', (req, res) => {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
const monthlyState = monthlyStateStmt.get(bill.id, year, month);
|
const monthlyState = monthlyStateStmt.get(bill.id, year, month);
|
||||||
const actualAmount = monthlyState?.actual_amount ?? null;
|
const actualAmount = fromCents(monthlyState?.actual_amount);
|
||||||
const isSkipped = !!monthlyState?.is_skipped;
|
const isSkipped = !!monthlyState?.is_skipped;
|
||||||
const effectiveAmount = actualAmount ?? row.expected_amount;
|
const effectiveAmount = actualAmount ?? row.expected_amount;
|
||||||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= effectiveAmount;
|
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= effectiveAmount;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const fs = require('fs');
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const xlsx = require('xlsx');
|
const xlsx = require('xlsx');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
|
const { fromCents } = require('../utils/money');
|
||||||
|
|
||||||
// GET /api/export?year=2026&format=csv
|
// GET /api/export?year=2026&format=csv
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
|
@ -58,11 +59,11 @@ router.get('/', (req, res) => {
|
||||||
r.paid_date,
|
r.paid_date,
|
||||||
escCsv(r.bill_name),
|
escCsv(r.bill_name),
|
||||||
escCsv(r.category),
|
escCsv(r.category),
|
||||||
r.expected_amount.toFixed(2),
|
fromCents(r.expected_amount).toFixed(2),
|
||||||
r.paid_amount.toFixed(2),
|
fromCents(r.paid_amount).toFixed(2),
|
||||||
escCsv(r.method),
|
escCsv(r.method),
|
||||||
escCsv(r.notes),
|
escCsv(r.notes),
|
||||||
mbs?.actual_amount != null ? mbs.actual_amount.toFixed(2) : '',
|
mbs?.actual_amount != null ? fromCents(mbs.actual_amount).toFixed(2) : '',
|
||||||
escCsv(mbs?.notes ?? null),
|
escCsv(mbs?.notes ?? null),
|
||||||
].join(',');
|
].join(',');
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
@ -79,7 +80,9 @@ router.get('/', (req, res) => {
|
||||||
const mbs = mbsStmt.get(r.bill_id, paidYear, paidMonth);
|
const mbs = mbsStmt.get(r.bill_id, paidYear, paidMonth);
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
actual_amount: mbs?.actual_amount ?? null,
|
expected_amount: fromCents(r.expected_amount),
|
||||||
|
paid_amount: fromCents(r.paid_amount),
|
||||||
|
actual_amount: fromCents(mbs?.actual_amount ?? null),
|
||||||
monthly_notes: mbs?.notes ?? null,
|
monthly_notes: mbs?.notes ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -96,7 +99,7 @@ function getUserExportData(userId) {
|
||||||
FROM bills
|
FROM bills
|
||||||
WHERE user_id = ? AND deleted_at IS NULL
|
WHERE user_id = ? AND deleted_at IS NULL
|
||||||
ORDER BY active DESC, due_day ASC, name ASC
|
ORDER BY active DESC, due_day ASC, name ASC
|
||||||
`).all(userId);
|
`).all(userId).map(b => ({ ...b, expected_amount: fromCents(b.expected_amount) }));
|
||||||
const payments = db.prepare(`
|
const payments = db.prepare(`
|
||||||
SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes,
|
SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes,
|
||||||
CASE WHEN p.payment_source = 'transaction_match' THEN 'manual' ELSE p.payment_source END AS payment_source,
|
CASE WHEN p.payment_source = 'transaction_match' THEN 'manual' ELSE p.payment_source END AS payment_source,
|
||||||
|
|
@ -105,20 +108,25 @@ function getUserExportData(userId) {
|
||||||
JOIN bills b ON b.id = p.bill_id
|
JOIN bills b ON b.id = p.bill_id
|
||||||
WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.deleted_at IS NULL
|
WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.deleted_at IS NULL
|
||||||
ORDER BY p.paid_date ASC, p.id ASC
|
ORDER BY p.paid_date ASC, p.id ASC
|
||||||
`).all(userId);
|
`).all(userId).map(p => ({ ...p, amount: fromCents(p.amount) }));
|
||||||
const monthlyState = db.prepare(`
|
const monthlyState = db.prepare(`
|
||||||
SELECT m.id, m.bill_id, m.year, m.month, m.actual_amount, m.notes, m.is_skipped, m.created_at, m.updated_at
|
SELECT m.id, m.bill_id, m.year, m.month, m.actual_amount, m.notes, m.is_skipped, m.created_at, m.updated_at
|
||||||
FROM monthly_bill_state m
|
FROM monthly_bill_state m
|
||||||
JOIN bills b ON b.id = m.bill_id
|
JOIN bills b ON b.id = m.bill_id
|
||||||
WHERE b.user_id = ? AND b.deleted_at IS NULL
|
WHERE b.user_id = ? AND b.deleted_at IS NULL
|
||||||
ORDER BY m.year, m.month, m.bill_id
|
ORDER BY m.year, m.month, m.bill_id
|
||||||
`).all(userId);
|
`).all(userId).map(m => ({ ...m, actual_amount: fromCents(m.actual_amount) }));
|
||||||
const monthlyStartingAmounts = db.prepare(`
|
const monthlyStartingAmounts = db.prepare(`
|
||||||
SELECT id, year, month, first_amount, fifteenth_amount, other_amount, notes, created_at, updated_at
|
SELECT id, year, month, first_amount, fifteenth_amount, other_amount, notes, created_at, updated_at
|
||||||
FROM monthly_starting_amounts
|
FROM monthly_starting_amounts
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY year, month
|
ORDER BY year, month
|
||||||
`).all(userId);
|
`).all(userId).map(r => ({
|
||||||
|
...r,
|
||||||
|
first_amount: fromCents(r.first_amount),
|
||||||
|
fifteenth_amount: fromCents(r.fifteenth_amount),
|
||||||
|
other_amount: fromCents(r.other_amount),
|
||||||
|
}));
|
||||||
const historyRanges = db.prepare(`
|
const historyRanges = db.prepare(`
|
||||||
SELECT id, bill_id, start_year, start_month, end_year, end_month, label, created_at, updated_at
|
SELECT id, bill_id, start_year, start_month, end_year, end_month, label, created_at, updated_at
|
||||||
FROM bill_history_ranges
|
FROM bill_history_ranges
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const {
|
||||||
rejectMatchSuggestion,
|
rejectMatchSuggestion,
|
||||||
} = require('../services/matchSuggestionService');
|
} = require('../services/matchSuggestionService');
|
||||||
const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService');
|
const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService');
|
||||||
|
const { serializePayment } = require('../services/paymentValidation');
|
||||||
const { todayLocal } = require('../utils/dates');
|
const { todayLocal } = require('../utils/dates');
|
||||||
|
|
||||||
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {
|
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {
|
||||||
|
|
@ -57,7 +58,7 @@ router.post('/confirm', (req, res) => {
|
||||||
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) : todayLocal());
|
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)); // tx.amount and payments.amount are both cents
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
|
|
@ -87,7 +88,7 @@ router.post('/confirm', (req, res) => {
|
||||||
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.deleted_at IS NULL
|
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.deleted_at IS NULL
|
||||||
WHERE t.id = ?
|
WHERE t.id = ?
|
||||||
`).get(txId);
|
`).get(txId);
|
||||||
res.json({ transaction: updated, payment });
|
res.json({ transaction: updated, payment: serializePayment(payment) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { db.exec('ROLLBACK'); } catch {}
|
try { db.exec('ROLLBACK'); } catch {}
|
||||||
return sendMatchError(res, err, 'Failed to confirm match');
|
return sendMatchError(res, err, 'Failed to confirm match');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const router = express.Router();
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { getCycleRange } = require('../services/statusService');
|
const { getCycleRange } = require('../services/statusService');
|
||||||
const { accountingActiveSql } = require('../services/paymentAccountingService');
|
const { accountingActiveSql } = require('../services/paymentAccountingService');
|
||||||
|
const { toCents, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
function parseYearMonth(source) {
|
function parseYearMonth(source) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -32,9 +33,9 @@ function getStartingAmounts(db, userId, year, month) {
|
||||||
`).get(userId, year, month);
|
`).get(userId, year, month);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
first_amount: money(row?.first_amount || 0),
|
first_amount: fromCents(row?.first_amount || 0),
|
||||||
fifteenth_amount: money(row?.fifteenth_amount || 0),
|
fifteenth_amount: fromCents(row?.fifteenth_amount || 0),
|
||||||
other_amount: money(row?.other_amount || 0),
|
other_amount: fromCents(row?.other_amount || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,10 +89,10 @@ function calculatePaidDeductions(db, userId, year, month) {
|
||||||
`).get(userId, start, end);
|
`).get(userId, start, end);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paid_from_first: money(firstPaid.paid),
|
paid_from_first: fromCents(firstPaid.paid),
|
||||||
paid_from_fifteenth: money(fifteenthPaid.paid),
|
paid_from_fifteenth: fromCents(fifteenthPaid.paid),
|
||||||
paid_from_other: money(otherPaid.paid),
|
paid_from_other: fromCents(otherPaid.paid),
|
||||||
paid_total: money(totalPaid.paid),
|
paid_total: fromCents(totalPaid.paid),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,7 +157,7 @@ router.put('/', (req, res) => {
|
||||||
fifteenth_amount = excluded.fifteenth_amount,
|
fifteenth_amount = excluded.fifteenth_amount,
|
||||||
other_amount = excluded.other_amount,
|
other_amount = excluded.other_amount,
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
`).run(req.user.id, parsed.year, parsed.month, firstAmount, fifteenthAmount, otherAmount);
|
`).run(req.user.id, parsed.year, parsed.month, toCents(firstAmount), toCents(fifteenthAmount), toCents(otherAmount));
|
||||||
|
|
||||||
res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
|
res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@ const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
|
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
|
||||||
const { validatePaymentInput } = require('../services/paymentValidation');
|
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation');
|
||||||
const { getCycleRange, resolveDueDate } = require('../services/statusService');
|
const { getCycleRange, resolveDueDate } = require('../services/statusService');
|
||||||
const {
|
const {
|
||||||
markProvisionalManualPaymentsOverridden,
|
markProvisionalManualPaymentsOverridden,
|
||||||
reactivatePaymentsOverriddenBy,
|
reactivatePaymentsOverriddenBy,
|
||||||
} = require('../services/paymentAccountingService');
|
} = require('../services/paymentAccountingService');
|
||||||
const { todayLocal } = require('../utils/dates');
|
const { todayLocal } = require('../utils/dates');
|
||||||
|
const { fromCents } = require('../utils/money');
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -66,7 +67,7 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
|
||||||
if (!dueDate) {
|
if (!dueDate) {
|
||||||
return { error: standardizeError('Bill does not occur in the selected month', 'VALIDATION_ERROR', 'month'), status: 400 };
|
return { error: standardizeError('Bill does not occur in the selected month', 'VALIDATION_ERROR', 'month'), status: 400 };
|
||||||
}
|
}
|
||||||
const amount = state?.actual_amount ?? bill.expected_amount;
|
const amount = fromCents(state?.actual_amount ?? bill.expected_amount);
|
||||||
return { bill, dueDate, amount };
|
return { bill, dueDate, amount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +108,7 @@ router.get('/', (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
query += ' ORDER BY p.paid_date DESC';
|
query += ' ORDER BY p.paid_date DESC';
|
||||||
res.json(db.prepare(query).all(...params));
|
res.json(db.prepare(query).all(...params).map(serializePayment));
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/payments/recent-auto — provider_sync payments with a linked tx, last 7 days
|
// GET /api/payments/recent-auto — provider_sync payments with a linked tx, last 7 days
|
||||||
|
|
@ -130,7 +131,7 @@ router.get('/recent-auto', (req, res) => {
|
||||||
ORDER BY p.created_at DESC
|
ORDER BY p.created_at DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
res.json(rows);
|
res.json(rows.map(serializePayment));
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/payments/:id
|
// GET /api/payments/:id
|
||||||
|
|
@ -138,7 +139,7 @@ router.get('/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
|
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
|
||||||
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
||||||
res.json(payment);
|
res.json(serializePayment(payment));
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/payments/:id/undo-auto — reverse a provider_sync auto-match
|
// POST /api/payments/:id/undo-auto — reverse a provider_sync auto-match
|
||||||
|
|
@ -164,7 +165,7 @@ router.post('/:id/undo-auto', (req, res) => {
|
||||||
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
||||||
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
|
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
|
||||||
if (bill?.current_balance != null) {
|
if (bill?.current_balance != null) {
|
||||||
const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100);
|
const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta));
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE bills
|
UPDATE bills
|
||||||
SET current_balance = ?,
|
SET current_balance = ?,
|
||||||
|
|
@ -212,7 +213,7 @@ router.post('/', (req, res) => {
|
||||||
|
|
||||||
applyBalanceDelta(db, bill.id, balCalc);
|
applyBalanceDelta(db, bill.id, balCalc);
|
||||||
|
|
||||||
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
|
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/payments/quick — pay a bill (expected amount, today)
|
// POST /api/payments/quick — pay a bill (expected amount, today)
|
||||||
|
|
@ -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 : fromCents(bill.expected_amount),
|
||||||
paid_date: paid_date || todayLocal(),
|
paid_date: paid_date || todayLocal(),
|
||||||
payment_source: payment_source ?? 'manual',
|
payment_source: payment_source ?? 'manual',
|
||||||
},
|
},
|
||||||
|
|
@ -251,7 +252,7 @@ router.post('/quick', (req, res) => {
|
||||||
|
|
||||||
applyBalanceDelta(db, bill.id, balCalc);
|
applyBalanceDelta(db, bill.id, balCalc);
|
||||||
|
|
||||||
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
|
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/payments/autopay-suggestions/:billId/confirm
|
// POST /api/payments/autopay-suggestions/:billId/confirm
|
||||||
|
|
@ -296,7 +297,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
|
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
|
||||||
.run(req.user.id, bill.id, ym.year, ym.month);
|
.run(req.user.id, bill.id, ym.year, ym.month);
|
||||||
return res.json({ created: false, payment: existing });
|
return res.json({ created: false, payment: serializePayment(existing) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
|
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
|
||||||
|
|
@ -318,7 +319,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
|
||||||
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
|
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
|
||||||
.run(req.user.id, bill.id, ym.year, ym.month);
|
.run(req.user.id, bill.id, ym.year, ym.month);
|
||||||
|
|
||||||
res.status(201).json({ created: true, payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid) });
|
res.status(201).json({ created: true, payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/payments/autopay-suggestions/:billId/dismiss
|
// POST /api/payments/autopay-suggestions/:billId/dismiss
|
||||||
|
|
@ -407,7 +408,7 @@ router.post('/bulk', (req, res) => {
|
||||||
// Check for duplicates using composite key (bill_id + paid_date + amount)
|
// Check for duplicates using composite key (bill_id + paid_date + amount)
|
||||||
const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt);
|
const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt);
|
||||||
if (isDuplicate) {
|
if (isDuplicate) {
|
||||||
skipped.push({ bill_id, paid_date, amount: parsedAmt });
|
skipped.push({ bill_id, paid_date, amount: fromCents(parsedAmt) });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -421,7 +422,7 @@ router.post('/bulk', (req, res) => {
|
||||||
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment_source);
|
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment_source);
|
||||||
applyBalanceDelta(db, bill_id, balCalc);
|
applyBalanceDelta(db, bill_id, balCalc);
|
||||||
|
|
||||||
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
|
created.push(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -460,7 +461,7 @@ router.put('/:id', (req, res) => {
|
||||||
const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
|
const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
|
||||||
let restoredBalance = bill.current_balance;
|
let restoredBalance = bill.current_balance;
|
||||||
if (paymentPortion != null && bill.current_balance != null) {
|
if (paymentPortion != null && bill.current_balance != null) {
|
||||||
restoredBalance = Math.max(0, Math.round((bill.current_balance - paymentPortion) * 100) / 100);
|
restoredBalance = Math.max(0, bill.current_balance - paymentPortion);
|
||||||
}
|
}
|
||||||
|
|
||||||
// interest_accrued_month is still set to this month (if interest was charged) so
|
// interest_accrued_month is still set to this month (if interest was charged) so
|
||||||
|
|
@ -499,7 +500,7 @@ router.put('/:id', (req, res) => {
|
||||||
req.user.id,
|
req.user.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
|
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/payments/:id — soft delete (sets deleted_at)
|
// DELETE /api/payments/:id — soft delete (sets deleted_at)
|
||||||
|
|
@ -515,7 +516,7 @@ router.delete('/:id', (req, res) => {
|
||||||
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
||||||
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
|
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
|
||||||
if (bill?.current_balance != null) {
|
if (bill?.current_balance != null) {
|
||||||
const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100);
|
const restored = Math.max(0, bill.current_balance - payment.balance_delta);
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE bills
|
UPDATE bills
|
||||||
SET current_balance = ?,
|
SET current_balance = ?,
|
||||||
|
|
@ -543,7 +544,7 @@ router.post('/:id/restore', (req, res) => {
|
||||||
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
||||||
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
|
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
|
||||||
if (bill?.current_balance != null) {
|
if (bill?.current_balance != null) {
|
||||||
const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100);
|
const reapplied = Math.max(0, bill.current_balance + payment.balance_delta);
|
||||||
const interestMonth = payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : null;
|
const interestMonth = payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : null;
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE bills
|
UPDATE bills
|
||||||
|
|
@ -556,7 +557,7 @@ router.post('/:id/restore', (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id);
|
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id);
|
||||||
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
|
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/payments/:id/attribute-to-month
|
// PATCH /api/payments/:id/attribute-to-month
|
||||||
|
|
@ -617,7 +618,7 @@ router.patch('/:id/attribute-to-month', (req, res) => {
|
||||||
if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date });
|
if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(paymentId, req.user.id));
|
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(paymentId, req.user.id)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[payments] attribute-to-month error:', err.message);
|
console.error('[payments] attribute-to-month error:', err.message);
|
||||||
res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR'));
|
res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR'));
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ const { getDb } = require('../db/database');
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
|
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
|
||||||
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService');
|
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService');
|
||||||
|
const { serializeBill } = require('../services/billsService');
|
||||||
|
const { toCents, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
const DEBT_LIKE_CLAUSES = `(
|
const DEBT_LIKE_CLAUSES = `(
|
||||||
b.snowball_include = 1
|
b.snowball_include = 1
|
||||||
|
|
@ -84,7 +86,7 @@ function getDebtBills(userId, ramseyMode) {
|
||||||
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
|
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const ramseyMode = isRamseyMode(req.user.id);
|
const ramseyMode = isRamseyMode(req.user.id);
|
||||||
res.json(getDebtBills(req.user.id, ramseyMode));
|
res.json(getDebtBills(req.user.id, ramseyMode).map(serializeBill));
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/snowball/settings — extra monthly payment for this user
|
// GET /api/snowball/settings — extra monthly payment for this user
|
||||||
|
|
@ -92,7 +94,7 @@ router.get('/settings', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
|
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
|
||||||
res.json({
|
res.json({
|
||||||
extra_payment: user?.snowball_extra_payment ?? 0,
|
extra_payment: fromCents(user?.snowball_extra_payment ?? 0),
|
||||||
ramsey_mode: isRamseyMode(req.user.id),
|
ramsey_mode: isRamseyMode(req.user.id),
|
||||||
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
|
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
|
||||||
ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'),
|
ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'),
|
||||||
|
|
@ -118,7 +120,7 @@ router.patch('/settings', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const save = db.transaction(() => {
|
const save = db.transaction(() => {
|
||||||
if (extra_payment !== undefined) {
|
if (extra_payment !== undefined) {
|
||||||
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
|
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(toCents(val), req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ramsey_mode !== undefined) {
|
if (ramsey_mode !== undefined) {
|
||||||
|
|
@ -137,7 +139,7 @@ router.patch('/settings', (req, res) => {
|
||||||
|
|
||||||
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
|
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
|
||||||
res.json({
|
res.json({
|
||||||
extra_payment: user?.snowball_extra_payment ?? 0,
|
extra_payment: fromCents(user?.snowball_extra_payment ?? 0),
|
||||||
// Use body value when ramsey_mode was just saved; fall back to DB read if not in request
|
// Use body value when ramsey_mode was just saved; fall back to DB read if not in request
|
||||||
ramsey_mode: ramsey_mode !== undefined ? !!ramsey_mode : isRamseyMode(req.user.id),
|
ramsey_mode: ramsey_mode !== undefined ? !!ramsey_mode : isRamseyMode(req.user.id),
|
||||||
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
|
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
|
||||||
|
|
@ -154,16 +156,24 @@ router.get('/projection', (req, res) => {
|
||||||
const bills = getDebtBills(req.user.id, ramseyMode);
|
const bills = getDebtBills(req.user.id, ramseyMode);
|
||||||
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
|
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
|
||||||
|
|
||||||
|
// Money fields on `bills` are stored as integer cents; the snowball/APR math
|
||||||
|
// and the API response are dollar-denominated, so convert before computing.
|
||||||
|
const billsForMath = bills.map(b => ({
|
||||||
|
...b,
|
||||||
|
current_balance: fromCents(b.current_balance),
|
||||||
|
minimum_payment: fromCents(b.minimum_payment),
|
||||||
|
}));
|
||||||
|
|
||||||
// Allow an optional ?extra=N override so the client can preview an unsaved
|
// Allow an optional ?extra=N override so the client can preview an unsaved
|
||||||
// extra payment without a round-trip save. Falls back to the stored value.
|
// extra payment without a round-trip save. Falls back to the stored value.
|
||||||
const queryExtra = req.query.extra !== undefined ? parseFloat(req.query.extra) : NaN;
|
const queryExtra = req.query.extra !== undefined ? parseFloat(req.query.extra) : NaN;
|
||||||
const extra = Number.isFinite(queryExtra) && queryExtra >= 0
|
const extra = Number.isFinite(queryExtra) && queryExtra >= 0
|
||||||
? queryExtra
|
? queryExtra
|
||||||
: (user?.snowball_extra_payment ?? 0);
|
: fromCents(user?.snowball_extra_payment ?? 0);
|
||||||
|
|
||||||
// Build a lookup of APR snapshots keyed by bill id (computed once from current balances)
|
// Build a lookup of APR snapshots keyed by bill id (computed once from current balances)
|
||||||
const aprByBill = {};
|
const aprByBill = {};
|
||||||
for (const b of bills) {
|
for (const b of billsForMath) {
|
||||||
const snap = debtAprSnapshot(b);
|
const snap = debtAprSnapshot(b);
|
||||||
if (snap) aprByBill[b.id] = snap;
|
if (snap) aprByBill[b.id] = snap;
|
||||||
}
|
}
|
||||||
|
|
@ -180,9 +190,9 @@ router.get('/projection', (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const snowball = enrich(calculateSnowball(bills, extra, now));
|
const snowball = enrich(calculateSnowball(billsForMath, extra, now));
|
||||||
const avalanche = enrich(calculateAvalanche(bills, extra, now));
|
const avalanche = enrich(calculateAvalanche(billsForMath, extra, now));
|
||||||
const minimum_only = enrich(calculateMinimumOnly(bills, now));
|
const minimum_only = enrich(calculateMinimumOnly(billsForMath, now));
|
||||||
|
|
||||||
// Comparison: what does the snowball save vs just paying minimums?
|
// Comparison: what does the snowball save vs just paying minimums?
|
||||||
const comparison = buildComparison(snowball, minimum_only);
|
const comparison = buildComparison(snowball, minimum_only);
|
||||||
|
|
@ -270,7 +280,7 @@ function enrichPlanWithProgress(db, plan) {
|
||||||
|
|
||||||
const currentDebts = (snapshot?.debts ?? []).map(d => {
|
const currentDebts = (snapshot?.debts ?? []).map(d => {
|
||||||
const bill = db.prepare('SELECT current_balance, name, deleted_at FROM bills WHERE id = ?').get(d.bill_id);
|
const bill = db.prepare('SELECT current_balance, name, deleted_at FROM bills WHERE id = ?').get(d.bill_id);
|
||||||
const currentBalance = bill && !bill.deleted_at ? (bill.current_balance ?? null) : null;
|
const currentBalance = bill && !bill.deleted_at ? fromCents(bill.current_balance) : null;
|
||||||
const startingBalance = d.starting_balance ?? 0;
|
const startingBalance = d.starting_balance ?? 0;
|
||||||
const progressPct = startingBalance > 0 && currentBalance !== null
|
const progressPct = startingBalance > 0 && currentBalance !== null
|
||||||
? Math.min(100, Math.max(0, Math.round((startingBalance - currentBalance) / startingBalance * 100)))
|
? Math.min(100, Math.max(0, Math.round((startingBalance - currentBalance) / startingBalance * 100)))
|
||||||
|
|
@ -281,7 +291,7 @@ function enrichPlanWithProgress(db, plan) {
|
||||||
const startedMs = plan.started_at ? new Date(plan.started_at).getTime() : Date.now();
|
const startedMs = plan.started_at ? new Date(plan.started_at).getTime() : Date.now();
|
||||||
const monthsElapsed = Math.floor((Date.now() - startedMs) / (1000 * 60 * 60 * 24 * 30));
|
const monthsElapsed = Math.floor((Date.now() - startedMs) / (1000 * 60 * 60 * 24 * 30));
|
||||||
|
|
||||||
return { ...plan, plan_snapshot: snapshot, months_elapsed: monthsElapsed, current_debts: currentDebts };
|
return { ...plan, extra_payment: fromCents(plan.extra_payment), plan_snapshot: snapshot, months_elapsed: monthsElapsed, current_debts: currentDebts };
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/snowball/plans — start a new snowball plan
|
// POST /api/snowball/plans — start a new snowball plan
|
||||||
|
|
@ -301,15 +311,24 @@ router.post('/plans', (req, res) => {
|
||||||
return res.status(400).json({ error: 'No debts with a balance found. Add a balance to at least one bill.' });
|
return res.status(400).json({ error: 'No debts with a balance found. Add a balance to at least one bill.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Money fields on `debts` are stored as integer cents; the snowball/APR
|
||||||
|
// math and plan_snapshot are dollar-denominated, so convert before computing.
|
||||||
|
const debtsForMath = debts.map(b => ({
|
||||||
|
...b,
|
||||||
|
current_balance: fromCents(b.current_balance),
|
||||||
|
minimum_payment: fromCents(b.minimum_payment),
|
||||||
|
}));
|
||||||
|
|
||||||
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId);
|
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId);
|
||||||
const extra = user?.snowball_extra_payment ?? 0;
|
const extraCents = user?.snowball_extra_payment ?? 0;
|
||||||
|
const extra = fromCents(extraCents);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const snowball = planMethod === 'avalanche' ? calculateAvalanche(debts, extra, now) : calculateSnowball(debts, extra, now);
|
const snowball = planMethod === 'avalanche' ? calculateAvalanche(debtsForMath, extra, now) : calculateSnowball(debtsForMath, extra, now);
|
||||||
const minOnly = calculateMinimumOnly(debts, now);
|
const minOnly = calculateMinimumOnly(debtsForMath, now);
|
||||||
const interestSaved = Math.max(0, Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) / 100);
|
const interestSaved = Math.max(0, Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) / 100);
|
||||||
|
|
||||||
const debtSnaps = debts.map((b, i) => {
|
const debtSnaps = debtsForMath.map((b, i) => {
|
||||||
const proj = snowball.debts?.find(d => d.id === b.id);
|
const proj = snowball.debts?.find(d => d.id === b.id);
|
||||||
return {
|
return {
|
||||||
bill_id: b.id,
|
bill_id: b.id,
|
||||||
|
|
@ -342,7 +361,7 @@ router.post('/plans', (req, res) => {
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at)
|
INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))
|
VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))
|
||||||
`).run(userId, planName, planMethod, extra, planSnapshot, notes || null);
|
`).run(userId, planName, planMethod, extraCents, planSnapshot, notes || null);
|
||||||
|
|
||||||
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid);
|
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid);
|
||||||
res.status(201).json(enrichPlanWithProgress(db, plan));
|
res.status(201).json(enrichPlanWithProgress(db, plan));
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
|
const { fromCents } = require('../utils/money');
|
||||||
const {
|
const {
|
||||||
createSubscriptionFromRecommendation,
|
createSubscriptionFromRecommendation,
|
||||||
declineRecommendation,
|
declineRecommendation,
|
||||||
|
|
@ -105,7 +106,7 @@ router.post('/recommendations/match-bill', (req, res) => {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const tx of txRows) {
|
for (const tx of txRows) {
|
||||||
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
||||||
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
const amount = Math.round(Math.abs(tx.amount)); // tx.amount and payments.amount are both cents
|
||||||
matchedCount += updateTx.run(billId, tx.id, req.user.id).changes;
|
matchedCount += updateTx.run(billId, tx.id, req.user.id).changes;
|
||||||
if (paidDate) insertPayment.run(billId, amount, paidDate, tx.id);
|
if (paidDate) insertPayment.run(billId, amount, paidDate, tx.id);
|
||||||
}
|
}
|
||||||
|
|
@ -213,9 +214,9 @@ router.get('/catalog', (req, res) => {
|
||||||
matched_bill: bill ? {
|
matched_bill: bill ? {
|
||||||
id: bill.id,
|
id: bill.id,
|
||||||
name: bill.name,
|
name: bill.name,
|
||||||
expected_amount: bill.expected_amount,
|
expected_amount: fromCents(bill.expected_amount),
|
||||||
active: !!bill.active,
|
active: !!bill.active,
|
||||||
monthly_equivalent: monthlyEquivalent(bill.expected_amount, bill.cycle_type, bill.billing_cycle),
|
monthly_equivalent: monthlyEquivalent(fromCents(bill.expected_amount), bill.cycle_type, bill.billing_cycle),
|
||||||
} : null,
|
} : null,
|
||||||
user_descriptors: userDescsByCatalogId.get(entry.id) ?? [],
|
user_descriptors: userDescsByCatalogId.get(entry.id) ?? [],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const { getDb } = require('../db/database');
|
||||||
const { getCycleRange } = require('../services/statusService');
|
const { getCycleRange } = require('../services/statusService');
|
||||||
const { getUserSettings } = require('../services/userSettings');
|
const { getUserSettings } = require('../services/userSettings');
|
||||||
const { accountingActiveSql } = require('../services/paymentAccountingService');
|
const { accountingActiveSql } = require('../services/paymentAccountingService');
|
||||||
|
const { toCents, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
const DEFAULT_INCOME_LABEL = 'Salary';
|
const DEFAULT_INCOME_LABEL = 'Salary';
|
||||||
const DEFAULT_PENDING_DAYS = 3;
|
const DEFAULT_PENDING_DAYS = 3;
|
||||||
|
|
@ -74,9 +75,9 @@ function buildBankTrackingSummary(db, userId, year, month) {
|
||||||
`).get(year, month, start, end, userId);
|
`).get(year, month, start, end, userId);
|
||||||
|
|
||||||
const balanceDollars = money(account.balance / 100);
|
const balanceDollars = money(account.balance / 100);
|
||||||
const pendingDollars = money(pendingRow.pending_total);
|
const pendingDollars = fromCents(pendingRow.pending_total);
|
||||||
const effectiveDollars = money(balanceDollars - pendingDollars);
|
const effectiveDollars = money(balanceDollars - pendingDollars);
|
||||||
const unpaidDollars = money(unpaidRow.unpaid_total);
|
const unpaidDollars = fromCents(unpaidRow.unpaid_total);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -127,9 +128,9 @@ function getStartingAmounts(db, userId, year, month) {
|
||||||
`).get(userId, year, month);
|
`).get(userId, year, month);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
first_amount: money(row?.first_amount || 0),
|
first_amount: fromCents(row?.first_amount || 0),
|
||||||
fifteenth_amount: money(row?.fifteenth_amount || 0),
|
fifteenth_amount: fromCents(row?.fifteenth_amount || 0),
|
||||||
other_amount: money(row?.other_amount || 0),
|
other_amount: fromCents(row?.other_amount || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,10 +188,10 @@ function calculatePaidDeductions(db, userId, year, month) {
|
||||||
`).get(userId, start, end);
|
`).get(userId, start, end);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paid_from_first: money(firstPaid.paid),
|
paid_from_first: fromCents(firstPaid.paid),
|
||||||
paid_from_fifteenth: money(fifteenthPaid.paid),
|
paid_from_fifteenth: fromCents(fifteenthPaid.paid),
|
||||||
paid_from_other: money(otherPaid.paid),
|
paid_from_other: fromCents(otherPaid.paid),
|
||||||
paid_total: money(totalPaid.paid),
|
paid_total: fromCents(totalPaid.paid),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,7 +230,7 @@ function getIncome(db, userId, year, month) {
|
||||||
return {
|
return {
|
||||||
id: row?.id || null,
|
id: row?.id || null,
|
||||||
label: row?.label || DEFAULT_INCOME_LABEL,
|
label: row?.label || DEFAULT_INCOME_LABEL,
|
||||||
amount: money(row?.amount),
|
amount: fromCents(row?.amount ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,7 +285,7 @@ function buildSummary(db, userId, year, month) {
|
||||||
for (const row of payments) {
|
for (const row of payments) {
|
||||||
paymentMap.set(row.bill_id, {
|
paymentMap.set(row.bill_id, {
|
||||||
payment_count: row.payment_count || 0,
|
payment_count: row.payment_count || 0,
|
||||||
paid_amount: money(row.paid_amount),
|
paid_amount: fromCents(row.paid_amount),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -292,14 +293,14 @@ function buildSummary(db, userId, year, month) {
|
||||||
const expenses = billRows.map(row => {
|
const expenses = billRows.map(row => {
|
||||||
const payment = paymentMap.get(row.bill_id) || { payment_count: 0, paid_amount: 0 };
|
const payment = paymentMap.get(row.bill_id) || { payment_count: 0, paid_amount: 0 };
|
||||||
const hasActual = row.actual_amount !== null && row.actual_amount !== undefined;
|
const hasActual = row.actual_amount !== null && row.actual_amount !== undefined;
|
||||||
const displayAmount = money(hasActual ? row.actual_amount : row.expected_amount);
|
const displayAmount = fromCents(hasActual ? row.actual_amount : row.expected_amount);
|
||||||
const paidAmount = money(payment.paid_amount);
|
const paidAmount = money(payment.paid_amount);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bill_id: row.bill_id,
|
bill_id: row.bill_id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
expected_amount: money(row.expected_amount),
|
expected_amount: fromCents(row.expected_amount),
|
||||||
actual_amount: hasActual ? money(row.actual_amount) : null,
|
actual_amount: hasActual ? fromCents(row.actual_amount) : null,
|
||||||
display_amount: displayAmount,
|
display_amount: displayAmount,
|
||||||
is_paid: payment.payment_count > 0,
|
is_paid: payment.payment_count > 0,
|
||||||
paid_amount: paidAmount,
|
paid_amount: paidAmount,
|
||||||
|
|
@ -407,7 +408,7 @@ router.put('/income', (req, res) => {
|
||||||
label = excluded.label,
|
label = excluded.label,
|
||||||
amount = excluded.amount,
|
amount = excluded.amount,
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
`).run(req.user.id, parsed.year, parsed.month, label, amount);
|
`).run(req.user.id, parsed.year, parsed.month, label, toCents(amount));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
year: parsed.year,
|
year: parsed.year,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { accountingActiveSql } = require('./paymentAccountingService');
|
const { accountingActiveSql } = require('./paymentAccountingService');
|
||||||
const { roundMoney } = require('../utils/money');
|
const { fromCents } = require('../utils/money');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes a suggested expected amount for a bill based on the rolling median
|
* Computes a suggested expected amount for a bill based on the rolling median
|
||||||
|
|
@ -48,7 +48,7 @@ function computeAmountSuggestion(db, billId, year, month) {
|
||||||
: sorted[mid];
|
: sorted[mid];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
suggestion: roundMoney(median),
|
suggestion: fromCents(Math.round(median)),
|
||||||
months_used: amounts.length,
|
months_used: amounts.length,
|
||||||
confidence: amounts.length >= 3 ? 'high' : 'low',
|
confidence: amounts.length >= 3 ? 'high' : 'low',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { accountingActiveSql } = require('./paymentAccountingService');
|
const { accountingActiveSql } = require('./paymentAccountingService');
|
||||||
const { sumMoney } = require('../utils/money');
|
const { sumMoney, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
function parseInteger(value, fallback) {
|
function parseInteger(value, fallback) {
|
||||||
if (value === undefined || value === null || value === '') return fallback;
|
if (value === undefined || value === null || value === '') return fallback;
|
||||||
|
|
@ -183,7 +183,7 @@ function getAnalyticsSummary(userId, query = {}) {
|
||||||
|
|
||||||
const monthly_spending = rangeMonths.map(m => {
|
const monthly_spending = rangeMonths.map(m => {
|
||||||
const total = sumMoney(bills, bill => paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0);
|
const total = sumMoney(bills, bill => paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0);
|
||||||
return { month: m.key, label: m.label, total: Number(total.toFixed(2)) };
|
return { month: m.key, label: m.label, total: fromCents(total) };
|
||||||
}).filter(row => row.total > 0);
|
}).filter(row => row.total > 0);
|
||||||
|
|
||||||
const expected_vs_actual = rangeMonths.map(m => {
|
const expected_vs_actual = rangeMonths.map(m => {
|
||||||
|
|
@ -204,8 +204,8 @@ function getAnalyticsSummary(userId, query = {}) {
|
||||||
return {
|
return {
|
||||||
month: m.key,
|
month: m.key,
|
||||||
label: m.label,
|
label: m.label,
|
||||||
expected: Number(expected.toFixed(2)),
|
expected: fromCents(expected),
|
||||||
actual: Number(actual.toFixed(2)),
|
actual: fromCents(actual),
|
||||||
skipped_count,
|
skipped_count,
|
||||||
};
|
};
|
||||||
}).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0);
|
}).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0);
|
||||||
|
|
@ -225,7 +225,7 @@ function getAnalyticsSummary(userId, query = {}) {
|
||||||
categoryMap.set(key, existing);
|
categoryMap.set(key, existing);
|
||||||
}
|
}
|
||||||
const category_spend = Array.from(categoryMap.values())
|
const category_spend = Array.from(categoryMap.values())
|
||||||
.map(row => ({ ...row, total: Number(row.total.toFixed(2)) }))
|
.map(row => ({ ...row, total: fromCents(row.total) }))
|
||||||
.filter(row => row.total > 0)
|
.filter(row => row.total > 0)
|
||||||
.sort((a, b) => b.total - a.total);
|
.sort((a, b) => b.total - a.total);
|
||||||
|
|
||||||
|
|
@ -242,7 +242,7 @@ function getAnalyticsSummary(userId, query = {}) {
|
||||||
month: m.key,
|
month: m.key,
|
||||||
label: m.label,
|
label: m.label,
|
||||||
status,
|
status,
|
||||||
amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)),
|
amount_paid: fromCents(paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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');
|
const { localDateString } = require('../utils/dates');
|
||||||
|
const { fromCents } = require('../utils/money');
|
||||||
|
|
||||||
// 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.
|
||||||
|
|
@ -165,10 +166,13 @@ function applyMerchantRules(db, userId) {
|
||||||
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
||||||
if (!paidDate) continue;
|
if (!paidDate) continue;
|
||||||
|
|
||||||
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
// tx.amount and payments.amount are both integer cents; keep a dollar
|
||||||
|
// copy only for the lateAttributions display payload below.
|
||||||
|
const amountCents = Math.round(Math.abs(tx.amount));
|
||||||
|
const amount = fromCents(amountCents);
|
||||||
const bill = getBill.get(rule.bill_id);
|
const bill = getBill.get(rule.bill_id);
|
||||||
|
|
||||||
const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id);
|
const result = insertPayment.run(rule.bill_id, amountCents, paidDate, tx.id);
|
||||||
if (result.changes > 0) {
|
if (result.changes > 0) {
|
||||||
const inserted = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(tx.id, rule.bill_id);
|
const inserted = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(tx.id, rule.bill_id);
|
||||||
updateTx.run(rule.bill_id, tx.id, userId);
|
updateTx.run(rule.bill_id, tx.id, userId);
|
||||||
|
|
@ -294,10 +298,13 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
|
||||||
if (!matches) continue;
|
if (!matches) continue;
|
||||||
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
||||||
if (!paidDate) continue;
|
if (!paidDate) continue;
|
||||||
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
// tx.amount and payments.amount are both integer cents; keep a dollar
|
||||||
|
// copy only for the lateAttributions display payload below.
|
||||||
|
const amountCents = Math.round(Math.abs(tx.amount));
|
||||||
|
const amount = fromCents(amountCents);
|
||||||
const bill = getBill.get(billId);
|
const bill = getBill.get(billId);
|
||||||
|
|
||||||
const result = insertPayment.run(billId, amount, paidDate, tx.id);
|
const result = insertPayment.run(billId, amountCents, paidDate, tx.id);
|
||||||
if (result.changes > 0) {
|
if (result.changes > 0) {
|
||||||
const inserted = getPaymentId.get(tx.id, billId);
|
const inserted = getPaymentId.get(tx.id, billId);
|
||||||
updateTx.run(billId, tx.id, userId);
|
updateTx.run(billId, tx.id, userId);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const { monthKey } = require('../utils/dates');
|
const { monthKey } = require('../utils/dates');
|
||||||
const { roundMoney, mulMoney } = require('../utils/money');
|
const { toCents, fromCents } = require('../utils/money');
|
||||||
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
|
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
|
||||||
|
|
||||||
const TEMPLATE_FIELDS = [
|
const TEMPLATE_FIELDS = [
|
||||||
|
|
@ -52,6 +52,19 @@ function categoryBelongsToUser(db, categoryId, userId) {
|
||||||
return !!db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(categoryId, userId);
|
return !!db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(categoryId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a bill row's integer-cents money columns to dollars for API responses.
|
||||||
|
*/
|
||||||
|
function serializeBill(bill) {
|
||||||
|
if (!bill) return bill;
|
||||||
|
return {
|
||||||
|
...bill,
|
||||||
|
expected_amount: fromCents(bill.expected_amount),
|
||||||
|
current_balance: fromCents(bill.current_balance),
|
||||||
|
minimum_payment: fromCents(bill.minimum_payment),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function insertBill(db, userId, normalized) {
|
function insertBill(db, userId, normalized) {
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO bills
|
INSERT INTO bills
|
||||||
|
|
@ -278,8 +291,8 @@ function validateBillData(data, existingBill = null) {
|
||||||
// override_due_date
|
// override_due_date
|
||||||
normalized.override_due_date = data.override_due_date !== undefined ? (data.override_due_date || null) : (existingBill?.override_due_date || null);
|
normalized.override_due_date = data.override_due_date !== undefined ? (data.override_due_date || null) : (existingBill?.override_due_date || null);
|
||||||
|
|
||||||
// expected_amount
|
// expected_amount (stored as integer cents)
|
||||||
normalized.expected_amount = data.expected_amount !== undefined ? (parseFloat(data.expected_amount) || 0) : (existingBill?.expected_amount || 0);
|
normalized.expected_amount = data.expected_amount !== undefined ? (toCents(data.expected_amount) || 0) : (existingBill?.expected_amount || 0);
|
||||||
|
|
||||||
// interest_rate
|
// interest_rate
|
||||||
if (data.interest_rate !== undefined) {
|
if (data.interest_rate !== undefined) {
|
||||||
|
|
@ -361,13 +374,13 @@ function validateBillData(data, existingBill = null) {
|
||||||
// Calculate bucket based on due_day
|
// Calculate bucket based on due_day
|
||||||
normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th';
|
normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th';
|
||||||
|
|
||||||
// current_balance — outstanding debt balance (nullable)
|
// current_balance — outstanding debt balance, stored as integer cents (nullable)
|
||||||
if (data.current_balance !== undefined) {
|
if (data.current_balance !== undefined) {
|
||||||
if (data.current_balance === null || data.current_balance === '') {
|
if (data.current_balance === null || data.current_balance === '') {
|
||||||
normalized.current_balance = null;
|
normalized.current_balance = null;
|
||||||
} else {
|
} else {
|
||||||
const cb = parseFloat(data.current_balance);
|
const cb = toCents(data.current_balance);
|
||||||
if (!Number.isFinite(cb) || cb < 0) {
|
if (!Number.isInteger(cb) || cb < 0) {
|
||||||
errors.push({ field: 'current_balance', message: 'current_balance must be a non-negative number' });
|
errors.push({ field: 'current_balance', message: 'current_balance must be a non-negative number' });
|
||||||
} else {
|
} else {
|
||||||
normalized.current_balance = cb;
|
normalized.current_balance = cb;
|
||||||
|
|
@ -377,13 +390,13 @@ function validateBillData(data, existingBill = null) {
|
||||||
normalized.current_balance = existingBill?.current_balance ?? null;
|
normalized.current_balance = existingBill?.current_balance ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// minimum_payment — required minimum payment for debt (nullable)
|
// minimum_payment — required minimum payment for debt, stored as integer cents (nullable)
|
||||||
if (data.minimum_payment !== undefined) {
|
if (data.minimum_payment !== undefined) {
|
||||||
if (data.minimum_payment === null || data.minimum_payment === '') {
|
if (data.minimum_payment === null || data.minimum_payment === '') {
|
||||||
normalized.minimum_payment = null;
|
normalized.minimum_payment = null;
|
||||||
} else {
|
} else {
|
||||||
const mp = parseFloat(data.minimum_payment);
|
const mp = toCents(data.minimum_payment);
|
||||||
if (!Number.isFinite(mp) || mp < 0) {
|
if (!Number.isInteger(mp) || mp < 0) {
|
||||||
errors.push({ field: 'minimum_payment', message: 'minimum_payment must be a non-negative number' });
|
errors.push({ field: 'minimum_payment', message: 'minimum_payment must be a non-negative number' });
|
||||||
} else {
|
} else {
|
||||||
normalized.minimum_payment = mp;
|
normalized.minimum_payment = mp;
|
||||||
|
|
@ -473,20 +486,20 @@ function validateCycleDayOnly(cycleType, cycleDay) {
|
||||||
// where interest_delta and interest_accrued_month are null when no interest
|
// where interest_delta and interest_accrued_month are null when no interest
|
||||||
// was charged this call (so callers can use COALESCE to leave the DB column alone).
|
// was charged this call (so callers can use COALESCE to leave the DB column alone).
|
||||||
function computeBalanceDelta(bill, paymentAmount) {
|
function computeBalanceDelta(bill, paymentAmount) {
|
||||||
const bal = Number(bill.current_balance);
|
const bal = Number(bill.current_balance); // cents
|
||||||
const rate = Number(bill.interest_rate) || 0;
|
const rate = Number(bill.interest_rate) || 0; // percent
|
||||||
const amt = Number(paymentAmount);
|
const amt = Number(paymentAmount); // cents
|
||||||
|
|
||||||
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 = monthKey(); // "YYYY-MM" (local time)
|
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 ? mulMoney(bal, rate / 100 / 12) : 0;
|
const interestDelta = applyInterest ? Math.round(bal * rate / 100 / 12) : 0; // cents
|
||||||
|
|
||||||
const raw = bal + interestDelta - amt;
|
const raw = bal + interestDelta - amt; // cents, exact integer arithmetic
|
||||||
const newBalance = roundMoney(Math.max(0, raw));
|
const newBalance = Math.max(0, raw);
|
||||||
const delta = roundMoney(newBalance - bal);
|
const delta = newBalance - bal;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
new_balance: newBalance,
|
new_balance: newBalance,
|
||||||
|
|
@ -519,6 +532,7 @@ module.exports = {
|
||||||
getValidCycleTypes,
|
getValidCycleTypes,
|
||||||
getDefaultCycleDay,
|
getDefaultCycleDay,
|
||||||
insertBill,
|
insertBill,
|
||||||
|
serializeBill,
|
||||||
parseTemplateData,
|
parseTemplateData,
|
||||||
validateCycleDay,
|
validateCycleDay,
|
||||||
parseDueDay,
|
parseDueDay,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { normalizeCycleType, resolveDueDate } = require('./statusService');
|
const { normalizeCycleType, resolveDueDate } = require('./statusService');
|
||||||
|
const { fromCents } = require('../utils/money');
|
||||||
|
|
||||||
const PRODID = '-//Bill Tracker//Calendar Feed//EN';
|
const PRODID = '-//Bill Tracker//Calendar Feed//EN';
|
||||||
const FEED_PAST_MONTHS = 12;
|
const FEED_PAST_MONTHS = 12;
|
||||||
|
|
@ -231,14 +232,14 @@ function eventUid(bill, dueDate) {
|
||||||
|
|
||||||
function eventSummary(bill, detailLevel = 'standard') {
|
function eventSummary(bill, detailLevel = 'standard') {
|
||||||
if (detailLevel === 'private') return 'Bill due';
|
if (detailLevel === 'private') return 'Bill due';
|
||||||
if (detailLevel === 'full') return `${bill.name} due - $${Number(bill.expected_amount || 0).toFixed(2)}`;
|
if (detailLevel === 'full') return `${bill.name} due - $${(fromCents(bill.expected_amount) || 0).toFixed(2)}`;
|
||||||
return `${bill.name} due`;
|
return `${bill.name} due`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventDescription(bill, dueDate, detailLevel = 'standard') {
|
function eventDescription(bill, dueDate, detailLevel = 'standard') {
|
||||||
const lines = ['Bill Tracker reminder'];
|
const lines = ['Bill Tracker reminder'];
|
||||||
if (detailLevel !== 'private') lines.push(`Bill: ${bill.name}`);
|
if (detailLevel !== 'private') lines.push(`Bill: ${bill.name}`);
|
||||||
if (detailLevel === 'full') lines.push(`Expected amount: $${Number(bill.expected_amount || 0).toFixed(2)}`);
|
if (detailLevel === 'full') lines.push(`Expected amount: $${(fromCents(bill.expected_amount) || 0).toFixed(2)}`);
|
||||||
lines.push(`Due date: ${dueDate}`);
|
lines.push(`Due date: ${dueDate}`);
|
||||||
if (bill.category_name) lines.push(`Category: ${bill.category_name}`);
|
if (bill.category_name) lines.push(`Category: ${bill.category_name}`);
|
||||||
if (bill.autopay_enabled) lines.push('Autopay: enabled');
|
if (bill.autopay_enabled) lines.push('Autopay: enabled');
|
||||||
|
|
@ -333,7 +334,7 @@ function previewFeed(userId, options = {}, db = getDb()) {
|
||||||
bill_id: event.bill.id,
|
bill_id: event.bill.id,
|
||||||
name: event.bill.name,
|
name: event.bill.name,
|
||||||
due_date: event.dueDate,
|
due_date: event.dueDate,
|
||||||
amount: Number(event.bill.expected_amount || 0),
|
amount: fromCents(event.bill.expected_amount) || 0,
|
||||||
cycle_type: normalizeCycleType(event.bill),
|
cycle_type: normalizeCycleType(event.bill),
|
||||||
category_name: event.bill.category_name || null,
|
category_name: event.bill.category_name || null,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ 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 { localDateString } = require('../utils/dates');
|
||||||
const { roundMoney } = require('../utils/money');
|
const { roundMoney, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
const MONTHS_BACK = 3;
|
const MONTHS_BACK = 3;
|
||||||
const MIN_PAID_MONTHS = 2;
|
const MIN_PAID_MONTHS = 2;
|
||||||
|
|
@ -53,7 +53,8 @@ function getDriftReport(userId, now = new Date()) {
|
||||||
`);
|
`);
|
||||||
|
|
||||||
for (const bill of bills) {
|
for (const bill of bills) {
|
||||||
if (!bill.expected_amount || bill.expected_amount <= 0) continue;
|
const expectedAmount = fromCents(bill.expected_amount);
|
||||||
|
if (!expectedAmount || expectedAmount <= 0) continue;
|
||||||
if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) continue;
|
if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) continue;
|
||||||
|
|
||||||
const monthTotals = [];
|
const monthTotals = [];
|
||||||
|
|
@ -74,15 +75,15 @@ function getDriftReport(userId, now = new Date()) {
|
||||||
if (!range) continue;
|
if (!range) continue;
|
||||||
|
|
||||||
const { total } = payStmt.get(bill.id, range.start, range.end);
|
const { total } = payStmt.get(bill.id, range.start, range.end);
|
||||||
if (total > 0) monthTotals.push(total);
|
if (total > 0) monthTotals.push(fromCents(total));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (monthTotals.length < MIN_PAID_MONTHS) continue;
|
if (monthTotals.length < MIN_PAID_MONTHS) continue;
|
||||||
|
|
||||||
const recentAmount = median(monthTotals);
|
const recentAmount = median(monthTotals);
|
||||||
const delta = recentAmount - bill.expected_amount;
|
const delta = recentAmount - expectedAmount;
|
||||||
const absDelta = Math.abs(delta);
|
const absDelta = Math.abs(delta);
|
||||||
const driftPct = (delta / bill.expected_amount) * 100;
|
const driftPct = (delta / expectedAmount) * 100;
|
||||||
|
|
||||||
if (absDelta < MIN_ABS_DELTA) continue;
|
if (absDelta < MIN_ABS_DELTA) continue;
|
||||||
if (Math.abs(driftPct) < thresholdPct) continue;
|
if (Math.abs(driftPct) < thresholdPct) continue;
|
||||||
|
|
@ -91,7 +92,7 @@ function getDriftReport(userId, now = new Date()) {
|
||||||
id: bill.id,
|
id: bill.id,
|
||||||
name: bill.name,
|
name: bill.name,
|
||||||
category_name: bill.category_name ?? null,
|
category_name: bill.category_name ?? null,
|
||||||
expected_amount: bill.expected_amount,
|
expected_amount: expectedAmount,
|
||||||
recent_amount: roundMoney(recentAmount),
|
recent_amount: roundMoney(recentAmount),
|
||||||
drift_pct: Math.round(driftPct * 10) / 10,
|
drift_pct: Math.round(driftPct * 10) / 10,
|
||||||
direction: delta > 0 ? 'up' : 'down',
|
direction: delta > 0 ? 'up' : 'down',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { getCycleRange, resolveDueDate } = require('./statusService');
|
const { getCycleRange, resolveDueDate } = require('./statusService');
|
||||||
const { decorateTransaction } = require('./transactionService');
|
const { decorateTransaction } = require('./transactionService');
|
||||||
|
const { fromCents } = require('../utils/money');
|
||||||
|
|
||||||
function suggestionError(status, message, code, field = null) {
|
function suggestionError(status, message, code, field = null) {
|
||||||
const err = new Error(message);
|
const err = new Error(message);
|
||||||
|
|
@ -66,7 +67,7 @@ function amountDollars(transaction) {
|
||||||
|
|
||||||
function addAmountScore(score, reasons, transaction, bill) {
|
function addAmountScore(score, reasons, transaction, bill) {
|
||||||
const txAmount = amountDollars(transaction);
|
const txAmount = amountDollars(transaction);
|
||||||
const expected = Number(bill.expected_amount) || 0;
|
const expected = fromCents(bill.expected_amount) || 0;
|
||||||
if (txAmount <= 0 || expected <= 0) return score;
|
if (txAmount <= 0 || expected <= 0) return score;
|
||||||
|
|
||||||
const delta = Math.abs(txAmount - expected);
|
const delta = Math.abs(txAmount - expected);
|
||||||
|
|
@ -298,7 +299,7 @@ function listMatchSuggestions(userId, options = {}) {
|
||||||
bill: {
|
bill: {
|
||||||
id: bill.id,
|
id: bill.id,
|
||||||
name: bill.name,
|
name: bill.name,
|
||||||
expected_amount: bill.expected_amount,
|
expected_amount: fromCents(bill.expected_amount),
|
||||||
due_day: bill.due_day,
|
due_day: bill.due_day,
|
||||||
category_name: bill.category_name || null,
|
category_name: bill.category_name || null,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const {
|
||||||
markNotificationTestSuccess,
|
markNotificationTestSuccess,
|
||||||
} = require('./statusRuntime');
|
} = require('./statusRuntime');
|
||||||
const { localDateString } = require('../utils/dates');
|
const { localDateString } = require('../utils/dates');
|
||||||
|
const { fromCents } = require('../utils/money');
|
||||||
|
|
||||||
// ── Push notification channels ────────────────────────────────────────────────
|
// ── Push notification channels ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -156,7 +157,7 @@ const URGENCY_COLOR = {
|
||||||
function buildEmailHtml(bill, type, dueDate) {
|
function buildEmailHtml(bill, type, dueDate) {
|
||||||
const meta = TYPE_META[type];
|
const meta = TYPE_META[type];
|
||||||
const color = URGENCY_COLOR[meta.urgency];
|
const color = URGENCY_COLOR[meta.urgency];
|
||||||
const amount = '$' + Number(bill.expected_amount || 0).toFixed(2);
|
const amount = '$' + (fromCents(bill.expected_amount) || 0).toFixed(2);
|
||||||
const fmt = (d) => {
|
const fmt = (d) => {
|
||||||
if (!d) return '—';
|
if (!d) return '—';
|
||||||
const [y, m, day] = d.split('-');
|
const [y, m, day] = d.split('-');
|
||||||
|
|
@ -384,7 +385,7 @@ async function runNotifications() {
|
||||||
const meta = TYPE_META[type];
|
const meta = TYPE_META[type];
|
||||||
const subject = meta.subject(bill);
|
const subject = meta.subject(bill);
|
||||||
const urgency = meta.urgency;
|
const urgency = meta.urgency;
|
||||||
const amount = '$' + Number(bill.expected_amount || 0).toFixed(2);
|
const amount = '$' + (fromCents(bill.expected_amount) || 0).toFixed(2);
|
||||||
const pushBody = `${subject} · ${amount}`;
|
const pushBody = `${subject} · ${amount}`;
|
||||||
let sent = false;
|
let sent = false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
||||||
const { getCycleRange } = require('./statusService');
|
const { getCycleRange } = require('./statusService');
|
||||||
const { roundMoney } = require('../utils/money');
|
|
||||||
|
|
||||||
const ACCOUNTING_ACTIVE_SQL = 'COALESCE(accounting_excluded, 0) = 0';
|
const ACCOUNTING_ACTIVE_SQL = 'COALESCE(accounting_excluded, 0) = 0';
|
||||||
const BANK_PAYMENT_SOURCES = new Set(['provider_sync', 'transaction_match', 'auto_match']);
|
const BANK_PAYMENT_SOURCES = new Set(['provider_sync', 'transaction_match', 'auto_match']);
|
||||||
|
|
@ -40,7 +39,7 @@ function reversePaymentBalance(db, payment) {
|
||||||
const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id);
|
const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id);
|
||||||
if (bill?.current_balance == null) return;
|
if (bill?.current_balance == null) return;
|
||||||
|
|
||||||
const restored = Math.max(0, roundMoney(Number(bill.current_balance) - Number(payment.balance_delta)));
|
const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta)); // cents, exact integer arithmetic
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE bills
|
UPDATE bills
|
||||||
SET current_balance = ?,
|
SET current_balance = ?,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const { toCents, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
function isPositiveIntegerString(value) {
|
function isPositiveIntegerString(value) {
|
||||||
return /^\d+$/.test(String(value).trim());
|
return /^\d+$/.test(String(value).trim());
|
||||||
}
|
}
|
||||||
|
|
@ -31,12 +33,29 @@ function validateIsoDate(value, field = 'paid_date') {
|
||||||
return { value: trimmed };
|
return { value: trimmed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a positive dollar amount and converts it to integer cents
|
||||||
|
* (the unit `payments.amount` and related money columns are stored in).
|
||||||
|
*/
|
||||||
function validatePositiveAmount(value, field = 'amount') {
|
function validatePositiveAmount(value, field = 'amount') {
|
||||||
const amount = Number(value);
|
const cents = toCents(value);
|
||||||
if (!Number.isFinite(amount) || amount <= 0) {
|
if (!Number.isInteger(cents) || cents <= 0) {
|
||||||
return { error: `${field} must be a positive number` };
|
return { error: `${field} must be a positive number` };
|
||||||
}
|
}
|
||||||
return { value: amount };
|
return { value: cents };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a payment row's cent columns (amount, balance_delta, interest_delta)
|
||||||
|
* to dollars for API responses.
|
||||||
|
*/
|
||||||
|
function serializePayment(payment) {
|
||||||
|
if (!payment) return payment;
|
||||||
|
const out = { ...payment };
|
||||||
|
if (out.amount != null) out.amount = fromCents(out.amount);
|
||||||
|
if (out.balance_delta != null) out.balance_delta = fromCents(out.balance_delta);
|
||||||
|
if (out.interest_delta != null) out.interest_delta = fromCents(out.interest_delta);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync'];
|
const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync'];
|
||||||
|
|
@ -101,6 +120,7 @@ function validatePaymentInput(data, options = {}) {
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
PAYMENT_SOURCES,
|
PAYMENT_SOURCES,
|
||||||
|
serializePayment,
|
||||||
validateIsoDate,
|
validateIsoDate,
|
||||||
validatePaymentInput,
|
validatePaymentInput,
|
||||||
validatePaymentSource,
|
validatePaymentSource,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
const { normalizeMerchant } = require('./subscriptionService');
|
const { normalizeMerchant } = require('./subscriptionService');
|
||||||
const { localDateString } = require('../utils/dates');
|
const { localDateString } = require('../utils/dates');
|
||||||
|
const { toCents, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
// 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.
|
||||||
|
|
@ -56,7 +57,7 @@ function getSpendingSummary(db, userId, year, month) {
|
||||||
category_name: r.category_name ?? '(Uncategorized)',
|
category_name: r.category_name ?? '(Uncategorized)',
|
||||||
amount: r.total_cents / 100,
|
amount: r.total_cents / 100,
|
||||||
tx_count: r.tx_count,
|
tx_count: r.tx_count,
|
||||||
budget: r.category_id ? (budgetMap.get(r.category_id) ?? null) : null,
|
budget: r.category_id ? fromCents(budgetMap.get(r.category_id) ?? null) : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -218,12 +219,13 @@ function merchantMatches(txMerchant, ruleMerchant) {
|
||||||
// ── Budgets ──────────────────────────────────────────────────────────────────
|
// ── Budgets ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getSpendingBudgets(db, userId, year, month) {
|
function getSpendingBudgets(db, userId, year, month) {
|
||||||
return db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT sb.category_id, sb.amount, c.name AS category_name
|
SELECT sb.category_id, sb.amount, c.name AS category_name
|
||||||
FROM spending_budgets sb
|
FROM spending_budgets sb
|
||||||
JOIN categories c ON c.id = sb.category_id AND c.deleted_at IS NULL
|
JOIN categories c ON c.id = sb.category_id AND c.deleted_at IS NULL
|
||||||
WHERE sb.user_id=? AND sb.year=? AND sb.month=?
|
WHERE sb.user_id=? AND sb.year=? AND sb.month=?
|
||||||
`).all(userId, year, month);
|
`).all(userId, year, month);
|
||||||
|
return rows.map(r => ({ ...r, amount: fromCents(r.amount) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSpendingBudget(db, userId, categoryId, year, month, amount) {
|
function setSpendingBudget(db, userId, categoryId, year, month, amount) {
|
||||||
|
|
@ -236,7 +238,7 @@ function setSpendingBudget(db, userId, categoryId, year, month, amount) {
|
||||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||||
ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET
|
ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET
|
||||||
amount=excluded.amount, updated_at=datetime('now')
|
amount=excluded.amount, updated_at=datetime('now')
|
||||||
`).run(userId, categoryId, year, month, Number(amount));
|
`).run(userId, categoryId, year, month, toCents(amount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const xlsx = require('xlsx');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
||||||
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
||||||
|
const { toCents, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -556,7 +557,7 @@ function findBillMatches(detectedName, userBills) {
|
||||||
bill_name: bill.name,
|
bill_name: bill.name,
|
||||||
category_id: bill.category_id ?? null,
|
category_id: bill.category_id ?? null,
|
||||||
category: bill.category_name || null,
|
category: bill.category_name || null,
|
||||||
expected_amount: bill.expected_amount,
|
expected_amount: fromCents(bill.expected_amount),
|
||||||
due_day: bill.due_day ?? null,
|
due_day: bill.due_day ?? null,
|
||||||
match_confidence: scored.match_confidence,
|
match_confidence: scored.match_confidence,
|
||||||
match_reason: scored.match_reason,
|
match_reason: scored.match_reason,
|
||||||
|
|
@ -1383,6 +1384,7 @@ function amountsEqual(a, b) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, allowOverwrite) {
|
function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, allowOverwrite) {
|
||||||
|
amount = toCents(amount); // incoming amount is dollars (decision/spreadsheet); column is cents
|
||||||
const existing = db.prepare(`
|
const existing = db.prepare(`
|
||||||
SELECT id, actual_amount, notes, is_skipped
|
SELECT id, actual_amount, notes, is_skipped
|
||||||
FROM monthly_bill_state
|
FROM monthly_bill_state
|
||||||
|
|
@ -1428,6 +1430,7 @@ function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, a
|
||||||
|
|
||||||
function createPaymentFromImport(db, billId, amount, paidDate, notes, allowOverwrite) {
|
function createPaymentFromImport(db, billId, amount, paidDate, notes, allowOverwrite) {
|
||||||
if (!paidDate || amount == null || amount <= 0) return null;
|
if (!paidDate || amount == null || amount <= 0) return null;
|
||||||
|
amount = toCents(amount); // incoming amount is dollars (decision/spreadsheet); column is cents
|
||||||
|
|
||||||
const dup = db.prepare(`
|
const dup = db.prepare(`
|
||||||
SELECT id, created_at FROM payments
|
SELECT id, created_at FROM payments
|
||||||
|
|
@ -1550,7 +1553,7 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
||||||
const ins = db.prepare(`
|
const ins = db.prepare(`
|
||||||
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, cycle_type, cycle_day, autopay_enabled, active)
|
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, cycle_type, cycle_day, autopay_enabled, active)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'monthly', 'monthly', ?, ?, 1)
|
VALUES (?, ?, ?, ?, ?, ?, 'monthly', 'monthly', ?, ?, 1)
|
||||||
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, String(dueDay), autopay);
|
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', toCents(expectedAmount), String(dueDay), autopay);
|
||||||
|
|
||||||
const newBillId = ins.lastInsertRowid;
|
const newBillId = ins.lastInsertRowid;
|
||||||
summary.created++;
|
summary.created++;
|
||||||
|
|
@ -1660,10 +1663,13 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// payAmount is dollars (decision/spreadsheet); payments columns are cents
|
||||||
|
const payAmountCents = toCents(payAmount);
|
||||||
|
|
||||||
const dup = db.prepare(`
|
const dup = db.prepare(`
|
||||||
SELECT id, created_at, paid_date, amount FROM payments
|
SELECT id, created_at, paid_date, amount FROM payments
|
||||||
WHERE bill_id = ? AND paid_date = ? AND amount = ? AND deleted_at IS NULL
|
WHERE bill_id = ? AND paid_date = ? AND amount = ? AND deleted_at IS NULL
|
||||||
`).get(billId, payDate, payAmount);
|
`).get(billId, payDate, payAmountCents);
|
||||||
|
|
||||||
if (dup && !allowOverwrite) {
|
if (dup && !allowOverwrite) {
|
||||||
summary.skipped++;
|
summary.skipped++;
|
||||||
|
|
@ -1675,17 +1681,17 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
||||||
note: 'Identical payment already exists',
|
note: 'Identical payment already exists',
|
||||||
existing_created_at: dup.created_at ?? null,
|
existing_created_at: dup.created_at ?? null,
|
||||||
existing_paid_date: dup.paid_date ?? null,
|
existing_paid_date: dup.paid_date ?? null,
|
||||||
existing_amount: dup.amount ?? null,
|
existing_amount: fromCents(dup.amount),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const balCalcCp = computeBalanceDelta(bill, payAmount);
|
const balCalcCp = computeBalanceDelta(bill, payAmountCents);
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
|
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'file_import')
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'file_import')
|
||||||
`).run(billId, payAmount, payDate, decision.payment_method ?? null, decision.payment_notes ?? null, balCalcCp?.balance_delta ?? null, balCalcCp?.interest_delta ?? null);
|
`).run(billId, payAmountCents, payDate, decision.payment_method ?? null, decision.payment_notes ?? null, balCalcCp?.balance_delta ?? null, balCalcCp?.interest_delta ?? null);
|
||||||
|
|
||||||
applyBalanceDelta(db, billId, balCalcCp);
|
applyBalanceDelta(db, billId, balCalcCp);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ function pad(value) {
|
||||||
return String(value).padStart(2, '0');
|
return String(value).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { roundMoney, sumMoney } = require('../utils/money');
|
const { fromCents } = require('../utils/money');
|
||||||
|
const { serializePayment } = require('./paymentValidation');
|
||||||
|
|
||||||
function dateString(year, month, day) {
|
function dateString(year, month, day) {
|
||||||
return `${year}-${pad(month)}-${pad(day)}`;
|
return `${year}-${pad(month)}-${pad(day)}`;
|
||||||
|
|
@ -194,7 +195,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) {
|
||||||
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
|
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
|
||||||
const safePayments = Array.isArray(payments) ? payments : [];
|
const safePayments = Array.isArray(payments) ? payments : [];
|
||||||
const expectedAmount = Number(bill.expected_amount) || 0;
|
const expectedAmount = Number(bill.expected_amount) || 0;
|
||||||
const totalPaid = sumMoney(safePayments, p => p.amount);
|
const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
|
||||||
|
|
||||||
if (totalPaid >= expectedAmount) return 'paid';
|
if (totalPaid >= expectedAmount) return 'paid';
|
||||||
|
|
||||||
|
|
@ -223,11 +224,11 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
|
||||||
const safePayments = Array.isArray(payments) ? payments : [];
|
const safePayments = Array.isArray(payments) ? payments : [];
|
||||||
const status = calculateStatus(bill, safePayments, dueDate, todayStr, options);
|
const status = calculateStatus(bill, safePayments, dueDate, todayStr, options);
|
||||||
const expectedAmount = Number(bill.expected_amount) || 0;
|
const expectedAmount = Number(bill.expected_amount) || 0;
|
||||||
const totalPaid = sumMoney(safePayments, p => p.amount);
|
const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
|
||||||
const hasPayment = safePayments.length > 0;
|
const hasPayment = safePayments.length > 0;
|
||||||
const isSettled = status === 'paid' || status === 'autodraft';
|
const isSettled = status === 'paid' || status === 'autodraft';
|
||||||
const paidTowardDue = roundMoney(Math.min(totalPaid, expectedAmount));
|
const paidTowardDue = Math.min(totalPaid, expectedAmount);
|
||||||
const overpaidAmount = roundMoney(Math.max(totalPaid - expectedAmount, 0));
|
const overpaidAmount = Math.max(totalPaid - expectedAmount, 0);
|
||||||
const rawBalance = expectedAmount - totalPaid;
|
const rawBalance = expectedAmount - totalPaid;
|
||||||
const balance = isSettled ? 0 : Math.max(rawBalance, 0);
|
const balance = isSettled ? 0 : Math.max(rawBalance, 0);
|
||||||
const lastPayment = hasPayment
|
const lastPayment = hasPayment
|
||||||
|
|
@ -242,16 +243,16 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
|
||||||
due_date: dueDate,
|
due_date: dueDate,
|
||||||
due_day: bill.due_day,
|
due_day: bill.due_day,
|
||||||
bucket,
|
bucket,
|
||||||
expected_amount: expectedAmount,
|
expected_amount: fromCents(expectedAmount),
|
||||||
notes: bill.notes || null, // Bill-level notes (always available)
|
notes: bill.notes || null, // Bill-level notes (always available)
|
||||||
total_paid: totalPaid,
|
total_paid: fromCents(totalPaid),
|
||||||
paid_toward_due: paidTowardDue,
|
paid_toward_due: fromCents(paidTowardDue),
|
||||||
overpaid_amount: overpaidAmount,
|
overpaid_amount: fromCents(overpaidAmount),
|
||||||
balance,
|
balance: fromCents(balance),
|
||||||
has_payment: hasPayment,
|
has_payment: hasPayment,
|
||||||
is_settled: isSettled,
|
is_settled: isSettled,
|
||||||
last_paid_date: lastPayment ? lastPayment.paid_date : null,
|
last_paid_date: lastPayment ? lastPayment.paid_date : null,
|
||||||
last_payment_amount: lastPayment ? lastPayment.amount : null,
|
last_payment_amount: lastPayment ? fromCents(lastPayment.amount) : null,
|
||||||
status,
|
status,
|
||||||
autopay_enabled: !!bill.autopay_enabled,
|
autopay_enabled: !!bill.autopay_enabled,
|
||||||
autodraft_status: bill.autodraft_status,
|
autodraft_status: bill.autodraft_status,
|
||||||
|
|
@ -259,8 +260,8 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
|
||||||
billing_cycle: bill.billing_cycle,
|
billing_cycle: bill.billing_cycle,
|
||||||
cycle_type: normalizeCycleType(bill),
|
cycle_type: normalizeCycleType(bill),
|
||||||
cycle_day: bill.cycle_day,
|
cycle_day: bill.cycle_day,
|
||||||
current_balance: bill.current_balance ?? null,
|
current_balance: bill.current_balance != null ? fromCents(bill.current_balance) : null,
|
||||||
minimum_payment: bill.minimum_payment ?? null,
|
minimum_payment: bill.minimum_payment != null ? fromCents(bill.minimum_payment) : null,
|
||||||
interest_rate: bill.interest_rate ?? null,
|
interest_rate: bill.interest_rate ?? null,
|
||||||
is_subscription: !!bill.is_subscription,
|
is_subscription: !!bill.is_subscription,
|
||||||
has_2fa: !!bill.has_2fa,
|
has_2fa: !!bill.has_2fa,
|
||||||
|
|
@ -272,7 +273,7 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
|
||||||
inactivated_at: bill.inactivated_at ?? null,
|
inactivated_at: bill.inactivated_at ?? null,
|
||||||
sparkline: bill.sparkline ?? null,
|
sparkline: bill.sparkline ?? null,
|
||||||
autopay_stats: bill.autopay_stats ?? null,
|
autopay_stats: bill.autopay_stats ?? null,
|
||||||
payments: safePayments,
|
payments: safePayments.map(serializePayment),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,5 +286,4 @@ module.exports = {
|
||||||
resolveBucket,
|
resolveBucket,
|
||||||
resolveDueDate,
|
resolveDueDate,
|
||||||
resolveGracePeriodDays,
|
resolveGracePeriodDays,
|
||||||
roundMoney,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService');
|
const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService');
|
||||||
const { localDateString, todayLocal } = require('../utils/dates');
|
const { localDateString, todayLocal } = require('../utils/dates');
|
||||||
const { roundMoney, sumMoney, mulMoney } = require('../utils/money');
|
const { roundMoney, sumMoney, mulMoney, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
const SUBSCRIPTION_TYPES = [
|
const SUBSCRIPTION_TYPES = [
|
||||||
'streaming', 'software', 'cloud', 'music', 'news',
|
'streaming', 'software', 'cloud', 'music', 'news',
|
||||||
|
|
@ -494,9 +494,13 @@ function nextDueDate(bill, now = new Date()) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function decorateSubscription(bill) {
|
function decorateSubscription(bill) {
|
||||||
const monthly = monthlyEquivalent(bill.expected_amount, bill.cycle_type, bill.billing_cycle);
|
const expectedAmount = fromCents(bill.expected_amount);
|
||||||
|
const monthly = monthlyEquivalent(expectedAmount, bill.cycle_type, bill.billing_cycle);
|
||||||
return {
|
return {
|
||||||
...bill,
|
...bill,
|
||||||
|
expected_amount: expectedAmount,
|
||||||
|
current_balance: fromCents(bill.current_balance),
|
||||||
|
minimum_payment: fromCents(bill.minimum_payment),
|
||||||
is_subscription: !!bill.is_subscription,
|
is_subscription: !!bill.is_subscription,
|
||||||
active: !!bill.active,
|
active: !!bill.active,
|
||||||
monthly_equivalent: monthly,
|
monthly_equivalent: monthly,
|
||||||
|
|
@ -666,7 +670,7 @@ function existingBillMatch(existingBills, { merchant, catalogEntry, averageAmoun
|
||||||
|
|
||||||
if (score === 0) continue;
|
if (score === 0) continue;
|
||||||
|
|
||||||
const expected = Number(bill.expected_amount || 0);
|
const expected = fromCents(bill.expected_amount) || 0;
|
||||||
const amountDelta = expected ? Math.abs(expected - averageAmount) : null;
|
const amountDelta = expected ? Math.abs(expected - averageAmount) : null;
|
||||||
if (amountDelta !== null) {
|
if (amountDelta !== null) {
|
||||||
const pct = expected ? amountDelta / expected : 1;
|
const pct = expected ? amountDelta / expected : 1;
|
||||||
|
|
@ -1094,7 +1098,7 @@ function createSubscriptionFromRecommendation(db, userId, payload = {}) {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const tx of txRows) {
|
for (const tx of txRows) {
|
||||||
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
||||||
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
const amount = Math.round(Math.abs(tx.amount)); // tx.amount and payments.amount are both cents
|
||||||
updateTx.run(created.id, tx.id, userId);
|
updateTx.run(created.id, tx.id, userId);
|
||||||
if (paidDate) insertPayment.run(created.id, amount, paidDate, tx.id);
|
if (paidDate) insertPayment.run(created.id, amount, paidDate, tx.id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { buildTrackerRow, getCycleRange, resolveDueDate, roundMoney } = require('./statusService');
|
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
|
||||||
const { getUserSettings } = require('./userSettings');
|
const { getUserSettings } = require('./userSettings');
|
||||||
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
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 { localDateString } = require('../utils/dates');
|
||||||
const { sumMoney } = require('../utils/money');
|
const { sumMoney, roundMoney, fromCents } = require('../utils/money');
|
||||||
|
|
||||||
const DEFAULT_PENDING_DAYS = 3;
|
const DEFAULT_PENDING_DAYS = 3;
|
||||||
|
|
||||||
|
|
@ -116,9 +116,9 @@ function buildBankTracking(db, userId, year, month) {
|
||||||
`).get(year, month, start, end, userId);
|
`).get(year, month, start, end, userId);
|
||||||
|
|
||||||
const balance = roundMoney(account.balance / 100);
|
const balance = roundMoney(account.balance / 100);
|
||||||
const pending = roundMoney(pendingRow.pending_total);
|
const pending = fromCents(pendingRow.pending_total);
|
||||||
const effective = roundMoney(balance - pending);
|
const effective = roundMoney(balance - pending);
|
||||||
const unpaid = roundMoney(unpaidRow.unpaid_total);
|
const unpaid = fromCents(unpaidRow.unpaid_total);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -250,7 +250,7 @@ function fetchSparklines(db, billIds) {
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (!out[r.bill_id]) out[r.bill_id] = [];
|
if (!out[r.bill_id]) out[r.bill_id] = [];
|
||||||
out[r.bill_id].push(r.total);
|
out[r.bill_id].push(fromCents(r.total));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
@ -357,7 +357,7 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
|
||||||
if (dismissedSuggestions.has(bill.id)) return null;
|
if (dismissedSuggestions.has(bill.id)) return null;
|
||||||
return {
|
return {
|
||||||
bill_id: bill.id,
|
bill_id: bill.id,
|
||||||
amount: suggestedAmount,
|
amount: fromCents(suggestedAmount),
|
||||||
paid_date: dueDate,
|
paid_date: dueDate,
|
||||||
method: 'autopay',
|
method: 'autopay',
|
||||||
};
|
};
|
||||||
|
|
@ -389,7 +389,7 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
|
||||||
year: date.getFullYear(),
|
year: date.getFullYear(),
|
||||||
month: date.getMonth() + 1,
|
month: date.getMonth() + 1,
|
||||||
key: monthKey,
|
key: monthKey,
|
||||||
payment: parseFloat(monthlyPaymentsMap.get(monthKey) || 0),
|
payment: fromCents(monthlyPaymentsMap.get(monthKey) || 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -455,13 +455,13 @@ function getTracker(userId, query = {}, now = new Date()) {
|
||||||
const row = buildTrackerRow(billForStatus, payments, year, month, todayStr, rowOptions);
|
const row = buildTrackerRow(billForStatus, payments, year, month, todayStr, rowOptions);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
row.expected_amount = bill.expected_amount;
|
row.expected_amount = fromCents(bill.expected_amount);
|
||||||
row.actual_amount = mbs?.actual_amount ?? null;
|
row.actual_amount = mbs?.actual_amount != null ? fromCents(mbs.actual_amount) : null;
|
||||||
row.monthly_notes = mbs?.notes ?? null;
|
row.monthly_notes = mbs?.notes ?? null;
|
||||||
row.is_skipped = !!(mbs?.is_skipped);
|
row.is_skipped = !!(mbs?.is_skipped);
|
||||||
row.snoozed_until = mbs?.snoozed_until ?? null;
|
row.snoozed_until = mbs?.snoozed_until ?? null;
|
||||||
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
|
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
|
||||||
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
|
row.previous_month_paid = fromCents(prevMonthPayments[bill.id] || 0);
|
||||||
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
|
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
|
||||||
return row;
|
return row;
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
|
|
@ -476,6 +476,13 @@ function getTracker(userId, query = {}, now = new Date()) {
|
||||||
WHERE user_id = ? AND year = ? AND month = ?
|
WHERE user_id = ? AND year = ? AND month = ?
|
||||||
`).get(userId, year, month);
|
`).get(userId, year, month);
|
||||||
|
|
||||||
|
if (startingAmounts) {
|
||||||
|
startingAmounts.first_amount = fromCents(startingAmounts.first_amount);
|
||||||
|
startingAmounts.fifteenth_amount = fromCents(startingAmounts.fifteenth_amount);
|
||||||
|
startingAmounts.other_amount = fromCents(startingAmounts.other_amount);
|
||||||
|
startingAmounts.combined_amount = fromCents(startingAmounts.combined_amount);
|
||||||
|
}
|
||||||
|
|
||||||
const dayOfMonth = now.getDate();
|
const dayOfMonth = now.getDate();
|
||||||
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
|
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
|
||||||
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
|
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
|
||||||
|
|
@ -634,7 +641,7 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
|
||||||
name: bill.name,
|
name: bill.name,
|
||||||
category_name: bill.category_name,
|
category_name: bill.category_name,
|
||||||
due_date: dueDate,
|
due_date: dueDate,
|
||||||
expected_amount: bill.expected_amount,
|
expected_amount: row.expected_amount,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`) - new Date(`${todayStr}T00:00:00`)) / 86400000),
|
days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`) - new Date(`${todayStr}T00:00:00`)) / 86400000),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const {
|
||||||
decorateTransaction,
|
decorateTransaction,
|
||||||
getTransactionForUser,
|
getTransactionForUser,
|
||||||
} = require('./transactionService');
|
} = require('./transactionService');
|
||||||
const { roundMoney } = require('../utils/money');
|
const { serializePayment } = require('./paymentValidation');
|
||||||
|
|
||||||
const MATCH_PAYMENT_SOURCE = 'transaction_match';
|
const MATCH_PAYMENT_SOURCE = 'transaction_match';
|
||||||
const MATCH_PAYMENT_METHOD = 'transaction_match';
|
const MATCH_PAYMENT_METHOD = 'transaction_match';
|
||||||
|
|
@ -76,7 +76,7 @@ function paymentAmountForTransaction(transaction) {
|
||||||
'amount',
|
'amount',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Math.round(Math.abs(cents)) / 100;
|
return Math.round(Math.abs(cents)); // tx.amount and payments.amount are both cents
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivePaymentForTransaction(db, userId, transactionId) {
|
function getActivePaymentForTransaction(db, userId, transactionId) {
|
||||||
|
|
@ -109,7 +109,7 @@ function restorePaymentBalance(db, payment) {
|
||||||
const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id);
|
const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id);
|
||||||
if (bill?.current_balance == null) return;
|
if (bill?.current_balance == null) return;
|
||||||
|
|
||||||
const restored = Math.max(0, roundMoney(Number(bill.current_balance) - Number(payment.balance_delta)));
|
const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta)); // cents, exact integer arithmetic
|
||||||
// Clear interest_accrued_month when reversing a payment that charged interest,
|
// Clear interest_accrued_month when reversing a payment that charged interest,
|
||||||
// so the re-applied payment can accrue interest fresh.
|
// so the re-applied payment can accrue interest fresh.
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
|
|
@ -220,7 +220,7 @@ function unlinkPaymentForTransaction(db, userId, transactionId) {
|
||||||
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(existingPayment.id);
|
`).run(existingPayment.id);
|
||||||
return { ...existingPayment, deleted: true };
|
return { ...serializePayment(existingPayment), deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
|
|
@ -228,14 +228,14 @@ function unlinkPaymentForTransaction(db, userId, transactionId) {
|
||||||
SET transaction_id = NULL, updated_at = datetime('now')
|
SET transaction_id = NULL, updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(existingPayment.id);
|
`).run(existingPayment.id);
|
||||||
return { ...existingPayment, unlinked: true };
|
return { ...serializePayment(existingPayment), unlinked: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function responseForTransaction(db, userId, transactionId, paymentId = null, extra = {}) {
|
function responseForTransaction(db, userId, transactionId, paymentId = null, extra = {}) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
transaction: decorateTransaction(getTransactionForUser(db, userId, transactionId)),
|
transaction: decorateTransaction(getTransactionForUser(db, userId, transactionId)),
|
||||||
payment: getPaymentForResponse(db, userId, paymentId),
|
payment: serializePayment(getPaymentForResponse(db, userId, paymentId)),
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const path = require('path');
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService');
|
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService');
|
||||||
|
const { toCents } = require('../utils/money');
|
||||||
|
|
||||||
const MAX_SQLITE_BYTES = 50 * 1024 * 1024;
|
const MAX_SQLITE_BYTES = 50 * 1024 * 1024;
|
||||||
const SESSION_TTL_HOURS = 24;
|
const SESSION_TTL_HOURS = 24;
|
||||||
|
|
@ -131,7 +132,28 @@ function sanitizeCategory(row) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeBill(row) {
|
/**
|
||||||
|
* Convert a money value from a source export to integer cents.
|
||||||
|
* Pre-v1.03 exports store dollars (REAL); post-v1.03 exports already store
|
||||||
|
* integer cents. Applying toCents() to a cents value would multiply it ×100,
|
||||||
|
* so the caller detects the source's unit via its schema_migrations table.
|
||||||
|
*/
|
||||||
|
function importMoney(value, sourceIsCents) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
return sourceIsCents ? Math.round(Number(value)) : toCents(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the source DB already stores money in integer cents (v1.03+). */
|
||||||
|
function sourceUsesCents(src) {
|
||||||
|
try {
|
||||||
|
if (!tableNames(src).has('schema_migrations')) return false;
|
||||||
|
return !!src.prepare("SELECT 1 FROM schema_migrations WHERE version = 'v1.03'").get();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeBill(row, sourceIsCents) {
|
||||||
const name = cleanText(row.name, 160);
|
const name = cleanText(row.name, 160);
|
||||||
const dueDay = toInt(row.due_day);
|
const dueDay = toInt(row.due_day);
|
||||||
if (!name || dueDay < 1 || dueDay > 31) return null;
|
if (!name || dueDay < 1 || dueDay > 31) return null;
|
||||||
|
|
@ -149,7 +171,7 @@ function sanitizeBill(row) {
|
||||||
due_day: dueDay,
|
due_day: dueDay,
|
||||||
override_due_date: cleanText(row.override_due_date, 32),
|
override_due_date: cleanText(row.override_due_date, 32),
|
||||||
bucket: dueDay <= 14 ? '1st' : '15th',
|
bucket: dueDay <= 14 ? '1st' : '15th',
|
||||||
expected_amount: Math.max(0, toNumber(row.expected_amount, 0) ?? 0),
|
expected_amount: importMoney(Math.max(0, toNumber(row.expected_amount, 0) ?? 0), sourceIsCents),
|
||||||
interest_rate: interestRate == null || interestRate < 0 || interestRate > 100 ? null : interestRate,
|
interest_rate: interestRate == null || interestRate < 0 || interestRate > 100 ? null : interestRate,
|
||||||
billing_cycle: VALID_BILLING_CYCLES.has(row.billing_cycle) ? row.billing_cycle : billingCycleForCycleType(normalizedCycleType),
|
billing_cycle: VALID_BILLING_CYCLES.has(row.billing_cycle) ? row.billing_cycle : billingCycleForCycleType(normalizedCycleType),
|
||||||
cycle_type: normalizedCycleType,
|
cycle_type: normalizedCycleType,
|
||||||
|
|
@ -167,7 +189,7 @@ function sanitizeBill(row) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizePayment(row, validBillIds) {
|
function sanitizePayment(row, validBillIds, sourceIsCents) {
|
||||||
const billId = toInt(row.bill_id);
|
const billId = toInt(row.bill_id);
|
||||||
const amount = toNumber(row.amount);
|
const amount = toNumber(row.amount);
|
||||||
const paidDate = cleanDate(row.paid_date);
|
const paidDate = cleanDate(row.paid_date);
|
||||||
|
|
@ -176,7 +198,7 @@ function sanitizePayment(row, validBillIds) {
|
||||||
return {
|
return {
|
||||||
old_id: toInt(row.id),
|
old_id: toInt(row.id),
|
||||||
bill_id: billId,
|
bill_id: billId,
|
||||||
amount,
|
amount: importMoney(amount, sourceIsCents),
|
||||||
paid_date: paidDate,
|
paid_date: paidDate,
|
||||||
method: cleanText(row.method, 120),
|
method: cleanText(row.method, 120),
|
||||||
notes: cleanText(row.notes, 2000),
|
notes: cleanText(row.notes, 2000),
|
||||||
|
|
@ -187,7 +209,7 @@ function sanitizePayment(row, validBillIds) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeMonthlyState(row, validBillIds) {
|
function sanitizeMonthlyState(row, validBillIds, sourceIsCents) {
|
||||||
const billId = toInt(row.bill_id);
|
const billId = toInt(row.bill_id);
|
||||||
const year = toInt(row.year);
|
const year = toInt(row.year);
|
||||||
const month = toInt(row.month);
|
const month = toInt(row.month);
|
||||||
|
|
@ -198,7 +220,7 @@ function sanitizeMonthlyState(row, validBillIds) {
|
||||||
bill_id: billId,
|
bill_id: billId,
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
actual_amount: actual == null || actual < 0 ? null : actual,
|
actual_amount: actual == null || actual < 0 ? null : importMoney(actual, sourceIsCents),
|
||||||
notes: cleanText(row.notes, 2000),
|
notes: cleanText(row.notes, 2000),
|
||||||
is_skipped: toInt(row.is_skipped, 0) ? 1 : 0,
|
is_skipped: toInt(row.is_skipped, 0) ? 1 : 0,
|
||||||
created_at: cleanText(row.created_at, 32),
|
created_at: cleanText(row.created_at, 32),
|
||||||
|
|
@ -206,7 +228,7 @@ function sanitizeMonthlyState(row, validBillIds) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeMonthlyStartingAmounts(row) {
|
function sanitizeMonthlyStartingAmounts(row, sourceIsCents) {
|
||||||
const year = toInt(row.year);
|
const year = toInt(row.year);
|
||||||
const month = toInt(row.month);
|
const month = toInt(row.month);
|
||||||
if (year < 2000 || year > 2100 || month < 1 || month > 12) return null;
|
if (year < 2000 || year > 2100 || month < 1 || month > 12) return null;
|
||||||
|
|
@ -214,9 +236,9 @@ function sanitizeMonthlyStartingAmounts(row) {
|
||||||
old_id: toInt(row.id),
|
old_id: toInt(row.id),
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
first_amount: Math.max(0, toNumber(row.first_amount, 0) ?? 0),
|
first_amount: importMoney(Math.max(0, toNumber(row.first_amount, 0) ?? 0), sourceIsCents),
|
||||||
fifteenth_amount: Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0),
|
fifteenth_amount: importMoney(Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0), sourceIsCents),
|
||||||
other_amount: Math.max(0, toNumber(row.other_amount, 0) ?? 0),
|
other_amount: importMoney(Math.max(0, toNumber(row.other_amount, 0) ?? 0), sourceIsCents),
|
||||||
notes: cleanText(row.notes, 2000),
|
notes: cleanText(row.notes, 2000),
|
||||||
created_at: cleanText(row.created_at, 32),
|
created_at: cleanText(row.created_at, 32),
|
||||||
updated_at: cleanText(row.updated_at, 32),
|
updated_at: cleanText(row.updated_at, 32),
|
||||||
|
|
@ -234,23 +256,25 @@ function readExportData(src) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = parseMetadata(src);
|
const metadata = parseMetadata(src);
|
||||||
|
// Pre-v1.03 exports store money in dollars; v1.03+ exports store integer cents.
|
||||||
|
const sourceIsCents = sourceUsesCents(src);
|
||||||
const categories = selectKnown(src, 'categories', ['id', 'name', 'created_at', 'updated_at'])
|
const categories = selectKnown(src, 'categories', ['id', 'name', 'created_at', 'updated_at'])
|
||||||
.map(sanitizeCategory).filter(Boolean);
|
.map(sanitizeCategory).filter(Boolean);
|
||||||
const bills = selectKnown(src, 'bills', [
|
const bills = selectKnown(src, 'bills', [
|
||||||
'id', 'name', 'category_id', 'due_day', 'override_due_date', 'bucket', 'expected_amount', 'interest_rate',
|
'id', 'name', 'category_id', 'due_day', 'override_due_date', 'bucket', 'expected_amount', 'interest_rate',
|
||||||
'billing_cycle', 'autopay_enabled', 'autodraft_status', 'website', 'username', 'account_info', 'has_2fa',
|
'billing_cycle', 'autopay_enabled', 'autodraft_status', 'website', 'username', 'account_info', 'has_2fa',
|
||||||
'active', 'notes', 'created_at', 'updated_at',
|
'active', 'notes', 'created_at', 'updated_at',
|
||||||
]).map(sanitizeBill).filter(Boolean);
|
]).map(row => sanitizeBill(row, sourceIsCents)).filter(Boolean);
|
||||||
const validBillIds = new Set(bills.map(b => b.old_id).filter(Boolean));
|
const validBillIds = new Set(bills.map(b => b.old_id).filter(Boolean));
|
||||||
const payments = selectKnown(src, 'payments', [
|
const payments = selectKnown(src, 'payments', [
|
||||||
'id', 'bill_id', 'amount', 'paid_date', 'method', 'notes', 'payment_source', 'transaction_id', 'created_at', 'updated_at',
|
'id', 'bill_id', 'amount', 'paid_date', 'method', 'notes', 'payment_source', 'transaction_id', 'created_at', 'updated_at',
|
||||||
])
|
])
|
||||||
.map(row => sanitizePayment(row, validBillIds)).filter(Boolean);
|
.map(row => sanitizePayment(row, validBillIds, sourceIsCents)).filter(Boolean);
|
||||||
const monthlyState = selectKnown(src, 'monthly_bill_state', ['id', 'bill_id', 'year', 'month', 'actual_amount', 'notes', 'is_skipped', 'created_at', 'updated_at'])
|
const monthlyState = selectKnown(src, 'monthly_bill_state', ['id', 'bill_id', 'year', 'month', 'actual_amount', 'notes', 'is_skipped', 'created_at', 'updated_at'])
|
||||||
.map(row => sanitizeMonthlyState(row, validBillIds)).filter(Boolean);
|
.map(row => sanitizeMonthlyState(row, validBillIds, sourceIsCents)).filter(Boolean);
|
||||||
const monthlyStartingAmounts = names.has('monthly_starting_amounts')
|
const monthlyStartingAmounts = names.has('monthly_starting_amounts')
|
||||||
? selectKnown(src, 'monthly_starting_amounts', ['id', 'year', 'month', 'first_amount', 'fifteenth_amount', 'other_amount', 'notes', 'created_at', 'updated_at'])
|
? selectKnown(src, 'monthly_starting_amounts', ['id', 'year', 'month', 'first_amount', 'fifteenth_amount', 'other_amount', 'notes', 'created_at', 'updated_at'])
|
||||||
.map(sanitizeMonthlyStartingAmounts).filter(Boolean)
|
.map(row => sanitizeMonthlyStartingAmounts(row, sourceIsCents)).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
const notes = names.has('notes')
|
const notes = names.has('notes')
|
||||||
? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', 'notes'])
|
? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', 'notes'])
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ function createUser(db, suffix) {
|
||||||
function createBill(db, userId, name, dueDay) {
|
function createBill(db, userId, name, dueDay) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
INSERT INTO bills (user_id, name, due_day, expected_amount)
|
INSERT INTO bills (user_id, name, due_day, expected_amount)
|
||||||
VALUES (?, ?, ?, 25)
|
VALUES (?, ?, ?, 2500)
|
||||||
`).run(userId, name, dueDay).lastInsertRowid;
|
`).run(userId, name, dueDay).lastInsertRowid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ function createBill(db, userId, overrides = {}) {
|
||||||
userId,
|
userId,
|
||||||
overrides.name || 'Water, Power; Internet',
|
overrides.name || 'Water, Power; Internet',
|
||||||
overrides.due_day || 15,
|
overrides.due_day || 15,
|
||||||
overrides.expected_amount || 123.45,
|
overrides.expected_amount || 12345,
|
||||||
overrides.cycle_type || 'monthly',
|
overrides.cycle_type || 'monthly',
|
||||||
overrides.cycle_day || '1',
|
overrides.cycle_day || '1',
|
||||||
overrides.billing_cycle || 'monthly',
|
overrides.billing_cycle || 'monthly',
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,8 @@ test('tracker rows are skipped when a bill does not occur in the requested month
|
||||||
|
|
||||||
test('tracker rows cap due math when a payment exceeds the amount due', () => {
|
test('tracker rows cap due math when a payment exceeds the amount due', () => {
|
||||||
const row = buildTrackerRow(
|
const row = buildTrackerRow(
|
||||||
bill({ expected_amount: 100 }),
|
bill({ expected_amount: 10000 }),
|
||||||
[{ amount: 125, paid_date: '2026-05-10' }],
|
[{ amount: 12500, paid_date: '2026-05-10' }],
|
||||||
2026,
|
2026,
|
||||||
5,
|
5,
|
||||||
'2026-05-16',
|
'2026-05-16',
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ function createBill(db, userId, overrides = {}) {
|
||||||
userId,
|
userId,
|
||||||
overrides.name || 'Netflix',
|
overrides.name || 'Netflix',
|
||||||
overrides.due_day || 8,
|
overrides.due_day || 8,
|
||||||
overrides.expected_amount ?? 15.99,
|
overrides.expected_amount ?? 1599,
|
||||||
overrides.is_subscription ?? 1,
|
overrides.is_subscription ?? 1,
|
||||||
overrides.cycle_type || 'monthly',
|
overrides.cycle_type || 'monthly',
|
||||||
overrides.billing_cycle || 'monthly',
|
overrides.billing_cycle || 'monthly',
|
||||||
|
|
@ -97,7 +97,7 @@ test('existing tracked bills are recommended for linking instead of tracking aga
|
||||||
const billId = createBill(db, userId, {
|
const billId = createBill(db, userId, {
|
||||||
name: 'Netflix',
|
name: 'Netflix',
|
||||||
due_day: 12,
|
due_day: 12,
|
||||||
expected_amount: 15.99,
|
expected_amount: 1599,
|
||||||
is_subscription: 1,
|
is_subscription: 1,
|
||||||
});
|
});
|
||||||
createTransaction(db, userId, {
|
createTransaction(db, userId, {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ function createUser(db, suffix) {
|
||||||
function createBill(db, userId, name = 'City Water') {
|
function createBill(db, userId, name = 'City Water') {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
INSERT INTO bills (user_id, name, due_day, expected_amount)
|
INSERT INTO bills (user_id, name, due_day, expected_amount)
|
||||||
VALUES (?, ?, 16, 85)
|
VALUES (?, ?, 16, 8500)
|
||||||
`).run(userId, name).lastInsertRowid;
|
`).run(userId, name).lastInsertRowid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,7 +68,7 @@ function createManualPayment(db, billId, overrides = {}) {
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
billId,
|
billId,
|
||||||
overrides.amount ?? 85,
|
overrides.amount ?? 8500,
|
||||||
overrides.paid_date || '2026-05-16',
|
overrides.paid_date || '2026-05-16',
|
||||||
overrides.method || 'manual',
|
overrides.method || 'manual',
|
||||||
overrides.payment_source || 'manual',
|
overrides.payment_source || 'manual',
|
||||||
|
|
@ -237,7 +237,7 @@ test('transaction match payments cannot be edited, deleted, or restored through
|
||||||
assert.equal(updateRes.status, 409);
|
assert.equal(updateRes.status, 409);
|
||||||
|
|
||||||
let payment = db.prepare('SELECT amount, paid_date, method, payment_source, transaction_id, deleted_at FROM payments WHERE id = ?').get(matched.payment.id);
|
let payment = db.prepare('SELECT amount, paid_date, method, payment_source, transaction_id, deleted_at FROM payments WHERE id = ?').get(matched.payment.id);
|
||||||
assert.equal(payment.amount, 85);
|
assert.equal(payment.amount, 8500);
|
||||||
assert.equal(payment.paid_date, '2026-05-16');
|
assert.equal(payment.paid_date, '2026-05-16');
|
||||||
assert.equal(payment.method, 'transaction_match');
|
assert.equal(payment.method, 'transaction_match');
|
||||||
assert.equal(payment.payment_source, 'transaction_match');
|
assert.equal(payment.payment_source, 'transaction_match');
|
||||||
|
|
@ -387,7 +387,7 @@ test('manual payment history remains visible and suppresses duplicate suggestion
|
||||||
const userId = createUser(db, 'manual-history');
|
const userId = createUser(db, 'manual-history');
|
||||||
const billId = createBill(db, userId, 'Internet');
|
const billId = createBill(db, userId, 'Internet');
|
||||||
const manualPaymentId = createManualPayment(db, billId, {
|
const manualPaymentId = createManualPayment(db, billId, {
|
||||||
amount: 65,
|
amount: 6500,
|
||||||
notes: 'Paid from checking',
|
notes: 'Paid from checking',
|
||||||
});
|
});
|
||||||
const transactionId = createTransaction(db, userId, {
|
const transactionId = createTransaction(db, userId, {
|
||||||
|
|
@ -427,7 +427,7 @@ test('bank-backed match overrides same-cycle manual tracker payment but keeps it
|
||||||
const userId = createUser(db, 'bank-override');
|
const userId = createUser(db, 'bank-override');
|
||||||
const billId = createBill(db, userId, 'Internet Override');
|
const billId = createBill(db, userId, 'Internet Override');
|
||||||
const manualPaymentId = createManualPayment(db, billId, {
|
const manualPaymentId = createManualPayment(db, billId, {
|
||||||
amount: 85,
|
amount: 8500,
|
||||||
notes: 'Marked paid while waiting for bank clear',
|
notes: 'Marked paid while waiting for bank clear',
|
||||||
});
|
});
|
||||||
const transactionId = createTransaction(db, userId, {
|
const transactionId = createTransaction(db, userId, {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue