---
name: evolution-dispatch-invoicing
description: The INVOICE button on a committed dispatch — how it works, and the five defects fixed 2026-07-30 (incl. the both-ways job-first guard)
metadata: 
  node_type: memory
  type: project
  originSessionId: 223a3fcc-5606-40f1-bcfd-fcae3c4ac754
  modified: 2026-07-30T03:07:38.501Z
---

A committed dispatch can be invoiced directly: `dispatchedit.inc:927` **INVOICE** submit → `dispatchsave.php:177` (`$_REQUEST['invoice']`) → `dispatch::invoiceAdd()` in `library/dispatch.php:140`. Documented at `docs/projects/invoicing-dispatches.php` (prod release notes 150 = guide, 151 = fixes 1–4, 152 = fix 5 + the label change).

Button gate: `dispatch->status==2 && job->status=='1' && (mod_jc==2 || mod_inv==2)` **and no live invoice already linked**. It sits inside the card headed *"Uncommit Dispatch"*, which is purely a layout accident — it does not uncommit.

What it builds: `newInvoice()` then one `addLines()` per `dispatchitems` row. Qty = dispatched qty, price = `jobInventory.price`, `src='job'`, `srcid`+`jobitemid` = jobitemid. Passes `taxid`/`taxamount`/`accountid` as `""`, which is fine — `addLines()` inherits `inventory.taxSell`/`incaccount`. `dispatchsave.php` then stamps custref/reference/qtype/email/telephone/contactid/contact_p and redirects to `invoiceadd`.

**Five defects found + FIXED 2026-07-30 (verified live on dev: dispatch 10395 → invoice 59909):**

1. **`invoiceitems.jobitemid` never set** — only `srcid`. `jobedit.inc:111` / `job.php:773` / `returngoods.inc:24` compute invoiced qty off `jobitemid`, so the job's un-invoiced qty never dropped and the Itemized method re-offered delivered goods → **double billing**. Fixed by passing `jobitemid` too (audited every consumer; `invoiceaddsave.php:797` is gated on `src=='timesheets'` so it's unaffected).
2. **No re-invoice guard** — pressing INVOICE twice built a second full invoice and overwrote `dispatch.invoiceid`. Fixed in depth: handler-side check in `invoiceAdd()` (returns false + `$invoiceError`/`$invoiceExistingId`) **and** UI — the button becomes a link "INVOICE {id}". A link to a *deleted* invoice is treated as stale (invoice delete is a hard `DELETE FROM invoices`), so the dispatch can be re-invoiced.
3. **Wrong contact** — stamped `$contacts[0]` (lowest-id active addressbook row for the client). Now uses the dispatch's own `contact_name`/`phone`/`email`, resolving the name against the client's addressbook for a `contactid`, and leaves contact blank rather than borrowing someone else's. Also now sets **`invoices.contact_p`** (the field actually displayed at `invoiceadd.inc:576`) — previously the shown name and `contactid` could disagree. `dispatch.contactid` holds a **client** id, not an addressbook id. See [[evolution_dispatch_contact_source]].
4. **INNER JOIN to `jobInventory`** silently dropped ad-hoc dispatch lines → delivered, never billed. Now LEFT JOIN; unpriced lines come in at 0.00 and `$disp->unpricedLines` drives a `doNotice()` toast. (Not verifiable end-to-end on dev — no committed+open+uninvoiced dispatch has orphan lines; query verified against dispatch 10292.)

5. **Fix 1 was one-directional** — it stopped the job re-offering delivered goods, but invoicing *from the job first* (Itemized) still left the dispatch's INVOICE button willing to bill the same goods again. Fixed with **`dispatch::billableLines()`** — one shared method called by both `invoiceAdd()` and `dispatchedit.inc`, so the button and the handler cannot drift. Per job item it takes the **lower of two ceilings**, and *both are needed*:
   - **C1 (this delivery):** `sum(dispatchitems.qty for this dispatch) − (invoicedQty − invoicedByOtherDispatches)`. The attribution term is essential — without it a job item with far more ordered than this drop shipped looks like it still has room (proved on dispatch 10394, whose 4 lines were all fully billed yet the item ceiling alone said 2.75 remained).
   - **C2 (the job item):** `cap − invoicedQty`, where cap comes from `invoice_flag`.
   Both sub-selects **`INNER JOIN invoices`** — dev has 49 orphan `invoiceitems` rows (8,089 qty, 33 job items) left by `invoiceaddsave.php:1036` deleting lines before the header; counting them would wrongly block real invoices. (Those orphans still inflate the job screens — separate pre-existing bug.)
   Three UI states: greyed-out button + red text when `blocked`; live button + amber "only X of Y will be billed" when reduced; unchanged otherwise. Handler repeats it via `$_SESSION['message']` from `reducedLines`/`skippedLines`. Qty columns are floats, so both ceilings go through `billableRound()` (4dp, <0.001 → 0) *before* being compared — otherwise a 0.0000018 sliver becomes an invoice line and picks the wrong holdback reason.

**`jobInventory.invoice_flag` semantics** (the thing that made this subtle — 0 and 1 are about *qty variations*, not exclusion):
- `1` **Invoice/Credit Extras** → cap = `qty` (bill the varied qty)
- `0` **Do not invoice/Credit Extras** → cap = `qty_orig` (variation neither charged nor credited; ordered 8 → varied 10 → bill 8)
- `5` **Disable All Invoicing** → cap = 0, line never billable

Confirmed by `invoicejob.inc:427-433` (flag 1 strikes through `qty_orig`, flag 0 strikes through `qty`) and `jobeditsave.php:2148-2157`. The Add-Item dropdown in `jobeditinv.inc:194` and `jobeditquot.inc:83` used to say *Do & Charge / Do not invoice / Do not invoice item* — three labels that all read as exclusion; relabelled to match the item-edit modal (`jobeditinv.inc:1185`), which was already right.

Also added: `mod_jc==2 || mod_inv==2` gate on the handler (the POST is replayable), and `logEntry("dispatch",…)` + `logEntry("job",…)` — the dispatch History panel is scoped `$histsrc="dispatch"` (`dispatchedit.inc:905`), so a job-only entry doesn't show there.

Still outstanding: the *Invoiced* column on the job's Dispatch tab (`jobeditdispatch.inc:79`) is hard-coded `N/A`.

Rarely used — only **5 of 9721** dev dispatches had ever been invoiced this way pre-fix.

See [[evolution_invoice_job_quote_import]] for the Invoice Job window's own methods.
