fix(money): manual payment path flags suspected dupes (409) w/o losing legit ones (Track A)
The deliberate manual POST /payments had no dedupe guard (double-submit made a second payment), but silently deduping it like the auto paths would lose a legitimately-identical same-day payment — a money bug. So the server returns 409 DUPLICATE_SUSPECTED (existing payment attached), and a shared client helper (client/lib/paymentActions.ts, used by the tracker inline cell, partial-payment dialog, and bill modal) turns it into an 'add anyway?' toast that resends with allow_duplicate. Automated one-click paths keep deduping silently (a repeat there is a retry). Dropped a now-unused api import in PaymentLedgerDialog (Track E sweep). Server suite 188 green; typecheck/lint/build clean; probe 17/17. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
60848964b6
commit
dd5bf92fc1
|
|
@ -3,7 +3,8 @@
|
||||||
|
|
||||||
### 💰 Payment atomicity audit (Track A) — every balance mutation is now transactional
|
### 💰 Payment atomicity audit (Track A) — every balance mutation is now transactional
|
||||||
|
|
||||||
- **[Money integrity] Wrapped the four remaining non-atomic payment paths in `db.transaction()`.** Quick-pay and bulk-pay were already atomic (X1), but the audit found the INSERT/UPDATE + `applyBalanceDelta` pair split across two un-transactional statements on **manual `POST /payments`**, **autopay-suggestion confirm**, **`DELETE` (unpay)**, and **`POST /:id/restore`** — plus **`PUT` (edit)**, where the bill-balance write and the payment `UPDATE` weren't atomic. A crash/constraint failure between the two statements could leave a payment without its balance adjustment (or a balance reversed without the row deleted). Each is now a single transaction: the row write and the balance mutation land together or neither. Behavior-preserving — `tests/paymentsRoute.test.js` pins the create → delete → restore → edit balance round-trip and the double-restore no-op; full server suite green (186 tests). (Dedupe-guard work for the manual path follows as a separate change.)
|
- **[Money integrity] Wrapped the four remaining non-atomic payment paths in `db.transaction()`.** Quick-pay and bulk-pay were already atomic (X1), but the audit found the INSERT/UPDATE + `applyBalanceDelta` pair split across two un-transactional statements on **manual `POST /payments`**, **autopay-suggestion confirm**, **`DELETE` (unpay)**, and **`POST /:id/restore`** — plus **`PUT` (edit)**, where the bill-balance write and the payment `UPDATE` weren't atomic. A crash/constraint failure between the two statements could leave a payment without its balance adjustment (or a balance reversed without the row deleted). Each is now a single transaction: the row write and the balance mutation land together or neither. Behavior-preserving — `tests/paymentsRoute.test.js` pins the create → delete → restore → edit balance round-trip and the double-restore no-op.
|
||||||
|
- **[Money integrity] Manual `POST /payments` now flags a suspected duplicate instead of blindly creating one — without silently dropping a legitimate one.** The deliberate manual-add path had *no* dedupe guard, so a double-submit created a second payment. But unlike the automated one-click paths (`/quick`, `/bulk`, autopay-confirm — which dedupe *silently* because a repeat there is a retry), the manual path may legitimately record two identical payments on the same day, so silently deduping would *lose a real payment* — itself a money bug. Resolution: the server returns **`409 DUPLICATE_SUSPECTED`** (with the existing payment attached) on a same `bill+date+amount`; a shared client helper (`client/lib/paymentActions.ts`, used by the tracker inline cell, the partial-payment ledger dialog, and the bill modal) turns that into an **"add anyway?"** toast that resends with `allow_duplicate`. Best of both: retry-safety on the auto paths, no lost-write on the deliberate one. (A DB `UNIQUE` index was rejected — it would forbid legitimately-identical payments; an idempotency-key header is the documented upgrade if this ever goes multi-process.) Full server suite 188 green; e2e probe 17/17. (Track A)
|
||||||
|
|
||||||
### 🔷 TypeScript foundation + branded money types
|
### 🔷 TypeScript foundation + branded money types
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
||||||
import { cn, todayStr, errMessage } from '@/lib/utils';
|
import { cn, todayStr, errMessage } from '@/lib/utils';
|
||||||
import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection';
|
import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection';
|
||||||
import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator';
|
import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator';
|
||||||
|
|
@ -368,17 +369,22 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
notes: paymentNotes || null,
|
notes: paymentNotes || null,
|
||||||
payment_source: 'manual',
|
payment_source: 'manual',
|
||||||
};
|
};
|
||||||
if (editingPayment) {
|
const finishPaymentSave = async (successMessage: string) => {
|
||||||
await api.updatePayment(editingPayment.id, payload);
|
toast.success(successMessage);
|
||||||
toast.success('Payment updated');
|
|
||||||
} else {
|
|
||||||
await api.createPayment({ ...payload, bill_id: bill?.id });
|
|
||||||
toast.success('Payment added');
|
|
||||||
}
|
|
||||||
resetPaymentForm();
|
resetPaymentForm();
|
||||||
setPaymentFormOpen(false);
|
setPaymentFormOpen(false);
|
||||||
await loadPayments();
|
await loadPayments();
|
||||||
onSave?.();
|
onSave?.();
|
||||||
|
};
|
||||||
|
if (editingPayment) {
|
||||||
|
await api.updatePayment(editingPayment.id, payload);
|
||||||
|
await finishPaymentSave('Payment updated');
|
||||||
|
} else {
|
||||||
|
await createPaymentOrConfirm(
|
||||||
|
{ ...payload, bill_id: bill?.id },
|
||||||
|
() => finishPaymentSave('Payment added'),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err, 'Payment could not be saved.'));
|
toast.error(errMessage(err, 'Payment could not be saved.'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
||||||
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import type { Dollars } from '@/lib/money';
|
import type { Dollars } from '@/lib/money';
|
||||||
|
|
@ -47,15 +48,18 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres
|
||||||
if (field === 'amount') update.amount = parseFloat(val);
|
if (field === 'amount') update.amount = parseFloat(val);
|
||||||
if (field === 'date') update.paid_date = val;
|
if (field === 'date') update.paid_date = val;
|
||||||
await api.updatePayment(row.payments[0]!.id, update);
|
await api.updatePayment(row.payments[0]!.id, update);
|
||||||
|
toast.success('Saved');
|
||||||
|
refresh();
|
||||||
} else {
|
} else {
|
||||||
await api.createPayment({
|
await createPaymentOrConfirm(
|
||||||
|
{
|
||||||
bill_id: row.id,
|
bill_id: row.id,
|
||||||
amount: field === 'amount' ? parseFloat(val) : threshold,
|
amount: field === 'amount' ? parseFloat(val) : threshold,
|
||||||
paid_date: field === 'date' ? val : defaultPaymentDate,
|
paid_date: field === 'date' ? val : defaultPaymentDate,
|
||||||
});
|
},
|
||||||
|
() => { toast.success('Saved'); refresh(); },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
toast.success('Saved');
|
|
||||||
refresh();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err));
|
toast.error(errMessage(err));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
||||||
import { fmt, fmtDate, errMessage } from '@/lib/utils';
|
import { fmt, fmtDate, errMessage } from '@/lib/utils';
|
||||||
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
|
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -53,16 +53,16 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.createPayment({
|
await createPaymentOrConfirm(
|
||||||
|
{
|
||||||
bill_id: row.id,
|
bill_id: row.id,
|
||||||
amount: parsedAmount,
|
amount: parsedAmount,
|
||||||
paid_date: date,
|
paid_date: date,
|
||||||
method: method === METHOD_NONE ? null : method,
|
method: method === METHOD_NONE ? null : method,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
});
|
},
|
||||||
toast.success('Partial payment added');
|
() => { toast.success('Partial payment added'); onSaved?.(); onClose?.(); },
|
||||||
onSaved?.();
|
);
|
||||||
onClose?.();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err, 'Failed to add payment'));
|
toast.error(errMessage(err, 'Failed to add payment'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { api, type ApiError } from '@/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { errMessage } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a manual payment, treating the server's "duplicate suspected" (409) as a
|
||||||
|
* question rather than an error. The deliberate manual-add path must never
|
||||||
|
* *silently* drop a legitimately-identical second payment (same bill, date,
|
||||||
|
* amount) — losing a real payment is itself a money-integrity bug. So the server
|
||||||
|
* returns `409 DUPLICATE_SUSPECTED` instead of silently deduping, and here we
|
||||||
|
* surface an "add anyway?" toast; confirming resends with `allow_duplicate`.
|
||||||
|
*
|
||||||
|
* `onSuccess` runs after any *real* creation — immediate OR confirmed-retry — so
|
||||||
|
* callers put their toast/refresh/close there. Any non-duplicate error re-throws
|
||||||
|
* for the caller's existing `catch`.
|
||||||
|
*/
|
||||||
|
export async function createPaymentOrConfirm(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
onSuccess: () => void | Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const create = async (allowDuplicate: boolean) => {
|
||||||
|
await api.createPayment(allowDuplicate ? { ...payload, allow_duplicate: true } : payload);
|
||||||
|
await onSuccess();
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await create(false);
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as ApiError;
|
||||||
|
if (e?.status === 409 && e?.code === 'DUPLICATE_SUSPECTED') {
|
||||||
|
toast.warning('A matching payment already exists for this bill, date, and amount.', {
|
||||||
|
description: 'Add it anyway?',
|
||||||
|
action: {
|
||||||
|
label: 'Add anyway',
|
||||||
|
onClick: () => {
|
||||||
|
create(true).catch(retryErr => toast.error(errMessage(retryErr, 'Failed to add payment')));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return; // handled — not an error the caller should re-toast
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -190,7 +190,7 @@ router.post('/:id/undo-auto', (req, res) => {
|
||||||
// POST /api/payments — create single payment
|
// POST /api/payments — create single payment
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { bill_id, amount, paid_date, method, notes, payment_source, autopay_failure } = req.body;
|
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' });
|
const validation = validatePaymentInput({ bill_id, amount, paid_date, payment_source: payment_source ?? 'manual' });
|
||||||
if (validation.error) {
|
if (validation.error) {
|
||||||
|
|
@ -202,6 +202,26 @@ router.post('/', (req, res) => {
|
||||||
if (!bill)
|
if (!bill)
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const existingDuplicate = db.prepare(
|
||||||
|
`SELECT p.* FROM payments p
|
||||||
|
WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND p.${SQL_NOT_DELETED}
|
||||||
|
ORDER BY p.id DESC LIMIT 1`
|
||||||
|
).get(bill.id, payment.paid_date, payment.amount);
|
||||||
|
if (existingDuplicate) {
|
||||||
|
return res.status(409).json({
|
||||||
|
...standardizeError('A matching payment already exists for this bill, date, and amount', 'DUPLICATE_SUSPECTED', 'amount'),
|
||||||
|
existing: serializePayment(existingDuplicate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const balCalc = computeBalanceDelta(bill, payment.amount);
|
const balCalc = computeBalanceDelta(bill, payment.amount);
|
||||||
const failureFlag = autopay_failure ? 1 : 0;
|
const failureFlag = autopay_failure ? 1 : 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,3 +98,26 @@ test('PUT edit reverses the old amount and applies the new one atomically', asyn
|
||||||
// reverse $100 (→ 1000.00) then apply $50 (→ 950.00)
|
// reverse $100 (→ 1000.00) then apply $50 (→ 950.00)
|
||||||
assert.equal(balanceOf(billId), 95000, '1000.00 − 50.00 = 950.00 (cents)');
|
assert.equal(balanceOf(billId), 95000, '1000.00 − 50.00 = 950.00 (cents)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manual POST of a same bill+date+amount is a suspected duplicate (409, no new row, balance unchanged)', async () => {
|
||||||
|
const before = balanceOf(billId);
|
||||||
|
const { status, data } = await call('post', '/', {
|
||||||
|
userId, body: { bill_id: billId, amount: 50, paid_date: '2026-07-01' },
|
||||||
|
});
|
||||||
|
assert.equal(status, 409);
|
||||||
|
assert.equal(data.code, 'DUPLICATE_SUSPECTED');
|
||||||
|
assert.ok(data.existing?.id, 'returns the existing payment so the client can show it');
|
||||||
|
assert.equal(balanceOf(billId), before, 'balance not touched by a rejected duplicate');
|
||||||
|
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||||||
|
assert.equal(active, 1, 'still one payment');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual POST with allow_duplicate bypasses the guard and records the second payment', async () => {
|
||||||
|
const { status } = await call('post', '/', {
|
||||||
|
userId, body: { bill_id: billId, amount: 50, paid_date: '2026-07-01', allow_duplicate: true },
|
||||||
|
});
|
||||||
|
assert.equal(status, 201);
|
||||||
|
assert.equal(balanceOf(billId), 90000, '950.00 − 50.00 = 900.00 (cents)');
|
||||||
|
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||||||
|
assert.equal(active, 2, 'a legitimately-identical second payment is recorded');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue