Back to Spree

Multi-Vendor Marketplace (Open Source Core)

docs/plans/6.0-multi-vendor-marketplace.md

5.6.081.9 KB
Original Source

Multi-Vendor Marketplace (Open Source Core)

Status: Draft — design in progress, implementation not started Target: Spree 6.0 (the "Marketplace" release) Depends on: 6.0-cart-order-split.md (Cart/Order split + polymorphic LineItem#ownercurrently a stub; must be fleshed out before Phase 3 design freezes), 6.0-split-adjustments.md (TaxLine/Discount/Fee replace polymorphic Adjustment), 6.0-order-routing.md (per-seller stock location), 6.0-fulfillment-and-delivery.md (Fulfillment/DeliveryMethod vocabulary, per-vendor delivery selection), 6.0-admin-spa.md (vendor panel composition), 6.0-admin-api.md (Marketplace endpoints) Author: Damian + Claude Last updated: 2026-07-14

Summary

Open-source the core of Spree's multi-vendor marketplace into Spree 6.0, per spree/spree#13323. Today multi-vendor lives in the paid Enterprise module as a decorator-heavy monkey-patch of core (dozens of prepended core classes, a parent order mutated into a line-item-less "splitted" state). 6.0 brings the Vendor identity, per-seller order splitting, the commission engine, and the payout ledger into core as first-class models — built natively on the 6.0 Cart/Order split rather than retrofitted onto the legacy Order-only model.

What stays paid (Enterprise — boundary amended 2026-07-15, see decisions.md): refund clawbacks (automatic Stripe transfer reversals + negative-balance netting), ledger⇄provider reconciliation, KYC/account-health operations, payout reports (incl. DAC7), marketplace-facilitator taxes, Shopify/WooCommerce sales-channel apps, the category mapper. Basic Stripe Connect execution — Express onboarding + on-fulfillment transfers — ships open-source in the monorepo, alongside the Stripe core gateway being pulled in from the standalone spree_stripe repo (the payment-sessions-API classes only; the legacy v2-API/storefront code stays behind — likely home: spree/core). Core runs the happy path end-to-end (onboard, sell, get paid); Enterprise operates the unhappy paths and compliance at scale.

This plan covers the four subsystems: (A) vendor identity & teams, (B) order splitting, (C) the commission engine, and (D) the payout/fund-transfer ledger. It is informed by a source-level port review of the current Enterprise multi-vendor module (private source — internals deliberately not cited here), the Enterprise behavior documented in the public user docs, and a survey of modern open-source marketplace implementations — see References (competitor names and the detailed comparison are likewise kept out of this public file).

Key Decisions (do not deviate without discussion)

Scope split (OSS 6.0 vs Enterprise) — per #13323, amended 2026-07-15 (basic Stripe execution → OSS; see decisions.md)

OSS core (this plan)Enterprise (stays paid)
Spree::Vendor identity + invitation and self-serve application onboarding state machine + vendor teams (reusing Role/RoleUser/Invitation)KYC/account-health operations (requirements monitoring, restricted-account workflows)
Vendor admin dashboard (composed from @spree/dashboard-*) + operator marketplace screensRefund clawbacks — automatic Stripe transfer reversals + negative-balance netting
Basic Stripe Connect payouts (Express onboarding link + on-fulfillment transfers — ships with the monorepo Stripe gateway)Ledger⇄Stripe reconciliation
Commission engine (vendor- + product- + taxon-level rules, commission taxation)Payout reports (incl. EU DAC7 seller-income reporting)
Order splitting + payment splitShopify white-label Sales Channel app
Fund ledgerVendorTransfer (on-fulfillment earnings) + VendorPayout (scheduled settlement) + balance, lifecycle, reversalsWooCommerce integration
Vendor CSV import/export (rides the Spree::Import/Spree::Export pipeline)Category mapper (external category → Spree taxon)
Vendors API endpoints (Admin + Store) + lifecycle webhooks/events + onboarding task registry (core tasks)Enterprise onboarding tasks registered into the core checklist (Stripe KYC, Shopify/Woo store connection)
Merchandising + permissions (via code)"Automatic taxes" (marketplace-facilitator remittance)

The boundary is operational depth: you can run a marketplace for free end-to-end — vendors onboard to Stripe Express through a hosted link and get paid automatically on fulfillment — and core records what each vendor is owed (the payout ledger). The moment you need the money operations — automatic refund clawbacks, negative-balance netting, ledger⇄Stripe reconciliation, KYC workflows, regulatory reporting, tax remittance — that's Enterprise (Decision 9).

The OSS payout story is execution-capable, not just record-keeping (amended 2026-07-15): the monorepo's Stripe gateway ships a basic PayoutProvider::StripeConnect — Express onboarding + on-fulfillment transfers — so the free tier demos money movement end-to-end, at parity with the surveyed OSS competition. PayoutProvider stays a public interface, so community providers (PayPal Payouts, Wise, SEPA batch files) are first-class. Enterprise differentiates on the operations around the money, not on whether money moves.

Spree::Vendor name ownership (RESOLVED 2026-07-14)

Spree::Vendor (prefix ven_, table spree_vendors) is the marketplace seller — this plan's model. The purchase-order procurement source that 6.0-inventory-operations.md had also named Spree::Vendor is renamed Spree::Supplier (sup_, spree_suppliers, /api/v3/admin/suppliers). Rationale in decisions.md 2026-07-14: the marketplace name is locked publicly (#13323, the user docs' Vendors area, the legacy Enterprise module's ven_ prefix and production data), and the two are different lifecycles — a supplier is an address-book entry the merchant buys stock from; a vendor is an onboarded selling party with users, commission, and payouts.

1. Split happens at cart completion, into N child Orders under one parent — never seller-tagged flat line items

The legacy module and current industry implementations agree on the shape, and it maps cleanly onto 6.0-cart-order-split.md:

  • One mixed Spree::Cart through the entire shopping + checkout flow (a customer adds items from many vendors to one cart — no per-vendor cart fragmentation).
  • At cart.complete! (the Spree::Carts::Complete service from the cart/order-split plan), partition cart line items by the denormalized line_item.vendor_id snapshot (set from product.vendor at add-to-cart; revalidated at completion — see Order splitting) and, when the cart spans more than one partition (first-party items, vendor_id: nil, count as their own partition), fan out into N child Spree::Orders, one per partition, all owned by one Spree::OrderGroup that holds customer/payment/address context. Single-partition carts complete as a bare order — no group (see the Trigger & gate bullet).
  • The parent↔child relationship is an explicit FK via a dedicated Spree::OrderGroup, not the legacy state == 'splitted' string hack and not a join-table indirection. See Design Details and Decision 8.

This is the single most important integration point with the rest of 6.0: the split is a consumer of the Cart/Order split, implemented inside the completion service, not a state-machine after_transition callback bolted onto a monolithic Order (which is how the legacy module did it). The 6.0 cart/order plan removes the order-level state machine entirely, so the legacy trigger cannot survive — splitting must be invoked explicitly from Carts::Complete.

2. Commission is a frozen snapshot line attached to the order line item, computed once at placement

Mirror the core insight from both the legacy module and the industry references: commission is denormalized and frozen at sale time, never recomputed on read. This matches Spree's existing EU Omnibus "frozen at sale" precedent (5.4-6.0-eu-legal-compliance.md PriceHistory).

  • New model Spree::CommissionLine (NOT a revival of the legacy commission table, NOT a polymorphic Adjustment). One row per commissioned line item — plus one per fulfillment when the rate commissions delivery revenue (include_shipping, Decision 4) — storing a snapshot of rate, amount, and the resolved rule's identity.
  • Written by a service during/after cart.complete!, once. A later rate change does not touch existing lines.
  • Commission is NOT a Spree::Fee. 6.0-split-adjustments.md's Fee is a buyer-facing charge — it rolls into order.fee_total and the order total the customer pays. Commission is a platform ↔ vendor settlement line the customer never sees; it must never touch order totals. CommissionLine is therefore its own off-order ledger table that merely follows the same typed-line design (concrete FKs, no polymorphic source, no state machine) as the split-adjustments family. The only real split-adjustments dependency is that tax lines / discounts / fees are line-item-attached, so they re-home cleanly to the child order during the split. (A buyer-facing marketplace service fee — StockX-style — would be a Spree::Fee with a kind; possible later, out of scope here.)

3. Commission taxation is a first-class field — this is the OSS differentiator

This is the one place where the legacy Enterprise module is ahead of the surveyed industry implementations and the reason #13323 calls out "commission taxation which is essential for European marketplaces." The common industry pattern uses an include_tax toggle that only decides whether the seller's item tax inflates the commission base; it charges no tax on the marketplace fee itself. The legacy module already charges VAT on the commission at a configurable per-vendor rate (fee_total = fee_amount + fee_amount × tax_rate, half-up rounding). The marketplace's commission is itself a taxable B2B service the platform renders to the seller. 6.0 must model:

  • amount — the commission (the fee the platform charges the seller),
  • tax_amount — VAT/tax on that commission, at a configurable commission tax rate,
  • totalamount + tax_amount.

EU base (RESOLVED 2026-06-25): commission is computed on the seller's net (ex-VAT) item price, and tax_amount is applied on top of the commission — the marketplace fee is a separate taxable supply from the consumer's item sale, so the two VATs never mix. The include_tax-style base toggle stays available for non-EU/edge merchants who want a gross base, but the EU default is net-base + fee-VAT. The tax-on-the-fee field is mandatory and is the headline EU feature. The commission tax rate is resolved through 6.0-tax-provider.md (per-Market TaxProvider) rather than a bare platform_fee_tax_rate column on the vendor (RESOLVED — see Resolved Decisions).

4. Commission rate resolution: layered rules, first-match-wins by priority — generalize the legacy flat rate

The legacy module resolves a single flat percentage with a two-level fallback: the product's platform-fee override if set, else the vendor's default rate. That is "very simplified" — percentage only; no categories, no caps, no currency scoping. The industry pattern generalizes this into a proper CommissionRate + CommissionRule engine (priority-ordered, first-match-wins, dimension-grouped targeting rules, percentage | fixed, min_amount floor, currency gate, no-rules = global default).

6.0 adopts the rule-engine shape but keeps the legacy fallback as the default ladder. New models Spree::CommissionRate (priority, kind, value, currency, commission tax handling) + Spree::CommissionRule (polymorphic subject → Product / Taxon / Vendor / global). Resolution walks enabled rates by priority DESC, first match wins. Seed two implicit rules so existing single-rate merchants keep working: a product-scoped rate and a vendor-scoped rate, mirroring product.platform_fee || vendor.platform_fee.

Rule matching is dimension-grouped: AND across dimensions, OR within a dimension. A rate's rules are grouped by subject type (Product / Taxon / Vendor); the rate matches a line item when every dimension group present has at least one matching rule. {taxon: Electronics, taxon: Cameras, vendor: X} reads "(Electronics OR Cameras) AND vendor X" — which expresses plain lists and the vendor × category pairing today's Enterprise sells (user docs: "per vendor, per category-vendor pairing, or per individual product") with no match_policy knob at all. The leading surveyed OSS engine uses the same semantics, so the model is proven; where we differ deliberately is precedence — explicit operator priority instead of implicit most-specific-wins, which is more predictable (already RESOLVED below).

include_shipping on the rate (default false): when the resolved rate has it, the same calculation also runs against the vendor order's delivery amounts, persisting a fulfillment-scoped CommissionLine. The surveyed OSS competitor commissions shipping via a global-rate-only flag, and Amazon-style referral fees include it; the legacy module was items-only (its order-level "reverse fee" only prorates item commission on whole-order refunds — shipping is excluded from its base). Ours is per-rate — strictly more flexible than a global-only flag, and off by default so legacy behavior is preserved.

Precedence (RESOLVED 2026-06-25): integer-priority, not a fixed ladder. priority is operator-assignable and walked DESC, first match wins. Core seeds default priorities so the product-scoped rate beats the vendor-scoped rate out of the box — simple merchants never touch priorities, advanced merchants reorder freely. No hardcoded product > taxon > vendor > global ladder.

5. One payment, recorded as a per-seller split — adopt the explicit split-payment record

The customer makes one payment (one gateway charge against the order group). Do not create N gateway charges. The split is bookkeeping:

Two patterns exist in the wild: (a) link one payment collection to all child orders and split captures proportionally into per-order transaction rows computed on read; or (b) an explicit split-payment record per child order (authorized_amount, captured_amount, refunded_amount).

6.0 adopts the explicit per-seller payment record (pattern b) rather than proportion-on-read, because a marketplace needs a durable per-seller audit trail for partial captures, partial refunds, and payouts. This is also what the legacy module effectively did by creating real Spree::Payment rows on each vendor sub-order — but 6.0 does not clone Payment rows: the gateway object stays singular.

Concretely: Spree::Payment gains a nullable order_group_id — a grouped checkout's payment attaches to the group (its order_id is nil); a plain ungrouped order keeps payment.order as today. The per-seller bookkeeping is a new Spree::PaymentSplit row per child order (payment_id, order_id, authorized_amount / captured_amount / refunded_amount, currency) — sketched in Design Details. A partial refund on one vendor's order updates that child's split, never the siblings'.

6. Per-seller totals are computed independently, not prorated

Confirmed by the references and the legacy module (its split moves line items, adjustments, inventory units, and shipments wholesale to the child order, then recomputes). Taxes/discounts/shipping travel with whichever line item or shipment they were attached to, into exactly one vendor's order; each child order recomputes its own totals. No cart-level total proration (the one exception is the single payment capture, Decision 5, and store-credit allocation, which the references and the legacy module prorate per item).

7. Native models, not decorators

The legacy module is a large decorator layer prepending ~35 core classes. 6.0 brings this into core as real models, associations, and servicesSpree::Vendor, Spree::OrderGroup, Spree::PaymentSplit, Spree::CommissionLine, Spree::CommissionRate, Spree::CommissionRule, Spree::VendorTransfer, Spree::VendorPayout, and a Spree::Carts::SplitByVendor (or folded into Carts::Complete) service. No prepend. Follow CLAUDE.md model conventions (Spree.base_class, prefixed IDs, class_name/dependent on every association, string status columns, paranoia where needed).

Deliberately not new models: vendor team membership and invitations reuse the existing polymorphic rails — Spree::RoleUser (resource: vendor) and Spree::Invitation (resource: vendor; its resource docstring already names Vendor as an intended type). The legacy admin's current_vendor seams (spree/admin base_controller.rb:155, resource_controller.rb:249) exist only for the Enterprise module's legacy-admin panel and die with the legacy admin; the 6.0 equivalent is ability-level scoping (see Vendor identity & teams).

8. The parent is a dedicated Spree::OrderGroup, not Order#parent_id (RESOLVED 2026-06-16)

The container is a separate Spree::OrderGroup model, not the legacy "an Order that is secretly a parent" (Order#parent_id + state == 'splitted').

Crucially, OrderGroup is a domain-neutral primitive, not a marketplace concept. It means "N orders placed together as one customer transaction" — multi-vendor is merely its first consumer. The same primitive is reused, without a second container model, for: split-by-fulfillment-location (one checkout → per-warehouse orders), split-by-availability (in-stock + pre-order/backorder in one cart), and B2B split-by-company-location (6.0-channels-catalogs-b2b.md → 6.1). Naming it OrderGroup (not VendorOrderSet) keeps it reusable across all of these. The vendor relationship lives on the child Order (order.vendor), never on the group — the group stays vendor-agnostic.

Why a dedicated model beats parent_id:

  • No ambiguity / no empty parent orders. A parent Order with parent_id: nil and zero line items (the legacy shape) breaks every "an order has line items" assumption and forces a family of union-association and "is this really a parent?" workarounds. OrderGroup simply has many orders.
  • Clean composition with the Cart/Order split: Cart (one, mixed) → on completion → one OrderGroup owning N child Orders. The group is the natural home for the single payment, the customer, and the shared addresses; children own their own line items, shipments, totals.
  • Future-proof: when split-by-location or B2B needs the same fan-out, they reuse OrderGroup rather than overloading Order again.

This requires an Enterprise-side data backfill (not core — existing OSS stores have no vendors). The Enterprise module must ship a one-time migration that, for every legacy splitted parent order, creates an OrderGroup, attaches its parent_id-children as the group's orders, and migrates the legacy commission rows into spree_commission_lines. The backfill lives in Enterprise because only Enterprise customers have legacy marketplace data; core ships clean. Tracked as a hard dependency for the Enterprise 6.0 upgrade. See Migration Path.

9. Two-level fund ledger — VendorTransfer (on-fulfillment) + VendorPayout (on-interval), generic in core + a pluggable provider that executes it (RESOLVED 2026-06-25)

Transfer and payout are two distinct events — the legacy module conflated them, and an earlier draft of this plan repeated the mistake. They must be separate models:

  • Spree::VendorTransfer — the per-order fund movement, triggered on-fulfillment. When a vendor's order is fully fulfilled (shipped; digital fulfills instantly after payment), the vendor earns sale − commission; that credits their balance. One transfer per fulfilled vendor order. This is what the legacy Stripe transfer ledger actually modeled (it fires when the vendor sub-order's shipments are all shipped).
  • Spree::VendorPayout — the scheduled settlement, triggered on-interval (daily/weekly/biweekly/monthly). It sweeps the vendor's accumulated unpaid transfers, batches them into one bank settlement, and debits the balance. One payout per interval per vendor (per currency).

The legacy module only models the transfer and outsources payouts to Stripe (it just configures Stripe's native connected-account payout schedule — there is no Payout record; its "payouts" screens are reports aggregating transfers, not a ledger). That's fine for Stripe-only Enterprise but wrong for OSS core: a system-provider marketplace has no Stripe to schedule settlements, so it needs its own payout records.

text
Spree::VendorTransfer   (one per fulfilled vendor order, on-fulfillment) → credits vendor balance
      ↓ accumulate                                                   (sale − commission)
Spree::VendorPayout     (one per interval per vendor, scheduled)  → debits balance, settles to bank
      ↑ has_many transfers (each VendorTransfer.payout_id)           (batches N transfers; amount = Σ them)

vendor.balance = Σ(completed transfers) − Σ(completed payouts)   # derived bridge between the two levels

Payout ⇄ Transfer link (RESOLVED): transfers belong_to a payout. Each VendorTransfer carries a nullable payout_id. The scheduled job sweeps all unpaid completed transfers for a vendor, creates one VendorPayout, and stamps them with its id; payout.amount = SUM(its transfers). Every payout itemizes exactly which order-transfers it settled — auditable, and robust to mid-period reversals (a refund after a transfer was already paid out becomes the next period's negative transfer rather than corrupting a closed payout). This is the seller-balance ledger the surveyed industry references documented but never implemented — Spree ships it properly.

Both levels are generic in core + provider-executed, mirroring 6.0-tax-provider.md / 6.0-delivery-rate-provider.md:

  • Core ships both ledger models + a Spree::PayoutProvider::Base strategy with a system default: it records transfers (on-fulfillment) and payouts (on-interval) but moves no money — the OSS operator sees the per-vendor balance and the itemized payout records, and settles offline (marking the payout complete). This is what #13323 means by core knowing "what's owed."
  • The monorepo ships the basic Spree::PayoutProvider::StripeConnect (boundary amendment 2026-07-15), alongside the Stripe core gateway being pulled in from the standalone spree_stripe repo — only the payment-sessions-API gateway classes come over (likely into spree/core); the legacy v2-API/storefront code stays behind. The provider: Express-account creation + hosted onboarding link surfaced in the vendor dashboard + account.updated webhook → payout-account status on the vendor. transfer! executes an on-fulfillment Stripe::Transfer tied to the charge via source_transaction (gated on the account being payouts-enabled); pay! maps payouts_schedule_interval onto Stripe's native connected-account payout schedule and records VendorPayout rows from payout webhooks.
  • Enterprise extends that provider with the money operations: reverse! executing prorated Stripe transfer reversals on refund (ports the legacy reversal logic), negative-balance netting against future transfers, KYC/account-health workflows beyond the hosted flow, and ledger⇄Stripe reconciliation + payout reports. The paid tier operates the unhappy paths.

OSS funds flow — two modes. (a) system (no gateway objects): the customer's single charge settles in full to the operator's own gateway account as a plain payment; the split is pure bookkeeping (PaymentSplit + this ledger); disbursement happens outside the gateway — on the payout schedule the operator pays each vendor by bank (SEPA/ACH/wire), guided by the itemized VendorPayout, and marks it complete, or automates it (vendor_payout.due webhook → banking API → POST /vendor_payouts/:id/complete). The platform is merchant of record for the full transaction and carries vendor payables on its own books. (b) stripe_connect basic (the monorepo Stripe gateway): vendors onboard as Express accounts through Stripe's hosted link; on fulfillment the provider executes a real transfer — money moves automatically. What OSS mode (b) does not do is pull money back: a refund always writes the ledger reversal (the books stay correct in every tier), but the Stripe transfer reversal, netting against future transfers, and reconciliation are Enterprise — the first production refund is where the upgrade conversation starts. Note the hard coupling that shapes this seam: a Stripe::Transfer requires a connected account, so basic Connect onboarding necessarily ships together with OSS transfer execution; what Enterprise keeps is not onboarding itself but the KYC operations around it.

Transfer amount basis (ports the legacy allowed-transfer-amount rule, generalized per the resolved store-credit decision): the vendor's sale value minus commission, payment-source-agnostic (store credit / gift card is the platform's funding concern, not the vendor's):

text
transfer.amount = vendor_order.total − vendor_order.commission_lines.sum(:total)

vendor_order.total is the child order's grand total — items + its delivery + its taxes − its discounts. The deduction is CommissionLine#total, i.e. commission including the VAT on the commission (the platform invoices the vendor commission + VAT; the vendor reclaims that VAT as input tax). The vendor is merchant of record for their child order in v1: consumer tax and shipping revenue are theirs. A marketplace-facilitator / deemed-supplier mode — where the platform withholds consumer tax and the basis becomes total − tax_total − commission — is deliberately not core; it's Enterprise "automatic taxes" territory (see the scope table and Open Questions).

Reversals (ports the legacy reversal chain): a refund on a shipped order writes a negative VendorTransfer linked to the original via reversed_from, so the vendor's net earned is always Σ(vendor_transfers). If the original was already settled in a closed payout, the reversal lands as a negative transfer in the next payout period. Ties into 6.0-returns-exchanges-claims.md. The reversible-amount rule — original minus already-reversed, floored at zero — carries over as a model method on VendorTransfer.

Triggers (RESOLVED): vendor_transfer.due fires on-fulfillment (all the vendor order's fulfillments fulfilled); vendor_payout.due fires on the vendor's payouts_schedule_interval (daily/weekly/biweekly/monthly/manual, nil ⇒ store default — the "Use marketplace default interval" semantics in today's Enterprise settings, carried over). Core honors manual natively (operator triggers settlement by hand); Enterprise implements the scheduled intervals.

Naming & scope (RESOLVED 2026-06-25): the models keep the Vendor prefix and belongs_to :vendor — they are deliberately not generalized to bare Transfer/Payout. Rationale: (1) "transfer" and "payout" are heavily overloaded payment terms (gateway ACH/wire transfers, Stripe payout objects) — the Vendor prefix is what keeps these ledger records unambiguous and avoids re-introducing the very namespace confusion that made the legacy gateway-namespaced transfer model unclear; (2) the reusability that a dealership / B2B revenue-share scenario would want comes from an abstract payee (belongs_to :payee, polymorphic), not from a generic model name — and we have no second consumer yet, so per YAGNI we bind to Vendor now. If a concrete non-vendor payee requirement lands later, the migration path is to make payee polymorphic (Vendor + e.g. CompanyLocation) — a localized schema change — rather than a rename. This mirrors OrderGroup's vendor-agnostic structure, but the inverse conclusion is correct here: the container is generic because the vendor lives on its children, whereas these ledger rows are intrinsically a payment to one specific payee.

Design Details

Vendor identity & teams

The headline model. Ports the Enterprise module's Spree::Vendor (keeping prefix ven_ for Enterprise data continuity) and adopts the lifecycle already shipped in the Enterprise admin (user docs: Invited → Onboarding → Ready for Review → Approved / Rejected / Suspended). The external-store subsystem is not ported — none of it, ever: the external store/product/order/category resource family, the per-platform Shopify/WooCommerce connections and sync workers, the category mapper, and the vendor-side hooks that reach into them (external-store shipping-method pulls, external-store-derived settings) all remain Enterprise. Core's Vendor carries no external-store associations, columns, or callbacks — the Enterprise module layers its connection models onto core's Vendor from the outside:

ruby
class Spree::Vendor < Spree.base_class
  has_prefix_id :ven
  include Spree::SingleStoreResource
  include Spree::Metafields
  include Spree::Metadata
  include Spree::TranslatableResource   # about/description translate like Product
  acts_as_paranoid
  publishes_lifecycle_events

  belongs_to :store, class_name: 'Spree::Store'
  belongs_to :billing_address, class_name: 'Spree::Address', optional: true  # on the commission invoice (EU)
  belongs_to :returns_address, class_name: 'Spree::Address', optional: true  # where customer returns route

  has_many :products,        class_name: 'Spree::Product',       dependent: :nullify
  has_many :stock_locations, class_name: 'Spree::StockLocation', dependent: :nullify
  has_many :orders,          class_name: 'Spree::Order'           # child (vendor) orders
  has_many :commission_lines, class_name: 'Spree::CommissionLine'
  has_many :vendor_transfers, class_name: 'Spree::VendorTransfer'
  has_many :vendor_payouts,   class_name: 'Spree::VendorPayout'
  has_many :role_users,  class_name: 'Spree::RoleUser',   as: :resource, dependent: :destroy
  has_many :invitations, class_name: 'Spree::Invitation', as: :resource, dependent: :destroy

  # name, slug (unique per store), contact_email, billing_email
  # about          : rich text (sanitized HTML column per 6.0-rich-text-descriptions.md)
  # NO denormalized sales_total/commission_total counters (legacy has them) — lifetime
  # revenue & commission derive from commission_lines / vendor_transfers (we have a ledger now)
  # NO encrypted name/contact columns (legacy encrypts for its multi-tenant SaaS; core stays plain)
  # logo / square_logo / cover_photo : ActiveStorage attachments (storefront-facing branding)
  # terms_accepted_at : datetime — vendor ToS acceptance (store-configured rich-text ToS)
  # holiday_mode_until : datetime, nullable — paused storefront presence ("holiday mode")
  # payouts_schedule_interval : string, nullable — 'daily'|'weekly'|'biweekly'|'monthly'|'manual';
  #                             nil ⇒ store default (store preference), matching today's
  #                             "Use marketplace default interval" semantics

  # Ports the legacy lifecycle (status column renamed from `state` per
  # 6.0-normalize-state-to-status.md). Legacy allows approve/reject/suspend from most
  # states; keep that permissiveness where operationally sane.
  state_machine :status, initial: :pending do
    event(:invite)              { transition [:pending, :canceled] => :invited }  # send — or re-send after cancel/expiry
    event(:cancel_invite)       { transition invited: :canceled }           # withdrawn/expired invitation
    event(:start_onboarding)    { transition [:pending, :invited] => :onboarding }  # invite accepted / application admitted
    event(:submit_for_review)   { transition onboarding: :ready_for_review }
    event(:approve)             { transition [:onboarding, :ready_for_review, :suspended, :rejected] => :approved }
    event(:reject)              { transition [:onboarding, :ready_for_review] => :rejected }
    event(:suspend)             { transition %i[onboarding ready_for_review approved rejected] => :suspended }
    # suspension hides the vendor's catalog (legacy archives products); approve un-suspends
  end

  validates :name, :slug, presence: true, uniqueness: { scope: spree_base_uniqueness_scope }
end
  • Two entry paths, one lifecycle. (a) Invitation: operator creates a pending vendor and invites → Spree::Invitation (resource: vendor, vendor-role) → acceptance creates the RoleUser and fires start_onboarding. (b) Self-serve application — a public "become a seller" flow: a Store API endpoint creates a pending vendor + application; the operator admits into onboarding or rejects. Same state machine, no second flow.
  • Teams = Spree::RoleUser rows with resource: vendor and user: Spree.admin_user_class. Vendor admins invite teammates through the same Invitation rail the store admin uses. No new membership model.
  • Authorization: vendor users authenticate like any admin user (JWT); their ability is built from vendor-scoped permission sets whose conditions are tenancy hashes (vendor_id: user.vendor_ids on Product/Order/StockLocation/etc.). Record-state rules stay on models per the RBAC convention. #13323's "manage permissions (via code)" = operators customize these permission sets. Port the legacy PermissionSets::VendorUser baseline: vendor-scoped CRUD on products/variants/prices/orders/fulfillments/stock/delivery methods/returns/exports, with writes force-stamped vendor_id; no commission management, no channel publication, no tag management, no stock-location create, no self-approve/suspend.
  • Onboarding checklist — the registry is core, Enterprise contributes tasks. Core ships a lightweight task registry (task key + completion check + required flag) and the core tasks: accept ToS, complete profile/branding, billing + returns addresses, at least one delivery method, at least one product. submit_for_review requires all required tasks complete (ports the legacy onboarding_completed? guard — without a checklist the guard has nothing to check, and vendors can't see what's missing). Extensions register additional tasks into the same registry: the monorepo Stripe gateway adds "connect your Stripe account" (Express onboarding); Enterprise adds KYC verification follow-through and Shopify/Woo store connection — the paid value is in those tasks, not in the tracker. The vendor dashboard and the operator's onboarding-progress view (the n/m column in today's Enterprise) both render from the registry.
  • Storefront visibility: only approved vendors (and their products) are exposed; holiday_mode_until in the future hides the vendor's products from sale (purchasability gate, not catalog deletion).

Columns added to existing tables (the complete list — everything else in this plan is new tables):

TableColumnWhy
spree_productsvendor_id (nullable)product ownership / split partition key
spree_stock_locationsvendor_id (nullable)vendor-owned stock, feeds order routing
spree_ordersvendor_id (nullable), order_group_id (nullable)child-order identity
spree_line_itemsvendor_id (nullable, denormalized)split partition + vendor scoping without 3-join hops
spree_paymentsorder_group_id (nullable)the single shared payment (Decision 5)
spree_delivery_methodsvendor_id (nullable)optional vendor-scoped delivery options (see Order splitting)

Legacy vendor scoping deliberately not carried over: spree_payments.vendor_id and spree_shipments.vendor_id (both derivable — PaymentSplit/fulfillment reach the vendor through the child order), and vendor_id on reports + webhook subscribers (vendor-scoped reports and vendor-owned webhook subscriptions are post-6.0 dashboard/API work, not v1 schema).

Order splitting

text
Spree::Cart (one, mixed)
  └─ line_items (polymorphic owner = Cart)   ← 6.0-cart-order-split.md

   cart.complete!  →  Spree::Carts::Complete  →  splits by vendor
        │
        ▼
Spree::OrderGroup (the transaction container — customer, single payment, shared addresses)
  ├─ Spree::Order (vendor A)  belongs_to :order_group, belongs_to :vendor
  │     ├─ line_items (owner = this order)   — vendor A's items
  │     ├─ fulfillments                       — vendor A's fulfillment (Shipment pre-rename)
  │     ├─ commission_lines                   — frozen at split
  │     ├─ vendor_transfer                    — vendor A's earning, on-fulfillment (level-1 ledger)
  │     └─ payment_split                      — vendor A's share of the single payment
  ├─ Spree::Order (vendor B)  …
  └─ …

The OrderGroup owns the customer + single payment + shared addresses; each child Order owns its own line items, fulfillments, and totals. The group carries no vendor reference — vendor lives on the child order (Decision 8). Vocabulary follows 6.0-fulfillment-and-delivery.md throughout: Fulfillment (né Shipment), DeliveryMethod (né ShippingMethod), and the ledger trigger is on-fulfillment — which also covers digital/instant fulfillment types (a digital vendor order fulfills, and therefore earns, immediately after payment).

  • Trigger & gate: explicit call inside Spree::Carts::Complete, after payment authorization. The gate is more than one fulfillment partition — line items partition by the vendor_id snapshot, with nil (the operator's own first-party items) forming its own partition, and the split + OrderGroup happen only when ≥ 2 partitions exist. A mixed cart of vendor A + first-party items splits into two child orders (one with vendor_id: nil). A single-partition cart — all first-party, or all one vendor — completes as a bare order exactly as today: no group, no PaymentSplit; when that partition is a vendor's, the order still carries vendor_id and commission + ledger apply unchanged (Phase 2's compute-only mode is exactly this). Not a state-machine callback — and not the legacy shape either: the legacy module splits ~30 seconds after checkout in a background job, leaving a window where the parent order exists un-split. 6.0 splits synchronously inside completion, so the checkout response already returns the finished OrderGroup and no splitted-pending state ever exists.
  • Partition key: line_item.vendor_id, denormalized from variant.product.vendor when the item is added (matching legacy behavior). Denormalization is load-bearing, not an optimization: the split, vendor ability scoping, and vendor order lists all key on it. Because product ownership can change while an item sits in a cart, Carts::Complete revalidates each line's vendor_id against the product's current vendor before partitioning and refreshes stale lines (re-running package/delivery validation) — the same cart-revalidation class as price or availability drift.
  • Per-vendor delivery selection happens at checkout, before the split. Today's Enterprise checkout already groups packages by vendor and lets the customer pick a delivery method per vendor package (user docs: multi-vendor-checkout); 6.0 keeps that UX. The cart's packages come from 6.0-order-routing.md (for_allocation — vendor items allocate from that vendor's stock location), delivery options per package come from 6.0-delivery-rate-provider.md filtered to the package's vendor: a DeliveryMethod with vendor_id is offered only on that vendor's packages; vendor_id: nil methods are marketplace-wide. The chosen method + rate travel with the package into that vendor's child order at split. This is a hard requirement on the cart/order-split checkout design (see Constraints).
  • Move semantics: line items, their tax lines / discounts / fees (6.0-split-adjustments.md), fulfillment items, and fulfillments are assigned to the correct child Order (created from the cart partition). Stock location per child comes from the routing strategy, not a bare detect.
  • Group status: derived, not a column. The OrderGroup is "fully fulfilled" when all its orders are, "partially canceled" when some are, etc. — computed across order_group.orders, replacing the legacy all_shipments/splitted?/cancel_parent_order workarounds (which existed only because the legacy parent was itself an Order).

OrderGroup model

ruby
# Transaction container — N orders placed together as one customer checkout.
# Domain-neutral (Decision 8): multi-vendor is its first consumer, but it's
# reused for split-by-location, split-by-availability, B2B split-by-company-location.
class Spree::OrderGroup < Spree.base_class
  include Spree::Metadata
  has_prefix_id :ogrp
  publishes_lifecycle_events

  belongs_to :store, class_name: 'Spree::Store'
  belongs_to :customer, class_name: Spree.customer_class.to_s, optional: true  # guest groups allowed

  has_many :orders, class_name: 'Spree::Order', dependent: :restrict_with_error  # never cascade-delete completed orders
  has_many :payments, class_name: 'Spree::Payment'                          # the single shared payment(s)
  has_many :payment_splits, class_name: 'Spree::PaymentSplit', through: :payments
  belongs_to :ship_address, class_name: 'Spree::Address', optional: true
  belongs_to :bill_address, class_name: 'Spree::Address', optional: true

  # number (human-facing, e.g. the cart-derived order number), currency
  # email — guest checkouts have no customer; the group carries the contact email

  # Status is DERIVED across orders, never a column:
  def fulfillment_status; ...; end  # all fulfilled / partial / pending
  def payment_status;     ...; end  # paid / partial / void, aggregated from the shared payment
end

The child Order gains belongs_to :order_group (optional — a plain single-vendor / single-fulfillment order has no group) and belongs_to :vendor (optional — non-marketplace orders have no vendor). No parent_id, no splitted status.

Split payment record (Decision 5)

ruby
# Per-child-order bookkeeping of the ONE gateway payment. No cloned Payment rows.
class Spree::PaymentSplit < Spree.base_class
  has_prefix_id :paysp

  belongs_to :payment, class_name: 'Spree::Payment'   # the group's single gateway payment
  belongs_to :order,   class_name: 'Spree::Order'     # the child (vendor) order

  # authorized_amount / captured_amount / refunded_amount : decimal
  # currency : string

  extend Spree::DisplayMoney
  money_methods :authorized_amount, :captured_amount, :refunded_amount
end

Created at split, one per child order, with a unique index on (payment_id, order_id). authorized_amount is the child's share of that gateway paymentchild.total minus the child's allocated store-credit/gift-card coverage (allocation per Open Question 1), so a mixed store-credit checkout never overstates the gateway exposure. Captures on partial fulfillment and refunds on a child order update that child's split only. Spree::Refund against a grouped payment always carries the child-order context so the split, the commission reversal, and the transfer reversal reconcile against the same order (see Reversals).

Commission models

ruby
# Snapshot line — one per commissioned line item, frozen at placement
class Spree::CommissionLine < Spree.base_class
  include Spree::Metadata
  has_prefix_id :comln

  belongs_to :order,       class_name: 'Spree::Order'
  belongs_to :line_item,   class_name: 'Spree::LineItem',   optional: true  # the commissioned item…
  belongs_to :fulfillment, class_name: 'Spree::Fulfillment', optional: true # …or the commissioned delivery (include_shipping)
  belongs_to :vendor,      class_name: 'Spree::Vendor'
  belongs_to :commission_rate, class_name: 'Spree::CommissionRate', optional: true  # nullable: rule may be deleted later

  # exactly one of line_item / fulfillment is set (same pattern as TaxLine's adjustable),
  # enforced by a model validation AND a DB CHECK constraint (num_nonnulls-style XOR)
  validate :exactly_one_of_line_item_or_fulfillment

  # snapshot fields (frozen — never recomputed)
  # rate        : decimal  — the % or flat value applied, as resolved at sale time
  # kind        : string   — 'percentage' | 'fixed'
  # amount      : decimal  — commission charged to the vendor (the platform's fee)
  # tax_amount  : decimal  — VAT/tax ON the commission (EU) — Decision 3
  # total       : decimal  — amount + tax_amount
  # currency    : string

  extend Spree::DisplayMoney
  money_methods :amount, :tax_amount, :total
end

# Rate definition (mutable config) — rule-engine resolution, legacy-style defaults
class Spree::CommissionRate < Spree.base_class
  has_prefix_id :comrt
  include Spree::SingleStoreResource
  acts_as_paranoid

  belongs_to :store, class_name: 'Spree::Store'
  has_many :commission_rules, class_name: 'Spree::CommissionRule', dependent: :destroy

  # name, code (unique per store), enabled (bool), priority (int)
  # kind             : string  — 'percentage' | 'fixed'
  # value            : decimal — percent (e.g. 10.0) or flat amount
  # currency         : string  — REQUIRED when kind == 'fixed', ignored when 'percentage' (RESOLVED)
  # include_tax      : bool    — compute commission base on item gross vs net (EU default: net — Decision 3)
  # include_shipping : bool    — also commission the vendor order's delivery amounts (Decision 4)
  # min_amount       : decimal — nullable floor
  # max_amount       : decimal — nullable cap (NEW — neither reference has this; EU/fairness)
  # commission tax: resolved via 6.0-tax-provider, not a flat column (Decision 3)

  validates :code, uniqueness: { scope: spree_base_uniqueness_scope }
  validates :currency, presence: true, if: -> { kind == 'fixed' }   # flat fee needs a currency; percentage doesn't
end

# Targeting rule — grouped by subject_type: AND across dimensions, OR within one (Decision 4)
class Spree::CommissionRule < Spree.base_class
  belongs_to :commission_rate, class_name: 'Spree::CommissionRate'
  belongs_to :subject, polymorphic: true, optional: true   # Spree::Product | Spree::Taxon | Spree::Vendor; nil ⇒ global
end

Resolution (Spree::Commissions::ResolveRate service): load enabled CommissionRates for the store ordered by priority DESC — the currency gate applies only to fixed rates (a fixed rate must match the order currency; percentage rates are currency-agnostic and always pass) — and return the first whose rules match the line item: rules grouped by subject_type, every present group needs ≥1 match (Decision 4). A rate with no rules is the global default. Seed default rates so product.platform_fee || vendor.platform_fee behavior is preserved without configuration — including the legacy store-preference default (30%) as the seeded global rate. This also untangles the three legacy meanings of platform_fee (store preference / vendor column / product override) into one model.

Calculation (Spree::Commissions::CalculateLine service) — ports the legacy per-item fee calculation, generalized:

text
item_net   = discounted item amount, ex-VAT     # price − discounts, tax excluded (EU default base)
item_gross = discounted item amount, incl. VAT  # price − discounts, tax included
base       = include_tax ? item_gross_per_unit : item_net_per_unit
per_unit   = kind == 'percentage' ? (base * rate / 100) : rate
amount     = per_unit * quantity
amount     = clamp(amount, min_amount, max_amount)                   # NEW: max cap
tax_amount = amount * commission_tax_rate / 100                      # Decision 3 — the EU piece
total      = amount + tax_amount

The base is payment-source-agnostic: store credit / gift cards are how the customer paid, not a price component — they never reduce the commission base (consistent with the resolved transfer-basis decision; the legacy implementation subtracted a store-credit share from the base, and that behavior is deliberately dropped). "Net" here means net of VAT; discounts are always subtracted first in both bases (the vendor's commission is charged on what the customer actually paid for the item — promotions are vendor-funded in v1, see Open Questions).

Commission tax rate source (the contract CalculateLine consumes): precedence is (1) an explicit commission_tax_rate override on the CommissionRate, else (2) the store's TaxProvider asked for the platform-service rate against the vendor's billing address (commission is a B2B supply to the vendor — not the consumer's ship address), else (3) a store-preference default, else 0. Missing/erroring provider lookups fall through to (3) — commission lines are written inside the split transaction and must never block checkout on a tax-service outage.

Rounding: every persisted money field rounds half-up to the currency's minor unit (2 dp for most currencies, using the currency exponent — matching legacy behavior): per_unit after the percentage step, amount after quantity × clamp, tax_amount after the tax step; total is the sum of the two rounded parts, so Σ CommissionLine amounts always reconcile with balances and payouts to the cent.

Persist one CommissionLine per line item on the child order, inside the split transaction. When the resolved rate has include_shipping, run the same calculation against the vendor order's delivery amount and persist a fulfillment-scoped CommissionLine alongside.

Fund ledger — two levels (Decision 9)

ruby
# Level 1 — PER-ORDER, on-fulfillment. Credits the vendor's balance with what they earned.
# CORE; provider-agnostic. The `system` provider records only; StripeConnect executes.
class Spree::VendorTransfer < Spree.base_class
  has_prefix_id :vtr
  include Spree::RansackableAttributes

  belongs_to :vendor, class_name: 'Spree::Vendor'
  belongs_to :order,  class_name: 'Spree::Order'                       # the child (vendor) order
  belongs_to :payout, class_name: 'Spree::VendorPayout', optional: true  # nil until settled (Decision 9 link)
  belongs_to :reversed_from, class_name: 'Spree::VendorTransfer', optional: true
  has_many   :reversals, class_name: 'Spree::VendorTransfer', foreign_key: :reversed_from_id

  # amount   : decimal — vendor_order.total − commission_lines.sum(:total) (payment-source-agnostic)
  # currency : string
  # kind     : string  — 'earning' | 'refund_reversal'
  # provider : string  — 'system' | 'stripe_connect'
  # reference: string  — provider txn id (Stripe transfer id); nil for manual

  state_machine :status, initial: :pending do
    event(:process)  { transition pending: :processing }
    event(:complete) { transition [:pending, :processing] => :completed }   # earned/confirmed
    event(:fail)     { transition [:pending, :processing] => :failed }
  end

  scope :completed,  -> { where(status: 'completed') }
  scope :unsettled,  -> { completed.where(payout_id: nil) }              # awaiting the next payout sweep

  extend Spree::DisplayMoney
  money_methods :amount

  # original minus already-reversed, floored at zero (ports the legacy rule)
  def reversible_amount; ...; end
end
ruby
# Level 2 — PER-INTERVAL, scheduled. Batches the vendor's unsettled transfers into one
# bank settlement and debits the balance. CORE; the `system` provider records the
# settlement, StripeConnect performs it.
class Spree::VendorPayout < Spree.base_class
  has_prefix_id :vpo
  include Spree::RansackableAttributes

  belongs_to :vendor, class_name: 'Spree::Vendor'
  has_many   :transfers, class_name: 'Spree::VendorTransfer'           # the transfers this payout settles

  # amount   : decimal — SUM(transfers.amount); set when the payout is assembled
  # currency : string
  # period_start / period_end : datetime — the interval this payout covers
  # provider : string  — 'system' | 'stripe_connect'
  # reference: string  — provider payout id; nil for manual

  state_machine :status, initial: :pending do
    event(:process)  { transition pending: :processing }
    event(:complete) { transition [:pending, :processing] => :completed }   # manual mark-paid OR provider webhook
    event(:fail)     { transition [:pending, :processing] => :failed }
  end

  scope :owed,      -> { where(status: %w[pending processing]) }
  scope :completed, -> { where(status: 'completed') }

  extend Spree::DisplayMoney
  money_methods :amount
end

Spree::Vendor exposes the derived bridge:

ruby
# vendor.rb — per currency; every ledger row carries `currency`, so a multi-currency
# store accrues separate balances and the payout sweep runs per (vendor, currency).
def balance(currency)   # what we still owe the vendor in that currency
  vendor_transfers.completed.where(currency: currency).sum(:amount) -
    vendor_payouts.completed.where(currency: currency).sum(:amount)
end
ruby
# Pluggable execution strategy — CORE base + system no-op; basic StripeConnect ships
# with the monorepo Stripe gateway (2026-07-15 amendment); Enterprise extends it.
# Mirrors 6.0-tax-provider.md / 6.0-delivery-rate-provider.md. Two verbs, one per level.
class Spree::PayoutProvider::Base
  def transfer!(vendor_transfer); raise NotImplementedError; end   # on-fulfillment (level 1)
  def pay!(vendor_payout);        raise NotImplementedError; end   # on-interval (level 2)
  def reverse!(vendor_transfer);  raise NotImplementedError; end   # refund → reverse transfer
end

# CORE default — records both ledgers, moves no money (manual/offline settlement).
class Spree::PayoutProvider::System < Spree::PayoutProvider::Base
  def transfer!(vendor_transfer) = vendor_transfer.complete!   # earned → credits balance
  def pay!(vendor_payout)        = vendor_payout               # no-op; admin marks complete manually
  def reverse!(vendor_transfer)  = vendor_transfer
end

# OSS — ships with the monorepo Stripe gateway (pulled in from the standalone
# spree_stripe repo; payment-sessions classes only, likely spree/core): BASIC Stripe Connect.
#   Spree::PayoutProvider::StripeConnect — Express-account onboarding link + account
#   status webhook; transfer! → Stripe::Transfer with source_transaction on fulfillment
#   (ports the legacy transfer creation); pay! → Stripe's native connected-account
#   payout schedule (ports the legacy schedule mapping) + VendorPayout rows from payout
#   webhooks; reverse! → ledger-only (records the reversal, moves no money back).
#
# ENTERPRISE — extends the OSS provider with the money OPERATIONS (not in this repo):
#   reverse! → prorated Stripe transfer reversals (ports the legacy reversal logic) +
#   negative-balance netting against future transfers; KYC/account-health workflows;
#   ledger⇄Stripe reconciliation + payout reports. Credentials live on its
#   Spree::Integration record, same convention as external tax providers.

Settlement sweep (level 2): the scheduled job runs per (vendor, currency) pair on the vendor's payouts_schedule_interval: it collects that pair's unsettled transfers, creates one VendorPayout with amount = Σ, stamps each transfer's payout_id, and invokes provider.pay!. system leaves it pending for manual completion; stripe_connect settles and a webhook completes it.

Claim & idempotency contract: creating the payout and stamping its transfers' payout_id happen in one transaction — that stamp is the claim; a re-run sweep only ever sees payout_id IS NULL transfers, so a batch can never be swept twice. provider.pay! must be idempotent, keyed on the payout's prefixed ID (Stripe idempotency key), so a crashed/retried invocation cannot double-pay. Failure paths: pay! raising → payout.fail! with its transfers still stamped — retry means re-invoking pay! on the same payout (never re-sweeping into a new one); a payout stuck in processing past a timeout is surfaced in the operator payouts queue for manual resolution (webhook lost vs. provider-side failure), never auto-recreated.

The same contract applies to level 1. One earning transfer per vendor order — enforced by a partial unique index on (order_id) WHERE kind = 'earning' — so a re-fired fulfillment event finds the existing row instead of double-crediting. provider.transfer! is idempotent keyed on the transfer's prefixed ID, and the provider reference (Stripe transfer id) is unique when present: if transfer! succeeded externally but the record never reached completed (crash between API call and commit), the retry's idempotency key returns the same external transfer and the recovery path re-syncs reference + status rather than creating a second money movement. Ambiguous/timed-out attempts park the record in processing for the operator queue, same as payouts.

OSS settlement statements come free. The ledger is exportable through the standard Spree::Export pipeline — a per-vendor CSV of transfers/payouts (sales − commission − commission VAT = net, plus reversals) is just an export of these tables, replacing the legacy Spree::Reports::VendorsPayout report. What stays Enterprise under "payout reports" is provider reconciliation (ledger ⇄ Stripe) and regulatory reporting (DAC7) — not the raw statement.

Reversals (refunds)

The legacy module writes reverse commission rows when items are refunded (per returned item, plus an order-scoped prorated row for whole-order refunds) and reverse transfers. 6.0 keeps both, symmetric — but reversals are always per line (a whole-order refund reverses each line's commission exactly; the legacy order-level proration and its rounding fuzz are not carried over):

  • A refund writes a negative/reversed CommissionLine (net commission = sum(commission_lines)).
  • A refund writes a refund_reversal VendorTransfer linked via reversed_from (net earned = sum(vendor_transfers)). If the original transfer was already settled in a completed payout, the reversal lands in the next payout period (Decision 9).

The hook point is Spree::Refund. 6.0-returns-exchanges-claims.md funnels Return, Exchange, and Claim money movement through Spree::Refund — so a single subscriber on the refund event covers all three flows: it updates the child order's PaymentSplit.refunded_amount, writes the reverse CommissionLines for the refunded items, and writes the reversal VendorTransfer.

Product ownership & approval

  • Products carry vendor_id (nullable — the operator's own first-party products have none). Vendor users only see/manage their own (ability tenancy condition).
  • Approval reuses the product status workflow — no new approval model. Vendor-created products default to draft; a store preference vendor_products_require_approval (default true) controls whether vendors may activate their own products or an operator must. This covers the seller product-approval workflow competing marketplaces ship, with zero new machinery, and #13323's "merchandise Vendor's products" stays intact — the operator retains full edit rights over vendor products (naming, taxons, publication to channels).
  • Vendor bulk product CSV import/export rides the existing Spree::Import / Spree::Export pipeline (see 5.6-admin-spa-csv-import.md) with the vendor context stamped on created records — no marketplace-specific importer.
  • SKU uniqueness relaxes to per-vendor scope. Two vendors legitimately sell the same manufacturer SKU (the legacy module disables SKU validation outright); 6.0 scopes the uniqueness check by vendor_id instead of dropping it.
  • "Shop by vendor" curated pages come from a vendor-subject CollectionRule kind (6.0-replace-taxons-with-categories.md), replacing the legacy Spree::TaxonRules::Vendor.

Vendor notifications

Vendor notifications are event-driven, with OSS mailers included — split along the existing mailer boundary:

  • Back-office emails to vendors and admins live in core, next to the existing back-office mailers (Spree::ExportMailer, Spree::ImportMailer, Spree::InvitationMailer, Spree::ReportMailer): a Spree::VendorMailer ports the legacy set — vendor approved/rejected/suspended (to the vendor's team), onboarding started/submitted-for-review (to store admins), new vendor order, payout completed — wired as subscribers to the lifecycle events below. Vendor invitations need nothing new: they already ride core's InvitationMailer via the Invitation rail. Core-resident means vendor email works in every installation, regardless of gems.
  • Consumer-facing emails (e.g. the per-vendor shipment tracking emails customers receive) belong to spree/emails (rebuilt + modernized in 5.6, optional) — or to the storefront's own email stack via webhooks, for headless setups that own transactional email.

Either way the events are the seam — subscribers/webhooks feed n8n/Slack/custom stacks, including OSS payout automation: vendor_payout.due → webhook → operator's banking flow → POST /vendor_payouts/:id/complete.

Events (via publishes_lifecycle_events + explicit publishes): vendor.created/updated/approved/rejected/suspended, order_group.completed, per-child order.placed (existing), commission_line.created, vendor_transfer.completed/reversed, vendor_payout.due/completed/failed.

Store API surface

Mirrors what the legacy v2 storefront already exposed for Enterprise (/api/v2/storefront/vendors), rebuilt on v3:

  • GET /api/v3/store/vendors + GET /api/v3/store/vendors/:slug — public profile: name, slug, about, logo, square_logo, cover_photo. Only approved, non-holiday vendors. No timestamps, no contact email, no financials (store-serializer rules).
  • Product serializers gain a vendor { id, name, slug } object (nil for first-party products); product listing accepts q[vendor_id_eq] — that plus the vendor profile endpoint is a complete vendor storefront page. Vendor becomes a search facet through the existing SearchProvider filter registry.
  • POST /api/v3/store/vendor_applications — the self-serve "become a seller" application (name, contact email, message) creating a pending vendor + owner invitation for operator review. Rate-limited, no auth required.
  • Checkout completion of a cart that split returns the order group: { order_group: { id, number, orders: [...] } }; carts that don't split (a single partition — all one vendor, or all first-party) keep returning the bare order. Customer account history lists groups with nested per-vendor orders + per-order fulfillment tracking (today's "separate tracking per vendor" UX).

Admin API surface

Endpoint list lives in 6.0-admin-api.md → "Marketplace (new in 6.0)": vendors CRUD + invite/approve/reject/suspend (approve also un-suspends, matching the legacy machine), commission rates CRUD (nested rules: [], PriceList-style), read-only commission_lines + vendor_transfers, vendor_payouts + POST :id/complete (manual settlement), read-only order_groups. API key scopes: read_vendors/write_vendors, read_commissions/write_commissions, read_payouts/write_payouts. Vendor users hit the same admin API with JWT + vendor-scoped abilities — there are no separate "vendor API" routes to maintain (the legacy module's separate vendor endpoints collapse into ability scoping).

Migration Path

This is new OSS code, not a rename of existing core — there is no in-place data migration for existing Spree stores (they have no vendors). The "migration" is twofold:

  1. From the Enterprise module to core, for existing marketplace customers. A one-time backfill rake task (shipped in Enterprise — only Enterprise has legacy data) must:

    • For each legacy splitted parent order, create a Spree::OrderGroup and attach its parent_id children as the group's orders.
    • Migrate the legacy per-item commission rows → spree_commission_lines. Sign convention flips: the legacy module stores order-level fee totals negative (load-bearing in its payout math); CommissionLine amounts are positive, reversals are their own negative/reversed rows.
    • Decrypt the legacy encrypted vendor contact columns (ActiveRecord encryption — a multi-tenant SaaS choice) into core's plain columns.
    • Migrate the legacy Stripe transfer ledger → spree_vendor_transfers (the per-order, on-fulfillment level) with provider: 'stripe_connect' and the Stripe transfer id in reference. The legacy module has no payout records (it delegated scheduling to Stripe), so there is nothing to backfill into spree_vendor_payouts — historical Stripe payouts can optionally be reconstructed from Stripe's API, or simply start fresh from the cutover. The Enterprise module then becomes a thin layer on top of core's new models, deleting its splitting + commission + transfer decorators. The basic Spree::PayoutProvider::StripeConnect ships in the monorepo with the pulled-in Stripe gateway (2026-07-15 amendment); Enterprise extends it (clawbacks, netting, KYC ops, reconciliation) and keeps Shopify/WooCommerce.
  2. Build order (each phase shippable):

    • Phase 1 — Vendor identity. Spree::Vendor model (see Vendor identity & teams: lifecycle, branding, ToS acceptance, holiday mode), onboarding task registry + core tasks, vendor teams via RoleUser/Invitation (resource: vendor), self-serve application endpoint, core Spree::VendorMailer lifecycle emails (see Vendor notifications), product ownership (products.vendor_id + approval preference), Admin API Marketplace/vendors endpoints + Store API vendor profiles, vendor CSV import/export on the Spree::Import pipeline. No splitting yet.
    • Phase 2 — Commission engine. CommissionRate + CommissionRule + CommissionLine, resolution + calculation services (dimension-grouped matching, min/max clamps, include_shipping), commission-tax field, admin UI for rates. Compute-only (attach to single-vendor orders, no split).
    • Phase 3 — Order splitting + fund ledger. OrderGroup, Carts::SplitByVendor inside Carts::Complete, per-vendor delivery selection at checkout, PaymentSplit, derived group status, reversals, the two-level VendorTransfer (on-fulfillment) + VendorPayout (scheduled sweep) + PayoutProvider::System + vendor.balance(currency). Gated on 6.0-cart-order-split.md and 6.0-split-adjustments.md landing first — and cart-order-split is today a one-page stub: it must be expanded (checkout/delivery-selection/payment flow on Cart) before this phase's design can freeze. Flagging early: the 6.0 headline feature sits downstream of the two biggest unstarted refactors.
    • Phase 3b — OSS Stripe provider (after Phase 3, alongside the Stripe gateway pull-in: the payment-sessions-API gateway classes come into the monorepo from the standalone spree_stripe repo, likely into spree/core, leaving the legacy v2-API/storefront code behind): Express onboarding link in the vendor dashboard, account.updated status webhook, transfer! on fulfillment with source_transaction, payout-schedule mapping + payout webhooks. Ledger-only reverse!.
    • Phase 4 — Dashboards. (a) Operator marketplace screens in @spree/dashboard: Vendors list/detail (lifecycle actions, branding, commission + payout settings, ledger-derived lifetime revenue/commission), Commission Rates settings page, Payouts review queue (owed balances, itemized payouts, mark-paid), OrderGroup rendering on order screens, and a "needs attention" queue on the dashboard home (vendors ready_for_review + pending vendor products — a lightweight unified review inbox). (b) Vendor panel: compose @spree/dashboard-ui + @spree/dashboard-core into a vendor-scoped panel (per 6.0-admin-spa.md — the package split exists precisely for this). Permissions via code. Surfaces the vendor's products, orders + fulfillment actions, and the balance + transfer/payout ledger (earned/settled) read-only for OSS.

    Phases 1–2 have no dependency on the Cart/Order split — they can be built (even shipped behind a preview flag in a 5.x release) while the split refactor lands, de-risking the 6.0 timeline.

Constraints on Current Work

Even before this lands, work touching adjacent areas should:

  • Cart/Order split (6.0-cart-order-split.md): keep cart.complete!/Spree::Carts::Complete as the single completion entry point — the vendor split hooks in there. Do not reintroduce an order-level after_transition extension surface; multi-vendor must not depend on one. When fleshing out the checkout flow on Cart, design delivery selection per package (packages come from order routing) — the multi-vendor checkout needs one delivery choice per vendor package before completion, and payments must be attachable to an OrderGroup, not only to an Order.
  • Split adjustments (6.0-split-adjustments.md): keep TaxLine/Discount/Fee strictly line-item- or fulfillment-attached (already the resolved design — order-level discounts distribute to line items) so every adjustment re-homes unambiguously to one child order during the split. Do not add a commission/marketplace Fee kind — commission is Spree::CommissionLine, off-order (Decision 2); Fee stays buyer-facing.
  • Inventory operations (6.0-inventory-operations.md): the procurement model is Spree::Supplier — never reuse Spree::Vendor (reserved for marketplace sellers) for purchase-order sources. See decisions.md 2026-07-14.
  • External-store sync stays Enterprise: do not add external store/resource models, associations, or callbacks to core (no external_store on Vendor, no external product/order/category tables, no platform sync hooks). The Shopify/WooCommerce connection layer is an Enterprise concern that decorates core's Vendor from outside; core only needs to stay decorable (events + the onboarding task registry are the seams it plugs into).
  • Fulfillment & delivery (6.0-fulfillment-and-delivery.md): the ledger trigger is defined against Fulfillment ("all fulfillments fulfilled"), and DeliveryMethod gets an optional vendor_id — keep the fulfillment-status surface queryable per order (child orders fulfill independently) and don't assume one order per checkout in fulfillment UIs.
  • Order routing (6.0-order-routing.md): the per-child stock-location decision must come from the routing strategy, so a vendor's items allocate from that vendor's stock location. Keep for_allocation vendor-aware-friendly (it already takes order:).
  • Tax provider (6.0-tax-provider.md) and Delivery rate provider (6.0-delivery-rate-provider.md): the PayoutProvider follows the same pluggable-strategy shape — keep the provider-registration convention consistent so all three (tax, delivery rate, payout) register and resolve the same way.
  • Admin API (6.0-admin-api.md): the Vendor prefix is ven_ (matches legacy has_prefix_id :ven). The "Marketplace (new in 6.0)" endpoint group there is this plan's API surface — keep it in sync.
  • Do not revive the legacy commission model's shape (label-based rows + denormalized fee totals on the order), do not model commission as a polymorphic Adjustment, and do not write commission as a Spree::Fee (fees are buyer-facing and roll into order totals — Decision 2) — 6.0 uses the off-order Spree::CommissionLine ledger. Do not put the fund ledger in a payment-gateway namespace (the legacy gateway-namespaced-transfer mistake) nor collapse transfer and payout into one model — Spree::VendorTransfer (on-fulfillment) and Spree::VendorPayout (scheduled) are both core and provider-agnostic.

Resolved Decisions

  • Basic Stripe Connect execution → OSS (boundary amendment) (RESOLVED 2026-07-15). Express onboarding + on-fulfillment transfers ship in the monorepo alongside the pulled-in Stripe gateway, for demo/adoption parity with the surveyed competitor — "money moves automatically" is the demo that sells, and transfers are inseparable from basic onboarding (a Stripe::Transfer needs a connected account). Enterprise repositions on money operations: automatic refund clawbacks (prorated transfer reversals + negative-balance netting), KYC/account-health workflows, ledger⇄Stripe reconciliation, payout reports incl. DAC7, facilitator taxes. Ledger reversal rows stay OSS in every mode — only the pullback execution is paid. Supersedes #13323's "execution stays Enterprise" framing (amendment by the issue author; update the issue). See decisions.md 2026-07-15.
  • Spree::Vendor name ownership → marketplace seller; inventory-operations' procurement model renamed Spree::Supplier (RESOLVED 2026-07-14). See the Key Decisions entry and decisions.md 2026-07-14.
  • Commission is not a Spree::Fee (RESOLVED 2026-07-14). Fee is buyer-facing and rolls into order totals; CommissionLine is an off-order platform↔vendor ledger. See Decision 2.
  • Commission rule matching → dimension-grouped (AND across subject types, OR within one) (RESOLVED 2026-07-14). Expresses plain lists and the vendor × taxon pairings today's Enterprise sells, with no match_policy knob; same semantics as the leading surveyed OSS engine, but precedence stays explicit operator priority rather than implicit specificity. See Decision 4.
  • Commission on delivery revenue → include_shipping on the rate (RESOLVED 2026-07-14). Per-rate flag, default off (legacy was items-only); a fulfillment-scoped CommissionLine (nullable fulfillment_id, exactly-one-of with line_item_id). Matches the surveyed OSS competitor (global-only there) and Amazon referral-fee behavior. See Decision 4.
  • Split payment record → Spree::PaymentSplit + payments.order_group_id (RESOLVED 2026-07-14). One gateway payment on the group, one split row per child order; no cloned Payment rows. See Decision 5 + Design Details.
  • Vendor teams → reuse Role/RoleUser/Invitation with resource: vendor (RESOLVED 2026-07-14). The polymorphic rails already exist in core (Invitation's docstring names Vendor); no VendorUser model. See Vendor identity & teams.
  • Parent representation → dedicated Spree::OrderGroup (RESOLVED 2026-06-16). See Decision 8 + Design Details. Diverges from the legacy parent_id/splitted shape; requires the Enterprise backfill.
  • Commission tax rate source → per-CommissionRate field, resolved through the TaxProvider (RESOLVED 2026-06-16). Not a per-vendor platform_fee_tax_rate column (legacy) and not a bare per-market lookup. The rate carries its own commission-tax configuration with a sensible store default; the actual rate is resolved via 6.0-tax-provider.md so it stays jurisdiction-correct. See Decision 3.
  • Fund ledger → two levels: Spree::VendorTransfer (per-order, on-fulfillment) + Spree::VendorPayout (per-interval settlement), both generic in core + pluggable PayoutProvider (system no-op in core; basic StripeConnect with the monorepo Stripe gateway per the 2026-07-15 amendment, extended by Enterprise with the money operations) (RESOLVED 2026-06-25). See Decision 9. Transfer credits the vendor balance on fulfillment; payout sweeps unsettled transfers into a scheduled bank settlement (transfers belong_to their payout — auditable). vendor.balance(currency) = Σ completed transfers − Σ completed payouts per currency. Splits the legacy gateway-namespaced transfer ledger (which conflated the two and outsourced settlement to Stripe) into core ledger + provider execution along the OSS/Enterprise seam.
  • Commission VAT base → net item price, VAT added on top of the fee (RESOLVED 2026-06-25). For EU, commission is computed on the seller's net (ex-VAT) item price; the commission tax_amount (Decision 3) is then applied on top of the commission as a separate B2B service supply — the marketplace fee is a distinct taxable event from the consumer's item sale, so the two VATs never mix. The include_tax toggle stays available for non-EU/edge merchants who genuinely want a gross base, but the EU default is net-base + fee-VAT. This makes the marketplace's commission invoice to the seller stand on its own (correct for EU invoicing + remittance).
  • Store-credit/gift-card payout → payment-source-agnostic (RESOLVED 2026-06-25). The VendorTransfer amount (which rolls up into the payout) is (vendor's sale value) − (commission owed), independent of how the customer paid. Store credit / gift cards are the platform's funding concern, not the vendor's — the vendor is owed their full cut of the sale regardless. This drops the legacy store-credit dual-basis from the amount calculation; that branch existed only to reconcile the old store-credit funding model and does not belong in the payout basis. (The platform still tracks its own store-credit liability separately — that's orthogonal to what the vendor receives.)
  • Commission precedence → integer priority + seeded defaults (RESOLVED 2026-06-25). CommissionRate.priority is operator-assignable, walked DESC, first match wins (Decision 4). Core seeds default priorities so a product-scoped rate beats a vendor-scoped rate out of the box — simple merchants never touch the knob; advanced merchants reorder freely. No hardcoded semantic ladder.
  • Commission rate currency → required for fixed, ignored for percentage (RESOLVED 2026-06-25). A kind: 'fixed' rate must carry a currency (a flat "2" is meaningless without one); a kind: 'percentage' rate ignores currency (10% applies in any currency). Enforced by a model validation: validates :currency, presence: true, if: -> { kind == 'fixed' }. See the CommissionRate model.
  • Payout vs transfer trigger → transfer on-fulfillment, payout on-interval (RESOLVED 2026-06-25). These are the two distinct events of Decision 9: a VendorTransfer becomes due on-fulfillment (the vendor earned it); a VendorPayout settles on the vendor's scheduled interval (payouts_schedule_interval). The earlier "on-ship vs on-deliver vs scheduled" framing was a category error — it conflated the two. Core's system provider honors manual settlement natively; Enterprise implements daily/weekly/biweekly/monthly.

Open Questions

  1. Store credit / gift card split across vendors. The references and the legacy module prorate store credit across child orders. Confirm this survives the 6.0 store-credit model and the Carts::Complete flow. (Distinct from the resolved transfer-basis decision — this is about which child order a store-credit payment is attributed to during the split, not about the transfer amount, which is now payment-source-agnostic.)
  2. Payout holdback / minimum threshold. Real marketplaces hold a rolling reserve (refund/chargeback buffer) and skip sub-threshold payouts (minimum_payout_amount, "carry balances under €50 to the next period"). Both are easy columns on Vendor + guards in the sweep job — decide whether v1 core ships them or they start as Enterprise policy.
  3. Marketplace-facilitator tax remittance. US marketplace-facilitator laws (and EU deemed-supplier rules for some scenarios) make the platform remit consumer tax, which changes the transfer basis to total − tax_total − commission. Currently parked as Enterprise "automatic taxes" — confirm core doesn't need at least a tax_remittance: vendor|platform flag on Vendor so the ledger math is provider-independent.
  4. Vendor-funded vs platform-funded promotions. v1: discounts reduce the vendor's sale value (vendor-funded). Platform-funded promos ("we run a 10% site-wide sale and make vendors whole") need a funding flag on Promotion and a compensating ledger line — post-6.0.
  5. Vendor-scoped promotions. Can a vendor create promotions limited to their own products (the surveyed OSS competitor supports seller promotions)? Requires promotions.vendor_id + ability scoping + a guard that vendor promos only match their line items. Lean post-6.0; cheap to add on the single-store Promotion model.

References

  • spree/spree#13323 — "Open source Spree Multi Vendor for Spree 6" (the OSS/Enterprise scope split; amended 2026-07-15 — basic Stripe Connect execution moved to OSS, see decisions.md; the issue text needs a matching update).
  • Current Enterprise multi-vendor module (private source, actively maintained against Spree 5.4+): source-read for this port — the Vendor lifecycle, per-item commission + commission-VAT formula, parent/child split flow, Stripe transfer ledger + reversal chain, vendor-user permission baseline, and the settlement-statement report. Internals (file paths, class/table names) are deliberately not cited in this public file — the Enterprise repository holds the detailed port map. Public behavior cross-checked against the user docs (next bullet).
  • Surveyed modern open-source marketplace implementations (primary-source survey 2026-07-14; names and the detailed comparison deliberately kept out of this public file): source of the commission rule-engine shape (dimension-grouped matching, percentage | fixed, include_tax/include_shipping), the OrderGroup container pattern, and the payout-provider abstraction. Framework-specific machinery NOT to copy: big-number jsonb sidecar columns, remote-link join-table indirection (use Rails FKs + has_many), and external workflow/step engines (use Spree services + publish_event). The leading implementation's payout pipeline creates per-order payouts after capture and keeps no balance ledger and no reversal chain — Spree's two-level ledger is designed from scratch.
  • Current Enterprise behavior (user docs, spreecommerce.org/docs): user/vendors/vendor-management (lifecycle: Invited → Onboarding → Ready for Review → Approved/Rejected/Suspended; branding; onboarding checklist), user/vendors/commission-rates (product > vendor > marketplace-default ladder, category-vendor pairing), user/vendors/vendor-payouts (schedule intervals incl. bi-weekly + "use marketplace default"), user/vendors/multi-vendor-checkout (per-vendor delivery selection, suborder split, per-vendor tracking), user/settings/marketplace (default rate copied at invite, vendor ToS), api-reference/storefront/vendors (public vendor profile fields).
  • Related plans: 6.0-cart-order-split.md, 6.0-split-adjustments.md, 6.0-order-routing.md, 6.0-tax-provider.md, 6.0-delivery-rate-provider.md, 6.0-fulfillment-and-delivery.md, 6.0-returns-exchanges-claims.md, 6.0-inventory-operations.md (Supplier disambiguation), 5.4-6.0-eu-legal-compliance.md, 5.6-admin-spa-csv-import.md, 6.0-admin-spa.md, 6.0-admin-api.md, 6.0-channels-catalogs-b2b.md (B2B — deferred to 6.1, see decisions.md).