Bulk Invoices — Admin
HTTP surface for an admin to batch-generate the invoices for many orders at once — across every vendor on them — merged into a single downloadable PDF, with async status polling and retry of failed orders.
HTTP surface behind Print Invoice: an admin selects whole orders and requests one merged invoice PDF. Each selected order is expanded into its invoiceable sub-orders — across every vendor on it — and a BullMQ worker generates (or reuses the cached) invoice for each, then concatenates them into a single file. A batch finishes ready as soon as at least one invoice succeeded; it only finishes failed when every one did. Failed orders can be retried without re-selecting the batch.
Source:
api-modules/invoice/src/controllers/admin-bulk-invoices.controller.ts.Admin batches are platform-scoped (
bulk_invoice_job.vendor_id IS NULL) and visible to every admin who can read invoices, so ops can pick up a colleague's batch. A vendor's own batch id returns 404 here, and an admin batch id returns 404 on the vendor surface — neither side can probe the other for existence.Note the input difference from the vendor surface: this endpoint takes
orderIds(parent orders), wherePOST /vendor/bulk-invoicestakesorderVendorIds(sub-orders). A vendor only ever sees their own sub-orders; an admin works in whole orders.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching permission.
| Permission | Grants |
|---|---|
invoice:view | List batches, read one batch's detail, download the PDF. |
invoice:bulk | Start a batch and retry its failed orders. |
invoice:regenerate | Start a batch with regenerate: true (on top of invoice:bulk). |
Response shape
- Create / retry / status endpoints return the standard JSON envelope:
{ data, message, statusCode }. - List returns the standard paginated envelope:
{ data, message, statusCode, metadata: { total, limit, offset, hasMore } }. - Download (
GET …/:id/download) streams rawapplication/pdfbytes withContent-Disposition: attachment— no JSON envelope.
Generation flow
POST /admin/bulk-invoices { orderIds: [...] }
│
▼
each order expanded into its invoiceable sub-orders
(cancelled ones skipped, unless every sub-order on that order is cancelled)
│
▼
bulk_invoice_job created (status: pending) — one item per sub-order
│
▼
background worker generates each invoice, then merges the
successful PDFs into one file
│
├─ at least one invoice succeeded ──► status: "ready" (fileName/fileSize set)
└─ every invoice failed ──► status: "failed"
GET /admin/bulk-invoices — poll the batch list (or a single id) for status
GET /admin/bulk-invoices/:id/download — download the merged PDF once ready
POST /admin/bulk-invoices/:id/retry — re-run only the failed orders in the backgroundRendering happens in the worker role (APP_ROLE=worker), never in the API process. If no worker is consuming the bulk-invoice queue, a batch stays pending indefinitely — the admin UI flags a batch that has been queued for over five minutes rather than polling forever.
Endpoints
Start a batch
POST /admin/bulk-invoices · invoice:bulk
Request body:
{
"orderIds": ["…", "…"], // 1–100 parent order ids
"regenerate": false // optional, defaults to false
}| Field | Effect |
|---|---|
regenerate: false (default) | A sub-order whose invoice PDF is already cached is reused as-is. Only sub-orders without a PDF are rendered. |
regenerate: true | Every selected sub-order is re-rendered from current data. Requires invoice:regenerate on top of invoice:bulk — 403 otherwise. |
On a regenerate, each invoice keeps its existing invoice number and its issuance-time seller snapshot (a tax invoice's number and issuing entity are immutable once issued). Everything else is rebuilt from current data — tax rows, store branding, invoice presentation config — and the stored PDF is overwritten. regeneratedAt is stamped on the invoice record; generatedAt never moves. This is the batch-scale equivalent of POST /admin/orders/:orderId/invoice/regenerate.
The flag is persisted on the batch, so POST …/:id/retry re-renders on the same terms as the original pass.
Expansion rules and limits:
- Per order, the non-cancelled sub-orders are invoiced; if every sub-order on that order is cancelled, all of them are, since a cancelled order keeps its tax invoice on record (GST reverses via a credit note, not by withdrawing the invoice).
- Sub-orders are ordered by the parent's
placedAt, then the sub-order'screatedAtand id, so the merged PDF's page order is reproducible across downloads. - At most 100 orders per request, expanding to at most 200 invoices — both bounds exist because the merge runs in memory. Exceeding either returns 400.
- An
orderIdthat does not exist fails the whole request with 400. - An order still in
pending_paymenthas no invoice yet; it is recorded as a failed line item with a readable reason rather than rejecting the batch.
Response (data field) — a batch row:
{
"id": "…",
"status": "pending" | "processing" | "ready" | "failed",
"orderCount": 24, // sub-orders (invoices), not parent orders
"successCount": 0,
"failedCount": 0,
"fileName": null,
"fileSize": null,
"retryCount": 0,
"regenerate": false, // the batch re-rendered already-cached invoices
"error": null,
"createdAt": "…",
"startedAt": null,
"completedAt": null,
"downloadUrl": null, // set only when status = ready
"requestedBy": { "id": "…", "name": "…" } // null once that user is deleted
}List batches
GET /admin/bulk-invoices · invoice:view
Standard offset/limit pagination (querySchema) plus an optional status filter. Rows are the same shape as the create response, newest first, and span every admin's batches.
Batch status + per-order breakdown
GET /admin/bulk-invoices/:id · invoice:view
Same row shape as above, plus an items array — one entry per sub-order. orderId lets a client group the items back under the order the admin selected:
{
"...": "batch row fields",
"items": [
{
"orderVendorId": "…",
"orderId": "…" | null,
"orderNumber": "ORD-2026-00000123" | null,
"status": "pending" | "success" | "failed",
"error": "…" | null
}
]
}Retry failed orders
POST /admin/bulk-invoices/:id/retry · invoice:bulk
Allowed only when the batch is ready or failed and has at least one failed item — returns 409 otherwise (including while a batch is still processing). Resets every failed item back to pending and re-runs them in the background; the merged PDF is rebuilt from the full current set of successful invoices (previously-succeeded ones are not re-rendered), so a partially-fixed retry still produces one complete file.
Response: the updated batch row (status: "processing", retryCount incremented).
Download the merged PDF
GET /admin/bulk-invoices/:id/download · invoice:view
Streams the merged application/pdf once the batch is ready. Returns 404 otherwise (not yet ready, or the batch failed outright).
Filename: the batch's stored fileName (bulk-invoices-{id}.pdf).
Banner Module — Admin
HTTP surface for managing promotional banners attached to catalog entities (categories, brands, tags, ingredients). Banner targets a single entity via a polymorphic (entityType,…
Cart Module — Admin
HTTP surface for platform-admin oversight of carts: paginated browse + detail view (priced, vendor-allocated), force-discard, manual reservation release, and a suite of analytics…