---
title: "Winning the bid books the order and starts the install — exactly once"
description: "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."
source: "https://noconaconsulting.com/blog/booking-a-won-bid-order-and-install/"
---

# Winning the bid books the order and starts the install — exactly once

_Build log · Doorvana · Garage door manufacturing & service_

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.

Nocona Consulting · October 12, 2026 · 8 min read

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

```apex
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

```apex
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

```apex
/** 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

```apex
/**
 * 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
    );
}
```

Assert the total, not the line count

A test that checks the right number of Order Products were created passes happily while every unit price is wrong. The assertion that actually protects this is the Order total against the quote total. Reading `TotalPrice` rather than `UnitPrice` from the source line is also what makes the arithmetic survive a line whose discount was entered as an amount rather than a percentage.

## 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

```apex
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](https://noconaconsulting.com/blog/product-consumed-isconsumed-billing-filter/) — 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.

_Two automations with no knowledge of each other. New Construction revenue is booked when the bid is won; Service revenue is booked when the Work Order completes. The standard OrderId on the line item is what keeps them from booking the same work twice._

Rule of thumb

When two automations must not do the same work twice, have them agree on a standard field rather than a custom flag one of them invented. A standard field is part of the platform’s model, so neither side owns it, neither side can redefine it, and a developer reading either class alone can see what the field means. The test that matters is the one that runs both automations in sequence and asserts the second one did nothing.

## 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

```apex
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](https://noconaconsulting.com/blog/no-such-column-field-level-security/). 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

```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](https://noconaconsulting.com/blog/one-revenue-ledger/), 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 `IsWon` against `Trigger.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.
-   `OrderItem` has no discount field. Resolve the discount into `UnitPrice` by dividing the source line’s `TotalPrice` by 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.

-   Opportunity
-   Order
-   Quote
-   Field Service
-   Apex
-   Queueable
-   Custom Metadata Types

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](https://noconaconsulting.com/work/).

## FAQ

### How do you create a Salesforce Order automatically when an Opportunity is won?

Fire on the transition to won rather than on the won state, and do the work asynchronously. An after-update trigger compares IsWon on the new record against IsWon in Trigger.oldMap and collects only the records that just changed, which means an Opportunity inserted already won — migrated history, for example — is never booked. The collected Ids go to a Queueable so the rep's save completes regardless of what happens during booking, and the booking logic reads which record types should book from configuration rather than from a hardcoded list.

### Should Order lines come from the Quote or from the Opportunity products?

Prefer the synced Quote when there is one. Opportunity.SyncedQuoteId names the quote whose lines and amount are mirrored onto the Opportunity, and it is the document the customer actually agreed to, so its QuoteLineItem records are the more faithful source. Fall back to OpportunityLineItem when no quote is synced. Taking the quote's price book from SyncedQuote.Pricebook2Id in that case matters too, since the quote and the Opportunity can in principle differ.

### Does the Salesforce OrderItem object have a discount field?

No. OrderItem carries UnitPrice, Quantity, ListPrice and TotalPrice, and has no discount or discount-percent field, unlike QuoteLineItem and OpportunityLineItem. A discounted quote line therefore has to arrive on the Order with the discount already resolved into the unit price — divide the discounted line total by the quantity — or the booked revenue will not match what the customer agreed to. Assert the Order total against the quote total in a test, because the arithmetic is the only thing preventing a quiet drift.

### How do you stop a Salesforce job from being billed twice by two automations?

Give both automations one field to agree on, and prefer a standard field over a custom flag. On a Field Service install created from a won bid, stamping the new Order's Id into the standard WorkOrderLineItem.OrderId records that the line is already accounted for, and the Work Order billing automation skips any line that carries one. Because the field is part of the platform's own model rather than a flag one automation invented, neither side owns it and neither can quietly change its meaning.

### Where should record type behaviour like which Opportunities book be configured?

In a Custom Metadata Type rather than in Apex constants. A Billing\_Setting\_\_mdt record holding comma-separated record type developer names lets a new line of business start booking without a deployment, and it makes the deliberate omissions visible: a Service record type that is absent from the booking list is absent on purpose, because service revenue is booked when its Work Order completes. A hardcoded list expresses the same rule invisibly, and reads as an oversight rather than a decision.

### How should an asynchronous Salesforce automation report failure to users?

Write the failure onto the record the user is looking at. A Queueable that fails after the save leaves no error on screen, so an error field on the parent — updated in the same job, and cleared when a later run succeeds — is what turns an invisible failure into something a person can see and act on. Logging alone means the first report comes from whoever notices the missing Order, which is usually a customer.
