DocsWorking on the code
The commerce model
On this page4 sections
The engineering side of the multi-channel model: the identifiers you’ll code against, and the per-domain notes that aren’t obvious from the code.
For what a channel is and how scope, locale and currency behave, read concepts.md then channels.md. This page assumes both and doesn’t repeat them.
Header-driven scope (dashboard)
The single source of truth for “what scope am I editing in” is the header
dropdowns, implemented in apps/dashboard/src/lib/config-scope.ts (state and
hooks) and config-scope-ui.tsx (the selector). Mode is GLOBAL or
CHANNEL. In CHANNEL the user picks a channel and optionally a locale. Pages
read from useConfigScope():
effectiveChannelId- the channel id when CHANNEL, elseundefinedeffectiveLocale- explicit locale > channel default >''isGlobal/isChannelflags
Rules (wired across products, customers, blog, cms, categories, collections, currencies, countries, locales, attributes, product types):
- Lists in CHANNEL are filtered to that channel. GLOBAL shows everything.
- Detail pages drop in-form locale pickers; the header is the only locale source. Detail forms in GLOBAL show translation fields read-only.
- Per-channel availability cards: GLOBAL renders a multi-channel editor; CHANNEL renders a single-row editor for the active channel.
- Truly global entities (categories, collections, attributes, currencies, countries, locales, product types) disable mutations in CHANNEL.
- Mutations that require a channel gate behind
useScopeRequirement({ requiresChannel: true })rather than checking the flag by hand. - Switching the header with unsaved edits triggers a confirm prompt
(registered via
useUnsavedChangesGuard(isDirty)).
Per-channel availability
Products, categories, CMS pages and blog posts each have a per-(entity, channel) listing row that gates storefront visibility. Without a row, the storefront hides the entity entirely. The Go domain is symmetric across these:
| Entity | Go type | Where |
|---|---|---|
| Product | catalog.ProductChannelListing | internal/catalog/listing.go |
| Category | catalog.CategoryChannelListing | internal/catalog/category.go |
| Blog post | blog.PostChannelListing | internal/blog/domain.go |
| CMS page | db.CmsPageChannelListing | internal/cms/repository.go |
The tables are product_channel_listings, category_channel_listings,
blog_post_channel_listings and cms_page_channel_listings, all created in the
schema baseline.
Admin: edit listings via the entity’s detail page (single-row in CHANNEL, multi-channel grid in GLOBAL). The save-confirm modal lists every staged change before commit.
Permissions
Code-declared: permission.RegisterGroup + permission.Register in domain
init(), with the core groups in internal/permission/defaults.go and the rest
registered by their own domain packages. permission.Sync()
(internal/permission/registry.go) upserts rows and CROSS JOIN grants all
permissions to administrator, so adding a permission needs no migration.
- Admins: permissions via roles;
is_superuser = truebypasses checks. - Integrations: permissions directly via
integration_permissions. - Guards (
auth/guard.go,auth/actor.go):RequireActor(an admin or a token),RequireAdmin(a person only),RequireCustomerAuth,RequirePermission(ctx, checker, slug),RequireTokenPermission(ctx, slug),RequireChannelPermission(ctx, checker, slug, channelID, locale)- the last one keeps a pre-rename name, but the argument is a channel id.
Seeded roles: administrator (all), store_manager (catalog/orders/customers),
support_agent (read-only), seeded in the reference-data baseline.
The slug catalogue is generated, not listed here - see permissions.md for the current reference and permissions-enforcement.md for the who/what/scope contract to read before writing a resolver gate. A hand-copied list in this file went stale; don’t reintroduce one.
Key domain notes
Attribute value storage (catalog): the attribute’s input type picks
the store. Scalars (plain_text/numeric/boolean/date/date_time) live in
catalog_products.attributes JSONB keyed by attribute slug; selectables
(dropdown/multiselect/swatch) are junction rows in
catalog_product_attribute_values pointing at the per-attribute value
dictionary. The JSONB value is canonical and locale-independent - it is
what the admin edits, the indexer’s facet slugs, URL {attr:} tokens,
and CSV import/export all read. Locale is a sparse overlay resolved at
the front GraphQL edge only (graph/front/attribute_localization.go):
plain_text values can get per-locale override rows
(catalog_product_attribute_locale_values, empty/equal-to-canonical =
no row, falls back to canonical); selectable value names translate at
the dictionary level (catalog_attribute_value_content). Channels never
scope values - only URL slugs (catalog_attribute_value_channel_slugs,
catalog_product_attribute_slugs global row + per-channel override).
Admin users: roleSlugs: [] clears all roles; nil leaves unchanged.
deleteRole refuses (ErrRoleInUse) if role has members.
Customer: adminCreateCustomer - admin supplies password,
isEmailVerified defaults true, no verification email fired.
deleteCustomer refuses (ErrCustomerHasOrders) if orders exist.
Customer.group is non-nullable - every return path must load it;
toCustomerModel panics on nil in dev to catch missed loads.
setDefaultCustomerGroup atomically swaps via tx, no window with zero
defaults. Customer.note is admin-only internal text (not exposed to
the customer-facing API).
Consent (customer_consents): nil row falls back to
Definition.DefaultOptIn. Load-bearing rule: any marketing send MUST
go through consent.Service.IsOptedIn - raw repo.Get treats nil as
“no opinion” and will send to non-consenting customers.
Email: LogSender (dev, no SMTP) / SMTPSender (env:
SMTP_HOST/PORT/USERNAME/PASSWORD). SMTP_USERNAME empty = skip auth.
Non-dev with no SMTP → error-sender that fails every Send loudly.
The optional mailpit dev tool catches mail at :1025, UI at
http://localhost:8025. Token stores: VerificationStore (24h, prefix
verify:customer) + PasswordResetStore (30m, prefix reset:customer).
resetPassword calls RefreshStore.RevokeAll. STOREFRONT_URL env
builds link base.
Cart: guest cart - knowing UUID is the capability. Customer cart -
JWT required. mergeGuestCart adopts guest cart if no active customer
cart (no copy). ConvertCart is the concurrent placement serialization
point - concurrent callers get ErrCartAlreadyConverted. Statuses:
active → converted (placeOrder) or merged (mergeGuestCart).
Checkout lives on the cart, in three steps: how the order arrives,
who it is for, then payment. setCartDestination saves a country and
postcode, which is all delivery and pickup need to be quoted, so no
address is typed until the second step. setCartShippingMethod picks
one of the quotes, delivery or pickup. setCartDetails saves contact,
the business details, and either the delivery address with its billing
address or, for pickup, who collects it, in one write. A pickup order
needs no shipping address: placement ships it to the pickup location,
addressed to the collector. Then setCartPaymentMethod,
applyGiftCard and setCartStoreCredit, plus setCartEmail and
setCartNote. Each is validated in the resolver and saved on the cart,
so a reload resumes where the shopper was. A step is computed from the
cart as loaded, with no transaction open, since its quotes and checks
may wait on a carrier or a remote validator; the write is a short
transaction that takes the row lock and saves only if
carts.checkout_version hasn’t moved, recomputing the step otherwise
(CHECKOUT_CONFLICT after three tries). The steps and Cart.checkout
share a per-IP budget, CHECKOUT_RATE_LIMIT_PER_MIN. What the
details step asks for and requires follows the sales/checkout/*
settings (phone, business purchase, PO number, someone else
collecting, a billing address on pickup orders), and placement
re-checks them. The company identifiers a business purchase asks for
are defined per country (countries.business_id_fields: label, key,
required, an RE2 pattern, a hint) and follow the company’s country - its
billing address, else the destination - with one generic tax ID, per
sales/checkout/tax_id_field, where a country defines none. The order
keeps them with their labels (orders.business_ids) for the invoice. Addresses are snapshots with the saved address they
came from. Moving the destination, or saving an address in another
postcode, drops a shipping or payment method it no longer supports, and
Cart.checkout re-checks both on read, so a line change that alters the
quotes shows up as an unchosen method. A carrier that fails to answer
isn’t one that stopped offering the method: the choice is kept, and
choosing or placing answers SHIPPING_QUOTE_FAILED. Purchase order is
offered only on a business purchase whose details step asks for the PO
number (sales/checkout/po_number_field), and payment eligibility is
checked for the destination’s country at every step and at placement,
pickup included. A pickup with no billing address is billed to the
collector by name, never to the store’s address. Cart.checkout.totals is priced
by order.Service.QuoteCheckout, the placement pricing path without the
transaction, which quotes from the destination alone until there is an
address. placeOrder(cartId, paymentToken, savedPaymentMethodId, acceptTerms) reads everything else from the cart and refuses an
incomplete checkout with a CHECKOUT_*_REQUIRED code, a chosen method
the carriers no longer offer with SHIPPING_METHOD_UNAVAILABLE rather
than the cheapest one, and an unticked terms box with
TERMS_NOT_ACCEPTED when sales/checkout/terms_consent is checkbox. On merge the guest cart’s selections and
email win.
Orders: prices in cents (BIGINT). Address denormalized on
orders (snapshot at placement, never changes). order_events
immutable audit log. Number format ORD-001000. PlaceOrder wraps
create+lines+event+convert-cart in one tx. Status:
pending → confirmed → processing → fulfilled → cancelled.
Config: paths always section/group/field. Scope is default or
channel; all global values at default/0. Resolution:
requested scope → default/0 → registry default. Set validates +
normalizes (int "007" → "7", bool "1" → "true"). Unknown paths
rejected.
Catalog: ProductType slug immutable. Attribute slug + inputType
immutable (changing inputType orphans values). Dual attribute storage:
scalar types (plain_text/numeric/boolean/date/date_time) →
products.attributes JSONB; selectable types
(dropdown/multiselect/swatch) → product_attribute_values junction.
setProductAttributes upsert - only touched attributes change.
hasVariants flip blocked while variants exist
(ErrHasVariantsFlipBlocked). Known footgun: deleting an attribute
leaves dead JSONB keys on products.
Catalog product listings: ProductChannelListing ties (product,
channel) with isPublished + optional explicit price. null price means
inherit from defaultPrice - only valid when the channel currency matches
the product’s defaultCurrencyCode. Currency mismatch on inherit is
rejected (ErrListingCurrencyMismatch).
Catalog category listings: CategoryChannelListing ties (category,
channel) with isPublished + optional sort_order_override. Without a
row, the category is hidden on that storefront. Storefront resolvers
(rootCategories/categoryChildren/category) branch on the active
channel: with one in scope, only listed-and-published categories are
returned. Admin’s rootCategories(channelId) and
categoryChildren(parentId, channelId) accept the same filter so the
admin tree mirrors what the storefront serves. The dashboard’s category
pickers read categoryOptions(channelId) instead - every category flat,
with parentId and a direct product count, which the client builds into a
tree at any depth (the nested tree queries stop at a fixed depth). Creating a category in
CHANNEL mode auto-fires setCategoryListing on the active channel so
new categories show up where the user is working.
Events (internal/events/): Publish (async, background goroutine,
errors logged, panics recovered) · PublishSync (blocking, first error
aborts chain). -ing events = pre-commit sync (can abort/enrich).
-ed events = post-commit async. Bus is in-process only - not durable.
Subscriber wiring in RegisterDomainSubscribers in
internal/wiring/subscribers.go - registration order there is load-bearing.
Webhooks (internal/webhook/): async (post-commit) · sync
(pre-commit). HMAC-SHA256 signature in X-Webhook-Signature-256: sha256=<hex> over <ts>.<body>; envelope {"apiVersion","event", "timestamp","data"} (+ X-Webhook-Api-Version header). Secrets
(whsec_<hex>) shown once, stored plaintext as the outbound signing key -
never returned via GraphQL.
- Delivery contract (v2): each webhook carries
failure_policy(fail_closeddefault /fail_open),timeout_ms(0=kind default, ≤30s),custom_headers(reserved names rejected),payload_version. Syncfail_openforgives transport/timeout/non-2xx but still honors an explicit{"reject":true}. A per-process circuit breaker (internal/circuit, 5 fails → 30s open) guards sync targets; state onWebhook.circuit. - Async delivery is queue-backed (
webhook_queue): the dispatcher enqueues,QueueWorkerdelivers on the 10s/60s/300s ladder (survives restarts), exhausted rows become redeliverable dead letters.redeliverWebhookDeliveryre-enqueues any historical delivery. - Subscriptions accept exact types,
*, or<prefix>.*wildcards (matched in SQL +webhook.MatchesEntry- keep in sync). Sync events implementSyncResponseApplier; response mutation coverage includes cart price/qty, inventory routing, review status, and order discount/risk/tags. Tax/shipping/total are NOT webhook-settable - they’re owned by the remote tax + shipping providers (one HTTP source per number).checkout.validatingis the pre-transaction veto point (twin oforder.placing, no held tx).
Remote providers (internal/remoteprovider/): provider-shaped webhooks
- merchant HTTP endpoints that IMPLEMENT a pluggable role instead of observing
events. 16 kinds,
AllKindsindomain.gois the list (no payment kind, on purpose - PCI scope; gateways are Tier 1 plugins). Integrator-facing contract isdocs/remote-providers.md; per-kind JSON Schemas + example payloads are GENERATED from the adapter structs intointernal/remoteprovider/contract/bycmd/providerschema(runs inskelet gen, so a renamed payload type fails the run rather than leaving a stale published schema).skelet providerconformance-tests an endpoint. Theremote_providerstable carries kind+slug identity, config JSON, timeout, cache TTL, failure policy, channel scope and anrpsec_secret. TheInvokersigns a{kind,provider,timestamp,config,payload}envelope (X-Provider-Signature-256), enforces per-invoke timeout + payload-keyed response cache + circuit breaker. Consumers wire adapters in main.go: shippingSetRemoteRateSource, orderSetRemoteTax,address.RegisterRemoteValidator,promotion.RegisterRemoteProviderTypes(remote.condition/remote.action rule types). Admin CRUD gated byremote_providers.view/manage;remoteprovider.MemRepofor consumer tests. SSRF guard shared viaurlguard- its own package so webhook and remote providers can both use it without importing each other.
Admin Actions: handlers are closures - register after services
built, before action.Validate(). Default 30s timeout. Args numbers
arrive as float64 - use Args.Int(name).
Scheduler (internal/scheduler/): DB-backed scheduled jobs claimed
atomically via FOR UPDATE SKIP LOCKED + a lease (multi-replica safe, no
leader election). Code jobs register at boot (Register), plugins via
sdk.RegisterScheduledJob; webhook jobs are admin-created (SSRF-guarded,
HMAC-signed). Per-job timezone, retries/backoff, failure alerting, and run
history. Global pause + alert URL + history retention live in config
(system/scheduler/*). Durable async event delivery is the Postgres
outbox (internal/outbox/), not a broker.
