feat: v0.93 — stable provider keys, per-payment interest tracking with once-per-month gating
This commit is contained in:
parent
a2ac241cd3
commit
840620efe2
|
|
@ -2984,6 +2984,78 @@ function runMigrations() {
|
||||||
`);
|
`);
|
||||||
console.log('[v0.92] WebAuthn tables + users columns added');
|
console.log('[v0.92] WebAuthn tables + users columns added');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.93',
|
||||||
|
description: 'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key + dedupe index',
|
||||||
|
dependsOn: ['v0.92'],
|
||||||
|
run: function() {
|
||||||
|
// 1. Track the calendar month when interest was last applied to a debt bill
|
||||||
|
// so computeBalanceDelta can skip interest if it was already charged this month.
|
||||||
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||||
|
if (!billCols.includes('interest_accrued_month')) {
|
||||||
|
db.exec('ALTER TABLE bills ADD COLUMN interest_accrued_month TEXT');
|
||||||
|
console.log('[v0.93] bills.interest_accrued_month column added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Track the interest component of each payment separately so delete/restore
|
||||||
|
// can handle it without double-charging interest.
|
||||||
|
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
||||||
|
if (!paymentCols.includes('interest_delta')) {
|
||||||
|
db.exec('ALTER TABLE payments ADD COLUMN interest_delta REAL');
|
||||||
|
console.log('[v0.93] payments.interest_delta column added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Strip the data_source_id from existing provider_transaction_id keys so
|
||||||
|
// they survive disconnect/reconnect. Old: "simplefin:{dsId}:{acctId}:{txId}"
|
||||||
|
// New: "simplefin:{acctId}:{txId}".
|
||||||
|
// Only rows where the segment after "simplefin:" is a numeric id are migrated.
|
||||||
|
db.exec(`
|
||||||
|
UPDATE transactions
|
||||||
|
SET provider_transaction_id =
|
||||||
|
'simplefin:' || SUBSTR(
|
||||||
|
provider_transaction_id,
|
||||||
|
INSTR(SUBSTR(provider_transaction_id, 11), ':') + 11
|
||||||
|
)
|
||||||
|
WHERE provider_transaction_id LIKE 'simplefin:%'
|
||||||
|
AND CAST(
|
||||||
|
SUBSTR(provider_transaction_id, 11,
|
||||||
|
INSTR(SUBSTR(provider_transaction_id, 11), ':') - 1)
|
||||||
|
AS INTEGER) > 0
|
||||||
|
`);
|
||||||
|
console.log('[v0.93] transactions: stripped data_source_id from provider_transaction_id');
|
||||||
|
|
||||||
|
// 4. Dedup: after the key change, users who disconnected and reconnected now
|
||||||
|
// have duplicate (user_id, provider_transaction_id) pairs. Keep the best row
|
||||||
|
// (prefer linked rows; break ties by most-recent created_at).
|
||||||
|
db.exec(`
|
||||||
|
DELETE FROM transactions
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY user_id, provider_transaction_id
|
||||||
|
ORDER BY (data_source_id IS NULL) ASC, created_at DESC
|
||||||
|
) AS rn
|
||||||
|
FROM transactions
|
||||||
|
WHERE provider_transaction_id IS NOT NULL
|
||||||
|
)
|
||||||
|
WHERE rn > 1
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('[v0.93] transactions: removed duplicate provider keys from disconnect/reconnect');
|
||||||
|
|
||||||
|
// 5. Replace the old dedupe index (data_source_id, provider_transaction_id)
|
||||||
|
// with a user-scoped one (user_id, provider_transaction_id) so reconnect
|
||||||
|
// with a new data_source_id still deduplicates correctly.
|
||||||
|
db.exec(`
|
||||||
|
DROP INDEX IF EXISTS idx_transactions_provider_dedupe;
|
||||||
|
CREATE UNIQUE INDEX idx_transactions_provider_dedupe
|
||||||
|
ON transactions (user_id, provider_transaction_id)
|
||||||
|
WHERE provider_transaction_id IS NOT NULL;
|
||||||
|
`);
|
||||||
|
console.log('[v0.93] transactions: dedupe index changed to (user_id, provider_transaction_id)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -3327,6 +3399,19 @@ function getDbPath() {
|
||||||
|
|
||||||
// Rollback SQL definitions
|
// Rollback SQL definitions
|
||||||
const ROLLBACK_SQL_MAP = {
|
const ROLLBACK_SQL_MAP = {
|
||||||
|
'v0.93': {
|
||||||
|
description: 'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key',
|
||||||
|
sql: [
|
||||||
|
'ALTER TABLE bills DROP COLUMN IF EXISTS interest_accrued_month',
|
||||||
|
'ALTER TABLE payments DROP COLUMN IF EXISTS interest_delta',
|
||||||
|
// Restore the old (data_source_id, provider_transaction_id) dedupe index.
|
||||||
|
// The key format change and deleted duplicates cannot be reversed.
|
||||||
|
'DROP INDEX IF EXISTS idx_transactions_provider_dedupe',
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_provider_dedupe
|
||||||
|
ON transactions (data_source_id, provider_transaction_id)
|
||||||
|
WHERE provider_transaction_id IS NOT NULL`,
|
||||||
|
]
|
||||||
|
},
|
||||||
'v0.44': {
|
'v0.44': {
|
||||||
description: 'performance: add missing indexes for frequently queried columns',
|
description: 'performance: add missing indexes for frequently queried columns',
|
||||||
sql: [
|
sql: [
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const {
|
||||||
sanitizeTemplateData,
|
sanitizeTemplateData,
|
||||||
validateBillData,
|
validateBillData,
|
||||||
computeBalanceDelta,
|
computeBalanceDelta,
|
||||||
|
applyBalanceDelta,
|
||||||
} = 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');
|
||||||
|
|
@ -683,13 +684,10 @@ router.post('/:id/toggle-paid', (req, res) => {
|
||||||
const balCalc = computeBalanceDelta(bill, payment.amount);
|
const balCalc = computeBalanceDelta(bill, payment.amount);
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
).run(billId, payment.amount, payment.paid_date, method, notes, balCalc?.balance_delta ?? null, payment.payment_source);
|
).run(billId, payment.amount, payment.paid_date, method, notes, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment.payment_source);
|
||||||
|
|
||||||
if (balCalc) {
|
applyBalanceDelta(db, billId, balCalc);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
|
||||||
.run(balCalc.new_balance, billId);
|
|
||||||
}
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
isPaid: true,
|
isPaid: true,
|
||||||
|
|
@ -1138,13 +1136,12 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => {
|
||||||
if (validIds.length === 0)
|
if (validIds.length === 0)
|
||||||
return res.status(400).json(standardizeError('No valid transaction ids provided', 'VALIDATION_ERROR'));
|
return res.status(400).json(standardizeError('No valid transaction ids provided', 'VALIDATION_ERROR'));
|
||||||
|
|
||||||
const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL');
|
const getBill = db.prepare('SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL');
|
||||||
const getTx = db.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ? AND amount < 0');
|
const getTx = db.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ? AND amount < 0');
|
||||||
const insertPayment = db.prepare(`
|
const insertPayment = db.prepare(`
|
||||||
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta)
|
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta, interest_delta)
|
||||||
VALUES (?, ?, ?, 'provider_sync', ?, ?)
|
VALUES (?, ?, ?, 'provider_sync', ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
|
|
||||||
const updateTx = db.prepare(`
|
const updateTx = db.prepare(`
|
||||||
UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') WHERE id = ?
|
UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') WHERE id = ?
|
||||||
`);
|
`);
|
||||||
|
|
@ -1165,9 +1162,9 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => {
|
||||||
const billRow = getBill.get(billId);
|
const billRow = getBill.get(billId);
|
||||||
const balCalc = billRow ? computeBalanceDelta(billRow, amount) : null;
|
const balCalc = billRow ? computeBalanceDelta(billRow, amount) : null;
|
||||||
|
|
||||||
const result = insertPayment.run(billId, amount, paidDate, txId, balCalc?.balance_delta ?? null);
|
const result = insertPayment.run(billId, amount, paidDate, txId, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null);
|
||||||
if (result.changes > 0) {
|
if (result.changes > 0) {
|
||||||
if (balCalc) updateBalance.run(balCalc.new_balance, billId);
|
applyBalanceDelta(db, billId, balCalc);
|
||||||
updateTx.run(billId, txId);
|
updateTx.run(billId, txId);
|
||||||
imported++;
|
imported++;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { computeBalanceDelta } = require('../services/billsService');
|
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
|
||||||
const {
|
const {
|
||||||
listMatchSuggestions,
|
listMatchSuggestions,
|
||||||
rejectMatchSuggestion,
|
rejectMatchSuggestion,
|
||||||
|
|
@ -62,13 +62,10 @@ router.post('/confirm', (req, res) => {
|
||||||
|
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
const payResult = db.prepare(
|
const payResult = db.prepare(
|
||||||
"INSERT INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta) VALUES (?, ?, ?, 'transaction_match', ?, ?)"
|
"INSERT INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta, interest_delta) VALUES (?, ?, ?, 'transaction_match', ?, ?, ?)"
|
||||||
).run(billId, amount, paidDate, txId, balCalc?.balance_delta ?? null);
|
).run(billId, amount, paidDate, txId, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null);
|
||||||
|
|
||||||
if (balCalc) {
|
applyBalanceDelta(db, billId, balCalc);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
|
||||||
.run(balCalc.new_balance, billId);
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE transactions
|
UPDATE transactions
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ const express = require('express');
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
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 } = require('../services/billsService');
|
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
|
||||||
const { validatePaymentInput } = require('../services/paymentValidation');
|
const { validatePaymentInput } = require('../services/paymentValidation');
|
||||||
const { getCycleRange, resolveDueDate } = require('../services/statusService');
|
const { getCycleRange, resolveDueDate } = require('../services/statusService');
|
||||||
|
|
||||||
|
|
@ -130,13 +130,10 @@ router.post('/', (req, res) => {
|
||||||
|
|
||||||
const balCalc = computeBalanceDelta(bill, payment.amount);
|
const balCalc = computeBalanceDelta(bill, payment.amount);
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, payment.payment_source);
|
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment.payment_source);
|
||||||
|
|
||||||
if (balCalc) {
|
applyBalanceDelta(db, bill.id, balCalc);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
|
||||||
.run(balCalc.new_balance, bill.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
|
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
|
||||||
});
|
});
|
||||||
|
|
@ -172,13 +169,10 @@ router.post('/quick', (req, res) => {
|
||||||
const balCalc = computeBalanceDelta(bill, payAmount);
|
const balCalc = computeBalanceDelta(bill, payAmount);
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null, paySource);
|
).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, paySource);
|
||||||
|
|
||||||
if (balCalc) {
|
applyBalanceDelta(db, bill.id, balCalc);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
|
||||||
.run(balCalc.new_balance, bill.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
|
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
|
||||||
});
|
});
|
||||||
|
|
@ -230,8 +224,8 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
|
||||||
|
|
||||||
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
|
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source)
|
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
bill.id,
|
bill.id,
|
||||||
suggestedPayment.amount,
|
suggestedPayment.amount,
|
||||||
|
|
@ -239,13 +233,11 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
|
||||||
'autopay',
|
'autopay',
|
||||||
'Confirmed autopay suggestion',
|
'Confirmed autopay suggestion',
|
||||||
balCalc?.balance_delta ?? null,
|
balCalc?.balance_delta ?? null,
|
||||||
|
balCalc?.interest_delta ?? null,
|
||||||
'manual',
|
'manual',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (balCalc) {
|
applyBalanceDelta(db, bill.id, balCalc);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at=datetime('now') WHERE id=?")
|
|
||||||
.run(balCalc.new_balance, bill.id);
|
|
||||||
}
|
|
||||||
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);
|
||||||
|
|
||||||
|
|
@ -309,10 +301,9 @@ router.post('/bulk', (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const insert = db.prepare(
|
const insert = db.prepare(
|
||||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
const getBillForBalance = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL');
|
const getBillForBalance = db.prepare('SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL');
|
||||||
const applyBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
|
|
||||||
|
|
||||||
// Prepare statement for duplicate checking
|
// Prepare statement for duplicate checking
|
||||||
const duplicateCheckStmt = db.prepare(
|
const duplicateCheckStmt = db.prepare(
|
||||||
|
|
@ -350,8 +341,8 @@ router.post('/bulk', (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const balCalc = computeBalanceDelta(billRow, parsedAmt);
|
const balCalc = computeBalanceDelta(billRow, parsedAmt);
|
||||||
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_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);
|
||||||
if (balCalc) applyBalance.run(balCalc.new_balance, bill_id);
|
applyBalanceDelta(db, bill_id, balCalc);
|
||||||
|
|
||||||
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
|
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
|
||||||
}
|
}
|
||||||
|
|
@ -383,18 +374,27 @@ router.put('/:id', (req, res) => {
|
||||||
let nextBalanceDelta = existing.balance_delta;
|
let nextBalanceDelta = existing.balance_delta;
|
||||||
|
|
||||||
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(existing.bill_id, req.user.id);
|
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(existing.bill_id, req.user.id);
|
||||||
|
let nextInterestDelta = existing.interest_delta ?? null;
|
||||||
if (bill) {
|
if (bill) {
|
||||||
|
// Reverse only the *payment* portion of the stored delta (not the interest component)
|
||||||
|
// so that interest already charged this month is not double-counted. For legacy rows
|
||||||
|
// where interest_delta is NULL, fall back to reversing the full delta as before.
|
||||||
|
const interestPortion = existing.interest_delta ?? 0;
|
||||||
|
const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
|
||||||
let restoredBalance = bill.current_balance;
|
let restoredBalance = bill.current_balance;
|
||||||
if (existing.balance_delta != null && bill.current_balance != null) {
|
if (paymentPortion != null && bill.current_balance != null) {
|
||||||
restoredBalance = Math.max(0, Math.round((bill.current_balance - existing.balance_delta) * 100) / 100);
|
restoredBalance = Math.max(0, Math.round((bill.current_balance - paymentPortion) * 100) / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// interest_accrued_month is still set to this month (if interest was charged) so
|
||||||
|
// computeBalanceDelta will skip interest when the payment is within the same month,
|
||||||
|
// and charge a fresh month of interest if editing into a new calendar month.
|
||||||
const balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
|
const balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
|
||||||
nextBalanceDelta = balCalc?.balance_delta ?? null;
|
nextBalanceDelta = balCalc?.balance_delta ?? null;
|
||||||
|
nextInterestDelta = balCalc?.interest_delta ?? null;
|
||||||
if (balCalc) {
|
if (balCalc) {
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
applyBalanceDelta(db, existing.bill_id, balCalc);
|
||||||
.run(balCalc.new_balance, existing.bill_id);
|
} else if (paymentPortion != null && restoredBalance != null) {
|
||||||
} else if (existing.balance_delta != null && restoredBalance != null) {
|
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
.run(restoredBalance, existing.bill_id);
|
.run(restoredBalance, existing.bill_id);
|
||||||
}
|
}
|
||||||
|
|
@ -402,8 +402,8 @@ router.put('/:id', (req, res) => {
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE payments SET
|
UPDATE payments SET
|
||||||
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, payment_source = ?,
|
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?,
|
||||||
updated_at = datetime('now')
|
payment_source = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
|
AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
|
||||||
`).run(
|
`).run(
|
||||||
|
|
@ -412,6 +412,7 @@ router.put('/:id', (req, res) => {
|
||||||
method !== undefined ? (method || null) : existing.method,
|
method !== undefined ? (method || null) : existing.method,
|
||||||
notes !== undefined ? (notes || null) : existing.notes,
|
notes !== undefined ? (notes || null) : existing.notes,
|
||||||
nextBalanceDelta,
|
nextBalanceDelta,
|
||||||
|
nextInterestDelta,
|
||||||
nextPaymentSource,
|
nextPaymentSource,
|
||||||
req.params.id,
|
req.params.id,
|
||||||
req.user.id,
|
req.user.id,
|
||||||
|
|
@ -427,12 +428,20 @@ router.delete('/:id', (req, res) => {
|
||||||
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'));
|
||||||
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
||||||
|
|
||||||
// Reverse any balance delta that was stored when this payment was created
|
// Reverse any balance delta that was stored when this payment was created.
|
||||||
|
// If this payment was the one that charged interest this month, clear
|
||||||
|
// interest_accrued_month so the next payment can re-accrue correctly.
|
||||||
if (payment.balance_delta != null) {
|
if (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, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, payment.bill_id);
|
db.prepare(`
|
||||||
|
UPDATE bills
|
||||||
|
SET current_balance = ?,
|
||||||
|
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -447,12 +456,21 @@ router.post('/:id/restore', (req, res) => {
|
||||||
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
|
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
|
||||||
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
||||||
|
|
||||||
// Re-apply the balance delta (undo the reversal done on delete)
|
// Re-apply the balance delta (undo the reversal done on delete).
|
||||||
|
// If this payment originally charged interest, restore interest_accrued_month
|
||||||
|
// to the month of the payment so future same-month payments skip interest.
|
||||||
if (payment.balance_delta != null) {
|
if (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, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(reapplied, payment.bill_id);
|
const interestMonth = payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : null;
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE bills
|
||||||
|
SET current_balance = ?,
|
||||||
|
interest_accrued_month = CASE WHEN ? IS NOT NULL THEN ? ELSE interest_accrued_month END,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(reapplied, interestMonth, interestMonth, payment.bill_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ async function runSync(db, userId, dataSource, { days } = {}) {
|
||||||
|
|
||||||
for (const rawTx of (rawAccount.transactions || [])) {
|
for (const rawTx of (rawAccount.transactions || [])) {
|
||||||
const txRow = normalizeTransaction(
|
const txRow = normalizeTransaction(
|
||||||
rawTx, localAccount.id, dataSource.id, userId, dataSource.id, rawAccount.id,
|
rawTx, localAccount.id, dataSource.id, userId, rawAccount.id, rawAccount.currency,
|
||||||
);
|
);
|
||||||
const outcome = insertTransactionIfNew(db, txRow);
|
const outcome = insertTransactionIfNew(db, txRow);
|
||||||
if (outcome === 'inserted') transactionsNew += 1;
|
if (outcome === 'inserted') transactionsNew += 1;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { normalizeMerchant } = require('./subscriptionService');
|
const { normalizeMerchant } = require('./subscriptionService');
|
||||||
const { computeBalanceDelta } = require('./billsService');
|
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
||||||
const { getUserSettings } = require('./userSettings');
|
const { getUserSettings } = require('./userSettings');
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
@ -87,12 +87,11 @@ function applyMerchantRules(db, userId) {
|
||||||
const userSettings = (() => { try { return getUserSettings(userId); } catch { return {}; } })();
|
const userSettings = (() => { try { return getUserSettings(userId); } catch { return {}; } })();
|
||||||
const globalGraceDays = parseInt(userSettings.bank_late_attribution_days, 10) || 0;
|
const globalGraceDays = parseInt(userSettings.bank_late_attribution_days, 10) || 0;
|
||||||
|
|
||||||
const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL');
|
const getBill = db.prepare('SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL');
|
||||||
const insertPayment = db.prepare(`
|
const insertPayment = db.prepare(`
|
||||||
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta)
|
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta, interest_delta)
|
||||||
VALUES (?, ?, ?, 'provider_sync', ?, ?)
|
VALUES (?, ?, ?, 'provider_sync', ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
|
|
||||||
const updateTx = db.prepare(`
|
const updateTx = db.prepare(`
|
||||||
UPDATE transactions
|
UPDATE transactions
|
||||||
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
||||||
|
|
@ -121,9 +120,9 @@ function applyMerchantRules(db, userId) {
|
||||||
const bill = getBill.get(rule.bill_id);
|
const bill = getBill.get(rule.bill_id);
|
||||||
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
|
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
|
||||||
|
|
||||||
const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id, balCalc?.balance_delta ?? null);
|
const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null);
|
||||||
if (result.changes > 0) {
|
if (result.changes > 0) {
|
||||||
if (balCalc) updateBalance.run(balCalc.new_balance, rule.bill_id);
|
applyBalanceDelta(db, rule.bill_id, balCalc);
|
||||||
updateTx.run(rule.bill_id, tx.id, userId);
|
updateTx.run(rule.bill_id, tx.id, userId);
|
||||||
matched++;
|
matched++;
|
||||||
matchedBills.set(rule.bill_id, rule.bill_name || `Bill #${rule.bill_id}`);
|
matchedBills.set(rule.bill_id, rule.bill_name || `Bill #${rule.bill_id}`);
|
||||||
|
|
@ -221,12 +220,11 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
|
||||||
if (txRows.length === 0) return { added: 0 };
|
if (txRows.length === 0) return { added: 0 };
|
||||||
|
|
||||||
const billMeta = db.prepare('SELECT name, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL').get(billId);
|
const billMeta = db.prepare('SELECT name, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL').get(billId);
|
||||||
const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL');
|
const getBill = db.prepare('SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL');
|
||||||
const insertPayment = db.prepare(`
|
const insertPayment = db.prepare(`
|
||||||
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta)
|
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta, interest_delta)
|
||||||
VALUES (?, ?, ?, 'provider_sync', ?, ?)
|
VALUES (?, ?, ?, 'provider_sync', ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
|
|
||||||
const updateTx = db.prepare(`
|
const updateTx = db.prepare(`
|
||||||
UPDATE transactions
|
UPDATE transactions
|
||||||
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
||||||
|
|
@ -249,9 +247,9 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
|
||||||
const bill = getBill.get(billId);
|
const bill = getBill.get(billId);
|
||||||
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
|
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
|
||||||
|
|
||||||
const result = insertPayment.run(billId, amount, paidDate, tx.id, balCalc?.balance_delta ?? null);
|
const result = insertPayment.run(billId, amount, paidDate, tx.id, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null);
|
||||||
if (result.changes > 0) {
|
if (result.changes > 0) {
|
||||||
if (balCalc) updateBalance.run(balCalc.new_balance, billId);
|
applyBalanceDelta(db, billId, balCalc);
|
||||||
updateTx.run(billId, tx.id, userId);
|
updateTx.run(billId, tx.id, userId);
|
||||||
added++;
|
added++;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -456,14 +456,15 @@ function validateCycleDayOnly(cycleType, cycleDay) {
|
||||||
return validateCycleDay(cycleType, cycleDay);
|
return validateCycleDay(cycleType, cycleDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Computes how a payment affects a debt bill's current_balance.
|
||||||
* Computes how a payment affects a debt bill's current_balance, accounting for
|
// Interest is applied at most once per calendar month: if bill.interest_accrued_month
|
||||||
* one month of interest accrual.
|
// already equals the current month, no interest is added this call.
|
||||||
*
|
//
|
||||||
* Returns { new_balance, balance_delta } where balance_delta is negative when
|
// Returns null when the bill has no trackable balance.
|
||||||
* the balance was reduced (typical case). Returns null when the bill has no
|
// Otherwise returns:
|
||||||
* trackable balance.
|
// { new_balance, balance_delta, interest_delta, interest_accrued_month }
|
||||||
*/
|
// 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).
|
||||||
function computeBalanceDelta(bill, paymentAmount) {
|
function computeBalanceDelta(bill, paymentAmount) {
|
||||||
const bal = Number(bill.current_balance);
|
const bal = Number(bill.current_balance);
|
||||||
const rate = Number(bill.interest_rate) || 0;
|
const rate = Number(bill.interest_rate) || 0;
|
||||||
|
|
@ -472,12 +473,33 @@ function computeBalanceDelta(bill, paymentAmount) {
|
||||||
if (!Number.isFinite(bal) || bal <= 0) return null;
|
if (!Number.isFinite(bal) || bal <= 0) return null;
|
||||||
if (!Number.isFinite(amt) || amt <= 0) return null;
|
if (!Number.isFinite(amt) || amt <= 0) return null;
|
||||||
|
|
||||||
const monthlyInterest = bal * (rate / 100 / 12);
|
const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM"
|
||||||
const raw = bal + monthlyInterest - amt;
|
const applyInterest = rate > 0 && bill.interest_accrued_month !== currentMonth;
|
||||||
const newBalance = Math.round(Math.max(0, raw) * 100) / 100;
|
const interestDelta = applyInterest ? Math.round(bal * (rate / 100 / 12) * 100) / 100 : 0;
|
||||||
const delta = Math.round((newBalance - bal) * 100) / 100;
|
|
||||||
|
|
||||||
return { new_balance: newBalance, balance_delta: delta };
|
const raw = bal + interestDelta - amt;
|
||||||
|
const newBalance = Math.round(Math.max(0, raw) * 100) / 100;
|
||||||
|
const delta = Math.round((newBalance - bal) * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
new_balance: newBalance,
|
||||||
|
balance_delta: delta,
|
||||||
|
interest_delta: applyInterest ? interestDelta : null,
|
||||||
|
interest_accrued_month: applyInterest ? currentMonth : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates current_balance (and interest_accrued_month when interest was charged)
|
||||||
|
// after a payment. Uses COALESCE so a null interest_accrued_month leaves the column alone.
|
||||||
|
function applyBalanceDelta(db, billId, balCalc) {
|
||||||
|
if (!balCalc) return;
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE bills
|
||||||
|
SET current_balance = ?,
|
||||||
|
interest_accrued_month = COALESCE(?, interest_accrued_month),
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(balCalc.new_balance, balCalc.interest_accrued_month, billId);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
@ -498,4 +520,5 @@ module.exports = {
|
||||||
validateBillData,
|
validateBillData,
|
||||||
validateCycleDayOnly,
|
validateCycleDayOnly,
|
||||||
computeBalanceDelta,
|
computeBalanceDelta,
|
||||||
|
applyBalanceDelta,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,10 @@ function normalizeAccount(rawAccount, dataSourceId, userId) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, connId, accountId) {
|
// accountCurrency: currency string from the parent account (e.g. "USD", "EUR").
|
||||||
|
// accountId: raw SimpleFIN account id — used in the stable dedup key so the key
|
||||||
|
// survives disconnect/reconnect (data_source_id is intentionally omitted).
|
||||||
|
function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, accountId, accountCurrency) {
|
||||||
const amount = Math.round(parseFloat(rawTx.amount) * 100);
|
const amount = Math.round(parseFloat(rawTx.amount) * 100);
|
||||||
const postedDate = rawTx.posted
|
const postedDate = rawTx.posted
|
||||||
? new Date(rawTx.posted * 1000).toISOString().slice(0, 10)
|
? new Date(rawTx.posted * 1000).toISOString().slice(0, 10)
|
||||||
|
|
@ -171,8 +174,8 @@ function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, connI
|
||||||
? new Date(rawTx['transacted_at'] * 1000).toISOString()
|
? new Date(rawTx['transacted_at'] * 1000).toISOString()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Format: simplefin:{data_source_id}:{simplefin_account_id}:{tx.id}
|
// Format: simplefin:{simplefin_account_id}:{tx.id} (no data_source_id — stable across reconnects)
|
||||||
const providerTxId = `simplefin:${connId}:${accountId}:${rawTx.id}`;
|
const providerTxId = `simplefin:${accountId}:${rawTx.id}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
|
@ -183,7 +186,7 @@ function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, connI
|
||||||
posted_date: postedDate,
|
posted_date: postedDate,
|
||||||
transacted_at: transactedAt,
|
transacted_at: transactedAt,
|
||||||
amount: Number.isFinite(amount) ? amount : 0,
|
amount: Number.isFinite(amount) ? amount : 0,
|
||||||
currency: 'USD',
|
currency: accountCurrency ? String(accountCurrency).slice(0, 10) : 'USD',
|
||||||
description: rawTx.description ? String(rawTx.description).slice(0, 500) : null,
|
description: rawTx.description ? String(rawTx.description).slice(0, 500) : null,
|
||||||
payee: rawTx.payee ? String(rawTx.payee).slice(0, 255) : null,
|
payee: rawTx.payee ? String(rawTx.payee).slice(0, 255) : null,
|
||||||
memo: rawTx.memo ? String(rawTx.memo).slice(0, 500) : null,
|
memo: rawTx.memo ? String(rawTx.memo).slice(0, 500) : null,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
const xlsx = require('xlsx');
|
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 } = require('./billsService');
|
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -1439,20 +1439,17 @@ function createPaymentFromImport(db, billId, amount, paidDate, notes, allowOverw
|
||||||
// Read the bill fresh so sequential imports for the same bill chain correctly
|
// Read the bill fresh so sequential imports for the same bill chain correctly
|
||||||
// (each payment reduces current_balance before the next one is computed).
|
// (each payment reduces current_balance before the next one is computed).
|
||||||
const bill = db.prepare(
|
const bill = db.prepare(
|
||||||
'SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL'
|
'SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL'
|
||||||
).get(billId);
|
).get(billId);
|
||||||
|
|
||||||
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
|
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_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, amount, paidDate, null, notes, balCalc?.balance_delta ?? null);
|
`).run(billId, amount, paidDate, null, notes, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null);
|
||||||
|
|
||||||
if (balCalc) {
|
applyBalanceDelta(db, billId, balCalc);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
|
||||||
.run(balCalc.new_balance, billId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { result: 'created', existing_created_at: null };
|
return { result: 'created', existing_created_at: null };
|
||||||
}
|
}
|
||||||
|
|
@ -1650,7 +1647,7 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
||||||
|
|
||||||
} else if (action === 'create_payment') {
|
} else if (action === 'create_payment') {
|
||||||
const billId = decision.bill_id;
|
const billId = decision.bill_id;
|
||||||
const bill = db.prepare('SELECT id, current_balance, interest_rate FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
|
const bill = db.prepare('SELECT id, current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
|
||||||
if (!bill) throw new Error(`Bill id=${billId} not found or inactive`);
|
if (!bill) throw new Error(`Bill id=${billId} not found or inactive`);
|
||||||
|
|
||||||
const payAmount = decision.payment_amount ?? amount;
|
const payAmount = decision.payment_amount ?? amount;
|
||||||
|
|
@ -1686,14 +1683,11 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
||||||
const balCalcCp = computeBalanceDelta(bill, payAmount);
|
const balCalcCp = computeBalanceDelta(bill, payAmount);
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_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);
|
`).run(billId, payAmount, payDate, decision.payment_method ?? null, decision.payment_notes ?? null, balCalcCp?.balance_delta ?? null, balCalcCp?.interest_delta ?? null);
|
||||||
|
|
||||||
if (balCalcCp) {
|
applyBalanceDelta(db, billId, balCalcCp);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
|
||||||
.run(balCalcCp.new_balance, billId);
|
|
||||||
}
|
|
||||||
|
|
||||||
summary.created++;
|
summary.created++;
|
||||||
summary.details.push({ row_id, action, result: 'created', bill_id: billId, paid_date: payDate, amount: payAmount });
|
summary.details.push({ row_id, action, result: 'created', bill_id: billId, paid_date: payDate, amount: payAmount });
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { buildTrackerRow, getCycleRange, resolveDueDate, roundMoney } = require('./statusService');
|
const { buildTrackerRow, getCycleRange, resolveDueDate, roundMoney } = require('./statusService');
|
||||||
const { getUserSettings } = require('./userSettings');
|
const { getUserSettings } = require('./userSettings');
|
||||||
const { computeBalanceDelta } = require('./billsService');
|
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
||||||
const { computeAmountSuggestion } = require('./amountSuggestionService');
|
const { computeAmountSuggestion } = require('./amountSuggestionService');
|
||||||
|
|
||||||
const DEFAULT_PENDING_DAYS = 3;
|
const DEFAULT_PENDING_DAYS = 3;
|
||||||
|
|
@ -216,8 +216,8 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
|
||||||
|
|
||||||
const balCalc = computeBalanceDelta(bill, suggestedAmount);
|
const balCalc = computeBalanceDelta(bill, suggestedAmount);
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source)
|
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
bill.id,
|
bill.id,
|
||||||
suggestedAmount,
|
suggestedAmount,
|
||||||
|
|
@ -225,12 +225,12 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
|
||||||
'autopay',
|
'autopay',
|
||||||
'Auto-marked paid on due date',
|
'Auto-marked paid on due date',
|
||||||
balCalc?.balance_delta ?? null,
|
balCalc?.balance_delta ?? null,
|
||||||
|
balCalc?.interest_delta ?? null,
|
||||||
'manual',
|
'manual',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (balCalc) {
|
if (balCalc) {
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at=datetime('now') WHERE id=?")
|
applyBalanceDelta(db, bill.id, balCalc);
|
||||||
.run(balCalc.new_balance, bill.id);
|
|
||||||
bill.current_balance = balCalc.new_balance;
|
bill.current_balance = balCalc.new_balance;
|
||||||
}
|
}
|
||||||
payments.push(db.prepare(`
|
payments.push(db.prepare(`
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { computeBalanceDelta } = require('./billsService');
|
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
||||||
const {
|
const {
|
||||||
decorateTransaction,
|
decorateTransaction,
|
||||||
getTransactionForUser,
|
getTransactionForUser,
|
||||||
|
|
@ -105,18 +105,22 @@ function restorePaymentBalance(db, payment) {
|
||||||
if (bill?.current_balance == null) return;
|
if (bill?.current_balance == null) return;
|
||||||
|
|
||||||
const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100);
|
const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
// Clear interest_accrued_month when reversing a payment that charged interest,
|
||||||
.run(restored, bill.id);
|
// so the re-applied payment can accrue interest fresh.
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE bills
|
||||||
|
SET current_balance = ?,
|
||||||
|
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(restored, payment.interest_delta != null ? 1 : 0, bill.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPaymentBalance(db, bill, amount) {
|
function applyPaymentBalance(db, bill, amount) {
|
||||||
const freshBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(bill.id) || bill;
|
const freshBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(bill.id) || bill;
|
||||||
const balCalc = computeBalanceDelta(freshBill, amount);
|
const balCalc = computeBalanceDelta(freshBill, amount);
|
||||||
if (balCalc) {
|
applyBalanceDelta(db, bill.id, balCalc);
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
return { balance_delta: balCalc?.balance_delta ?? null, interest_delta: balCalc?.interest_delta ?? null };
|
||||||
.run(balCalc.new_balance, bill.id);
|
|
||||||
}
|
|
||||||
return balCalc?.balance_delta ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMatchPaymentNotes(transaction, bill) {
|
function buildMatchPaymentNotes(transaction, bill) {
|
||||||
|
|
@ -141,7 +145,7 @@ function createOrUpdateMatchPayment(db, userId, transaction, bill) {
|
||||||
|
|
||||||
if (existingPayment) {
|
if (existingPayment) {
|
||||||
restorePaymentBalance(db, existingPayment);
|
restorePaymentBalance(db, existingPayment);
|
||||||
const balanceDelta = applyPaymentBalance(db, bill, amount);
|
const { balance_delta, interest_delta } = applyPaymentBalance(db, bill, amount);
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE payments
|
UPDATE payments
|
||||||
SET bill_id = ?,
|
SET bill_id = ?,
|
||||||
|
|
@ -150,6 +154,7 @@ function createOrUpdateMatchPayment(db, userId, transaction, bill) {
|
||||||
method = ?,
|
method = ?,
|
||||||
notes = ?,
|
notes = ?,
|
||||||
balance_delta = ?,
|
balance_delta = ?,
|
||||||
|
interest_delta = ?,
|
||||||
payment_source = ?,
|
payment_source = ?,
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|
@ -159,25 +164,27 @@ function createOrUpdateMatchPayment(db, userId, transaction, bill) {
|
||||||
paidDate,
|
paidDate,
|
||||||
MATCH_PAYMENT_METHOD,
|
MATCH_PAYMENT_METHOD,
|
||||||
notes,
|
notes,
|
||||||
balanceDelta,
|
balance_delta,
|
||||||
|
interest_delta,
|
||||||
MATCH_PAYMENT_SOURCE,
|
MATCH_PAYMENT_SOURCE,
|
||||||
existingPayment.id,
|
existingPayment.id,
|
||||||
);
|
);
|
||||||
return existingPayment.id;
|
return existingPayment.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const balanceDelta = applyPaymentBalance(db, bill, amount);
|
const { balance_delta, interest_delta } = applyPaymentBalance(db, bill, amount);
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO payments
|
INSERT INTO payments
|
||||||
(bill_id, amount, paid_date, method, notes, balance_delta, payment_source, transaction_id)
|
(bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source, transaction_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
bill.id,
|
bill.id,
|
||||||
amount,
|
amount,
|
||||||
paidDate,
|
paidDate,
|
||||||
MATCH_PAYMENT_METHOD,
|
MATCH_PAYMENT_METHOD,
|
||||||
notes,
|
notes,
|
||||||
balanceDelta,
|
balance_delta,
|
||||||
|
interest_delta,
|
||||||
MATCH_PAYMENT_SOURCE,
|
MATCH_PAYMENT_SOURCE,
|
||||||
transaction.id,
|
transaction.id,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue