Billing runs
Period-end batch invoicing — scope, exceptions-first review, issue attempts, recovery, multi-currency totals, and limited undo.
Overview
A billing run is the period-end invoice sweep. You choose a period, warehouses, and charge types; Signals drafts the billable work, you resolve exceptions, then a queued batch issues the ready invoices. Nothing is posted until the Issue step.
Routes: /billing-runs (new run) and /billing-runs/{billingRun} (resume a run).
Open Finance → Billing runs. Requires invoices.billing-run (which depends on invoices.issue). Live issue also requires invoices.issue; undo requires invoices.void.
Scope → Review → Issue
Drafts can be re-scoped and regenerated. Open exceptions block issue. Undo is limited to runs whose issued documents are still legally voidable; a posted invoice needs a separate dated cancellation path, which is not yet available.
Scope
The scope card is free to explore. Changing period, warehouses, or charge types only refreshes the live preview; invoices are not created until you generate drafts.
| Control | What it includes |
|---|---|
| Billing period | Charges accrued up to and including the period end date. Rental orders match on charge_ends_at, falling back to ends_at. Project costs match on the cost row's created_at. |
| Warehouses | At least one warehouse is required. Each chip shows in-scope order and project counts. |
| Charge types | Hire, sales, services, and Project costs. At least one type is required. |
The preview reports billable items, orders, projects, and per-currency totals excl. VAT. When more than one currency is in scope, the company-base rollup is shown beside the currency map.
Project costs
Project costs are optional and only appear when Project costs is selected.
- Remaining, non-optional project cost quantity is discovered with the same ledger semantics as standalone project invoicing (
GenerateInvoiceFromProject). - This includes the
Labourproject cost snapshotted when a project crew assignment is created with its required Labour service. It is selected by Project costs, not by the rental-line Services charge type. - A project is in warehouse scope when its deterministic primary rental — earliest-created live member, id as the tie-breaker — belongs to a selected warehouse. Projects have no warehouse of their own.
- Each project becomes one billing-run item: a snapshot of the selected cost lines, their remaining quantities, currency, stored-base amount, billing account and primary rental id. The identity evidence keeps issue and PO-stamping deterministic.
- Draft invoices for project items are not created at populate time. They are generated at issue time from that snapshot.
base_currency_code is written once when the run is created from the company base currency. Later company-base changes do not rewrite an existing run; project issue fails closed if the live base no longer matches, so you start a new run.
Review
Review is exceptions-first. A segmented bar shows clean drafts, items with exceptions, and excluded items. Groups that still need a decision start open.
| Exception | Typical cause | Remediation |
|---|---|---|
| Missing PO number | Customer PO required and blank | Enter the PO; it is stamped onto the source rental (or the project's primary rental) and any draft invoice |
| Rate disagreement vs contract | Charged hire rate differs from authoritative rental pricing | Use the contract rate, or accept the charged rate |
| Credit-hold customer | Account is on stop | Supply the required reason and approve; a blank reason is refused, while the supplied reason is stored on the override and shown on the resolved row; or exclude |
| Zero-value line | Amount is zero | Exclude, or keep as intentional |
| Partially returned early | Hire line returned before the period end | Apply a pro-rata credit, or keep the full charge |
Rate disagreement and partial return apply to rental items only. Project items honestly raise credit hold, missing PO, and zero value.
Each row carries a Project costs or rental identity, then an evidence grid (project name, primary rental, cost-line count, exception summary). Project rows also remind you that the invoice is generated at issue time and that warehouse follows the primary rental.
Resolved or excluded items can be reopened before issue. Continue to issue stays disabled while any exception is open.
Issue
The commit summary repeats ready and excluded counts, per-currency totals incl. VAT with the stored base rollup when mixed, and the per-warehouse split.
Send options choose how issued invoices are delivered (hold, email now, or the other catalogued delivery modes). Dry run simulates the batch without issuing and leaves the run in review.
Dry run
A dry run writes last_dry_run_at, dry_run_count, dry_run_totals_minor, and dry_run_base_total_minor — the same per-currency map plus stored-base pair used for live issue. The run stays in review so you can switch to a live run. Nothing is posted.
Live issue
Live issue atomically claims the reviewed scope as an immutable issue attempt, moves the run to Issuing, dispatches one queued job per ready item, and shows a progress panel (issued / total, failures if any). The attempt retains the scope revision, ready-item IDs, each ready draft's invoice version, delivery mode, dispatch state and framework batch identity for recovery. A second issue cannot replace an active attempt. Before claim and posting, an attempt-bound worker requires the matching saved batch marker even after its batch ID was acknowledged; a missing or altered batch cannot issue or fail the item. A draft edited after that version snapshot is refused before claim or posting; it needs a new reviewed scope, not a silent issue of the newer draft. A rental must also remain eligible for the run's period, warehouse, charge-type and source-state scope at both worker boundaries; a moved or reclassified source is not issued from its old draft. The draft must still have its full unpaid balance and no payment or settlement-allocation rows; cash evidence on a draft blocks the worker and requires investigation. The frozen draft's line net, line tax and tax-row sums must reconcile with its header before claim and posting; this checks structural tax evidence, not the still-open legal tax-point policy. Foreign-currency drafts also need a dated resolved base rate before the worker claims them. A newly detected exception, or a changed exception that no longer matches the reviewed waiver, blocks issue. Project cost sources must still be Draft or Active, and their deterministic primary rental, warehouse and billing account must still match the reviewed item before an issue-time draft is generated and before it posts. A new project exception or changed waiver evidence also blocks issue. Before generating that draft, the worker rechecks the selected project-cost membership, remaining quantities, currency and current calculated total against the reviewed item; drift fails without reserving cost quantity. Once the draft exists, its frozen cost lines must still match the reviewed selection and live source costs, and newly billable in-period costs block posting. Older project items without the account identity evidence cannot issue through an immutable attempt and need re-review.
- Rental items issue the draft already attached at populate.
- Project items call
GenerateInvoiceFromProjectfrom the snapshotted cost lines, then issue that invoice. - Counters refresh after each item:
totals_minor/base_total_minorfor the whole run (excluding excluded items) andissued_totals_minor/issued_base_total_minorfor issued items.
The terminal result is derived from all run items, including items that failed before dispatch. A callback cannot complete a run while a Ready, Pending or Exception item remains.
| Run state | Meaning |
|---|---|
| Issuing | An active attempt is incomplete or has unresolved claims; do not infer success from a finished queue batch alone. |
| Completed | Every item is Issued or Excluded, with at least one Issued and no Failed item. |
| Completed with failures | Every item is terminal, at least one is Issued, and at least one Failed. Inspect failed items; success is partial. |
| Failed | Every item is terminal, but none was Issued. A safely cancelled undispatched attempt also ends here. |
| Undone | A permitted undo voided eligible non-posting documents and reset their items. It does not mean a posted financial invoice was reversed. |
There is no separate Cancelled run state. Cancelling a verified stalled batch stops future jobs, then reconciliation must settle its claims; the run can remain Issuing until that evidence is resolved. Do not treat a cancelled queue batch as a cancelled invoice or run.
Webhooks fire for billing_run.created (at create) and billing_run.completed when a run reaches Completed or Completed with failures. Scope updates and PO stamps audit only.
Framework batch retention
Run php artisan signals:billing-runs:prune-batches manually in the selected tenant context (or the self-hosted application). It deletes only framework job_batches metadata with a non-null finished_at strictly older than 24 hours and a null cancelled_at. The cutoff and cancellation exclusion have no command overrides. Finished-and-cancelled batches, unfinished batches, and batches at or after the cutoff survive. This command has no scheduled run.
Finance runs, items, issue attempts and issue-effect receipts are retained, including their original batch IDs after metadata expires. Invoices, payments, postings and event sources are untouched. Use the attempt/item/receipt links and the recovery commands below when the framework batch is absent; absence does not establish successful issuance or authorize a financial retry.
The framework queue:prune-batches command has broader optional pruning and can delete finished-and-cancelled batches even with its default options. Use the Signals command for this retention policy; do not schedule the framework command for these records.
Operator recovery
The scheduler attempts pending dispatch every minute, reconciles stale claims every fifteen minutes, and retries pending issue effects every minute. In Signals Cloud, run the commands inside a selected tenant context; the command refuses an unbound or config-only tenant. Inspect one attempt and its run/item/invoice evidence before using a write option.
The attempt's dispatch_status is separate from the run state:
| Attempt dispatch state | Operator interpretation |
|---|---|
pending |
The locked issue claim exists, but no worker has claimed queue dispatch. Preview or retry pending dispatch. |
claimed |
A dispatcher is or was working. An old claim may be retried; first check for an unacknowledged durable batch. |
dispatched |
A framework batch was acknowledged. Inspect its immutable marker and item claims before reconciliation or cancellation. |
cancelled |
An old attempt with no durable batch was safely cancelled and its claim items settled Failed. This is not a Cancelled run or a reversal of posted invoices. |
Older attempts without the immutable invoice-version snapshot cannot be dispatched or used to claim a draft. Inspect their batch and invoice evidence, then use the guarded cancellation or reconciliation path; do not invent a version for an already-claimed item.
If a worker retries after its claimed invoice is already issued, it settles the item only when that attempt's complete posting and downstream-intent receipts are present. An invoice issued outside the run, or one with missing receipts, stays claimed for operator investigation; the retry must not manufacture receipts for it.
| Situation | Preview first | Apply only when the evidence is clear |
|---|---|---|
| Attempt claimed but no acknowledged batch | php artisan signals:billing-runs:dispatch-pending --attempt=<uuid> |
Add --apply to retry dispatch/adopt its durable batch. |
| Dispatched attempt with stale Pending/Ready claims | php artisan signals:billing-runs:reconcile-attempts --attempt=<uuid> |
Add --apply to settle only receipt-backed posted or authoritatively unissued items. Read the unresolved count; it is not an all-clear. |
| Old attempt without a durable batch cannot safely dispatch | php artisan signals:billing-runs:reconcile-attempts --attempt=<uuid> --cancel-stalled-attempt --older-than-minutes=60 --reason=<code> |
Add --apply only after confirming the command's preview and absence of posting/effect receipts. The run becomes Failed. |
| Verified dispatched batch is stalled | php artisan signals:billing-runs:reconcile-attempts --attempt=<uuid> --cancel-stalled-batch --older-than-minutes=60 --reason=<code> |
Add --apply to cancel the batch and reconcile safe claims. Check the resulting run state and unresolved count. |
| Posted invoice is settled but PDF, communication or dashboard work is pending | php artisan signals:billing-runs:dispatch-issue-effects --receipt=<id> |
Add --apply to retry the recorded effect; do not reissue the invoice. |
A saved framework batch is not proof every queue job was delivered. If an old unacknowledged batch has the matching immutable marker but its jobs are absent or delivery cannot be confirmed, preview and apply dispatch-pending to adopt that batch; it must not create another one. Adoption retains the batch's original creation time for the cancellation age check. After inspecting the queue, invoice states and effect receipts, preview reconcile-attempts --cancel-stalled-batch --older-than-minutes=60 --reason=queue-delivery-unconfirmed and apply only if cancellation is warranted. It fences late jobs, but may leave a posted or ambiguous claim unresolved for investigation. Do not infer missing jobs solely from a batch counter or an empty queue listing: a worker may already have taken a job.
<code> is a short, non-sensitive reason such as stalled-worker-review; never put customer data in it. Cancellation requires one attempt UUID and a minimum age of 60 minutes. Missing/mismatched batch identity, incomplete attempt scope, ambiguous posted invoices or incomplete effect receipts fail closed or remain unresolved: investigate the authoritative invoice and receipts rather than manually clearing a claim. The normal reconciler starts at 15 minutes; it cannot prove every ambiguous external effect safe to repeat.
Bounded reconciliation modes
reconcile-attempts is read-only by default. --record reports attempt facts that are already retained and does not settle items or cancel a batch. --repair explicitly invokes the installed receipt-backed reconciliation; --apply is still accepted as an alias from earlier Signals versions. Never combine mode flags. Repair keeps the existing transaction locks through settlement; a held run, attempt or item is reported as skipped_locked without settling it.
Use --limit=1..500 and the returned --cursor to resume the same age, attempt, cancellation and mode selection. The cursor keeps the original cutoff and upper attempt ID; already visited attempts do not repeat. After a skipped lock is released, start a new selection to revisit that attempt. --json emits version 1 with attempt rows, errors, pagination and the command exit code; it includes no invoice/customer payloads. See Operational reconciliation for the complete command and exit-status map.
Recovery controls in the console
A saved run with a recorded active attempt shows Attempt recovery. Select Resume attempt, Reconcile stale claims, or either guarded cancellation mode. Preview first, inspect the original status and unresolved count, then explicitly apply. Resume preview performs no dispatch and does not certify eligibility; apply calls the released dispatcher for that same attempt. Cancellation requires a non-sensitive reason code and at least 60 minutes; reconciliation requires at least 15 minutes. Recovery never creates a replacement attempt or resets failed items.
Recovery requires both billing-run and invoice-issue permissions, a selected Cloud tenant, and access to every warehouse in the run and its items. The original dispatcher and reconciler decide eligibility, immutable batch identity, receipt completeness and cancellation safety. An inactive, unverified or unresolved outcome remains visible and is not success.
Download attempt evidence requires billing-run permission and the same tenant/warehouse scope. The JSON contains recorded attempt identity, scope revision/digest, batch and dispatch status, invoice-version references and at most the first 100 ready-item references. For those items it includes up to 400 matching effect-receipt identities, effect keys, required/status flags and dispatch counts/times, with an explicit effect_receipts_truncated flag. ready_item_count and truncated disclose omitted references; this is a bounded investigation extract, not complete posting certification. Raw item evidence, failure messages, dispatch errors, provider payloads and claim tokens are excluded. The download does not change records.
Retry failed item is unavailable: there is no released single-item retry authority for terminal failed items. Do not reset Failed to Ready, clear the active attempt, or reissue a posted invoice. Full review regeneration is a separate guarded operation and does not establish single-item retry safety.
Before choosing a recovery action
Record the attempt UUID, active run, immutable scope/item and invoice-version snapshot, dispatch status, batch ID/marker, item states and posting/effect receipts. Attempt, queue-batch and run states answer different questions; a cancelled batch does not reverse invoices.
Preview the command for that one attempt without --apply. Dispatch/reconciliation inspection defaults to 50 attempts and accepts --limit from 1 to 500; age filters must be integer minutes. Normal reconciliation starts at 15 minutes, while cancellation requires at least 60 minutes, exactly one attempt and one non-sensitive reason code. Use either cancellation flag, never both.
If a durable matching batch exists, recover its acknowledgement rather than creating a replacement. If posting or effect evidence is ambiguous, preserve the claim and investigate the source; do not invent a receipt, invoice version or item status. Read both the per-attempt status and unresolved count after apply. The ordinary reconcile command can exit successfully while unresolved items remain, so a zero exit status is not proof the run is terminal. A cancelled batch with unresolved claims is explicitly reported as an unsuccessful cancellation outcome.
Treat completion as an all-item result: any remaining non-terminal item keeps Issuing; no issued item means Failed, including an all-excluded run; issued plus failed means Completed with failures; issued with only issued/excluded terminal items means Completed. Completion does not prove every PDF, communication or other downstream effect finished. Inspect those recorded effect receipts and use the effect dispatcher rather than issuing invoices again. Never edit immutable attempt identity/scope evidence or delete its record to force recovery.
Undo
Undo is void-based. It never deletes issued invoices and is not a posted-invoice cancellation.
A Completed or Completed with failures run can be undone only while undo_deadline_at is still in the future, its terminal issue attempt still matches, and every issued invoice is legally voidable. Posted invoices, recorded payments or settlement allocations, and persisted PDF/share/email/view document effects block undo. When allowed, undo voids the non-posting documents, returns their issued items to Ready, clears their issued links, and marks the run Undone in one transaction. An operator must not bypass a refusal by editing run or invoice status directly.
Voiding a project invoice releases the invoiced_quantity frozen on each source project cost, so the same remaining quantity can be swept again.
The window length is invoices.billing_run_undo_minutes (default 15, range 1–1440), configured on Admin → Money & Pricing → Finance. The minutes are read when the batch finishes. Changing the setting later does not move an already-stamped deadline. After the window closes, canUndo() is false and undo is refused.
Multi-currency
Mixed-currency scopes never add raw minor units across currencies.
| Field | Meaning |
|---|---|
base_currency_code |
Company base at run creation; fixed for the life of the run |
totals_minor |
Per-currency map of in-scope amounts (excludes excluded items) |
base_total_minor |
Stored-rate rollup of those amounts into the run's base (items with a resolved base amount only) |
base_unresolved_count |
In-scope items whose base amount is not resolved (stored as NULL, never 0) |
issued_totals_minor / issued_base_total_minor / issued_base_unresolved_count |
The same values, issued items only |
dry_run_totals_minor / dry_run_base_total_minor |
The same pair written by a dry run; a dry run or issue is refused while a ready item has no resolved base amount |
Progress, the completion panel, webhooks, and undo all read these stored maps. Preview uses the same shape before a run exists and adds base_total_complete.
A base total is complete only when its unresolved count is 0. While it is not, the console shows Base incomplete with the count instead of a base amount, and the webhook and job-progress payloads send base_total_minor: null (or issued_base_total_minor: null) with base_total_complete: false and the unresolved count.
Permissions
| Permission | Grants |
|---|---|
invoices.billing-run |
Open the console, create and re-scope runs, populate, resolve exceptions |
invoices.issue |
Required together with invoices.billing-run to issue (including dry run) |
invoices.void |
Required together with invoices.billing-run to undo |
Related
- Invoicing & Payments — checkout, issue, void
- Rental Projects — standalone project invoicing and the primary-rental rule
- Multi-Currency & Tax
- Finance settings — undo window