feat(transactions): match overshoot guards + supplier voucher linking (#602)
* feat(transactions): match overshoot guards + supplier voucher linking
Three changes that together close the "I can't link a bank transaction
to an already-booked verifikat on the supplier side" gap and fix a
latent data-corruption bug on the per-tx match endpoints.
1. fix: clamp paid_amount on match endpoints when tx > remaining
/api/transactions/[id]/match-{invoice,supplier-invoice} previously
used transaction.amount wholesale as the paid amount, pushing
invoice.paid_amount past invoice.total whenever the bank tx was
larger than what was owed. Both endpoints now reject with
MATCH_AMOUNT_EXCEEDS_REMAINING / MATCH_SI_AMOUNT_EXCEEDS_REMAINING
and a structured { transaction_amount, remaining_amount, excess }
payload that points the user at the future split-payment flow.
FX branch already clamps to invoice.remaining_amount and is
unchanged.
2. feat: supplier-side "link existing verifikat" (mirror of #591)
lib/invoices/supplier-voucher-matching.ts mirrors the customer
voucher-matching module: finds posted JEs that debit 2440
(Leverantörsskulder), validates currency + remaining-amount, and
atomically links them as supplier_invoice_payments rows. New
/api/supplier-invoices/[id]/{voucher-candidates,link-to-voucher}
routes wrap it. LinkVoucherPicker gains a mode='supplier_invoice'
prop so the same component renders both flows. The supplier-invoice
mark-paid dialog now uses Tabs ("Ny betalning" / "Befintlig
verifikation") to match the customer-side UX.
3. infra: transaction_voucher_links junction + denorm guard
Foundation migration for upcoming multi-tx ↔ multi-voucher flows.
Adds the junction table (with RLS, updated_at, indexes), a
block_contradictory_invoice_denorm trigger on transactions that
refuses to set invoice_id/supplier_invoice_id to a value that
contradicts an existing payment row, and is_transaction_booked(uuid)
as a single source of truth for "is this tx anchored?" once
multi-allocation leaves denorm columns NULL. No application code
uses these yet — they unlock the batch allocation and bulk-book
flows in follow-up PRs.
Tests: 98 unit tests pass across the touched paths (match-invoice,
match-supplier-invoice, supplier-voucher-matching, link-to-voucher).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): PR review — atomic link RPC, computeRemaining edge case, pg-real tests
Addresses the three real issues raised by Greptile on PR #602.
1. (P1) Atomic supplier voucher linking — new
link_supplier_invoice_to_voucher PL/pgSQL RPC. The TS-side
linkSupplierInvoiceToVoucher() previously did UPDATE-then-INSERT with
a manual unconditional rollback. Under concurrent linking against the
same invoice, request A's rollback could overwrite a sibling B's
successful write while leaving B's payment row in place. Moving both
writes into a single PG transaction (one RPC call) lets PG's own
rollback handle the failure path correctly. TS wrapper now just
translates the structured RPC return into the lib's Result type.
2. (P1) pg-real tests — tests/pg/transaction_voucher_links.pg.test.ts.
CLAUDE.md mandates *.pg.test.ts for any PR adding a trigger, RPC, or
RLS. The Phase 1A foundation migration added all three but had no
pg-real coverage. Tests now cover:
- trg_block_contradictory_invoice_denorm refusing contradictory
UPDATEs on invoice_id and supplier_invoice_id
- the same trigger PERMITTING a matching UPDATE (no false positives)
- is_transaction_booked() returning true via journal_entry_id, via
invoice_payments, and via transaction_voucher_links rows.
3. (P2) computeRemaining edge case — trust remaining_amount whenever
the column is non-null (including the legitimate 0 for fully-paid
invoices). The old "> 0" guard fell through to total - paid_amount,
which under rounding drift could compute a tiny positive residue and
slip a fully-paid invoice past LINK_SI_VOUCHER_INVOICE_FULLY_PAID.
The fourth Greptile comment (overdue invoices silently get no
candidates) was a misread: 'overdue' IS in the open-state list at
route.ts:35. No code change needed there.
Tests: 100 unit tests pass (16 in the directly-touched paths).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): PR review round 2 — broaden AP range, log event failures
Addresses the actionable findings from the compliance-swarm and
Swedish-accounting-compliance bot reviews on PR #602.
1. (swedish-accounting-compliance, high) AP account hardcoded to 2440
rejected legitimate samlingsverifikationer that debit 2441
(Leverantörsskulder i utländsk valuta), 2443 (Skuldfakturor), etc.
BAS 2026 reserves the full 2440–2449 range for Leverantörsskulder.
The TS-side AP_ACCOUNT constant becomes AP_ACCOUNT_PREFIX ('244')
used with .like() and .startsWith(). The PL/pgSQL RPC's
account_number filter becomes LIKE '244%'. The
LINK_SI_VOUCHER_NO_AP_DEBIT error message updates to reference the
244x range with examples.
2. (ISO 27001:2022 A.8.15 / OWASP V16) Empty catch on the
supplier_invoice.paid event emission now logs with log.warn so a
failure in the downstream reminder/audit subscriber leaves an
auditable trail without blocking the response.
3. (GDPR Art.5(1)(c)) Documented design rationale for retaining
select('*') on the post-link invoice re-fetch: the
supplier_invoice.paid event payload is typed as
`supplierInvoice: SupplierInvoice` in lib/events/types.ts, narrowing
would break the subscriber contract. The event stays in-process
and consumers legitimately need the full context.
Skipped findings:
- V8.2.1 ownership concerns: route + RPC already filter by
company_id from withRouteContext; the RPC's WHERE clause covers it.
- DELETE policy scoping: matches the gnubok pattern across all
company-scoped tables — any member with write access manages records.
- transaction_id = NULL on the voucher-link path: by design — the
flow has no bank tx (the voucher's 1930 line represents it).
- Reverse-charge VAT (2614/2647) validation on linked vouchers:
real concern but invasive change; tracked for follow-up.
- Storno-chain integrity (linking the original of a storno pair):
edge case; tracked for follow-up.
Tests: 26 unit tests pass in the directly-touched paths. RPC patch
applied to remote via Supabase MCP.
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:
@@ -10,10 +10,12 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info } from 'lucide-react'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
@@ -45,6 +47,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
|
||||
const [payTab, setPayTab] = useState<'new' | 'existing'>('new')
|
||||
const [payAmount, setPayAmount] = useState('')
|
||||
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
@@ -586,45 +589,72 @@ export default function SupplierInvoiceDetailPage() {
|
||||
<DestructiveConfirmDialog {...confirmDialogProps} />
|
||||
|
||||
{/* Pay Dialog */}
|
||||
<Dialog open={isPayDialogOpen} onOpenChange={setIsPayDialogOpen}>
|
||||
<Dialog
|
||||
open={isPayDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsPayDialogOpen(open)
|
||||
if (!open) setPayTab('new')
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('pay_dialog_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-date">{t('payment_date_label')}</Label>
|
||||
<Input
|
||||
id="payment-date"
|
||||
type="date"
|
||||
value={paymentDate}
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
onChange={(e) => setPaymentDate(e.target.value)}
|
||||
className="w-full sm:w-48"
|
||||
<Tabs value={payTab} onValueChange={(v) => setPayTab(v as 'new' | 'existing')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="new">{t('tab_new_payment')}</TabsTrigger>
|
||||
<TabsTrigger value="existing">{t('tab_existing_voucher')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="new" className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-date">{t('payment_date_label')}</Label>
|
||||
<Input
|
||||
id="payment-date"
|
||||
type="date"
|
||||
value={paymentDate}
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
onChange={(e) => setPaymentDate(e.target.value)}
|
||||
className="w-full sm:w-48"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-amount">{t('payment_amount_label')}</Label>
|
||||
<Input
|
||||
id="payment-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={payAmount}
|
||||
onChange={(e) => setPayAmount(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('remaining_to_pay', { amount: formatAmount(invoice.remaining_amount), currency: invoice.currency })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsPayDialogOpen(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => handleMarkPaid(false)} disabled={isProcessing}>
|
||||
{isProcessing ? t('processing') : t('register_payment')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="existing" className="mt-4">
|
||||
<LinkVoucherPicker
|
||||
mode="supplier_invoice"
|
||||
invoiceId={invoice.id}
|
||||
invoiceCurrency={invoice.currency}
|
||||
onLinked={() => {
|
||||
setIsPayDialogOpen(false)
|
||||
setPayTab('new')
|
||||
fetchInvoice()
|
||||
}}
|
||||
onCancel={() => setPayTab('new')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-amount">{t('payment_amount_label')}</Label>
|
||||
<Input
|
||||
id="payment-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={payAmount}
|
||||
onChange={(e) => setPayAmount(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('remaining_to_pay', { amount: formatAmount(invoice.remaining_amount), currency: invoice.currency })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsPayDialogOpen(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => handleMarkPaid(false)} disabled={isProcessing}>
|
||||
{isProcessing ? t('processing') : t('register_payment')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const mockLink = vi.fn()
|
||||
vi.mock('@/lib/invoices/supplier-voucher-matching', () => ({
|
||||
linkSupplierInvoiceToVoucher: (...args: unknown[]) => mockLink(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const VALID_JE_UUID = '550e8400-e29b-41d4-a716-446655440001'
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
describe('POST /api/supplier-invoices/[id]/link-to-voucher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 400 when journal_entry_id is missing', async () => {
|
||||
const request = createMockRequest(`/api/supplier-invoices/${VALID_UUID}/link-to-voucher`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: VALID_UUID }))
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 200 with the linked payment payload on success', async () => {
|
||||
mockLink.mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
paymentId: 'sip-1',
|
||||
invoiceStatus: 'paid',
|
||||
paidAmount: 1000,
|
||||
remainingAmount: 0,
|
||||
paymentAmount: 1000,
|
||||
journalEntryId: VALID_JE_UUID,
|
||||
},
|
||||
})
|
||||
|
||||
const request = createMockRequest(`/api/supplier-invoices/${VALID_UUID}/link-to-voucher`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: VALID_JE_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: VALID_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: {
|
||||
invoice_status: string
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
payment_amount: number
|
||||
payment_id: string
|
||||
journal_entry_id: string
|
||||
}
|
||||
}>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.invoice_status).toBe('paid')
|
||||
expect(body.data.paid_amount).toBe(1000)
|
||||
expect(body.data.remaining_amount).toBe(0)
|
||||
expect(body.data.payment_id).toBe('sip-1')
|
||||
expect(body.data.journal_entry_id).toBe(VALID_JE_UUID)
|
||||
})
|
||||
|
||||
it('maps a structured failure code to the correct HTTP status', async () => {
|
||||
mockLink.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'LINK_SI_VOUCHER_NO_AP_DEBIT',
|
||||
details: { source_type: 'opening_balance' },
|
||||
})
|
||||
|
||||
const request = createMockRequest(`/api/supplier-invoices/${VALID_UUID}/link-to-voucher`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: VALID_JE_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: VALID_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('LINK_SI_VOUCHER_NO_AP_DEBIT')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { LinkSupplierInvoiceToVoucherSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/supplier-invoices/[id]/link-to-voucher
|
||||
*
|
||||
* Marks a supplier invoice as paid (or partially paid) by linking an existing
|
||||
* posted verifikat whose lines already debit AP (2440). Creates no new journal
|
||||
* entry — only a supplier_invoice_payments row + invoice status advance.
|
||||
*
|
||||
* Rejects with LINK_SI_VOUCHER_NO_AP_DEBIT for vouchers that book the expense
|
||||
* directly without going through 2440 — those require gnubok_correct_entry first.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'supplier_invoice.link_to_voucher',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ supplierInvoiceId: id })
|
||||
|
||||
const validation = await validateBody(request, LinkSupplierInvoiceToVoucherSchema, {
|
||||
log: opLog,
|
||||
operation: 'supplier_invoice.link_to_voucher',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { journal_entry_id, notes } = validation.data
|
||||
|
||||
const outcome = await linkSupplierInvoiceToVoucher(supabase, user.id, companyId, {
|
||||
supplierInvoiceId: id,
|
||||
journalEntryId: journal_entry_id,
|
||||
notes,
|
||||
})
|
||||
|
||||
if (!outcome.ok) {
|
||||
return errorResponseFromCode(outcome.code, opLog, {
|
||||
requestId,
|
||||
details: outcome.details,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
invoice_status: outcome.result.invoiceStatus,
|
||||
paid_amount: outcome.result.paidAmount,
|
||||
remaining_amount: outcome.result.remainingAmount,
|
||||
payment_amount: outcome.result.paymentAmount,
|
||||
payment_id: outcome.result.paymentId,
|
||||
journal_entry_id: outcome.result.journalEntryId,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { findMatchingVouchersForSupplierInvoice } from '@/lib/invoices/supplier-voucher-matching'
|
||||
import type { Supplier, SupplierInvoice } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/supplier-invoices/[id]/voucher-candidates
|
||||
*
|
||||
* Returns posted verifikat candidates that could be linked as payment for
|
||||
* this supplier invoice. Used by the "Befintlig verifikation" tab in the
|
||||
* supplier-invoice mark-paid dialog to auto-suggest matches.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'supplier_invoice.voucher_candidates',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
// Project only the fields the matcher actually reads. Avoids leaking the
|
||||
// full supplier row (bank details, contact info, etc.) into the response.
|
||||
const { data: invoice, error } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select(
|
||||
'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, supplier:suppliers(id, name)',
|
||||
)
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error || !invoice) {
|
||||
return errorResponseFromCode('LINK_SI_VOUCHER_INVOICE_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
if (!['registered', 'approved', 'overdue', 'partially_paid'].includes(invoice.status)) {
|
||||
return NextResponse.json({ data: { candidates: [], invoice_status: invoice.status } })
|
||||
}
|
||||
|
||||
const candidates = await findMatchingVouchersForSupplierInvoice(
|
||||
supabase,
|
||||
companyId,
|
||||
// The narrow projection above means TS infers `supplier` as `{ id, name }[]`
|
||||
// from the join shorthand. The matcher only reads `supplier?.name`, so
|
||||
// cast through unknown to the runtime shape it expects.
|
||||
invoice as unknown as SupplierInvoice & { supplier?: Supplier },
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: { candidates } })
|
||||
},
|
||||
)
|
||||
@@ -393,6 +393,40 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
expect(body.remaining_amount).toBe(7500)
|
||||
})
|
||||
|
||||
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when tx amount exceeds invoice remaining', async () => {
|
||||
// Tx is +12 000 SEK, invoice has 5 000 SEK remaining. Legacy code path
|
||||
// would push paid_amount past invoice.total; the new guard rejects so
|
||||
// the user routes the excess through the split-payment flow.
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12000, invoice_id: null, date: '2024-06-15' })
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'partially_paid',
|
||||
total: 10000,
|
||||
remaining_amount: 5000,
|
||||
paid_amount: 5000,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Hard-duplicate check is skipped for partially_paid status — no enqueue needed.
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: unknown }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect((body.error as unknown as { code: string }).code).toBe(
|
||||
'MATCH_AMOUNT_EXCEEDS_REMAINING',
|
||||
)
|
||||
const details = (body.error as unknown as { details: Record<string, number> }).details
|
||||
expect(details.transaction_amount).toBe(12000)
|
||||
expect(details.remaining_amount).toBe(5000)
|
||||
expect(details.excess).toBe(7000)
|
||||
})
|
||||
|
||||
it('cash method partial payment uses clearing entry with note', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 5000, invoice_id: null, date: '2024-06-15' })
|
||||
const invoice = makeInvoice({
|
||||
|
||||
@@ -219,8 +219,25 @@ export const POST = withRouteContext(
|
||||
const now = new Date().toISOString()
|
||||
const paidAmount = transaction.amount
|
||||
|
||||
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
|
||||
const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0))
|
||||
|
||||
// Overshoot guard: the single-tx match endpoint always books tx.amount in
|
||||
// full against the invoice. If tx > remaining the legacy code path would
|
||||
// push invoice.paid_amount past invoice.total — silently. Reject and
|
||||
// point the user at the split-payment flow which can allocate the excess
|
||||
// across additional invoices.
|
||||
if (paidAmount > currentRemaining + 0.005) {
|
||||
return errorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
transaction_amount: paidAmount,
|
||||
remaining_amount: Math.round(currentRemaining * 100) / 100,
|
||||
excess: Math.round((paidAmount - currentRemaining) * 100) / 100,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
|
||||
const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100)
|
||||
const isFullyPaid = newRemaining <= 0
|
||||
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||||
|
||||
@@ -195,4 +195,56 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — non-FX paths',
|
||||
expect(body.paid_amount).toBe(1000)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 400 MATCH_SI_AMOUNT_EXCEEDS_REMAINING when tx exceeds invoice remaining (same currency)', async () => {
|
||||
// Tx pays out 6 000 SEK, invoice has 5 000 SEK remaining. Legacy code path
|
||||
// would push paid_amount past invoice.total. The new guard rejects so the
|
||||
// user routes the excess through the split-payment flow.
|
||||
enqueue({
|
||||
data: {
|
||||
id: TX_UUID,
|
||||
company_id: 'company-1',
|
||||
amount: -6000,
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
supplier_invoice_id: null,
|
||||
date: '2026-05-12',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: SI_UUID,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
status: 'registered',
|
||||
remaining_amount: 5000,
|
||||
paid_amount: 0,
|
||||
supplier: { supplier_type: 'swedish_business' },
|
||||
items: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: unknown }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect((body.error as { code: string }).code).toBe('MATCH_SI_AMOUNT_EXCEEDS_REMAINING')
|
||||
const details = (body.error as { details: Record<string, number> }).details
|
||||
expect(details.transaction_amount).toBe(6000)
|
||||
expect(details.remaining_amount).toBe(5000)
|
||||
expect(details.excess).toBe(1000)
|
||||
})
|
||||
|
||||
it('does NOT trigger overshoot guard on currency mismatch (FX path clamps to remaining)', async () => {
|
||||
// SEK transaction paying a EUR invoice. The currency-mismatch branch
|
||||
// collapses paymentAmountInvoiceCurrency to invoice.remaining_amount and
|
||||
// cannot overshoot, so the guard must not fire here.
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -10000, currency: 'SEK' },
|
||||
invoice: { currency: 'EUR', remaining_amount: 200, exchange_rate: 11.5 },
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,6 +81,27 @@ export const POST = withRouteContext(
|
||||
|
||||
const txAmountAbs = Math.abs(transaction.amount)
|
||||
|
||||
// Overshoot guard for the same-currency branch. The legacy code path used
|
||||
// txAmountAbs wholesale and would push supplier_invoices.paid_amount past
|
||||
// invoice.total whenever the bank transaction was larger than what was
|
||||
// owed. Reject and direct the user at the split-payment flow which can
|
||||
// allocate the excess to additional supplier invoices.
|
||||
// FX branch (currency mismatch) is already clamped below to
|
||||
// invoice.remaining_amount, so it cannot overshoot.
|
||||
if (
|
||||
transaction.currency === invoice.currency &&
|
||||
txAmountAbs > invoice.remaining_amount + 0.005
|
||||
) {
|
||||
return errorResponseFromCode('MATCH_SI_AMOUNT_EXCEEDS_REMAINING', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
transaction_amount: txAmountAbs,
|
||||
remaining_amount: Math.round(invoice.remaining_amount * 100) / 100,
|
||||
excess: Math.round((txAmountAbs - invoice.remaining_amount) * 100) / 100,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Amount in the *invoice's* currency — used to update
|
||||
// supplier_invoices.paid_amount/remaining_amount and the
|
||||
// supplier_invoice_payments row (whose `currency` is the invoice's).
|
||||
|
||||
@@ -17,19 +17,36 @@ interface VoucherCandidate {
|
||||
voucher_number: number | null
|
||||
entry_date: string
|
||||
description: string
|
||||
ar_credit_amount: number
|
||||
// Customer side returns ar_credit_amount; supplier side returns ap_debit_amount.
|
||||
// The picker treats them interchangeably — same UX, opposite sign convention.
|
||||
ar_credit_amount?: number
|
||||
ap_debit_amount?: number
|
||||
currency: string
|
||||
ar_line_currency: string | null
|
||||
ar_line_currency?: string | null
|
||||
ap_line_currency?: string | null
|
||||
period_locked: boolean
|
||||
confidence: number
|
||||
match_reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Linking mode determines which API surface the picker hits and which side
|
||||
* of the BAS chart the candidates are searched against (151x credits vs 2440
|
||||
* debits). The user-facing UX is identical; only the data path differs.
|
||||
*/
|
||||
export type VoucherPickerMode = 'customer_invoice' | 'supplier_invoice'
|
||||
|
||||
interface LinkVoucherPickerProps {
|
||||
invoiceId: string
|
||||
invoiceCurrency: string
|
||||
onLinked: () => void
|
||||
onCancel: () => void
|
||||
/** Defaults to 'customer_invoice' for back-compat with existing call sites. */
|
||||
mode?: VoucherPickerMode
|
||||
}
|
||||
|
||||
function candidateAmount(c: VoucherCandidate): number {
|
||||
return c.ar_credit_amount ?? c.ap_debit_amount ?? 0
|
||||
}
|
||||
|
||||
function voucherLabel(c: VoucherCandidate): string {
|
||||
@@ -54,10 +71,18 @@ export default function LinkVoucherPicker({
|
||||
invoiceCurrency,
|
||||
onLinked,
|
||||
onCancel,
|
||||
mode = 'customer_invoice',
|
||||
}: LinkVoucherPickerProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('invoice_link_voucher')
|
||||
|
||||
const apiBase =
|
||||
mode === 'supplier_invoice'
|
||||
? `/api/supplier-invoices/${invoiceId}`
|
||||
: `/api/invoices/${invoiceId}`
|
||||
const errorContext: 'invoice' | 'supplier_invoice' =
|
||||
mode === 'supplier_invoice' ? 'supplier_invoice' : 'invoice'
|
||||
|
||||
const [candidates, setCandidates] = useState<VoucherCandidate[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -69,7 +94,7 @@ export default function LinkVoucherPicker({
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoiceId}/voucher-candidates`)
|
||||
const response = await fetch(`${apiBase}/voucher-candidates`)
|
||||
if (!response.ok) {
|
||||
if (cancelled) return
|
||||
setCandidates([])
|
||||
@@ -88,7 +113,7 @@ export default function LinkVoucherPicker({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [invoiceId])
|
||||
}, [apiBase])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!candidates) return [] as VoucherCandidate[]
|
||||
@@ -110,7 +135,7 @@ export default function LinkVoucherPicker({
|
||||
if (!selected) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoiceId}/link-to-voucher`, {
|
||||
const response = await fetch(`${apiBase}/link-to-voucher`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: selected.journal_entry_id }),
|
||||
@@ -120,7 +145,7 @@ export default function LinkVoucherPicker({
|
||||
toast({
|
||||
title: t('link_failed_title'),
|
||||
description: getErrorMessage(body, {
|
||||
context: 'invoice',
|
||||
context: errorContext,
|
||||
statusCode: response.status,
|
||||
}),
|
||||
variant: 'destructive',
|
||||
@@ -132,7 +157,7 @@ export default function LinkVoucherPicker({
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('link_failed_title'),
|
||||
description: getErrorMessage(err, { context: 'invoice' }),
|
||||
description: getErrorMessage(err, { context: errorContext }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -199,7 +224,7 @@ export default function LinkVoucherPicker({
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-sm font-medium tabular-nums">
|
||||
{formatCurrency(c.ar_credit_amount, invoiceCurrency)}
|
||||
{formatCurrency(candidateAmount(c), invoiceCurrency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,7 +240,7 @@ export default function LinkVoucherPicker({
|
||||
<p className="text-sm">
|
||||
{t('confirmation', {
|
||||
voucher: voucherLabel(selected),
|
||||
amount: formatCurrency(selected.ar_credit_amount, invoiceCurrency),
|
||||
amount: formatCurrency(candidateAmount(selected), invoiceCurrency),
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('no_new_je_note')}</p>
|
||||
|
||||
@@ -508,6 +508,16 @@ export const LinkInvoiceToVoucherSchema = z.object({
|
||||
notes: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Supplier-invoice mirror: link an existing posted verifikat as payment for a
|
||||
* supplier invoice. No new JE — only a supplier_invoice_payments row pointing
|
||||
* at the supplied journal_entry_id, plus the invoice's paid/remaining advance.
|
||||
*/
|
||||
export const LinkSupplierInvoiceToVoucherSchema = z.object({
|
||||
journal_entry_id: uuid,
|
||||
notes: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
export const LinkTransactionJournalEntrySchema = z.object({
|
||||
journal_entry_id: uuid,
|
||||
// Optional invoice to settle alongside the link. When provided, the
|
||||
|
||||
@@ -394,6 +394,13 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'The candidate journal entry echoed in expected_journal_entry_id does not match the one detected at request time. Re-run the duplicate-payment pre-flight to obtain the current candidate, then retry.',
|
||||
},
|
||||
MATCH_AMOUNT_EXCEEDS_REMAINING: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Transaktionsbeloppet är större än fakturans återstående belopp. Dela betalningen och fördela överskottet på en eller flera andra fakturor.',
|
||||
message_en:
|
||||
'Transaction amount exceeds the invoice remaining amount. Use the split-payment flow to allocate the excess across one or more other invoices.',
|
||||
},
|
||||
}
|
||||
|
||||
const LINK_TX_JE: Record<string, StructuredErrorEntry> = {
|
||||
@@ -479,6 +486,13 @@ const MATCH_SI: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.',
|
||||
},
|
||||
MATCH_SI_AMOUNT_EXCEEDS_REMAINING: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Transaktionsbeloppet är större än leverantörsfakturans återstående belopp. Dela betalningen och fördela överskottet på en eller flera andra leverantörsfakturor.',
|
||||
message_en:
|
||||
'Transaction amount exceeds the supplier invoice remaining amount. Use the split-payment flow to allocate the excess across one or more other supplier invoices.',
|
||||
},
|
||||
TX_UNCATEGORIZE_NOT_BOOKED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Transaktionen är inte bokförd. Det finns inget att av-kategorisera.',
|
||||
@@ -1725,6 +1739,69 @@ const LINK_INVOICE_VOUCHER: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Link SUPPLIER invoice to an existing posted verifikat (no new JE)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const LINK_SI_VOUCHER: Record<string, StructuredErrorEntry> = {
|
||||
LINK_SI_VOUCHER_INVOICE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Leverantörsfakturan kunde inte hittas.',
|
||||
message_en: 'Supplier invoice not found.',
|
||||
},
|
||||
LINK_SI_VOUCHER_VOUCHER_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Verifikationen kunde inte hittas.',
|
||||
message_en: 'Journal entry not found.',
|
||||
},
|
||||
LINK_SI_VOUCHER_NOT_POSTED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Verifikationen är inte bokförd. Endast bokförda verifikationer kan länkas som betalning.',
|
||||
message_en: 'Journal entry is not posted. Only posted entries can be linked as a payment.',
|
||||
},
|
||||
LINK_SI_VOUCHER_NO_AP_DEBIT: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Verifikationen debiterar inget leverantörsskuldskonto (244x). Rätta bokföringen först med en stornoverifikation som debiterar t.ex. 2440 (SEK) eller 2441 (utländsk valuta), via gnubok_correct_entry.',
|
||||
message_en:
|
||||
'The journal entry does not debit any accounts-payable account in the 244x range (e.g. 2440 SEK, 2441 foreign currency). Correct the booking first via a storno+correction (gnubok_correct_entry).',
|
||||
remediation: {
|
||||
description:
|
||||
'Use gnubok_correct_entry to storno the existing voucher and re-book the payment as Dr 244x / Cr 1930, then link the corrected voucher.',
|
||||
tool: 'gnubok_correct_entry',
|
||||
},
|
||||
},
|
||||
LINK_SI_VOUCHER_ALREADY_LINKED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Verifikationen är redan länkad till den här leverantörsfakturan.',
|
||||
message_en: 'This journal entry is already linked to this supplier invoice.',
|
||||
},
|
||||
LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Verifikationens leverantörsskuldsdebitering är större än leverantörsfakturans återstående belopp. Verifikationen täcker fler fakturor — välj en annan verifikation eller rätta beloppet först.',
|
||||
message_en:
|
||||
'The voucher\'s AP debit exceeds the supplier invoice\'s remaining balance. Split the voucher across multiple supplier invoices via gnubok_correct_entry first, or pick a different voucher.',
|
||||
},
|
||||
LINK_SI_VOUCHER_CURRENCY_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Verifikationens valuta matchar inte leverantörsfakturans. Endast verifikationer i fakturans valuta kan länkas.',
|
||||
message_en: 'The voucher\'s currency does not match the supplier invoice currency.',
|
||||
},
|
||||
LINK_SI_VOUCHER_INVOICE_FULLY_PAID: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Leverantörsfakturan har redan slutbetalats. Inget mer behöver länkas.',
|
||||
message_en: 'Supplier invoice is already fully paid.',
|
||||
},
|
||||
LINK_SI_VOUCHER_DB_ERROR: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Databasfel under länkning. Försök igen.',
|
||||
message_en: 'Database error while linking the voucher. Please retry.',
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Combined registry
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -1736,6 +1813,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...MATCH_INVOICE,
|
||||
...LINK_TX_JE,
|
||||
...LINK_INVOICE_VOUCHER,
|
||||
...LINK_SI_VOUCHER,
|
||||
...MATCH_SI,
|
||||
...INVOICE,
|
||||
...SUPPLIER_INVOICE,
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
validateVoucherForSupplierInvoiceLink,
|
||||
linkSupplierInvoiceToVoucher,
|
||||
} from '../supplier-voucher-matching'
|
||||
import {
|
||||
makeSupplierInvoice,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
// ============================================================
|
||||
// validateVoucherForSupplierInvoiceLink — happy path + rejects
|
||||
// ============================================================
|
||||
|
||||
describe('validateVoucherForSupplierInvoiceLink', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function setup(
|
||||
invoice = makeSupplierInvoice({ remaining_amount: 1000, total: 1000, currency: 'SEK' }),
|
||||
) {
|
||||
return invoice
|
||||
}
|
||||
|
||||
it('rejects when the invoice has nothing remaining', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const invoice = setup(
|
||||
makeSupplierInvoice({
|
||||
remaining_amount: 0,
|
||||
paid_amount: 1000,
|
||||
total: 1000,
|
||||
currency: 'SEK',
|
||||
}),
|
||||
)
|
||||
enqueue({ data: null }) // unused — short-circuits before any query
|
||||
const result = await validateVoucherForSupplierInvoiceLink(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
invoice as never,
|
||||
'je-1',
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_INVOICE_FULLY_PAID')
|
||||
})
|
||||
|
||||
it('rejects when the voucher is missing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const invoice = setup()
|
||||
enqueue({ data: null, error: null }) // journal_entries.maybeSingle → null
|
||||
const result = await validateVoucherForSupplierInvoiceLink(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
invoice as never,
|
||||
'je-missing',
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_VOUCHER_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects when the voucher is not posted', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const invoice = setup()
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'je-1',
|
||||
voucher_series: 'B',
|
||||
voucher_number: 12,
|
||||
entry_date: '2024-06-15',
|
||||
description: '',
|
||||
status: 'draft',
|
||||
source_type: 'manual',
|
||||
fiscal_period_id: 'fp-1',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
})
|
||||
const result = await validateVoucherForSupplierInvoiceLink(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
invoice as never,
|
||||
'je-1',
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_NOT_POSTED')
|
||||
})
|
||||
|
||||
it('rejects when the voucher has no AP debit on 2440', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const invoice = setup()
|
||||
// journal_entries lookup
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'je-1',
|
||||
voucher_series: 'B',
|
||||
voucher_number: 12,
|
||||
entry_date: '2024-06-15',
|
||||
description: '',
|
||||
status: 'posted',
|
||||
source_type: 'manual',
|
||||
fiscal_period_id: 'fp-1',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
})
|
||||
// journal_entry_lines — no 2440 line
|
||||
enqueue({
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
|
||||
{ account_number: '4010', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
|
||||
],
|
||||
})
|
||||
const result = await validateVoucherForSupplierInvoiceLink(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
invoice as never,
|
||||
'je-1',
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_NO_AP_DEBIT')
|
||||
})
|
||||
|
||||
it('rejects when the AP debit exceeds invoice remaining', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const invoice = setup(
|
||||
makeSupplierInvoice({
|
||||
remaining_amount: 1000,
|
||||
paid_amount: 0,
|
||||
total: 1000,
|
||||
currency: 'SEK',
|
||||
}),
|
||||
)
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'je-1',
|
||||
voucher_series: 'B',
|
||||
voucher_number: 12,
|
||||
entry_date: '2024-06-15',
|
||||
description: '',
|
||||
status: 'posted',
|
||||
source_type: 'manual',
|
||||
fiscal_period_id: 'fp-1',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
})
|
||||
// 5 000 debit on 2440 — overshoots a 1 000 invoice
|
||||
enqueue({
|
||||
data: [
|
||||
{ account_number: '2440', debit_amount: 5000, credit_amount: 0, currency: 'SEK' },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 5000, currency: 'SEK' },
|
||||
],
|
||||
})
|
||||
const result = await validateVoucherForSupplierInvoiceLink(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
invoice as never,
|
||||
'je-1',
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe('LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING')
|
||||
expect(result.details?.ap_debit).toBe(5000)
|
||||
expect(result.details?.remaining).toBe(1000)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts an exact-amount match and reports paymentAmount + isFullyPaid', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const invoice = setup(
|
||||
makeSupplierInvoice({
|
||||
remaining_amount: 1000,
|
||||
paid_amount: 0,
|
||||
total: 1000,
|
||||
currency: 'SEK',
|
||||
}),
|
||||
)
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'je-1',
|
||||
voucher_series: 'B',
|
||||
voucher_number: 12,
|
||||
entry_date: '2024-06-15',
|
||||
description: '',
|
||||
status: 'posted',
|
||||
source_type: 'manual',
|
||||
fiscal_period_id: 'fp-1',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ account_number: '2440', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
|
||||
],
|
||||
})
|
||||
// existingLinks lookup — none
|
||||
enqueue({ data: [], error: null })
|
||||
const result = await validateVoucherForSupplierInvoiceLink(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
invoice as never,
|
||||
'je-1',
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.apDebitAmount).toBe(1000)
|
||||
expect(result.paymentAmount).toBe(1000)
|
||||
expect(result.isFullyPaid).toBe(true)
|
||||
expect(result.remainingAfter).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a partial-payment voucher (debit < remaining) and reports partially_paid math', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const invoice = setup(
|
||||
makeSupplierInvoice({
|
||||
remaining_amount: 1000,
|
||||
paid_amount: 0,
|
||||
total: 1000,
|
||||
currency: 'SEK',
|
||||
}),
|
||||
)
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'je-1',
|
||||
voucher_series: 'B',
|
||||
voucher_number: 12,
|
||||
entry_date: '2024-06-15',
|
||||
description: '',
|
||||
status: 'posted',
|
||||
source_type: 'manual',
|
||||
fiscal_period_id: 'fp-1',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ account_number: '2440', debit_amount: 400, credit_amount: 0, currency: 'SEK' },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 400, currency: 'SEK' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
const result = await validateVoucherForSupplierInvoiceLink(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
invoice as never,
|
||||
'je-1',
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.paymentAmount).toBe(400)
|
||||
expect(result.isFullyPaid).toBe(false)
|
||||
expect(result.remainingAfter).toBe(600)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects currency mismatch', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const invoice = setup(
|
||||
makeSupplierInvoice({
|
||||
remaining_amount: 200,
|
||||
paid_amount: 0,
|
||||
total: 200,
|
||||
currency: 'EUR',
|
||||
}),
|
||||
)
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'je-1',
|
||||
voucher_series: 'B',
|
||||
voucher_number: 12,
|
||||
entry_date: '2024-06-15',
|
||||
description: '',
|
||||
status: 'posted',
|
||||
source_type: 'manual',
|
||||
fiscal_period_id: 'fp-1',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ account_number: '2440', debit_amount: 200, credit_amount: 0, currency: 'SEK' },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 200, currency: 'SEK' },
|
||||
],
|
||||
})
|
||||
const result = await validateVoucherForSupplierInvoiceLink(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
invoice as never,
|
||||
'je-1',
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_CURRENCY_MISMATCH')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// linkSupplierInvoiceToVoucher — end-to-end advancement
|
||||
// ============================================================
|
||||
|
||||
describe('linkSupplierInvoiceToVoucher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The implementation now delegates the lock + validate + UPDATE + INSERT
|
||||
// sequence to the link_supplier_invoice_to_voucher PL/pgSQL RPC (PR #602
|
||||
// review fix). The TS wrapper only translates the RPC's structured jsonb
|
||||
// return into the lib's typed Result type and emits the paid event. These
|
||||
// tests mock the RPC response directly.
|
||||
|
||||
it('rejects with INVOICE_NOT_FOUND when the RPC reports the invoice is missing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { ok: false, code: 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND' },
|
||||
error: null,
|
||||
})
|
||||
const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
|
||||
supplierInvoiceId: 'si-missing',
|
||||
journalEntryId: 'je-1',
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_INVOICE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects with INVOICE_FULLY_PAID when the RPC reports the invoice is already paid', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
ok: false,
|
||||
code: 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID',
|
||||
details: { status: 'paid' },
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
|
||||
supplierInvoiceId: 'si-1',
|
||||
journalEntryId: 'je-1',
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe('LINK_SI_VOUCHER_INVOICE_FULLY_PAID')
|
||||
expect(result.details?.status).toBe('paid')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns LINK_SI_VOUCHER_DB_ERROR when the RPC raises an error', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: { message: 'connection lost' } })
|
||||
const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
|
||||
supplierInvoiceId: 'si-1',
|
||||
journalEntryId: 'je-1',
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe('LINK_SI_VOUCHER_DB_ERROR')
|
||||
expect(result.details?.reason).toBe('connection lost')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns success + emits supplier_invoice.paid on the happy path (full payment)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const invoice = makeSupplierInvoice({
|
||||
status: 'paid',
|
||||
paid_amount: 1000,
|
||||
remaining_amount: 0,
|
||||
total: 1000,
|
||||
currency: 'SEK',
|
||||
})
|
||||
|
||||
// 1. RPC returns the happy path
|
||||
enqueue({
|
||||
data: {
|
||||
ok: true,
|
||||
payment_id: 'sip-1',
|
||||
invoice_status: 'paid',
|
||||
paid_amount: 1000,
|
||||
remaining_amount: 0,
|
||||
payment_amount: 1000,
|
||||
journal_entry_id: 'je-1',
|
||||
currency: 'SEK',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
// 2. Lightweight invoice re-fetch for the event payload
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit').mockResolvedValue(undefined)
|
||||
|
||||
const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
|
||||
supplierInvoiceId: invoice.id,
|
||||
journalEntryId: 'je-1',
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.result.invoiceStatus).toBe('paid')
|
||||
expect(result.result.paidAmount).toBe(1000)
|
||||
expect(result.result.remainingAmount).toBe(0)
|
||||
expect(result.result.paymentAmount).toBe(1000)
|
||||
expect(result.result.journalEntryId).toBe('je-1')
|
||||
expect(result.result.paymentId).toBe('sip-1')
|
||||
}
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'supplier_invoice.paid',
|
||||
payload: expect.objectContaining({ paymentAmount: 1000, userId: 'user-1' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('still returns success even if the post-link invoice re-fetch is empty (event is best-effort)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
ok: true,
|
||||
payment_id: 'sip-2',
|
||||
invoice_status: 'partially_paid',
|
||||
paid_amount: 400,
|
||||
remaining_amount: 600,
|
||||
payment_amount: 400,
|
||||
journal_entry_id: 'je-1',
|
||||
currency: 'SEK',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit').mockResolvedValue(undefined)
|
||||
|
||||
const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
|
||||
supplierInvoiceId: 'si-2',
|
||||
journalEntryId: 'je-1',
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.result.invoiceStatus).toBe('partially_paid')
|
||||
expect(result.result.remainingAmount).toBe(600)
|
||||
}
|
||||
// Event NOT emitted when re-fetch found nothing
|
||||
expect(emitSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* Link an existing posted verifikat to a supplier invoice as its payment row.
|
||||
*
|
||||
* Mirror of voucher-matching.ts but targets 2440 (Leverantörsskulder) debits
|
||||
* instead of 151x credits. Used when the GL already contains a verifikat that
|
||||
* pays down AP — e.g. an SIE-imported payment voucher, a manually entered
|
||||
* bank-transfer voucher, or any flow where the bookkeeping landed without
|
||||
* supplier-invoice linkage. No new journal entry is created. Only a
|
||||
* supplier_invoice_payments row is inserted pointing at the existing
|
||||
* journal_entry_id, plus the invoice's paid_amount / remaining_amount /
|
||||
* status are advanced.
|
||||
*
|
||||
* Vouchers that book the supplier expense directly without going through 2440
|
||||
* (e.g. Dr 4010 / Cr 1930 for a non-invoiced purchase) are rejected with
|
||||
* LINK_SI_VOUCHER_NO_AP_DEBIT. The proper fix for those is a storno+correction
|
||||
* via gnubok_correct_entry — out of scope for V1.
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import {
|
||||
CONFIDENCE,
|
||||
amountsMatchExact,
|
||||
amountsMatchFuzzy,
|
||||
customerNameMatches,
|
||||
} from './invoice-matching'
|
||||
import type { SupplierInvoice, Supplier } from '@/types'
|
||||
|
||||
const log = createLogger('supplier-voucher-matching')
|
||||
|
||||
/** AP account class. BAS 2026 reserves 2440–2449 for Leverantörsskulder
|
||||
* (2440 SEK, 2441 utländsk valuta, 2443 Skuldfakturor, 2448 övriga). The
|
||||
* supplier sub-ledger lives in the supplier_invoices table, not in per-
|
||||
* supplier accounts. A samlingsverifikat that pays mixed SEK + EUR
|
||||
* suppliers will legitimately debit both 2440 and 2441 — summing across
|
||||
* the 244x range catches that. PR #602 Swedish-compliance fix. */
|
||||
const AP_ACCOUNT_PREFIX = '244'
|
||||
|
||||
/** ±90 days from the invoice's due_date as the default search window. */
|
||||
const DEFAULT_DATE_WINDOW_DAYS = 90
|
||||
|
||||
/** Tolerance for floating-point comparisons on monetary amounts (0.5 öre). */
|
||||
const AMOUNT_TOLERANCE = 0.005
|
||||
|
||||
/** Date-proximity bump applied when entry_date is within ±7 days of due_date. */
|
||||
const DATE_PROXIMITY_BUMP = 0.05
|
||||
|
||||
export interface SupplierVoucherCandidate {
|
||||
journal_entry_id: string
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
entry_date: string
|
||||
description: string
|
||||
/** Total debit on the AP account (2440) on this voucher, always positive. */
|
||||
ap_debit_amount: number
|
||||
currency: string
|
||||
/** Currency of the AP-debit line; nullable when the line stores SEK only. */
|
||||
ap_line_currency: string | null
|
||||
/** True when the voucher's fiscal period is closed or locked. */
|
||||
period_locked: boolean
|
||||
/** Confidence score 0..1 (or 0.99 for OCR match). */
|
||||
confidence: number
|
||||
/** Localized reason in Swedish. */
|
||||
match_reason: string
|
||||
}
|
||||
|
||||
interface JournalEntryLine {
|
||||
id: string
|
||||
journal_entry_id: string
|
||||
account_number: string
|
||||
debit_amount: number | null
|
||||
credit_amount: number | null
|
||||
currency: string | null
|
||||
}
|
||||
|
||||
interface VoucherRow {
|
||||
id: string
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
entry_date: string
|
||||
description: string
|
||||
status: string
|
||||
source_type: string | null
|
||||
fiscal_period_id: string
|
||||
}
|
||||
|
||||
interface FiscalPeriodRow {
|
||||
id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface CandidateContext {
|
||||
invoice: SupplierInvoice & { supplier?: Supplier }
|
||||
remainingAmount: number
|
||||
}
|
||||
|
||||
const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno']
|
||||
|
||||
/**
|
||||
* Find posted journal entries whose lines debit 2440 and could plausibly be
|
||||
* the payment for this supplier invoice. Ranking mirrors the customer side:
|
||||
* exact amount + supplier match wins, then exact, then fuzzy (±1% capped at
|
||||
* 500 SEK), with a small bump for date proximity to due_date.
|
||||
*/
|
||||
export async function findMatchingVouchersForSupplierInvoice(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
invoice: SupplierInvoice & { supplier?: Supplier },
|
||||
options: { limit?: number; dateWindowDays?: number } = {},
|
||||
): Promise<SupplierVoucherCandidate[]> {
|
||||
const limit = options.limit ?? 10
|
||||
const windowDays = options.dateWindowDays ?? DEFAULT_DATE_WINDOW_DAYS
|
||||
|
||||
const remainingAmount = computeRemaining(invoice)
|
||||
if (remainingAmount <= AMOUNT_TOLERANCE) return []
|
||||
|
||||
const dueDate = new Date(invoice.due_date)
|
||||
const dateFrom = new Date(dueDate)
|
||||
dateFrom.setDate(dateFrom.getDate() - windowDays)
|
||||
const dateTo = new Date(dueDate)
|
||||
dateTo.setDate(dateTo.getDate() + windowDays)
|
||||
|
||||
const { data: lines, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
journal_entry_id,
|
||||
account_number,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
currency,
|
||||
journal_entries!inner (
|
||||
id,
|
||||
voucher_series,
|
||||
voucher_number,
|
||||
entry_date,
|
||||
description,
|
||||
status,
|
||||
source_type,
|
||||
fiscal_period_id,
|
||||
company_id
|
||||
)
|
||||
`,
|
||||
)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.like('account_number', `${AP_ACCOUNT_PREFIX}%`)
|
||||
.gt('debit_amount', 0)
|
||||
.gte('journal_entries.entry_date', dateFrom.toISOString().slice(0, 10))
|
||||
.lte('journal_entries.entry_date', dateTo.toISOString().slice(0, 10))
|
||||
.limit(limit * 10)
|
||||
if (error || !lines) return []
|
||||
|
||||
// Sum the AP debit per voucher across multiple 2440 lines (a samlings-
|
||||
// verifikation paying several supplier invoices in one shot will have one
|
||||
// 2440 row per supplier).
|
||||
const byEntry = new Map<
|
||||
string,
|
||||
{ entry: VoucherRow; apDebitTotal: number; lineCurrency: string | null }
|
||||
>()
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw as unknown as JournalEntryLine & {
|
||||
journal_entries: VoucherRow
|
||||
}
|
||||
const entry = line.journal_entries
|
||||
if (!entry) continue
|
||||
if (EXCLUDED_SOURCE_TYPES.includes(entry.source_type ?? '')) continue
|
||||
|
||||
const debit = Number(line.debit_amount ?? 0)
|
||||
if (debit <= 0) continue
|
||||
|
||||
const existing = byEntry.get(entry.id)
|
||||
if (existing) {
|
||||
existing.apDebitTotal += debit
|
||||
} else {
|
||||
byEntry.set(entry.id, {
|
||||
entry,
|
||||
apDebitTotal: debit,
|
||||
lineCurrency: line.currency,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (byEntry.size === 0) return []
|
||||
|
||||
// Drop entries already fully linked to *this* supplier invoice.
|
||||
const candidateEntryIds = Array.from(byEntry.keys())
|
||||
const { data: existingLinks } = await supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.select('journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('supplier_invoice_id', invoice.id)
|
||||
.in('journal_entry_id', candidateEntryIds)
|
||||
|
||||
const alreadyLinked = new Set(
|
||||
(existingLinks ?? [])
|
||||
.map((row) => (row as { journal_entry_id: string | null }).journal_entry_id)
|
||||
.filter((id): id is string => !!id),
|
||||
)
|
||||
for (const id of alreadyLinked) byEntry.delete(id)
|
||||
if (byEntry.size === 0) return []
|
||||
|
||||
// Period-lock flags (informational — linking is allowed in locked periods
|
||||
// because no JE is mutated).
|
||||
const periodIds = Array.from(
|
||||
new Set(Array.from(byEntry.values()).map((v) => v.entry.fiscal_period_id)),
|
||||
)
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, status')
|
||||
.in('id', periodIds)
|
||||
const lockedPeriods = new Set(
|
||||
(periods ?? [])
|
||||
.filter(
|
||||
(p) =>
|
||||
(p as FiscalPeriodRow).status === 'closed' ||
|
||||
(p as FiscalPeriodRow).status === 'locked',
|
||||
)
|
||||
.map((p) => (p as FiscalPeriodRow).id),
|
||||
)
|
||||
|
||||
const ctx: CandidateContext = { invoice, remainingAmount }
|
||||
const candidates: SupplierVoucherCandidate[] = []
|
||||
for (const { entry, apDebitTotal, lineCurrency } of byEntry.values()) {
|
||||
const scored = scoreCandidate(entry, apDebitTotal, lineCurrency, ctx)
|
||||
if (!scored) continue
|
||||
candidates.push({
|
||||
journal_entry_id: entry.id,
|
||||
voucher_series: entry.voucher_series,
|
||||
voucher_number: entry.voucher_number,
|
||||
entry_date: entry.entry_date,
|
||||
description: entry.description,
|
||||
ap_debit_amount: round2(apDebitTotal),
|
||||
currency: invoice.currency,
|
||||
ap_line_currency: lineCurrency,
|
||||
period_locked: lockedPeriods.has(entry.fiscal_period_id),
|
||||
confidence: scored.confidence,
|
||||
match_reason: scored.match_reason,
|
||||
})
|
||||
}
|
||||
|
||||
candidates.sort(
|
||||
(a, b) => b.confidence - a.confidence || a.entry_date.localeCompare(b.entry_date),
|
||||
)
|
||||
return candidates.slice(0, limit)
|
||||
}
|
||||
|
||||
function scoreCandidate(
|
||||
entry: VoucherRow,
|
||||
apDebitTotal: number,
|
||||
lineCurrency: string | null,
|
||||
ctx: CandidateContext,
|
||||
): { confidence: number; match_reason: string } | null {
|
||||
// OCR-style: invoice number or arrival number appears in the entry description.
|
||||
const invoiceNumberHit =
|
||||
ctx.invoice.supplier_invoice_number &&
|
||||
descriptionMentionsToken(entry.description, ctx.invoice.supplier_invoice_number)
|
||||
const arrivalHit =
|
||||
ctx.invoice.arrival_number != null &&
|
||||
descriptionMentionsToken(entry.description, String(ctx.invoice.arrival_number))
|
||||
if (invoiceNumberHit || arrivalHit) {
|
||||
return {
|
||||
confidence: CONFIDENCE.OCR_REFERENCE_MATCH,
|
||||
match_reason: invoiceNumberHit
|
||||
? `Fakturanummer ${ctx.invoice.supplier_invoice_number} omnämnt i verifikatets beskrivning`
|
||||
: `Ankomstnummer ${ctx.invoice.arrival_number} omnämnt i verifikatets beskrivning`,
|
||||
}
|
||||
}
|
||||
|
||||
// Currency check — 2440 line currency must match invoice currency (or be
|
||||
// unset, which we treat as the invoice currency).
|
||||
const lineCurrencyEffective = lineCurrency ?? ctx.invoice.currency
|
||||
if (lineCurrencyEffective !== ctx.invoice.currency) {
|
||||
return null
|
||||
}
|
||||
|
||||
const exactRemaining = amountsMatchExact(apDebitTotal, ctx.remainingAmount)
|
||||
const exactTotal =
|
||||
!exactRemaining && amountsMatchExact(apDebitTotal, ctx.invoice.total)
|
||||
const fuzzyRemaining =
|
||||
!exactRemaining &&
|
||||
!exactTotal &&
|
||||
amountsMatchFuzzy(apDebitTotal, ctx.remainingAmount)
|
||||
|
||||
// Supplier name in description — reuse customer-side helper since the logic
|
||||
// (significant tokens of the counterparty name appearing in free text) is
|
||||
// identical regardless of AR vs AP.
|
||||
const supplierMatch = customerNameMatches(
|
||||
ctx.invoice.supplier?.name,
|
||||
entry.description,
|
||||
null,
|
||||
)
|
||||
|
||||
let confidence = 0
|
||||
let reason = ''
|
||||
if (exactRemaining && supplierMatch) {
|
||||
confidence = CONFIDENCE.EXACT_AMOUNT_CUSTOMER
|
||||
reason = `Exakt belopp (${formatNumber(apDebitTotal)} ${ctx.invoice.currency}) och leverantörsnamn matchar`
|
||||
} else if (exactRemaining) {
|
||||
confidence = CONFIDENCE.EXACT_AMOUNT_ONLY
|
||||
reason = `Exakt belopp (${formatNumber(apDebitTotal)} ${ctx.invoice.currency})`
|
||||
} else if (exactTotal && supplierMatch) {
|
||||
confidence = CONFIDENCE.FUZZY_AMOUNT_CUSTOMER
|
||||
reason = `Fakturans totalbelopp och leverantörsnamn matchar`
|
||||
} else if (exactTotal) {
|
||||
confidence = CONFIDENCE.FUZZY_AMOUNT_ONLY + 0.05
|
||||
reason = `Fakturans totalbelopp matchar`
|
||||
} else if (fuzzyRemaining && supplierMatch) {
|
||||
confidence = CONFIDENCE.FUZZY_AMOUNT_CUSTOMER
|
||||
reason = `Belopp nära (±1%) och leverantörsnamn matchar`
|
||||
} else if (fuzzyRemaining) {
|
||||
confidence = CONFIDENCE.FUZZY_AMOUNT_ONLY
|
||||
reason = `Belopp nära (±1%)`
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isDateWithinDays(entry.entry_date, ctx.invoice.due_date, 7)) {
|
||||
confidence = Math.min(CONFIDENCE.OCR_REFERENCE_MATCH - 0.001, confidence + DATE_PROXIMITY_BUMP)
|
||||
}
|
||||
|
||||
return { confidence, match_reason: reason }
|
||||
}
|
||||
|
||||
export type SupplierVoucherLinkErrorCode =
|
||||
| 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND'
|
||||
| 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND'
|
||||
| 'LINK_SI_VOUCHER_NOT_POSTED'
|
||||
| 'LINK_SI_VOUCHER_NO_AP_DEBIT'
|
||||
| 'LINK_SI_VOUCHER_ALREADY_LINKED'
|
||||
| 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING'
|
||||
| 'LINK_SI_VOUCHER_CURRENCY_MISMATCH'
|
||||
| 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID'
|
||||
| 'LINK_SI_VOUCHER_DB_ERROR'
|
||||
|
||||
export type ValidateSupplierVoucherResult =
|
||||
| {
|
||||
ok: true
|
||||
apDebitAmount: number
|
||||
apLineCurrency: string | null
|
||||
voucher: VoucherRow
|
||||
remainingAfter: number
|
||||
isFullyPaid: boolean
|
||||
paymentAmount: number
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
code: SupplierVoucherLinkErrorCode
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a journal entry can be linked as payment for a supplier
|
||||
* invoice. Used by both the staging path (MCP tool, future) and the commit
|
||||
* path (web route + MCP commit handler, future) so the guards stay identical.
|
||||
*/
|
||||
export async function validateVoucherForSupplierInvoiceLink(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
invoice: SupplierInvoice & { supplier?: Supplier },
|
||||
journalEntryId: string,
|
||||
): Promise<ValidateSupplierVoucherResult> {
|
||||
const remainingAmount = computeRemaining(invoice)
|
||||
if (remainingAmount <= AMOUNT_TOLERANCE) {
|
||||
return { ok: false, code: 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID' }
|
||||
}
|
||||
|
||||
const { data: voucher, error: voucherError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select(
|
||||
'id, voucher_series, voucher_number, entry_date, description, status, source_type, fiscal_period_id, company_id',
|
||||
)
|
||||
.eq('id', journalEntryId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (voucherError || !voucher) {
|
||||
return { ok: false, code: 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND' }
|
||||
}
|
||||
|
||||
const v = voucher as VoucherRow & { company_id: string }
|
||||
if (v.status !== 'posted') {
|
||||
return { ok: false, code: 'LINK_SI_VOUCHER_NOT_POSTED', details: { status: v.status } }
|
||||
}
|
||||
if (EXCLUDED_SOURCE_TYPES.includes(v.source_type ?? '')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'LINK_SI_VOUCHER_NO_AP_DEBIT',
|
||||
details: { source_type: v.source_type },
|
||||
}
|
||||
}
|
||||
|
||||
const { data: lines, error: linesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, currency')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
if (linesError || !lines || lines.length === 0) {
|
||||
return { ok: false, code: 'LINK_SI_VOUCHER_NO_AP_DEBIT' }
|
||||
}
|
||||
|
||||
let apDebitTotal = 0
|
||||
let lineCurrency: string | null = null
|
||||
for (const raw of lines) {
|
||||
const line = raw as {
|
||||
account_number: string
|
||||
debit_amount: number | null
|
||||
credit_amount: number | null
|
||||
currency: string | null
|
||||
}
|
||||
if (!line.account_number?.startsWith(AP_ACCOUNT_PREFIX)) continue
|
||||
const debit = Number(line.debit_amount ?? 0)
|
||||
if (debit <= 0) continue
|
||||
apDebitTotal += debit
|
||||
if (!lineCurrency) lineCurrency = line.currency
|
||||
}
|
||||
apDebitTotal = round2(apDebitTotal)
|
||||
|
||||
if (apDebitTotal <= 0) {
|
||||
return { ok: false, code: 'LINK_SI_VOUCHER_NO_AP_DEBIT' }
|
||||
}
|
||||
|
||||
const lineCurrencyEffective = lineCurrency ?? invoice.currency
|
||||
if (lineCurrencyEffective !== invoice.currency) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
|
||||
details: {
|
||||
invoice_currency: invoice.currency,
|
||||
line_currency: lineCurrencyEffective,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (apDebitTotal > remainingAmount + AMOUNT_TOLERANCE) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
|
||||
details: { ap_debit: apDebitTotal, remaining: round2(remainingAmount) },
|
||||
}
|
||||
}
|
||||
|
||||
const { data: existingLinks } = await supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('supplier_invoice_id', invoice.id)
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
.limit(1)
|
||||
if (existingLinks && existingLinks.length > 0) {
|
||||
return { ok: false, code: 'LINK_SI_VOUCHER_ALREADY_LINKED' }
|
||||
}
|
||||
|
||||
const paymentAmount = Math.min(apDebitTotal, round2(remainingAmount))
|
||||
const remainingAfter = Math.max(0, round2(remainingAmount - paymentAmount))
|
||||
const isFullyPaid = remainingAfter <= AMOUNT_TOLERANCE
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
apDebitAmount: apDebitTotal,
|
||||
apLineCurrency: lineCurrency,
|
||||
voucher: v,
|
||||
remainingAfter,
|
||||
isFullyPaid,
|
||||
paymentAmount,
|
||||
}
|
||||
}
|
||||
|
||||
export interface LinkSupplierInvoiceToVoucherParams {
|
||||
supplierInvoiceId: string
|
||||
journalEntryId: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface LinkSupplierInvoiceToVoucherResult {
|
||||
paymentId: string
|
||||
invoiceStatus: 'paid' | 'partially_paid'
|
||||
paidAmount: number
|
||||
remainingAmount: number
|
||||
paymentAmount: number
|
||||
journalEntryId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically link an existing posted verifikat as payment for a supplier
|
||||
* invoice. Inserts a supplier_invoice_payments row pointing at the JE, advances
|
||||
* the invoice's paid_amount / remaining_amount, and emits supplier_invoice.paid
|
||||
* (reusing the existing event so reminder/automation subscribers fire without
|
||||
* a new channel).
|
||||
*
|
||||
* Re-validates inside the same call to defend against stage→commit drift.
|
||||
*/
|
||||
interface RpcLinkOk {
|
||||
ok: true
|
||||
payment_id: string
|
||||
invoice_status: 'paid' | 'partially_paid'
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
payment_amount: number
|
||||
journal_entry_id: string
|
||||
currency: string
|
||||
}
|
||||
|
||||
interface RpcLinkErr {
|
||||
ok: false
|
||||
code: SupplierVoucherLinkErrorCode
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export async function linkSupplierInvoiceToVoucher(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: LinkSupplierInvoiceToVoucherParams,
|
||||
): Promise<
|
||||
| { ok: true; result: LinkSupplierInvoiceToVoucherResult }
|
||||
| { ok: false; code: SupplierVoucherLinkErrorCode; details?: Record<string, unknown> }
|
||||
> {
|
||||
// All validation + writes happen inside link_supplier_invoice_to_voucher
|
||||
// (PL/pgSQL). The function locks the invoice row, validates the voucher,
|
||||
// and applies UPDATE + INSERT in a single PG transaction so a failure on
|
||||
// either rolls back automatically. The previous TS implementation did
|
||||
// UPDATE-then-INSERT with a manual rollback that could overwrite a
|
||||
// concurrent sibling's successful write — PR #602 review fix.
|
||||
const { data, error } = await supabase.rpc('link_supplier_invoice_to_voucher', {
|
||||
p_supplier_invoice_id: params.supplierInvoiceId,
|
||||
p_journal_entry_id: params.journalEntryId,
|
||||
p_user_id: userId,
|
||||
p_company_id: companyId,
|
||||
p_notes: params.notes ?? null,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
log.error('link_supplier_invoice_to_voucher RPC error', {
|
||||
companyId,
|
||||
userId,
|
||||
supplierInvoiceId: params.supplierInvoiceId,
|
||||
journalEntryId: params.journalEntryId,
|
||||
message: error.message,
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
code: 'LINK_SI_VOUCHER_DB_ERROR',
|
||||
details: { reason: error.message },
|
||||
}
|
||||
}
|
||||
|
||||
const result = data as RpcLinkOk | RpcLinkErr | null
|
||||
if (!result) {
|
||||
return { ok: false, code: 'LINK_SI_VOUCHER_DB_ERROR', details: { reason: 'empty RPC response' } }
|
||||
}
|
||||
if (!result.ok) {
|
||||
return { ok: false, code: result.code, details: result.details }
|
||||
}
|
||||
|
||||
// Fetch the now-updated invoice for event emission. Lightweight; the RPC
|
||||
// committed before this read so the row reflects post-link state.
|
||||
// select('*') is intentional — the supplier_invoice.paid event payload is
|
||||
// typed as `supplierInvoice: SupplierInvoice` in lib/events/types.ts, so
|
||||
// narrowing here would either break the subscriber contract or require a
|
||||
// separate event payload type. The event stays in-process (eventBus is a
|
||||
// module-level singleton) and any consumer subscribing to this event
|
||||
// legitimately needs the full invoice context for downstream reminders
|
||||
// and audit-log routing. PR #602 compliance review note documented.
|
||||
const { data: invoice } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*')
|
||||
.eq('id', params.supplierInvoiceId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (invoice) {
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.paid',
|
||||
payload: {
|
||||
supplierInvoice: invoice as SupplierInvoice,
|
||||
paymentAmount: result.payment_amount,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
// Event emission failure must not block the response, but should leave
|
||||
// an audit trail (ISO 27001:2022 A.8.15 / OWASP V16). Logged at warn
|
||||
// because the link itself succeeded — the downstream reminder/audit
|
||||
// subscriber will need separate intervention.
|
||||
log.warn('supplier_invoice.paid event emission failed', {
|
||||
err,
|
||||
supplierInvoiceId: params.supplierInvoiceId,
|
||||
journalEntryId: params.journalEntryId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
paymentId: result.payment_id,
|
||||
invoiceStatus: result.invoice_status,
|
||||
paidAmount: result.paid_amount,
|
||||
remainingAmount: result.remaining_amount,
|
||||
paymentAmount: result.payment_amount,
|
||||
journalEntryId: result.journal_entry_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function computeRemaining(invoice: SupplierInvoice): number {
|
||||
// Trust the stored value whenever present, including the legitimate 0 for
|
||||
// a fully-paid invoice. Falling through to `total - paid_amount` for the
|
||||
// 0 case can leak rounding drift across multiple payments and return a
|
||||
// tiny positive number, slipping a fully-paid invoice past
|
||||
// LINK_SI_VOUCHER_INVOICE_FULLY_PAID. PR #602 review fix.
|
||||
if (typeof invoice.remaining_amount === 'number') {
|
||||
return Math.max(0, invoice.remaining_amount)
|
||||
}
|
||||
const paid = invoice.paid_amount ?? 0
|
||||
return Math.max(0, round2(invoice.total - paid))
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function isDateWithinDays(a: string, b: string, days: number): boolean {
|
||||
const ad = new Date(a).getTime()
|
||||
const bd = new Date(b).getTime()
|
||||
if (Number.isNaN(ad) || Number.isNaN(bd)) return false
|
||||
return Math.abs(ad - bd) <= days * 24 * 3600 * 1000
|
||||
}
|
||||
|
||||
function descriptionMentionsToken(description: string | null, token: string): boolean {
|
||||
if (!description || !token) return false
|
||||
const normalizedDesc = description.replace(/\s+/g, '').toLowerCase()
|
||||
const normalizedTok = token.replace(/\s+/g, '').toLowerCase()
|
||||
if (normalizedTok.length < 2) return false
|
||||
return normalizedDesc.includes(normalizedTok)
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n)
|
||||
}
|
||||
@@ -2589,6 +2589,8 @@
|
||||
"payment_voucher": "Payment verifikat",
|
||||
"notes_title": "Notes",
|
||||
"pay_dialog_title": "Mark as paid",
|
||||
"tab_new_payment": "New payment",
|
||||
"tab_existing_voucher": "Existing voucher",
|
||||
"payment_date_label": "Payment date",
|
||||
"payment_amount_label": "Amount to pay",
|
||||
"remaining_to_pay": "Remaining to pay: {amount} {currency}",
|
||||
|
||||
@@ -2589,6 +2589,8 @@
|
||||
"payment_voucher": "Betalningsverifikation",
|
||||
"notes_title": "Anteckningar",
|
||||
"pay_dialog_title": "Markera som betald",
|
||||
"tab_new_payment": "Ny betalning",
|
||||
"tab_existing_voucher": "Befintlig verifikation",
|
||||
"payment_date_label": "Betalningsdatum",
|
||||
"payment_amount_label": "Belopp att betala",
|
||||
"remaining_to_pay": "Kvar att betala: {amount} {currency}",
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
-- Phase 1A — Foundation for multi-tx ↔ multi-voucher matching.
|
||||
--
|
||||
-- This migration scaffolds three pieces of schema that later phases (the
|
||||
-- match_batch_allocate and bulk_book_transactions RPCs, the new transactions
|
||||
-- inbox UI) build on. No RPC is added here — RPCs land in a follow-up
|
||||
-- migration to keep review small.
|
||||
--
|
||||
-- 1. transaction_voucher_links
|
||||
-- Junction table for N-tx → 1-JE flows (samlingsverifikation, bulk-book,
|
||||
-- "link N bank lines to an existing day-summary verifikat"). The 1:1
|
||||
-- case continues to use transactions.journal_entry_id; this junction is
|
||||
-- additive. Sum(allocated_amount) per JE must match the JE's net 19xx
|
||||
-- side within rounding tolerance — enforced by RPC business logic, not
|
||||
-- a DB constraint (a partial allocation is legitimate before the second
|
||||
-- bank line lands).
|
||||
--
|
||||
-- 2. block_contradictory_invoice_denorm trigger
|
||||
-- transactions.invoice_id / supplier_invoice_id are denormalized pointers
|
||||
-- that only carry meaning for the 1:1 case. After multi-match,
|
||||
-- invoice_payments / supplier_invoice_payments are the source of truth.
|
||||
-- This trigger refuses to set the denorm column to an invoice id that
|
||||
-- already conflicts with a payment row, preventing the table from
|
||||
-- silently lying after a multi-match.
|
||||
--
|
||||
-- 3. is_transaction_booked(uuid) SQL helper
|
||||
-- Single source of truth for "is this tx anchored to a verifikat?". A tx
|
||||
-- is booked when (a) transactions.journal_entry_id is set, or (b) any
|
||||
-- invoice_payments row references it, or (c) any
|
||||
-- supplier_invoice_payments row references it, or (d) any
|
||||
-- transaction_voucher_links row references it. Used by inbox filters
|
||||
-- and MCP list_uncategorized_transactions so the predicate stays
|
||||
-- consistent across surfaces.
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 1. transaction_voucher_links: N-tx → 1-JE junction
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.transaction_voucher_links (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users ON DELETE CASCADE,
|
||||
company_id UUID NOT NULL REFERENCES public.companies ON DELETE CASCADE,
|
||||
transaction_id UUID NOT NULL REFERENCES public.transactions ON DELETE CASCADE,
|
||||
-- ON DELETE CASCADE so delete_last_voucher (which permits removing the last
|
||||
-- draft / final voucher in a series, see 20260509103736 + 20260528120000)
|
||||
-- transparently strips the link rows. The txs themselves remain but become
|
||||
-- "unbooked" via is_transaction_booked() and re-surface in the inbox for
|
||||
-- re-booking — the desired behaviour after voucher deletion.
|
||||
journal_entry_id UUID NOT NULL REFERENCES public.journal_entries ON DELETE CASCADE,
|
||||
-- Signed amount in the transaction's own currency. Positive when the tx
|
||||
-- credits the JE's 19xx side (deposit), negative for debits (payment).
|
||||
-- Sum across all rows pointing at a given JE must equal the JE's net 19xx
|
||||
-- side within rounding tolerance — enforced by RPC business logic.
|
||||
allocated_amount NUMERIC(15,2) NOT NULL,
|
||||
-- bank_line = ordinary settlement leg (default)
|
||||
-- clearing = clearing-account leg (e.g. card/Swish day-summary clearing)
|
||||
-- other = future use
|
||||
role TEXT NOT NULL DEFAULT 'bank_line',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT transaction_voucher_links_role_check
|
||||
CHECK (role IN ('bank_line', 'clearing', 'other')),
|
||||
CONSTRAINT transaction_voucher_links_tx_je_unique
|
||||
UNIQUE (transaction_id, journal_entry_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transaction_voucher_links_company_id
|
||||
ON public.transaction_voucher_links (company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transaction_voucher_links_transaction_id
|
||||
ON public.transaction_voucher_links (transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transaction_voucher_links_journal_entry_id
|
||||
ON public.transaction_voucher_links (journal_entry_id);
|
||||
|
||||
ALTER TABLE public.transaction_voucher_links ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "transaction_voucher_links_select" ON public.transaction_voucher_links
|
||||
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "transaction_voucher_links_insert" ON public.transaction_voucher_links
|
||||
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "transaction_voucher_links_update" ON public.transaction_voucher_links
|
||||
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
|
||||
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "transaction_voucher_links_delete" ON public.transaction_voucher_links
|
||||
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
CREATE TRIGGER transaction_voucher_links_updated_at
|
||||
BEFORE UPDATE ON public.transaction_voucher_links
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
COMMENT ON TABLE public.transaction_voucher_links IS
|
||||
'N-tx → 1-JE junction. Use when one verifikat aggregates multiple bank lines (samlingsverifikation, bulk-book). The 1:1 case continues to use transactions.journal_entry_id; this junction is additive. is_transaction_booked() consults both.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 2. block_contradictory_invoice_denorm
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.block_contradictory_invoice_denorm()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
conflicting_invoice_id UUID;
|
||||
conflicting_supplier_invoice_id UUID;
|
||||
BEGIN
|
||||
-- transactions.invoice_id must not contradict the invoice_payments table.
|
||||
IF NEW.invoice_id IS NOT NULL THEN
|
||||
SELECT ip.invoice_id INTO conflicting_invoice_id
|
||||
FROM public.invoice_payments ip
|
||||
WHERE ip.transaction_id = NEW.id
|
||||
AND ip.invoice_id <> NEW.invoice_id
|
||||
LIMIT 1;
|
||||
IF conflicting_invoice_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'transactions.invoice_id=% contradicts invoice_payments(invoice_id=%) for tx %',
|
||||
NEW.invoice_id, conflicting_invoice_id, NEW.id
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Same for supplier_invoice_id.
|
||||
IF NEW.supplier_invoice_id IS NOT NULL THEN
|
||||
SELECT sip.supplier_invoice_id INTO conflicting_supplier_invoice_id
|
||||
FROM public.supplier_invoice_payments sip
|
||||
WHERE sip.transaction_id = NEW.id
|
||||
AND sip.supplier_invoice_id <> NEW.supplier_invoice_id
|
||||
LIMIT 1;
|
||||
IF conflicting_supplier_invoice_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'transactions.supplier_invoice_id=% contradicts supplier_invoice_payments(supplier_invoice_id=%) for tx %',
|
||||
NEW.supplier_invoice_id, conflicting_supplier_invoice_id, NEW.id
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- BEFORE INSERT OR UPDATE so an INSERT carrying both invoice_id and a pre-
|
||||
-- existing payment row (unusual but possible via direct DB insert) also gets
|
||||
-- caught. The UPDATE path is the common one (the match endpoints set
|
||||
-- invoice_id after inserting the payment row).
|
||||
CREATE TRIGGER trg_block_contradictory_invoice_denorm
|
||||
BEFORE INSERT OR UPDATE OF invoice_id, supplier_invoice_id
|
||||
ON public.transactions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.block_contradictory_invoice_denorm();
|
||||
|
||||
COMMENT ON COLUMN public.transactions.invoice_id IS
|
||||
'Denormalized link for the 1:1 tx → invoice case. NULL when the tx settles multiple invoices (truth lives in invoice_payments). Guarded by trg_block_contradictory_invoice_denorm.';
|
||||
COMMENT ON COLUMN public.transactions.supplier_invoice_id IS
|
||||
'Denormalized link for the 1:1 tx → supplier_invoice case. NULL when the tx settles multiple supplier invoices (truth lives in supplier_invoice_payments). Guarded by trg_block_contradictory_invoice_denorm.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 3. is_transaction_booked(uuid)
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.is_transaction_booked(p_transaction_id UUID)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
AS $$
|
||||
SELECT
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.transactions t
|
||||
WHERE t.id = p_transaction_id
|
||||
AND t.journal_entry_id IS NOT NULL
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.invoice_payments ip
|
||||
WHERE ip.transaction_id = p_transaction_id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.supplier_invoice_payments sip
|
||||
WHERE sip.transaction_id = p_transaction_id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.transaction_voucher_links tvl
|
||||
WHERE tvl.transaction_id = p_transaction_id
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.is_transaction_booked(UUID) IS
|
||||
'Returns true if the transaction is anchored to ANY verifikat — directly via journal_entry_id, indirectly via a payment row, or via the transaction_voucher_links junction. Single source of truth for "is this booked?" used by inbox filters, reconciliation status, and MCP list_uncategorized_transactions.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,190 @@
|
||||
-- PR #602 review fix — atomic supplier-invoice voucher linking RPC.
|
||||
--
|
||||
-- Closes the race surfaced by greptile review: the TS-side
|
||||
-- linkSupplierInvoiceToVoucher() updated the supplier_invoices row first, then
|
||||
-- inserted the supplier_invoice_payments row, with a manual unconditional
|
||||
-- rollback on insert failure. Under concurrent linking against the same
|
||||
-- invoice (A starts on `registered`, B completes to `paid`, A's insert fails
|
||||
-- and the rollback overwrites B's `paid` back to `registered`) the rollback
|
||||
-- could clobber a sibling's successful write while leaving its payment row
|
||||
-- in place. This RPC moves both writes into a single Postgres transaction
|
||||
-- so PG's own rollback handles the failure path correctly.
|
||||
--
|
||||
-- Mirrors the existing commit_journal_entry pattern (atomic voucher commit).
|
||||
-- The TS wrapper now reads the validated invoice + voucher state from the
|
||||
-- RPC return payload and only emits the `supplier_invoice.paid` event on the
|
||||
-- happy path.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.link_supplier_invoice_to_voucher(
|
||||
p_supplier_invoice_id uuid,
|
||||
p_journal_entry_id uuid,
|
||||
p_user_id uuid,
|
||||
p_company_id uuid,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_invoice RECORD;
|
||||
v_voucher RECORD;
|
||||
v_ap_debit_total numeric := 0;
|
||||
v_line_currency text;
|
||||
v_remaining numeric;
|
||||
v_payment_amount numeric;
|
||||
v_new_paid numeric;
|
||||
v_new_remaining numeric;
|
||||
v_new_status text;
|
||||
v_is_fully_paid boolean;
|
||||
v_now timestamptz := now();
|
||||
v_payment_id uuid;
|
||||
BEGIN
|
||||
-- 1. Lock invoice for the duration of this transaction. FOR UPDATE so a
|
||||
-- concurrent linker has to wait until we commit (or roll back).
|
||||
SELECT * INTO v_invoice
|
||||
FROM public.supplier_invoices
|
||||
WHERE id = p_supplier_invoice_id AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
IF v_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID',
|
||||
'details', jsonb_build_object('status', v_invoice.status)
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Trust the stored remaining_amount when present (even when 0), only fall
|
||||
-- through to total - paid_amount when the column is NULL. The "> 0" guard
|
||||
-- was the original sin from voucher-matching.ts; rounding drift on a
|
||||
-- fully-paid invoice persisted as remaining_amount=0 could compute a
|
||||
-- residual via total - paid_amount and slip past the FULLY_PAID guard.
|
||||
v_remaining := COALESCE(v_invoice.remaining_amount,
|
||||
v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
|
||||
IF v_remaining <= 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID');
|
||||
END IF;
|
||||
|
||||
-- 2. Resolve the voucher
|
||||
SELECT * INTO v_voucher
|
||||
FROM public.journal_entries
|
||||
WHERE id = p_journal_entry_id AND company_id = p_company_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
IF v_voucher.status <> 'posted' THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_SI_VOUCHER_NOT_POSTED',
|
||||
'details', jsonb_build_object('status', v_voucher.status)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT',
|
||||
'details', jsonb_build_object('source_type', v_voucher.source_type)
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- 3. Sum AP debit on 2440 across all lines in this voucher.
|
||||
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
|
||||
INTO v_ap_debit_total, v_line_currency
|
||||
FROM public.journal_entry_lines
|
||||
WHERE journal_entry_id = p_journal_entry_id
|
||||
AND account_number = '2440'
|
||||
AND debit_amount > 0;
|
||||
|
||||
v_ap_debit_total := ROUND(v_ap_debit_total * 100) / 100;
|
||||
|
||||
IF v_ap_debit_total <= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT');
|
||||
END IF;
|
||||
|
||||
IF COALESCE(v_line_currency, v_invoice.currency) IS DISTINCT FROM v_invoice.currency THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object(
|
||||
'invoice_currency', v_invoice.currency,
|
||||
'line_currency', v_line_currency
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_ap_debit_total > v_remaining + 0.005 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
|
||||
'details', jsonb_build_object(
|
||||
'ap_debit', v_ap_debit_total,
|
||||
'remaining', ROUND(v_remaining * 100) / 100
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- 4. Reject re-link of the same voucher to the same invoice.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.supplier_invoice_payments
|
||||
WHERE company_id = p_company_id
|
||||
AND supplier_invoice_id = p_supplier_invoice_id
|
||||
AND journal_entry_id = p_journal_entry_id
|
||||
) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_ALREADY_LINKED');
|
||||
END IF;
|
||||
|
||||
-- 5. Compute the advance.
|
||||
v_payment_amount := LEAST(v_ap_debit_total, ROUND(v_remaining * 100) / 100);
|
||||
v_new_remaining := GREATEST(0,
|
||||
ROUND((v_remaining - v_payment_amount) * 100) / 100
|
||||
);
|
||||
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
|
||||
v_is_fully_paid := v_new_remaining <= 0.005;
|
||||
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
|
||||
|
||||
-- 6. Apply both writes. The RPC body is one transaction; a failure on the
|
||||
-- INSERT triggers PG's own rollback of the UPDATE — no manual rollback
|
||||
-- path needed.
|
||||
UPDATE public.supplier_invoices
|
||||
SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_is_fully_paid THEN v_now ELSE paid_at END,
|
||||
paid_amount = v_new_paid,
|
||||
remaining_amount = v_new_remaining,
|
||||
updated_at = v_now
|
||||
WHERE id = p_supplier_invoice_id;
|
||||
|
||||
INSERT INTO public.supplier_invoice_payments (
|
||||
user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
|
||||
journal_entry_id, transaction_id, notes
|
||||
) VALUES (
|
||||
p_user_id, p_company_id, p_supplier_invoice_id, v_voucher.entry_date,
|
||||
v_payment_amount, v_invoice.currency, p_journal_entry_id, NULL, p_notes
|
||||
)
|
||||
RETURNING id INTO v_payment_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'payment_id', v_payment_id,
|
||||
'invoice_status', v_new_status,
|
||||
'paid_amount', v_new_paid,
|
||||
'remaining_amount', v_new_remaining,
|
||||
'payment_amount', v_payment_amount,
|
||||
'journal_entry_id', p_journal_entry_id,
|
||||
'currency', v_invoice.currency
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.link_supplier_invoice_to_voucher(uuid, uuid, uuid, uuid, text) IS
|
||||
'Atomically link an existing posted verifikat as payment for a supplier invoice. Locks the invoice row, validates the voucher debits 2440, advances paid_amount/remaining_amount/status, and inserts a supplier_invoice_payments row in one PG transaction. Returns jsonb { ok, ..., payment_id } on success or { ok: false, code, details } on guard failure.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,144 @@
|
||||
-- PR #602 Swedish-compliance fix — broaden AP-account check in
|
||||
-- link_supplier_invoice_to_voucher to cover the full BAS 2440–2449 range
|
||||
-- (2440 Leverantörsskulder, 2441 Leverantörsskulder i utländsk valuta,
|
||||
-- 2443 Skuldfakturor leverantörer, etc.). A samlingsverifikat paying mixed
|
||||
-- SEK + EUR suppliers will legitimately debit both 2440 and 2441; the
|
||||
-- earlier hardcode rejected the latter with LINK_SI_VOUCHER_NO_AP_DEBIT
|
||||
-- even though the booking was BAS-compliant.
|
||||
--
|
||||
-- Mirrors the TS-side change in lib/invoices/supplier-voucher-matching.ts
|
||||
-- where AP_ACCOUNT='2440' became AP_ACCOUNT_PREFIX='244'.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.link_supplier_invoice_to_voucher(
|
||||
p_supplier_invoice_id uuid,
|
||||
p_journal_entry_id uuid,
|
||||
p_user_id uuid,
|
||||
p_company_id uuid,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_invoice RECORD;
|
||||
v_voucher RECORD;
|
||||
v_ap_debit_total numeric := 0;
|
||||
v_line_currency text;
|
||||
v_remaining numeric;
|
||||
v_payment_amount numeric;
|
||||
v_new_paid numeric;
|
||||
v_new_remaining numeric;
|
||||
v_new_status text;
|
||||
v_is_fully_paid boolean;
|
||||
v_now timestamptz := now();
|
||||
v_payment_id uuid;
|
||||
BEGIN
|
||||
SELECT * INTO v_invoice
|
||||
FROM public.supplier_invoices
|
||||
WHERE id = p_supplier_invoice_id AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
IF v_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID',
|
||||
'details', jsonb_build_object('status', v_invoice.status));
|
||||
END IF;
|
||||
|
||||
v_remaining := COALESCE(v_invoice.remaining_amount, v_invoice.total - COALESCE(v_invoice.paid_amount, 0));
|
||||
IF v_remaining <= 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_voucher
|
||||
FROM public.journal_entries
|
||||
WHERE id = p_journal_entry_id AND company_id = p_company_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
IF v_voucher.status <> 'posted' THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NOT_POSTED',
|
||||
'details', jsonb_build_object('status', v_voucher.status));
|
||||
END IF;
|
||||
|
||||
IF v_voucher.source_type IN ('opening_balance', 'storno') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT',
|
||||
'details', jsonb_build_object('source_type', v_voucher.source_type));
|
||||
END IF;
|
||||
|
||||
-- Sum AP debit across the full 244x range (was: account_number = '2440').
|
||||
SELECT COALESCE(SUM(debit_amount), 0), MAX(currency)
|
||||
INTO v_ap_debit_total, v_line_currency
|
||||
FROM public.journal_entry_lines
|
||||
WHERE journal_entry_id = p_journal_entry_id
|
||||
AND account_number LIKE '244%'
|
||||
AND debit_amount > 0;
|
||||
|
||||
v_ap_debit_total := ROUND(v_ap_debit_total * 100) / 100;
|
||||
|
||||
IF v_ap_debit_total <= 0 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_NO_AP_DEBIT');
|
||||
END IF;
|
||||
|
||||
IF COALESCE(v_line_currency, v_invoice.currency) IS DISTINCT FROM v_invoice.currency THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
|
||||
'details', jsonb_build_object('invoice_currency', v_invoice.currency, 'line_currency', v_line_currency));
|
||||
END IF;
|
||||
|
||||
IF v_ap_debit_total > v_remaining + 0.005 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
|
||||
'details', jsonb_build_object('ap_debit', v_ap_debit_total, 'remaining', ROUND(v_remaining * 100) / 100));
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.supplier_invoice_payments
|
||||
WHERE company_id = p_company_id
|
||||
AND supplier_invoice_id = p_supplier_invoice_id
|
||||
AND journal_entry_id = p_journal_entry_id
|
||||
) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'LINK_SI_VOUCHER_ALREADY_LINKED');
|
||||
END IF;
|
||||
|
||||
v_payment_amount := LEAST(v_ap_debit_total, ROUND(v_remaining * 100) / 100);
|
||||
v_new_remaining := GREATEST(0, ROUND((v_remaining - v_payment_amount) * 100) / 100);
|
||||
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_payment_amount) * 100) / 100;
|
||||
v_is_fully_paid := v_new_remaining <= 0.005;
|
||||
v_new_status := CASE WHEN v_is_fully_paid THEN 'paid' ELSE 'partially_paid' END;
|
||||
|
||||
UPDATE public.supplier_invoices
|
||||
SET status = v_new_status,
|
||||
paid_at = CASE WHEN v_is_fully_paid THEN v_now ELSE paid_at END,
|
||||
paid_amount = v_new_paid,
|
||||
remaining_amount = v_new_remaining,
|
||||
updated_at = v_now
|
||||
WHERE id = p_supplier_invoice_id;
|
||||
|
||||
INSERT INTO public.supplier_invoice_payments (
|
||||
user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
|
||||
journal_entry_id, transaction_id, notes
|
||||
) VALUES (
|
||||
p_user_id, p_company_id, p_supplier_invoice_id, v_voucher.entry_date,
|
||||
v_payment_amount, v_invoice.currency, p_journal_entry_id, NULL, p_notes
|
||||
)
|
||||
RETURNING id INTO v_payment_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'payment_id', v_payment_id,
|
||||
'invoice_status', v_new_status,
|
||||
'paid_amount', v_new_paid,
|
||||
'remaining_amount', v_new_remaining,
|
||||
'payment_amount', v_payment_amount,
|
||||
'journal_entry_id', p_journal_entry_id,
|
||||
'currency', v_invoice.currency
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,367 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
insertAuthUser,
|
||||
insertCompany,
|
||||
insertCompanyMember,
|
||||
insertFiscalPeriod,
|
||||
} from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* Covers 20260529120000_transaction_voucher_links per PR #602 review note:
|
||||
* - transaction_voucher_links table is RLS-policied + indexed
|
||||
* - block_contradictory_invoice_denorm trigger refuses an UPDATE that
|
||||
* would set transactions.invoice_id (or supplier_invoice_id) to a value
|
||||
* contradicting an existing payment row.
|
||||
* - is_transaction_booked(uuid) returns true when ANY of:
|
||||
* transactions.journal_entry_id IS NOT NULL,
|
||||
* invoice_payments references the tx,
|
||||
* supplier_invoice_payments references the tx,
|
||||
* transaction_voucher_links references the tx
|
||||
* …and false otherwise.
|
||||
*
|
||||
* Tests write via the superuser pool (bypass RLS) — the goal is to exercise
|
||||
* trigger logic + the helper's SQL truth, not the policy layer.
|
||||
*/
|
||||
|
||||
async function insertCustomer(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.customers
|
||||
(id, user_id, company_id, name, customer_type, country)
|
||||
VALUES ($1, $2, $3, 'Kund AB', 'swedish_business', 'SE')`,
|
||||
[id, params.userId, params.companyId],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertInvoice(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
customerId: string
|
||||
total?: number
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
const invoiceNumber = `F-${Date.now() % 1_000_000}-${Math.floor(Math.random() * 1_000)}`
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date, status,
|
||||
currency, subtotal, vat_amount, total, paid_amount, remaining_amount, vat_treatment)
|
||||
VALUES ($1, $2, $3, $4, $5, '2026-06-01', '2026-07-01', 'sent', 'SEK',
|
||||
$6, 0, $6, 0, $6, 'standard_25')`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.customerId,
|
||||
invoiceNumber,
|
||||
params.total ?? 1000,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertSupplier(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.suppliers
|
||||
(id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency)
|
||||
VALUES ($1, $2, $3, 'Leverantör AB', 'swedish_business', 'SE', 30, 'SEK')`,
|
||||
[id, params.userId, params.companyId],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertSupplierInvoice(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
supplierId: string
|
||||
total?: number
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
const arrivalNumber = (Date.now() % 1_000_000_000) + Math.floor(Math.random() * 10_000)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_invoices
|
||||
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
|
||||
invoice_date, due_date, received_date, status, currency,
|
||||
subtotal, vat_amount, total, paid_amount, remaining_amount,
|
||||
vat_treatment, reverse_charge, is_credit_note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, '2026-06-01', '2026-07-01', '2026-06-02',
|
||||
'approved', 'SEK', $7, 0, $7, 0, $7, 'standard_25', false, false)`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.supplierId,
|
||||
arrivalNumber,
|
||||
`LF-${arrivalNumber}`,
|
||||
params.total ?? 1000,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertTransaction(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
amount?: number
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.transactions
|
||||
(id, user_id, company_id, date, description, amount, currency, category)
|
||||
VALUES ($1, $2, $3, '2026-06-05', 'Bank transfer', $4, 'SEK', 'uncategorized')`,
|
||||
[id, params.userId, params.companyId, params.amount ?? 1000],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function seedTenant() {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId, role: 'owner' })
|
||||
const fiscalPeriodId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
periodStart: '2026-01-01',
|
||||
periodEnd: '2026-12-31',
|
||||
})
|
||||
return { userId, companyId, fiscalPeriodId }
|
||||
}
|
||||
|
||||
describe('block_contradictory_invoice_denorm trigger', () => {
|
||||
it('blocks an UPDATE that sets transactions.invoice_id to a value contradicting an invoice_payments row', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedTenant()
|
||||
const customerId = await insertCustomer({ userId, companyId })
|
||||
|
||||
const invoiceA = await insertInvoice({ userId, companyId, customerId, total: 1000 })
|
||||
const invoiceB = await insertInvoice({ userId, companyId, customerId, total: 2000 })
|
||||
const txId = await insertTransaction({ userId, companyId, amount: 1000 })
|
||||
|
||||
// Create a posted journal_entries row so we can satisfy invoice_payments.journal_entry_id FK
|
||||
const jeId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status)
|
||||
VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`,
|
||||
[jeId, userId, companyId, fiscalPeriodId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', 1000, 0), ($1, '1510', 0, 1000)`,
|
||||
[jeId],
|
||||
)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
|
||||
|
||||
// Insert invoice_payments row linking tx → invoiceA
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoice_payments
|
||||
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id, transaction_id)
|
||||
VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`,
|
||||
[userId, companyId, invoiceA, jeId, txId],
|
||||
)
|
||||
|
||||
// Now try to set transactions.invoice_id to invoiceB (contradiction)
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.transactions SET invoice_id = $1 WHERE id = $2`, [
|
||||
invoiceB,
|
||||
txId,
|
||||
]),
|
||||
).rejects.toThrow(/contradicts invoice_payments/)
|
||||
})
|
||||
|
||||
it('permits an UPDATE that sets transactions.invoice_id to the same value as the existing payment row', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedTenant()
|
||||
const customerId = await insertCustomer({ userId, companyId })
|
||||
const invoiceA = await insertInvoice({ userId, companyId, customerId, total: 1000 })
|
||||
const txId = await insertTransaction({ userId, companyId, amount: 1000 })
|
||||
|
||||
const jeId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status)
|
||||
VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`,
|
||||
[jeId, userId, companyId, fiscalPeriodId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', 1000, 0), ($1, '1510', 0, 1000)`,
|
||||
[jeId],
|
||||
)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoice_payments
|
||||
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id, transaction_id)
|
||||
VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`,
|
||||
[userId, companyId, invoiceA, jeId, txId],
|
||||
)
|
||||
|
||||
// Setting invoice_id to the SAME id should succeed
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.transactions SET invoice_id = $1 WHERE id = $2`, [
|
||||
invoiceA,
|
||||
txId,
|
||||
]),
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('blocks an UPDATE that sets transactions.supplier_invoice_id to a value contradicting a supplier_invoice_payments row', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedTenant()
|
||||
const supplierId = await insertSupplier({ userId, companyId })
|
||||
const siA = await insertSupplierInvoice({ userId, companyId, supplierId, total: 1000 })
|
||||
const siB = await insertSupplierInvoice({ userId, companyId, supplierId, total: 2000 })
|
||||
const txId = await insertTransaction({ userId, companyId, amount: -1000 })
|
||||
|
||||
const jeId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status)
|
||||
VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`,
|
||||
[jeId, userId, companyId, fiscalPeriodId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '2440', 1000, 0), ($1, '1930', 0, 1000)`,
|
||||
[jeId],
|
||||
)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_invoice_payments
|
||||
(user_id, company_id, supplier_invoice_id, payment_date, amount, currency,
|
||||
journal_entry_id, transaction_id)
|
||||
VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`,
|
||||
[userId, companyId, siA, jeId, txId],
|
||||
)
|
||||
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.transactions SET supplier_invoice_id = $1 WHERE id = $2`, [
|
||||
siB,
|
||||
txId,
|
||||
]),
|
||||
).rejects.toThrow(/contradicts supplier_invoice_payments/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('is_transaction_booked', () => {
|
||||
it('returns false for a fresh, unbooked transaction', async () => {
|
||||
const { userId, companyId } = await seedTenant()
|
||||
const txId = await insertTransaction({ userId, companyId })
|
||||
const r = await getPool().query<{ is_transaction_booked: boolean }>(
|
||||
`SELECT is_transaction_booked($1)`,
|
||||
[txId],
|
||||
)
|
||||
expect(r.rows[0]!.is_transaction_booked).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when transactions.journal_entry_id is set', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedTenant()
|
||||
const txId = await insertTransaction({ userId, companyId })
|
||||
|
||||
const jeId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status)
|
||||
VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`,
|
||||
[jeId, userId, companyId, fiscalPeriodId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', 1000, 0), ($1, '3001', 0, 1000)`,
|
||||
[jeId],
|
||||
)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
|
||||
|
||||
await getPool().query(`UPDATE public.transactions SET journal_entry_id = $1 WHERE id = $2`, [
|
||||
jeId,
|
||||
txId,
|
||||
])
|
||||
|
||||
const r = await getPool().query<{ is_transaction_booked: boolean }>(
|
||||
`SELECT is_transaction_booked($1)`,
|
||||
[txId],
|
||||
)
|
||||
expect(r.rows[0]!.is_transaction_booked).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when only an invoice_payments row references the tx (multi-allocation case)', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedTenant()
|
||||
const customerId = await insertCustomer({ userId, companyId })
|
||||
const invoiceId = await insertInvoice({ userId, companyId, customerId })
|
||||
const txId = await insertTransaction({ userId, companyId })
|
||||
|
||||
const jeId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status)
|
||||
VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`,
|
||||
[jeId, userId, companyId, fiscalPeriodId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', 1000, 0), ($1, '1510', 0, 1000)`,
|
||||
[jeId],
|
||||
)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
|
||||
|
||||
// No tx.journal_entry_id set, but a payment row exists referencing tx.
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoice_payments
|
||||
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id, transaction_id)
|
||||
VALUES ($1, $2, $3, '2026-06-05', 1000, 'SEK', $4, $5)`,
|
||||
[userId, companyId, invoiceId, jeId, txId],
|
||||
)
|
||||
|
||||
const r = await getPool().query<{ is_transaction_booked: boolean }>(
|
||||
`SELECT is_transaction_booked($1)`,
|
||||
[txId],
|
||||
)
|
||||
expect(r.rows[0]!.is_transaction_booked).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when only a transaction_voucher_links row references the tx', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedTenant()
|
||||
const txId = await insertTransaction({ userId, companyId })
|
||||
|
||||
const jeId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status)
|
||||
VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Test', 'manual', 'draft')`,
|
||||
[jeId, userId, companyId, fiscalPeriodId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', 1000, 0), ($1, '3001', 0, 1000)`,
|
||||
[jeId],
|
||||
)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.transaction_voucher_links
|
||||
(user_id, company_id, transaction_id, journal_entry_id, allocated_amount)
|
||||
VALUES ($1, $2, $3, $4, 1000)`,
|
||||
[userId, companyId, txId, jeId],
|
||||
)
|
||||
|
||||
const r = await getPool().query<{ is_transaction_booked: boolean }>(
|
||||
`SELECT is_transaction_booked($1)`,
|
||||
[txId],
|
||||
)
|
||||
expect(r.rows[0]!.is_transaction_booked).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user