When a tech logs time on a customer's job — using the start/stop timer in /portal/tech — those entries become 'unbilled labor' against that customer. The invoice composer has a 'Pull labor from past visits' button that lists every unbilled entry grouped by appointment so you don't have to re-type hours or guess what the customer owes.
What counts as unbilled labor
- A time_entries row with this customer's client_id where billed_at IS NULL.
- Started_at + ended_at both set (the entry is closed — open timers don't show).
- The picker groups entries by appointment_id so you see 'Tuesday's water-heater install — 3.5 hrs across 2 entries' instead of a flat row-per-row list.
Pulling labor onto an invoice
- 1Open the invoice composer
/portal/invoices/new with the customer pre-selected (from the customer detail page) or pick the customer in the composer first. The 'Pull labor from past visits' button only shows when a customer is set.
- 2Click 'Pull labor from past visits'
A panel slides in showing every unbilled appointment with totals: 'Tuesday Apr 29 — Mike Johnson — 3.5 hrs at $85/hr suggested = $297.50.'
- 3Pick which appointments to add
Tick the appointments you want billed. Each entry shows the duration, the suggested rate (weighted average of the rates on the underlying entries — see below), and the resulting line-item dollars.
- 4Override the rate or description if needed
Default description is 'Labor — [appointment service name] — [date].' You can rename or change the rate before adding; once added it's a normal invoice line.
- 5Click 'Add to invoice'
Plyrium creates a labor line item per selected appointment, marks every underlying time_entry billed_at = now() + invoice_line_item_id = the new line, and recomputes invoice totals. Closing the composer without saving DOES NOT mark anything billed — only Save / Save & send commits.
How the suggested rate is computed
Each time_entry has its own hourly_rate_cents (set from the tech's default rate at the moment they started the timer). When multiple entries roll into one appointment the picker shows a weighted average: total cost ÷ total minutes × 60. So 1 hour at $85 + 2 hours at $95 → 2.917 hrs × $91.43 weighted-avg.
Plyrium computes duration_minutes + cost_cents server-side via Postgres GENERATED ALWAYS AS columns from the millisecond started_at + ended_at timestamps. No client clock skew, no JS floating-point drift. Jobber's customers report ~90 phantom minutes per week of math-drift between their tech app and reports — that doesn't happen here. Run scripts/audit-time-math.mjs against any workspace to verify.
When NOT to pull labor
- The job is flat-rate — labor is already bundled into the service price (e.g. a water-heater install priced $1,200 total). Pulling time entries on top of that double-bills the customer.
- The visit was a contract-included tune-up — the customer already paid for that visit via their recurring contract. Add a $0 line item or skip it.
- The customer is on a Care Club membership with included visits — same logic; the perk covers the labor.
Reversing a pulled labor line
Delete the line item from the invoice (drafts only). The underlying time_entries.billed_at column stays set — that's intentional, it's an audit trail of 'this hour was billed at some point.' If you need to bill that time on a different invoice, run an SQL update to clear billed_at, or wait for the future 'remove from invoice' flow that does this automatically.
Once you click Send on the invoice, the labor lines are locked along with everything else (financial integrity). To make changes, void the invoice + create a new one — the underlying time_entries are still marked billed, so they won't reappear in the picker. Clear billed_at manually on the relevant entries before re-pulling.