Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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}/finalize returns a 4xx or 5xx.
  • The monthly invoice generator logs finalize_invoice failed and leaves the invoice in draft.

What finalize_invoice() actually does#

In order, in a single transaction:

  1. Re-loads the invoice and validates status == draft and line_items non-empty.
  2. Looks up TenantBillingProfile for the tenant.
  3. Looks up the partner's partner_configuration row.
  4. Calls determine_vat(seller_country, buyer_country, buyer_vat_number, is_business).
  5. Recomputes subtotal_cents, tax_cents, total_cents from line items.
  6. Assigns the next number via SELECT … FOR UPDATE on invoice_sequence.
  7. Renders the PDF via template_renderer.render_pdf().
  8. Uploads PDF to S3 → sets pdf_url.
  9. 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:

sql
1
SELECT id, company_name, country_code FROM tenant_billing_profiles WHERE tenant_id = '<tnt_id>';

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:

sql
1
2
SELECT legal_name, seller_country_code, seller_vat_number
FROM partner_configuration WHERE partner_id = '<prt_id>';

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:

sql
1
2
SELECT id, name, document_type, language, partner_id, is_default
FROM invoice_templates WHERE document_type='invoice';

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 language field matches an existing template language.

4. Template Jinja syntax error#

Symptom: TemplateSyntaxError: unexpected … at PDF render time.

Diagnose with the CLI:

bash
1
scaicontrol admin templates validate

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:

sql
1
2
3
4
5
6
7
8
9
SELECT
  i.id, i.status, i.tenant_id, i.partner_id,
  CASE WHEN tbp.id IS NULL THEN 'NO_PROFILE' ELSE 'OK' END AS buyer,
  CASE WHEN pc.legal_name IS NULL THEN 'NO_PARTNER_CFG' ELSE 'OK' END AS seller,
  (SELECT COUNT(*) FROM invoice_items WHERE invoice_id = i.id) AS line_count
FROM invoices i
LEFT JOIN tenant_billing_profiles tbp ON tbp.tenant_id = i.tenant_id
LEFT JOIN partner_configuration pc ON pc.partner_id = i.partner_id
WHERE i.id = '<invoice-id>';

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:

bash
1
grep "finalize_invoice failed" /var/log/scaicontrol/backend.log | tail -20

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.

See also#

Updated 2026-05-18 01:48:40 View source (.md) rev 2