Dentolize · Suppliers (Marketplace) Walkthrough
On this pageBusiness viewTechnical view

Order Lifecycle & Accounting

Business view

When a clinic places an order with a supplier, that order needs to move through real-world fulfilment steps — the supplier has to acknowledge it, get it ready, ship it, and mark it delivered. Each step is visible to both sides: the supplier sees it in their order inbox and updates the status as work happens; the clinic sees the same status update on their own Orders tab.

The money side happens automatically and independently on each side:

  • The moment a supplier marks an order shipped or delivered, Dentolize recognizes that revenue in the supplier's own books — no separate "create an invoice" step.
  • If a fulfilled order is later cancelled, that revenue entry is reversed.
  • When the clinic receives the order into stock, Dentolize books the purchase on the clinic's side — inventory goes up, and a supplier bill (expense) is recorded — in one click.

Each side's books are independent. There's no shared ledger between the two companies; the supplier posts a sale, the buyer posts a purchase, and neither entry references or depends on the other beyond both pointing at the same TradeOrder record. This matches Dentolize's existing multi-tenant model, where every company keeps its own books.

Technical view

The seller state machine (packages/server/src/supplier/orderService.js:22-33)

DRAFT      → PLACED, CANCELLED
PLACED     → ACCEPTED, REJECTED, CANCELLED
ACCEPTED   → PREPARING, SHIPPED, CANCELLED
PREPARING  → SHIPPED, DELIVERED, CANCELLED
SHIPPED    → DELIVERED
DELIVERED  → (terminal, via this endpoint)
RECEIVED   → (terminal, via this endpoint)
CLOSED     → (terminal, via this endpoint)
CANCELLED  → (terminal)
REJECTED   → (terminal)

This is looser than a strict five-step chain — a few nuances worth knowing:

  • PREPARING is optional in both directions: an accepted order can jump straight to SHIPPED, and a preparing order can jump straight to DELIVERED.
  • REJECTED is only reachable from PLACED — once a supplier has accepted an order, they can no longer reject it, only cancel it.
  • CANCELLED is not reachable from SHIPPED. Once an order ships, transitionSupplierOrder only allows moving it to DELIVERED — there is no seller-side "cancel after shipping" path through this mutation.
  • placeTradeOrder (the only order-creation path in this branch) creates orders directly at PLACED, never DRAFT — so the DRAFT row in the table above is presently unreachable code from this branch (likely intended for an RFQ-award creation path owned by the sibling marketplace-core branch).
  • RECEIVED and CLOSED are never set by this state machine at all — RECEIVED is set exclusively by the buyer-driven receive bridge (see Buyer Procurement), and CLOSED isn't set anywhere in this branch.

Every transition is checked against this table (transitionSupplierOrder, orderService.js:123-145): tenancy first (vendorCompanyId must match the caller), then nextStatus must be in the allowed list for the order's current status, or it throws ` Cannot move order from ${order.status} to ${nextStatus} `.

A gap worth documenting: the code also contains a reversal branch — "if the order was fulfilled (SHIPPED/DELIVERED) and moves to CANCELLED or REJECTED, reverse the revenue entry" (orderService.js:140-142). But given the transition table above, there is no coded path that lets an order reach CANCELLED/REJECTED after reaching SHIPPED/DELIVEREDSHIPPED's only allowed next state is DELIVERED, and DELIVERED has no outgoing transitions. So while the reversal logic itself is real, tested, and correct (see below), it is currently unreachable through this seller-transition endpoint. If a genuine post-shipment cancellation workflow is needed later, either the transition table needs a SHIPPED → CANCELLED (or similar) entry, or reversal happens through a different path than this one.

Revenue recognition (packages/server/src/accounting/posting/supplierOrder.js)

There's no dedicated JournalSource enum value for the trade platform (the foundation migration froze the schema — see docs/trade-platform/README.md), so this reuses the existing sale-shaped INVOICE journal source with a distinct sourceType: 'SupplierOrder', which is what actually keys idempotency/dedupe — it never collides with a real clinic invoice.

Posted lines, in net-of-VAT convention (deriveSupplierOrderLines, supplierOrder.js:25-41):

netRevenue = subtotal + delivery − discount
vat        = tax

Dr Accounts Receivable        netRevenue + vat
  Cr Marketplace Sales Revenue  netRevenue
  Cr VAT Payable                vat

Dr COGS / Cr Inventory          cost   (only emitted when a cost is resolvable)

AR is deliberately derived as netRevenue + vat rather than read from the order's own stored total, so the entry is guaranteed balanced no matter how the order's total was rounded upstream. The COGS/Inventory pair is independently balanced and only appears when cost is known, so its absence never unbalances the revenue entry — this is how a supplier who doesn't run perpetual inventory still gets a clean (COGS-less) revenue entry.

Cost is best-effort (deriveOrderCogs, supplierOrder.js:112-127): for each order line that maps to a catalogProductId, it looks up the supplier's own InventorySubItem for that product, and values it at stock.value / stock.amount (i.e., the supplier's own average unit cost). A line with no matching or zero-quantity stock contributes 0 — silently, no error.

Posting fires from transitionSupplierOrder whenever the new status is SHIPPED or DELIVERED (POSTING_STATUSES, orderService.js:20) — it fires every time that happens, including e.g. SHIPPED → DELIVERED, but postSourceCreated is itself idempotent (no-ops if a journal entry already exists for that sourceType+sourceId), so this is safe rather than a double-post.

Posting is decoupled from the status-change transaction via safePost (accounting/resilientPosting.js) — a GL misconfiguration (e.g. a missing account mapping) never blocks or rolls back the operational status update; the posting attempt is retried and, on persistent failure, parked for a background retry sweep (postingHandlers.js registers 'SupplierOrder'loadAndPostSupplierOrder).

Another nuance: the retry/resilient-posting path's "is this order still fulfilled" guard (loadAndPostSupplierOrder, supplierOrder.js:134-162) accepts SHIPPED, DELIVERED, RECEIVED, and CLOSED — broader than the live transition trigger, which only fires on entering SHIPPED/DELIVERED. In practice this means a parked posting can still succeed even after the order has moved on to a buyer-driven RECEIVED/CLOSED state.

Accounts used

RoleDefault codeNotes
AR1210Accounts Receivable
MARKETPLACE_SALES_REVENUE4150Falls back to the generic REVENUE role's account if 4150 isn't provisioned
VAT_PAYABLE2200
COGS5100
INVENTORY1300

MARKETPLACE_SALES_REVENUE and a sibling LAB_SERVICES_REVENUE (4160, used by the labs branch, not this one) were added to the chart-of-accounts role map by the shared trade-platform foundation, not by this PR.

Verified by test (packages/server/src/accounting/__tests__/supplierOrder.test.js)

  • Revenue + VAT balances with no COGS pair when cost is unknown.
  • With delivery, discount, and COGS, the entry still balances (Dr AR 1092.5 = Cr revenue 950 + Cr VAT 142.5; Dr COGS 600 = Cr Inventory 600).
  • Reversal (debit/credit swap) nets to exactly zero — the "post → reverse Δ0" guarantee referenced in the PR description.
  • Every role the derivation emits exists in the posting engine's role map (a regression guard against introducing an unmapped role).

Per the PR description, the babel-node accounting test harness (__tests__/run.js) is pre-existing-broken in this tree due to a Prisma 7 .ts client import chain unrelated to this PR (the original money.test.js fails identically) — the pure-logic assertions above were verified with a runner that avoids that import chain, and are wired into the suite for when the harness is fixed.