Winning the bid books the order and starts the install — exactly once
Closed Won on a construction bid has to produce a booked Order and an install job, and the install must never bill a second time when the crew finishes. Two independent automations cooperate through one standard field that neither of them owns: WorkOrderLineItem.OrderId.
A rep marks a builder’s bid Closed Won. Three things should follow: the office sees a booked order for the agreed amount, the install crew gets a job at the right lot, and when that crew finishes, nobody bills the builder a second time.
The first two are ordinary automation. The third is the one that decides whether the revenue numbers mean anything, and it comes down to a single standard field that two independent pieces of automation both agree to respect.
The transition, not the state
Booking hangs off an after-update trigger on Opportunity, and the condition is a change
rather than a value:
force-app/main/default/classes/OpportunityTriggerHandler.cls
Set<Id> wonIds = new Set<Id>();
for (Opportunity opportunity : newOpportunities) {
if (opportunity.IsWon && oldOpportunitiesById.get(opportunity.Id)?.IsWon != true) {
wonIds.add(opportunity.Id);
}
}
OpportunityBookingQueueable.enqueue(wonIds); IsWon alone would be wrong in a way that only shows up later. Any subsequent edit to a won
Opportunity — a rep fixing a typo in the name six weeks after the deal closed — re-satisfies
IsWon and would book again. And a data load of historical closed deals during a migration
would book every single one of them.
Comparing against Trigger.oldMap also handles the insert case for free: an Opportunity
inserted already won never appears in an after-update at all.
The work itself runs in a Queueable, not inline, because closing a deal must not fail on account of a booking problem. The enqueue helper has one wrinkle worth copying:
force-app/main/default/classes/OpportunityBookingQueueable.cls
if (Limits.getQueueableJobs() < Limits.getLimitQueueableJobs()) {
System.enqueueJob(new OpportunityBookingQueueable(opportunityIds));
} else {
OpportunityBookingService.bookOpportunities(opportunityIds);
} If something else in the transaction has already used the Queueable allowance, booking runs synchronously rather than throwing. Slower is a better failure than not booked.
Where the lines come from
A construction bid usually has a Quote attached, and the quote is the document the customer
signed. Opportunity.SyncedQuoteId names the quote whose lines are mirrored onto the
Opportunity, so when it is populated the QuoteLineItem records are the source of truth and
the Opportunity’s own products are the fallback:
force-app/main/default/classes/OpportunityBookingService.cls
/** Quote lines win when the Opportunity has a synced quote; otherwise its own products. */
void addLines(List<QuoteLineItem> quoteLines, List<OpportunityLineItem> opportunityLines) {
if (opportunity.SyncedQuoteId != null && quoteLines != null) {
for (QuoteLineItem line : quoteLines) {
OrderItem item = newItem(line.PricebookEntryId, line.Quantity, line.TotalPrice, line.Description);
item.QuoteLineItemId = line.Id;
item.Material_Quote_Reference__c = line.Material_Quote_Reference__c;
item.Labor_Reference__c = line.Labor_Reference__c;
items.add(item);
}
return;
}
for (OpportunityLineItem line : opportunityLines ?? new List<OpportunityLineItem>()) {
items.add(newItem(line.PricebookEntryId, line.Quantity, line.TotalPrice, line.Description));
}
} The price book follows the same precedence — SyncedQuote.Pricebook2Id first, then
Opportunity.Pricebook2Id — and OrderItem.QuoteLineItemId keeps each Order Product pointed
back at the quote line it came from, which is the difference between an invoice dispute taking
five minutes and taking an afternoon.
OrderItem has no discount field
This is the detail that would have quietly cost money. QuoteLineItem has a Discount.
OpportunityLineItem has a Discount. OrderItem does not — it carries UnitPrice,
Quantity, ListPrice and TotalPrice, and nothing else.
So a quote line for 10 units at a list price of 150, discounted ten percent, cannot be copied field-for-field onto an Order. Copy the unit price and the Order is 10 × 150 and the booked revenue is higher than the signed quote. The discount has to be resolved into the unit price on the way across:
force-app/main/default/classes/OpportunityBookingService.cls
/**
* Order Products have no discount field, so the unit price is the discounted line total
* divided by quantity; the Order total then matches the quote.
*/
OrderItem newItem(Id pricebookEntryId, Decimal quantity, Decimal totalPrice, String description) {
Decimal lineQuantity = (quantity == null || quantity == 0) ? 1 : quantity;
return new OrderItem(
PricebookEntryId = pricebookEntryId,
Quantity = lineQuantity,
UnitPrice = (totalPrice ?? 0) / lineQuantity,
Description = description
);
} Two automations, one standard field
The install job is created at the bid’s property — WorkOrder.LocationId from
Opportunity.Location__c — with one WorkOrderLineItem per Order Product. Each of those
line items carries the new Order’s Id:
force-app/main/default/classes/OpportunityBookingService.cls
List<WorkOrderLineItem> buildInstallLines() {
List<WorkOrderLineItem> lineItems = new List<WorkOrderLineItem>();
for (OrderItem item : items) {
lineItems.add(new WorkOrderLineItem(
WorkOrderId = installWorkOrder.Id,
PricebookEntryId = item.PricebookEntryId,
Quantity = item.Quantity,
UnitPrice = item.UnitPrice,
OrderId = order.Id,
Description = item.Description
));
}
return lineItems;
} WorkOrderLineItem.OrderId is a standard Field Service field, and it is the reason the crew
finishing the install does not generate a second invoice. The
Work Order to Order bridge — a separate
class, written days earlier, triggered by a Work Order reaching the Completed status category
— skips any line that already carries an OrderId, because that line has already been
accounted for on the ledger.
Never twice, even on retry
The same discipline applies to booking itself. Before building anything, the service asks which of these Opportunities already has an Order, using the standard lookup:
force-app/main/default/classes/OpportunityBookingSelector.cls
for (Order existing : [
SELECT OpportunityId FROM Order WHERE OpportunityId IN :opportunityIds WITH SYSTEM_MODE
]) {
bookedIds.add(existing.OpportunityId);
} Those Opportunities are skipped. And if an Order inserts successfully but its Order Products fail, the Order is deleted again rather than left behind as a zero-line booking — a half-built Order is worse than none, because the guard above would then treat the Opportunity as already booked forever.
The queries all carry WITH SYSTEM_MODE, deliberately. Booking runs as whoever won the bid,
Apex defaults to user mode from API version 67.0, and in this org the standard fields
Order.OpportunityId and Order.QuoteId carry no field-level security even for the
System Administrator profile. In user mode this
class would fail for the administrator who wrote it, never mind the rep who triggers it.
Which record types book is configuration, not code
Three lines of business behave differently, and the differences live in a Custom Metadata Type rather than in Apex:
force-app/main/default/customMetadata/Billing_Setting.Default.md-meta.xml
<values>
<field>Book_On_Win_Record_Types__c</field>
<value xsi:type="xsd:string">New_Construction,Wholesale</value>
</values>
<values>
<field>Install_On_Win_Record_Types__c</field>
<value xsi:type="xsd:string">New_Construction</value>
</values> New Construction and Wholesale both book an Order on the win. Only New Construction gets an install Work Order, because a wholesale dealer order never touches Field Service. And Service is absent from both lists on purpose: service revenue is booked when its Work Order completes, not when an Opportunity closes, because on a service job what you did and what you booked are established at the same moment.
Writing that rule as configuration does two things. A fourth line of business can start booking without a deployment. More usefully, the omission is legible — a Service record type missing from a metadata record reads as a decision, where a Service record type missing from a hardcoded set in Apex reads as a bug somebody should fix.
An async failure nobody can see is worse than a blocking one
Booking happens after the rep’s save, which means a failure produces no error on their screen. Left there, the first person to discover a missing Order is whoever goes looking for the invoice.
So every failure — a missing Account, a missing price book, an Opportunity with no products
and no synced quote lines, or an unexpected exception from the job itself — is written to
Opportunity.Booking_Error__c and shown on the layout. A later successful run clears it. The
field is the difference between an automation that failed and an automation that failed
visibly, and only one of those is finished work.
Practical summary
- Trigger on the transition to won by comparing
IsWonagainstTrigger.oldMap, never on the won state. State-based conditions re-fire on every later edit and on every migrated record. - Prefer the synced Quote’s lines over the Opportunity’s products, and take the price book from the quote too.
OrderItemhas no discount field. Resolve the discount intoUnitPriceby dividing the source line’sTotalPriceby its quantity, and assert the Order total against the quote total.- Let independent automations coordinate through a standard field — here
WorkOrderLineItem.OrderId— rather than a custom flag, and test them in sequence. - Check for an existing Order before booking, and delete a partially built Order rather than leaving one that makes the idempotency guard lie.
- Put record-type behaviour in a Custom Metadata Type so deliberate omissions are visible as decisions.
- An asynchronous job needs an error field on the record. Logs are for you; the field is for the person who will notice first.
None of this is clever. It is two classes that were written days apart, do not import each other, and are kept honest by one field that the platform shipped for exactly this purpose. The interesting engineering was deciding that the field they share should be one neither of them invented.