Commerce

Products are model records, the cart is a public mutation namespace your storefront calls, and orders live in the admin.

cmssy ships a cart, orders, discounts and an order pipeline. None of it is a separate product area: a product is a model record, the storefront talks to the same delivery API as everything else, and the order desk is the admin.

The capability is a plan flag - canUseCart in get_workspace_info - so read it rather than assuming.

Products are records

There is no product type in cmssy. You create a model - product, plan, ticket, whatever your catalogue is - and the model's Product tab declares which of its fields play which role: name, price, image, SKU, tax rate, inventory, and the variant axes. A role you leave unset resolves to the model's display field or the conventional key (price, sku, inventory…), so a plain catalogue needs no mapping at all. The workspace's cart config then only lists the models that are for sale as product sources.

The price field holds the price in the workspace's default currency. Prices in other currencies are not a field of the model: they are set per currency (see Currencies below), and a model can no longer name a currency field.

Agents read the resolved roles from get_model (product.roles) and set the overrides through update_model.

That is why a catalogue can come from more than one model at once, and why the same block that renders any other record renders a product.

Reading the catalogue

A product comes back from public.model.records like any other record, with a few fields that only mean something on a product.

  • variants(limit, offset) - one page of the product's variants, at most 50; variantCount counts all of them. A product with more variants than one page reads the next by offset.
  • variantPriceRange { min max } - the cheapest and the dearest variant, in minor units (4900 = 49.00), null on a product without variants. A listing card that says "from 49.00" reads this instead of the variants.
  • records(sku: "TEE-M") - the product carrying this SKU, as its own or on one of its variants. Add variants(sku: "TEE-M", limit: 1) for just that variant - a quick-order form needs nothing else.
  • available - units a buyer can order now: on hand minus what open carts reserve, summed across variants. Null when the product does not track stock, which includes a product with any variant whose stock is not tracked.
  • records(stockState: "in" | "low" | "out") - filters by that same number against public.siteConfig.lowStockThreshold: low is more than zero and no more than the threshold. Colour a stock badge from available and the same threshold and the badge cannot disagree with the filter.

A hidden field does not leak through another one. A product's own SKU is matched only when the delivery API serves the field holding it. When the stock field is hidden, available and every variant's inventory are null and stockState is refused.

What the storefront can call

The cart namespace is public: no token, session-scoped, the one part of the delivery API that writes.

  • cart.get(workspaceId) - items, subtotal, tax, shippingTotal, discountedTotal, totalGross, currency, pricesIncludeTax, availableShippingMethods, appliedDiscount, taxSummary.
  • cart.addItem - { recordId, quantity, variantSelections, notes }. The item is a reference to the record, not a copy of it.
  • cart.updateItem, cart.removeItem(itemId), cart.clear.
  • cart.applyDiscount(code), cart.removeDiscount, cart.setShippingMethod(shippingMethodId).
  • cart.merge - folds an anonymous session cart into the signed-in member's after login. Call it once, right after authentication.
  • cart.checkout - { customerEmail, customerNote, poNumber, shippingAddress, customFields } turns the cart into an order.

Totals come back computed. Do not add up line prices in the browser: tax mode, discounts and shipping are decided server-side, and a second implementation in your frontend is a second answer.

One cart mutation per request. The fields inside cart { … } are fields of a namespace object, not root fields, so GraphQL runs them concurrently - addItem and setShippingAddress in one document race each other, and a brand-new session minted by the first call is not visible to the second. Send each cart write as its own request.

Order status without an account

public.order.byToken returns one order - orderNumber, items, paymentStatus, fulfillmentStatus, amountPaid, balanceDue, customFields, invoiceUrl, shipments (carrier, tracking number and link, status) - for the token issued at checkout. That is how a "track your order" page works for a guest.

What the workspace configures

public.siteConfig.publicCart is the storefront-safe projection of the cart settings: defaultCurrency, additionalCurrencies, pricesIncludeTax, taxRates, shippingMethods, productSources, maxItemsPerCart, maxQuantityPerItem, orderFields, enableSavedCarts, enableQuoteRequests. Read it once and let it shape the UI - currency formatting, quantity limits, whether a quote request is offered at all.

Shipping rules

Each shipping method carries its own rules: the countries it serves (ISO codes, empty = everywhere), a subtotal window (minSubtotal / maxSubtotal) and freeAbove, the subtotal from which it costs nothing. availableShippingMethods on the cart already applies them against the shipping address and the subtotal, so the storefront never filters methods itself. In an additional currency a method uses the amounts set for that currency under Prices in other currencies; a method with none is not offered to carts in it.

Order fields

Fields of your own on every order - a gift message, a delivery window, an internal reference. They are defined once in Settings → Order fields (or update_cart_config with orderFields), at most 30, each with a key, a label per language, a type (text, number, date, select, boolean, email, phone, url) and optional limits.

Each field is either the customer's or staff-only. publicCart.orderFields lists only the ones the customer can set: render an input for each and send the values as customFields on cart.checkout, by key. The server validates them - type, limits, required - and refuses a staff-only key; a blank value is dropped. Only a field the customer can set can be required, so a staff-only field never blocks a checkout.

The values are stored on the order as customFields. public.order.byToken returns the customer's fields only; the admin, get_order and the order.* webhooks carry all of them. Staff edit them on the order, or with update_order_details: a key set to null clears it. Removing a definition leaves the values already on orders untouched.

Currencies

A workspace has one default currency, the base of the catalogue, and up to 9 additional currencies. Both are set in Settings → Region and tax, or with update_cart_config (defaultCurrency, additionalCurrencies). The default currency cannot be changed while additional currencies exist - remove them first. A currency a sales channel still sells in cannot be removed.

Each sales channel sells in one currency, the default or an additional one (currency on create_sales_channel and update_sales_channel). The buyer never picks a currency: the storefront picks the channel, and the channel's currency prices everything. A cart, its order, shipping, discount, payments and refunds are all in that one currency. Changing a channel's currency abandons the carts open on it.

Nothing is converted. cmssy has no exchange rates. Every amount in an additional currency is one you set:

  • Products - Prices per currency on a product in the catalogue: a price and volume breaks per currency, and for a product with variants a price per variant. Saving it never changes stock. Agents use set_product_currency_prices, which asks for confirmation, and currencyPrices on set_product_variants.
  • Price lists - one fixed-price row per currency, and the list's percent comes off the price in that currency. See Price lists.
  • Shipping methods - price, subtotal window and free-above per currency, under Prices in other currencies.
  • Discount codes - Minimum per currency (minSubtotalByCurrency) on percentage and free-shipping codes. A code with a minimum applies only in the currencies its minimums name. A fixed-amount code has one currency and applies only to carts in it.

A product with no price in the channel's currency is not sold on that channel: a price read returns it with price: null, a priced listing leaves it out, and cart.addItem refuses it with extensions.reason NO_PRICE_IN_CURRENCY. Prices in a currency you switch off are kept; they price nothing until the currency is switched on again.

Orders keep their currency. The orders list has a currency column and filter (currency on list_orders), a manual order picks its currency (currency on create_manual_order), and analytics and customer spend are reported per currency, never summed across currencies.

Currencies on the storefront

Read a channel's prices from public.pricing.prices and public.pricing.catalogPrices with the channel key, or from account.prices and account.catalogPrices for a signed-in buyer. The record's price field and variantPriceRange hold the default-currency price only. Every price row carries currency: format each amount with its own row's currency, never a hard-coded one, and divide by that currency's number of decimals, not by 100. On a channel in an additional currency the result's currency names it, even on an empty page. publicCart lists defaultCurrency and additionalCurrencies.

Orders are back-office

Everything after checkout is authorized: the admin, or the MCP server with a token whose role allows it. Orders can be listed and read, created by hand, edited line by line, marked paid in full or in part, refunded, canceled, moved through fulfillment, given an invoice, and moved along a configurable pipeline. Every parcel sent is recorded on the order with create_shipment and update_shipment - carrier, tracking number, tracking link, and a status from pending to delivered with its history. Adding one marks the order fulfilled, or partially fulfilled for a split delivery. Discounts are created, updated and switched on or off the same way; products can be bulk-updated, bulk-deleted or given price tiers.

Payment providers are not part of this: cmssy records what happened - mark_order_paid, record_order_payment, refund_order - and your integration decides when it happened.

Anonymizing an order

For an erasure request, staff with orders:manage anonymize a finished order - canceled, fulfilled, returned or refunded - from its page, or every finished order of one customer from the customer's page. The buyer's email, shipping address, PO number, notes, order fields and delivery notes are cleared from the order, its cart, the stored webhook deliveries and the Spotlight history that showed them. Items, amounts, payments, the invoice and the order number stay, so the books still add up. It cannot be undone, an order still in progress is refused, and the order.anonymized webhook tells your systems to erase their copy.

Deleting a test order

A canceled order that took no payment and has no invoice - a test or spam order - can be deleted outright from its page by staff with orders:manage. The buyer's data is swept from its cart, stored webhook deliveries and Spotlight history the same way as for anonymization, and the audit log keeps the order number. Every other order is refused: once money or an invoice is involved, anonymize it instead.

Next steps

  • Data models - the records a catalogue is made of.
  • GraphQL delivery API - how a public write is scoped.
  • Customers - the accounts behind the orders, and companies for B2B.
  • Price lists - B2B prices per company or customer, and how a storefront shows them.
  • Webhooks - the ten order.* events, and shipment.* for parcels.