Two doors, one trip fee, and the fields that were on the wrong object
A warranty network's diagnosis checklist was planned as seven fields on WorkOrderLineItem. Two of them belong on the Work Order instead, because the trip fee is charged once per visit however many doors get diagnosed — and the question that settles it is cardinality, not the requirement document.
A technician arrives at a house with two failed garage doors. One needs a spring; the other needs the whole operator replaced. He is diagnosing both for a home-warranty network, which requires seven specific items documented on every diagnosis before it will pay anything.
The trip fee was charged once. He drove to one address.
That sentence is the whole design problem, and I nearly missed it. The implementation plan —
written by me, weeks earlier — listed the network’s seven requirements and put all seven on
WorkOrderLineItem. Building it that way would have compiled, deployed, and stored the same
trip-fee answer twice on every two-door visit, with nothing defining what happens when the two
copies disagree.
Four of those seven requirements turned out to be answerable from standard fields nobody had looked at, which is its own story and has its own post. This one is about the three that genuinely needed building, and the question that decided where they go.
The question that picks the object: how many can there be?
The requirement document is no help here. A warranty network does not care which object anything sits on; it cares that the answers arrive. So the placement decision has to come from the shape of the work, and in Field Service the shape is already fixed:
- A
WorkOrderis one visit to one place. - A
WorkOrderLineItemis one piece of work, and on this model, one opening — oneAsset, through the standardWorkOrderLineItem.AssetId.
Then ask each fact how many answers it can have in a single visit.
Cause of failure — two doors, two causes. Per line. Repair or replace — the spring gets repaired and the operator gets replaced, on the same trip, in the same hour. Per line. Was a service fee collected — he drove to one address and charged for one trip. Per visit. Does that fee credit against the repair if the customer goes ahead — same fee, same answer. Per visit.
Two checkboxes went on WorkOrder, and the field description carries the reasoning so the
next person does not have to re-derive it:
force-app/main/default/objects/WorkOrder/fields/Service_Fee_Collected__c.field-meta.xml
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Service_Fee_Collected__c</fullName>
<label>Service Fee Collected</label>
<description>Whether a service (trip) fee was collected. On the Work Order, not the
line item, because the fee is charged once per visit however many doors are
diagnosed. This records the answer the warranty network asks for; the fee itself
must still be a priced line so it reaches Order Products.</description>
<inlineHelpText>Check if the customer paid a service fee for this visit.</inlineHelpText>
<type>Checkbox</type>
<defaultValue>false</defaultValue>
</CustomField> That last clause matters more than it reads. A checkbox saying a fee was collected is
documentation, not money. The fee still has to exist as a priced WorkOrderLineItem for the
billing bridge to carry it onto the Order, and the same is true of the credit when the fee
applies toward the repair.
The bridge only bills what carries a price,
which is exactly the property you want here: a checkbox can never silently become an invoice
line, and a fee that was never charged shows up as a missing priced line rather than as two
fields disagreeing about the truth.
The two line-item fields got the same treatment in the other direction.
Cause_of_Failure__c is a 2,000-character long text area rather than a picklist, and its
description records that as a reversible choice: if failure-cause reporting ever becomes a
requirement, add a restricted picklist beside it rather than converting the field — converting
a text field to a picklist on a global value set
is not a thing the platform will do in place.
Recommendation__c is restricted with exactly two values, Repair and Replace, because that is
the entire universe the network recognises and free text there would be a reporting problem
from the first record.
The help text that described a feature nobody had built
Earlier the same day, WorkOrder.Bill_To_Account__c had been created with this inline help
text:
The account that pays. Defaults to the Work Order’s Account; change it when someone else, such as a landlord, is paying.
Nothing defaulted it. The field was a plain lookup and the sentence was aspirational — a description of behaviour that existed only in the head of whoever wrote the help text. The platform does not check that claim, and the person most likely to believe it is a dispatcher who leaves the field empty because the tooltip promised it fills itself. The field is genuinely needed, incidentally: the caller is often not the payer, because a renter reports the broken door and the landlord pays for it unless the tenant caused the damage.
The fix is a before-save record-triggered flow, and the interesting part is that it contains no decision element. The condition lives in the entry criteria:
force-app/main/default/flows/Work_Order_Default_Bill_To.flow-meta.xml
<start>
<connector>
<targetReference>Bill_To_Work_Order_Account</targetReference>
</connector>
<filterLogic>and</filterLogic>
<filters>
<field>Bill_To_Account__c</field>
<operator>IsNull</operator>
<value><booleanValue>true</booleanValue></value>
</filters>
<filters>
<field>AccountId</field>
<operator>IsNull</operator>
<value><booleanValue>false</booleanValue></value>
</filters>
<object>WorkOrder</object>
<recordTriggerType>CreateAndUpdate</recordTriggerType>
<triggerType>RecordBeforeSave</triggerType>
</start> Because the flow cannot fire on a record that already has a Bill To, it is safe to run on update as well as create. That matters: Work Orders generated from a Maintenance Plan, or migrated in, can arrive with no Account and be given one later, and a create-only default would miss every one of them. A landlord typed in by hand survives every subsequent save, because the flow never runs on that record again.
Bill_To_Contact__c is deliberately not defaulted. Blank there means invoice the Work
Order’s Contact, which is a meaningful answer rather than a gap — and a default that filled it
would make “the usual person” and “this specific person” indistinguishable.
One more field that was pointing at too much
Pickup_Location__c on WorkOrder records where the technician collects product before the
job. This company holds no inventory — technicians collect from vendor sites on the way to the
first call — so where to go has to be on the technician’s Work Order rather than implied by a
warehouse the company does not have.
A lookup to Location offers every Location in the org, and in this data model that includes
every customer’s house, because a customer property
is a Location record too. So the lookup
carries a required filter:
force-app/main/default/objects/WorkOrder/fields/Pickup_Location__c.field-meta.xml
<lookupFilter>
<active>true</active>
<errorMessage>Pickup Location must be a Warehouse location.</errorMessage>
<filterItems>
<field>Location.LocationType</field>
<operation>equals</operation>
<value>Warehouse</value>
</filterItems>
<isOptional>false</isOptional>
</lookupFilter> isOptional false is the part worth noticing. An optional lookup filter is a suggestion the
user can switch off in the lookup dialog; a required one is enforced at save with that error
message. For a field whose wrong values are all customer addresses, the suggestion is not good
enough.
Sixteen components passed a sf project deploy start --dry-run, then deployed to the sandbox,
and the three things worth clicking all held: a blank Bill To filled from the Account on save, a
hand-entered Bill To survived save and reload, and the Pickup Location lookup hid a customer
property while offering a warehouse.
Practical summary
- Choose between a parent and a child object by cardinality per parent, not by which object the requirement document happens to mention. One trip, one trip fee; two openings, two diagnoses.
- In Field Service the
Assetlink lives onWorkOrderLineItem, which is what lets one visit cover several pieces of equipment. Design for two before you meet the second one. - Keep documentation fields and money strictly apart. A fee is a priced line item; a checkbox recording that it was collected is paperwork, and neither should be able to impersonate the other.
- Put a default’s condition in a before-save flow’s entry criteria rather than a decision element. The flow then cannot overwrite a human’s value, which is what makes it safe on update as well as create.
- Leave a field undefaulted when blank already means something.
- Filter a lookup whenever the field means something narrower than the object it points at, make the filter required rather than optional, and write the error message as a sentence the user can act on.
- Help text is a tooltip, not an implementation. Every behavioural claim in a description or a tooltip should be traceable to something that actually runs.
The pattern I keep hitting on this project is that the requirement document and the platform describe the world at different resolutions. The document says “document whether a service fee was collected”. The platform asks: collected for what — this door, or this trip? Answering that is most of the modelling work, and it is not in the document.