Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, IT partner for business & fractional CTO. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. B2B Quote-to-Order Flow in Next.js: A State Machine That Doesn't Drift

Iurii Builds: B2B Quote-to-Order Flow in Next.js: A State Machine That Doesn't Drift

A quote is a promise and an order is a commitment. The engineering is keeping the two in sync — one line-item model, explicit transitions, and a conversion that copies instead of re-reads.

August 7, 2026· 10 min read

B2B quote-to-order flow in Next.js: an RFQ → quote → order state machine, per-deal line items, guarded status transitions, and converting an accepted quote without data drift.

Stack

Next.jsReactTypeScript

Databases

PostgreSQL

Topics

B2BE-commerceArchitectureSSRSales Automation
B2B Quote-to-Order Flow in Next.js: A State Machine That Doesn't Drift

On this page

  • The States, Named Out Loud
  • Transitions Are the Real Model
  • One Line-Item Model, From Draft to Order
  • Editing Is Gated by State, Not by Page
  • The Conversion: Copy, Don't Re-Read
  • Expiry Is a Transition Too, Not a Filter
  • Where This Is Overkill
  • The Whole Thing in One Glance
  • Takeaways

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; terminal

Six 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

ConcernMechanism
Legal statesOne closed DealStatus union — no boolean combinations that can't exist
Legal movesALLOWED transition table; ordered/rejected/expired are terminal
Every state changeFunnelled through transitionDeal — guarded, transactional, logged
Quote vs orderOne deals record in different states, not two tables to reconcile
Line integritydescription + unit_price snapshotted when priced, never re-read live
EditingGated by status (draft only); reopen to change a sent quote
ConversionCopy, 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
ExpiryA real transition run on a schedule, not a read-time WHERE filter

Takeaways

  1. Model the deal as one closed set of states, not a bag of booleans. Names like quoted, accepted, ordered carry the business rules. A union you can't put in an impossible combination beats three flags you can.

  2. Put the rules in a transition table and refuse illegal moves at the boundary. canTransition inside a database transaction is where races are settled and where "you can't accept an expired quote" becomes a 409, not a hope about the UI.

  3. 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.

  4. 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.

  5. 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.

Iurii Rogulia

Working on something like this?

E-commerce

Building a B2B store where buyers request a quote before they order? The workflow behind that — states, transitions, and a conversion that can't drift — is the part I like getting right from the start. Happy to help.

More about this service

Relevant client work

View all projects
pi-pi.ee — B2B Deal & Document Portal
pi-pi.ee — B2B Deal & Document Portal
July 1, 2026
pi-pi.ee — B2B Deal & Document Portal

Internal sales portal that turns a wholesale deal into a full set of trade paperwork — pro forma, contract, commercial invoice, packing list, CMR and more —

pi-pi.ee — B2B E-commerce for Waterless Urinal Systems
pi-pi.ee — B2B E-commerce for Waterless Urinal Systems
January 17, 2026
pi-pi.ee — B2B E-commerce for Waterless Urinal Systems

i18n B2B e-commerce platform for waterless urinal products across 32 European countries with automated VAT handling, PDF invoicing, and CRM integration.

Pikkuna — E-commerce for Vinyl Curtains & PVC Products
Pikkuna — E-commerce for Vinyl Curtains & PVC Products
October 12, 2024
Pikkuna — E-commerce for Vinyl Curtains & PVC Products

International e-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe → Zoho CRM → Airtable → Mailgun →

What clients say

“

I'd built most of our MVP with Cursor and it looked finished — it compiled, the tests were green, the demo worked. It just wouldn't survive real users. Iurii took it over and found what I couldn't see: authentication handled four different ways, tests that only asserted what the code already did, and a dependency list half of which was unused. He didn't rewrite it from scratch — he told me honestly what was salvageable, ripped out the dead code, and got it to something a real team could build on. Two weeks and it went from 'looks done' to actually shippable.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
“

I had a validated idea and a deadline tied to an accelerator demo day, but no product. Iurii helped me cut the scope down to what actually needed to exist for launch and pushed back hard on the features I thought I needed but didn't. We shipped a working MVP in six weeks with real users on it by demo day. The honest scoping at the start is what saved the timeline — it would have been easy to let me build twice as much and miss the date.

Aino Virtanen 🇫🇮

Founder

Stack

Next.jsTypeScript

Databases

PostgreSQL

Topics

MVPProductScopeSaaS
“

We wanted to add an AI feature that turns messy user notes into structured records, but our first attempt returned unpredictable JSON that broke the app half the time. Iurii rebuilt it using structured outputs against the OpenAI API with proper validation, so the data is always shaped the way our database expects. He also added a fallback path for when the model is unsure instead of letting it guess. It's been running in production for a month with no manual cleanup.

Bram de Vries 🇳🇱

Product Lead

Stack

Next.jsTypeScript

Services

OpenAI

Topics

AILLMStructured Outputs

Related articles

Gated B2B Pricing in Next.js: Hiding Prices Behind Per-Customer Access
July 24, 2026· 9 min
Gated B2B Pricing in Next.js: Hiding Prices Behind Per-Customer Access

Gated B2B pricing in Next.js: per-account price lists, server-side access control so prices never leak to crawlers, and a deal-record data model where every

Stack

Next.jsReactTypeScript

Databases

PostgreSQL

Topics

B2BE-commerceAuthSSRArchitectureSecurity
Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026· 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis

Topics

E-commercePaymentsArchitectureSaaS
How to Add AI to an Existing Product Without Rewriting It
May 4, 2026· 11 min
How to Add AI to an Existing Product Without Rewriting It

Add AI to an existing product without a rebuild. Three integration patterns, how to pick the right one, and what production-ready AI actually demands.

Stack

Next.jsTypeScript

Libraries

Vercel AI SDK

Databases

PostgreSQLRedisUpstash Vector

Services

OpenAI

Topics

RAGArchitectureSaaSE-commerce
Subscription Billing: The Edge Cases Stripe Docs Skip
July 22, 2026· 17 min
Subscription Billing: The Edge Cases Stripe Docs Skip

Subscription billing edge cases Stripe glosses over: proration, dunning, the state machine, cancellation timing, refunds, VAT, and webhook ordering.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQL

Services

Stripe

Topics

SaaSPaymentsE-commerceBilling
Build an Internal CRM on Supabase: A Weekend-Scale Guide
June 24, 2026· 21 min
Build an Internal CRM on Supabase: A Weekend-Scale Guide

Build an internal CRM on Supabase: schema design, RLS policies, Next.js App Router frontend, realtime subscriptions, and an honest look at where the weekend

Stack

Next.jsTypeScript

Libraries

Drizzle ORM

Databases

PostgreSQL

Services

Supabase

Topics

CRMArchitectureSaaSInternal Tools