The job is not finished when the first visit is

A two-visit repair bills once, when the Work Order completes. A technician who taps Completed at the end of visit one invoices the diagnosis and nothing else, and the spring fitted on Thursday never reaches an invoice. The guard that stops it once the return visit is on the job, the status that had to exist before blocking anything was reasonable, and the case no guard of this kind can see.

A technician diagnoses a garage door on Tuesday, finds a snapped spring, orders the part, and packs up. He is done for the day and the job in his hand looks finished, so he marks it Completed.

That night the customer is invoiced for the diagnosis. On Thursday he comes back, fits the spring, and adds it to the job. Nothing bills. No error at any point — the invoice went out on Tuesday and it is never coming out again.

This is the failure the completion guard exists to prevent — with one condition, covered below: the Thursday visit has to exist as an appointment when he taps Completed. Most of the work was not the guard.

One job, two visits, one invoice

At Doorvana a service job is one WorkOrder, and each visit to the property is one ServiceAppointment on it. A diagnosis and a return trip for an ordered part are two appointments, one job, one invoice.

That shape is deliberate and it is the right one. It also means the billing trigger has exactly one moment to fire, and everything depends on that moment being the right one:

Billing fires on the transition into the Completed status category. Anything added to the job after that transition is never billed, which makes a premature completion silent rather than loud.

The guard is one sentence of policy: a WorkOrder cannot enter the Completed or Closed status category while any ServiceAppointment under it is still open. Everything below is what that sentence cost.

It has to run after update

WorkOrder.StatusCategory is not a field anybody sets. It is derived from Status through the WorkOrderStatus standard value set, and in a before-update trigger it still holds the old value. A guard that reads StatusCategory in before update is reading yesterday’s answer about today’s record.

So the check runs in after update, where StatusCategory already reflects the new Status, and it uses addError — which in after update rolls the entire transaction back, status change included.

force-app/main/default/classes/WorkOrderCompletionGuard.cls

private static final Set<String> CLOSED_WORK_ORDER_CATEGORIES =
    new Set<String>{ 'Completed', 'Closed' };

private static Map<Id, WorkOrder> selectClosing(
    List<WorkOrder> newWorkOrders, Map<Id, WorkOrder> oldWorkOrdersById
) {
    Map<Id, WorkOrder> closingById = new Map<Id, WorkOrder>();
    for (WorkOrder workOrder : newWorkOrders) {
        WorkOrder previous = oldWorkOrdersById.get(workOrder.Id);
        if (CLOSED_WORK_ORDER_CATEGORIES.contains(workOrder.StatusCategory)
            && !CLOSED_WORK_ORDER_CATEGORIES.contains(previous?.StatusCategory)) {
            closingById.put(workOrder.Id, workOrder);
        }
    }
    return closingById;
}

Only the transition counts. A Work Order that was already closed and gets edited is not closing again, and a history load that arrives already Completed was never a completion event at all — the same rule the billing trigger uses, for the same reason.

ParentRecordId is not only the Work Order

The obvious query is appointments where ParentRecordId is one of the Work Orders being completed. It is also wrong.

ServiceAppointment.ParentRecordId is polymorphic. It accepts WorkOrder, WorkOrderLineItem and Asset, and on a multi-door job it is entirely normal for an appointment to hang off a specific line item rather than the job as a whole. A guard that only knows about Work Order Ids sees no open appointments on a job that plainly has one.

So the guard builds a map from every possible parent back to its Work Order — the Work Order Ids themselves, plus the Ids of all their line items — and queries against the whole key set:

force-app/main/default/classes/WorkOrderCompletionGuard.cls

Map<Id, Id> workOrderIdByParentId = new Map<Id, Id>();
for (Id workOrderId : closingById.keySet()) {
    workOrderIdByParentId.put(workOrderId, workOrderId);
}
for (WorkOrderLineItem lineItem : WorkOrderCompletionSelector.selectLineItems(closingById.keySet())) {
    workOrderIdByParentId.put(lineItem.Id, lineItem.WorkOrderId);
}

One appointment, wherever it is parented, resolves back to one Work Order to put the error on.

The query has to see more than the user does

From API version 67.0 Apex runs in user mode by default, which on this project has already produced one post of its own. Here it bites from the other direction.

The user completing the job is a technician. The appointment that makes the job unfinished may be assigned to a different technician, and there is no guarantee the completing user is shared on that record. In user mode, that appointment simply is not in the result set — and an empty result set is indistinguishable from a finished job.

force-app/main/default/classes/WorkOrderCompletionSelector.cls

return [
    SELECT Id, AppointmentNumber, ParentRecordId
    FROM ServiceAppointment
    WHERE ParentRecordId IN :parentIds
        AND StatusCategory NOT IN :CLOSED_APPOINTMENT_CATEGORIES
    WITH SYSTEM_MODE
    ORDER BY AppointmentNumber
];

The selector reads Ids, the appointment number and the parent, and nothing leaves the transaction except the appointment number in the error message. Widening visibility in a guard is only defensible when the widened data does not escape.

Closed is quieter than Completed

Blocking Completed is the obvious half. Closed matters more than it looks.

Billing fires on the transition into Completed. A Work Order dragged straight from In Progress to Closed skips that transition entirely, so it is not misbilled — it is never billed. The loud failure is a half-invoice; the quiet one is no invoice, on a job that reads as finished on every list view in the org.

Both categories are in the set, and there is a test for each.

The status that had to exist first

Here is the part that is actually design rather than code: a guard that only says no is a guard that gets worked around. The technician on Tuesday is not being careless. He has finished what he can do today and the system offers him no honest way to say so.

So Awaiting Parts was added to the WorkOrderStatus standard value set, and the groupingString is the whole point:

force-app/main/default/standardValueSets/WorkOrderStatus.standardValueSet-meta.xml

<standardValue>
    <fullName>Awaiting Parts</fullName>
    <default>false</default>
    <label>Awaiting Parts</label>
    <groupingString>OnHold</groupingString>
</standardValue>

groupingString is what StatusCategory resolves to. Because the guard keys on the category and not on the status label, a status mapped to OnHold is automatically not a completion — no Apex change, no list of blessed status names to keep in sync. A future Awaiting Approval mapped the same way inherits the behaviour for free.

The error message then points at it, verbatim:

This job still has an open service appointment (SA-0042). Complete or cancel it first.
If the tech is coming back with a part, set the status to Awaiting Parts instead.

Naming the specific appointment matters because on a multi-door job the technician needs to know which visit is outstanding, and naming the alternative matters because otherwise the next thing tried is Closed.

Jobs parked in Awaiting Parts get their own list view, so a job waiting on a spring is a queue somebody works rather than a record that quietly ages. Deposit tracking lives on the same view: a deposit is a payment rather than revenue, so it never becomes an OrderItem — Salesforce records only that one is required and whether it has arrived, so nobody orders a part before it does. The Order stays at the full value of the job and the accounting system applies the deposit against the invoice.

What the guard cannot see

The guard reads appointments that exist. If the technician on Tuesday completes his own appointment and nobody has created Thursday’s yet, the query finds nothing open, the guard passes, and the Work Order moves into Completed and bills the diagnosis — exactly the opening scenario.

This limitation was recorded on the project after the guard shipped, while working out what “done” means for a two-visit repair. The guard is still worth having — it stops the case where the return visit is booked and someone closes the job anyway — but it is half of the control, not all of it.

What the tests pin down

Eight tests, and the useful thing is which cases they are:

  • an open appointment on the WorkOrder blocks completion, and the status change is rolled back
  • an open appointment on a line item blocks it too
  • Closed is blocked the same way as Completed
  • a blocked Work Order produces zero Orders
  • in a bulk update, every Work Order with an open appointment is rejected individually
  • Completed and Canceled appointments do not block, and the job bills exactly once
  • moving to Awaiting Parts with an appointment open succeeds
  • null trigger collections block nothing

The one that earns its place is shouldNotBill_WhenCompletionBlocked. addError in after update rolls the transaction back, so the billing that ran earlier in the same transaction is rolled back with it, and the trigger handler also excludes blocked Ids before enqueuing billing. Belt and braces — and the assertion is on the Order count, because that is the thing the customer sees.

The deploy validated with RunSpecifiedTests: 38 of 38 passing.

Practical summary

  • Read WorkOrder.StatusCategory in after update, never before update — it is derived from Status and holds the old value until the update is applied.
  • Guard on the transition into a closed category, not on the category itself, or every later edit to a finished job re-triggers everything.
  • ServiceAppointment.ParentRecordId is polymorphic. Collect the Work Order’s line item Ids and query both, or you will miss appointments and pass a job that is not finished.
  • A guard may legitimately need WITH SYSTEM_MODE to see records the running user cannot. Say so in a comment, and do not let the widened data leave the transaction.
  • A guard on open appointments cannot see a visit nobody has created. Book the return appointment, or park the job in an on-hold status, before closing out the first visit.
  • Block Closed as well as Completed. Skipping Completed skips billing altogether, which is the failure nobody notices.
  • Add the alternative status before adding the block, map it with groupingString so category logic picks it up automatically, and give it a list view so parked jobs stay visible.

The pattern holds across everything on this build so far: the automation was the easy half. The hard half was making sure the person holding the tablet had a true thing they could click.

Filed under

Disclosure: Doorvana's owner is also a co-owner of Nocona Consulting. This is a real engagement between two separate companies, but not an arm's-length one — and not an independent reference. More on the work page.

FAQ

How do you stop a Salesforce Work Order from being completed while a Service Appointment is still open?

Add an after-update trigger check that inspects StatusCategory and calls addError on any Work Order moving into the Completed or Closed category while a Service Appointment under it is not Completed, Canceled or Cannot Complete. It has to run after update because StatusCategory is derived from Status and is only populated by then. addError rolls the whole transaction back, so any billing automation downstream in the same transaction is rolled back with it.

Can a Salesforce Service Appointment be parented to a Work Order Line Item rather than the Work Order?

Yes. ServiceAppointment.ParentRecordId is polymorphic and accepts WorkOrder, WorkOrderLineItem and Asset among others, so appointments on a job can hang off either the Work Order or one of its line items. Any logic that reads appointments for a job must collect the Work Order's line item Ids and query ParentRecordId against both sets, or it will miss the line-level ones and reach the wrong conclusion about whether the job is finished.

Why would a validation query need WITH SYSTEM_MODE rather than user mode?

Because a guard has to see records the running user cannot. From API version 67.0 Apex runs in user mode by default, so a query for appointments on a job returns only the appointments the completing user is shared on. Appointments assigned to a different technician disappear, and the check concludes the job is finished when it is not. System mode in a guard is used to see more rather than to write more, which is the opposite of the usual reason, and it is worth a comment saying so.

Does the Closed status category need blocking as well as Completed?

Usually yes, and for a different reason. Billing that fires on the transition into Completed never fires for a Work Order moved straight to Closed, so closing an unfinished job does not misbill it; it silently never bills it at all. A guard that only covers Completed leaves the quieter of the two failures wide open.

Can a completion guard on open Service Appointments catch a return visit that has not been booked yet?

No. A guard that blocks Work Order completion while a ServiceAppointment under it is open can only see appointments that exist. If the first visit's appointment is Completed and the return visit has not been created yet, there is nothing open, the guard passes, and the Work Order completes and bills. Closing that gap needs a process step — booking the return appointment, or moving the job to an on-hold status such as Awaiting Parts — before the first visit is closed out.

How do you add a custom value to the Salesforce WorkOrderStatus standard value set?

Retrieve WorkOrderStatus as a standardValueSet component and add a standardValue with a fullName, a label and a groupingString naming the status category it belongs to. The groupingString is the load-bearing part: it is what StatusCategory resolves to, so a status mapped to OnHold is automatically not a completion for any logic keyed on the category rather than on the status label.

Working on something like this?

I can tell you what it takes.

If you run a home services business and this looks like a problem you have, book a call and we will talk through it.