Consumer checkout is a single event: add to cart, pay, done. B2B rarely works that way. A buyer with a purchase order and a procurement process doesn't hit "buy" — they ask for a quote, someone on the seller's side prices it, the buyer gets it approved internally, and only then does it become an order. The money moves at the end of a conversation, not the start.
So the thing you're modelling isn't a cart. It's a small workflow with distinct states — a request that becomes a quote, a quote that becomes an order — and a set of rules about what's allowed to change at each step. Get the model wrong and you get the failure that quietly ruins B2B systems: the quote the buyer approved and the order that shipped don't say the same thing. Different price, different quantity, a line that appeared or vanished. That's not a display bug. That's an invoice someone has to explain.
I worked most of this out building the Pi-Pi B2B systems — a self-serve e-commerce platform across 32 European markets and, alongside it, an internal deal portal where a sales rep turns a wholesale deal into a full set of trade paperwork. The portal is the sharp version of the problem: one deal record has to drive a pro forma, a contract, a commercial invoice and a packing list, and they can never disagree. This article is the workflow underneath that — the states, the transitions, and the one conversion step where drift usually creeps in.
This is a different concern from who sees which price — that's access control, and I covered it separately in gated B2B pricing in Next.js. Here the price is already resolved. The question is how it moves through the workflow without changing behind anyone's back.
The States, Named Out Loud
Start by naming what a deal can actually be. The temptation is a loose status: string and a scattering of booleans — isQuoted, isApproved, isOrdered. That rots fast, because it lets you represent states that shouldn't exist: an order that was never quoted, a rejected deal that's also confirmed.
Model it as one closed set instead:
// lib/deal/status.ts
export type DealStatus =
| "draft" // rep or buyer is still assembling lines
| "quoted" // priced and sent to the buyer; awaiting their decision
| "accepted" // buyer approved the quote — locked, ready to convert
| "ordered" // converted to a confirmed order
| "rejected" // buyer declined; terminal
| "expired"; // quote validity lapsed before a decision; terminalSix states, and the names carry the business meaning. draft is editable. quoted is a promise with a shelf life. accepted is the buyer's yes — the point where the numbers freeze. ordered is the commitment that generates paperwork. rejected and expired are dead ends. That last distinction matters more than it looks: a quote that lapsed unanswered is not the same as one the buyer turned down, and a sales team needs to see the difference when they follow up.
Transitions Are the Real Model
The states are the easy part. The value is in saying which moves are legal — and refusing the rest at the boundary, instead of hoping the UI never offers them.
// lib/deal/transitions.ts
const ALLOWED: Record<DealStatus, DealStatus[]> = {
draft: ["quoted"],
quoted: ["accepted", "rejected", "expired", "draft"], // "draft" = reopen to re-quote
accepted: ["ordered", "expired"], // buyer said yes, but can still lapse before conversion
ordered: [], // terminal — an order is not edited, it's superseded
rejected: [],
expired: [],
};
export function canTransition(from: DealStatus, to: DealStatus): boolean {
return ALLOWED[from].includes(to);
}Read the table as the actual sales rules. You can re-quote a deal the buyer is still deciding on (quoted → draft), but you cannot re-quote one they already accepted — that would rewrite a price they signed off on. An ordered deal has no outgoing edges at all: you don't edit an order, you issue a credit note or a new order against it. Encoding "no way back from ordered" as an empty array is a one-line guarantee that no later feature accidentally mutates a shipped commitment.
The transition function is where every state change funnels, and it's the only place allowed to write status:
// lib/deal/transitions.ts
export async function transitionDeal(dealId: string, to: DealStatus, actor: Actor) {
return db.transaction(async (tx) => {
const deal = await tx.query.deals.findFirst({ where: eq(deals.id, dealId) });
if (!deal) throw new NotFoundError(dealId);
if (!canTransition(deal.status, to)) {
throw new IllegalTransitionError(deal.status, to); // 409, not 500
}
await tx.insert(dealEvents).values({
dealId,
from: deal.status,
to,
actorId: actor.id,
at: new Date(),
});
await tx.update(deals).set({ status: to }).where(eq(deals.id, dealId));
});
}Three things earn their place here. The guard runs inside the same transaction as the write, so two requests racing to accept the same quote can't both win. Every transition writes a dealEvents row — the audit trail a B2B deal needs, and the thing that lets you answer "who moved this to ordered, and when" months later. And an illegal transition is a 409 Conflict, a domain outcome, not a crashed handler. The buyer clicking "accept" on a quote that expired thirty seconds ago should get a clean "this quote is no longer valid," not a 500.
One Line-Item Model, From Draft to Order
Here's the decision that prevents most drift, and it's a modelling choice, not a clever trick: a quote and an order are the same record in different states — not two tables you have to keep in sync.
The tempting design is quotes and orders as separate tables, with a job that copies rows across on acceptance. Every copy is a chance for the two to diverge — a rounding difference, a field someone forgot to map, a quote edited after the order was cut. You spend the rest of the project writing reconciliation code.
Instead, one deals table carries the deal through its whole life, and the line items hang off it once:
CREATE TABLE deals (
id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts(id),
status text NOT NULL DEFAULT 'draft',
currency char(3) NOT NULL DEFAULT 'EUR',
valid_until date, -- quote shelf life; drives 'expired'
order_number text UNIQUE, -- assigned only at conversion, never before
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE deal_lines (
id uuid PRIMARY KEY,
deal_id uuid NOT NULL REFERENCES deals(id) ON DELETE CASCADE,
product_id uuid NOT NULL REFERENCES products(id),
description text NOT NULL, -- snapshotted at add-time, not joined live
qty integer NOT NULL CHECK (qty > 0),
unit_price numeric(12, 2) NOT NULL, -- frozen when the line is priced
line_no integer NOT NULL,
UNIQUE (deal_id, line_no)
);Two fields do the heavy lifting. description is copied onto the line when it's added, not read live from the product catalogue at render time — so renaming a product next quarter doesn't silently reword a quote the buyer already holds. unit_price is frozen the moment the line is priced, for the same reason the Pi-Pi portal snapshots the buyer's company and VAT number at deal creation: a document reissued months later still has to match what the customer agreed to, even if the live price list moved since.
Because there's one set of line items, a quote PDF and an order PDF are the same query with a different heading. There is no second copy to drift from the first. This is the same principle the deal portal runs on — every trade document is a pure function of one deal record, so a change to a price propagates to all of them at once, or to none.
Editing Is Gated by State, Not by Page
With a single record, "can this line be edited?" is answered by the deal's status, not by which screen you're on. A draft is fully editable. A sent quote is not — change a line on a quote the buyer is looking at and you've moved the goalposts mid-decision.
// lib/deal/lines.ts
const EDITABLE: DealStatus[] = ["draft"];
export async function upsertLine(dealId: string, line: DraftLine, actor: Actor) {
const deal = await getDeal(dealId);
if (!EDITABLE.includes(deal.status)) {
// To change a sent quote, reopen it: quoted → draft, then re-quote.
throw new DealLockedError(deal.status);
}
const price = await getPriceForViewer(line.productId, deal.account, line.qty);
if (!price) throw new ProductNotOnAccountError(line.productId);
await db
.insert(dealLines)
.values({
dealId,
productId: line.productId,
description: await productName(line.productId), // snapshot now
qty: line.qty,
unitPrice: price.unitPrice, // freeze now
lineNo: line.lineNo,
})
.onConflictDoUpdate({
target: [dealLines.dealId, dealLines.lineNo],
set: { qty: line.qty, unitPrice: price.unitPrice },
});
}The rule this enforces: you never mutate a quote in place after it's sent. If the buyer wants a change, the deal goes quoted → draft (an allowed transition), gets re-priced, and gets re-sent as a fresh quote — with the event log recording that it happened. The buyer always sees a coherent snapshot, never a document changing under them. And because pricing runs through the same getPriceForViewer gate used everywhere else, a per-account price list and its quantity breaks apply here too; that resolution is the subject of the gated-pricing article and isn't repeated here.
The Conversion: Copy, Don't Re-Read
This is the step where drift is born, so be pedantic about it. When an accepted quote becomes an order, the wrong instinct is to re-resolve everything — re-run pricing, re-read the catalogue, recompute totals "to be safe." That's exactly backwards. The quote is the agreement. Re-reading live data at conversion is how the order ends up costing more than the number the buyer approved, because the price list moved between acceptance and conversion.
The order is not a fresh computation. It's the accepted deal, promoted:
// lib/deal/convert.ts
export async function convertToOrder(dealId: string, actor: Actor) {
return db.transaction(async (tx) => {
const deal = await tx.query.deals.findFirst({
where: eq(deals.id, dealId),
with: { lines: true },
});
if (!deal) throw new NotFoundError(dealId);
// Only an accepted deal converts. The guard is the model, not a comment.
if (!canTransition(deal.status, "ordered")) {
throw new IllegalTransitionError(deal.status, "ordered");
}
// Assign an order number ONCE. Idempotent: re-running returns the same one.
const orderNumber = deal.orderNumber ?? (await nextOrderNumber(tx));
await tx.update(deals).set({ status: "ordered", orderNumber }).where(eq(deals.id, dealId));
await tx.insert(dealEvents).values({
dealId,
from: deal.status,
to: "ordered",
actorId: actor.id,
at: new Date(),
});
// NOTE: lines are NOT touched. No re-pricing, no re-read.
// The order IS the accepted quote — same rows, now with an order number.
return { ...deal, status: "ordered" as const, orderNumber };
});
}What conversion does not do is the whole point. It doesn't recompute a price. It doesn't rebuild the line items. It doesn't join back to the live catalogue. It flips the status, stamps an order number, and logs the event. The line items the buyer accepted are the line items that ship, byte for byte, because nothing re-derives them.
The order number is assigned exactly once and guarded by deal.orderNumber ?? …, so a double-clicked "confirm" or a retried request doesn't burn two numbers or produce two orders. Everything runs in one transaction, so a failure halfway through rolls the whole thing back — you never get a deal marked ordered with no order number, or an order number allocated to a deal that stayed accepted.
Expiry Is a Transition Too, Not a Filter
Quotes have a shelf life — valid_until. The lazy way to handle it is a WHERE valid_until < now() filter at read time, showing lapsed quotes as "expired" in the UI. That's a lie the moment you look closely: the deal is still quoted in the database, still technically acceptable through the API, and still sitting in the accept path. A buyer with a stale link can approve a price you no longer offer.
Expiry is a real transition, so make it one. A scheduled job walks quotes past their date and moves them properly:
// lib/deal/expire.ts — run on a schedule
export async function expireLapsedQuotes() {
const lapsed = await db.query.deals.findMany({
where: and(eq(deals.status, "quoted"), lt(deals.validUntil, new Date())),
});
for (const deal of lapsed) {
await transitionDeal(deal.id, "expired", SYSTEM_ACTOR); // guarded, logged
}
}Now expired is a genuine state with an event row, not a display trick. The accept endpoint rejects it because canTransition("expired", "accepted") is false — the same guard that protects every other move. There's no second, weaker code path where a stale quote can slip through, because there's only ever one path: transitionDeal.
Where This Is Overkill
Reaching for the full machine reflexively is its own mistake, so be honest about the threshold.
If your buyers just add to cart and pay — a self-serve B2B store with published, per-account prices and no negotiation — you don't have a quote-to-order flow. You have a checkout. The Pi-Pi e-commerce platform is exactly that case for its routine SKUs: the buyer validates a VAT number, sees their price, pays, and an invoice is generated. No quote state, no acceptance step, no conversion. Bolting a state machine onto that adds ceremony no one asked for.
The flow earns its keep when three things are true at once: prices are negotiated per deal rather than published, the buyer needs a formal quote to approve internally before committing, and the accepted numbers have to survive unchanged into the order and the paperwork. That's wholesale and contract B2B — the world the Pi-Pi deal portal was built for. Below that bar, a cart and a status field are plenty; don't build a workflow to solve a checkout.
The Whole Thing in One Glance
| Concern | Mechanism |
|---|---|
| Legal states | One closed DealStatus union — no boolean combinations that can't exist |
| Legal moves | ALLOWED transition table; ordered/rejected/expired are terminal |
| Every state change | Funnelled through transitionDeal — guarded, transactional, logged |
| Quote vs order | One deals record in different states, not two tables to reconcile |
| Line integrity | description + unit_price snapshotted when priced, never re-read live |
| Editing | Gated by status (draft only); reopen to change a sent quote |
| Conversion | Copy, don't re-derive — order IS the accepted quote plus an order number |
| Idempotent order no. | orderNumber ?? next() in a transaction — retries can't double-allocate |
| Expiry | A real transition run on a schedule, not a read-time WHERE filter |
Takeaways
-
Model the deal as one closed set of states, not a bag of booleans. Names like
quoted,accepted,orderedcarry the business rules. A union you can't put in an impossible combination beats three flags you can. -
Put the rules in a transition table and refuse illegal moves at the boundary.
canTransitioninside a database transaction is where races are settled and where "you can't accept an expired quote" becomes a409, not a hope about the UI. -
A quote and an order are one record, not two. Separate tables mean copying, and copying means drift. Keep one set of line items and change the state, not the storage.
-
Snapshot at pricing time; never re-read at conversion. The order is the accepted quote promoted, not recomputed. Re-resolving live data "to be safe" is how the invoice ends up disagreeing with what the buyer signed.
-
Make expiry a transition, not a filter. A quote that only looks expired in the UI is still acceptable through the API. One code path — the guarded transition — or a stale link becomes a price you no longer honour.
If you're building a B2B store where buyers request a quote before they order, the workflow underneath — the states, the transitions, and a conversion that can't drift — is the part worth getting right before you style a single button. It's the kind of work I do on e-commerce projects, and on a contract-B2B system it's the difference between an order that matches the quote and an invoice someone has to apologise for.









