fix(invoices): remaining_amount can no longer be inserted as 0 on an unpaid invoice (#1655)

* fix(invoices): remaining_amount can no longer be inserted as 0 on an unpaid invoice

remaining_amount is NOT NULL DEFAULT 0 and every payment surface (payment
dialog, bank match, Stripe sync, agent mark-paid) reads it as the customer's
open balance. Four writers omitted it, so their invoices looked settled: the
dialog rejected every payment as an overpayment and the bank match saw
nothing to clear. Prod carried 337 such open invoices on 2026-08-17
(backfilled the same day, snapshot in _backfill_remaining_20260817).

- Migration 20260817191708: BEFORE INSERT trigger invoices_derive_remaining_amount.
  When remaining_amount is NULL/0 on a real invoice (document_type invoice,
  not a credit note) with total > 0 and a status that still owes money, it
  becomes total - paid_amount - deduction_total (>= 0). The ROT/RUT share is a
  1513 receivable on Skatteverket, never the customer's, exactly as
  buildInvoiceWriteData computes it. INSERT only: settlement code owns
  updates and legitimately writes 0 when paid in full.
- pg-real test: derivation, explicit value respected, paid/prior/deduction
  arithmetic, drafts + overdue, paid/cancelled keep 0, credit notes and
  proformas untouched, never negative.
- Writers fixed as well: proforma -> invoice conversion (dashboard route and
  MCP commitConvertInvoice), MCP commitCreateInvoice, sandbox seed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox): every row in the seed invoice batch carries remaining_amount + paid_amount

PostgREST normalises a bulk insert to the union of keys, so a row that
omits a column the others set arrives as NULL, not as the default. Keep the
draft row on the same contract as the rest of the batch (CodeRabbit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

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-17 21:36:16 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent e030393fe6
commit a447b29210
6 changed files with 188 additions and 0 deletions
+1
View File
@@ -1042,3 +1042,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-17] Skattekontoutdrag file import (Sebastian's request) writes into skattekonto_transactions, not into transactions as a pseudo-bank with 1630 unlocked in BankFileConfirmStep: rows inherit the skattekonto_rules 1630 booking engine, matching, drift and both UIs for free, while the literal ask would bypass the rules and double against the SKV inbox for connected companies. Dedup pairs file hash-keys with API id-keys by CONTENT in both directions (import-time skip/promote against existing rows, sync-time takeover that rewrites an imported row's key in place so journal links survive connecting the API later). Import is free for everyone per the requireSkvCapability doctrine (manual paths never blocked); only sync/saldo stay capability-gated. The parse route hard-rejects files that fail detectSkattekontoFile and statements whose opening+sum!=closing, and warns on orgnr mismatch against company_settings: wrong-company imports are a known support-incident class.
[2026-08-17] articles.housework_type keeps two vocabularies (Skatteverket arbetstypskod, or bare ROT/RUT) instead of migrating legacy ROT/RUT rows: a kind-only row cannot be upgraded to a code without knowing the work, so the article form preserves the legacy choice as an explicit option and the invoice prefill treats it as kind-only; everything else normalizes to null and is rejected at the API.
[2026-08-17] ROT/RUT claim completeness (arbetstyp + arbetstimmar) is enforced at invoice creation (validateInvoice + CreateInvoiceItemSchema + editor), not only at begäran-file time: the file blocker fired when the invoice was already numbered/booked/paid with no repair path short of a credit note; schablontjänster (TRANSPORT/TVATT) stay hours-exempt. Yearly ceiling accumulation is per CUSTOMER in the editor (personnummer is ciphertext client-side) and warning-only; server warnings are still dropped on success, so the editor computes its own via the shared deductionCapWarnings helper.
[2026-08-17] invoices.remaining_amount gets a BEFORE INSERT trigger deriving total - paid_amount - deduction_total when a fresh unpaid real invoice arrives with NULL/0, instead of only fixing the writers: four writers had drifted (proforma conversion x2, MCP create_invoice, sandbox seed) and 337 open invoices on prod sat at 0, so the column's DEFAULT 0 must never be able to mean 'settled' again; UPDATE is left to the settlement code, which legitimately writes 0 on full payment. Writers fixed too (defense in depth).
+6
View File
@@ -67,6 +67,12 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
vat_amount_sek: proforma.vat_amount_sek,
total: proforma.total,
total_sek: proforma.total_sek,
// The converted invoice is a fresh unpaid receivable: proformas carry no
// ROT/RUT deduction, so the customer owes the full total. Omitting this
// left the NOT NULL DEFAULT 0, which every payment surface reads as
// "nothing open" (dialog overpayment rejection, bank match sees 0).
remaining_amount: proforma.total,
paid_amount: 0,
vat_treatment: proforma.vat_treatment,
vat_rate: proforma.vat_rate,
moms_ruta: proforma.moms_ruta,
+9
View File
@@ -293,6 +293,7 @@ export async function POST(request: Request) {
document_type: 'invoice',
paid_at: toDateStr(fifteenDaysAgo),
paid_amount: 18750,
remaining_amount: 0,
},
{
user_id: userId,
@@ -305,6 +306,8 @@ export async function POST(request: Request) {
subtotal: 20000,
vat_amount: 0,
total: 20000,
remaining_amount: 20000,
paid_amount: 0,
vat_treatment: 'reverse_charge',
vat_rate: 0,
reverse_charge_text: 'Reverse charge: buyer is liable for VAT',
@@ -321,6 +324,8 @@ export async function POST(request: Request) {
subtotal: 5000,
vat_amount: 1250,
total: 6250,
remaining_amount: 6250,
paid_amount: 0,
vat_treatment: 'standard_25',
vat_rate: 25,
moms_ruta: '10',
@@ -337,6 +342,10 @@ export async function POST(request: Request) {
subtotal: 8000,
vat_amount: 2000,
total: 10000,
// PostgREST normalises a bulk insert to the union of keys: every row in
// this batch carries both columns so none arrives as NULL.
remaining_amount: 10000,
paid_amount: 0,
vat_treatment: 'standard_25',
vat_rate: 25,
moms_ruta: '10',
+7
View File
@@ -1671,6 +1671,11 @@ async function commitCreateInvoice(
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
// Fresh unpaid receivable: remaining_amount is what every payment
// surface reads as the open balance; leaving the NOT NULL DEFAULT 0
// made every agent-created invoice look settled.
remaining_amount: total,
paid_amount: 0,
vat_treatment: notVatRegistered ? 'exempt' : vatRules.treatment,
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
moms_ruta: notVatRegistered ? null : vatRules.momsRuta,
@@ -4438,6 +4443,8 @@ async function commitConvertInvoice(
vat_amount: proforma.vat_amount,
vat_amount_sek: proforma.vat_amount_sek,
total: proforma.total,
remaining_amount: proforma.total,
paid_amount: 0,
total_sek: proforma.total_sek,
vat_treatment: proforma.vat_treatment,
vat_rate: proforma.vat_rate,
@@ -0,0 +1,47 @@
-- invoices.remaining_amount insert guard.
--
-- remaining_amount is NOT NULL DEFAULT 0 (20260323120001). Every payment
-- surface (payment dialog, bank match, Stripe sync, agent mark-paid) treats it
-- as the customer's open balance, so a writer that omits it leaves an unpaid
-- invoice looking settled: the dialog rejects any payment as an overpayment
-- and the bank match sees nothing to clear. On 2026-08-17 prod carried 337
-- such open invoices (proforma conversion, MCP create_invoice, sandbox seed,
-- older imports); they were backfilled the same day. This trigger keeps the
-- default from ever meaning "0 kr open" again on a fresh unpaid invoice.
--
-- Rule (BEFORE INSERT only; updates are owned by the settlement code, which
-- legitimately writes 0 when an invoice is paid in full):
-- when remaining_amount is NULL or 0
-- and the row is a real invoice (document_type invoice, not a credit note)
-- with total > 0 and a status that still owes money,
-- derive remaining_amount = total - paid_amount - deduction_total (>= 0).
-- The ROT/RUT deduction is a receivable on Skatteverket (1513), never the
-- customer's to pay, so it is excluded exactly as buildInvoiceWriteData does.
CREATE OR REPLACE FUNCTION public.invoices_derive_remaining_amount()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF COALESCE(NEW.remaining_amount, 0) = 0
AND NEW.credited_invoice_id IS NULL
AND COALESCE(NEW.document_type, 'invoice') = 'invoice'
AND COALESCE(NEW.total, 0) > 0
AND COALESCE(NEW.status, 'draft') NOT IN ('paid', 'cancelled', 'credited')
THEN
NEW.remaining_amount := GREATEST(
0,
ROUND((NEW.total - COALESCE(NEW.paid_amount, 0) - COALESCE(NEW.deduction_total, 0))::numeric, 2)
);
END IF;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION public.invoices_derive_remaining_amount() IS
'BEFORE INSERT guard: an unpaid real invoice inserted with remaining_amount NULL/0 gets total - paid_amount - deduction_total, so the NOT NULL DEFAULT 0 can never read as "settled".';
DROP TRIGGER IF EXISTS invoices_derive_remaining_amount ON public.invoices;
CREATE TRIGGER invoices_derive_remaining_amount
BEFORE INSERT ON public.invoices
FOR EACH ROW EXECUTE FUNCTION public.invoices_derive_remaining_amount();
@@ -0,0 +1,118 @@
/**
* pg-real tests for 20260817191708_invoices_remaining_amount_guard.sql.
*
* remaining_amount is NOT NULL DEFAULT 0, and every payment surface reads it
* as the customer's open balance. The BEFORE INSERT trigger derives it for a
* fresh unpaid real invoice that arrives with NULL/0, so a writer that omits
* the column can no longer make an unpaid invoice look settled.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from './setup'
import { insertAuthUser, insertCompany } from './fixtures'
let userId: string
let companyId: string
let customerId: string
async function insertInvoice(cols: Record<string, unknown>): Promise<{ remaining_amount: number }> {
const id = randomUUID()
const base: Record<string, unknown> = {
id,
user_id: userId,
company_id: companyId,
customer_id: customerId,
invoice_date: '2026-06-01',
due_date: '2026-06-30',
currency: 'SEK',
vat_treatment: 'standard_25',
vat_rate: 25,
subtotal: 8000,
vat_amount: 2000,
total: 10000,
// invoices_sent_requires_number: anything past draft carries a number.
invoice_number: cols.status === 'draft' ? null : `T-${id.slice(0, 8)}`,
...cols,
}
const keys = Object.keys(base)
await getPool().query(
`INSERT INTO public.invoices (${keys.join(', ')})
VALUES (${keys.map((_, i) => `$${i + 1}`).join(', ')})`,
keys.map((k) => base[k]),
)
const { rows } = await getPool().query(
`SELECT remaining_amount::float8 AS remaining_amount FROM public.invoices WHERE id = $1`,
[id],
)
return rows[0]
}
beforeAll(async () => {
userId = await insertAuthUser()
companyId = await insertCompany({ createdBy: userId })
customerId = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
VALUES ($1, $2, $3, 'Guard Cust', 'swedish_business')`,
[customerId, userId, companyId],
)
})
describe('invoices_derive_remaining_amount (BEFORE INSERT)', () => {
it('trigger exists on invoices', async () => {
const { rows } = await getPool().query(
`SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid
WHERE c.relname = 'invoices' AND t.tgname = 'invoices_derive_remaining_amount' AND NOT t.tgisinternal`,
)
expect(rows).toHaveLength(1)
})
it('an unpaid invoice inserted without remaining_amount gets its total', async () => {
const row = await insertInvoice({ status: 'sent' })
expect(row.remaining_amount).toBe(10000)
})
it('an explicit remaining_amount is respected', async () => {
const row = await insertInvoice({ status: 'sent', remaining_amount: 4321.5 })
expect(row.remaining_amount).toBe(4321.5)
})
it('prior payments and the ROT/RUT share (1513) are excluded from what the customer owes', async () => {
const row = await insertInvoice({ status: 'sent', paid_amount: 1000, deduction_total: 3000 })
expect(row.remaining_amount).toBe(6000)
})
it('drafts derive too (buildInvoiceWriteData semantics), overdue as well', async () => {
expect((await insertInvoice({ status: 'draft' })).remaining_amount).toBe(10000)
expect((await insertInvoice({ status: 'overdue' })).remaining_amount).toBe(10000)
})
it('paid, cancelled and credited invoices keep 0', async () => {
expect((await insertInvoice({ status: 'paid', paid_amount: 10000 })).remaining_amount).toBe(0)
expect((await insertInvoice({ status: 'cancelled' })).remaining_amount).toBe(0)
})
it('credit notes and non-invoice documents keep 0', async () => {
const originalId = randomUUID()
await getPool().query(
`INSERT INTO public.invoices (id, user_id, company_id, customer_id, invoice_date, due_date, currency,
vat_treatment, vat_rate, subtotal, vat_amount, total, status, invoice_number)
VALUES ($1, $2, $3, $4, '2026-06-01', '2026-06-30', 'SEK', 'standard_25', 25, 8000, 2000, 10000, 'sent', $5)`,
[originalId, userId, companyId, customerId, `T-${originalId.slice(0, 8)}`],
)
const credit = await insertInvoice({
status: 'sent',
credited_invoice_id: originalId,
subtotal: -8000,
vat_amount: -2000,
total: -10000,
})
expect(credit.remaining_amount).toBe(0)
expect((await insertInvoice({ status: 'sent', document_type: 'proforma' })).remaining_amount).toBe(0)
})
it('never goes negative when paid_amount exceeds total on insert', async () => {
const row = await insertInvoice({ status: 'sent', paid_amount: 12000 })
expect(row.remaining_amount).toBe(0)
})
})