Commerce Architecture
This page covers the layering, the core/commerce contracts, how satellites register, the data model, RBAC, and the generic core extension points Commerce relies on.
Layering and the dependency rule
theme βββΊ core βββ plugin
β²
βββ core/commerce (contracts)
- Themes depend on core only. A shop theme declares
[requires] commerce(a declarative themeβplugin dependency, resolved by core) and calls documented render-hook slots. It never imports the Commerce plugin. - The Commerce engine (
plugins/commerce) depends oncoreandcore/commerce. - Satellite gateways (
plugins/commerce-paypal, β¦) depend oncore/commerce(and a narrow slice of core for the hook bus / options / site URL) β never onplugins/commerce.
The invariant: no plugin imports another plugin, and there is no import cycle. core/commerce imports only core/hook. You can assert it in CI with go list -deps.
Money
All monetary values are exact integers, never floats:
type Money struct {
Amount int64 // minor units: 199 with "USD" == $1.99
Currency string // ISO 4217
}
func New(minorUnits int64, currency string) Money
func (m Money) Add(o Money) (Money, error) // ErrCurrencyMismatch when currencies differ
func (m Money) Sub(o Money) (Money, error)
func (m Money) MulQty(qty int) Money
Display formatting happens in the render layer per request locale; Money.String() is debug-only.
The core/commerce contract package
| File | Contents |
|---|---|
money.go |
Money value type |
payment.go |
PaymentGateway, optional GatewayAvailability / GatewayCurrencySupport / IdempotentRefunder, PaymentAction (sealed sum), PaymentSettler, Reconciler, request/settle/refund types |
shipping.go |
ShippingZone, ShippingMethod, ShippingRate |
tax.go |
TaxCalculator |
promotion.go |
PromotionRule, Adjustment |
registry.go |
Hook-bus register/read helpers + hook-name constants |
types.go |
Address, KV |
PaymentGateway and PaymentAction
type PaymentGateway interface {
ID() string
Title(c *gin.Context) string
Icon() string
Capabilities() Capabilities // Refund / PartialRefund
StartPayment(c *gin.Context, req PaymentRequest) (PaymentAction, error)
Refund(c *gin.Context, req RefundRequest) error
}
PaymentRequest.IdempotencyKey and RefundRequest.IdempotencyKey are stable retry keys that gateways must forward to their provider. Two optional capabilities narrow runtime behavior without coupling Commerce to a specific gateway:
type GatewayAvailability interface {
Available(c *gin.Context) bool
}
type GatewayCurrencySupport interface {
SupportsCurrency(currency string) bool
}
type IdempotentRefunder interface {
RefundWithResult(c *gin.Context, req RefundRequest) (RefundResult, error)
}
Gateways without GatewayAvailability remain available for compatibility; gateways without GatewayCurrencySupport remain currency-agnostic. Commerce requires IdempotentRefunder for an automatic provider refund and persists RefundResult.TransactionID; a gateway that advertises Refund: false continues to use the explicit manual/offline refund workflow.
PaymentAction is a sealed sum (an unexported marker method keeps the set closed) so the storefront can render it exhaustively:
| Action | Meaning |
|---|---|
RedirectAction{URL} |
Send the buyer to a hosted page (PayPal). |
DisplayAction{Title, Rows, QR, ExpiresAt} |
Show instructions and await payment (crypto, offline transfer). |
InlineAction{ClientData} |
Render an inline tokenized widget (card SDK; no PAN touches GoPress). |
CompletedAction{} |
Payment settled synchronously (rare). |
Confirmation inversion β PaymentSettler
Commerce does not poll gateways. A gateway confirms by its own means and calls back:
type PaymentSettler interface {
Settle(ctx context.Context, req SettleRequest) error
}
type SettleRequest struct {
OrderRef, Gateway, TxnID string
Amount Money
Status SettleStatus // paid/underpaid/overpaid/expired/failed/refunded
IdempotencyKey string // unique-indexed β dedup
Raw map[string]any
}
The engine implements PaymentSettler and publishes it via SetSettler. Gateways fetch it with GetSettler(bus). All settlement funnels through this one idempotent method β see Payments.
Registration helpers
// Satellite, in Activate:
handle := commerce.RegisterPaymentGateway(bus, myGateway) // remove in Deactivate
// Engine, at checkout:
gateways := commerce.AvailablePaymentGateways(c, bus) // lazy + runtime-ready
usdGateways := commerce.AvailablePaymentGatewaysForCurrency(c, bus, "USD")
RegisterPaymentGateway adds the gateway to a commerce.payment.gateways filter; PaymentGateways applies that filter, while AvailablePaymentGatewaysForCurrency evaluates both optional runtime availability and currency support. Because the read is lazy, activation order never matters. Checkout filters the displayed choices and rechecks both capabilities on submission, so a stale or forged method value cannot select a disabled, unconfigured, or currency-incompatible gateway.
Data model
Tables use dbprefix.PluginTable("commerce", <name>) β gp_plgn_commerce_*, so they are multi-site isolated and registered for ownership with RegisterPluginTable.
| Group | Tables |
|---|---|
| Catalog | product_data (content_id, sku, price, sale_price, stock, tax_class, β¦), product_lookup (denormalized current price + in-stock for fast listing) |
| Cart | carts (guest token or user_id), cart_items |
| Orders | orders (number, access_key, status, totals snapshot, payment_method), order_items (name/price snapshot), order_addresses, payments (idempotency_key unique), order_notes, refunds (pending/succeeded/failed, idempotency_key unique, provider refund id) |
| Inventory | inventory_ledger (product_ref, delta, reason: in/out/reserve/release, order_id) |
Money columns are BIGINT minor units with a separate currency column. Order rows snapshot name and price, so historical orders are unaffected by later product edits. Order line items carry no FK to content (soft association), so deleting a product never cascades into order history.
RBAC
Commerce does not add a new role. On Activate it grants commerce capabilities to the existing editor role and revokes them on Deactivate:
shop_order.{read, update, refund}
coupon.{create, read, update, delete}
commerce_settings.{read, update}
superadmin has them via the *.* wildcard. Every protected admin route uses admin.RequirePermission, and customer-facing order lookups enforce ownership (IDOR guard) via WHERE user_id = <current>. A plugin can implement core's generic SettingsAuthorizationProvider to declare its settings resource: settings GET requires <resource>.read, POST requires <resource>.update, and contributed navigation items declare their own resource/action so unauthorized entries are not rendered. Commerce maps this to commerce_settings; core contains no Commerce-specific permission branch. See Catalog, Cart & Orders.
Generic core extension points
Commerce is built on generic core capabilities. Each is neutral β core never hardcodes an e-commerce concept.
content.register_types
activateTheme() rebuilds the content registry from core + active-theme types. Plugin-contributed types (like product) would be lost on every theme switch. So core fires content.register_types after theme types are registered; Commerce (re)registers product idempotently in that action, and once more immediately on Activate (the first theme activation may precede the plugin load).
default_inactive module gating
plugin.Meta.DefaultInactive (from default_inactive = true in plugin.toml) + the DefaultInactiveProvider interface mark an opt-in module. LoadPlugin registers it without activating when there is no persisted state. Settings β Modules lists every default-inactive plugin as a toggle.
DepNeedsEnable dependency state
When a theme requires a plugin that is present, version-compatible, but disabled and default-inactive, dependency resolution returns DepNeedsEnable β non-blocking, so the theme still activates, and the admin shows a strong "enable" prompt rather than a hard failure. Absent or version-mismatched dependencies still block.
Engine.RenderNamespacedInActiveTheme
func (e *Engine) RenderNamespacedInActiveTheme(c *gin.Context, namespace, fragment, extensionDefaultDir string, data gin.H) error
Renders an extension-owned fragment inside the active theme's layouts/base.tmpl + partials, using the theme's own FuncMap. Fragment resolution is generic: <theme>/templates/<namespace>/<fragment>.tmpl wins over <extensionDefaultDir>/<fragment>.tmpl. Namespace and fragment are validated as single safe path components. Commerce passes the commerce namespace; core itself has no Commerce-specific branch. This is how /cart, /checkout, /order-tracking, and /my-account/* share the site's header/footer and let a theme override any page. RenderInActiveTheme remains as a compatibility wrapper that derives the namespace from the default directory's basename.
admin.nav.items
buildMenuItems() applies this filter before the System section, so an active plugin can contribute a sidebar section. Commerce adds an "η΅ε / Commerce" section with Orders. Products appear under Content automatically (they are a content type).
seed.completed
The seeder fires this action after first-run seeding and admin demo import. Commerce listens and derives product_data from the _commerce_* meta on product content, so demo seed files stay pure core content and never reference plugin tables. See Theme Integration.
Commerce hook namespace
| Hook | Kind | Payload |
|---|---|---|
commerce.payment.gateways |
filter | []PaymentGateway |
commerce.shipping.methods |
filter | []ShippingMethod |
commerce.payment.settler |
filter | PaymentSettler (engine-provided) |
commerce.checkout.validate |
filter | []string errors (non-empty aborts) |
commerce.order.status_changed |
action | (order, old, new), published only after the surrounding transaction commits |
commerce.product.add_to_cart |
filter | storefront price + add-to-cart HTML slot |
Next: Catalog, Cart & Orders.
