Skip to content

Async Invoice Generation Queue #4

@nullata

Description

@nullata

Add an async invoice generation path that coexists with the current synchronous endpoints. Incoming requests are persisted immediately to a queue table, a background worker processes them, and clients poll for status/results.

The existing synchronous endpoints (POST /api/v1/invoices/generate and POST /invoices/generate) must remain unchanged.


Design justification:

Pessimistic write lock (SELECT ... FOR UPDATE on the supplier row) is acquired inside InvoiceService.generateInvoice(). It doesn't matter who calls it - the sync API, the UI endpoint, or the new queue worker. All callers funnel through the same lockSupplierForUpdate(), so the database serializes invoice number assignment across all paths. No changes to the existing locking logic are needed.

New API Endpoints Spec

Endpoint 1: Submit to Queue

POST /api/v1/invoice-requests

Request body should use the same GenerateInvoiceRequest used by the existing sync endpoint

Response 201:

{
    "requestId": 42,
    "status": "PENDING",
    "message": "invoice generation request queued"
}

Calls invoiceRequestService.submitRequest(request). Executes ASAP - just a single INSERT.

Endpoint 2: Check Status (Polling)

GET /api/v1/invoice-requests/{requestId}

Response 200:

{
    "requestId": 42,
    "status": "COMPLETED",
    "invoiceId": 107,
    "invoiceNumber": "INV-000042",
    "errorMessage": null,
    "createdAt": "2026-03-30T10:00:00Z",
    "completedAt": "2026-03-30T10:00:02Z"
}

Use ENUM status: PENDING, PROCESSING, COMPLETED, FAILED.

When COMPLETED, includes invoiceId and invoiceNumber so the client can decide whether to fetch the full invoice.

Endpoint 3: Get Invoice by Request ID

GET /api/v1/invoice-requests/{requestId}/invoice

Response 200: Returns the full InvoiceResponse - same shape as GET /api/v1/invoices/{invoiceNumber}

Error responses:

  • 404 - request ID not found
  • 409 Conflict - request exists but status is not COMPLETED (body includes current status)

Crash Scenarios

Crash Point Queue Row State Invoice State Recovery
After queue INSERT, before worker picks up PENDING Does not exist Worker processes it on restart
Worker set PROCESSING, mid-generateInvoice() PROCESSING (rolled back to PENDING by tx rollback) Rolled back Worker retries on restart - attempts not incremented since tx rolled back
After generateInvoice() commits but before queue row updated to COMPLETED PROCESSING Invoice exists Edge case - see below
After queue row set to COMPLETED COMPLETED Invoice exists Clean - client polls and finds it

Edge Case: Invoice created but queue row stuck at PROCESSING

This happens if generateInvoice() runs in a separate transaction (REQUIRES_NEW) from the queue row update, and the app crashes between the two commits. On restart, the worker sees PROCESSING, retries, and generateInvoice() would try to create a duplicate.

Mitigation: The unique constraint ux_invoices_supplier_number on (supplier_party_id, invoice_number) prevents a true duplicate. The retry would fail, and the worker should catch DataIntegrityViolationException, look up the existing invoice, link it to the queue row, and mark COMPLETED.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions