---
title: Invoice won't finalise
path: troubleshooting/invoice-not-finalising
status: published
---

# 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
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
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](../concepts/templates)): partner-specific exact match → partner-specific language fallback → platform-default exact → platform-default language fallback.

Check what's available:

```sql
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
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
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
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

- [Concepts: invoice lifecycle](../concepts/invoice-lifecycle)
- [Concepts: templates](../concepts/templates)
- [Concepts: VAT & reverse charge](../concepts/vat-and-reverse-charge)
