feat: bulk unmatch — review similar payees, batch-undo with optional rule removal
This commit is contained in:
parent
f69f778821
commit
fc3709337c
|
|
@ -366,6 +366,7 @@ export const api = {
|
||||||
deleteTransaction: (id) => del(`/transactions/${id}`),
|
deleteTransaction: (id) => del(`/transactions/${id}`),
|
||||||
matchTransaction: (id, billId) => post(`/transactions/${id}/match`, { billId }),
|
matchTransaction: (id, billId) => post(`/transactions/${id}/match`, { billId }),
|
||||||
unmatchTransaction: (id) => post(`/transactions/${id}/unmatch`),
|
unmatchTransaction: (id) => post(`/transactions/${id}/unmatch`),
|
||||||
|
unmatchTransactionBulk: (matches) => post('/transactions/unmatch-bulk', { matches }),
|
||||||
ignoreTransaction: (id) => post(`/transactions/${id}/ignore`),
|
ignoreTransaction: (id) => post(`/transactions/${id}/ignore`),
|
||||||
unignoreTransaction: (id) => post(`/transactions/${id}/unignore`),
|
unignoreTransaction: (id) => post(`/transactions/${id}/unignore`),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ChevronDown, Copy, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
import { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||||
|
|
@ -115,6 +116,18 @@ function isSnowballCat(categories, catId) {
|
||||||
return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false;
|
return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePayee(s) {
|
||||||
|
return (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSimilarPayee(a, b) {
|
||||||
|
const na = normalizePayee(a);
|
||||||
|
const nb = normalizePayee(b);
|
||||||
|
const minLen = Math.min(na.length, nb.length);
|
||||||
|
if (minLen < 3) return false;
|
||||||
|
return na.startsWith(nb) || nb.startsWith(na);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) {
|
export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) {
|
||||||
const isNew = !bill;
|
const isNew = !bill;
|
||||||
const sourceBill = bill || initialBill || null;
|
const sourceBill = bill || initialBill || null;
|
||||||
|
|
@ -171,6 +184,12 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
const [paymentMethod, setPaymentMethod] = useState('manual');
|
const [paymentMethod, setPaymentMethod] = useState('manual');
|
||||||
const [paymentNotes, setPaymentNotes] = useState('');
|
const [paymentNotes, setPaymentNotes] = useState('');
|
||||||
|
|
||||||
|
// Unmatch dialog state
|
||||||
|
const [unmatchTarget, setUnmatchTarget] = useState(null);
|
||||||
|
const [unmatchConfirmOpen, setUnmatchConfirmOpen] = useState(false);
|
||||||
|
const [bulkUnmatch, setBulkUnmatch] = useState(null);
|
||||||
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
|
|
||||||
const isDebtCategory = isDebtCat(categories, categoryId);
|
const isDebtCategory = isDebtCat(categories, categoryId);
|
||||||
const isSnowballCategory = isSnowballCat(categories, categoryId);
|
const isSnowballCategory = isSnowballCat(categories, categoryId);
|
||||||
const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt);
|
const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt);
|
||||||
|
|
@ -401,12 +420,25 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUnmatchTransaction(transaction) {
|
function openUnmatch(transaction) {
|
||||||
if (!transaction?.id) return;
|
setUnmatchTarget(transaction);
|
||||||
setTransactionBusyId(transaction.id);
|
setBulkUnmatch(null);
|
||||||
|
setUnmatchConfirmOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUnmatch() {
|
||||||
|
setUnmatchTarget(null);
|
||||||
|
setBulkUnmatch(null);
|
||||||
|
setUnmatchConfirmOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSingleUnmatch() {
|
||||||
|
if (!unmatchTarget?.id) return;
|
||||||
|
setTransactionBusyId(unmatchTarget.id);
|
||||||
try {
|
try {
|
||||||
await api.unmatchTransaction(transaction.id);
|
await api.unmatchTransaction(unmatchTarget.id);
|
||||||
toast.success('Transaction unmatched');
|
toast.success('Transaction unmatched');
|
||||||
|
closeUnmatch();
|
||||||
await Promise.all([loadPayments(), loadLinkedTransactions()]);
|
await Promise.all([loadPayments(), loadLinkedTransactions()]);
|
||||||
onSave?.();
|
onSave?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -416,6 +448,62 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleOpenBulkUnmatch() {
|
||||||
|
if (!unmatchTarget || !bill?.id) return;
|
||||||
|
const targetPayee = transactionTitle(unmatchTarget);
|
||||||
|
const similar = linkedTransactions.filter(tx =>
|
||||||
|
isSimilarPayee(transactionTitle(tx), targetPayee)
|
||||||
|
);
|
||||||
|
if (!similar.find(tx => tx.id === unmatchTarget.id)) {
|
||||||
|
similar.unshift(unmatchTarget);
|
||||||
|
}
|
||||||
|
let rules = [];
|
||||||
|
try {
|
||||||
|
const ruleData = await api.billMerchantRules(bill.id);
|
||||||
|
rules = (ruleData || []).filter(r => isSimilarPayee(r.merchant, targetPayee));
|
||||||
|
} catch {
|
||||||
|
// ignore — rules are optional
|
||||||
|
}
|
||||||
|
setBulkUnmatch({
|
||||||
|
similar,
|
||||||
|
rules,
|
||||||
|
checkedIds: new Set(similar.map(tx => tx.id)),
|
||||||
|
removeRuleId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBulkConfirm() {
|
||||||
|
if (!bulkUnmatch) return;
|
||||||
|
const { similar, checkedIds, removeRuleId } = bulkUnmatch;
|
||||||
|
const matches = similar
|
||||||
|
.filter(tx => checkedIds.has(tx.id) && tx.linked_payment)
|
||||||
|
.map(tx => ({
|
||||||
|
transaction_id: tx.id,
|
||||||
|
payment_id: tx.linked_payment.id,
|
||||||
|
payment_source: tx.linked_payment.payment_source,
|
||||||
|
}));
|
||||||
|
if (matches.length === 0) { closeUnmatch(); return; }
|
||||||
|
setBulkBusy(true);
|
||||||
|
try {
|
||||||
|
await api.unmatchTransactionBulk(matches);
|
||||||
|
if (removeRuleId) {
|
||||||
|
try {
|
||||||
|
await api.deleteMerchantRule(bill.id, removeRuleId);
|
||||||
|
} catch {
|
||||||
|
toast.error('Transactions unmatched, but could not remove merchant rule.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(`${matches.length} transaction${matches.length !== 1 ? 's' : ''} unmatched`);
|
||||||
|
closeUnmatch();
|
||||||
|
await Promise.all([loadPayments(), loadLinkedTransactions()]);
|
||||||
|
onSave?.();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Could not unmatch transactions.');
|
||||||
|
} finally {
|
||||||
|
setBulkBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
@ -1122,7 +1210,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={transactionBusyId === transaction.id}
|
disabled={transactionBusyId === transaction.id}
|
||||||
onClick={() => handleUnmatchTransaction(transaction)}
|
onClick={() => openUnmatch(transaction)}
|
||||||
className="h-8 gap-1.5 text-xs"
|
className="h-8 gap-1.5 text-xs"
|
||||||
>
|
>
|
||||||
<Link2Off className="h-3.5 w-3.5" />
|
<Link2Off className="h-3.5 w-3.5" />
|
||||||
|
|
@ -1261,6 +1349,235 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* ── Unmatch choice dialog ─────────────────────────────────── */}
|
||||||
|
<Dialog
|
||||||
|
open={!!unmatchTarget && !bulkUnmatch}
|
||||||
|
onOpenChange={open => { if (!open) closeUnmatch(); }}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md border-border/60 bg-card/95 backdrop-blur-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base font-semibold">Unmatch transaction</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm text-muted-foreground">
|
||||||
|
How would you like to proceed?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{unmatchTarget && (
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
|
||||||
|
<p className="text-sm font-medium text-foreground">{transactionTitle(unmatchTarget)}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'}
|
||||||
|
{' · '}
|
||||||
|
<span className="font-mono">{fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnmatchConfirmOpen(true)}
|
||||||
|
className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<Link2Off className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Unmatch this payment only</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">Remove just this one transaction from the bill.</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenBulkUnmatch}
|
||||||
|
className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<Layers className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Review all similar matches</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
See all transactions with a similar payee name and manage them together. Optionally remove a merchant rule too.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="ghost" onClick={closeUnmatch} className="text-xs">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Single unmatch confirm ────────────────────────────────── */}
|
||||||
|
<AlertDialog
|
||||||
|
open={!!unmatchTarget && unmatchConfirmOpen}
|
||||||
|
onOpenChange={open => { if (!open) setUnmatchConfirmOpen(false); }}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Unmatch this transaction?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{unmatchTarget && (
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-foreground">{transactionTitle(unmatchTarget)}</span>
|
||||||
|
{' '}will be unlinked from this bill.
|
||||||
|
{unmatchTarget.linked_payment?.payment_source === 'provider_sync'
|
||||||
|
? ' The payment record will be removed and the balance restored.'
|
||||||
|
: ' The payment record will be removed.'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={!!transactionBusyId} onClick={() => setUnmatchConfirmOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={!!transactionBusyId}
|
||||||
|
onClick={handleSingleUnmatch}
|
||||||
|
>
|
||||||
|
{transactionBusyId ? 'Unmatching…' : 'Confirm Unmatch'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* ── Bulk unmatch dialog ───────────────────────────────────── */}
|
||||||
|
<Dialog
|
||||||
|
open={!!unmatchTarget && !!bulkUnmatch}
|
||||||
|
onOpenChange={open => { if (!open) closeUnmatch(); }}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base font-semibold">Review similar matches</DialogTitle>
|
||||||
|
{unmatchTarget && (
|
||||||
|
<DialogDescription className="text-sm text-muted-foreground">
|
||||||
|
Transactions similar to <span className="font-medium text-foreground">{transactionTitle(unmatchTarget)}</span>.
|
||||||
|
Uncheck any you want to keep matched.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{bulkUnmatch && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Quick select:</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => setBulkUnmatch(p => ({ ...p, checkedIds: new Set(p.similar.map(t => t.id)) }))}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => setBulkUnmatch(p => ({ ...p, checkedIds: new Set() }))}
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</Button>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{bulkUnmatch.checkedIds.size} of {bulkUnmatch.similar.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-60 space-y-2 overflow-y-auto pr-1">
|
||||||
|
{bulkUnmatch.similar.map(tx => (
|
||||||
|
<label
|
||||||
|
key={tx.id}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors',
|
||||||
|
bulkUnmatch.checkedIds.has(tx.id)
|
||||||
|
? 'border-primary/30 bg-primary/5'
|
||||||
|
: 'border-border/60 bg-background/35 opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={bulkUnmatch.checkedIds.has(tx.id)}
|
||||||
|
onCheckedChange={checked => {
|
||||||
|
setBulkUnmatch(p => {
|
||||||
|
const next = new Set(p.checkedIds);
|
||||||
|
checked ? next.add(tx.id) : next.delete(tx.id);
|
||||||
|
return { ...p, checkedIds: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{transactionTitle(tx)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{transactionDate(tx) ? fmtDate(transactionDate(tx)) : 'No date'}
|
||||||
|
{tx.account_name && ` · ${tx.account_name}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className={cn(
|
||||||
|
'shrink-0 font-mono text-sm font-semibold tabular-nums',
|
||||||
|
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
||||||
|
)}>
|
||||||
|
{fmtTransactionAmount(tx.amount, tx.currency)}
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bulkUnmatch.rules.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-amber-500/25 bg-amber-500/5 px-3 py-2.5">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">
|
||||||
|
Merchant rules
|
||||||
|
</p>
|
||||||
|
{bulkUnmatch.rules.map(rule => (
|
||||||
|
<label key={rule.id} className="flex cursor-pointer items-start gap-2.5">
|
||||||
|
<Checkbox
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={bulkUnmatch.removeRuleId === rule.id}
|
||||||
|
onCheckedChange={checked =>
|
||||||
|
setBulkUnmatch(p => ({ ...p, removeRuleId: checked ? rule.id : null }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">
|
||||||
|
Remove rule <span className="font-mono font-semibold">"{rule.merchant}"</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Future syncs won't auto-match this pattern to this bill.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="button" variant="ghost" onClick={closeUnmatch} className="text-xs">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBulkUnmatch(null)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={bulkBusy || !bulkUnmatch || bulkUnmatch.checkedIds.size === 0}
|
||||||
|
onClick={handleBulkConfirm}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{bulkBusy
|
||||||
|
? 'Unmatching…'
|
||||||
|
: `Unmatch ${bulkUnmatch?.checkedIds?.size ?? 0} transaction${bulkUnmatch?.checkedIds?.size !== 1 ? 's' : ''}`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -514,6 +514,79 @@ router.post('/:id/unmatch', (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/transactions/unmatch-bulk
|
||||||
|
// Body: { matches: [{ transaction_id, payment_id, payment_source }] }
|
||||||
|
// Handles both provider_sync (full undo: balance restore + delete payment) and
|
||||||
|
// transaction_match (standard service unmatch) in one call.
|
||||||
|
router.post('/unmatch-bulk', (req, res) => {
|
||||||
|
const matches = req.body?.matches;
|
||||||
|
if (!Array.isArray(matches) || matches.length === 0) {
|
||||||
|
return res.status(400).json(standardizeError('matches array required', 'VALIDATION_ERROR'));
|
||||||
|
}
|
||||||
|
if (matches.length > 50) {
|
||||||
|
return res.status(400).json(standardizeError('Cannot unmatch more than 50 at once', 'VALIDATION_ERROR'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const userId = req.user.id;
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const m of matches) {
|
||||||
|
const txId = parseInt(m.transaction_id, 10);
|
||||||
|
if (!Number.isInteger(txId) || txId < 1) {
|
||||||
|
results.push({ transaction_id: m.transaction_id, ok: false, error: 'Invalid transaction_id' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (m.payment_source === 'provider_sync' && m.payment_id) {
|
||||||
|
// Full reversal: restore balance, soft-delete payment, unlink tx
|
||||||
|
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 NULL AND b.user_id = ?
|
||||||
|
`).get(m.payment_id, userId);
|
||||||
|
|
||||||
|
if (payment) {
|
||||||
|
if (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, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
|
||||||
|
}
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE transactions
|
||||||
|
SET match_status = 'unmatched', matched_bill_id = NULL, updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
`).run(txId, userId);
|
||||||
|
results.push({ transaction_id: txId, ok: true });
|
||||||
|
} else {
|
||||||
|
// Standard service unmatch (restores balance for transaction_match payments)
|
||||||
|
unmatchTransaction(userId, String(txId));
|
||||||
|
results.push({ transaction_id: txId, ok: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
results.push({ transaction_id: txId, ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const failed = results.filter(r => !r.ok);
|
||||||
|
if (failed.length > 0 && failed.length === results.length) {
|
||||||
|
return res.status(500).json({ error: 'All unmatches failed', results });
|
||||||
|
}
|
||||||
|
res.json({ results, unmatched: results.filter(r => r.ok).length });
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/transactions/:id/ignore
|
// POST /api/transactions/:id/ignore
|
||||||
router.post('/:id/ignore', (req, res) => {
|
router.post('/:id/ignore', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue