Wine2You-PIM-Product-API-Taxonomy

Wine2You — PIM Product API Taxonomy

Version: 2.0 · Owner: Wine2You Platform · Status: model update (supersedes v1.0) Scope: the Product Database — canonical wines, offers (supply), inventory, roles, and how a partner publishes them. Companion documents: Order API Taxonomy and Partner Onboarding.

Design thesis. A wine is an independent fact about the world — a complete, deduplicated register of every wine Wine2You knows it can source, enriched from external databases. It exists with or without any offer. An offer is one Originator's supply listing on that wine (stock + a willingness-to-sell price + selling permissions). A Seller layers its own price on top of the offers it curates. Three things stay separate and never duplicate: the canonical wine (descriptive), the offer (supply/commercial), and the listing (a Seller's shelf price). Inventory is a fourth, fully decoupled concern.


1. Overview & design principles

Principle What it means for the API
Canonical wine is independent wines holds a complete, deduplicated list of known wines from any source (external DBs, estates, feeds). A wine may have zero offers. Never created per-supplier.
Deduplication is mandatory One wine row per real-world wine. Two suppliers of the same wine = two offers, one wine. Matching is by tiered keys (§3), enforced at ingest.
Offer = Originator's supply An offer is this originator will sell N units of wine W at seller_price P, permitted to these buyers. Uniqueness: wine_id + originator_id (+ condition).
Seller price sits on top The waterfall takes P_B2B from the selected offer's seller_price. A Seller's shelf price is a separate listing computed over that.
Roles, one onboarding A single onboarding flow; roles (Originator · Fulfiller · Seller) are enabled capabilities, not separate programs.
Inventory is decoupled Stock lives on (offer × location), not on the wine. ATP = on_hand − allocated − reserved.
Estate-first fulfilment Fulfilment prefers Estate sales when the estate is on W2Y; otherwise routes to other enabled inventories (indirect sourcing, gated by feed-curation).
API-first, storage-agnostic The contract is the API (OpenAPI 3.x DTOs). Payload/Medusa storage can change without breaking partners.

The three-way split (integrity contract):

wine (canonical, descriptive)  ──1:N──►  offer (originator supply: qty, seller_price, permissions)
                                              │
                                              ├──1:N──►  inventory levels (location × condition)
                                              └──N:1──►  listing (seller shelf price, on its own channel)

Monitored nightly: wines with no offer, offers with no stock, listings referencing missing offers → ops alert.


2. Resource map

Resource Verb endpoints (REST) Key fields Notes
Partner (entity) GET/POST/PATCH /v1/partners, GET /v1/partners/{id} id, legal_name, country, roles[], payout_*, status The onboarding record. One record, many roles (§7).
Wine (canonical) GET/POST/PATCH /v1/wines, GET /v1/wines/{id}, POST /v1/wines/{id}/publish, POST /v1/wines/match see §4–§5 The independent descriptive master. May have 0 offers.
External ID (crosswalk) GET/POST /v1/wines/{id}/external-ids source, external_id, url, confidence Match to Vivino/WS/CT/LWIN/VIVC/eAmbrosia (§3).
Offer (supply) GET/POST/PATCH /v1/offers, GET /v1/offers/{id}, POST /v1/offers/{id}/withdraw see §6 Originator's supply listing; carries seller_price.
Listing (shelf) GET/POST/PATCH /v1/listings, GET /v1/listings/{id} seller_id, offer_ref, channel_id, shelf_price, status A Seller's price over a curated offer.
Media (DAM) POST /v1/media (signed upload), GET /v1/media/{id} id, url, mime, width, height, alt, role Assets referenced, never inlined.
Stock location GET/POST /v1/stock-locations see §9 Physical siting (cellar, 3PL, bonded…).
Inventory item / level GET /v1/inventory?offer=&location= on_hand, allocated, reserved, atp Decoupled from wine and offer.
Sell permission GET /v1/offers/{id}/permissions allow/block by country[], named_sellers[], default any Originator's selling restrictions (§6).
Feed sync POST /v1/sync/push, POST /v1/sync/pull, GET /v1/sync/status direction, scope, cursor Two-way endless-isles feed (§8).
Sales channel GET/POST /v1/sales-channels id, seller_id, listings[] One storefront/seller shelf; governs visibility.
Curated shop GET/POST /v1/curated-shops id, seller_id, markup, layout, channel_id Tenant record: markup + layout.
Tax/duty context embedded + GET /v1/tax-context?origin=&destination= see §10 Computed per destination; not stored per wine.
Network snapshot GET /v1/inventory/network-snapshot node capability + coords + stock + duty context Read side of the whole network.

3. Identity, external matching & dedup taxonomy

3a. Two-level identity (industry standard)

wine (producer + cuvée)          ──1:N──►  vintage + format = SKU

Vivino (wine.id vs vintage.id), Liv-ex LWIN 7/11/16, CellarTracker (iWine + vintage) all split this way. Wine2You models it as wines (wine, vintage-independent identity) → wine_vintages/variants (vintage + format). One wine row; many vintage/format SKUs.

3b. Tiered match keys (how a wine is matched to external DBs)

Tier Key Use
1 GTIN / EAN-13 (per format, per vintage per GS1 rule 8.2.1) retail catalogue match; cases = GTIN-14. Bottle size & ABV are GDSN attributes, not in the barcode.
2 LWIN 7 (+ vintage → LWIN 11, + case → LWIN 16) fine-wine trade (Liv-ex, Jancis Robinson, merchants).
3 normalized natural key: producer + cuvée + vintage/NV + volume_ml + country universal fallback; slugify, strip diacritics, unify NV, normalize format units.
4 label-image match manual-review fallback (Vivino/Delectable pattern).

3c. External-ID crosswalk (stored per wine, refreshed by re-match)

gtin[], lwin, vivino_wine_id, vivino_vintage_id, wine_searcher_wine_id, cellartracker_iwine, wine_com_product_id, vivc_variety_number[], eambrosia_gi_id, eambrosia_gi_name. This is how a wine sourced from an external DB is linked, not re-keyed.

3d. Dedup rule at ingest

Before creating a wine: resolve Tier 1 → 2 → 3. On match, enrich the existing row (never insert). On no match, create. A POST /v1/wines/match endpoint returns matched | candidate | new with the tier used.

⚠ No "Wine Business Identifier" exists — that is a fabricated SEO artifact. Do not design to it.


4. Canonical wine record (descriptive & compliance)

The canonical wine is independent of offers and holds everything needed to be compliantly described and externally matched.

Group Field Type Notes
Identity title text required
slug text, unique, indexed URL key
producer ref → producer/estate who makes it (independent of who sells it)
cuvee / label_name text the wine name within the producer's range
medusa_product_id text, indexed the join to the commercial record
Vintage vintage int 1900–2099 per vintage SKU
is_non_vintage bool NV
nv_base_vintage_pct number? NV blend granularity (base-vintage %)
Colour & category colour enum red|white|rose|orange new axis — every external DB carries it
category enum → EU Annex VII Part II wine|sparkling|quality_sparkling|semi_sparkling|fortified|dessert|must|dealcoholised…
dosage_class enum? sparkling sugar class: brut_nature|extra_brut|brut|extra_dry|dry|demi_sec|sweet
Regulatory abv number 0–22 drives excise band + LPA
volume_in_liters number (0.375 | 0.75 | 1.5 | …) flexible formats, not a 3-value enum
hs_code text (default 2204.21.79) TARIC/HS customs code
Terroir country ISO
region → sub_region → appellation text hierarchy, multilingual
designation text PDO/PGI/traditional term
quality_scheme enum? PDO|PGI|GI_none join target = eAmbrosia
grape_varieties[] array {variety, vivc_number, percentage} VIVC-keyed for cross-border matching
EU compliance block bottler {name, address} object mandatory label particular
importer {name, address}? object for EU imports
lot_number text Directive 2011/91/EU
allergens[] enum sulphites, egg, milk (on physical label)
ingredients text? Reg 2021/2117, mandatory from 2023-12-08
energy_kj_per_100ml / energy_kcal_per_100ml number? mandatory; or e_label_url (QR)
nutrition_optional object? fats/carbs/sugars/protein/salt per 100 ml (voluntary)
Attributes certifications enum[] organic|biodynamic|natural|vegan|sustainable
Storytelling description richText
aroma_profile / palate_profile text
food_pairings[] array {pairing}
main_image / gallery[] upload → media DAM assets
Ratings ratings[] array {source, score, scale, url} crosswalk to Vivino/pro scores
Workflow status enum draft|published|archived publish gate

EU mandatory particulars (1308/2013 Annex VIII + 2019/33 + 2021/2117): category, ABV %, provenance (PDO/PGI), bottler, importer, nominal volume, lot, sulphites, dosage class, ingredients + energy value (label or e-label QR; allergens always physical). 85% rules for vintage / single-variety / sub-region. Wine labelling law = Reg 1308/2013 + Delegated Reg 2019/33 + Reg 1169/2011 + Reg 2021/2117 (not 2019/787 — that is spirit drinks).


5. Offer taxonomy (the Originator's supply listing)

An offer is what an Originator puts forward: "I will sell N units of wine W at seller_price P, to these buyers." Uniqueness: wine_id + originator_id (+ condition for mixed vintages of stock). Zero or many offers per wine.

Field Type Notes
offer_id string, opaque
wine_id ref → wine never duplicates wine data
originator_id ref → partner who supplies it (Estate / Collector / Stock-importer)
seller_price number the provider's "willing-to-sell" / export price — pre tax, duties, platform fees, shipping. This is the P_B2B input to the waterfall.
currency ISO
qty_available int headline stock (detail lives in inventory levels §9)
condition enum new|bin_end|mixed_vintage?
incoterm enum FCA/EXW/DAP… (who bears freight)
min_order_qty / case_size int? MOQ / case multiple
valid_from / valid_to timestamp? price validity window
permissions object selling restrictions (below)
status enum draft|active|paused|withdrawn
source enum direct|feed|external_match provenance
created_at / updated_at timestamp

Sell permissions (default any):

{ "default": "any",
  "allowed_countries": ["CH","DE","NO"],        // if present, only these
  "named_sellers": ["partner_abc"],              // if present, only these
  "blocked_countries": ["US"] }

Evaluated at listing time and re-checked at order capture.

Inventory is separate from offer — the offer carries headline qty_available; per-location truth is the inventory level (§9), so a wine can be sold from several physical pools.


6. Listing taxonomy (a Seller's shelf price)

A Seller curates offers and sets its own selling price on top. The listing is that layer — it never re-states wine or offer data.

Field Type Notes
listing_id string
seller_id ref → partner (role Seller)
offer_id ref → offer the supply being resold (or the Seller's own if also Originator)
channel_id ref → sales channel where it appears
shelf_price number + currency Seller's own price over the offer
curation object what the Seller surfaces (featured, notes, pairing)
status enum draft|live|delisted

Price layering: shelf_price (Seller) is built over seller_price (Originator's offer). The order-side waterfall consumes seller_price as P_B2B; the Seller's margin is its own listing layer (M_Curator-equivalent), not a mutation of the offer.


7. Partner roles (single onboarding, enabled capabilities)

One onboarding flow. Roles are enabled flags, not separate programs.

Role What it does Capability flags
Originator Provides data to the canonical wines DB and/or inventory to the offer table. Sub-types: Estate, Collector, Stock (e.g. importer). provides_wine_data, provides_inventory, permissions (allowed countries / named sellers / any).
Fulfiller An Originator that also does order fulfilment (pick, pack, ship). On by default for Originators; can also feed wines not yet in any offer. fulfills_orders, feeds_unoffered_wines.
Seller Generates its own sales orders. Sells own stock (if also Originator) and/or others' inventory (curating what it offers). Sets its own shelf price on the offer-feed. creates_orders, curates_offers, sets_shelf_price.

A partner record holds roles[] = any combination. Example: an Estate = Originator + Fulfiller (+ Seller if it sells direct). A dropship curator = Seller (+ Fulfiller). An importer = Originator (Stock).


8. Two-way endless-isles feed (sync taxonomy)

A partner can be a provider of stock and a receiver/curator of the endless-isles feed — both directions, independently enabled.

Direction Endpoint Payload Use
Outbound (platform → partner) POST /v1/sync/pull / webhook offers, wines, price/stock deltas, cursor partner receives & curates the feed
Inbound (partner → platform) POST /v1/sync/push new/updated wines + offers, upsert keys partner supplies stock / wine data
Status GET /v1/sync/status last_cursor, pending, errors reconciliation

9. Inventory taxonomy (decoupled from the wine)

Concept Meaning Key fields
Stock location one physical cellar/warehouse (per originator or 3PL) see location schema
Inventory item the stock-keeping record, decoupled from wine and offer sku, offer_id, owner_entity_id
Inventory level (item × location × condition) → quantities on_hand, incoming, reserved, allocated
ATP what can be sold right now atp = on_hand − allocated − reserved (calculated)

Ownership: each originator's stock sits at its own stock_location with owner_entity_id. Availability is scoped by the sales channel's linked locations (Medusa v2 reality — see §11), which is what enables cross-seller dropship: Seller B sells Estate A's stock with no warehouse.

⚠ is_network_shareable is not a Medusa v2 concept (verified: no such field). The real mechanisms are product ↔ sales_channel (M:N), sales_channel ↔ stock_location (M:N), and variant ↔ inventory_item (M:N). Wine2You models "shareable across the network" as its own flag on the offer (network_shareable), resolved against channel↔location links — do not cite a Medusa field that does not exist.


10. Location & tax/duty taxonomy

Location (LocationDataSchema): location_id, gln (GS1), location_type (WINERY_ESTATE|BONDED_WAREHOUSE|DUTY_PAID_WAREHOUSE|RETAIL_STORE|3PL_FULFILLMENT_CENTER|CONSIGNMENT_CELLAR|IN_TRANSIT_CONTAINER), address, geo_coordinates, tax_jurisdiction {country, state, excise_tax_zone_id, eu_seed_number}, capabilities {supports_d2c_fulfillment, supports_b2b_wholesale, supports_bonded_transfer, temperature_controlled}.

Tax/duty (TaxDutiesContextSchema): hs_tariff_code, country_of_origin, duty_status (DUTY_PAID|IN_BOND|DUTY_FREE|EXCISE_SUSPENDED|PRE_ARRIVAL), volumetric_metrics {volume_liters, net_weight_kg, gross_weight_kg, abv_percentage, liters_pure_alcohol}, tax_classification {sales_tax_category_code, vat_rate_type, excise_tax_category}, cross_border_terms {incoterm, import_duty_rate_percent, preferential_trade_agreement_code}, packaging_and_environmental_levies {container_deposit_fee, epr_recycling_category_code}.

LPA = volume_liters × abv_percentage / 100 — the excise base in every destination.


11. Medusa alignment (v2)

Wine2You concept Medusa v2 anchor Note
Canonical wine Product (title, handle, status, hs_code, origin_country, metadata) Product carries no price, no stock in v2.
Vintage + format SKU ProductVariant (sku, barcode, ean, upc, allow_backorder, manage_inventory) allow_backorder lives on the variant, not the level.
Buyer-facing price PriceSet → Price (currency_code, amount, min_quantity/max_quantity, rules) dimensions: currency, region, customer-group, quantity — not seller.
Seller/Originator price Wine2You Offer (seller_price) beyond Medusa core — a W2Y concept (§5).
Stock InventoryItem → InventoryLevel (stocked_quantity, reserved_quantity, available_quantity = calc) per location.
Back-to-back / dropship ReservationItem (reserve at order → deduct at fulfilment → release on cancel) the dropship primitive.
Channel / shelf SalesChannel (status: published + sales_channels[] link) per seller/storefront.
Multi-vendor Marketplace recipe: custom vendor module + links + vendor-admin auth + per-vendor channel + split-orders no vendor/ownership entity in core; created_by is audit-only.
Split fulfilment Fulfillment: FulfillmentSet, ServiceZone/GeoZone, ShippingOption, Fulfillment (an order → many) routing is app-level.
Wine attributes (colour, region tree, EU block) custom WineProfile module (Product has no EAV) typed & queryable, not metadata.

12. Fulfilment logic (estate-first, then other inventories)

At order capture, routing resolves stock by priority:

  1. Estate direct — if the wine's estate is an Originator on W2Y, prefer estate stock (estate sale).
  2. Other inventories — otherwise (or if estate ATP is short), route to other enabled offers/stock. Indirect sourcing is gated by feed-curation: an originator's permissions (allowed countries / named sellers / default any) and network_shareable must permit the selling partner + destination.
  3. Split — if no single source covers the quantity, split across sources (each becomes its own fulfilment leg from its own stock location).

Selection inputs: atp, seller_price, incoterm/freight, permissions, destination eligibility, and priority tier (estate > indirect). The chosen offer's seller_price becomes that line's P_B2B in the waterfall.


13. Webhooks & events taxonomy (proposed)

Event Fires when Consumer
wine.created canonical wine inserted (matched or new) search, dashboards
wine.matched ingest matched an existing wine via a tier crosswalk refresh
wine.published publish gate passed search, storefront, channel sync
offer.created / offer.updated / offer.withdrawn supply changes availability, sellers
listing.created / listing.price_changed seller shelf changes storefront, cached quotes
stock.changed inventory level moves availability
sync.completed a feed push/pull lands reconciliation
pim.reconciliation.flagged nightly join-integrity failure ops alert

Delivery: at-least-once, signed, idempotency key = event id.


14. Worked example — canonical wine with two offers and one listing

{
  "title": "Barolo DOCG — Cantina X",
  "slug": "barolo-docg-cantina-x",
  "producer": "prod_cantina_x",
  "cuvee": "Barolo DOCG",
  "medusa_product_id": "prod_01HX…",
  "vintage": 2019, "is_non_vintage": false,
  "colour": "red", "category": "wine",
  "abv": 14.5, "volume_in_liters": 0.75,
  "hs_code": "2204.21.79",
  "terroir": {
    "country": "IT", "region": "Piemonte", "sub_region": "Langhe",
    "appellation": "Barolo DOCG", "designation": "DOCG", "quality_scheme": "PDO",
    "grape_varieties": [{ "variety": "Nebbiolo", "vivc_number": 8885, "percentage": 100 }]
  },
  "eu_compliance": {
    "bottler": { "name": "Cantina X S.r.l.", "address": "…" },
    "lot_number": "L2019-0043", "allergens": ["sulphites"],
    "ingredients": "Grapes: Nebbiolo.", "energy_kj_per_100ml": 310, "energy_kcal_per_100ml": 74,
    "e_label_url": "https://…/e-label/L2019-0043"
  },
  "external_ids": [
    { "source": "gtin", "external_id": "8001234567890", "confidence": 1.0 },
    { "source": "lwin", "external_id": "1005767", "confidence": 0.98 },
    { "source": "vivino_wine_id", "external_id": "1105374", "confidence": 0.91 },
    { "source": "eambrosia_gi_id", "external_id": "PDO-IT-B0442", "confidence": 1.0 }
  ],
  "ratings": [{ "source": "vivino", "score": 4.3, "scale": 5 }],
  "status": "published",
  "offers": [
    { "offer_id": "off_estate_1", "originator_id": "partner_estate_x", "seller_price": 18.00,
      "currency": "EUR", "qty_available": 240, "incoterm": "FCA",
      "permissions": { "default": "any" }, "network_shareable": true, "status": "active" },
    { "offer_id": "off_importer_1", "originator_id": "partner_importer_ch", "seller_price": 20.50,
      "currency": "EUR", "qty_available": 60, "incoterm": "DAP",
      "permissions": { "allowed_countries": ["CH","NO"] }, "status": "active" }
  ],
  "listings": [
    { "listing_id": "lst_curator_9", "seller_id": "partner_curator_ch",
      "offer_id": "off_estate_1", "channel_id": "ch_shop_ch", "shelf_price": 34.90,
      "currency": "CHF", "status": "live" }
  ]
}

One canonical wine, two originator offers at different seller_price, one seller's shelf price on top — no duplication of the wine record.


15. Best-practice notes