Block editing supplier invoices that have credit notes¶
Date: 2026-06-22 Type: Hotfix Domain: app/graphql/invoices/ Related models: supplier_credit_detail.py
Problem / motivation¶
updateSupplierInvoice crashed with a raw Postgres foreign-key violation for
any supplier invoice that has a supplier credit note (nota de crédito) issued
against one of its lines:
update or delete on table "supplier_invoice_details" violates foreign key
constraint "supplier_credit_details_supplier_invoice_detail_id_fkey" on table
"supplier_credit_details"
Root cause is the delete-and-recreate detail strategy in the update path:
SupplierInvoiceInput.to_orm_model()builds a brand-newSupplierInvoicewhose details come fromSupplierInvoiceDetailInput.to_orm_model(), which never setsid— every line gets a freshuuid4(). The frontend payload carries no detail ids either.session.merge()inSupplierInvoiceMutationRepository.updatetherefore treats the old detail rows as orphans. Withcascade="all, delete, delete-orphan"onSupplierInvoice.supplier_invoice_details, the flush issues aDELETE FROM supplier_invoice_detailsfor every pre-edit row.supplier_credit_details.supplier_invoice_detail_idis a FK tosupplier_invoice_details.idwith noON DELETEaction, so Postgres blocks the delete and the whole mutation rolls back.
Net effect: once a credit note references an invoice line, the invoice can no longer be edited at all — even an unrelated change like the due date triggers the full detail delete-and-recreate and fails.
This is also the accounting-correct place to stop the user: a credit note's amounts derive from specific invoice lines, so those lines must not be silently rewritten underneath an existing credit.
In scope¶
- A fail-fast guard on the supplier-invoice update path: if any
supplier_credit_detailsrow references a line of the invoice, reject the edit with a clear domain error instead of letting the FK violation surface. - A dedicated exception,
SupplierInvoiceHasCreditsError, so the frontend gets a stableextensions.exceptioncode and a human-readable message. - A reusable existence check,
SupplierInvoiceQueryRepository.has_credit_references. - Tests covering the query method (referenced vs. not) and the guard raising.
Out of scope¶
- Editing while preserving the credit — matching old detail rows to new ones and updating in place so credited invoices stay editable. The chosen behavior is to block; in-place preservation is a larger, riskier change and semantically wrong while a credit is outstanding. Noted as a future addition.
- Delete path —
AbstractRepository.deletealready guards referenced rows viacan_delete/EntityStillInUseError, so it does not hit this crash. - Any change to the GraphQL surface — types, inputs, queries, mutations and field names are unchanged.
Data model changes¶
None. No new tables, columns, FKs, or indexes. No alembic migration.
GraphQL surface¶
Unchanged:
The only observable change is the error returned when a credited invoice is
edited: a SupplierInvoiceHasCreditsError (carried in
extensions.exception) instead of an opaque IntegrityError.
RBAC¶
No change.
Background tasks / cron¶
None.
Frontend contract¶
updateSupplierInvoice now returns a clean, catchable error when the invoice
has credit notes against it:
extensions.exception = "SupplierInvoiceHasCreditsError"- message: "Cannot edit this supplier invoice because one or more credit notes have been issued against its lines. Void or delete the related credit note(s) first."
The frontend should surface this message and, ideally, disable the edit action
(or show why it is disabled) when the invoice has associated credit notes —
supplierCreditsBySupplierInvoiceId already exposes that list. No query, field,
or type changed.
What's being implemented¶
- New exception —
SupplierInvoiceHasCreditsError. - New query method —
SupplierInvoiceQueryRepository.has_credit_references:SELECT 1joiningsupplier_credit_details → supplier_invoice_detailsfor the invoice, returning whether any credit line references it. - Guard —
SupplierInvoiceMutationRepository.updatecalls the check first thing (after the id check) and raisesSupplierInvoiceHasCreditsErrorbefore any destructive merge/flush. - Tests —
tests/graphql/invoices/repositories/test_supplier_invoice_credit_guard.py.
What shipped¶
Everything in the in-scope list landed:
SupplierInvoiceHasCreditsErroradded to invoice_exceptions.py.SupplierInvoiceQueryRepository.has_credit_references(supplier_invoice_id)runs a singleSELECT 1 ... LIMIT 1joiningsupplier_credit_details → supplier_invoice_detailsfiltered to the invoice.SupplierInvoiceMutationRepository.updatecalls the check immediately after the id check and raises before any merge/flush, so a credited invoice never reaches the destructive detail delete-and-recreate.tests/graphql/invoices/repositories/test_supplier_invoice_credit_guard.py— 3 tests, all green:has_credit_referencestrue when a credit line targets a detail, false without a credit, andupdateraisingSupplierInvoiceHasCreditsErrorfor a credited invoice.
task all is green (lint, typecheck-basedpy, file-length, both schema exports)
and the new module passes 3/3 alongside the invoices + recompute suites (30/30).
Future additions¶
- Editable credited invoices — preserve detail-row ids across an edit
(match by line number / detail id sent from the frontend,
UPDATEin place) so non-line edits to a credited invoice are allowed and credits stay valid. Deferred: needs the frontend to round-trip detail ids and a clear rule for what happens when a credited line is removed.