Jobs & Dispatch runs the work that happens after a quote is accepted, for trades that don't run field-service software today: customers with their service addresses, jobs with one or more scheduled visits, and techs who sign in on their own phones to see the day assigned to them. Jobs bill through Client Billing.
The problem
A two-truck electrician runs the day from a whiteboard, a group text and memory. The office doesn't know who is where, the tech doesn't have the gate code, and the job never quite turns into an invoice. The tools that fix that (Jobber, Housecall Pro, ServiceTitan) are a second monthly bill, a second customer list and a second login, none of which talk to the website the customer found them on.
The fix
A feature module under app/Features/WorkOrders/ (feature key work_orders; "Jobs" in every word a person reads, WorkOrder in code because Laravel owns jobs). It does not keep a customer list of its own: the customer is the Client Billing client, and a job becomes a Client Billing invoice. A client gets any number of service addresses — the job site, with its access notes — separate from the billing address on the client record.
Like every addon it is a feature module: the service provider boots unconditionally, and routes 404 when the feature is off. Because it bills through Client Billing it runs only while both work_orders and client_invoicing are on — the routes carry both feature: gates, and everything else asks WorkOrdersFeature::enabled(). Switching Client Billing off another way (a theme install, a direct setting write) therefore takes the whole module off with it rather than leaving it half on.
Techs and their access
A tech is a User — they sign in with their own account — plus a row on Jobs → Techs that holds their mobile number, board colour and hourly cost. That row is the grant: it is what opens the tech pages to a Staff login, the same per-user grant shape as the live chat console and content-type grants (the Staff role itself opens nothing).
- Who can be a tech: anyone at Staff or above. A Standard account is a customer; a tech row on one admits nothing, and the Techs page flags it ("Cannot sign in — not Staff"). Add the person on the Users page (admins) as Staff first.
- Where a tech lands: signing in sends a Staff tech to My day (
/dashboard/my-day), not the account hub, and their sidebar holds only that page. - What a tech sees: only visits assigned to them, and the job, customer and address of those visits. Every tech query starts from
Visit::assignedTo($user)(andWorkOrder::visibleToTech($user)for jobs) — scoping is enforced in the query, never by hiding a link. - Managers and up reach the tech pages without a tech row (they see every job anyway); a Manager who also runs calls can be made a tech too, and then sees My day in the Jobs group.
- Revoking: Deactivate keeps the colour and hourly cost for when they come back; Remove deletes the row. Either takes effect on the tech's very next click — the gate (
field-techmiddleware,EnsureFieldTech) is re-applied on every Livewire update, so a page left open stops working too. Visits they worked keep their name, because assignments point at the user, not the tech row. Granting, deactivating, reactivating and removing tech access is written to the Activity Log (when that feature is on), each under its own name — a deactivation never reads as a removal, so an audit of who can still come back is possible from the log alone. - Hourly cost is private (never on anything a customer or another tech sees) and may be left blank — an unknown cost is stored as unknown, never as zero, so job profit can say "incomplete" instead of showing a flattering margin.
- Mobile numbers are stored in E.164 (
+15551234567), so a text reaches the same number an opt-out was recorded against.
Jobs
A job is work for one client at one of their service addresses: a title, the work to do (customer-visible), internal notes (office and techs only — never on anything the customer sees), a priority (normal / high / emergency), its lines, and one or more visits. Jobs are Manager-and-up; a tech never reaches the jobs pages, only My day.
The jobs list (/dashboard/jobs)
Saved views as tabs — Open (the default), Needs scheduling (open jobs with no open visit, or an open visit nobody has put a time on), Today (a visit that isn't cancelled starts today on the site's clock), To invoice (completed, with no invoice or only a voided one), Completed, All — the working tabs show a count. Filters narrow any view: search (job number, title, client name / company / email / phone, address), status (picking one switches to All, so it never fights the tab), tech (jobs with a visit assigned to them), and a visit date range (site-clock days, inclusive). Emergencies sort first, then high, then the newest. Each row shows the next visit with its techs' colours ("No tech assigned" in amber), the lines' total and the invoice with its status — and an Out of date badge when the job changed after it was billed.
Creating and editing a job (/dashboard/jobs/new, /dashboard/jobs/{id}/edit)
- Client — search and pick, or New client inline: an email OR a phone is enough (phone-only customers are normal in trades), an optional "They agreed to get texts at this number" box records a staff-attested consent (refused without a number that can take a text), and an optional service address is created with them and also becomes their billing address until someone changes it on the Clients page.
?client={id}on the New job link preselects the client. - Service address — one of the chosen client's addresses (a client with exactly one gets it picked automatically), or none yet. Add and edit addresses right there. An address must belong to the job's client — an id from another client's list is refused on save. An address a job points at can't be removed (the jobs table refuses to lose it); the form says so instead of failing.
- Lines — description (free prose, no 255 limit), kind (service / material), quantity, unit price (negative allowed, for a discount line) and unit cost (private, for job profit; blank = unknown, stored
NULL, never0). Add from price list copies the item in by value — its wording, price, kind and today's cost (blank when the price list doesn't know it), plus the catalog id as a note of where it came from. A later reprice, rename or archive of the price-list item never changes the job. Blank rows are dropped on save. - Job type — optional free text ("Panel upgrade", "EV charger", up to 60 characters), suggested from the types already in use so one kind of job isn't spelled three ways; spaces are collapsed on save. The profit report totals by it.
- Lines save by id. Only the lines this form loaded and then removed are deleted; a line someone else added while the form was open (a tech logging a material from the van, once the tech pages can) survives the save. A posted line id belonging to another job is treated as a new line, never as an edit of that job's line.
- Once the job has an invoice (not voided), it stays with that client — the client can't be changed.
The job page (/dashboard/jobs/{id})
The work, the internal notes, the visits, the checklists, the lines and total (before tax — tax is added on the invoice; a line with no cost is marked No cost), the job's expenses, its Profit box (see Job costing), the client (tap-to-call / email) and address (maps link, access notes), the invoice, and a History built from the stamps the rows already carry: created (and by whom), each visit's on-the-way / started / completed (and by whom), invoiced, invoice sent / paid, job completed / cancelled — oldest first.
Visits. Add visit: an optional title (Rough-in, Final), a date and start time on the site's clock, and either a length (defaults to the Settings visit length) or an arrival window ("arrive from 8:00 to 12:00", same day, must end after it starts). Leave the date blank to keep the visit unscheduled — it shows under Needs scheduling. Pick the techs (active techs only; anyone already on a visit whose tech row has since been removed stays until someone takes them off). Times are stored as UTC instants and a length is real minutes, so a visit on the day the clocks change is still at the time that was typed and lasts what it says. Per visit: Edit visit, Mark done, Cancel visit (off the tech's day, kept on the record), Delete visit (only while it is still a plan — nobody has set off, started or finished it).
- A stale edit is refused. Every change bumps the visit's
lock_version; saving an editor opened before someone else's change says so ("Someone else changed this visit…") instead of overwriting it. - Once a tech has set off, the visit's time is history: the editor shows it read-only and only its techs, title and notes can change.
- Every visit action looks the visit up through the job on the page, so a visit id from another job is a 404.
How a job's status moves
A job's status is derived from its visits (JobStatus), never typed in:
| Visits (cancelled ones don't count) | Job status |
|---|---|
| none, or none with a time | Unscheduled |
| an open visit with a time | Scheduled |
| a tech on the way or working one, or some done and some still to come | In progress |
| every visit done | Completed (stamps completed_at) |
- The last visit done completes the job — whether by Mark done or by cancelling the only other open visit. Adding a visit to a completed job reopens it.
- Mark job complete (the actions menu) closes a job with nothing open on it — the phone fix that never needed a visit. A job with work still scheduled finishes when that work does.
- A required checklist item holds up the work it belongs to (see Checklists): a visit can't be completed — from the phone or with Mark done — while one of its own checklists has a required item unanswered, nor can the job's last open visit (or Mark job complete) while a whole-job checklist has one. The refusal names the items.
- Cancel job cancels it and every visit still only planned, so nothing of it stays on a tech's day; completed visits and any invoice are kept. Refused while a tech is on the way or working it — including a tech whose On my way or Start lands while the cancel is being written: the cancel re-checks after writing and, if a visit went underway, is undone whole and refused. Reopen job brings it back; its status then follows its visits again — except that it never comes back Completed. A job reopened because more work is coming, whose finished visits are all that's left (the rest were cancelled with it), comes back In progress and waits in Needs scheduling for the rest to be booked (or for Mark job done) — so a job the customer called off half-way never announces a finish (
job.completed, the CRM entry, a review request). Cancelled is the one status the office sets by hand, and it sticks until reopened. - Delete job is only for a job made by mistake: never invoiced, and no tech ever set off on, started or finished any of its visits. Anything else is cancelled instead.
- Marking a visit done is claimed atomically (a double tap stamps it once, by the first person), and the job row is locked while its status is re-derived, so two techs finishing the last two visits at once can't leave it "in progress".
Job → invoice
Create invoice on the job page makes a draft Client Billing invoice (JobInvoicer) for the job's client: the job's lines copied by value (description, quantity, unit price — cost and kind stay on the job), the site's default tax rate, due days and terms, and notes that start with "Job JOB-0007 — Panel upgrade, 12 Oak St". Draft, because the office usually looks it over first; Send invoice on the job page (or the invoice editor) issues it through InvoiceSender — by email, or Mark sent (no email) for a phone-only client so its link and QR work — with the Contracts gate kept.
- One job, one invoice. The link is claimed with a compare-and-swap on
work_orders.invoice_id: a double tap, or the office and a tech pressing it at once, makes one invoice, and the loser gets the winner's (its own is rolled back as if never made). A job with no lines, a cancelled job, or a module that is off is refused with a reason. - A voided invoice is no invoice — the job can be billed again, and it shows under To invoice.
- Out of date. The job remembers a fingerprint of the lines it copied (
invoiced_lines_hash). If the job's billable lines change afterwards (added, removed, reworded, re-priced, re-counted) the job page and the list say Out of date — the invoice still bills the old lines. Changing a line's cost or kind doesn't (neither reaches the invoice), and adjusting the invoice itself in the invoice editor doesn't either (that is deliberate). Update invoice replaces the invoice's lines with the job's and re-totals it — only while the invoice is editable (a draft, or sent with no payment); a payment recorded mid-update wins. Once money has moved, bill the difference on a new invoice. - Creating and updating are written to the Activity Log (
invoicing). - A job made from an accepted proposal or estimate is billed at the tax rate the client signed for (stored on the job as
tax_percent), not the site's default; the job page says so under its lines. Jobs made by hand or from a booking use the default.
Where jobs come from
Besides New job, jobs come from three places (JobIntake). Each source can become one job, ever — work_orders is unique on (source_type, source_id), so a booking confirmed twice, a button pressed twice or two tabs at once all end with the same job (the loser of a race gets the winner's, and anything it created rolls back). The job page shows where a job came from ("From Proposal PRO-0012", linking back).
A service-call booking (Online Booking, service-calls mode) becomes a job automatically when it is confirmed — from the booking wizard, the chat tools or the dashboard alike:
- The client is matched by email, so a repeat customer keeps one record; a booking with no email gets its own client (phone-only customers never merge). The customer's yes to texts on the booking is copied onto the client (source
booking). This is the same rule the bookings page's Bill action uses (AppointmentClient). - The address becomes one of the client's service addresses — or reuses one they already have when the street, city and ZIP match (ignoring case and spacing).
- The visit is the arrival window they booked, as a window, with no tech on it yet — the dispatcher assigns it. The job reads Scheduled because its visit has a time; it is never set by hand.
- The fee (a priced service's "due at the visit") is the job's first line; the customer's notes and intake answers go into the internal notes (office and techs only).
- Staff-slot bookings are not jobs (v1).
- Safety: the job is made only after the booking's transaction commits (a booking that rolls back leaves nothing), and a job problem is reported, never thrown — it can't break the customer's confirmation. While the module is off, nothing happens.
When the customer changes the booking:
- Reschedules (manage page or dashboard): the visit moves with them — but only while it still holds the window the booking had, untouched by anyone since. A visit the dispatcher already moved, or one a tech has set off on, keeps what they decided; the job page's "From Online booking · …" line shows the booking's new window beside it. The move is a compare-and-set on the visit's
lock_version, so a dispatcher saving at the same moment wins. The visit's techs stay on it. - Cancels: if nothing has happened on the job yet — no tech set off, no visit done, not invoiced — the job is cancelled with its planned visits (off every tech's day and off Needs scheduling). Otherwise the job is left exactly as it is for the office to settle; the "From" line reads "(Cancelled)".
- Revert to confirmed on the bookings page finds the job it already made.
An accepted proposal or estimate becomes a job with Create job — on the proposal editor (accepted callout), the estimate editor, and the proposals / invoices list menus (which then say Open job). Or switch on Create a job when a client accepts a proposal or estimate (Jobs → Settings, off by default) to make it the moment the client accepts on the public page.
- A proposal brings only the lines the client accepted — required lines plus the add-ons and tier they chose, never an option they passed on — its title, its scope as the work to do, and its tax rate. An estimate brings every line, its tax rate, and its first line as the title. The job is unscheduled; the office books the visit.
- Quote lines have no cost or kind, so they arrive as services with an unknown cost (
NULL, never0), with no price-list link. - Billed once. If the quote was already converted to an invoice (Proposals does that on accept by default), the job takes that invoice as its own. Once a quote or booking has a job, Convert to invoice / Bill return the job's invoice (and link to it), or — if the job has none yet — say "This became job JOB-0007 — invoice it from the job". The list menus stop offering Convert to invoice for it. (This holds while the module is on; with it off, the old paths work as before.)
- Only an accepted proposal or estimate can become a job.
The schedule board (/dashboard/jobs/schedule)
Jobs → Schedule (Manager and up) is the dispatcher's view of the techs' days. Everything on it is on the site's clock: a visit belongs to the site-local day it starts on, and sits in the row of its wall-clock time — a 9:00 AM visit is in the 9:00 row on the day the clocks change too.
- Day view — techs as columns, hours as rows (7 AM–7 PM, widened to whole hours to fit any visit outside that). Each visit is a chip in its tech's colour with its time, job number, title and customer; visits that share time in one column sit side by side. A visit with two techs shows in both columns.
- Week view — techs × days (Sunday to Saturday), each cell listing its visits by time. A day's heading opens that day.
- No tech — the first lane holds visits that have a time but nobody on them yet: a booking's arrival window arrives this way.
- Needs scheduling — beside the board: open visits with no time, and open jobs with no open visit at all (a quote that became a job), emergencies first, then the longest waiting — the same set as the jobs list's Needs scheduling tab (the first 50 of each; the rest link there).
- A deactivated or removed tech still on a visit gets a lane of their own ("not an active tech") so nothing on the schedule is hidden; nothing can be dropped on it.
- On a phone the board is a list: the day (or each day of the week) by tech, then Needs scheduling. Dragging is for the tablet-and-up board.
Dragging (native HTML5 drag-and-drop — no library):
- Onto a time in the day view moves the visit there (snapped to 15 minutes), keeping its length in real minutes and its kind — an arrival window stays a window and must still end on the day it starts. Onto a day in the week view moves it to that day at its own time of day.
- Onto another tech gives it to them: the tech it was dragged from comes off, anyone else on it stays. Onto No tech takes the dragged-from tech off. Only active techs can be given a visit.
- Onto Needs scheduling takes its time off (its techs stay on it).
- A job from Needs scheduling dropped on the board gets its first visit there, at the default visit length (a week-view drop starts at 8:00 AM). The job row is locked while that's checked, so the same job dropped twice — a double drop, two dispatchers — gets one visit.
- A visit a tech has set off on keeps its time; it can still change hands from the job page. Completed visits show muted and don't move.
- A stale move is refused. Each chip carries the
lock_versionit was drawn with; if someone else moved the visit in the meantime (another dispatcher, the customer rescheduling, the job page) the drop says so and the board redraws with their change, instead of dragging the visit back over it. - Double-booking is a warning, not a refusal. A move that puts a tech on two visits at the same time is saved with "Saved — but Alex is also on JOB-0012 then (9:00 AM)", and both chips are ringed red ("Double-booked"); visits that only touch end-to-start, other techs' visits, and finished or cancelled ones don't count. The job page's visit editor gives the same warning.
- Every board write goes through
VisitScheduler(the same path as the job page's visit editor), so the job's status follows on its own, a later booking reschedule leaves a visit the dispatcher moved where they put it, and the techs are told.
Tech notices
A tech hears when their day changes — put on a visit, taken off one, a visit of theirs moved, taken off the schedule, or cancelled (including a planned visit or job deleted from the job page, and the customer cancelling their booking): an email in their own dashboard language (TechNotifier) with the job, the time (and the old time when it moved), the customer and the address, and an Open My day button — plus a text when they have a mobile number on the Techs page, Marketing is on and texts are set up, and the number hasn't replied STOP.
- Never the person who made the change — a dispatcher who is also a tech and drags a visit onto their own column hears nothing.
- Once per change, one message per tech. Only the write that won tells anyone (the
lock_versioncompare-and-set, the cancel claims), so a double drop or two dispatchers at once can't send a notice twice; a tech both added and moved gets "New visit", not two messages. Changing only a visit's title or notes tells nobody. - History is left alone — tidying a visit that already ended tells nobody. Only people who still hold an active tech row are told.
- A text a person in the dashboard caused goes straight away (they pressed it); one with nobody signed in behind it — a customer moving their booking at 11 PM — goes as email only outside sending hours (8 AM–9 PM site time,
SmsSendWindow). - Notices are sent after the change commits and after the dispatcher's request has answered; a failing mail provider or text is reported, never thrown — the move is already saved.
The tech's phone
Everything a tech does happens on two pages built for a phone held in one hand — big buttons, call / text / map one tap away.
My day (/dashboard/my-day)
At the top, the time clock (techs only — a Manager without a tech row has none): Clock in / Clock out, the hours counted today and this week, and the visit time running, if any, with a Stop button (it works even on a visit the office has since taken the tech off). See Timesheets.
Below it, the tech's visits, in schedule order on the site's clock: Not finished (a visit from an earlier day that is still open — set off for and never completed, or never marked done — so it can't drop off the page with the date), Today, and Coming up (the next two weeks). Each card shows the time (or arrival window), status, visit / job title, customer and address, and opens the visit; underneath are Call and Text (the customer's number, opening the tech's own phone app) and Map (the address in the phone's maps app).
A visit (/dashboard/my-day/visits/{id})
- When and what — the time or arrival window, the job number and title, priority when it isn't normal.
- The buttons — On my way and Start work while the visit is still planned, Start work once on the way, then Pause and Complete visit (with a confirm — once done, only the office can change it). Start work also starts the tech's own time on the visit; Pause stops it without finishing the visit (a parts run, the end of day one of two), and Start work shows again to pick it back up. On a visit a partner already started, Start work starts only this tech's time. The page says whether the tech's time on the visit is running, and since when. Each goes through
JobStatus, exactly like the job page: the job is in progress while anyone is on the way or working, and completing the job's last open visit completes the job. Each is claimed atomically, so a double tap stamps once; who pressed it and when shows on the page and in the job's timeline. A tech can start without pressing On my way first, but can't set off once working. On my way doesn't bump the visit'slock_version, so the office's open editor can still save notes and techs — but not a new time: a visit's time is frozen once someone has set off. - The customer — name, Call / Text, the number, whether texts to them can go (and why not), and where the day-before reminder stands.
- Address — with Open in maps and the service address's access notes ("Getting in": gate code, where the panel is).
- The job — the work to do (marked as what the customer sees), this visit's notes, and the office's internal notes (marked internal).
- Checklists — the visit's own checklists and the job's whole-job ones, filled in while the tech is working the visit (after Start work; before that they are shown read-only with a "Press Start work" line). See Checklists. The Complete visit confirm lists any required item still unanswered and offers only Not yet until they are.
- Photos — the job's photos (see Photos); while the visit is open, Add photos (several at once) with a switch for whether the customer sees them. A tech can show / hide or remove only a photo they took on this visit, while it is open.
- Payment — see Taking payment at the door.
- Expenses — while the visit is open, Log an expense: what it was for, the amount and an optional photo of the receipt (stored like a job photo — private, re-encoded, never the media library). The tech sees only the expenses they logged on this visit, and can take back only those, while it is open — another tech's, the office's, or one from another visit is refused whatever id is posted. See Job costing.
- Work and materials — the job's lines and total before tax. While the visit is open the tech can add from the price list (search, quantity, tap — copied by value with its kind and cost, like the job editor; an item archived since the list opened is refused) or a material used that isn't on it (description, quantity, a price each — required, 0 being a deliberate answer — and, optionally, what one cost them; left blank, the cost stays unknown for the office to add, never free). A tech can remove only a line they added on this visit, while it is open — never an office line, another tech's, or one from another visit, whatever id is posted. Lines record the visit and who added them. When the job is already invoiced the page says so: a line added or removed then makes the job page show the invoice as out of date.
Whose visit: both pages, and every action on them, look the visit up through Visit::assignedTo($user). Another tech's visit, an unassigned one, or one the office took this tech off while the page was open is a 404 on the next tap — never acted on. A Manager passes the gate but still sees only visits assigned to them.
Add to Home Screen
The tech pages link their own web app manifest (/dashboard/my-day/manifest, TechManifestController) in place of the site's, so Add to Home Screen installs My day: it opens straight onto the tech's visits, full screen, with the site's icon. My day shows a one-line tip on how to add it. The manifest is served without a session (browsers fetch it without cookies) and holds nothing private; it 404s while the module is off.
Checklists
The inspections and tests a tech does again and again — "Panel inspection", "Smoke detector test" — written once on Jobs → Checklists (/dashboard/jobs/checklists, Manager and up) and added to a job from its page.
- Items. Each checklist is up to 50 items, each a Checkbox (tick when done), Yes / No, Text, Number or Photo (answered by taking one or more photos on the visit). An item can be Required — it must be answered before the work it belongs to can be marked done (a required checkbox must be ticked; for a Yes / No, No is an answer) — and Customer sees — its answer shows on the customer's job page once the work is finished. A checklist can carry instructions for the tech. Checklists can be archived (no longer offered on the job page) and restored, or deleted.
- Adding one to a job (the job page's Checklists box → Add checklist): for the whole job (filled in on whichever of its visits the work happens) or for one open visit. The job gets its own copy of the checklist's name, instructions and items, taken when it is added — editing, archiving or deleting the checklist afterwards never changes a copy already on a job, nor the answers in it. Pressing Add twice adds it once; a checklist archived after the picker opened, a closed visit or job, or another job's visit is refused.
- Filling it in. On the phone, while the tech is working the visit (after Start work): a checkbox or Yes / No saves on the tap (tapping the chosen one again clears it), a text or number item as the tech leaves the field, a photo item from the camera (stored like every job photo — private, with the visit and the tech recorded — and marked for the customer when the item is a Customer-sees one). Each answer records who gave it and when. The office can fill in any open checklist from the job page too (everything but photos, which are taken on site). Answers are checked against the checklist's own copy: an item it doesn't have, or a value of the wrong kind (a word for a number, "maybe" for a yes / no, a value for a photo item), is refused and nothing is stored. Two techs filling in one whole-job checklist at once each keep their answers — every answer re-reads the checklist under the job's lock.
- Closed work is the record. Once the visit a checklist is for is completed or cancelled (or, for a whole-job checklist, the job is), its answers can't be changed by anyone and it can't be removed; a photo answering it can be hidden from the customer but not deleted. While it is open the office can Remove checklist — its answers and the photos taken for it go with it.
- Required before complete. Enforced in
JobStatusitself, so every path holds: the tech's Complete, the office's Mark done and Mark job complete. A visit's own checklists hold up that visit; a whole-job checklist holds up only the visit that would finish the job (completing the rough-in while the final is still to come is fine). The job row and the visit are locked while the check reads the answers, so two techs closing the last two visits at once can't both skip the whole-job checklist, and an answer can't be cleared — nor a checklist photo taken back — under a Complete that just read it. - On the customer's job page, each finished checklist shows its Customer sees items that were answered — the answer, or the item's photos that are marked Customer sees (the office can still hide one). Internal items, unanswered items, and anything from a checklist whose work is still going on never show. Photos taken for a checklist item show with that item, not again in the page's general photo grid.
Timesheets
The hours techs work, kept without a separate app: Clock in / Clock out on My day, and the time on each visit from the visit's own buttons.
- Two kinds of time. A shift runs from Clock in to Clock out. Visit time runs from Start work to Complete visit (or Pause) on one visit, for the tech who pressed it — a partner on the same visit presses Start work for their own time. Completing a visit stops everyone's time on it; so does the office marking it done or cancelling it. Clock out stops the visit time running too (going home stops the visit; Start work picks it back up). Starting another visit stops the time on the last one — one visit at a time.
- One open at a time — enforced. A person has at most one open shift and one open visit entry. The database holds that rule itself (a unique index over
user_id,kind,open_slot, whereopen_slotis 1 only while an entry is open), so a double tap on Clock in, or two phones at once, hands back the shift already running instead of opening a second. - Payroll hours are the union. A day's hours are everything the tech had running that day — shifts and visit time merged where they overlap, never added together — so a visit worked inside a shift counts once, and visit time with no shift around it still counts. Days run midnight to midnight on the site's clock, cut as real instants: the night the clocks go back is a 25-hour day, and 10 PM Sunday in Chicago is Sunday, whatever UTC says. Weeks start on the day set in Jobs → Settings → Timesheets (Monday unless changed;
work_orders.week_starts_on, Carbon's 0 = Sunday … 6 = Saturday). - Forgotten clock-outs. Time running (or recorded) for more than 16 hours is marked Needs fixing and counts as 16 hours until it is fixed — a shift left open over a weekend reads as one long day, flagged, never as 60 hours. The tech's My day says so and offers Set when I finished; Clock in hands back the old shift until it is closed. A genuine long day (an 18-hour storm shift) counts in full once a manager approves that entry (Count all of it).
- Who changes what. A tech can fix only their own entry, and only while it is still open — move its start, or set when they finished, which closes it. Once closed, only Manager and up change it, on the timesheets page. Typed times are on the site's clock and are refused when they make no sense: in the future, an end before the start, longer than 24 hours (add each day as its own entry), or no end on time that has already closed.
- Every change is on record. Each edit stamps who and when on the entry (shown under it), clears its approval (a changed entry is approved again or not at all), and is written to the Activity Log (
jobs) with the old and new times; so are entries added and removed by hand, single approvals and week approvals. - Hourly cost. When an entry closes it takes the tech's hourly cost at that moment (
labor_cost_cents), so changing a rate later never rewrites what past work cost. An unknown rate stays unknown (NULL), never free. Job costing builds its labor on the visit entries.
The timesheets page (/dashboard/jobs/timesheets)
Manager and up. One row per tech (every tech, active or not, plus anyone else with time that week): the hours each day and the week's total, and a status — Needs fixing, Clocked in, Approved, or how many entries are approved. Previous / next week and This week. Picking a name lists their entries day by day — kind (shift, or visit with its job), start – end, length, note, who added / changed / approved it — with Fix (start, end, note), Remove (confirmed), Count all of it on a long entry, Add time (a shift nobody clocked, at the tech's hourly cost now) and Approve week. Approving a week approves every closed entry in it, and is refused while any of its time is still running or needs fixing — a week approval never waves a forgotten clock-out through.
Export CSV downloads the week for payroll: one row per tech — name, email, each day's hours as a decimal (8.25), the total, how many entries need fixing or are still open, and whether the week is approved. Names are formula-hardened (CsvCell).
Job costing
What each job made (JobCosting): revenue − line costs − labor − expenses.
- Revenue is before tax — sales tax is collected for the state, not earned. Once the job has an invoice (a voided one doesn't count) it is that invoice's subtotal, less the pre-tax share of anything refunded (a refund returns tax along with the price, in the invoice's own proportion; only the price part was revenue), so a refunded job shows the money it actually kept. Before that, it is the job's own lines.
- A maintenance-plan visit also earns its share of the plan (see Maintenance plans); the job page says how much of the revenue it is.
- Line costs — each line's quantity × its copied unit cost: what a material cost at the supply house, or what a service costs besides the techs' time (a subcontractor, say — 0 when their time is all of it). A discount or credit line (negative price) needs no cost.
- Labor — the time on the job's visits: each entry's recorded length × the hourly cost it took when it closed (see Timesheets), so a raise today never rewrites what last month's job cost. Read from the entries themselves, not the payroll report — payroll's overlap merge and 16-hour cap answer a different question.
- Expenses — the job's
work_order_expenses: a permit, a dump fee, a rented lift, a supply-house run — anything not already a line's cost — each with an optional receipt photo. The office adds, edits (date, amount, a new receipt or none) and removes them on the job page; a tech logs them from the visit (above). Every add, change and removal is written to the Activity Log (jobs). The customer never sees an expense.
Incomplete, never 100 %. The silent failure here is a flattering wrong number, so any unknown makes the job incomplete, and an incomplete job has no profit and no margin — the Profit box says Not known yet and lists what is missing, each with its fix:
| Missing | Fix |
|---|---|
| A line with no cost (blank, not 0) | add it on the job's lines — 0 if the techs' time is all of it |
| Visit time from a tech with no hourly cost when it closed | Use each tech's hourly cost now — fills only entries that had none; an entry that took a rate keeps it. Hours don't change, so approvals stand; each fill is logged |
| A tech's time still running | it stops when they pause or finish the visit |
| Visit time over 16 hours nobody has fixed or approved | fix or approve it on Timesheets |
| A visit done with no time on the clock at all (the office marked it done, or the tech never pressed Start work) | Add time on that visit — who did it (a tech, or someone on the visit), start and finish on the site's clock, at their hourly cost now. It is visit time, so it shows on their timesheet too, and is logged |
The box shows the known figures alongside (revenue and where it came from, line costs, labor and its hours, expenses), and on an open job says these are the costs so far. A cancelled visit with no time is left out of the reckoning; a job with no visits at all has no labor to miss.
The profit report (/dashboard/jobs/profit)
Manager and up — Jobs → Job profit. The jobs completed in a period (this month, last month, this year, last year, or the last 12 months — the default — on the site's clock), totalled by month (the month each was completed), by tech, or by job type (untyped jobs last): jobs, revenue, costs, profit and margin.
- Totals count only jobs whose costs are complete. An incomplete job never adds its revenue without its costs; each row and the summary say how many were left out, and Only jobs with costs missing lists them (each with what is missing on hover) to work through.
- By tech, a job's revenue, costs and profit are shared between its techs by their part of the time on its visits (to the cent — the rows add up to the whole); a job nobody has visit time on sits under No time on the clock.
- Every completed job is listed below, newest first, 25 to a page — revenue, costs, profit and margin, or Costs missing.
- Export CSV — one row per job: number, title, client, completed date, job type, techs, revenue, where revenue came from (invoice / job lines), line costs, labor, labor hours, expenses, profit, margin %, whether costs are complete, and what is missing. Money is a plain decimal (
1240.50); an incomplete job's profit and margin are blank, never 0. Text cells are formula-hardened (CsvCell).
Maintenance plans
Service agreements the office sells — "Gold: two safety checks a year, 10 % off repairs, priority service" — billed on their own schedule, with each visit's job made ahead of time (Agreements, AgreementBilling, PlanVisitGenerator, MemberPerks). Manager and up — Jobs → Maintenance plans.
Plans (/dashboard/jobs/plans)
A plan is the template: name, what the customer gets, price per billing period (billed monthly, quarterly, every 6 months or yearly), visits a year (1, 2, 3, 4, 6 or 12 — each divides the year into whole months), the member discount (0–100 %), priority service, and what each visit's job starts with: its title, its customer-visible description, a checklist and its lines (usually at no charge — "Safety check — included in your plan" — with a cost for any parts it uses). Archive a plan to stop selling it (its agreements carry on); only a plan that was never sold can be deleted.
Selling one (/dashboard/jobs/agreements)
Sell a plan picks the client, one of their service addresses (optional), the plan, the start day, an optional end day (the last day covered — blank runs until cancelled; a day already past is refused, since its recurring invoice would be due at once for a term that is over), how it's paid — Send invoices (and whether each one is emailed automatically) or Pay automatically by card (see Paying automatically). The agreement (AGR-0001) keeps its own copy of the plan's name, price, cadence, visits a year, discount and priority: editing the plan afterwards changes what it sells from then on, never an agreement already sold (the agreement page says when the plan has moved on). What a visit's job starts with — title, description, checklist, lines — is read from the plan as each visit is made, so an improved checklist reaches every customer's next visit.
Billing, for a plan paid by invoice, is a Client Billing recurring invoice the sale creates ("Maintenance plan: Gold (AGR-0001)" on Client Billing → Recurring), with the agreement's price as its one line, starting on the start day, ending on the end day, at the site's default tax rate. Client Billing generates and sends it like any other — there is no second billing engine. A start day in the past bills the current period now.
A contract to sign (with Contracts on): the agreement page drafts one from a contract template, merged for the client, for the office to look over and send from the Contracts page — or links one the client already has. One per agreement; the page shows its status.
The visits
The year is cut into equal periods from the start day (two visits a year = one every six months, counted from the start each time so a short month never drifts it). Each period's visit is a job made N days before the period starts (Jobs → Settings → Maintenance plans, 14 by default, 0–90): the plan's visit title and description, its lines, one unscheduled visit with the plan's checklist on it (left off if the checklist was archived), the plan's name as its job type (so the profit report totals each plan's visits together), at the agreement's client and address. It waits in Needs scheduling like any other job.
- Once per period. The generator (
work-orders:generate-plan-visits, hourly via LazyCron) makes each period under the agreement's row lock, moving its cursor on (next_period/next_visit_on), and(work_order_agreement_id, plan_period)is unique onwork_orders— two passes, or a pass and a retry, make one job. - Every owed period, a bounded batch at a time. A site that saw no traffic for months still makes each visit it owes — the customer paid for them — but at most 50 a pass; the next pass carries on. (Invoices are different: Client Billing's catch-up bills one period and skips the rest.)
- A backdated agreement (a customer moved over from paper) starts with the period in effect on the day it was sold; periods that ended before then are taken as already done.
- The end day is the last day covered: a period starting after it gets no visit. An agreement whose end day has passed expires once its last covered visit has been made — never before, so a site down past the end still makes them — and its recurring invoice is switched off. (An autopay plan the customer never signed up to expires right after its end day: nothing was paid, so no visit is owed.)
"Time to book your visit." When a visit is made, the customer is told it's time to book — by text when they can be texted (the Plan visit due text in Jobs settings, with a {plan} merge field; automated, so it keeps to 8 AM–9 PM), otherwise by email when they have an address. Once per visit (claimed on plan_notified_at, released if the provider fails so the next pass tries again); a visit the office booked before the notice went out isn't told at all. When nobody could be reached the agreement page says why — "Not told — the customer hasn't agreed to texts. Call them to book." Switching the Plan visit due text off stops the email too.
Member perks
A job for a client with an active agreement is tied to it (work_order_agreement_id) — made by hand, from a booking, or as a plan visit:
- The member discount is one line on the job, "Member discount (10% — Gold)", worked out as the percent of the job's other lines and kept up to date as they change — the office's editor, a tech's material on site, a line removed. It is marked on the line (
member_discount_slot, unique per job), so applying it automatically, pressing Apply member discount on the job page, or two people at once can never put it on twice. The job keeps its own copy of the percent (member_discount_percent) from the agreement, never the plan. The line has a negative price, so it needs no cost and never makes job costing read incomplete; it is copied onto the job's invoice like any other line. The job editor shows it read-only — no posted id reaches it — and Remove discount takes it off. - A quote's job keeps its price. A job made from an accepted proposal or estimate is tied to the agreement (priority, the badge) but carries no discount — the client signed for that price. The job page offers Apply member discount for it, and for any job of a member that doesn't have it.
- Changing a job's client in the editor moves the perks with it: the old client's agreement and discount come off, the new one's go on. A plan visit can't change client.
- Priority. A job of a member on a priority plan sorts ahead in the board's Needs scheduling queue — after emergencies and high-priority work, before everyone else — and carries a Priority member badge (Member on a plan without priority) on the board, the jobs list and the job page, while the agreement is active.
Cancelling and expiry
Cancel plan on the agreement page stops it at once: its recurring invoice is switched off (ending today), the generator makes no more visits (re-checked under the agreement's lock, so a pass that read it as active a moment earlier makes nothing), and, if ticked, its plan visits nobody has booked yet are cancelled too; a visit already on the schedule stays for the office to decide. Invoices already sent stay as they are. A member's later jobs get no perks; jobs that already had the discount keep it. Each sale, cancel and expiry is written to the Activity Log (jobs).
Paying automatically (autopay)
A plan can be paid by card instead of by invoice: a Stripe subscription per agreement, on the site's own Stripe keys (the ones Client Billing takes card payments with). Stripe keeps the card, charges it every billing period, retries a declined card and asks the customer for any bank confirmation — none of that happens on the site (AgreementSubscriptions).
Setting it up (admin, once). Stripe keys under Settings → API Keys, then Jobs → Settings → Automatic payments for plans: create a webhook endpoint in Stripe pointing at /work-orders/stripe/webhook, subscribed to checkout.session.completed, invoice.paid, invoice.payment_failed, customer.subscription.updated and customer.subscription.deleted, and paste its signing secret (write-only — the page shows whether one is saved, never the value). It is its own endpoint, separate from Client Billing's. Until both are in place the sell modal shows Pay automatically greyed out with the reason. In Stripe, also turn on the emails that tell customers a card payment failed, and save the customer portal settings once (the Change your card button opens it).
Selling it. Pay automatically by card makes the agreement with no recurring invoice — a plan is paid one way or the other, never both — and gives it the customer's own link, /service/plan/{token}. The agreement page shows the link to copy, Text link (the Automatic payments link text in Jobs settings — consent, opt-outs and the reason when it can't go, like every customer text; a manual send, so no texting hours; once a minute), Email link, or Show QR code for the customer to scan in person. The customer's page shows the plan, its price (plus sales tax at the site's default rate when it has one — Stripe charges it as its own line) and Set up automatic payments, which opens Stripe's own checkout page. No visits are made until they have signed up; once they have, the visits it owes are made on the next pass.
- Billing starts the day they sign up — that is when Stripe takes the first period — and repeats every month, three months, six months or year after. The visits still follow the agreement's start day.
- Signed up once. The customer comes back to their page from Stripe and Stripe's webhook arrives too; whichever is first records the sign-up, the other finds it done. A second sign-up from another tab is cancelled in Stripe at once (the Activity Log says to refund any charge it made), and starting again expires an earlier Stripe page — or, when that page was already paid (a tab closed before it came back), takes it as the sign-up instead of opening another.
- A fixed term ends the subscription on the day after the end day, with no part-period credit. The plan then expires (not cancelled) on the generator's next pass, once any visit it still owes is made — unless a charge was still unpaid, which cancels it.
- Nothing is sold once the term is over. From the end day on, the plan link refuses a sign-up. A plan the customer never signed up to owes no visit, so it expires after its end day like any other — it doesn't wait forever on a card.
- Tax is one Stripe tax rate per rate and name, found on the account and reused by every sign-up — Stripe tax rates can be archived but never deleted, so a new one per sign-up would pile up.
Each charge Stripe makes is recorded in Client Billing as a paid invoice with its payment ("Gold maintenance plan (AGR-0001) — Nov 3, 2026 to Nov 3, 2027"), built from Stripe's own totals — price, any discount given in Stripe, the tax exactly as charged — so reports and anything else that reads invoices sees the money. Recorded once per Stripe invoice however often Stripe delivers it (client_invoices.stripe_invoice_id is unique). Reminders and late fees never touch it, and no receipt is sent from the site — Stripe emails its own. The agreement page lists the charges and shows Paid through (the end of the last paid period). A period Stripe charged nothing for records no invoice.
A declined card. The plan goes past due: the owner is emailed once (to Client Billing's notify address, else the first admin) with a link to the plan, the agreements list shows Payment failed, and no new visits are made until it is paid — the cursor waits, so the visits it owes are made once Stripe's retry (or the customer's new card) goes through. The customer can change their card from their job page (/service/{token}, on any job under the plan) or their plan link — Change your card opens Stripe's own card page and comes back. A failure for a charge already paid, or an old payment delivered late, doesn't change the plan's standing.
Stripe has the last word on the subscription. Cancelled in the Stripe dashboard (or by Stripe after its retries run out), the plan is cancelled here too, like Cancel plan. A subscription update is read back from Stripe rather than trusted from the event, since Stripe doesn't deliver events in order.
Managing it.
- Cancel plan cancels the subscription in Stripe as well (what it already charged stays paid). If Stripe can't be reached, the plan is still cancelled, the agreement page says the subscription may still charge (Cancel in Stripe now), and it is tried again every hour (five a pass; one that fails again goes to the back of the line, so a few Stripe keeps refusing never hold up the rest); a charge it lets through meanwhile is still recorded, so it can be refunded from Client Billing. Expiry cancels it the same way.
- Switch to invoices cancels the subscription (no refund of the period already paid) and starts a Client Billing recurring invoice from where the paid time runs out — the start day for a customer who never signed up — with or without emailing each one, taxed at the rate the card was charged (the plan's own
tax_percent, not whatever the site default is now). A sign-up that completes after the switch cancels itself.
Two installs on one Stripe account. Stripe sends every event on the account to every endpoint on it. A subscription is matched to a plan by its Stripe id, which is unique across the account, or — before the sign-up is recorded — by the plan's id in its metadata, trusted only when the metadata is stamped with this install's id. Anything else is acknowledged and ignored.
In job costing
A plan visit's revenue includes its share of the plan — the agreement's price × billing periods a year ÷ visits a year (Gold: $240 ÷ 2 = $120), fixed on the job when it is made (plan_visit_value_cents) — because the plan's own invoices belong to no job — and the same goes for the paid invoices an autopay plan's charges are recorded as, which are never counted against a visit as well. Anything the tech adds on site is billed and counted as usual. A completed plan visit with nothing chargeable on it stays off the jobs list's To invoice tab.
Photos
Before / after shots, the panel, the damage — taken on a visit from the tech's phone and kept with the job (the visit they were taken on is recorded too, and survives the visit being deleted). They are never the media library: the library's files and image variants are public to anyone with the URL, and these show the inside of a customer's home.
- Stored privately (
PhotoUploads) on the privatelocaldisk underwork-orders/{Y}/{m}/— a display copy (1600 px on the long side) and a thumbnail (400 px), both re-encoded as JPEG. The re-encode turns the photo the way the camera's EXIF orientation says (a portrait shot arrives as landscape pixels plus a hint) and leaves every other byte behind, the GPS position of the customer's house included. - What a phone sends: JPEG, PNG and WebP, read from the file's own header (never its name). The upload button asks for JPEG/PNG, so an iPhone converts its HEIC on the way in; a HEIC that arrives another way is decoded with Imagick where the server's build can, and otherwise refused with a reason saying how to send a JPEG. A file over 25 MB, a pixel-flood image or something that isn't a photo is refused with a reason — never a 500, never a file nobody can open. With Imagick a big JPEG is decoded straight at a fraction of its size, so a 50-megapixel shot costs a few MB; without it, plain GD takes up to 32 megapixels (a 24 MP phone photo fits). One upload takes up to 10 photos and a job up to 200; one refused photo doesn't lose the rest.
- Internal by default. A photo reaches the customer's job page only when it is marked Customer sees — by the tech when adding it (the switch), or by the office on the job page.
- Served only through
PhotoController: the office (Manager and up) sees every job's photos; a tech only those of a job they have a visit on; the customer's page only the job's own customer-visible photos, by its token. Anything else — another tech's job, a tech-only photo through the customer link, another job's token — is a 404, the same answer as a photo that doesn't exist. Staff copies areCache-Control: private; the customer's areno-store, so hiding one takes effect at once. - On the job page (office): a Photos box with every photo, Show the customer / Hide, and Delete (refused for a photo answering a finished checklist — it is part of that checklist's record). Deleting a job deletes its photo files — and its expenses' receipts — with it.
- Backups: the
work-orderstree is one of the backup's private trees (work-order-photos), so a restore brings the files back with their rows — expense receipts included, which are stored in the same tree.
Taking payment at the door
The visit page's Payment box (OnSitePayment), on any visit that isn't cancelled — a finished one included, since that is when the customer pays. Nothing here is a second billing path:
- Take payment bills the job if nothing is billed yet (
JobInvoicer::invoice()— one invoice however often it is pressed, by the tech or the office) and issues it without emailing anyone (InvoiceSenderwithdeliver: false), keeping the Contracts gate: a customer without a signed contract (when that gate is on) can't be invoiced at the door, and the page says so. An invoice the office already drafted is issued, not duplicated. - Once the invoice is payable the page shows what is due and:
- a QR code the customer scans to pay by card — only for an invoice they can actually pay (issued, a balance left, no bank payment clearing) on a site that takes cards (Client Billing online payment on, Stripe keys in place). A draft's public page is a 404, so a draft never gets a QR. A site that takes no cards says to take cash or a check instead.
- Text pay link — the customer's job page link, through the customer-text rules (consent, opt-outs, the switch; see the Pay link text below).
- Cash or check — the amount (pre-filled with the balance; partial payments are fine, more than is owed is refused) and a check number. Recorded through
InvoicePaymentRecorderwith an idempotency key the form made when it opened, so the same press arriving twice — a double tap, a phone retrying on a weak signal — records one payment — a retry that arrives while the first press is still writing waits for the invoice lock and gets that payment back, never "already paid"; the invoice row is locked while the balance is checked, so two techs recording at once can't take more than is owed between them. Each payment taken on site is written to the Activity Log with who took it.
- A job whose lines changed after it was invoiced says so ("Ask the office to update it, or take payment for it as it stands") — before Take payment issues an office draft as well as once the invoice is payable — and a tech can't rewrite the invoice from the phone, since that would throw away any adjustment the office made.
The customer's job page (/service/{token})
The page the visit texts link to ({link}), one per job, by the job's 40-character token (the office copies it from the job page's Customer page box). It shows:
- where things stand — your next visit is booked, :name is on the way, work is under way, all done, or cancelled;
- the next visit's day and time (or arrival window) on the site's clock, the tech's first name and the address;
- the invoice once it is issued — Pay now (when the site takes cards) and View invoice, or Paid in full;
- for a job under a maintenance plan paid automatically, the plan with Change your card (Stripe's own card page) — flagged when its last payment failed;
- the work to do (the job's customer-visible description) and each finished visit with its date;
- the results of finished checklists — only the items marked Customer sees;
- the photos marked Customer sees (a photo taken for a checklist item shows with that item instead);
- once the job is done, a Leave us a review link when Reviews is on and the customer has an email.
It never shows the job's internal notes, a visit's notes, costs, line prices before an invoice exists, a draft invoice, a tech-only photo, or a checklist item that isn't marked for the customer. It is noindex, never cached (module routes don't pass through the response cache, and the page is sent Cache-Control: private, no-store), and while the job is open it refreshes itself every minute, so "on the way" shows up without a reload. A bad token, or the module switched off, is a 404.
Texts to customers
The texts to the customer about their visit, set at Jobs → Settings → Texts to customers (CustomerTexts):
| Text | Sent | Default |
|---|---|---|
| On my way | When a tech presses On my way — in their own request, there and then | On |
| Reminder the day before | By the cron, for visits starting from two hours ahead to the end of tomorrow, between 8 AM and 9 PM site time (one due overnight goes out at 8 AM) | On |
| Job done | When a tech completes the job's last visit from their phone | Off |
| Pay link | When a tech presses Text pay link on a visit, once the job's invoice is issued | On |
| Plan visit due | When a maintenance plan's visit is made, between 8 AM and 9 PM site time; a customer who can't be texted is emailed instead (see Maintenance plans) | On |
| Automatic payments link | When the office presses Text link on a plan paid automatically that the customer hasn't signed up to yet; {link} is the plan's own page (see Paying automatically) |
On |
Each has its own wording with merge fields {first_name}, {business}, {tech} (the tech's first name), {when} (the visit's day and time), {address}, {job}, {plan} (the maintenance plan's name; blank outside one) and {link} (the customer's job page — every suggested wording ends with it); leave it blank to use the suggestion shown (which then follows the site's language). An unknown field ({frist_name}) is refused on save; a field with nothing to fill leaves no gap. Texts are written in the site's main language — the client records no language of their own — never the tech's dashboard language.
A text goes only when all of these hold, and the visit page says which one didn't:
- Texts are set up — Marketing is on with an SMS provider (Twilio) configured; texts go from that number. The settings page warns when they aren't.
- The text is switched on.
- The customer has a mobile number that normalises to E.164 (
+15125550142). - They haven't opted out — checked against the STOP list and their Marketing subscriber record under both the E.164 spelling and the digits-only one older rows use, so a number typed
512-555-0142never slips past a+15125550142opt-out. A STOP outranks any permission recorded later. - They agreed to texts —
sms_consenton the client: ticked on their booking, given on a form, or recorded by the office ("They agreed to get texts at this number"). Consent belongs to a number: changing the number clears it.
Once each. Every text claims its own stamp before sending — en_route_notified_at and reminder_sent_at on the visit, done_texted_at and pay_link_texted_at on the job — so a double tap or two cron runs send one. The pay link's claim holds for a minute only, so the tech can send it again when the customer says it never came. If the provider fails, the stamp is released and the failure reported: the tech is still on the way (the status never waits on the text), and the visit page offers Text the customer to try again while they're on the way. A visit moved to a new time clears its reminder stamp, so the customer is reminded about the new time (a notes-only save doesn't); the cron claims a reminder against the start time it read, so a visit moved mid-run is never reminded about its old time.
A visit that is still the customer's online booking slot gets no reminder from here while Online Booking sends its own SMS reminders — once the office moves it off that slot, this one goes instead.
The reminders run as work-orders:send-visit-reminders every 15 minutes (LazyCron, so a site without a real cron still sends them); the command does nothing while the module, Marketing or the reminder text is off, or outside texting hours.
What the dashboard gets
- Jobs → Jobs (Manager and up) — the jobs list, new job, job page and job editor (above).
- Jobs → Schedule (Manager and up) — the schedule board (above). Also linked from the jobs list.
- Jobs → Techs (Manager and up) — add, edit, deactivate and remove techs.
- Jobs → Checklists (Manager and up) — make, edit, archive and delete checklists (see Checklists).
- Jobs → Timesheets (Manager and up) — each tech's hours per week, fixing and approving entries, CSV export (see Timesheets).
- Jobs → Job profit (Manager and up) — the profit report (see Job costing).
- Jobs → Settings (Admin) — the job number prefix (default
JOB-; jobs are numberedJOB-0001,JOB-0002, … and two created in the same instant get consecutive numbers rather than an error), the default visit length (15–720 minutes, default 120), Create a job when a client accepts a proposal or estimate (work_orders.create_jobs_on_accept, off by default), the timesheet week start (work_orders.week_starts_on, Monday by default), and Texts to customers (work_orders.text_{on_my_way,reminder,job_done}+_template, above). - Create job / Open job on accepted proposals (editor + list menu) and accepted estimates (editor + invoices list menu).
- My day and each visit's page — the tech's phone pages (above).
- On the job page: Checklists (add, fill in, remove), Expenses (add, edit, remove, receipt photos), Profit (with the fixes for missing costs), Photos (show / hide / delete) and Customer page (copy or open the customer's link).
- A Today's visits dashboard card (
work_orders_today): visits starting today on the site's clock (cancelled ones left out), with how many jobs need scheduling, linking to the schedule board. Off with the module (either switch).
Connections to the rest of the CMS
- Reviews — a finished job asks the customer for a review when Reviews → Settings → After a job is completed is on (
reviews.auto_request_job, off by default; shown while the module is on). Email by default; a phone-only customer who agreed to texts is asked by text at their E.164 number; once per job (a job reopened and finished again doesn't ask twice), with Reviews' own delay and per-customer cooldown. The customer's job page offers the same review link once the job is done. - CRM — a job made and a job finished land on the customer's CRM timeline (type
job), on the contact Client Billing uses for their invoices (the client's linked contact, else an email match — never one for a phone-only customer with no link). - Webhooks —
job.created,job.completedandvisit.scheduled(see Webhooks).
Data model
All times are UTC instants, shown on the site's clock. Unknown money is NULL, never 0.
| Table | What it holds |
|---|---|
work_order_techs |
The tech grant: user_id (unique), active, phone (E.164), color (hex), labor_cost_cents (hourly; NULL = unknown) |
work_order_locations |
Service addresses per client: street, city, state, ZIP, lat/lng, label, access_notes. Deleting a client with no jobs deletes their addresses |
work_orders |
A job: number (unique), client, service address, title, customer-visible description, internal_notes, status (unscheduled / scheduled / in progress / completed / cancelled — derived from the visits, see above), priority (normal / high / emergency), source_type + source_id (unique together — one booking or accepted quote can never become two jobs), invoice_id (claimed by compare-and-swap), invoiced_lines_hash (fingerprint of the lines copied into that invoice — the out-of-date check), tax_percent (decimal 6,3 — the rate the client signed for on a quote; NULL = the site default when billed), public_token, completed_at, done_texted_at (claims the "job done" text), cancelled_at, created_by, job_type (free text, up to 60; NULL = none) |
work_order_items |
The job's own lines, copied by value from the price list (catalog_item_id only records where it came from): description, kind (service / material), quantity, unit price, unit cost (NULL = unknown); a line a tech added from their phone also records work_order_visit_id and added_by (both NULL on office lines) |
work_order_visits |
One trip: starts_at / ends_at (NULL until scheduled), is_window (an arrival window rather than an exact time), status, the on-the-way / started / completed stamps and who pressed them, reminder_sent_at / en_route_notified_at (claimed so a text goes once; a time change clears reminder_sent_at), lock_version (so a stale schedule change is refused rather than winning) |
work_order_visit_assignees |
Which users are on a visit (unique per visit + user) |
work_order_photos |
A job's photos: work_order_id (cascade), work_order_visit_id + uploaded_by (both nullable, kept when the visit or user goes), work_order_visit_checklist_id + checklist_field (the checklist photo item it answers, if any), path / thumb_path (on the private local disk), width / height (the display copy), bytes, caption, customer_visible (default false) |
work_order_checklist_templates |
The office's checklists: name, description (instructions for the tech), fields (JSON list of {key, label, type, required, customer} — key is f + 7 lowercase letters/digits, stable across edits; type check / yes_no / text / number / photo), active, created_by |
work_order_time_entries |
Time worked: user_id (kept NULL if the user is deleted), work_order_visit_id (NULL for a shift; kept NULL if the visit is deleted), kind (shift / visit), started_at / ended_at (NULL while open), open_slot (1 while open, NULL once closed — unique with user_id + kind), labor_cost_cents (the tech's hourly cost when it closed; NULL = unknown), note, created_by (the tech for clocked time, the manager for time added by hand), edited_by / edited_at, approved_by / approved_at |
work_order_expenses |
A job's costs beyond its lines and time: work_order_id (cascade), work_order_visit_id (the visit a tech logged it on; NULL for the office's; kept NULL if the visit is deleted), description, amount_cents (required, above 0), spent_on (a site-clock date), receipt_path / receipt_thumb_path (on the private local disk, under the photos' work-orders tree), created_by |
work_order_visit_checklists |
A checklist on a job: work_order_id (cascade), work_order_visit_id (cascade; NULL = the whole job), checklist_template_id (NULL once the template is deleted — where the copy came from, nothing more), the copied name / description / fields, answers (JSON {field key: {value, by, by_name, at}}), attached_by |
| work_order_plans | A maintenance plan: name, description, price_cents (per billing period), billing_frequency (monthly / quarterly / semiannual / yearly), visits_per_year (1, 2, 3, 4, 6, 12), visit_title, visit_description, checklist_template_id (NULL once deleted), items (JSON lines: description, kind, quantity, unit price, unit cost), member_discount_percent, priority_service, active |
| work_order_agreements | A sold plan: number (AGR-, unique), plan (NULL once deleted), client (cascade), service address (NULL once removed), the snapshot plan_name / price_cents / billing_frequency / visits_per_year / member_discount_percent / priority_service, starts_on / ends_on (site-clock days; ends_on the last day covered, NULL = until cancelled), status (active / cancelled / expired), the generator's cursor next_period / next_visit_on (NULL once no more visits are due), invoice_schedule_id (its Client Billing recurring invoice, NULL if deleted there or on autopay), billing_mode (invoice / autopay), autopay_token (the customer's link, 40 characters, unique), tax_percent (autopay's sales-tax rate, from the site default when sold; kept by the recurring invoice if the plan moves to invoices), stripe_customer_id, stripe_subscription_id (unique), stripe_subscription_status (Stripe's own status — canceled once it ends; anything else on a stopped plan is a cancel still owed), stripe_checkout_session_id (the open sign-up page), autopay_started_at, paid_through_on (site-clock day the last paid period runs to), past_due_at (set while a charge has failed), contract_id, notes, cancelled_at / cancelled_by, created_by |
work_orders gained work_order_agreement_id (NULL once the agreement is deleted), plan_period (which period a plan visit is — unique with the agreement), plan_visit_value_cents, member_discount_percent (the job's own copy) and plan_notified_at / plan_notice (the time-to-book notice: when, and texted / emailed / booked / why not); work_order_items gained member_discount_slot (1 on the job's discount line, NULL on every other — unique per job). work_orders.pay_link_texted_at claims the pay-link text. Client Billing's client_invoice_payments gained idempotency_key (unique, nullable) for the cash / check form, and client_invoices gained stripe_invoice_id (unique, nullable — the Stripe invoice an autopay charge was recorded from) and stripe_subscription_id (indexed).
A client who has jobs cannot be deleted — the Clients page says so instead of failing, whether or not the feature is on now. Nor can one with a maintenance plan still running (active), or a stopped plan whose card subscription Stripe hasn't confirmed ended: the plan would go with the client and leave nothing on the site to stop the billing. A client whose plans are all cancelled or expired, and done charging, can be deleted (the plans go too). Likewise a service address a job points at can't be removed (the job editor says so).
Limitations
- Line items carry no per-line tax: the invoice applies the site's one default tax rate to every line, as Client Billing does.
- The board doesn't refresh itself: another dispatcher's change shows after Refresh, the next move, or a page change (a move made over theirs is refused, so nothing is lost).
- Dragging needs a mouse or trackpad on a tablet-width screen or wider; on a phone the board is a read-only list (change visits from the job page).
- Double-booking is judged on the visits' times as booked — an arrival window counts as busy for its whole window.
- Staff-slot bookings don't become jobs, and a booking's job carries the booking's fee but not its online deposit (service calls never collect online).
- With the module switched off, a quote or booking that already has a job can be converted or billed the old way again; switching the module back on doesn't undo that, so check the job's invoice before billing it.
- Customer texts are texts only — no email reminder or on-my-way email — in the site's main language. The one exception is the maintenance plan's time-to-book notice, emailed when the customer can't be texted.
- A maintenance plan's member discount goes on jobs, not on invoices made directly in Client Billing for a member — add it there by hand.
- Autopay bills from the day the customer signs up, not the agreement's start day, and a plan already paid by invoice can't move to autopay — cancel it and sell it again on autopay. Its sales tax is the site's default rate when the plan is sold.
- An autopay charge settled from a credit balance in Stripe (no money taken) isn't recorded as an invoice. A refund made in Stripe reaches the recorded payment only when Stripe's payload carries the payment intent (older webhook API versions do) and Client Billing's own endpoint is subscribed to
charge.refunded. - Cancelling a plan doesn't refund or credit anything already invoiced; settle that in Client Billing.
- A visit booked for later the same day (two or more hours ahead) gets the "reminder" too, since the cron reminds everything from two hours ahead to the end of tomorrow.
- The "job done" text goes when a tech completes the last visit from their phone; marking a visit done on the office's job page sends nothing.
- Photos can be added and changed from the phone only while the visit is open; after that they are the office's (job page).
- Payment at the door takes the invoice as it stands: when lines were added after it was made, the office updates it (the phone can't rewrite an invoice).
- A required checklist item holds up completing work, not cancelling it: cancelling the last open visit still completes a job whose other visits are done, even with a whole-job checklist unanswered.
- The office can't take checklist photos (they are taken on the visit, from the phone); a required photo item on a visit the tech can no longer reach means removing the checklist.
- There is no job PDF: the customer's job page is the job's summary, and it is where checklist results show.
- Time added by hand on the timesheets page is a shift; visit time comes only from the visit's own buttons. Taking a tech off a visit doesn't stop their time running on it — My day's Stop, Clock out, starting another visit, or the 16-hour flag does.
- A tech who never presses Start work (the office marks the visit done) has no visit time for it; their shift still counts for payroll, and the job's profit reads incomplete until the office adds the visit's time on the job page.
- Job costing counts what a line's cost and an expense say. Putting one purchase in both (a part's cost on its line and its receipt as an expense) counts it twice — the expense form says a part's cost belongs on its line.
- Revenue is what was invoiced, less refunds — not what has been collected; an unpaid invoice still counts in full.
- A partner on a visit who never pressed Start work has no time on it; only a visit with no time at all is flagged as missing labor.
- My day shows today, the next two weeks and up to 20 unfinished visits from earlier days, and refreshes itself every minute (an installed home-screen app has no reload button); a visit's own page refreshes on the tech's next tap.
Privacy policy
While the feature is on (or the site still holds jobs or service addresses), the live privacy policy gains a Service visits clause: the job-site address and access notes, the record of each job and visit, kept with the client account, photos of the work (kept with the job; only the ones the business shares appear on the customer's job page), and — for customers who agree to them — the texts about their visits (reminder, on the way, job done) and that replying STOP ends them.
Technical notes (for developers)
- Module:
app/Features/WorkOrders/, Livewire namespacework_orders, routesdashboard.work-orders.*, Settings prefixwork_orders.*. - The tech pages live under
resources/views/dashboard/tech/so the dashboard CSS bundle scans them (app.csssourcesapp/Features/*/resources/views/dashboard/**only). User::isFieldTech()— the grant (active row, Staff or above, feature on), memoized per instance;User::hasDashboardAccess()includes it so a tech gets themanager.jsbundle;RedirectAccountHoldersFromCmslands a Staff tech on My day.- Job numbers go through
ClientInvoicing\Support\DocumentNumberslike invoices and proposals. Support/JobIntakeis booking / proposal / estimate → job (fromBooking,fromProposal,fromEstimate,afterAccepted,bookingRescheduled,bookingCancelled, andbillingJob/invoiceThroughJobfor the converters). The booking hooks areAppointment::created/updatedmodel events registered inWorkOrdersServiceProvider, each deferred withDB::afterCommitand wrapped in try/report. The proposal and estimate public accept pages callafterAccepted()(both claims are query-builder updates, so no model event fires).OnlineBooking\Support\AppointmentClient::resolve()is the shared booking → client rule.Support/JobStatusis the one place that decides a job's status (derive()/sync()) and the visit/job transitions; every path that changes a visit ends insync().Support/VisitScheduleris the one write path for a visit's time and techs (site-clock input → UTC,lock_versioncompare-and-set, assignable = active techs).Support/JobInvoiceris job → invoice. The schedule board and the tech pages build on these rather than writing visits or statuses themselves.VisitScheduler::update()locks the visit row while it decides a change (so what the techs are told is exactly what the write replaced, and "has a tech set off?" is read under the same lock), then writes with thelock_versioncompare-and-set.createFirst()is the board's "first visit for a job with none open" (job row locked).shiftedTo()keeps a visit's real length and kind when it moves;conflicts()/conflictWarning()are the double-booking check (half-open time ranges, open visits only).Support/ScheduleBoardis everything the board reads (days, lanes, chip geometry, side-by-side stacking, the queue, where a drop lands);Support/TechNotifieris the notices (snapshot()/recipients()decide who hears what; delivery isDB::afterCommit+defer(), so tests call$this->withoutDefer()).JobStatus::cancelVisit()/cancelJob()and the job page's delete actions tell the techs too.- The board's Alpine component is
app/Features/WorkOrders/resources/js/schedule-board.js, registered asscheduleBoardinresources/js/manager.js; it is excluded frompublic.css(dashboard-only JS). It only works out where a chip was dropped — the server writes the move and the board re-renders from it. Support/VisitActionsis everything the tech's phone does to a visit (onMyWay,start,complete,retryOnMyWayText,addFromPriceList,addMaterial,removeItem); each method takes the visit id and re-reads it throughvisitFor()(=Visit::assignedTo($user)), and refusals a tech should read areRuntimeExceptions. Status moves areJobStatus::enRoute()/start()/completeVisit()(conditional-update claims, thensync()).Support/CustomerTextsis every customer text:blocker()/clientBlocker()/optedOut()decide whether one can go andreason()says why not;onMyWay()/reminder()/jobDone()claim, compose insideMailLocale::stored(null, …)and send throughSmsSender, releasing the claim on failure;dueReminders()is the cron's query;bookingReminds()is the online-booking overlap rule. The cron isConsole/SendVisitRemindersCommand(work-orders:send-visit-reminders, LazyCron every 900 s, cache-locked).- On the tech pages, a view helper that takes a model (
dayLabel(),onMyWayTextStatus(),reminderStatus(), the page's ownvisit()) is protected: a public method is a Livewire action, and Livewire binds a model-typed parameter from whatever id a request posts and returns the method's result, so a public helper would answer about another tech's visit. The same holds on every Jobs, Proposals and Client Billing page (the Phase 1 review found a publiclineRows(Proposal)on the client's proposal page answering about any proposal) —tests/Unit/LivewireModelParameterGuardTest.phpfails on any public method there that takes a model. - The tech pages call
TechManifestController::linkFromThisPage()inmount(), which sets theweb_manifest_url/web_app_titlerequest attributes;partials/favicon-linksthen links that manifest instead of the site's (WebManifestController::icons()is shared). - The price-list picker (
ClientInvoicing\Concerns\PicksFromPriceList) has apriceListLine()hook; the job editor overrides it to add the kind, cost and catalog id toCatalogItem::toLine(). routes/web.php(loaded by the provider) holds the public routes:work-orders.service(/service/{token}, web group + both feature gates +cache.headers:private;no_store, token[A-Za-z0-9]{40}) andwork-orders.service.photo(session-less, both gates). The staff photo route isdashboard.work-orders.photoin the field-tech group ofroutes/cms.php.Support/PhotoUploadsis the only writer of photo files (Imagick when loaded, GD otherwise —new PhotoUploads(useImagick: false)forces GD in tests); refusals arePhotoRejected(aRuntimeExceptionwith the reason).WorkOrderPhoto'sdeletedhook removes the files;PhotoUploads::deleteForJob()runs before a job delete, whose database cascade skips model events.- The new tech actions are in
Support/VisitActionslike the rest (addPhoto,setPhotoVisibility,removePhoto,takePayment,textPayLink,recordPayment), each starting fromvisitFor().Support/OnSitePaymentisprepare()(JobInvoicer → InvoiceSenderdeliver: false),cardPayUrl()(the QR rule) andrecordPayment()(idempotency key + invoice row lock →InvoicePaymentRecorder::recordManualPayment(…, idempotencyKey:), which claims withinsertOrIgnoreon the unique key). - The QR comes from core
App\Support\QrSvg(Payment Links'PaymentLinkQrnow delegates to it), so this module needn't depend on Payment Links. Support/WorkOrderWebhooksbuilds the webhook payloads;job.*rideWorkOrder::created/updated(registered in the provider, withSupport/WorkOrderCrm's timeline entries),visit.scheduledis reported byVisitScheduler::create()/update()(a compare-and-swap fires no model event). The review trigger lives in Reviews'ReviewRequestTriggers, like every other origin (job).Support/Checklistsis the checklists logic:cleanFields()(the only way a template's items are saved),attach()(the copy, under the job lock; the same template on the same visit — or twice for the whole job — returns the first),answer()/normalize()(the only writer of answers: job row, then checklist row, locked; the key and value checked against the copy),assertVisitCanComplete()/assertJobCanComplete()(called byJobStatus::completeVisit()/completeJob()under their locks, throwingChecklistIncompletewith the missing labels),missingForCompleting()(the same list for the page),detach()andcustomerResults().JobStatus::completeVisit()now locks the job row, then the visit, before its claim — the same job → visit order every other path uses.- The tech's checklist actions are in
VisitActionslike the rest (answerChecklist,addChecklistPhoto,checklistsFor), each starting fromvisitFor()and looking the checklist up throughVisitChecklist::forVisit()(this visit's own + the job's whole-job ones).removePhoto()takes the job lock too, so a checklist photo can't be taken back under a Complete. - Both pages fill checklists through
Concerns/FillsChecklists(setChecklistAnswer()for taps;$checklistDraft+wire:model.live.blurfor text and number, saved inupdatedChecklistDraft()) and the shareddashboard/partials/checklistpartial; each page suppliespageChecklists()andwriteChecklistAnswer()(the tech's goes throughVisitActions, the office's through its own job). A not-found lookup is re-thrown, so another job's checklist id is a 404, never a toast. - Livewire's temporary uploads are swept by age, so a test that uploads through the page must not run on a clock moved days ahead (
travelBack()first, or switch the sweep off withlivewire.temporary_file_upload.cleanupasJobCostingTestdoes). Support/JobCostingis the one place a job's money is worked out:for()/forJobs()(a handful of queries for any number of jobs) return aSupport/JobCost— the parts,missing(reason → count), the labor seconds per user and the done visits with no time;profitCents()/marginPercent()are null while anything is missing.Support/ProfitReportis the report (period(),rows(),groups()— by month / tech / type, complete jobs only, tech shares split to the cent —totals(),csv()).Support/JobExpensesis the only writer of expenses (add()/update()/delete(), withamount()/description()/spentOn()validation and Activity Log entries); the job page andVisitActions::addExpense()/removeExpense()/expensesFor()call it. Receipts go throughPhotoUploads::process()(the decode / orient / re-encode half ofstore());Expense'sdeletedhook removes the files andPhotoUploads::deleteForJob()deletes a job's expenses through the model before the job row goes. Receipts are served byPhotoController::receipt()(dashboard.work-orders.receipt, field-tech group): Manager and up, or the tech who logged it on a visit still assigned to them.TimeClock::add()takes an optional visit (the job page's Add time — kindvisit, closed, at the tech's rate now);TimeClock::fillMissingRates()fills onlyNULLrates on a job's closed visit time (selected and claimed on theNULL).Support/TimeClockis every write to time entries:clockIn()(returns the open shift instead of a second; catches the unique violation a concurrent tap causes),clockOut()(shift + the visit time running),startVisit()(only on an in-progress visit, read under the visit's lock; stops the person's time on another visit first; hands back the time already running on this one),pauseVisit(),stopVisitTime(),closeVisit()(everyone's time on a visit — called byJobStatus::completeVisit()inside its transaction after the claim, and bycancelVisit()after its claim),fixOwnOpen()(the tech: own + open only, else 404),edit()/add()/delete()/approve()/approveWeek()(the office), andtimes()(typed site-clock input → UTC, with the refusals). Every change goes through one locked write that stamps, clears approval and logs. Closing is a conditional update (whereNull('ended_at')) that snapshots the rate.Support/HoursReportis every read:weekStart()(site clock,WorkOrdersSettings::weekStartsOn()),dayBounds()(eight site midnights as instants),secondsByDay()(the union per day, each entry to itsTimeEntry::countedEnd()— capped at 16 h when it needs fixing),week()(per person),totalsFor()(My day),format()/decimal()andcsv().VisitActions::start()callsTimeClock::startVisit()afterJobStatus::start()(so a partner's press on an in-progress visit starts their time) and still returns whether the visit moved;VisitActions::pause()is the visit page's Pause.- Maintenance plans:
Models/Plan+Models/Agreement(periodStart()/covers()/periodOn()/visitValueCents(); numbered throughDocumentNumbers);Support/Agreementsis every change to an agreement (sell(),cancel()— a conditional claim, thenAgreementBilling::stop()—expireDue(),waitingVisits(),createContract(),linkContract());Support/AgreementBillingmakes and stops theInvoiceSchedule;Support/PlanVisitGenerator::run()/generateNext()makes the visits (agreement row lock → job → visit, the job → visit order every other path keeps; a period already taken moves the cursor and makes nothing; the loop is bounded by steps as well as visits);Support/PlanVisitNoticesis the time-to-book notice (CustomerTexts::planVisitDue()for the text; the email is composed insideMailLocale::stored(null, …)).Console/GeneratePlanVisitsCommandruns both under a cache lock. - Autopay:
Support/AgreementSubscriptionsis every Stripe step of a plan —startCheckout()(hosted Checkout,mode=subscription,price_datawithrecurring.interval+interval_count, aTaxRatewhen taxed; metadatawork_order_agreement_id+install_idon the session and, throughsubscription_data, on the subscription and every invoice it raises),completeReturn()/completeSession()→activate()(a conditional claim: active + autopay + no subscription yet; a loser is cancelled in Stripe),recordPaidInvoice()(→InvoicePaymentRecorder::recordStripeSubscriptionInvoice(), unique on the Stripe invoice id, a unique-violation catch behind the look-up),recordPaymentFailed()/markPastDue()(claimed onpast_due_at),syncSubscription()(re-reads Stripe) /subscriptionDeleted(),cancelSubscription()(called byAgreementBilling::stop(); false when Stripe is unreachable) /retryCancels()(the hourly command),switchToInvoices(),cardUpdateUrl()(a billing-portal session withflow_data.type=payment_method_update) andemailSignupLink().CustomerTexts::autopayLink()is the text. A Stripe invoice's subscription is read fromparent.subscription_details(2025 API versions and later) or the top level (before), so the endpoint works whatever version it was created with. The endpoint isHttp/Controllers/AgreementStripeWebhookController(work-orders.stripe.webhook, CSRF-exempt inbootstrap/app.php), verified withInvoicingStripe::constructWebhookEvent(…, AgreementSubscriptions::WEBHOOK_SECRET_SETTING). The generator reads onlyAgreement::makingVisits()— so plans waiting on a payment can't fill its bounded pass — and re-checksvisitsPaused()under the agreement lock. The customer's page ispublic/⚡plan-checkout(work-orders.plan); both Stripe buttons there and on the job page are rate-limited per plan (10 a minute). Support/MemberPerksowns the perks:applyOnCreate()(WorkOrder::created),clientChanged()(WorkOrder::updated),itemChanged()(WorkOrderItem::saved/deleted— deferssyncDiscount()to after the writer's commit, never inside its transaction, and reports rather than throws),apply()/remove()for the job page,syncDiscount()(under the job's row lock; reads the lines fresh and upserts the one slotted line) andqueueOrderSql()(the board queue's order).CustomerTexts'{plan}reads the plan name by key, not through the relation, because the reminder pass loads many visits at once and lazy loading is refused there.- Tests:
tests/Feature/WorkOrders/(Batch 9:JobPhotosTest,OnSitePaymentTest,CustomerJobPageTest,JobIntegrationsTest; Batch 10:JobChecklistsTest; Batch 11:TimesheetsTest; Batch 12:JobCostingTest; Batch 13:MaintenancePlansTest; Batch 14:PlanAutopayTest).