docs/plans/decisions.md
Merchant asks (wholesale net payment vs DTC Stripe, installments above an
order-total threshold, market-bound payment methods, channels limited to
certain markets) plus a four-platform review (Vendure channel-scoped
PaymentMethods + PaymentMethodEligibilityChecker, Medusa region-scoped
payment providers, Saleor per-channel payment apps, Shopify Payment
Customization Functions + the May-2026 per-market multi-entity Shopify
Payments) settled three things:
5.7-payment-method-rules.md — Spree::PaymentMethodRule STI
(Channel / Market / OrderTotal / CustomerGroup rules), mirroring the
PromotionRule/PriceRule/OrderRoutingRule house pattern; enforced through
the single Order#collect_frontend_payment_methods seam; storefront-only
(admin/backoffice bypasses). Supersedes the "payment methods have no
distribution concept" rationale in
5.6-6.0-single-store-promotions-payment-methods.md — the single-store FK
stands, eligibility is layered on via rules.5.7-channel-markets.md — optional Channel→Markets allowlist
(spree_channel_markets, empty = all markets), enforced in market
resolution, the Store API markets reference endpoints, and order
validation. Composes with MarketRule above.types
"already installed" filter relaxed) vs per-channel credential mapping on
one record (Saleor style) — plus the legal-entity attribution question
(per-entity payouts, compliance, reporting). No plan yet; do not implement.Per-channel Spree::OrderRoutingRule management (the Phase 2 "Admin API + SPA
settings page" slice of 6.0-order-routing.md) ships ahead of the rest of
Phase 2: Admin API v3 CRUD nested under channels
(/channels/:channel_id/order_routing_rules) + top-level
/order_routing_rules/types discovery, @spree/admin-sdk resource, and a
routing-rules editor inside the dashboard's channel edit sheet (drag-to-reorder,
active toggles, schema-driven preference forms via PreferencesForm).
No legacy Rails admin UI — routing rules are managed exclusively in the React dashboard. The legacy admin's channel form keeps only the strategy override select it already had. New admin surfaces target the SPA; the legacy admin is in maintenance mode for 6.0.
The gated wholesale portal's apply form collects a company name. Considered
promoting it to a nullable company column on spree_users as a pre-6.1
stepping stone — mechanically fine (core already migrates that app-owned table:
add_phone_to_spree_users, add_first_name_and_last_name_to_spree_users), and
it would buy Ransack searchability and first-class serializer/export support.
Rejected for now. Free-text company on the customer becomes a second source
of truth the moment Spree::Company lands in 6.1, and free text doesn't dedupe
("Acme" / "Acme Ltd" / "ACME Limited"), so the backfill into real Company
records is messy and the column lingers as a deprecated shadow. Applicant
company is stored in customer metadata ({ company: "…" }) instead — already
supported end-to-end by RegisterParams + the Store customers controller, no
migration, no invented field.
The proper implementation is the Company → CompanyLocation → CompanyContact
tree in 6.0-channels-catalogs-b2b.md Phase 2 (6.1). Note that plan's
decision 7: approval workflows, role-based permissions, purchase limits, and
invoice management are deferred to a dedicated B2B plan after 6.1 — so the
demo's manual "admin adds the customer to the Wholesale group" approval step is
intentional and survives past 6.1 until that plan exists.
Also decided: no company row in the legacy Rails admin customer view. Metadata is exposed by the Admin API v3 customer serializer (React dashboard shows it); the legacy admin renders customer metadata nowhere and stays that way.
spree-starter and create-spree-app no longer provision Meilisearch. The Docker
compose files stop running the meilisearch service and stop hardcoding
MEILISEARCH_URL, so Spree::SearchProvider::Database — already the core
default and already the effective default on the native (no-Docker) path — is
now the default on every install path. One less always-on container (image
pull, RAM, volume) for the common case; Spree::SearchProvider::IndexJob /
RemoveJob never enqueue under the DB provider (indexing_required? is
false), so the default stack also stops paying per-save indexing jobs.
Opt-in stays config-only: commented service/depends_on/env/volume blocks in
both compose files + MEILISEARCH_URL, then spree:search:reindex. The
meilisearch gem stays in the starter Gemfile so the prebuilt
ghcr.io/spree/spree image retains the capability — enabling Meilisearch on
the quick-start compose must not require a custom image build. Docs continue
to recommend Meilisearch for production-scale catalogs (see
5.4-search-provider.md); this changes what's provisioned by default, not the
recommendation.
spree/spree#13323 originally kept "Stripe Connect onboarding, KYC, automatic
payouts" in Enterprise. Amended by the issue author: the basic Stripe Connect
path ships open-source in the monorepo, registering
Spree::PayoutProvider::StripeConnect — Express-account onboarding (hosted link +
account.updated status webhook) and on-fulfillment Stripe::Transfer execution
(source_transaction-tied), plus mapping the vendor payout schedule onto Stripe's
native schedule. It lands alongside the Stripe core gateway being pulled into the
monorepo 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 in that repo stays behind.
Rationale: the closest OSS competitor ships baseline Stripe transfers free —
"money moves automatically" is the demo that sells — and transfers are inseparable
from basic onboarding (a Stripe::Transfer requires a connected account), so a
transfers-only OSS cut was never buildable.
Enterprise keeps the money operations: automatic refund clawbacks (prorated
transfer reversals + negative-balance netting), KYC/account-health workflows beyond
hosted onboarding, ledger⇄Stripe reconciliation, payout reports incl. DAC7,
marketplace-facilitator taxes, and the Shopify/WooCommerce vendor apps. Ledger
correctness (reversal rows) stays OSS in every mode — only pullback execution is
paid. "Core runs the happy path; Enterprise operates the unhappy paths and
compliance at scale." #13323 to be updated accordingly.
Plan: 6.0-multi-vendor-marketplace.md.
Spree::Vendor = marketplace seller; procurement source renamed Spree::SupplierTwo 6.0 plans introduced a Spree::Vendor: the marketplace seller
(6.0-multi-vendor-marketplace.md, prefix ven_) and the purchase-order
procurement source (6.0-inventory-operations.md, prefix vnd_). Same class
name, same spree_vendors table, different domains — a hard collision.
Resolution: the marketplace owns the Vendor name. It is locked publicly
(spree/spree#13323, the user docs' "Vendors" area, the legacy Enterprise gem's
ven_ prefix and its production data). The inventory-operations model is renamed
Spree::Supplier (spree_suppliers, prefix sup_, /api/v3/admin/suppliers) —
matching Shopify's purchase-order vocabulary and standard ERP terminology. They
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.
Hybrid marketplaces (operator buys wholesale from a marketplace seller and
resells first-party) can later bridge the two with an optional
Spree::Supplier#vendor_id link — not scoped for 6.0.
6.0 is themed as the Marketplace release, headlined by open-sourcing the
multi-vendor marketplace (per spree/spree#13323) alongside React dashboard GA and
the architecture/rename wave. B2B (Catalog + Company/CompanyLocation/CompanyContact
from 6.0-channels-catalogs-b2b.md Phase 2) moves to 6.1, marketed as the B2B
release. Rationale: a crowded 6.0 dilutes the launch; one sharp headline per release
earns more buzz, and the B2B Phase 2 work is plan-only (not started), so deferring
it frees capacity for the multi-vendor open-sourcing rather than parking finished code.
Multi-vendor OSS/Enterprise boundary per #13323 as of this date (superseded in
part by 2026-07-15 above — basic Stripe Connect execution later moved to OSS):
core ships Vendor identity, order splitting, the commission engine (with EU
commission taxation), the payout ledger (Spree::VendorPayout — records what's
owed, provider-agnostic), vendor dashboard, CSV import/export, and Vendors API;
Enterprise keeps Stripe Connect/KYC and the execution of payouts (a
PayoutProvider::StripeConnect strategy), payout reports, Shopify/WooCommerce
sales-channel apps, and the category mapper. New plan:
6.0-multi-vendor-marketplace.md. The legacy Enterprise multi-vendor module is
rebuilt as native core models on top of the 6.0 Cart/Order split.
Spree::StockItem → Spree::StockLevel, spree_stock_items → spree_stock_levels.
Prefix ID: si_ → sl_.
Every other platform uses "level" for this concept — Shopify (InventoryLevel),
Medusa (InventoryLevel), Vendure (StockLevel), Saleor (Stock). "Item" sounds
like a physical object; "level" correctly describes "the quantity of a variant at
a location."
Part of the 6.0 model rename wave. Includes renaming the FK columns
(stock_item_id → stock_level_id) on StockMovement, StockReservation, and
any other referencing tables.
As part of the User → Customer rename (6.0-platform-auth.md), rename user_id
foreign key columns to customer_id on all models where the FK references a
storefront customer (not an admin user).
Rename to customer_id (11 models — FK references Spree.customer_class):
spree_orders.user_id → customer_idspree_addresses.user_id → customer_idspree_credit_cards.user_id → customer_idspree_store_credits.user_id → customer_idspree_wishlists.user_id → customer_idspree_gift_cards.user_id → customer_idspree_gateway_customers.user_id → customer_idspree_payment_sources.user_id → customer_idspree_newsletter_subscribers.user_id → customer_idspree_promotion_rule_users.user_id → customer_idspree_customer_group_users.user_id → customer_idKeep as user_id (5 models — FK references Spree.admin_user_class or is polymorphic):
spree_imports.user_id — admin who ran the importspree_exports.user_id — admin who ran the exportspree_reports.user_id — admin who generated the reportspree_state_changes.user_id — admin who triggered the changespree_user_identities.user_id — polymorphic (Customer or AdminUser)Single migration renames all 11 columns. Model associations updated:
belongs_to :user → belongs_to :customer with class_name: Spree.customer_class.
Both PaymentMethod and DeliveryMethod (renamed from ShippingMethod) switch from
multi-store join tables (StorePaymentMethod, StoreShippingMethod) to
SingleStoreResource with direct belongs_to :store.
In practice, different stores have different currencies, zones, and provider accounts — sharing the same payment/delivery config across stores is rare. If a merchant wants the same config on two stores, they create two records.
Changes:
store_id column to spree_payment_methods and spree_delivery_methodsstore_id; duplicate methods linked
to multiple storesspree_store_payment_methods and spree_store_shipping_methods join tablesSpree::SingleStoreResource concernRename Spree::Promotion::Rules::* → Spree::PromotionRules::* and
Spree::Promotion::Actions::* → Spree::PromotionActions::*.
The convention for STI subtypes is Spree::{BaseClass}s::{Subtype} — pluralized
base class as the namespace. Every other hierarchy follows this already:
Spree::PriceRules::VolumeRuleSpree::Metafields::ShortTextSpree::CollectionRules::Tag (from categories plan)Spree::ReimbursementType::CreditPromotion was the only one nesting under the parent model (Spree::Promotion::Rules)
instead of the base class (Spree::PromotionRules).
Changes:
app/models/spree/promotion/rules/ → app/models/spree/promotion_rules/app/models/spree/promotion/actions/ → app/models/spree/promotion_actions/type column in spree_promotion_rules and spree_promotion_actions
(e.g., Spree::Promotion::Rules::Product → Spree::PromotionRules::Product)Settle on status as the standard column name for state machines. Newer models
(Product, PriceList, PaymentSession, Import, Invitation) already use status.
Order.state and Adjustment.state are removed entirely by other 6.0 plans
(cart-order-split, split-adjustments). Five remaining models need a column
rename from state → status in 6.0:
state → statusstate → statusstate → statusstate → statusstate → statusSingle migration renaming all five columns. State machine declarations updated
to state_machine :status, initial: .... Deprecation aliases
(alias_attribute :state, :status) for one release.
Models already correct (no change): Product, PriceList, PaymentSession,
PaymentSetupSession, Import, ImportRow, Invitation, ReturnItem
(reception_status/acceptance_status), Reimbursement (reimbursement_status).
Replace three-way display_on (both/front_end/back_end) with storefront_visible
boolean (default: true) on CustomFieldDefinition. front_end-only was already
excluded from MetafieldDefinition::DISPLAY and never made sense.
This makes the two-system boundary razor-sharp:
Matches Vendure (public: boolean) and Saleor (visibleInStorefront: boolean).
Ships with the 6.0 model rename wave. See 5.4-6.0-custom-fields-rename.md.
Drop public_metadata column (never exposed in Store API, unused). Rename
private_metadata → metadata in the database. Simplify the Spree::Metadata
concern to a single metadata JSON column with no alias indirection.
Metadata (JSON column) is a permanent, first-class system — the schemaless
developer escape hatch for integration IDs, sync state, ad-hoc flags. No
definition required, one-step API:
PATCH /product { metadata: { erp_id: "123" } }. Never exposed in Store API
(Stripe convention: write-only). Metadata is here to stay.
Metafields (→ Custom Fields in 6.0, see 5.4-6.0-custom-fields-rename.md)
stay as merchant-defined structured data — typed values (short_text, number,
boolean, json, rich_text, long_text), require a MetafieldDefinition, have
storefront_visible boolean, searchable, CSV importable. With the
ProductType plan (6.0-product-types.md), metafields become schema-enforced
custom attributes driven by ProductType.
Two systems, two purposes, no overlap. No consolidation into one. Metadata for machines, metafields/custom fields for humans.
Considered Action Text. Rejected for API-first performance — serializing rich text adds overhead for every product response. Also in the new Admin UI we will use TipTap for rich text editing.