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
+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',