348 lines
11 KiB
JavaScript
348 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
const { getDb } = require('../db/database');
|
|
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
|
const {
|
|
applyBankPaymentAsSourceOfTruth,
|
|
reactivatePaymentsOverriddenBy,
|
|
} = require('./paymentAccountingService');
|
|
const {
|
|
decorateTransaction,
|
|
getTransactionForUser,
|
|
} = require('./transactionService');
|
|
const { serializePayment } = require('./paymentValidation');
|
|
|
|
const MATCH_PAYMENT_SOURCE = 'transaction_match';
|
|
const MATCH_PAYMENT_METHOD = 'transaction_match';
|
|
|
|
function matchError(status, message, code, field = null) {
|
|
const err = new Error(message);
|
|
err.status = status;
|
|
err.code = code;
|
|
err.field = field;
|
|
return err;
|
|
}
|
|
|
|
function normalizeId(value, field) {
|
|
const id = typeof value === 'number' ? value : Number(value);
|
|
if (!Number.isSafeInteger(id) || id <= 0) {
|
|
throw matchError(400, `${field} must be a positive integer`, 'VALIDATION_ERROR', field);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
function getOwnedTransaction(db, userId, transactionId) {
|
|
const id = normalizeId(transactionId, 'transaction_id');
|
|
const transaction = getTransactionForUser(db, userId, id);
|
|
if (!transaction) {
|
|
throw matchError(404, 'Transaction not found', 'NOT_FOUND', 'transaction_id');
|
|
}
|
|
return transaction;
|
|
}
|
|
|
|
function getOwnedBill(db, userId, billId) {
|
|
const id = normalizeId(billId, 'bill_id');
|
|
const bill = db.prepare(`
|
|
SELECT *
|
|
FROM bills
|
|
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
|
|
`).get(id, userId);
|
|
if (!bill) {
|
|
throw matchError(404, 'Bill not found', 'NOT_FOUND', 'bill_id');
|
|
}
|
|
return bill;
|
|
}
|
|
|
|
function paymentDateForTransaction(transaction) {
|
|
const date = transaction.posted_date || String(transaction.transacted_at || '').slice(0, 10);
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
throw matchError(
|
|
400,
|
|
'Transaction must have a posted date before it can be matched to a bill',
|
|
'VALIDATION_ERROR',
|
|
'posted_date',
|
|
);
|
|
}
|
|
return date;
|
|
}
|
|
|
|
function paymentAmountForTransaction(transaction) {
|
|
const cents = Number(transaction.amount);
|
|
if (!Number.isSafeInteger(cents) || cents === 0) {
|
|
throw matchError(
|
|
400,
|
|
'Transaction amount must be a non-zero integer number of cents',
|
|
'VALIDATION_ERROR',
|
|
'amount',
|
|
);
|
|
}
|
|
return Math.round(Math.abs(cents)); // tx.amount and payments.amount are both cents
|
|
}
|
|
|
|
function getActivePaymentForTransaction(db, userId, transactionId) {
|
|
return db.prepare(`
|
|
SELECT p.*
|
|
FROM payments p
|
|
JOIN bills b ON b.id = p.bill_id
|
|
WHERE p.transaction_id = ?
|
|
AND p.deleted_at IS NULL
|
|
AND b.user_id = ?
|
|
AND b.deleted_at IS NULL
|
|
ORDER BY p.id ASC
|
|
LIMIT 1
|
|
`).get(transactionId, userId);
|
|
}
|
|
|
|
function getPaymentForResponse(db, userId, paymentId) {
|
|
if (!paymentId) return null;
|
|
return db.prepare(`
|
|
SELECT p.*
|
|
FROM payments p
|
|
JOIN bills b ON b.id = p.bill_id
|
|
WHERE p.id = ?
|
|
AND b.user_id = ?
|
|
`).get(paymentId, userId) || null;
|
|
}
|
|
|
|
function restorePaymentBalance(db, payment) {
|
|
if (!payment || payment.balance_delta == null) return;
|
|
const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id);
|
|
if (bill?.current_balance == null) return;
|
|
|
|
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,
|
|
// 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) {
|
|
const freshBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(bill.id) || bill;
|
|
const balCalc = computeBalanceDelta(freshBill, amount);
|
|
applyBalanceDelta(db, bill.id, balCalc);
|
|
return { balance_delta: balCalc?.balance_delta ?? null, interest_delta: balCalc?.interest_delta ?? null };
|
|
}
|
|
|
|
function updatePaymentBalanceDeltas(db, paymentId, deltas) {
|
|
db.prepare(`
|
|
UPDATE payments
|
|
SET balance_delta = ?,
|
|
interest_delta = ?,
|
|
updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`).run(deltas.balance_delta, deltas.interest_delta, paymentId);
|
|
}
|
|
|
|
function buildMatchPaymentNotes(transaction, bill) {
|
|
const label = transaction.payee || transaction.description || `transaction ${transaction.id}`;
|
|
return `Matched transaction to ${bill.name}: ${label}`.slice(0, 500);
|
|
}
|
|
|
|
function createOrUpdateMatchPayment(db, userId, transaction, bill) {
|
|
const amount = paymentAmountForTransaction(transaction);
|
|
const paidDate = paymentDateForTransaction(transaction);
|
|
const notes = buildMatchPaymentNotes(transaction, bill);
|
|
const existingPayment = getActivePaymentForTransaction(db, userId, transaction.id);
|
|
|
|
if (existingPayment && existingPayment.payment_source !== MATCH_PAYMENT_SOURCE) {
|
|
throw matchError(
|
|
409,
|
|
'Transaction is already linked to a non-matching payment. Unlink that payment before matching this transaction.',
|
|
'TRANSACTION_PAYMENT_ALREADY_LINKED',
|
|
'transaction_id',
|
|
);
|
|
}
|
|
|
|
if (existingPayment) {
|
|
restorePaymentBalance(db, existingPayment);
|
|
db.prepare(`
|
|
UPDATE payments
|
|
SET bill_id = ?,
|
|
amount = ?,
|
|
paid_date = ?,
|
|
method = ?,
|
|
notes = ?,
|
|
balance_delta = NULL,
|
|
interest_delta = NULL,
|
|
payment_source = ?,
|
|
updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`).run(
|
|
bill.id,
|
|
amount,
|
|
paidDate,
|
|
MATCH_PAYMENT_METHOD,
|
|
notes,
|
|
MATCH_PAYMENT_SOURCE,
|
|
existingPayment.id,
|
|
);
|
|
const updatedPayment = db.prepare('SELECT * FROM payments WHERE id = ?').get(existingPayment.id);
|
|
const deltas = applyBankPaymentAsSourceOfTruth(db, bill, updatedPayment);
|
|
updatePaymentBalanceDeltas(db, existingPayment.id, deltas);
|
|
return existingPayment.id;
|
|
}
|
|
|
|
const result = db.prepare(`
|
|
INSERT INTO payments
|
|
(bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source, transaction_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
bill.id,
|
|
amount,
|
|
paidDate,
|
|
MATCH_PAYMENT_METHOD,
|
|
notes,
|
|
null,
|
|
null,
|
|
MATCH_PAYMENT_SOURCE,
|
|
transaction.id,
|
|
);
|
|
const insertedPayment = db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid);
|
|
const deltas = applyBankPaymentAsSourceOfTruth(db, bill, insertedPayment);
|
|
updatePaymentBalanceDeltas(db, result.lastInsertRowid, deltas);
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
function unlinkPaymentForTransaction(db, userId, transactionId) {
|
|
const existingPayment = getActivePaymentForTransaction(db, userId, transactionId);
|
|
if (!existingPayment) return null;
|
|
|
|
if (existingPayment.payment_source === MATCH_PAYMENT_SOURCE) {
|
|
restorePaymentBalance(db, existingPayment);
|
|
reactivatePaymentsOverriddenBy(db, existingPayment.id);
|
|
db.prepare(`
|
|
UPDATE payments
|
|
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`).run(existingPayment.id);
|
|
return { ...serializePayment(existingPayment), deleted: true };
|
|
}
|
|
|
|
db.prepare(`
|
|
UPDATE payments
|
|
SET transaction_id = NULL, updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`).run(existingPayment.id);
|
|
return { ...serializePayment(existingPayment), unlinked: true };
|
|
}
|
|
|
|
function responseForTransaction(db, userId, transactionId, paymentId = null, extra = {}) {
|
|
return {
|
|
success: true,
|
|
transaction: decorateTransaction(getTransactionForUser(db, userId, transactionId)),
|
|
payment: serializePayment(getPaymentForResponse(db, userId, paymentId)),
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
// opts.learnMerchant — when true (explicit user confirmation), remember a
|
|
// merchant→bill rule so future synced transactions from the same merchant
|
|
// auto-match. Left false for background auto-matching to avoid compounding
|
|
// a wrong auto-match into a permanent rule.
|
|
function matchTransactionToBill(userId, transactionId, billId, opts = {}) {
|
|
const db = getDb();
|
|
const tx = db.transaction(() => {
|
|
const transaction = getOwnedTransaction(db, userId, transactionId);
|
|
if (transaction.ignored || transaction.match_status === 'ignored') {
|
|
throw matchError(400, 'Ignored transactions must be unignored before matching', 'TRANSACTION_IGNORED', 'transaction_id');
|
|
}
|
|
|
|
const bill = getOwnedBill(db, userId, billId);
|
|
const paymentId = createOrUpdateMatchPayment(db, userId, transaction, bill);
|
|
|
|
db.prepare(`
|
|
UPDATE transactions
|
|
SET matched_bill_id = ?,
|
|
match_status = 'matched',
|
|
ignored = 0,
|
|
updated_at = datetime('now')
|
|
WHERE id = ? AND user_id = ?
|
|
`).run(bill.id, transaction.id, userId);
|
|
|
|
if (opts.learnMerchant) {
|
|
const { learnMerchantRuleFromMatch } = require('./billMerchantRuleService');
|
|
learnMerchantRuleFromMatch(db, userId, bill.id, transaction);
|
|
}
|
|
|
|
return responseForTransaction(db, userId, transaction.id, paymentId);
|
|
});
|
|
|
|
return tx();
|
|
}
|
|
|
|
function unmatchTransaction(userId, transactionId) {
|
|
const db = getDb();
|
|
const tx = db.transaction(() => {
|
|
const transaction = getOwnedTransaction(db, userId, transactionId);
|
|
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
|
|
|
|
db.prepare(`
|
|
UPDATE transactions
|
|
SET matched_bill_id = NULL,
|
|
match_status = 'unmatched',
|
|
ignored = 0,
|
|
updated_at = datetime('now')
|
|
WHERE id = ? AND user_id = ?
|
|
`).run(transaction.id, userId);
|
|
|
|
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
|
|
});
|
|
|
|
return tx();
|
|
}
|
|
|
|
function ignoreTransaction(userId, transactionId) {
|
|
const db = getDb();
|
|
const tx = db.transaction(() => {
|
|
const transaction = getOwnedTransaction(db, userId, transactionId);
|
|
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
|
|
|
|
db.prepare(`
|
|
UPDATE transactions
|
|
SET ignored = 1,
|
|
match_status = 'ignored',
|
|
matched_bill_id = NULL,
|
|
updated_at = datetime('now')
|
|
WHERE id = ? AND user_id = ?
|
|
`).run(transaction.id, userId);
|
|
|
|
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
|
|
});
|
|
|
|
return tx();
|
|
}
|
|
|
|
function unignoreTransaction(userId, transactionId) {
|
|
const db = getDb();
|
|
const tx = db.transaction(() => {
|
|
const transaction = getOwnedTransaction(db, userId, transactionId);
|
|
|
|
db.prepare(`
|
|
UPDATE transactions
|
|
SET ignored = 0,
|
|
match_status = 'unmatched',
|
|
matched_bill_id = NULL,
|
|
updated_at = datetime('now')
|
|
WHERE id = ? AND user_id = ?
|
|
`).run(transaction.id, userId);
|
|
|
|
return responseForTransaction(db, userId, transaction.id);
|
|
});
|
|
|
|
return tx();
|
|
}
|
|
|
|
module.exports = {
|
|
MATCH_PAYMENT_METHOD,
|
|
MATCH_PAYMENT_SOURCE,
|
|
ignoreTransaction,
|
|
matchTransactionToBill,
|
|
unignoreTransaction,
|
|
unmatchTransaction,
|
|
};
|