Order API taxonomy
How an order is captured, priced, fulfilled and settled.
Wine2You — Order API Taxonomy
Version: 2.1 · Owner: Wine2You Platform · Status: model update (supersedes v1.0; v2.1 adds §13 Data-exposure controls) Scope: how an order is captured, priced across a selected offer, fulfilled (estate-first or indirect), documented, settled and reported. Companion documents: PIM Product API Taxonomy, Partner Onboarding.
One sentence. An order is the API representation of the flash sell-buy-sell: at the instant a consumer buys, Wine2You purchases the wine from the offer that was selected (at that originator's
seller_price) and sells it to the consumer (at the destination landed price) — atomically. Which offer was selected, and who fulfils it, is now an explicit part of the order.
1. Overview & design principles
| Principle | Meaning for the API |
|---|---|
| Two sales in one instant | Platform is buyer from the selected offer's originator and seller to the consumer, simultaneously. |
| Offer-aware buy leg | The buy leg is no longer "the estate" by assumption — it is whichever offer won routing (§ estate-first, then other inventories). |
| Estate-first routing | Prefer estate stock when the estate is on W2Y; otherwise route to other enabled inventories (indirect sourcing, gated by feed-curation permissions). |
| Roles drive who acts | Originator (supply) · Fulfiller (pick/pack/ship) · Seller (own orders). One order may involve several partners in different roles. |
| Split by source & by role | An order may split across stock sources (each = a fulfilment leg) and across fulfillers (whoever each source's stock belongs to). |
| Event-driven | Downstream services subscribe to order.placed, order.confirmed, stock.reserved, … rather than poll. |
| Atomic or not at all | Buy and sell both succeed or neither — a saga with compensation. |
| Idempotency | Every write takes Idempotency-Key; a retried create returns the existing order. |
| One endpoint, not N | Every partner (any role) hits one order surface; the platform fans out internally. |
2. Resource map
| Resource | Verb endpoints (REST) | Key fields | Notes |
|---|---|---|---|
| Quote (pre-order) | POST /v1/orders/quote-calculate |
destination, lines[], → candidate offers, financial_breakdown, settlement_split |
Stateless pricing preview; returns offer candidates + routing. |
| Order | POST /v1/orders, GET /v1/orders/{id}, GET /v1/orders?partner=&status=, POST /v1/orders/{id}/cancel |
see §3 | Core object. |
| Order line | embedded | sku, wine_id, offer_id, qty, p2b (P_B2B from offer), unit_price_net, tax_breakdown |
offer-bound. |
| Routing / sourcing | GET /v1/orders/{id}/sourcing |
priority, source[], split_reason |
Which offers/stock won, and why. |
| Fulfilment leg | GET /v1/orders/{id}/fulfillments |
location_id, fulfiller_id, items[], carrier, tracking[] |
One per source/location. |
| Document | GET /v1/orders/{id}/documents, GET /v1/documents/{id} |
type, language, url |
Generated set (§7). |
| Pickup | POST /v1/orders/{id}/pickup, GET /v1/orders/{id}/pickup |
date, time_window, carrier_ref, location_id |
Per source location. |
| Settlement | GET /v1/orders/{id}/settlement |
lines[], per-partner payouts, state, payout_date |
Payouts to originator(s) + fulfiller(s) + seller. |
| Compliance record | GET /v1/orders/{id}/compliance |
delivery-register fields | Per-destination filing. |
| Inventory snapshot | GET /v1/inventory/network-snapshot |
node + stock + duty context | Read side at capture. |
3. The order object taxonomy
| Field | Type | Notes |
|---|---|---|
order_id |
string | opaque |
status |
enum | see §4 |
channel |
enum | marketplace | webshop_connect | pos_qr |
consumer |
object | {consumer_id, name, email, address, country} |
seller |
object | {partner_id, roles[], legal_name} |
platform_entity |
object | merchant of record |
lines[] |
array | {sku, wine_id, offer_id, qty, box_units, p2b, unit_price_net, tax_breakdown} |
sourcing |
object | {priority, source[], split_reason} |
fulfillments[] |
array | one per source/location, each with fulfiller_id |
destination_country |
ISO | drives VAT + excise + documents |
currency |
ISO | |
financial_breakdown |
object | §8 |
settlement_split |
object | §8 |
documents[] |
array | §7 |
compliance |
object | §9 |
created_at / updated_at |
timestamp |
4. Order lifecycle state machine
States (enum):
draft (cart) → placed → paid → confirmed → sourced → reserved
→ fulfilled → in_transit → delivered → settled
Terminal / branch: cancelled, failed, refunded, returned
Transitions:
| From | To | Trigger |
|---|---|---|
draft |
placed |
consumer submits |
placed |
paid |
payment captured |
paid |
confirmed |
flash buy+sell committed (saga success) |
confirmed |
sourced |
routing resolved which offers/stock supply each line |
sourced |
reserved |
stock reserved at each source (ReservationItem) |
reserved |
fulfilled |
each fulfilment leg picked/packed/shipped |
fulfilled |
in_transit |
carrier scan |
in_transit |
delivered |
proof of receipt (all legs) |
delivered |
settled |
payouts released (shipment + X days) |
| any (pre-fulfilment) | cancelled |
consumer/partner cancels |
paid |
failed |
saga compensation |
post-delivered |
returned / refunded |
within complaint window |
sourced and reserved are new — they make multi-provider routing explicit rather than assumed.
5. Sourcing & routing logic (estate-first, then other inventories)
Routing runs at quote-calculate (preview) and confirmed → sourced (commit). Per line:
- Estate direct (priority 1). If the wine's estate is an Originator on W2Y and its offer is active with
sufficient
atp→ select estate offer. (Estate sales are preferred.) - Other inventories (priority 2). Otherwise (or if estate ATP is short) select another active offer whose
originator permits the transaction:
permissions(allowed countries / named sellers / defaultany) +network_shareable+ destination eligibility must pass. This is indirect sourcing, gated by feed-curation. - Split. If no single offer covers the quantity, allocate across offers (cheapest/compliant first, or
explicit policy) → each allocation becomes its own
sourcing.source[]entry and its own fulfilment leg.
sourcing object:
{ "priority": "estate_first",
"split_reason": "estate_atp_short", // or "multi_originator_policy"
"source": [
{ "offer_id": "off_estate_1", "originator_id": "partner_estate_x",
"qty": 4, "p2b": 18.00, "incoterm": "FCA", "fulfiller_id": "partner_estate_x" },
{ "offer_id": "off_importer_1", "originator_id": "partner_importer_ch",
"qty": 2, "p2b": 20.50, "incoterm": "DAP", "fulfiller_id": "partner_3pl_ch" } ] }
Each source's p2b = that offer's seller_price → the line's P_B2B in the waterfall. A line split across
offers carries one p2b per source.
6. Fulfilment roles & split legs
- Fulfiller = the partner who picks/packs/ships. By default the stock owner fulfils their leg; an
originator that enables
fulfills_ordersships its own; a 3PL/Fulfillerpartner can ship on behalf of a source. - Split fulfilment: one order → N fulfilments, each bound to
location_id+fulfiller_id+ its items. Each has its own pickup, carrier, tracking and delivery scan. (Medusa v2: an order → manyFulfillment.) - Reservation: at
reserved, aReservationItemper source/location holds stock; on fulfilment creation it decrementsstocked_quantity; on cancel it releases. This is the back-to-back dropship primitive. - Seller vs Fulfiller: the Seller owns the consumer relationship and price; the Fulfiller executes physical delivery for the source it holds. One partner may be both (an Estate = Originator + Fulfiller + Seller).
7. Pricing & financial taxonomy
The landed-price waterfall (P_final, per destination) — P_B2B now comes from the selected offer:
P_final = P_B2B + R_Recruiter + M_Curator + F_Platform + C_Freight + E_Excise + V_VAT
| Component | Meaning | Source |
|---|---|---|
P_B2B |
originator's seller_price — willing-to-sell, pre tax/duty/platform/fee/shipping |
the winning offer (§5) |
R_Recruiter |
recruiter/agent royalty, on every channel | partner config |
M_Curator |
Seller's own margin — shelf price over offer (0 on an originator's own shop) | listing |
F_Platform |
Wine2You transaction/service fee | platform |
C_Freight |
carrier freight by destination + box size (6/12) + incoterm | quote |
E_Excise |
destination excise (category × volume × ABV) | tax engine |
V_VAT |
destination VAT on the taxable sum | tax engine |
financial_breakdown (what the consumer pays):
{ "currency": "NOK",
"lines": [
{ "component": "P_B2B", "amount": 18.00, "offer_id": "off_estate_1" },
{ "component": "M_Curator", "amount": 4.20 },
{ "component": "F_Platform", "amount": 1.50 },
{ "component": "C_Freight", "amount": 6.00 },
{ "component": "E_Excise", "amount": 12.40 },
{ "component": "V_VAT", "amount": 9.48 } ],
"total": 51.58 }
settlement_split (who gets what — now per partner, since sources/sellers/fulfillers differ):
{ "currency": "EUR",
"parties": [
{ "partner_id": "partner_estate_x", "role": "originator", "amount": 72.00, "basis": "p2b × 4" },
{ "partner_id": "partner_importer_ch", "role": "originator", "amount": 41.00, "basis": "p2b × 2" },
{ "partner_id": "partner_curator_ch", "role": "seller", "amount": 25.20, "basis": "M_Curator" },
{ "partner_id": "partner_3pl_ch", "role": "fulfiller", "amount": 6.00, "basis": "fulfilment fee" } ],
"platform_fee": 1.50,
"escrowed_taxes": { "excise": 12.40, "vat": 9.48 } }
Currency & rounding: consumer charged in destination currency; internal ledger in EUR. Round half-up to the destination's minor unit; tax components rounded per line, summed (never rounded twice).
8. Documents taxonomy
| Document | Issued by | Received by | Language | In parcel? |
|---|---|---|---|---|
| Consumer invoice | Platform (Seller as merchant) | Consumer | destination | yes |
| Transport / accompanying document | Platform | Customs / carrier | destination | yes |
| Carrier label (per leg) | Platform | Carrier | — | affixed per parcel |
| Originator proforma | Platform | Originator(s) of each source | — | no |
| Originator final invoice | Originator | Platform | — | no; matches per-source leg |
| Fulfiller instruction | Platform | Fulfiller(s) | — | no |
All generated from the same order data; a split order emits per-source originator docs and per-leg labels.
9. Compliance & reporting taxonomy
Delivery-register record (per delivery): date · consignee · delivery_address · product_description · excise_category_code · commodity_code (ch.22) · units_delivered · unit_capacity_litres · abv.
- Excise: declared/paid in the destination monthly via the local fiscal representative.
- VAT: Union OSS or local registration per destination.
- Non-EU (Norway): separate VAT + excise; prepaid import MVA (25%), Alkoholavgift (
V × ABV/100), packaging fees; TVINN pre-clearance; Vipps age/identity verification. - Per-order volume caps: e.g. 90 L still / 60 L sparkling per consumer (destination-dependent).
- Split orders: filings aggregate across legs into one destination declaration; each leg still carries its originator for the transport document.
10. Settlement & payout taxonomy
- Trigger: payout released at shipment date + X days (per leg; earliest leg can gate). Complaint window 7 days after delivery.
- Payout states:
pending → eligible → paid → (clawed_back), per party (originator, seller, fulfiller). - VAT on originator→platform invoice: cross-border → reverse charge; consumer in originator's own country → originator invoices with its own national VAT.
11. Webhooks & events taxonomy (proposed)
| Event | Fires when |
|---|---|
order.placed / order.paid |
consumer submits / payment captured |
order.confirmed |
flash buy+sell committed |
order.sourced |
routing resolved offers for each line |
stock.reserved |
reservation created per source |
order.fulfilled / order.picked_up / order.delivered |
per-leg fulfilment events |
order.cancelled / order.failed |
cancel / saga compensation |
order.settled |
payouts released |
document.generated |
document set ready |
settlement.released |
payout executed |
At-least-once, signed, Idempotency-Key = event id; exponential backoff + dead-letter queue.
12. Proposed core endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/orders/quote-calculate |
candidate offers + routing + financial_breakdown + settlement_split |
POST |
/v1/orders |
create order (flash buy+sell saga, multi-source) |
GET |
/v1/orders/{id} |
order detail incl. sourcing, legs, split, documents |
GET |
/v1/orders/{id}/sourcing |
which offers/stock won and why |
GET |
/v1/orders/{id}/fulfillments |
per-source fulfilment legs |
GET |
/v1/orders |
list (filter by partner/role, status, date) |
POST |
/v1/orders/{id}/cancel |
cancel (pre-fulfilment) |
POST |
/v1/orders/{id}/pickup |
request carrier pickup (per location) |
GET |
/v1/orders/{id}/documents |
document set |
GET |
/v1/orders/{id}/settlement |
per-party payouts + state |
GET |
/v1/inventory/network-snapshot |
node capability + coords + stock + duty context |
13. Data-exposure controls (anti-scrape)
Normative detail (C1–C4):
Wine2You-Data-Exposure-Controls-Spec.md.
Problem. The read side of the order/inventory surface —
GET /v1/orders?…and especiallyGET /v1/inventory/network-snapshot— can be page-walked by a legitimate key until the caller holds the fullofferpicture. These controls make partial data the only data a key can ever hold. (Same controls as PIM §14; restated here so the order surface is self-contained.)
13a. Read-path scoping (primary defence): every list/detail read is filtered server-side before
serialization — an order/offer row is returned only if network_shareable is true, the caller's entity
passes the offer's permissions (allowed countries / named sellers), and the caller's channel/tenant
scope admits it. Entity-scoping says who you are; this filter says what you may see.
13b. Bounded pagination: cursor pagination with a hard page_size cap (default 50, max 200).
GET /v1/inventory/network-snapshot — the whole-network read — requires an explicit read:network
scope that ordinary partner keys do not receive.
13c. Per-key row quota: a records-returned quota per rolling period (e.g. 50k rows/day) per entity,
in addition to the request rate limit; exceeding it returns 429 + Retry-After without leaking whether
more rows exist.
13d. Audit & anomaly: every read logs key_id, entity_id, endpoint, rows_returned,
cursor_span. A key pulling N× its trailing-average volume raises an ops alert.
14. Worked example — estate-first, then split for the short 2 bottles
Quote request:
POST /v1/orders/quote-calculate
{ "destination_country": "NO",
"seller": "partner_curator_ch",
"lines": [{ "sku": "CAN-BAR-2019-750", "qty": 6, "box_units": 6 }] }
Quote response (routing preview):
{ "currency": "NOK",
"sourcing": {
"priority": "estate_first", "split_reason": "estate_atp_short",
"source": [
{ "offer_id": "off_estate_1", "originator_id": "partner_estate_x", "qty": 4, "p2b": 18.00 },
{ "offer_id": "off_importer_1", "originator_id": "partner_importer_ch", "qty": 2, "p2b": 20.50 } ] },
"financial_breakdown": { "lines": [
{ "component": "P_B2B", "amount": 18.00, "offer_id": "off_estate_1" },
{ "component": "M_Curator", "amount": 4.20 },
{ "component": "F_Platform", "amount": 1.50 },
{ "component": "C_Freight", "amount": 6.00 },
{ "component": "E_Excise", "amount": 12.40 },
{ "component": "V_VAT", "amount": 9.48 } ],
"total": 51.58 },
"settlement_split": { "parties": [
{ "partner_id": "partner_estate_x", "role": "originator", "amount": 72.00 },
{ "partner_id": "partner_importer_ch", "role": "originator", "amount": 41.00 },
{ "partner_id": "partner_curator_ch", "role": "seller", "amount": 25.20 } ],
"platform_fee": 1.50 } }
Lifecycle (illustrative):
| t | State | Event |
|---|---|---|
| 09:00 | placed → paid → confirmed |
flash buy+sell committed atomically |
| 09:00 | sourced |
estate fills 4, importer fills 2 (estate-first, then other inventory) |
| 09:01 | reserved |
2 × ReservationItem (estate cellar + CH 3PL) |
| 09:01 | fulfilled |
two legs picked/packed/shipped by their fulfillers |
| +3 d | in_transit → delivered |
both legs delivered; Vipps age-check pre-release |
| +10 d | settled |
estate paid 72.00, importer 41.00, seller 25.20, 3PL fee released |
15. Best-practice notes
- Idempotency keys on create/pay/pickup — checkout retries and carrier webhooks never double-create/double-pay.
- Why
sourcedis explicit: estate-first vs indirect is a decision with reasons, not an assumption. Recording it makes split/routing auditable and lets ops re-run routing when a source fails. - Per-party settlement: once one order can involve several originators, a fulfiller and a seller, a single
supplier_payoutfield is wrong — payouts must be modelled per party from the start. - Why the flash must be atomic: model as
reserve_source_stock → charge_consumer → commit_buy → commit_sell, with compensation unwinding any completed leg. With multiple sources, each buy leg compensates independently. - Reconciliation: nightly compare orders ↔ sourcing ↔ settlement ↔ compliance; a paid order with no filed duty, or a filed duty with no paid order, is an ops alert.
- Bounded reads:
network-snapshotand order listing are page-walkable by design — guard them with the read-path filter, bounded cursors and a per-key row quota (§13) so no key can reconstruct the fulloffertable even over many requests.