Skip to content

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-new SupplierInvoice whose details come from SupplierInvoiceDetailInput.to_orm_model(), which never sets id — every line gets a fresh uuid4(). The frontend payload carries no detail ids either.
  • session.merge() in SupplierInvoiceMutationRepository.update therefore treats the old detail rows as orphans. With cascade="all, delete, delete-orphan" on SupplierInvoice.supplier_invoice_details, the flush issues a DELETE FROM supplier_invoice_details for every pre-edit row.
  • supplier_credit_details.supplier_invoice_detail_id is a FK to supplier_invoice_details.id with no ON DELETE action, 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_details row 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 stable extensions.exception code 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 pathAbstractRepository.delete already guards referenced rows via can_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:

updateSupplierInvoice(supplierInvoice: SupplierInvoiceInput!): SupplierInvoiceResponse!

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

What shipped

Everything in the in-scope list landed:

  • SupplierInvoiceHasCreditsError added to invoice_exceptions.py.
  • SupplierInvoiceQueryRepository.has_credit_references(supplier_invoice_id) runs a single SELECT 1 ... LIMIT 1 joining supplier_credit_details → supplier_invoice_details filtered to the invoice.
  • SupplierInvoiceMutationRepository.update calls 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_references true when a credit line targets a detail, false without a credit, and update raising SupplierInvoiceHasCreditsError for 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, UPDATE in 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.