Invoice won't finalise
A draft invoice refuses to transition to finalized. Use this page to diagnose.
Symptom#
Either:
- The admin UI's "Finalize" button returns an error toast.
POST /api/v1/admin/billing/invoices/{id}/finalizereturns a 4xx or 5xx.- The monthly invoice generator logs
finalize_invoice failedand leaves the invoice indraft.
What finalize_invoice() actually does#
In order, in a single transaction:
- Re-loads the invoice and validates
status == draftandline_itemsnon-empty. - Looks up
TenantBillingProfilefor the tenant. - Looks up the partner's
partner_configurationrow. - Calls
determine_vat(seller_country, buyer_country, buyer_vat_number, is_business). - Recomputes
subtotal_cents,tax_cents,total_centsfrom line items. - Assigns the next number via
SELECT … FOR UPDATEoninvoice_sequence. - Renders the PDF via
template_renderer.render_pdf(). - Uploads PDF to S3 → sets
pdf_url. - Sets
status='finalized',finalized_at=now(), commits.
Any failure rolls the whole thing back — the invoice stays in draft with no number consumed.
Common causes#
1. No billing profile for the tenant#
Symptom: BadRequestError: tenant has no billing profile.
Check:
1 | |
If empty, the tenant must complete their billing profile first. Either ask them to fill /settings/billing-profile in the portal, or — for an operator-driven correction — POST /api/v1/admin/tenants/{id}/billing-profile with the required fields.
Required fields: company_name, address_line1, postal_code, city, country_code (ISO 2-letter). Without those, the buyer snapshot is incomplete and finalize aborts.
2. Partner config missing seller fields#
Symptom: BadRequestError: partner has no seller_country_code (or legal_name, seller_address_line1, etc.).
Check:
1 2 | |
The seller side of the invoice can't be snapshotted with NULLs. Fill those via /admin/partners/{id}/configuration (PATCH) or set OPERATOR_* env vars to cover the platform-default partner.
3. Template not found#
Symptom: Template not found for (name, document_type='invoice', language).
Resolution order (see Concepts: templates): partner-specific exact match → partner-specific language fallback → platform-default exact → platform-default language fallback.
Check what's available:
1 2 | |
If no row matches anything for the chosen language, either:
- Add a fallback template for
(name=default, language=NULL)— applies to all languages. - Add the missing language template.
- Verify the invoice's
languagefield matches an existing template language.
4. Template Jinja syntax error#
Symptom: TemplateSyntaxError: unexpected … at PDF render time.
Diagnose with the CLI:
1 | |
That command compiles every template; non-zero exit = at least one failure with file/line. Fix in the designer UI (it does live syntax checking) or via PATCH /admin/invoice-templates/{id}.
5. WeasyPrint can't fetch an asset#
Symptom: PDF rendering hangs or fails with IOError fetching url.
WeasyPrint resolves URLs in <img src>, <link href>, @font-face url(...) at render time. If those reference PORTAL_URL and the network is blocked, render hangs until timeout.
Fix: ensure the backend container can reach PORTAL_URL (most logos live there), or inline the logo as data:image/png;base64,… in the template. Custom fonts should be served from a reliable origin — Blogger Sans is base64-embedded in the default templates precisely to avoid this.
6. S3 upload fails#
Symptom: botocore.exceptions.NoCredentialsError, S3UploadError: 403, or timeout.
Check S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY (or instance role attached). Verify the bucket exists and the credentials have s3:PutObject + s3:GetObject on the S3_PREFIX/invoices/* keyspace.
7. Stale invoice (already finalized once)#
Symptom: BadRequestError: invoice is not draft.
The invoice already finalised previously and was edited via direct DB write. Finalised invoices are immutable — issue a credit note instead via POST /admin/billing/invoices/{id}/credit-note.
Diagnostic queries#
The most useful single query:
1 2 3 4 5 6 7 8 9 | |
A row with buyer != 'OK', seller != 'OK', or line_count = 0 explains the failure without reading logs.
When all else fails#
Check backend logs for the traceback. The finalize transaction is wrapped in a try/except that logs the exception with invoice_id before re-raising:
1 | |
If you see a fresh IntegrityError on invoice_sequence, two finalize calls raced; this is benign — one of them got the number, the other will succeed on retry.