2026-07-06 11:26:50 -05:00
|
|
|
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
|
|
|
|
|
import type { Req, Res, Next } from '../types/http';
|
2026-05-03 19:51:57 -05:00
|
|
|
const express = require('express');
|
2026-07-10 16:53:20 -05:00
|
|
|
const {
|
|
|
|
|
ApiError,
|
|
|
|
|
ValidationError,
|
|
|
|
|
NotFoundError,
|
|
|
|
|
ConflictError,
|
|
|
|
|
} = require('../utils/apiError.cts');
|
2026-07-06 14:23:53 -05:00
|
|
|
const router = require('express').Router();
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
|
|
|
const { getDb } = require('../db/database.cts');
|
2026-07-06 11:12:06 -05:00
|
|
|
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.cts');
|
2026-07-05 13:51:37 -05:00
|
|
|
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');
|
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
|
|
|
const { getCycleRange, resolveDueDate } = require('../services/statusService.cts');
|
2026-07-06 10:47:16 -05:00
|
|
|
const { markUnmatched } = require('../services/transactionMatchState.cts');
|
2026-06-07 01:05:48 -05:00
|
|
|
const {
|
|
|
|
|
markProvisionalManualPaymentsOverridden,
|
|
|
|
|
reactivatePaymentsOverriddenBy,
|
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
|
|
|
} = require('../services/paymentAccountingService.cts');
|
2026-07-05 13:48:44 -05:00
|
|
|
const { todayLocal } = require('../utils/dates.mts');
|
2026-07-05 13:44:04 -05:00
|
|
|
const { fromCents } = require('../utils/money.mts');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-06-03 22:28:46 -05:00
|
|
|
// 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
|
|
|
|
|
// values are). Interpolating a hardcoded constant like this is safe by
|
|
|
|
|
// construction; do NOT replace this pattern with dynamic/user-controlled input.
|
|
|
|
|
const SQL_NOT_DELETED = 'deleted_at IS NULL';
|
2026-05-16 21:36:04 -05:00
|
|
|
const TRANSACTION_MATCH_SOURCE = 'transaction_match';
|
|
|
|
|
|
|
|
|
|
function isTransactionLinkedPayment(payment) {
|
|
|
|
|
return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 16:53:20 -05:00
|
|
|
function rejectTransactionLinkedPayment() {
|
|
|
|
|
throw ConflictError(
|
|
|
|
|
'Transaction-linked payments must be changed through transaction match controls',
|
|
|
|
|
'transaction_id',
|
|
|
|
|
'TRANSACTION_PAYMENT_LOCKED',
|
|
|
|
|
);
|
2026-05-16 21:36:04 -05:00
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
function parseYearMonth(body) {
|
|
|
|
|
const year = parseInt(body.year, 10);
|
|
|
|
|
const month = parseInt(body.month, 10);
|
|
|
|
|
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
|
2026-05-16 15:38:28 -05:00
|
|
|
}
|
|
|
|
|
if (!Number.isInteger(month) || month < 1 || month > 12) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('month must be an integer between 1 and 12', 'month');
|
2026-05-16 15:38:28 -05:00
|
|
|
}
|
|
|
|
|
return { year, month };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getAutopaySuggestionContext(db, userId, billId, year, month) {
|
2026-07-06 14:23:53 -05:00
|
|
|
const bill = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-05-16 15:38:28 -05:00
|
|
|
SELECT *
|
|
|
|
|
FROM bills
|
|
|
|
|
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.get(billId, userId);
|
2026-07-10 16:53:20 -05:00
|
|
|
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
2026-05-16 15:38:28 -05:00
|
|
|
if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('Bill is not eligible for autopay suggestions', 'bill_id');
|
2026-05-16 15:38:28 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const state = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-05-16 15:38:28 -05:00
|
|
|
SELECT actual_amount, is_skipped
|
|
|
|
|
FROM monthly_bill_state
|
|
|
|
|
WHERE bill_id = ? AND year = ? AND month = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.get(bill.id, year, month);
|
2026-05-16 15:38:28 -05:00
|
|
|
if (state?.is_skipped) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('Skipped bills cannot be suggested for payment', 'bill_id');
|
2026-05-16 15:38:28 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dueDate = resolveDueDate(bill, year, month);
|
2026-05-16 20:26:09 -05:00
|
|
|
if (!dueDate) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('Bill does not occur in the selected month', 'month');
|
2026-05-16 20:26:09 -05:00
|
|
|
}
|
2026-06-11 20:12:31 -05:00
|
|
|
const amount = fromCents(state?.actual_amount ?? bill.expected_amount);
|
2026-05-16 15:38:28 -05:00
|
|
|
return { bill, dueDate, amount };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// GET /api/payments?bill_id=&year=&month=
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/', (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
const db = getDb();
|
|
|
|
|
const { bill_id, year, month } = req.query;
|
|
|
|
|
|
|
|
|
|
// Validate year/month when provided
|
|
|
|
|
if ((year || month) && !(year && month)) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('Both year and month are required when filtering by date', 'year');
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let y, m;
|
|
|
|
|
if (year && month) {
|
|
|
|
|
y = parseInt(year, 10);
|
|
|
|
|
m = parseInt(month, 10);
|
|
|
|
|
if (!Number.isInteger(y) || y < 2000 || y > 2100) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
if (!Number.isInteger(m) || m < 1 || m > 12) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('month must be an integer between 1 and 12', 'month');
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
let query = `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`;
|
2026-05-03 19:51:57 -05:00
|
|
|
const params = [req.user.id];
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
if (bill_id) {
|
|
|
|
|
query += ' AND p.bill_id = ?';
|
|
|
|
|
params.push(parseInt(bill_id, 10));
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
if (y && m) {
|
|
|
|
|
const yStr = String(y);
|
|
|
|
|
const mStr = String(m).padStart(2, '0');
|
|
|
|
|
const daysInMonth = new Date(y, m, 0).getDate();
|
|
|
|
|
const endDay = String(daysInMonth).padStart(2, '0');
|
|
|
|
|
query += ' AND p.paid_date BETWEEN ? AND ?';
|
|
|
|
|
params.push(`${yStr}-${mStr}-01`, `${yStr}-${mStr}-${endDay}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
query += ' ORDER BY p.paid_date DESC';
|
2026-07-06 14:23:53 -05:00
|
|
|
res.json(
|
|
|
|
|
db
|
|
|
|
|
.prepare(query)
|
|
|
|
|
.all(...params)
|
|
|
|
|
.map(serializePayment),
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-06 17:34:09 -05:00
|
|
|
// GET /api/payments/recent-auto — provider_sync payments with a linked tx, last 7 days
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/recent-auto', (req: Req, res: Res) => {
|
2026-06-06 17:34:09 -05:00
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
const rows = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-06-06 17:34:09 -05:00
|
|
|
SELECT p.id, p.bill_id, p.amount, p.paid_date, p.payment_source,
|
|
|
|
|
p.transaction_id, p.balance_delta, p.interest_delta, p.created_at,
|
|
|
|
|
b.name AS bill_name,
|
|
|
|
|
t.payee, t.description, t.amount AS tx_cents, t.posted_date
|
|
|
|
|
FROM payments p
|
|
|
|
|
JOIN bills b ON b.id = p.bill_id
|
|
|
|
|
LEFT JOIN transactions t ON t.id = p.transaction_id
|
|
|
|
|
WHERE b.user_id = ?
|
|
|
|
|
AND p.payment_source = 'provider_sync'
|
|
|
|
|
AND p.transaction_id IS NOT NULL
|
|
|
|
|
AND p.deleted_at IS NULL
|
|
|
|
|
AND b.deleted_at IS NULL
|
|
|
|
|
AND p.created_at >= datetime('now', '-7 days')
|
|
|
|
|
ORDER BY p.created_at DESC
|
|
|
|
|
LIMIT 50
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.all(req.user.id);
|
2026-06-11 20:12:31 -05:00
|
|
|
res.json(rows.map(serializePayment));
|
2026-06-06 17:34:09 -05:00
|
|
|
});
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// GET /api/payments/:id
|
2026-07-06 11:26:50 -05:00
|
|
|
router.get('/:id', (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
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);
|
2026-07-10 16:53:20 -05:00
|
|
|
if (!payment) throw NotFoundError('Payment not found', 'id');
|
2026-06-11 20:12:31 -05:00
|
|
|
res.json(serializePayment(payment));
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-06 17:34:09 -05:00
|
|
|
// POST /api/payments/:id/undo-auto — reverse a provider_sync auto-match
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/:id/undo-auto', (req: Req, res: Res) => {
|
2026-06-06 17:34:09 -05:00
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
const payment = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-06-06 17:34:09 -05:00
|
|
|
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
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.get(req.params.id, req.user.id);
|
2026-06-06 17:34:09 -05:00
|
|
|
|
2026-07-10 16:53:20 -05:00
|
|
|
if (!payment) throw NotFoundError('Payment not found', 'id');
|
2026-06-06 17:34:09 -05:00
|
|
|
if (payment.payment_source !== 'provider_sync') {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ConflictError(
|
|
|
|
|
'Only provider_sync payments can be undone here',
|
|
|
|
|
undefined,
|
|
|
|
|
'NOT_AUTO_MATCH',
|
|
|
|
|
);
|
2026-06-06 17:34:09 -05:00
|
|
|
}
|
|
|
|
|
if (!payment.transaction_id) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ConflictError('Payment has no linked transaction', undefined, 'NO_TRANSACTION');
|
2026-06-06 17:34:09 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-10 16:53:20 -05:00
|
|
|
db.transaction(() => {
|
|
|
|
|
// Restore balance (same logic as DELETE /:id)
|
|
|
|
|
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
|
|
|
|
const bill = db
|
|
|
|
|
.prepare('SELECT current_balance FROM bills WHERE id = ?')
|
|
|
|
|
.get(payment.bill_id);
|
|
|
|
|
if (bill?.current_balance != null) {
|
|
|
|
|
const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta));
|
|
|
|
|
db.prepare(
|
|
|
|
|
`
|
2026-06-06 17:34:09 -05:00
|
|
|
UPDATE bills
|
|
|
|
|
SET current_balance = ?,
|
|
|
|
|
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
|
|
|
|
|
updated_at = datetime('now')
|
|
|
|
|
WHERE id = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
2026-07-10 16:53:20 -05:00
|
|
|
).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
|
2026-06-06 17:34:09 -05:00
|
|
|
}
|
2026-07-10 16:53:20 -05:00
|
|
|
}
|
|
|
|
|
reactivatePaymentsOverriddenBy(db, payment.id);
|
|
|
|
|
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
|
|
|
|
|
markUnmatched(db, req.user.id, payment.transaction_id);
|
|
|
|
|
})();
|
|
|
|
|
res.json({ success: true });
|
2026-06-06 17:34:09 -05:00
|
|
|
});
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// POST /api/payments — create single payment
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/', (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
const {
|
|
|
|
|
bill_id,
|
|
|
|
|
amount,
|
|
|
|
|
paid_date,
|
|
|
|
|
method,
|
|
|
|
|
notes,
|
|
|
|
|
payment_source,
|
|
|
|
|
autopay_failure,
|
|
|
|
|
allow_duplicate,
|
|
|
|
|
} = req.body;
|
|
|
|
|
|
|
|
|
|
const validation = validatePaymentInput({
|
|
|
|
|
bill_id,
|
|
|
|
|
amount,
|
|
|
|
|
paid_date,
|
|
|
|
|
payment_source: payment_source ?? 'manual',
|
|
|
|
|
});
|
2026-07-10 16:53:20 -05:00
|
|
|
if (validation.error) throw ValidationError(validation.error, validation.field);
|
2026-05-15 22:45:38 -05:00
|
|
|
const payment = validation.normalized;
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const bill = db
|
|
|
|
|
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
|
|
|
|
.get(payment.bill_id, req.user.id);
|
2026-07-10 16:53:20 -05:00
|
|
|
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-05 11:51:06 -05:00
|
|
|
// The deliberate manual add must not *silently* drop a same-key payment — a user
|
|
|
|
|
// may legitimately record two identical payments on the same day, and dropping
|
|
|
|
|
// one is itself a money-integrity bug. Surface a 409 the client turns into an
|
|
|
|
|
// "add anyway?" confirm; a resend with allow_duplicate bypasses this. (The
|
|
|
|
|
// automated one-click paths — /quick, /bulk, autopay-confirm — dedupe silently
|
|
|
|
|
// instead, because a repeat there is a retry, not a second intent.)
|
|
|
|
|
if (!allow_duplicate) {
|
2026-07-06 14:23:53 -05:00
|
|
|
const existingDuplicate = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`SELECT p.* FROM payments p
|
2026-07-05 11:51:06 -05:00
|
|
|
WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND p.${SQL_NOT_DELETED}
|
2026-07-06 14:23:53 -05:00
|
|
|
ORDER BY p.id DESC LIMIT 1`,
|
|
|
|
|
)
|
|
|
|
|
.get(bill.id, payment.paid_date, payment.amount);
|
2026-07-05 11:51:06 -05:00
|
|
|
if (existingDuplicate) {
|
2026-07-10 17:53:24 -05:00
|
|
|
// Deliberate hand-rolled body (documented exception): carries the
|
|
|
|
|
// `existing` payment the client's "add anyway?" flow renders —
|
|
|
|
|
// formatError has no extras channel. Shape matches the standard envelope.
|
2026-07-05 11:51:06 -05:00
|
|
|
return res.status(409).json({
|
2026-07-10 17:53:24 -05:00
|
|
|
error: 'DUPLICATE_SUSPECTED',
|
|
|
|
|
message: 'A matching payment already exists for this bill, date, and amount',
|
|
|
|
|
code: 'DUPLICATE_SUSPECTED',
|
|
|
|
|
field: 'amount',
|
2026-07-05 11:51:06 -05:00
|
|
|
existing: serializePayment(existingDuplicate),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
const balCalc = computeBalanceDelta(bill, payment.amount);
|
2026-06-07 14:49:39 -05:00
|
|
|
const failureFlag = autopay_failure ? 1 : 0;
|
2026-05-16 15:38:28 -05:00
|
|
|
|
2026-07-05 11:44:45 -05:00
|
|
|
// Atomic: the INSERT and the balance update land together or neither, so a
|
|
|
|
|
// mid-way failure never leaves a payment without its balance adjustment.
|
|
|
|
|
const insertManualPayment = db.transaction(() => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const r = db
|
|
|
|
|
.prepare(
|
|
|
|
|
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source, autopay_failure) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
|
|
|
)
|
|
|
|
|
.run(
|
|
|
|
|
payment.bill_id,
|
|
|
|
|
payment.amount,
|
|
|
|
|
payment.paid_date,
|
|
|
|
|
method || null,
|
|
|
|
|
notes || null,
|
|
|
|
|
balCalc?.balance_delta ?? null,
|
|
|
|
|
balCalc?.interest_delta ?? null,
|
|
|
|
|
payment.payment_source,
|
|
|
|
|
failureFlag,
|
|
|
|
|
);
|
2026-07-05 11:44:45 -05:00
|
|
|
applyBalanceDelta(db, bill.id, balCalc);
|
|
|
|
|
return r.lastInsertRowid;
|
|
|
|
|
});
|
|
|
|
|
const paymentId = insertManualPayment();
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
res
|
|
|
|
|
.status(201)
|
|
|
|
|
.json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)));
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/payments/quick — pay a bill (expected amount, today)
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/quick', (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
const db = getDb();
|
2026-05-16 20:26:09 -05:00
|
|
|
const { bill_id, amount, paid_date, method, notes, payment_source } = req.body;
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const billValidation = validatePaymentInput(
|
|
|
|
|
{ bill_id },
|
|
|
|
|
{ requireAmount: false, requirePaidDate: false },
|
|
|
|
|
);
|
2026-07-10 16:53:20 -05:00
|
|
|
if (billValidation.error) throw ValidationError(billValidation.error, billValidation.field);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const bill = db
|
|
|
|
|
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
|
|
|
|
.get(billValidation.normalized.bill_id, req.user.id);
|
2026-07-10 16:53:20 -05:00
|
|
|
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-15 22:45:38 -05:00
|
|
|
const paymentValidation = validatePaymentInput(
|
|
|
|
|
{
|
2026-06-11 20:12:31 -05:00
|
|
|
amount: amount != null ? amount : fromCents(bill.expected_amount),
|
2026-06-10 19:42:51 -05:00
|
|
|
paid_date: paid_date || todayLocal(),
|
2026-05-16 20:26:09 -05:00
|
|
|
payment_source: payment_source ?? 'manual',
|
2026-05-15 22:45:38 -05:00
|
|
|
},
|
|
|
|
|
{ requireBillId: false },
|
|
|
|
|
);
|
2026-07-10 16:53:20 -05:00
|
|
|
if (paymentValidation.error)
|
|
|
|
|
throw ValidationError(paymentValidation.error, paymentValidation.field);
|
2026-05-15 22:45:38 -05:00
|
|
|
const payAmount = paymentValidation.normalized.amount;
|
2026-07-06 14:23:53 -05:00
|
|
|
const payDate = paymentValidation.normalized.paid_date;
|
2026-05-16 20:26:09 -05:00
|
|
|
const paySource = paymentValidation.normalized.payment_source;
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-03 18:05:19 -05:00
|
|
|
// Guard against a double-clicked / retried "pay" (or two tabs) creating a
|
|
|
|
|
// duplicate payment. Mirrors the bulk route's composite-key check
|
|
|
|
|
// (bill_id + paid_date + amount). If one already exists, return it idempotently.
|
2026-07-06 14:23:53 -05:00
|
|
|
const existingDuplicate = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`SELECT p.* FROM payments p
|
2026-07-03 18:05:19 -05:00
|
|
|
WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND p.${SQL_NOT_DELETED}
|
2026-07-06 14:23:53 -05:00
|
|
|
ORDER BY p.id DESC LIMIT 1`,
|
|
|
|
|
)
|
|
|
|
|
.get(bill.id, payDate, payAmount);
|
2026-07-03 18:05:19 -05:00
|
|
|
if (existingDuplicate) {
|
|
|
|
|
return res.status(200).json(serializePayment(existingDuplicate));
|
|
|
|
|
}
|
2026-05-14 02:11:54 -05:00
|
|
|
|
2026-07-03 18:05:19 -05:00
|
|
|
const balCalc = computeBalanceDelta(bill, payAmount);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-03 18:05:19 -05:00
|
|
|
// Atomic: the INSERT and the balance update must both land or neither, so a
|
|
|
|
|
// mid-way failure never leaves a payment without its balance adjustment.
|
|
|
|
|
const insertQuickPayment = db.transaction(() => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const r = db
|
|
|
|
|
.prepare(
|
|
|
|
|
'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,
|
|
|
|
|
balCalc?.interest_delta ?? null,
|
|
|
|
|
paySource,
|
|
|
|
|
);
|
2026-07-03 18:05:19 -05:00
|
|
|
applyBalanceDelta(db, bill.id, balCalc);
|
|
|
|
|
return r.lastInsertRowid;
|
|
|
|
|
});
|
|
|
|
|
const paymentId = insertQuickPayment();
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
res
|
|
|
|
|
.status(201)
|
|
|
|
|
.json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)));
|
2026-05-16 15:38:28 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/payments/autopay-suggestions/:billId/confirm
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => {
|
2026-05-16 15:38:28 -05:00
|
|
|
const db = getDb();
|
|
|
|
|
const ym = parseYearMonth(req.body);
|
|
|
|
|
|
|
|
|
|
const billId = parseInt(req.params.billId, 10);
|
|
|
|
|
if (!Number.isInteger(billId)) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('bill_id must be an integer', 'bill_id');
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month);
|
|
|
|
|
const { bill, dueDate, amount } = context;
|
2026-06-10 19:42:51 -05:00
|
|
|
if (dueDate > todayLocal()) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('Autopay suggestion is not due yet', 'paid_date');
|
2026-05-16 15:38:28 -05:00
|
|
|
}
|
|
|
|
|
const paymentValidation = validatePaymentInput(
|
|
|
|
|
{ amount, paid_date: dueDate },
|
|
|
|
|
{ requireBillId: false },
|
|
|
|
|
);
|
2026-07-10 16:53:20 -05:00
|
|
|
if (paymentValidation.error)
|
|
|
|
|
throw ValidationError(paymentValidation.error, paymentValidation.field);
|
2026-05-16 15:38:28 -05:00
|
|
|
const suggestedPayment = paymentValidation.normalized;
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
const suggestionRange = getCycleRange(ym.year, ym.month, bill);
|
2026-07-06 14:23:53 -05:00
|
|
|
const existing = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-05-16 15:38:28 -05:00
|
|
|
SELECT p.*
|
|
|
|
|
FROM payments p
|
|
|
|
|
JOIN bills b ON b.id = p.bill_id
|
|
|
|
|
WHERE p.bill_id = ?
|
|
|
|
|
AND b.user_id = ?
|
|
|
|
|
AND p.deleted_at IS NULL
|
2026-05-16 20:26:09 -05:00
|
|
|
AND p.paid_date BETWEEN ? AND ?
|
2026-05-16 15:38:28 -05:00
|
|
|
ORDER BY p.paid_date DESC
|
|
|
|
|
LIMIT 1
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.get(bill.id, req.user.id, suggestionRange.start, suggestionRange.end);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
|
|
|
|
if (existing) {
|
2026-07-06 14:23:53 -05:00
|
|
|
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);
|
2026-06-11 20:12:31 -05:00
|
|
|
return res.json({ created: false, payment: serializePayment(existing) });
|
2026-05-16 15:38:28 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
|
|
|
|
|
|
2026-07-05 11:44:45 -05:00
|
|
|
// Atomic: the payment INSERT, its balance update, and clearing the dismissal
|
|
|
|
|
// must all land or none, so a mid-way failure can't leave a half-recorded pay.
|
|
|
|
|
const confirmAutopay = db.transaction(() => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const r = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-07-05 11:44:45 -05:00
|
|
|
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.run(
|
|
|
|
|
bill.id,
|
|
|
|
|
suggestedPayment.amount,
|
|
|
|
|
suggestedPayment.paid_date,
|
|
|
|
|
'autopay',
|
|
|
|
|
'Confirmed autopay suggestion',
|
|
|
|
|
balCalc?.balance_delta ?? null,
|
|
|
|
|
balCalc?.interest_delta ?? null,
|
|
|
|
|
'manual',
|
|
|
|
|
);
|
2026-07-05 11:44:45 -05:00
|
|
|
applyBalanceDelta(db, bill.id, balCalc);
|
2026-07-06 14:23:53 -05:00
|
|
|
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);
|
2026-07-05 11:44:45 -05:00
|
|
|
return r.lastInsertRowid;
|
|
|
|
|
});
|
|
|
|
|
const paymentId = confirmAutopay();
|
2026-05-16 15:38:28 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
res.status(201).json({
|
|
|
|
|
created: true,
|
|
|
|
|
payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)),
|
|
|
|
|
});
|
2026-05-16 15:38:28 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/payments/autopay-suggestions/:billId/dismiss
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/autopay-suggestions/:billId/dismiss', (req: Req, res: Res) => {
|
2026-05-16 15:38:28 -05:00
|
|
|
const db = getDb();
|
|
|
|
|
const ym = parseYearMonth(req.body);
|
|
|
|
|
|
|
|
|
|
const billId = parseInt(req.params.billId, 10);
|
|
|
|
|
if (!Number.isInteger(billId)) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('bill_id must be an integer', 'bill_id');
|
2026-05-16 15:38:28 -05:00
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
if (
|
|
|
|
|
!db
|
|
|
|
|
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
|
|
|
|
.get(billId, req.user.id)
|
|
|
|
|
) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw NotFoundError('Bill not found', 'bill_id');
|
2026-05-16 15:38:28 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
db.prepare(
|
|
|
|
|
`
|
2026-05-16 15:38:28 -05:00
|
|
|
INSERT INTO autopay_suggestion_dismissals (user_id, bill_id, year, month, dismissed_at)
|
|
|
|
|
VALUES (?, ?, ?, ?, datetime('now'))
|
|
|
|
|
ON CONFLICT(user_id, bill_id, year, month)
|
|
|
|
|
DO UPDATE SET dismissed_at = datetime('now')
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
).run(req.user.id, billId, ym.year, ym.month);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
|
|
|
|
res.json({ success: true });
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/payments/bulk — record multiple payments in one request
|
2026-05-09 23:41:28 -05:00
|
|
|
// Bulk payment creation endpoint
|
|
|
|
|
// Validation rules:
|
|
|
|
|
// - Request body must contain a `payments` array
|
|
|
|
|
// - Maximum 50 items per request
|
2026-05-15 22:45:38 -05:00
|
|
|
// - Each item requires: bill_id (integer), paid_date (valid date), amount (positive number)
|
2026-05-09 23:41:28 -05:00
|
|
|
// - Duplicate payments (same bill_id + paid_date + amount) are skipped, not created
|
|
|
|
|
// - Returns { created: [...], skipped: [...], errors: [...] }
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/bulk', (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
const db = getDb();
|
2026-05-09 23:41:28 -05:00
|
|
|
const { payments } = req.body;
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-09 23:41:28 -05:00
|
|
|
// Validate request body has payments array
|
|
|
|
|
if (!payments || !Array.isArray(payments))
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('Request body must contain a `payments` array', 'payments');
|
2026-05-09 23:41:28 -05:00
|
|
|
|
|
|
|
|
// Validate max items per request (50)
|
|
|
|
|
if (payments.length > 50)
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('Maximum 50 items allowed per request', 'payments');
|
2026-05-09 23:41:28 -05:00
|
|
|
|
|
|
|
|
// Validate each payment item
|
|
|
|
|
for (let i = 0; i < payments.length; i++) {
|
|
|
|
|
const item = payments[i];
|
2026-05-16 20:26:09 -05:00
|
|
|
const validation = validatePaymentInput(
|
|
|
|
|
{ ...item, payment_source: item.payment_source ?? 'manual' },
|
|
|
|
|
{ fieldPrefix: `payments[${i}].` },
|
|
|
|
|
);
|
2026-05-15 22:45:38 -05:00
|
|
|
if (validation.error) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError(`Payment at index ${i}: ${validation.error}`, validation.field);
|
2026-05-09 23:41:28 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
const insert = db.prepare(
|
2026-07-06 14:23:53 -05:00
|
|
|
'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, interest_accrued_month FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
|
2026-05-03 19:51:57 -05:00
|
|
|
);
|
|
|
|
|
|
2026-05-09 23:41:28 -05:00
|
|
|
// Prepare statement for duplicate checking
|
|
|
|
|
const duplicateCheckStmt = db.prepare(
|
|
|
|
|
`SELECT 1 FROM payments p
|
|
|
|
|
JOIN bills b ON b.id = p.bill_id
|
|
|
|
|
WHERE b.user_id = ?
|
2026-05-16 10:34:32 -05:00
|
|
|
AND b.deleted_at IS NULL
|
2026-05-09 23:41:28 -05:00
|
|
|
AND p.bill_id = ?
|
|
|
|
|
AND p.paid_date = ?
|
|
|
|
|
AND p.amount = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
AND p.${SQL_NOT_DELETED}`,
|
2026-05-09 23:41:28 -05:00
|
|
|
);
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
const created = [];
|
2026-05-09 23:41:28 -05:00
|
|
|
const skipped = [];
|
2026-07-06 14:23:53 -05:00
|
|
|
const errors = [];
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
const runBulk = db.transaction(() => {
|
2026-05-09 23:41:28 -05:00
|
|
|
for (const item of payments) {
|
2026-07-06 14:23:53 -05:00
|
|
|
const payment = validatePaymentInput({
|
|
|
|
|
...item,
|
|
|
|
|
payment_source: item.payment_source ?? 'manual',
|
|
|
|
|
}).normalized;
|
2026-05-16 20:26:09 -05:00
|
|
|
const { bill_id, amount: parsedAmt, paid_date, payment_source } = payment;
|
2026-05-15 22:45:38 -05:00
|
|
|
const { method, notes } = item;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
2026-05-09 23:41:28 -05:00
|
|
|
// Check for duplicates using composite key (bill_id + paid_date + amount)
|
|
|
|
|
const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt);
|
|
|
|
|
if (isDuplicate) {
|
2026-06-11 20:12:31 -05:00
|
|
|
skipped.push({ bill_id, paid_date, amount: fromCents(parsedAmt) });
|
2026-05-03 19:51:57 -05:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
const billRow = getBillForBalance.get(bill_id, req.user.id);
|
|
|
|
|
if (!billRow) {
|
2026-05-03 19:51:57 -05:00
|
|
|
errors.push({ item, error: `Bill ${bill_id} not found` });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-14 02:11:54 -05:00
|
|
|
|
|
|
|
|
const balCalc = computeBalanceDelta(billRow, parsedAmt);
|
2026-07-06 14:23:53 -05:00
|
|
|
const r = insert.run(
|
|
|
|
|
bill_id,
|
|
|
|
|
parsedAmt,
|
|
|
|
|
paid_date,
|
|
|
|
|
method || null,
|
|
|
|
|
notes || null,
|
|
|
|
|
balCalc?.balance_delta ?? null,
|
|
|
|
|
balCalc?.interest_delta ?? null,
|
|
|
|
|
payment_source,
|
|
|
|
|
);
|
2026-06-06 16:34:20 -05:00
|
|
|
applyBalanceDelta(db, bill_id, balCalc);
|
2026-05-14 02:11:54 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
created.push(
|
|
|
|
|
serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid)),
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
runBulk();
|
2026-05-09 23:41:28 -05:00
|
|
|
res.status(201).json({ created, skipped, errors });
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// PUT /api/payments/:id
|
2026-07-06 11:26:50 -05:00
|
|
|
router.put('/:id', (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
const existing = 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);
|
2026-07-10 16:53:20 -05:00
|
|
|
if (!existing) throw NotFoundError('Payment not found', 'id');
|
|
|
|
|
if (isTransactionLinkedPayment(existing)) rejectTransactionLinkedPayment();
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
const { amount, paid_date, method, notes, payment_source } = req.body;
|
2026-05-15 22:45:38 -05:00
|
|
|
const validation = validatePaymentInput(
|
2026-05-16 20:26:09 -05:00
|
|
|
{ amount, paid_date, payment_source },
|
2026-05-15 22:45:38 -05:00
|
|
|
{ requireBillId: false, requireAmount: false, requirePaidDate: false },
|
|
|
|
|
);
|
2026-07-10 16:53:20 -05:00
|
|
|
if (validation.error) throw ValidationError(validation.error, validation.field);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
const nextAmount = validation.normalized.amount ?? existing.amount;
|
|
|
|
|
const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date;
|
2026-07-06 14:23:53 -05:00
|
|
|
const nextPaymentSource =
|
|
|
|
|
validation.normalized.payment_source ?? existing.payment_source ?? 'manual';
|
2026-05-16 15:38:28 -05:00
|
|
|
let nextBalanceDelta = existing.balance_delta;
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const bill = db
|
|
|
|
|
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
|
|
|
|
.get(existing.bill_id, req.user.id);
|
2026-06-06 16:34:20 -05:00
|
|
|
let nextInterestDelta = existing.interest_delta ?? null;
|
2026-07-05 11:44:45 -05:00
|
|
|
// Compute the balance mutation without writing yet — the write happens inside
|
|
|
|
|
// the transaction below so it lands atomically with the payment UPDATE.
|
|
|
|
|
let balCalc = null;
|
|
|
|
|
let paymentPortion = null;
|
|
|
|
|
let restoredBalance = null;
|
2026-05-16 15:38:28 -05:00
|
|
|
if (bill) {
|
2026-06-06 16:34:20 -05:00
|
|
|
// 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;
|
2026-07-06 14:23:53 -05:00
|
|
|
paymentPortion =
|
|
|
|
|
existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
|
2026-07-05 11:44:45 -05:00
|
|
|
restoredBalance = bill.current_balance;
|
2026-06-06 16:34:20 -05:00
|
|
|
if (paymentPortion != null && bill.current_balance != null) {
|
2026-06-11 20:12:31 -05:00
|
|
|
restoredBalance = Math.max(0, bill.current_balance - paymentPortion);
|
2026-05-16 15:38:28 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-06 16:34:20 -05:00
|
|
|
// 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.
|
2026-07-05 11:44:45 -05:00
|
|
|
balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
|
2026-07-06 14:23:53 -05:00
|
|
|
nextBalanceDelta = balCalc?.balance_delta ?? null;
|
2026-06-06 16:34:20 -05:00
|
|
|
nextInterestDelta = balCalc?.interest_delta ?? null;
|
2026-05-16 15:38:28 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-07 14:49:39 -05:00
|
|
|
const { autopay_failure } = req.body;
|
2026-07-06 14:23:53 -05:00
|
|
|
const nextAutopayFailure =
|
|
|
|
|
autopay_failure !== undefined ? (autopay_failure ? 1 : 0) : existing.autopay_failure;
|
2026-06-07 14:49:39 -05:00
|
|
|
|
2026-07-05 11:44:45 -05:00
|
|
|
// Atomic: the bill-balance write and the payment UPDATE land together or neither,
|
|
|
|
|
// so an edit never leaves the balance reversed without the row updated (or vice versa).
|
|
|
|
|
const applyEdit = db.transaction(() => {
|
|
|
|
|
if (bill) {
|
|
|
|
|
if (balCalc) {
|
|
|
|
|
applyBalanceDelta(db, existing.bill_id, balCalc);
|
|
|
|
|
} else if (paymentPortion != null && restoredBalance != null) {
|
2026-07-06 14:23:53 -05:00
|
|
|
db.prepare(
|
|
|
|
|
"UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?",
|
|
|
|
|
).run(restoredBalance, existing.bill_id);
|
2026-07-05 11:44:45 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
db.prepare(
|
|
|
|
|
`
|
2026-07-05 11:44:45 -05:00
|
|
|
UPDATE payments SET
|
|
|
|
|
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?,
|
|
|
|
|
payment_source = ?, autopay_failure = ?, updated_at = datetime('now')
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
).run(
|
2026-07-05 11:44:45 -05:00
|
|
|
nextAmount,
|
|
|
|
|
nextPaidDate,
|
2026-07-06 14:23:53 -05:00
|
|
|
method !== undefined ? method || null : existing.method,
|
|
|
|
|
notes !== undefined ? notes || null : existing.notes,
|
2026-07-05 11:44:45 -05:00
|
|
|
nextBalanceDelta,
|
|
|
|
|
nextInterestDelta,
|
|
|
|
|
nextPaymentSource,
|
|
|
|
|
nextAutopayFailure,
|
|
|
|
|
req.params.id,
|
|
|
|
|
req.user.id,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
applyEdit();
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// DELETE /api/payments/:id — soft delete (sets deleted_at)
|
2026-07-06 11:26:50 -05:00
|
|
|
router.delete('/:id', (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
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);
|
2026-07-10 16:53:20 -05:00
|
|
|
if (!payment) throw NotFoundError('Payment not found', 'id');
|
|
|
|
|
if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment();
|
2026-05-14 02:11:54 -05:00
|
|
|
|
2026-07-05 11:44:45 -05:00
|
|
|
// Atomic: reverse the balance delta and soft-delete the payment together, so a
|
|
|
|
|
// failure can't leave the balance restored while the payment is still active
|
|
|
|
|
// (or vice versa). If this payment charged interest this month, clear
|
2026-06-06 16:34:20 -05:00
|
|
|
// interest_accrued_month so the next payment can re-accrue correctly.
|
2026-07-05 11:44:45 -05:00
|
|
|
const softDeletePayment = db.transaction(() => {
|
|
|
|
|
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
2026-07-06 14:23:53 -05:00
|
|
|
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);
|
2026-07-05 11:44:45 -05:00
|
|
|
if (bill?.current_balance != null) {
|
|
|
|
|
const restored = Math.max(0, bill.current_balance - payment.balance_delta);
|
2026-07-06 14:23:53 -05:00
|
|
|
db.prepare(
|
|
|
|
|
`
|
2026-07-05 11:44:45 -05:00
|
|
|
UPDATE bills
|
|
|
|
|
SET current_balance = ?,
|
|
|
|
|
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
|
|
|
|
|
updated_at = datetime('now')
|
|
|
|
|
WHERE id = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
|
2026-07-05 11:44:45 -05:00
|
|
|
}
|
2026-05-14 02:11:54 -05:00
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
db.prepare(
|
|
|
|
|
"UPDATE payments SET deleted_at = datetime('now') 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);
|
2026-07-05 11:44:45 -05:00
|
|
|
});
|
|
|
|
|
softDeletePayment();
|
2026-05-03 19:51:57 -05:00
|
|
|
res.json({ success: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/payments/:id/restore — undo soft delete
|
2026-07-06 11:26:50 -05:00
|
|
|
router.post('/:id/restore', (req: Req, res: Res) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
const payment = db
|
|
|
|
|
.prepare(
|
|
|
|
|
'SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL',
|
|
|
|
|
)
|
|
|
|
|
.get(req.params.id, req.user.id);
|
2026-07-10 16:53:20 -05:00
|
|
|
if (!payment) throw NotFoundError('Deleted payment not found', 'id');
|
|
|
|
|
if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment();
|
2026-05-14 02:11:54 -05:00
|
|
|
|
2026-07-05 11:44:45 -05:00
|
|
|
// Atomic: re-apply the balance delta and un-delete the payment together, so a
|
|
|
|
|
// failure can't leave the balance re-applied while the payment stays deleted
|
|
|
|
|
// (or vice versa). If this payment originally charged interest, restore
|
|
|
|
|
// interest_accrued_month to the payment's month so future same-month payments
|
|
|
|
|
// skip interest.
|
|
|
|
|
const restorePayment = db.transaction(() => {
|
|
|
|
|
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
2026-07-06 14:23:53 -05:00
|
|
|
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);
|
2026-07-05 11:44:45 -05:00
|
|
|
if (bill?.current_balance != null) {
|
2026-07-06 14:23:53 -05:00
|
|
|
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;
|
|
|
|
|
db.prepare(
|
|
|
|
|
`
|
2026-07-05 11:44:45 -05:00
|
|
|
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 = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
).run(reapplied, interestMonth, interestMonth, payment.bill_id);
|
2026-07-05 11:44:45 -05:00
|
|
|
}
|
2026-05-14 02:11:54 -05:00
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
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);
|
2026-07-05 11:44:45 -05:00
|
|
|
});
|
|
|
|
|
restorePayment();
|
2026-07-06 14:23:53 -05:00
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-04 00:06:16 -05:00
|
|
|
// PATCH /api/payments/:id/attribute-to-month
|
|
|
|
|
// Changes only the paid_date of a provider_sync payment to move it into the
|
|
|
|
|
// correct billing period when it posted just after month end.
|
|
|
|
|
// Does not touch the amount or balance_delta.
|
2026-07-06 11:26:50 -05:00
|
|
|
router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
|
2026-06-04 00:06:16 -05:00
|
|
|
const db = getDb();
|
|
|
|
|
const paymentId = parseInt(req.params.id, 10);
|
|
|
|
|
if (!Number.isInteger(paymentId) || paymentId < 1) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('Invalid payment id');
|
2026-06-04 00:06:16 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { paid_date } = req.body;
|
|
|
|
|
if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('paid_date must be YYYY-MM-DD', 'paid_date');
|
2026-06-04 00:06:16 -05:00
|
|
|
}
|
|
|
|
|
// Validate it is a real calendar date
|
2026-06-10 19:42:51 -05:00
|
|
|
const newDate = new Date(paid_date + 'T00:00:00Z');
|
2026-06-04 00:06:16 -05:00
|
|
|
if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) {
|
2026-07-10 16:53:20 -05:00
|
|
|
throw ValidationError('paid_date is not a valid calendar date', 'paid_date');
|
2026-06-04 00:06:16 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-10 16:53:20 -05:00
|
|
|
const payment = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-06-04 00:06:16 -05:00
|
|
|
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
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
2026-07-10 16:53:20 -05:00
|
|
|
)
|
|
|
|
|
.get(paymentId, req.user.id);
|
2026-06-04 00:06:16 -05:00
|
|
|
|
2026-07-10 16:53:20 -05:00
|
|
|
if (!payment) throw NotFoundError('Payment not found');
|
2026-06-04 00:06:16 -05:00
|
|
|
|
2026-07-10 16:53:20 -05:00
|
|
|
// Only allow date-only reclassification for provider_sync payments
|
|
|
|
|
if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') {
|
|
|
|
|
throw ConflictError(
|
|
|
|
|
'Only bank-synced payments can be reclassified to a different month',
|
|
|
|
|
undefined,
|
|
|
|
|
'RECLASSIFY_ONLY_SYNC',
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-07 01:05:48 -05:00
|
|
|
|
2026-07-10 16:53:20 -05:00
|
|
|
// Sanity check: new date must be in the month immediately before the original date
|
|
|
|
|
const orig = new Date(payment.paid_date + 'T00:00:00');
|
|
|
|
|
const origYM = orig.getFullYear() * 12 + orig.getMonth();
|
|
|
|
|
const newYM = newDate.getFullYear() * 12 + newDate.getMonth();
|
|
|
|
|
if (newYM !== origYM - 1) {
|
|
|
|
|
throw ValidationError(
|
|
|
|
|
'The new paid_date must be in the month immediately before the original payment date',
|
|
|
|
|
'paid_date',
|
2026-07-06 14:23:53 -05:00
|
|
|
);
|
2026-06-04 00:06:16 -05:00
|
|
|
}
|
2026-07-10 16:53:20 -05:00
|
|
|
|
|
|
|
|
db.transaction(() => {
|
|
|
|
|
db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?").run(
|
|
|
|
|
paid_date,
|
|
|
|
|
paymentId,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const bill = db
|
|
|
|
|
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
|
|
|
|
.get(payment.bill_id, req.user.id);
|
|
|
|
|
if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date });
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-06-04 00:06:16 -05:00
|
|
|
});
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
module.exports = router;
|