docs/plans/6.0-multi-vendor-marketplace.md
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#owner — currently 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
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).
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 screens | Refund 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 split | Shopify white-label Sales Channel app |
Fund ledger — VendorTransfer (on-fulfillment earnings) + VendorPayout (scheduled settlement) + balance, lifecycle, reversals | WooCommerce 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.
The legacy module and current industry implementations agree on the shape, and it maps cleanly onto 6.0-cart-order-split.md:
Spree::Cart through the entire shopping + checkout flow (a customer adds items from many vendors to one cart — no per-vendor cart fragmentation).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).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.
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).
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.cart.complete!, once. A later rate change does not touch existing lines.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.)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,total — amount + 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).
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.
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'.
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).
The legacy module is a large decorator layer prepending ~35 core classes. 6.0 brings this into core as real models, associations, and services — Spree::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).
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:
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.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.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.
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.
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:
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."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.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):
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.
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:
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
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.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.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.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.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):
| Table | Column | Why |
|---|---|---|
spree_products | vendor_id (nullable) | product ownership / split partition key |
spree_stock_locations | vendor_id (nullable) | vendor-owned stock, feeds order routing |
spree_orders | vendor_id (nullable), order_group_id (nullable) | child-order identity |
spree_line_items | vendor_id (nullable, denormalized) | split partition + vendor scoping without 3-join hops |
spree_payments | order_group_id (nullable) | the single shared payment (Decision 5) |
spree_delivery_methods | vendor_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).
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).
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.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.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).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.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).# 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.
# 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 payment — child.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).
# 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:
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.
# 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
# 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:
# 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
# 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.
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):
reversed CommissionLine (net commission = sum(commission_lines)).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.
vendor_id (nullable — the operator's own first-party products have none). Vendor users only see/manage their own (ability tenancy condition).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).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.vendor_id instead of dropping it.CollectionRule kind (6.0-replace-taxons-with-categories.md), replacing the legacy Spree::TaxonRules::Vendor.Vendor notifications are event-driven, with OSS mailers included — split along the existing mailer boundary:
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.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.
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).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.{ 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).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).
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:
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:
splitted parent order, create a Spree::OrderGroup and attach its parent_id children as the group's orders.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.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.Build order (each phase shippable):
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.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).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.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!.@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.
Even before this lands, work touching adjacent areas should:
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.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.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 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).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.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:).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.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.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.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.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.match_policy knob; same semantics as the leading surveyed OSS engine, but precedence stays explicit operator priority rather than implicit specificity. See Decision 4.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.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.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.Spree::OrderGroup (RESOLVED 2026-06-16). See Decision 8 + Design Details. Diverges from the legacy parent_id/splitted shape; requires the Enterprise backfill.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.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.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).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.)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.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.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.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.)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.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.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.decisions.md; the issue text needs a matching update).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.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).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).