fix(supplier-invoices): guard against duplicate payment when bank tx already booked (#461)
* fix(supplier-invoices): guard against duplicate payment when bank tx already booked
Two-pronged fix for a UX trap where a supplier invoice could be marked paid
even though the bank payment was already booked on 2440, creating a duplicate
verifikation.
Prong A — mark-paid duplicate guard: before booking, scan for an unlinked
outgoing bank transaction matching this supplier (merchant_name ILIKE) within
±2% / ±60 days. If found, return 409 SI_PAID_LIKELY_DUPLICATE with candidates
so the UI can offer "link existing" instead. Override via { force: true }.
Prong B — categorize match suggestion: when the user assigns 2440 directly on
a negative business transaction and an open supplier invoice from the same
supplier covers the same amount, return 409 TX_CATEGORIZE_SUGGEST_SI_MATCH
with candidates and route the user to match-supplier-invoice. Override via
{ confirm_no_match: true }.
Frontend dialogs added on the supplier-invoice detail page and the
transactions inbox. Partial payments skip the mark-paid guard (deliberate
action). Tests cover the 409 path, the override path, and the no-candidates
happy path on both routes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): apply PR review fixes to duplicate-payment guards
- Add `is_business = true` filter to mark-paid candidate query so private
bank withdrawals don't surface as false-positive duplicates
- Escape LIKE wildcards (`%`, `_`, `\`) in both ILIKE patterns to avoid
silent over-matching when a supplier/merchant name contains those chars
- Round paymentAmount and remaining_amount to 2 decimals before the
partial-payment guard comparison to avoid float-equality fragility
- Require credit account to be in the 1xxx (bank/cash) series for the
Prong B 2440 intercept so 2440 against clearing/equity accounts isn't
misinterpreted as a supplier payment
- Extract DUPLICATE_AMOUNT_TOLERANCE_PCT (0.02) and
DUPLICATE_DATE_WINDOW_DAYS (60) into a shared helper module with the
LIKE-escape utility
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): broaden 244x match, audit log overrides, drop JE id from response
Second-round PR review fixes:
- Widen Prong B regex from /^2440$/ to /^244\d$/ so payments mapped to BAS
sub-accounts (e.g. 2441 leverantörsskulder i utländsk valuta) also trigger
the suggestion (swedish-invoice-compliance bot)
- Log a structured warning when force=true or confirm_no_match=true is honored,
with the relevant context (amount, date, accounts) so the override is
traceable per BFNAR 2013:2 kap 8 (behandlingshistorik)
- Drop journal_entry_id from the SI_PAID_LIKELY_DUPLICATE candidate response
payload (data minimization, GDPR Art.5(1)(c)); the UI never rendered it
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): third-round PR review — date window on Prong B, length cap, VAT message
- Add the missing date window to the Prong B categorize candidate query
(swedish-compliance bot): without it, an open invoice from years back can
surface as a "match" for an unrelated bank transaction. Uses the shared
DUPLICATE_DATE_WINDOW_DAYS against invoice_date.
- Cap supplier/merchant names to 200 chars before they enter escapeLikePattern
(OWASP V1.2.5 / ISO A.8.28). Bounds DB work on pathological inputs.
- Log a structured warning when the mark-paid guard is skipped because the
invoice has no resolved supplier name (BFL 5 kap 7 § — motpart should be
identifiable; the absence is itself worth surfacing).
- Update the SI-match suggestion error and the matching UI copy to call out
the actual compliance risk: a duplicate 244x posting double-deducts ingående
moms (ML 8 kap 3 §), not just bookkeeping symmetry.
- Reword "Bokför på 2440 ändå" to "Bokför på leverantörsskulder ändå" now that
the regex covers BAS sub-accounts 244x.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): correct Prong B framing — duplicate verifikation, not VAT double-deduction
Latest swedish-compliance review correctly walked back the earlier
finding that asked for ML 8 kap 3 § VAT framing. Plain 244x
categorization via account_override does not include VAT lines (account
class 2), so the risk is a duplicate verifikation (BFL 5 kap 5 §), not
a double VAT deduction. Update both the structured error message and
the dialog body to reflect the actual mechanism.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
01e99d3220
commit
07a7964e8d
@@ -56,6 +56,15 @@ export default function SupplierInvoiceDetailPage() {
|
||||
const [payAmount, setPayAmount] = useState('')
|
||||
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [duplicateCandidates, setDuplicateCandidates] = useState<
|
||||
Array<{
|
||||
id: string
|
||||
date: string
|
||||
amount: number
|
||||
description: string | null
|
||||
merchant_name: string | null
|
||||
}> | null
|
||||
>(null)
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
||||
|
||||
async function fetchInvoice() {
|
||||
@@ -89,22 +98,28 @@ export default function SupplierInvoiceDetailPage() {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
async function handleMarkPaid() {
|
||||
async function handleMarkPaid(force: boolean = false) {
|
||||
setIsProcessing(true)
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount: parseFloat(payAmount), payment_date: paymentDate }),
|
||||
body: JSON.stringify({ amount: parseFloat(payAmount), payment_date: paymentDate, ...(force ? { force: true } : {}) }),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Betalning misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
|
||||
if (result?.error?.code === 'SI_PAID_LIKELY_DUPLICATE' && Array.isArray(result.error.details?.candidates)) {
|
||||
setDuplicateCandidates(result.error.details.candidates)
|
||||
setIsPayDialogOpen(false)
|
||||
} else {
|
||||
toast({ title: 'Betalning misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad',
|
||||
description: `${formatAmount(parseFloat(payAmount))} kr registrerat`,
|
||||
})
|
||||
setIsPayDialogOpen(false)
|
||||
setDuplicateCandidates(null)
|
||||
fetchInvoice()
|
||||
}
|
||||
setIsProcessing(false)
|
||||
@@ -593,13 +608,68 @@ export default function SupplierInvoiceDetailPage() {
|
||||
<Button variant="outline" onClick={() => setIsPayDialogOpen(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleMarkPaid} disabled={isProcessing}>
|
||||
<Button onClick={() => handleMarkPaid(false)} disabled={isProcessing}>
|
||||
{isProcessing ? 'Bearbetar...' : 'Registrera betalning'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Duplicate-payment warning dialog */}
|
||||
<Dialog
|
||||
open={duplicateCandidates !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDuplicateCandidates(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Möjlig dubbelbetalning</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Vi hittade {duplicateCandidates?.length === 1 ? 'en banktransaktion' : 'banktransaktioner'} som
|
||||
verkar matcha denna betalning. Länka den befintliga transaktionen istället för att skapa en ny
|
||||
verifikation.
|
||||
</p>
|
||||
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
|
||||
{duplicateCandidates?.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium tabular-nums">{formatDate(c.date)}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{c.merchant_name || c.description || 'Banktransaktion'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tabular-nums font-medium">
|
||||
{formatAmount(Math.abs(c.amount))} {invoice.currency}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/transactions?highlight=${c.id}`)}
|
||||
>
|
||||
Gå till
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setDuplicateCandidates(null)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleMarkPaid(true)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Bearbetar...' : 'Skapa ny verifikation ändå'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,6 +115,22 @@ export default function TransactionsPage() {
|
||||
const [quickReviewOpen, setQuickReviewOpen] = useState(false)
|
||||
const [quickReview, setQuickReview] = useState<QuickReviewState | null>(null)
|
||||
|
||||
// Prong B: prompt to match against an open supplier invoice instead of
|
||||
// categorizing direct to 2440. Triggered by a 409 TX_CATEGORIZE_SUGGEST_SI_MATCH.
|
||||
const [siMatchSuggestion, setSiMatchSuggestion] = useState<{
|
||||
transactionId: string
|
||||
retry: () => Promise<string | null>
|
||||
candidates: Array<{
|
||||
supplier_invoice_id: string
|
||||
invoice_number: string
|
||||
invoice_date: string
|
||||
remaining_amount: number
|
||||
currency: string
|
||||
supplier_name: string | null
|
||||
}>
|
||||
} | null>(null)
|
||||
const [siMatchProcessing, setSiMatchProcessing] = useState(false)
|
||||
|
||||
// Entity type for tooltip context
|
||||
const [entityType, setEntityType] = useState<string>('enskild_firma')
|
||||
|
||||
@@ -423,6 +439,20 @@ export default function TransactionsPage() {
|
||||
}, [transactions.length])
|
||||
|
||||
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => {
|
||||
return runCategorize({ id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, confirmNoMatch: false })
|
||||
}
|
||||
|
||||
async function runCategorize(args: {
|
||||
id: string
|
||||
isBusiness: boolean
|
||||
category?: TransactionCategory
|
||||
vatTreatment?: VatTreatment
|
||||
accountOverride?: string
|
||||
templateId?: string
|
||||
inboxItemId?: string
|
||||
confirmNoMatch: boolean
|
||||
}): Promise<string | null> {
|
||||
const { id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, confirmNoMatch } = args
|
||||
try {
|
||||
setProcessingId(id)
|
||||
const response = await fetch(`/api/transactions/${id}/categorize`, {
|
||||
@@ -435,11 +465,27 @@ export default function TransactionsPage() {
|
||||
account_override: accountOverride,
|
||||
template_id: templateId,
|
||||
inbox_item_id: inboxItemId,
|
||||
...(confirmNoMatch ? { confirm_no_match: true } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
if (
|
||||
result?.error?.code === 'TX_CATEGORIZE_SUGGEST_SI_MATCH' &&
|
||||
Array.isArray(result.error.details?.candidates)
|
||||
) {
|
||||
// Prong B: invite the user to match the open supplier invoice
|
||||
// instead of booking a plain 2440 categorization that would later
|
||||
// create a duplicate when they hit "Markera som betald".
|
||||
setSiMatchSuggestion({
|
||||
transactionId: id,
|
||||
retry: () => runCategorize({ ...args, confirmNoMatch: true }),
|
||||
candidates: result.error.details.candidates,
|
||||
})
|
||||
setProcessingId(null)
|
||||
return null
|
||||
}
|
||||
toast({
|
||||
title: 'Kategorisering misslyckades',
|
||||
description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }),
|
||||
@@ -522,6 +568,55 @@ export default function TransactionsPage() {
|
||||
await handleCategorize(id, false, 'private')
|
||||
}
|
||||
|
||||
async function handleMatchSuggestedSupplierInvoice(transactionId: string, supplierInvoiceId: string) {
|
||||
setSiMatchProcessing(true)
|
||||
try {
|
||||
const response = await fetch(`/api/transactions/${transactionId}/match-supplier-invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ supplier_invoice_id: supplierInvoiceId }),
|
||||
})
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: 'Matchning misslyckades',
|
||||
description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSiMatchProcessing(false)
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'Leverantörsfaktura matchad', description: 'Fakturan markerades som betald' })
|
||||
setSiMatchSuggestion(null)
|
||||
setExitingIds((prev) => new Set(prev).add(transactionId))
|
||||
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
|
||||
setTimeout(() => {
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transactionId
|
||||
? {
|
||||
...t,
|
||||
supplier_invoice_id: supplierInvoiceId,
|
||||
is_business: true,
|
||||
journal_entry_id: result.journal_entry_id ?? t.journal_entry_id,
|
||||
}
|
||||
: t
|
||||
)
|
||||
)
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(transactionId)
|
||||
return next
|
||||
})
|
||||
}, 350)
|
||||
} catch {
|
||||
toast({ title: 'Matchning misslyckades', description: 'Försök igen.', variant: 'destructive' })
|
||||
} finally {
|
||||
setSiMatchProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmInvoiceMatch() {
|
||||
if (!selectedTransaction) return
|
||||
const isSupplier = !!selectedTransaction.potential_supplier_invoice
|
||||
@@ -1340,6 +1435,64 @@ export default function TransactionsPage() {
|
||||
onClose={() => setSkvMatchTarget(null)}
|
||||
onMatched={handleSkvMatched}
|
||||
/>
|
||||
|
||||
{/* Prong B: match-against-supplier-invoice suggestion */}
|
||||
<Dialog
|
||||
open={siMatchSuggestion !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSiMatchSuggestion(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Matcha mot leverantörsfaktura?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Det finns en öppen leverantörsfaktura med samma belopp från samma leverantör. Matcha mot
|
||||
fakturan istället för att bokföra direkt på leverantörsskuldskontot, annars skapas en
|
||||
dubblerad verifikation som måste stornas (BFL 5 kap 5 §).
|
||||
</p>
|
||||
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
|
||||
{siMatchSuggestion?.candidates.map((c) => (
|
||||
<div key={c.supplier_invoice_id} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
{c.supplier_name || 'Leverantör'} · {c.invoice_number}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(c.invoice_date)} · kvar {formatCurrency(c.remaining_amount, c.currency)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleMatchSuggestedSupplierInvoice(siMatchSuggestion.transactionId, c.supplier_invoice_id)}
|
||||
disabled={siMatchProcessing}
|
||||
>
|
||||
Matcha
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setSiMatchSuggestion(null)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
const retry = siMatchSuggestion?.retry
|
||||
setSiMatchSuggestion(null)
|
||||
if (retry) await retry()
|
||||
}}
|
||||
disabled={siMatchProcessing}
|
||||
>
|
||||
Bokför på leverantörsskulder ändå
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: no candidate transactions
|
||||
enqueue({ data: [], error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
|
||||
@@ -212,6 +214,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: no candidate transactions
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'cash' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoiceCashEntry.mockResolvedValue({ id: 'je-3' })
|
||||
@@ -249,6 +253,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: no candidate transactions
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoicePaymentEntry.mockRejectedValue(new Error('Period locked'))
|
||||
@@ -264,6 +270,109 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('SI_PAID_FAILED')
|
||||
})
|
||||
|
||||
it('returns 409 SI_PAID_LIKELY_DUPLICATE when an unlinked transaction matches', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
paid_amount: 0,
|
||||
supplier,
|
||||
items: [],
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: one likely-matching unlinked transaction
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'tx-99',
|
||||
date: '2026-05-10',
|
||||
amount: -10000,
|
||||
description: 'Faktura Leverantör AB',
|
||||
merchant_name: 'Leverantör AB',
|
||||
journal_entry_id: 'je-99',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; details: { candidates: unknown[] } } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE')
|
||||
expect(body.error.details.candidates).toHaveLength(1)
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds when force=true even with candidates present', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
paid_amount: 0,
|
||||
supplier,
|
||||
items: [],
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// No candidates query happens because force=true skips it
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'si-1' }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { force: true },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; status: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.status).toBe('paid')
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips duplicate guard on partial payment (amount < remaining)', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
paid_amount: 0,
|
||||
supplier,
|
||||
items: [],
|
||||
})
|
||||
|
||||
// Note: no candidates enqueue — guard is skipped for partial payments
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'si-1' }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { amount: 3000 },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ status: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.status).toBe('partially_paid')
|
||||
})
|
||||
|
||||
it('emits supplier_invoice.paid event', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
@@ -277,6 +386,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: no candidate transactions
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
// Update invoice (CAS guard: returns matched row)
|
||||
|
||||
@@ -10,6 +10,11 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
DUPLICATE_DATE_WINDOW_DAYS,
|
||||
escapeLikePattern,
|
||||
} from '@/lib/invoices/duplicate-payment-guard'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -50,6 +55,73 @@ export const POST = withRouteContext(
|
||||
const paymentAmount = body.amount || invoice.remaining_amount
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (body.force) {
|
||||
opLog.warn('duplicate-payment guard bypassed', {
|
||||
reason: 'force=true',
|
||||
paymentAmount,
|
||||
paymentDate,
|
||||
})
|
||||
}
|
||||
|
||||
// Duplicate-payment guard: if a likely-matching unlinked bank transaction
|
||||
// exists for this supplier, surface it before booking a new payment entry.
|
||||
// Caller can override with `force: true`. Skipped on partial payments —
|
||||
// those are an explicit, deliberate action.
|
||||
const paidRounded = Math.round(paymentAmount * 100) / 100
|
||||
const remainingRounded = Math.round(invoice.remaining_amount * 100) / 100
|
||||
if (!body.force && paidRounded >= remainingRounded) {
|
||||
const supplierName = (invoice as SupplierInvoice & { supplier?: { name?: string } })
|
||||
.supplier?.name
|
||||
if (!supplierName) {
|
||||
// An invoice without a resolved supplier name is arguably *higher* risk
|
||||
// for duplicate booking, not lower (BFL 5 kap 7 § — motpart should be
|
||||
// identifiable). Log the skip so the gap is visible in audit.
|
||||
opLog.warn('duplicate-payment guard skipped', {
|
||||
reason: 'missing_supplier_name',
|
||||
supplierInvoiceId: id,
|
||||
})
|
||||
}
|
||||
if (supplierName) {
|
||||
const windowLow = Math.round(paymentAmount * (1 - DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
const windowHigh = Math.round(paymentAmount * (1 + DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
const dateMs = new Date(paymentDate).getTime()
|
||||
const dateLow = new Date(dateMs - DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().split('T')[0]
|
||||
const dateHigh = new Date(dateMs + DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().split('T')[0]
|
||||
const escapedSupplierName = escapeLikePattern(supplierName)
|
||||
|
||||
const { data: candidates } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, description, merchant_name')
|
||||
.eq('company_id', companyId!)
|
||||
.eq('is_business', true)
|
||||
.is('supplier_invoice_id', null)
|
||||
.is('invoice_id', null)
|
||||
.lt('amount', 0)
|
||||
.gte('amount', -windowHigh)
|
||||
.lte('amount', -windowLow)
|
||||
.gte('date', dateLow)
|
||||
.lte('date', dateHigh)
|
||||
.ilike('merchant_name', `%${escapedSupplierName}%`)
|
||||
.order('date', { ascending: false })
|
||||
.limit(5)
|
||||
|
||||
if (candidates && candidates.length > 0) {
|
||||
return errorResponseFromCode('SI_PAID_LIKELY_DUPLICATE', opLog, {
|
||||
requestId,
|
||||
details: {
|
||||
candidates: candidates.map((c) => ({
|
||||
id: c.id,
|
||||
date: c.date,
|
||||
amount: c.amount,
|
||||
description: c.description,
|
||||
merchant_name: c.merchant_name,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
|
||||
@@ -271,6 +271,130 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 409 TX_CATEGORIZE_SUGGEST_SI_MATCH when 2440 mapping matches an open supplier invoice', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -10000,
|
||||
merchant_name: 'Leverantör AB',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '2440',
|
||||
})
|
||||
|
||||
// Prong B: supplier lookup
|
||||
enqueue({ data: [{ id: 'sup-1' }], error: null })
|
||||
// Open supplier invoices candidate query
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'si-1',
|
||||
supplier_invoice_number: 'INV-2026-0042',
|
||||
invoice_date: '2026-05-01',
|
||||
remaining_amount: 10000,
|
||||
currency: 'SEK',
|
||||
supplier: { name: 'Leverantör AB' },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; details: { candidates: unknown[] } } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_SI_MATCH')
|
||||
expect(body.error.details.candidates).toHaveLength(1)
|
||||
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds with 2440 categorization when confirm_no_match=true', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -10000,
|
||||
merchant_name: 'Leverantör AB',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '2440',
|
||||
})
|
||||
|
||||
// No supplier/invoice lookups happen because confirm_no_match=true skips the block
|
||||
// ensureFiscalPeriod
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
// Transaction update
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software', confirm_no_match: true },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_created: boolean
|
||||
journal_entry_id: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_created).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
})
|
||||
|
||||
it('does not trigger SI suggestion when 2440 has no matching open supplier invoice', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -10000,
|
||||
merchant_name: 'Leverantör AB',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '2440',
|
||||
})
|
||||
|
||||
// Supplier lookup returns a supplier
|
||||
enqueue({ data: [{ id: 'sup-1' }], error: null })
|
||||
// No open invoices in the amount window
|
||||
enqueue({ data: [], error: null })
|
||||
// ensureFiscalPeriod
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
// Transaction update
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_created: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_created).toBe(true)
|
||||
})
|
||||
|
||||
it('categorizes as private when is_business is false', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
|
||||
@@ -9,6 +9,11 @@ import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
DUPLICATE_DATE_WINDOW_DAYS,
|
||||
escapeLikePattern,
|
||||
} from '@/lib/invoices/duplicate-payment-guard'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
@@ -262,6 +267,86 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
if (body.confirm_no_match && /^244\d$/.test(mappingResult.debit_account)) {
|
||||
txLog.warn('supplier-invoice match suggestion bypassed', {
|
||||
reason: 'confirm_no_match=true',
|
||||
debitAccount: mappingResult.debit_account,
|
||||
creditAccount: mappingResult.credit_account,
|
||||
})
|
||||
}
|
||||
|
||||
// Prong B: intercept plain 244x categorization of supplier payments when
|
||||
// an open supplier invoice already covers this amount. Categorizing direct
|
||||
// to 244x leaves the invoice with status='approved' and lures the user
|
||||
// into a duplicate "Markera som betald" later. Credit must be a bank/cash
|
||||
// account (1xxx) — 244x against a clearing account, equity, etc. isn't a
|
||||
// supplier payment and the suggestion would misdirect the user.
|
||||
if (
|
||||
!body.confirm_no_match &&
|
||||
is_business &&
|
||||
transaction.amount < 0 &&
|
||||
/^244\d$/.test(mappingResult.debit_account) &&
|
||||
/^1\d{3}$/.test(mappingResult.credit_account)
|
||||
) {
|
||||
const txAmountAbs = Math.abs(transaction.amount)
|
||||
const windowLow = Math.round(txAmountAbs * (1 - DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
const windowHigh = Math.round(txAmountAbs * (1 + DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
|
||||
let supplierIds: string[] = []
|
||||
if (transaction.merchant_name) {
|
||||
const escapedMerchant = escapeLikePattern(transaction.merchant_name)
|
||||
const { data: matchedSuppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.ilike('name', `%${escapedMerchant}%`)
|
||||
.limit(10)
|
||||
supplierIds = (matchedSuppliers || []).map((s) => s.id)
|
||||
}
|
||||
|
||||
if (supplierIds.length > 0) {
|
||||
// Restrict candidates to invoices within the date window relative to
|
||||
// the bank tx date. Without this, an open invoice from years back can
|
||||
// surface as a match and misdirect the user (swedish-compliance bot).
|
||||
const txDateMs = new Date(transaction.date).getTime()
|
||||
const invoiceDateLow = new Date(txDateMs - DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
const invoiceDateHigh = new Date(txDateMs + DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
const { data: openInvoices } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number, invoice_date, remaining_amount, currency, supplier:suppliers(name)')
|
||||
.eq('company_id', companyId)
|
||||
.in('supplier_id', supplierIds)
|
||||
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
|
||||
.gte('remaining_amount', windowLow)
|
||||
.lte('remaining_amount', windowHigh)
|
||||
.gte('invoice_date', invoiceDateLow)
|
||||
.lte('invoice_date', invoiceDateHigh)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.limit(5)
|
||||
|
||||
if (openInvoices && openInvoices.length > 0) {
|
||||
return errorResponseFromCode('TX_CATEGORIZE_SUGGEST_SI_MATCH', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
candidates: openInvoices.map((inv) => ({
|
||||
supplier_invoice_id: inv.id,
|
||||
invoice_number: inv.supplier_invoice_number,
|
||||
invoice_date: inv.invoice_date,
|
||||
remaining_amount: inv.remaining_amount,
|
||||
currency: inv.currency,
|
||||
supplier_name: (inv.supplier as { name?: string } | null)?.name ?? null,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ensureFiscalPeriod(supabase, user.id, companyId, transaction.date, fiscalYearStartMonth, txLog)
|
||||
|
||||
let journalEntryCreated = false
|
||||
|
||||
@@ -271,6 +271,7 @@ export const MarkSupplierInvoicePaidSchema = z.object({
|
||||
payment_date: isoDate.optional(),
|
||||
exchange_rate_difference: z.number().optional(),
|
||||
notes: z.string().optional(),
|
||||
force: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const UpdateSupplierInvoiceSchema = z.object({
|
||||
@@ -327,6 +328,7 @@ export const CategorizeTransactionSchema = z.object({
|
||||
counterparty_template_id: z.string().uuid().optional(),
|
||||
user_description: z.string().max(500).optional(),
|
||||
inbox_item_id: z.string().uuid().optional(),
|
||||
confirm_no_match: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const BookTransactionSchema = z.object({
|
||||
|
||||
@@ -269,6 +269,17 @@ const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Transaktionen kategoriserades av en annan förfrågan. Ladda om och försök igen.',
|
||||
message_en: 'Transaction was already categorized by another request.',
|
||||
},
|
||||
TX_CATEGORIZE_SUGGEST_SI_MATCH: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Det finns en öppen leverantörsfaktura från samma leverantör med samma belopp. Matcha mot fakturan istället för att bokföra direkt på leverantörsskuldskontot — annars skapas en dubblerad verifikation som måste stornas (BFL 5 kap 5 §).',
|
||||
message_en:
|
||||
'An open supplier invoice from the same supplier matches this amount. Suggest matching to the invoice instead of a plain 244x categorization to avoid producing a duplicate verifikation (BFL 5 kap 5 §).',
|
||||
remediation: {
|
||||
description:
|
||||
'Match the transaction via POST /api/transactions/{id}/match-supplier-invoice, or resend with confirm_no_match: true to keep the plain 244x categorization.',
|
||||
},
|
||||
},
|
||||
TX_UNCATEGORIZE_NO_LINKED_ENTRY: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Transaktionen har ingen kopplad verifikation att stornera.',
|
||||
@@ -1129,6 +1140,17 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Kunde inte registrera betalningen.',
|
||||
message_en: 'Failed to record supplier invoice payment.',
|
||||
},
|
||||
SI_PAID_LIKELY_DUPLICATE: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Det finns redan en obokförd banktransaktion som kan vara denna betalning. Länka den istället, eller markera som betald ändå om du är säker.',
|
||||
message_en:
|
||||
'A likely-matching unlinked bank transaction was found for this supplier. Suggest linking it instead of creating a new payment entry.',
|
||||
remediation: {
|
||||
description:
|
||||
'Match the candidate transaction via POST /api/transactions/{id}/match-supplier-invoice, or resend mark-paid with force: true to create the payment entry anyway.',
|
||||
},
|
||||
},
|
||||
SI_CREDIT_ALREADY_CREDITED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Leverantörsfakturan har redan krediterats.',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared constants and helpers for the duplicate-payment / SI-match guards
|
||||
* used by `/api/supplier-invoices/[id]/mark-paid` and
|
||||
* `/api/transactions/[id]/categorize`. Both guards look for a likely-matching
|
||||
* counterparty within a fuzzy amount + date window; keeping the thresholds in
|
||||
* one place makes them tunable as we learn from real false-positive rates.
|
||||
*/
|
||||
|
||||
/** Acceptable amount drift (±) when matching a bank tx to an invoice amount. */
|
||||
export const DUPLICATE_AMOUNT_TOLERANCE_PCT = 0.02
|
||||
|
||||
/** Date window (±days) around the payment / invoice date. */
|
||||
export const DUPLICATE_DATE_WINDOW_DAYS = 60
|
||||
|
||||
/** Cap on supplier / merchant names before they enter an ILIKE pattern, to
|
||||
* bound query work and avoid pathological inputs degrading the index scan. */
|
||||
const MAX_LIKE_NEEDLE_LENGTH = 200
|
||||
|
||||
/**
|
||||
* Escape LIKE/ILIKE wildcards (`%`, `_`, `\`) and truncate to a safe length
|
||||
* before embedding the value in an ILIKE pattern. SQL-injection is already
|
||||
* handled by Supabase's parameterization; this purely prevents silent
|
||||
* over-matching on names like "50% Off AB" and bounds DB work on long inputs.
|
||||
*/
|
||||
export function escapeLikePattern(value: string): string {
|
||||
const truncated = value.length > MAX_LIKE_NEEDLE_LENGTH
|
||||
? value.slice(0, MAX_LIKE_NEEDLE_LENGTH)
|
||||
: value
|
||||
return truncated.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
|
||||
}
|
||||
Reference in New Issue
Block a user