fix(invoices): settle öresavrundning in batch match and close stuck partially_paid invoices (#1829)

A whole-krona payment (Bankgiro/Swish/kort) of an öre-bearing invoice
left the invoice hanging: the match_batch_allocate RPC (samlingsbetalning
dialog + MCP path) had no öre handling, so a sub-krona overshoot was
rejected as BATCH_OVERSHOOT and a sub-krona shortfall parked the invoice
in partially_paid forever. Invoices already stuck that way had no exit:
the mark-paid dialog proposed clearing the full total (rejected with
MATCH_AMOUNT_EXCEEDS_REMAINING) and the route refused partially_paid
outright.

Fix, both halves:

1. New migration 20260824120000 replaces match_batch_allocate with the
   same öresavrundning band every single-payment path already uses
   (ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr, lib/money.ts): overshoot
   rejected only at >= 1 kr; a 0 < |remaining - allocation| < 1 kr diff
   clears the FULL remaining off 1510/2440, books the residual to 3740
   with correct polarity per side, records the full remaining as paid
   and flips the status to paid. >= 1 kr diffs keep today's behaviour.

2. proposePaymentLines is remaining-aware: a partially_paid SEK accrual
   invoice gets a proposal clearing the actual remaining, and a
   sub-krona remaining gets a bank-less Dr 3740 / Cr 1510 write-off so
   one click closes a stuck invoice. The mark-paid route and the
   invoice-detail button now accept partially_paid (the settle layer's
   CAS guard always did).

Forward-only: already-stuck invoices are not auto-repaired; they are
closed via the new dialog proposal.


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-24 13:19:32 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 6aecbdc9b7
commit 62135c0c02
9 changed files with 1302 additions and 8 deletions
+1
View File
@@ -1171,6 +1171,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-22] Per-company invoice sending domains are gated by a manually granted capability (custom_sender_domain), deliberately NOT in PAID_CAPABILITIES: the opt-in must not be trial-seeded or written by the Stripe subscription sync, and non-grantees must see an unchanged invoicing settings page (the section hides on the 403 capability_blocked envelope). The sending-domain module has no Resend orphan-adoption path (a name that already exists is a 409), because the same Resend account holds the platform's own outbound domain. The delivery log was left untouched (no from_address column): adding it would re-open the hardened invoice_deliveries evidence triggers/redaction paths for a nice-to-have, and the log already measures delivered/bounced per send.
[2026-08-22] company_sending_domains verification state (domain, status, resend_domain_id, dns_records, verified_at, last_checked_at) is service-role only via a BEFORE trigger keyed on the JWT role claim; tenant JWTs may only open a pending claim and edit sender_local_part/sender_name/enabled. Skeptic refutation: RLS alone let a granted admin insert {domain: platform sender domain, status: verified} through PostgREST and send invoice mail as the platform. The claim/verify helpers therefore take a separate service-role writer for those columns. Second refutation: a domain Resend later flips to failed made every invoice send for that company fail; the Resend adapter now retries once as the platform sender when an explicit company From is rejected (nothing was sent on the rejected attempt, so the retry cannot double-send).
[2026-08-22] Sending-domain verification writes bind by (id, company_id, domain, resend_domain_id IS NULL) and verify/webhook compare Resend's domain name with the row before writing verified; resolveInvoiceSender additionally refuses reserved platform domains and non-hostnames at send time. Skeptic re-check: a tenant could delete and re-insert its pending row under the same id with a reserved domain during the claim's Resend round-trip (TOCTOU), and the service-role writer updated by id alone. Defense in depth over a single gate.
[2026-08-24] #1717 öre settlement in match_batch_allocate replicates the ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr policy band in SQL (reject overshoot only at >= 1 kr; absorb sub-krona diffs to 3740 with full 1510/2440 clear) instead of routing the batch through planInvoicePayment: the RPC is one atomic transaction and must stay self-contained; the band constants are documented as mirrors of lib/money.ts. The mark-paid route and invoice-detail button now accept partially_paid (settleInvoicePayment's CAS guard always did) so a stuck invoice can be closed; the remaining-aware dialog proposal is deliberately scoped to SEK + accrual + no ROT/RUT deduction: cash partial completion is server-blocked (cashPartialBlockReason), FX needs a payment-day conversion the pure proposal lacks, and a ROT/RUT remainder is Skatteverket's 1513 share settled by the payout flow.
[2026-08-24] Assistant empty answer is a typed failure (EmptyModelAnswerError, 502 'Assistenten gav inget svar. Försök igen.'), one manual retry, NO auto-retry: the silent 200 with an empty answer was the 'Tänker then nothing' bug after the RIP-3 cutover (1500-token cap on /api/agent/ask), and auto-retry would double model spend while hiding the regression. Related deliberate request-shape change: anthropic-family's step-exhausted fallback now keeps tools with tool_choice none, because replaying a transcript containing tool_use/tool_result without declaring tools is rejected by the Messages API (the old shape 400ed every step-exhausted answer). general.help stays on the single-call runtime (founder decision); the fix raises headroom to 5400 tokens and surfaces the failure instead.
[2026-08-24] Issue #1820 self-billed credit fix: creditConfirmNumber()/originalRef fall back invoice_number -> external_invoice_number (typed 400 INVOICE_CREDIT_NO_NUMBER if both null) instead of relaxing the DB numbering constraint or dropping the type-the-number confirm step; the confirm step stays (dropping it is a founder call). The invoice-date Forval chip surfaces in ALL editor modes, not only self-billed: the silent today-default exists in every mode and the chip line already carries the due date. In self-billed mode fakturadatum + mottagningsdatum render uncollapsed next to the external number (transcription fields, not defaults); the panel rows are hidden there because registering the same RHF field twice desyncs the inputs. The v1 credit route's existing id-slice fallback was left unchanged (public API behavior).
[2026-08-24] No-IBAN reconnect pairing (issue #1709) uses only per-currency exactly-one-each-side elimination, deliberately WITHOUT name equality: ASPSPs reformat product names between consents, so requiring it would silently disable the fix for the banks that need it, while the one-per-currency guard already bounds a mis-pair to skipping rows whose account+date+amount+occurrence all collide. upsertFromPsd2 needed no change: its explicit reuse_cash_account_id promote path already covers a same-connection holder, so the fix only names the paired row from the callback.
+3 -1
View File
@@ -1540,7 +1540,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{t('mark_as_sent')}
</Button>
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && !isCreditNote && (
{/* partially_paid included (#1717): completes a stuck partial, e.g.
a sub-krona öresavrundning remaining, via the same dialog. */}
{(invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'partially_paid') && isRealInvoice && !isCreditNote && (
<Button
onClick={() => setShowPaymentDialog(true)}
disabled={isUpdating || !canWrite}
@@ -567,6 +567,52 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(body.journal_entry_id).toBe('je-ore')
})
it('accepts a partially_paid invoice and closes it with a bank-less öre write-off (#1717)', async () => {
// An invoice stuck with a sub-krona remaining (öresavrundning on an
// earlier payment path). The dialog proposes Dr 3740 / Cr 1510 for the
// remaining; booking it flips the invoice to paid. No customer →
// duplicate guard skips.
const invoice = makeInvoice({
id: 'inv-1',
status: 'partially_paid',
total: 12500.4,
paid_amount: 12500,
remaining_amount: 0.4,
})
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
mockFindFiscalPeriod.mockResolvedValue('fp-1')
mockCreateJournalEntry.mockResolvedValue({ id: 'je-writeoff' })
const writeOffLines = [
{ account_number: '3740', debit_amount: 0.4, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 0.4 },
]
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: { lines: writeOffLines },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
status: string
paid_amount: number
remaining_amount: number
journal_entry_id: string
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.status).toBe('paid')
expect(body.paid_amount).toBe(12500.4)
expect(body.remaining_amount).toBe(0)
expect(body.journal_entry_id).toBe('je-writeoff')
})
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when custom lines overpay the invoice', async () => {
// No customer → duplicate guard skips; the overpayment guard must reject
// BEFORE any journal entry is created (planInvoicePayment runs first).
+9 -1
View File
@@ -60,7 +60,15 @@ export const POST = withRouteContext(
})
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
// partially_paid is payable (#1717): an invoice stuck with a sub-krona
// remaining (öresavrundning) or an ordinary open partial is completed
// here. settleInvoicePayment's plan math and CAS guard already handle
// the state; only this route-level gate excluded it.
if (
invoice.status !== 'sent' &&
invoice.status !== 'overdue' &&
invoice.status !== 'partially_paid'
) {
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
requestId,
details: { currentStatus: invoice.status },
@@ -166,6 +166,10 @@ export default function PaymentBookingDialog({
default_dimensions: invoice.default_dimensions,
ore_rounding: invoice.ore_rounding,
deduction_total: invoice.deduction_total,
// #1717: lets the proposal clear the actual remaining on a
// partially_paid invoice (öre write-off when < 1 kr remains).
paid_amount: invoice.paid_amount,
remaining_amount: invoice.remaining_amount,
},
accountingMethod,
entityType,
@@ -323,6 +323,160 @@ describe('proposePaymentLines: öresavrundning (3740)', () => {
})
})
describe('proposePaymentLines: remaining-aware partials (#1717)', () => {
it('sub-krona remaining → bank-less öre write-off (Dr 3740 / Cr 1510)', () => {
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({ total: 12500.4 }),
paid_amount: 12500,
remaining_amount: 0.4,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines).toEqual([
{
account_number: '3740',
debit_amount: '0.4',
credit_amount: '',
line_description: 'Öresavrundning',
},
{
account_number: '1510',
debit_amount: '',
credit_amount: '0.4',
line_description: 'Betalning faktura 2025-001',
},
])
})
it('remaining >= 1 kr → clears the remaining, not the total', () => {
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({ total: 12500 }),
paid_amount: 5000,
remaining_amount: 7500,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
paymentAccount: '1920',
})
expect(lines).toHaveLength(2)
expect(lines[0]).toMatchObject({ account_number: '1920', debit_amount: '7500' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '7500' })
})
it('derives remaining from paid_amount when remaining_amount is absent', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput({ total: 12500.4 }), paid_amount: 12500 },
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines[0]).toMatchObject({ account_number: '3740', debit_amount: '0.4' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '0.4' })
})
it('fully unpaid invoice keeps the fresh proposal byte-identical (incl. öresavrundning)', () => {
const fresh = proposePaymentLines({
invoice: makeInvoiceInput({ total: 1234.75 }),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
const withZeroPaid = proposePaymentLines({
invoice: {
...makeInvoiceInput({ total: 1234.75 }),
paid_amount: 0,
remaining_amount: 1234.75,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(withZeroPaid).toEqual(fresh)
expect(withZeroPaid[2]).toMatchObject({ account_number: '3740' })
})
it('cash method partial keeps the legacy full-invoice proposal', () => {
// The server refuses cash partial completion (cashPartialBlockReason), so
// a remaining-based cash proposal would only dress up a rejected booking.
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput(),
paid_amount: 5000,
remaining_amount: 7500,
},
accountingMethod: 'cash',
entityType: 'enskild_firma',
})
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '12500' })
})
it('foreign-currency partial keeps the legacy proposal', () => {
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({
total: 1000,
total_sek: 10000,
currency: 'EUR',
exchange_rate: 10,
}),
paid_amount: 500,
remaining_amount: 500,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '10000' })
expect(lines.every((l) => l.account_number !== '3740')).toBe(true)
})
it('ROT/RUT invoice with a partial keeps the deduction-aware proposal', () => {
// The outstanding remainder on a ROT/RUT invoice is Skatteverket's share
// on 1513, settled by the ROT/RUT payout flow: never proposed as a 1510
// clearing here.
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput(),
deduction_total: 3750,
paid_amount: 8750,
remaining_amount: 3750,
},
accountingMethod: 'accrual',
entityType: 'aktiebolag',
})
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '8750' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '8750' })
})
it('write-off lines carry the invoice default dimensions', () => {
const bag = { '6': 'P001' }
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({ total: 1000.25 }),
paid_amount: 1000,
remaining_amount: 0.25,
default_dimensions: bag,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(2)
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
expect(line.dimensions).not.toBe(bag)
}
})
})
describe('proposePaymentLines: dimensions propagation (PR7)', () => {
const bag = { '1': 'KS01', '6': 'P001' }
+100 -3
View File
@@ -5,7 +5,7 @@
* No DB or Supabase dependency: all inputs are plain data.
*/
import { resolveSekAmount, resolveSekAmountOrNull } from './currency-utils'
import { roundOre } from '@/lib/money'
import { roundOre, ORE_TOLERANCE, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
import {
getRevenueAccount,
getOutputVatAccount,
@@ -47,6 +47,15 @@ export interface ProposePaymentLinesInput {
* (the preview groups per rate); users can retag lines in the grid.
*/
default_dimensions?: Record<string, string> | null
/**
* Prior-payment state (#1717). When a partial payment exists the proposal
* must clear what actually remains, not the full total: a full-total
* proposal is rejected server-side with MATCH_AMOUNT_EXCEEDS_REMAINING,
* which left invoices stuck in partially_paid with an öre remaining.
* Absent or fully-unpaid values keep the proposal identical to before.
*/
paid_amount?: number | null
remaining_amount?: number | null
}
accountingMethod: 'accrual' | 'cash'
entityType: EntityType
@@ -96,6 +105,11 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
const paymentAccount = input.paymentAccount || '1930'
const desc = invoice.invoice_number ? `Betalning faktura ${invoice.invoice_number}` : 'Betalning faktura'
// #1717: an invoice with a prior partial payment gets a proposal that
// clears the actual remaining, never the full total.
const remainingAware = proposeRemainingAwareLines(invoice, accountingMethod, paymentAccount, desc)
if (remainingAware) return withInvoiceDimensions(remainingAware, invoice)
// Öresavrundning: when it applies (SEK, enabled, non-integer total) the
// customer pays the rounded "Att betala" from the PDF, not the stored öre
// total. Propose the bank leg at the rounded amount and let 3740 carry the
@@ -114,8 +128,91 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference, roundingDelta, deductionSek)
: proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta, deductionSek)
// Dimensions PR7: re-propagate the invoice default onto every proposed leg
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
return withInvoiceDimensions(lines, invoice)
}
/**
* Remaining-aware proposal for an invoice with a prior partial payment
* (#1717). Returns null whenever the legacy full-total proposal applies, so
* fresh unpaid invoices keep a byte-identical proposal.
*
* Scope: SEK + accrual + no ROT/RUT deduction only.
* - Cash-method partial completion is refused server-side
* (cashPartialBlockReason: the generated cash entry always books the full
* invoice), so a remaining-based cash proposal would only book a rejected
* entry with a nicer preview.
* - A foreign-currency remaining needs a payment-day FX conversion this
* pure function does not carry; the dialog's FX path already handles it.
* - On a ROT/RUT invoice the outstanding remainder is (or includes)
* Skatteverket's share, which sits on 1513 and is settled by the ROT/RUT
* payout flow, not by clearing 1510 here.
*
* Two shapes:
* - 0 < remaining < 1 kr (the stuck öresavrundning case): a bank-less
* write-off, Dr 3740 / Cr 1510, so one click closes the invoice. Polarity
* per buildInvoicePaymentClearingLines: the customer under-paid, so 3740
* takes the debit (öresavrundningsförlust).
* - remaining >= 1 kr: a normal clearing of the remaining, Dr bank /
* Cr 1510.
*/
function proposeRemainingAwareLines(
invoice: ProposePaymentLinesInput['invoice'],
accountingMethod: 'accrual' | 'cash',
paymentAccount: string,
desc: string,
): FormLine[] | null {
if (accountingMethod !== 'accrual') return null
if (invoice.currency !== 'SEK') return null
if ((invoice.deduction_total ?? 0) > 0) return null
const total = roundOre(invoice.total)
const remaining = roundOre(
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0),
)
const hasPartial = remaining > ORE_TOLERANCE && total - remaining > ORE_TOLERANCE
if (!hasPartial) return null
if (remaining < ORE_ROUNDING_SETTLEMENT_MAX) {
return [
{
account_number: '3740',
debit_amount: toFormAmount(remaining),
credit_amount: '',
line_description: 'Öresavrundning',
},
{
account_number: '1510',
debit_amount: '',
credit_amount: toFormAmount(remaining),
line_description: desc,
},
]
}
return [
{
account_number: paymentAccount,
debit_amount: toFormAmount(remaining),
credit_amount: '',
line_description: desc,
},
{
account_number: '1510',
debit_amount: '',
credit_amount: toFormAmount(remaining),
line_description: desc,
},
]
}
/**
* Dimensions PR7: re-propagate the invoice default onto every proposed leg
* (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
*/
function withInvoiceDimensions(
lines: FormLine[],
invoice: ProposePaymentLinesInput['invoice'],
): FormLine[] {
const bag = invoice.default_dimensions
if (bag && Object.keys(bag).length > 0) {
return lines.map((line) => ({ ...line, dimensions: { ...bag } }))
@@ -0,0 +1,618 @@
-- match_batch_allocate: absorb sub-krona öresavrundning so a whole-krona
-- settlement fully closes the invoice (issue #1717).
--
-- Swedish whole-krona payments (Bankgiro, Swish, kort) pay an öre-bearing
-- invoice total rounded to the nearest krona. Every single-payment match path
-- already absorbs that residual into BAS 3740 (Öres- och kronutjämning) via
-- planInvoicePayment / buildInvoicePaymentClearingLines, but this RPC (the
-- samlingsbetalning / batch dialog and the MCP pending-operations path) did
-- not: a same-currency allocation more than half an öre over the remaining
-- was rejected as BATCH_OVERSHOOT, and an allocation a few öre short cleared
-- only the allocation off 1510/2440, leaving the invoice hanging in
-- partially_paid with öre remaining forever.
--
-- Fix, mirroring the policy band in lib/money.ts (ORE_ROUNDING_SETTLEMENT_MAX
-- = 1.00 SEK) and lib/invoices/apply-invoice-payment.ts:
-- 1. Same-currency overshoot is rejected only when it is >= 1.00 kr
-- (a real overpayment); a sub-krona overshoot is öresavrundning.
-- 2. When 0 < |remaining - allocation| < 1.00 the FULL remaining is cleared
-- off 1510 (customer) / 2440 (supplier) and a 3740 line carries the
-- residual. Polarity per lib/bookkeeping/invoice-payment-lines.ts and
-- supplier-payment-lines.ts: customer short-paid = Dr 3740 (förlust),
-- over-paid = Cr 3740 (vinst); supplier is the mirror (bank paid less
-- than owed = Cr 3740, more = Dr 3740). The bank leg is unchanged
-- (tx_abs), so the verifikat stays balanced: the 3740 line takes exactly
-- the difference moved onto the AR/AP leg.
-- 3. The settlement loop records the full remaining as paid in that band,
-- so paid_amount accumulates the whole remaining, remaining_amount lands
-- on 0 and the status flips to 'paid' (same convention as
-- planInvoicePayment: invoice_payments.amount = the full remaining).
-- A shortfall or overshoot of >= 1.00 kr keeps today's behaviour exactly
-- (real partial payment / BATCH_OVERSHOOT).
--
-- The body is otherwise byte-for-byte the 20260817150000 definition
-- (service-actor resolution). Signature is unchanged, so CREATE OR REPLACE
-- keeps the existing grants; they are re-asserted below anyway for clarity.
--
-- pg-test: tests/pg/match-batch-allocate.pg.test.ts
CREATE OR REPLACE FUNCTION public.match_batch_allocate(
p_tx_id uuid,
p_allocations jsonb,
p_company_id uuid,
p_user_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_tx RECORD;
v_tx_abs numeric;
v_tx_date_short text;
v_allocation jsonb;
v_alloc_index int := 0;
v_kind text;
v_invoice_id uuid;
v_supplier_invoice_id uuid;
v_alloc_amount numeric;
v_total_allocated numeric := 0;
v_has_customer boolean := false;
v_has_supplier boolean := false;
v_seen_ids text[] := ARRAY[]::text[];
v_target_id text;
v_invoice RECORD;
v_si_invoice RECORD;
v_supplier_name text;
v_supplier_invoice_number text;
v_invoice_number text;
v_fiscal_period_id uuid;
v_period_is_closed boolean;
v_period_locked_at timestamptz;
v_journal_entry_id uuid := gen_random_uuid();
v_voucher_series text := 'A';
v_voucher_number int;
v_entry_description text;
v_source_type text;
v_line_sort_order int := 0;
v_new_paid numeric;
v_new_remaining numeric;
v_new_status text;
v_now timestamptz := now();
v_payment_id uuid;
v_results jsonb := '[]'::jsonb;
v_inv_remaining numeric;
v_inv_currency text;
v_inv_fx_rate numeric;
v_inv_total numeric;
v_booked_sek numeric;
v_fx_diff numeric;
v_paid_in_inv_currency numeric;
v_payment_rate numeric; -- round-3 (swedish-compliance traceability)
v_inv_number_short text;
v_caller uuid;
v_ore_diff numeric; -- remaining - allocation, öre-rounded (#1717)
BEGIN
-- Actor resolution. p_user_id is an assertion by the caller, so it is
-- honored ONLY when the caller holds the service role (the cookieless
-- server client used by the pending-operations commit path, where
-- auth.uid() is NULL). Any other caller is pinned to its own auth.uid():
-- otherwise an authenticated PostgREST caller could pass another user's
-- UUID and walk through the membership gate below. Same shape as
-- undo_sie_import (20260727121000).
IF auth.role() = 'service_role' THEN
v_caller := COALESCE(p_user_id, auth.uid());
ELSE
v_caller := auth.uid();
END IF;
IF v_caller IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED');
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.company_members
WHERE user_id = v_caller AND company_id = p_company_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED');
END IF;
SELECT * INTO v_tx FROM public.transactions
WHERE id = p_tx_id AND company_id = p_company_id FOR UPDATE;
IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_NOT_FOUND'); END IF;
IF v_tx.journal_entry_id IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ALREADY_BOOKED',
'details', jsonb_build_object('journal_entry_id', v_tx.journal_entry_id));
END IF;
IF v_tx.amount = 0 THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ZERO_AMOUNT'); END IF;
v_tx_abs := ABS(v_tx.amount);
v_tx_date_short := LEFT(v_tx.date::text, 10);
IF jsonb_typeof(p_allocations) IS DISTINCT FROM 'array' OR jsonb_array_length(p_allocations) = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_ALLOCATIONS');
END IF;
FOR v_allocation IN
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
LOOP
v_kind := v_allocation->>'kind';
v_alloc_amount := (v_allocation->>'amount')::numeric;
v_target_id := COALESCE(v_allocation->>'invoice_id', v_allocation->>'supplier_invoice_id');
IF v_alloc_amount IS NULL OR v_alloc_amount <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_AMOUNT',
'details', jsonb_build_object('index', v_alloc_index, 'amount', v_alloc_amount));
END IF;
IF v_target_id IS NOT NULL AND v_target_id = ANY(v_seen_ids) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DUPLICATE_ALLOCATION',
'details', jsonb_build_object('id', v_target_id, 'index', v_alloc_index));
END IF;
IF v_target_id IS NOT NULL THEN v_seen_ids := array_append(v_seen_ids, v_target_id); END IF;
v_total_allocated := v_total_allocated + v_alloc_amount;
IF v_kind = 'customer_invoice' THEN
v_has_customer := true;
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
SELECT * INTO v_invoice FROM public.invoices
WHERE id = v_invoice_id AND company_id = p_company_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_FOUND',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id));
END IF;
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_OPEN',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, 'status', v_invoice.status));
END IF;
v_inv_remaining := COALESCE(v_invoice.remaining_amount, v_invoice.total);
v_inv_currency := v_invoice.currency;
v_inv_fx_rate := v_invoice.exchange_rate;
IF v_inv_currency = v_tx.currency THEN
-- #1717: a sub-krona overshoot is öresavrundning (whole-krona
-- settlement of an öre total), absorbed into 3740 below. Only an
-- excess of >= 1.00 kr is a real overpayment. Mirrors
-- planInvoicePayment (lib/invoices/apply-invoice-payment.ts).
IF ROUND((v_alloc_amount - v_inv_remaining) * 100) / 100 >= 1.00 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
'requested', v_alloc_amount, 'remaining', v_inv_remaining));
END IF;
ELSE
IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 OR v_inv_fx_rate >= 100000 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_RATE_MISSING',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
'invoice_currency', v_inv_currency));
END IF;
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
IF ABS(v_alloc_amount - v_booked_sek) > v_booked_sek * 0.10 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_DEVIATION_TOO_LARGE',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
'allocation_amount', v_alloc_amount, 'expected_sek', v_booked_sek));
END IF;
END IF;
ELSIF v_kind = 'supplier_invoice' THEN
v_has_supplier := true;
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
SELECT * INTO v_si_invoice FROM public.supplier_invoices
WHERE id = v_supplier_invoice_id AND company_id = p_company_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_FOUND',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id));
END IF;
IF v_si_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_OPEN',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id, 'status', v_si_invoice.status));
END IF;
v_inv_remaining := COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total);
v_inv_currency := v_si_invoice.currency;
v_inv_fx_rate := v_si_invoice.exchange_rate;
IF v_inv_currency = v_tx.currency THEN
-- #1717: same öresavrundning band as the customer side above.
IF ROUND((v_alloc_amount - v_inv_remaining) * 100) / 100 >= 1.00 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
'requested', v_alloc_amount, 'remaining', v_inv_remaining));
END IF;
ELSE
IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 OR v_inv_fx_rate >= 100000 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_RATE_MISSING',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
'invoice_currency', v_inv_currency));
END IF;
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
IF ABS(v_alloc_amount - v_booked_sek) > v_booked_sek * 0.10 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_DEVIATION_TOO_LARGE',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
'allocation_amount', v_alloc_amount, 'expected_sek', v_booked_sek));
END IF;
END IF;
ELSE
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_KIND',
'details', jsonb_build_object('index', v_alloc_index, 'kind', v_kind));
END IF;
v_alloc_index := v_alloc_index + 1;
END LOOP;
IF v_has_customer AND v_has_supplier THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_MIXED_KINDS_UNSUPPORTED');
END IF;
IF v_total_allocated > v_tx_abs + 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_EXCEEDS_TX',
'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs));
END IF;
IF v_total_allocated < v_tx_abs - 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_BELOW_TX',
'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs));
END IF;
IF v_has_customer AND v_tx.amount <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
'details', jsonb_build_object('expected', 'income', 'tx_amount', v_tx.amount));
END IF;
IF v_has_supplier AND v_tx.amount >= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
'details', jsonb_build_object('expected', 'expense', 'tx_amount', v_tx.amount));
END IF;
SELECT id, is_closed, locked_at INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
FROM public.fiscal_periods
WHERE company_id = p_company_id AND v_tx.date BETWEEN period_start AND period_end
ORDER BY period_start DESC LIMIT 1;
IF v_fiscal_period_id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_FISCAL_PERIOD',
'details', jsonb_build_object('tx_date', v_tx.date));
END IF;
IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_PERIOD_LOCKED',
'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id,
'is_closed', v_period_is_closed, 'locked_at', v_period_locked_at));
END IF;
v_entry_description := CASE WHEN v_has_customer THEN 'Samlingsinbetalning ' || v_tx_date_short ELSE 'Samlingsbetalning ' || v_tx_date_short END;
v_source_type := CASE WHEN v_has_customer THEN 'invoice_paid' ELSE 'supplier_invoice_paid' END;
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES
(v_journal_entry_id, v_caller, p_company_id, v_fiscal_period_id, 0, v_voucher_series,
v_tx.date, v_entry_description, v_source_type, 'draft');
v_alloc_index := 0;
FOR v_allocation IN
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
LOOP
v_alloc_amount := (v_allocation->>'amount')::numeric;
IF v_has_customer THEN
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
SELECT invoice_number, currency, exchange_rate, remaining_amount, total
INTO v_invoice_number, v_inv_currency, v_inv_fx_rate, v_inv_remaining, v_inv_total
FROM public.invoices
WHERE id = v_invoice_id AND company_id = p_company_id;
v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
v_inv_number_short := LEFT(COALESCE(v_invoice_number, ''), 32);
IF v_inv_currency = v_tx.currency THEN
-- #1717: inside the öresavrundning band, clear the FULL remaining off
-- 1510 so the invoice reaches 'paid' and let 3740 carry the residual.
-- Customer short-paid = Dr 3740 (förlust); over-paid = Cr 3740
-- (vinst), same polarity as buildInvoicePaymentClearingLines. The
-- bank leg (tx_abs) is unchanged, so the entry stays balanced: the
-- net credit of 1510 + 3740 equals the allocation exactly.
v_ore_diff := ROUND((v_inv_remaining - v_alloc_amount) * 100) / 100;
IF v_ore_diff <> 0 AND ABS(v_ore_diff) < 1.00 THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1510', 0, ROUND(v_inv_remaining * 100) / 100, v_tx.currency, v_line_sort_order,
'Faktura ' || v_inv_number_short);
v_line_sort_order := v_line_sort_order + 1;
IF v_ore_diff > 0 THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '3740', v_ore_diff, 0, v_tx.currency, v_line_sort_order,
'Öresavrundning');
ELSE
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '3740', 0, ABS(v_ore_diff), v_tx.currency, v_line_sort_order,
'Öresavrundning');
END IF;
v_line_sort_order := v_line_sort_order + 1;
ELSE
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1510', 0, v_alloc_amount, v_tx.currency, v_line_sort_order,
'Faktura ' || v_inv_number_short);
v_line_sort_order := v_line_sort_order + 1;
END IF;
ELSE
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
v_fx_diff := ROUND((v_booked_sek - v_alloc_amount) * 100) / 100;
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1510', 0, v_booked_sek, v_tx.currency, v_line_sort_order,
'Faktura ' || v_inv_number_short || ' (' || v_inv_currency || ')');
v_line_sort_order := v_line_sort_order + 1;
IF ABS(v_fx_diff) > 0.005 THEN
IF v_fx_diff > 0 THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '7960', v_fx_diff, 0, v_tx.currency, v_line_sort_order,
'Valutakursförlust ' || v_inv_number_short);
ELSE
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '3960', 0, ABS(v_fx_diff), v_tx.currency, v_line_sort_order,
'Valutakursvinst ' || v_inv_number_short);
END IF;
v_line_sort_order := v_line_sort_order + 1;
END IF;
END IF;
ELSE
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
SELECT si.supplier_invoice_number, s.name, si.currency, si.exchange_rate,
si.remaining_amount, si.total
INTO v_supplier_invoice_number, v_supplier_name, v_inv_currency, v_inv_fx_rate,
v_inv_remaining, v_inv_total
FROM public.supplier_invoices si LEFT JOIN public.suppliers s ON s.id = si.supplier_id
WHERE si.id = v_supplier_invoice_id AND si.company_id = p_company_id;
v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
v_inv_number_short := LEFT(COALESCE(v_supplier_invoice_number, ''), 32);
IF v_inv_currency = v_tx.currency THEN
-- #1717: supplier mirror of the customer branch above. Clear the FULL
-- remaining off 2440; polarity per buildSupplierPaymentClearingLines:
-- bank paid less than owed = Cr 3740 (vinst), more = Dr 3740
-- (förlust).
v_ore_diff := ROUND((v_inv_remaining - v_alloc_amount) * 100) / 100;
IF v_ore_diff <> 0 AND ABS(v_ore_diff) < 1.00 THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '2440', ROUND(v_inv_remaining * 100) / 100, 0, v_tx.currency, v_line_sort_order,
TRIM(BOTH ' - ' FROM COALESCE(v_supplier_name, '') || ' - ' || v_inv_number_short));
v_line_sort_order := v_line_sort_order + 1;
IF v_ore_diff > 0 THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '3740', 0, v_ore_diff, v_tx.currency, v_line_sort_order,
'Öresavrundning');
ELSE
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '3740', ABS(v_ore_diff), 0, v_tx.currency, v_line_sort_order,
'Öresavrundning');
END IF;
v_line_sort_order := v_line_sort_order + 1;
ELSE
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '2440', v_alloc_amount, 0, v_tx.currency, v_line_sort_order,
TRIM(BOTH ' - ' FROM COALESCE(v_supplier_name, '') || ' - ' || v_inv_number_short));
v_line_sort_order := v_line_sort_order + 1;
END IF;
ELSE
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
v_fx_diff := ROUND((v_booked_sek - v_alloc_amount) * 100) / 100;
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '2440', v_booked_sek, 0, v_tx.currency, v_line_sort_order,
TRIM(BOTH ' - ' FROM
COALESCE(v_supplier_name, '') || ' - ' || v_inv_number_short
|| ' (' || v_inv_currency || ')'));
v_line_sort_order := v_line_sort_order + 1;
IF ABS(v_fx_diff) > 0.005 THEN
IF v_fx_diff > 0 THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '3960', 0, v_fx_diff, v_tx.currency, v_line_sort_order,
'Valutakursvinst ' || v_inv_number_short);
ELSE
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '7960', ABS(v_fx_diff), 0, v_tx.currency, v_line_sort_order,
'Valutakursförlust ' || v_inv_number_short);
END IF;
v_line_sort_order := v_line_sort_order + 1;
END IF;
END IF;
END IF;
v_alloc_index := v_alloc_index + 1;
END LOOP;
IF v_has_customer THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1930', v_tx_abs, 0, v_tx.currency, v_line_sort_order,
'Inbetalning ' || v_tx_date_short);
ELSE
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1930', 0, v_tx_abs, v_tx.currency, v_line_sort_order,
'Utbetalning ' || v_tx_date_short);
END IF;
SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
v_alloc_index := 0;
FOR v_allocation IN
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
LOOP
v_alloc_amount := (v_allocation->>'amount')::numeric;
IF v_has_customer THEN
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
SELECT * INTO v_invoice FROM public.invoices
WHERE id = v_invoice_id AND company_id = p_company_id;
IF v_invoice.currency = v_tx.currency THEN
-- #1717: inside the öresavrundning band the invoice settles in full
-- (the 3740 line carries the difference), so the payment records the
-- FULL remaining: paid_amount accumulates it, remaining lands on 0
-- and the status flips to 'paid'. Same convention as
-- planInvoicePayment.
v_inv_remaining := ROUND(COALESCE(v_invoice.remaining_amount, v_invoice.total) * 100) / 100;
v_ore_diff := ROUND((v_inv_remaining - v_alloc_amount) * 100) / 100;
IF v_ore_diff <> 0 AND ABS(v_ore_diff) < 1.00 THEN
v_paid_in_inv_currency := v_inv_remaining;
ELSE
v_paid_in_inv_currency := v_alloc_amount;
END IF;
v_payment_rate := NULL; -- same-currency: no FX context
ELSE
v_paid_in_inv_currency := COALESCE(v_invoice.remaining_amount, v_invoice.total);
-- Round-3: effective payment-day rate. SEK_paid / foreign_remaining.
IF v_paid_in_inv_currency > 0 THEN
v_payment_rate := ROUND((v_alloc_amount / v_paid_in_inv_currency) * 1000000) / 1000000;
ELSE
v_payment_rate := NULL;
END IF;
END IF;
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_paid_in_inv_currency) * 100) / 100;
v_new_remaining := GREATEST(0,
ROUND((COALESCE(v_invoice.remaining_amount, v_invoice.total) - v_paid_in_inv_currency) * 100) / 100);
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
UPDATE public.invoices SET status = v_new_status,
paid_at = CASE WHEN v_new_status = 'paid' THEN
((v_tx.date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
ELSE paid_at END,
paid_amount = v_new_paid, remaining_amount = v_new_remaining, updated_at = v_now
WHERE id = v_invoice_id AND company_id = p_company_id;
INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, exchange_rate,
payment_exchange_rate, journal_entry_id, transaction_id)
VALUES
(v_caller, p_company_id, v_invoice_id, v_tx.date, v_paid_in_inv_currency, v_invoice.currency,
v_invoice.exchange_rate, v_payment_rate, v_journal_entry_id, p_tx_id)
RETURNING id INTO v_payment_id;
v_results := v_results || jsonb_build_array(jsonb_build_object(
'kind', 'customer_invoice', 'invoice_id', v_invoice_id, 'payment_id', v_payment_id,
'status', v_new_status, 'paid_amount', v_new_paid, 'remaining_amount', v_new_remaining,
'amount', v_alloc_amount,
'cross_currency', v_invoice.currency <> v_tx.currency));
ELSE
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
SELECT * INTO v_si_invoice FROM public.supplier_invoices
WHERE id = v_supplier_invoice_id AND company_id = p_company_id;
IF v_si_invoice.currency = v_tx.currency THEN
-- #1717: supplier mirror of the customer branch above.
v_inv_remaining := ROUND(COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) * 100) / 100;
v_ore_diff := ROUND((v_inv_remaining - v_alloc_amount) * 100) / 100;
IF v_ore_diff <> 0 AND ABS(v_ore_diff) < 1.00 THEN
v_paid_in_inv_currency := v_inv_remaining;
ELSE
v_paid_in_inv_currency := v_alloc_amount;
END IF;
v_payment_rate := NULL;
ELSE
v_paid_in_inv_currency := COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total);
IF v_paid_in_inv_currency > 0 THEN
v_payment_rate := ROUND((v_alloc_amount / v_paid_in_inv_currency) * 1000000) / 1000000;
ELSE
v_payment_rate := NULL;
END IF;
END IF;
v_new_paid := ROUND((COALESCE(v_si_invoice.paid_amount, 0) + v_paid_in_inv_currency) * 100) / 100;
v_new_remaining := GREATEST(0,
ROUND((COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) - v_paid_in_inv_currency) * 100) / 100);
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
UPDATE public.supplier_invoices SET status = v_new_status,
paid_at = CASE WHEN v_new_status = 'paid' THEN
((v_tx.date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
ELSE paid_at END,
paid_amount = v_new_paid, remaining_amount = v_new_remaining,
payment_journal_entry_id = v_journal_entry_id, updated_at = v_now
WHERE id = v_supplier_invoice_id AND company_id = p_company_id;
INSERT INTO public.supplier_invoice_payments
(user_id, company_id, supplier_invoice_id, payment_date, amount, currency, exchange_rate,
payment_exchange_rate, journal_entry_id, transaction_id)
VALUES
(v_caller, p_company_id, v_supplier_invoice_id, v_tx.date, v_paid_in_inv_currency,
v_si_invoice.currency, v_si_invoice.exchange_rate, v_payment_rate, v_journal_entry_id, p_tx_id)
RETURNING id INTO v_payment_id;
v_results := v_results || jsonb_build_array(jsonb_build_object(
'kind', 'supplier_invoice', 'supplier_invoice_id', v_supplier_invoice_id,
'payment_id', v_payment_id, 'status', v_new_status, 'paid_amount', v_new_paid,
'remaining_amount', v_new_remaining, 'amount', v_alloc_amount,
'cross_currency', v_si_invoice.currency <> v_tx.currency));
END IF;
v_alloc_index := v_alloc_index + 1;
END LOOP;
UPDATE public.transactions SET journal_entry_id = v_journal_entry_id, is_business = TRUE,
invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_customer AND ABS(v_total_allocated - v_tx_abs) < 0.005
THEN (p_allocations->0->>'invoice_id')::uuid ELSE NULL END,
supplier_invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_supplier AND ABS(v_total_allocated - v_tx_abs) < 0.005
THEN (p_allocations->0->>'supplier_invoice_id')::uuid ELSE NULL END,
potential_invoice_id = NULL, potential_supplier_invoice_id = NULL,
updated_at = v_now WHERE id = p_tx_id AND company_id = p_company_id;
RETURN jsonb_build_object('ok', true, 'journal_entry_id', v_journal_entry_id,
'voucher_series', v_voucher_series, 'voucher_number', v_voucher_number,
'tx_id', p_tx_id, 'allocations', v_results, 'total_allocated', v_total_allocated,
'leftover', 0);
END;
$$;
-- Same grants as 20260817150000. CREATE OR REPLACE preserves the existing
-- ACL, but they are re-asserted so this file stands on its own.
REVOKE EXECUTE ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid, uuid) TO authenticated, service_role;
COMMENT ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid, uuid) IS
'Books one bank transaction against N invoices/supplier invoices in a single samlingsverifikat. Sub-krona differences between an allocation and the invoice remaining are absorbed to 3740 (öresavrundning) so the invoice settles fully. p_user_id is honored only for service_role callers (the pending-operations commit path); every other caller resolves from its own auth.uid(). Not callable by anon.';
NOTIFY pgrst, 'reload schema';
+367 -3
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'
import type { PoolClient } from 'pg'
import { describe, expect, it } from 'vitest'
import {
insertAuthUser,
@@ -9,12 +10,16 @@ import {
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* Covers 20260529120100_match_batch_allocate:
* Covers 20260529120100_match_batch_allocate (latest body:
* 20260824120000_match_batch_allocate_ore_settlement):
* - 1 bank tx → N supplier invoices: builds ONE combined verifikat with
* N × Dr 2440 + 1 × Cr 1930, inserts N supplier_invoice_payments rows
* all pointing at the same JE.
* - Per-invoice paid_amount/remaining_amount/status advance correctly.
* - Overshoot guard returns BATCH_OVERSHOOT cleanly (no partial state).
* - Overshoot guard returns BATCH_OVERSHOOT cleanly (no partial state)
* for a >= 1 kr excess; a sub-krona excess is öresavrundning (#1717).
* - Sub-krona |remaining - allocation| settles the invoice in full with a
* 3740 line carrying the residual (#1717).
* - Already-booked tx rejection.
* - Direction mismatch rejection.
* - Mixed customer + supplier kinds rejection.
@@ -105,6 +110,48 @@ async function insertTransaction(params: {
return id
}
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 insertCustomerInvoice(params: {
userId: string
companyId: string
customerId: string
total: number
status?: string
}): Promise<string> {
const id = randomUUID()
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', $6, 'SEK',
$7, 0, $7, 0, $7, 'standard_25')`,
[
id,
params.userId,
params.companyId,
params.customerId,
`F-${id.slice(0, 8)}`,
params.status ?? 'sent',
params.total,
],
)
return id
}
async function seedTenant(opts: { isClosed?: boolean } = {}) {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
@@ -318,7 +365,10 @@ describe('match_batch_allocate', () => {
})
})
it('rejects with BATCH_OVERSHOOT when allocation exceeds invoice remaining', async () => {
// #1717 deliberately relaxed this guard: a SUB-krona excess is
// öresavrundning (absorbed to 3740, see the öre suite below); only an
// excess of >= 1.00 kr is still a real overshoot.
it('rejects with BATCH_OVERSHOOT when allocation exceeds invoice remaining by >= 1 kr', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
@@ -357,6 +407,29 @@ describe('match_batch_allocate', () => {
})
})
it('rejects with BATCH_OVERSHOOT at exactly 1 kr excess (band boundary)', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 1000,
})
const txId = await insertTransaction({ userId, companyId, amount: -1001 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 1001 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_OVERSHOOT')
})
})
it('rejects with BATCH_UNAUTHORIZED when caller is not a member of the company', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
@@ -684,4 +757,295 @@ describe('match_batch_allocate', () => {
expect(txRow.rows[0]!.journal_entry_id).toBeNull()
})
})
// #1717: öresavrundning. A whole-krona settlement of an öre-bearing
// remaining (|diff| < 1 kr) must settle the invoice in FULL: the AR/AP leg
// clears the whole remaining, 3740 carries the residual, status flips to
// 'paid'. A diff of >= 1 kr keeps the legacy behaviour (real partial /
// BATCH_OVERSHOOT above).
describe('öresavrundning settlement (#1717)', () => {
async function fetchLines(client: PoolClient, journalEntryId: string) {
const lines = await client.query<{
account_number: string
debit_amount: string
credit_amount: string
}>(
`SELECT account_number, debit_amount, credit_amount
FROM public.journal_entry_lines
WHERE journal_entry_id = $1
ORDER BY sort_order`,
[journalEntryId],
)
return lines.rows
}
function assertBalanced(rows: Array<{ debit_amount: string; credit_amount: string }>) {
const debits = rows.reduce((s, l) => s + Number(l.debit_amount), 0)
const credits = rows.reduce((s, l) => s + Number(l.credit_amount), 0)
expect(Math.round(debits * 100)).toBe(Math.round(credits * 100))
}
it('customer invoice paid a whole krona short settles to paid with Dr 3740', async () => {
const { userId, companyId } = await seedTenant()
const customerId = await insertCustomer({ userId, companyId })
const invoiceId = await insertCustomerInvoice({
userId, companyId, customerId, total: 1000.4,
})
const txId = await insertTransaction({ userId, companyId, amount: 1000 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1000 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations![0]!.status).toBe('paid')
expect(result.allocations![0]!.paid_amount).toBe(1000.4)
expect(result.allocations![0]!.remaining_amount).toBe(0)
const rows = await fetchLines(client, result.journal_entry_id!)
// Cr 1510 1000.40 (full remaining), Dr 3740 0.40, Dr 1930 1000.
expect(rows).toHaveLength(3)
const ar = rows.find((l) => l.account_number === '1510')!
expect(Number(ar.credit_amount)).toBe(1000.4)
const ore = rows.find((l) => l.account_number === '3740')!
expect(Number(ore.debit_amount)).toBe(0.4)
expect(Number(ore.credit_amount)).toBe(0)
const bank = rows.find((l) => l.account_number === '1930')!
expect(Number(bank.debit_amount)).toBe(1000)
assertBalanced(rows)
const inv = await client.query<{
status: string; paid_amount: string; remaining_amount: string; paid_at: string | null
}>(
`SELECT status, paid_amount, remaining_amount, paid_at FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(inv.rows[0]!.status).toBe('paid')
expect(Number(inv.rows[0]!.paid_amount)).toBe(1000.4)
expect(Number(inv.rows[0]!.remaining_amount)).toBe(0)
expect(inv.rows[0]!.paid_at).not.toBeNull()
// The payment row records the FULL remaining (planInvoicePayment
// convention), not the raw bank allocation.
const pay = await client.query<{ amount: string }>(
`SELECT amount FROM public.invoice_payments WHERE invoice_id = $1`,
[invoiceId],
)
expect(Number(pay.rows[0]!.amount)).toBe(1000.4)
})
})
it('customer invoice over-paid by öre is accepted with Cr 3740', async () => {
const { userId, companyId } = await seedTenant()
const customerId = await insertCustomer({ userId, companyId })
const invoiceId = await insertCustomerInvoice({
userId, companyId, customerId, total: 1000.6,
})
const txId = await insertTransaction({ userId, companyId, amount: 1001 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'customer_invoice', invoice_id: invoiceId, amount: 1001 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations![0]!.status).toBe('paid')
const rows = await fetchLines(client, result.journal_entry_id!)
// Cr 1510 1000.60, Cr 3740 0.40 (vinst), Dr 1930 1001.
expect(rows).toHaveLength(3)
expect(Number(rows.find((l) => l.account_number === '1510')!.credit_amount)).toBe(1000.6)
const ore = rows.find((l) => l.account_number === '3740')!
expect(Number(ore.credit_amount)).toBe(0.4)
expect(Number(ore.debit_amount)).toBe(0)
expect(Number(rows.find((l) => l.account_number === '1930')!.debit_amount)).toBe(1001)
assertBalanced(rows)
const inv = await client.query<{ status: string; paid_amount: string }>(
`SELECT status, paid_amount FROM public.invoices WHERE id = $1`,
[invoiceId],
)
expect(inv.rows[0]!.status).toBe('paid')
expect(Number(inv.rows[0]!.paid_amount)).toBe(1000.6)
})
})
it('supplier invoice paid öre short settles to paid with Cr 3740 (mirror polarity)', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 2000.3,
})
const txId = await insertTransaction({ userId, companyId, amount: -2000 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 2000 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations![0]!.status).toBe('paid')
const rows = await fetchLines(client, result.journal_entry_id!)
// Dr 2440 2000.30 (full remaining), Cr 3740 0.30 (vinst: paid less
// than owed), Cr 1930 2000.
expect(rows).toHaveLength(3)
expect(Number(rows.find((l) => l.account_number === '2440')!.debit_amount)).toBe(2000.3)
const ore = rows.find((l) => l.account_number === '3740')!
expect(Number(ore.credit_amount)).toBe(0.3)
expect(Number(ore.debit_amount)).toBe(0)
expect(Number(rows.find((l) => l.account_number === '1930')!.credit_amount)).toBe(2000)
assertBalanced(rows)
const inv = await client.query<{
status: string; paid_amount: string; remaining_amount: string
}>(
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
[si],
)
expect(inv.rows[0]!.status).toBe('paid')
expect(Number(inv.rows[0]!.paid_amount)).toBe(2000.3)
expect(Number(inv.rows[0]!.remaining_amount)).toBe(0)
})
})
it('supplier invoice over-paid by öre gets Dr 3740 (förlust)', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 1999.6,
})
const txId = await insertTransaction({ userId, companyId, amount: -2000 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 2000 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations![0]!.status).toBe('paid')
const rows = await fetchLines(client, result.journal_entry_id!)
expect(rows).toHaveLength(3)
expect(Number(rows.find((l) => l.account_number === '2440')!.debit_amount)).toBe(1999.6)
const ore = rows.find((l) => l.account_number === '3740')!
expect(Number(ore.debit_amount)).toBe(0.4)
expect(Number(ore.credit_amount)).toBe(0)
assertBalanced(rows)
})
})
it('a shortfall of exactly 1 kr stays a real partial (no 3740)', async () => {
const { userId, companyId } = await seedTenant()
const supplier = await insertSupplier({ userId, companyId })
const si = await insertSupplierInvoice({
userId, companyId, supplierId: supplier, total: 1000,
})
const txId = await insertTransaction({ userId, companyId, amount: -999 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([{ kind: 'supplier_invoice', supplier_invoice_id: si, amount: 999 }]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations![0]!.status).toBe('partially_paid')
const rows = await fetchLines(client, result.journal_entry_id!)
expect(rows).toHaveLength(2)
expect(rows.some((l) => l.account_number === '3740')).toBe(false)
expect(Number(rows.find((l) => l.account_number === '2440')!.debit_amount)).toBe(999)
const inv = await client.query<{
status: string; paid_amount: string; remaining_amount: string
}>(
`SELECT status, paid_amount, remaining_amount FROM public.supplier_invoices WHERE id = $1`,
[si],
)
expect(inv.rows[0]!.status).toBe('partially_paid')
expect(Number(inv.rows[0]!.paid_amount)).toBe(999)
expect(Number(inv.rows[0]!.remaining_amount)).toBe(1)
})
})
it('samlingsbetalning: whole-krona clump over two öre-bearing invoices settles both', async () => {
const { userId, companyId } = await seedTenant()
const customerId = await insertCustomer({ userId, companyId })
// 500.25 + 499.75 = 1000.00 owed; the batch dialog splits a 1000 kr
// payment 500/500, leaving +0.25 short on one and 0.25 over on the
// other: both absorb to 3740 and both invoices reach 'paid'.
const inv1 = await insertCustomerInvoice({
userId, companyId, customerId, total: 500.25,
})
const inv2 = await insertCustomerInvoice({
userId, companyId, customerId, total: 499.75,
})
const txId = await insertTransaction({ userId, companyId, amount: 1000 })
await withUserContext(userId, async (client) => {
const r = await client.query<{ match_batch_allocate: RpcResult }>(
`SELECT match_batch_allocate($1, $2::jsonb, $3)`,
[
txId,
JSON.stringify([
{ kind: 'customer_invoice', invoice_id: inv1, amount: 500 },
{ kind: 'customer_invoice', invoice_id: inv2, amount: 500 },
]),
companyId,
],
)
const result = r.rows[0]!.match_batch_allocate
expect(result.ok).toBe(true)
expect(result.allocations).toHaveLength(2)
for (const alloc of result.allocations!) {
expect(alloc.status).toBe('paid')
expect(alloc.remaining_amount).toBe(0)
}
const rows = await fetchLines(client, result.journal_entry_id!)
// 2 × Cr 1510 (full remainings) + Dr 3740 0.25 + Cr 3740 0.25
// + Dr 1930 1000.
expect(rows).toHaveLength(5)
const arSum = rows
.filter((l) => l.account_number === '1510')
.reduce((s, l) => s + Number(l.credit_amount), 0)
expect(Math.round(arSum * 100)).toBe(100000)
const oreRows = rows.filter((l) => l.account_number === '3740')
expect(oreRows).toHaveLength(2)
assertBalanced(rows)
const invoices = await client.query<{ status: string }>(
`SELECT status FROM public.invoices WHERE id IN ($1, $2)`,
[inv1, inv2],
)
expect(invoices.rows.every((row) => row.status === 'paid')).toBe(true)
})
})
})
})