User guide
Storefront and ordering
Looking for what it does rather than how to use it? Read the Storefront and ordering overview .
What it is
The storefront is the buying surface your IT provider gives you: a product catalogue priced for your organization, a persistent cart, and a checkout that either charges a card or places the order on account. Every order lands with your provider for approval, and you track it from the same portal you ordered it in.
It renders in two portals from one set of components. Org buyers use /org/shop; end users use /user/shop. The pages behave identically — only the links differ.
Concepts
| Term | What it is |
|---|---|
| Store | The catalogue one MSP account sells. Your organization buys from the store belonging to its own account. |
| Your price | The selling price computed for your organization: the MSP’s markup, then your org’s contract/discount rules. |
| Standard price | The account-wide price before your org’s rules. Shown struck through as “list” only when it is higher. |
| Cart | One active cart per person per organization. Holds loose product lines and bundle lines separately. |
| Bundle (kit) | A fixed-price set of products. Priced server-side from its definition; expands into component order lines. |
| Quote request | Converting a cart raises a DRAFT quote for your provider to price and send back formally. |
| Store order | MspStoreOrder — the placed order. Carries a snapshot of every line’s price, the tax, and the currency. |
| Transaction | MspStoreTransaction — the money ledger row behind a card order. Only a Stripe webhook marks it SETTLED. |
| Sell currency | The currency you are quoted and charged in: your org’s sellCurrency, else your account’s defaultCurrency. |
The pages
| Page | Org portal | End-user portal | In the sidebar? |
|---|---|---|---|
| Shop | /org/shop | /user/shop | Yes (both) |
| Product detail | /org/shop/[id] | /user/shop/[id] | No — reached from a product card |
| Bundles and kits | /org/bundles | /user/bundles | No — reached from the Shop header link |
| Cart | /org/cart | /user/cart | Org only; end users use the mini-cart |
| Checkout | /org/checkout | /user/checkout | No — reached from the cart |
| Orders | /org/orders | /user/orders | Yes (both) |
| Order detail | /org/orders/[id] | /user/orders/[id] | No — reached from the orders list |
| Spend analytics | /org/spend | — | Org only |
Roles and permissions
| Action | platform administrator | MSP administrator | MSP technician | organization administrator | power user | CUSTOMER |
|---|---|---|---|---|---|---|
| Browse the shop, product detail, bundles | ● | ● | ● | ● | ● | ● |
| See cost and margin on a product | ● | ● | ● | — | — | — |
| Add to cart, edit the cart, request a quote | ● | ● | ● | ● | ● | ● |
| Place an order (checkout) | ● | ● | ● | ● | ● | ● |
| See your own organization’s orders and spend | ● | ● | ● | ● | ● | ● |
| See every client’s orders in the account | ● | ● | ● | — | — | — |
| Approve, reject or fulfil an order | ● | ● | — | — | — | — |
Reach /org/* | ● | ● | ● | ● | ● | — |
Reach /user/* | ● | ● | ● | ● | ● | ● |
Three rules produce that table:
POST /store/ordersis the single, deliberate exemption — it carriesrequireMinRole("CUSTOMER")instead, because a client placing an order is the point of the store.- Below that floor,
costCents,marginPercentandhasVendorMarkupare omitted from every product row. - No org context is a
403 Organization context required, never an unscoped read.
Walkthrough — browse, cart, and pay by card
- Open the shop — Store & Catalog → Shop in the org sidebar, or Shop in the end-user sidebar.
- Active filters appear as chips above the grid; on mobile the rail is a drawer behind the Filters button.
- Search — the search box matches name, SKU and MPN. If your provider’s catalogue is at least 80% embedded, a Smart search toggle appears and switches to meaning-based search. The toggle is hidden below that threshold rather than silently searching a fraction of the catalogue.
- Open a product — the detail page shows the image gallery, grouped specifications, identifiers (MPN/UPC/EAN), datasheets and manuals, stock (“In stock · N available”, or “Backorder · ~N day lead time”), and your price with the list price struck through when your org’s pricing rule beat it. Pick a quantity and Add to cart.
- Review the cart — Cart, or the mini-cart in the shop header. Change quantities with the ± buttons, remove a line with the bin icon. Every read of the cart re-prices every line through the store engine, so a price your provider changed since you added the item is corrected in front of you rather than at checkout.
- Check out — Pay by Card takes you to
/…/checkout. Add an order note, choose Pay by card or Purchase on account, and place the order. - Pay — for a card order the page swaps to Stripe Elements for the exact total. Confirming the card does not mark the order paid; the confirmation screen says the payment is confirming. Your order becomes
APPROVEDwhen Stripe’spayment_intent.succeededwebhook reaches us.
Walkthrough — request a quote instead
Use this when you want a formal, negotiated price rather than a self-service order.
- Fill the Order Notes box on the cart page with anything your provider should know.
- Press Request Quote.
- Your cart moves to
CONVERTED_TO_QUOTEand empties. The success panel links to /org/quotes (org portal) or /user/billing?tab=quotes (end-user portal). - Your provider prices, approves and sends the quote; you accept it in the portal.
A line your provider has hidden or blocked since you added it cannot be quoted. The request is refused with the exact SKUs listed: “These items are no longer available and must be removed from the cart: SKU-1, SKU-2”. Remove them and try again.
Walkthrough — track an order
- Orders in the sidebar lists your organization’s orders newest first, 20 per page, each with its status, item count and total.
- Open one for the fulfilment timeline — Placed → Approved → Processing → Shipped → Delivered — with the real timestamp against each step your order has reached.
- Once your provider ships, the Tracking card shows the carrier, the tracking number and the shipped/delivered timestamps.
- Line items show the snapshot price at the time you ordered, with bundle components labelled “Part of:
” . Subtotal, tax and total are the order’s own recorded figures.
A cancelled order shows “This order was cancelled” in place of the timeline.
Order lifecycle
buyer places order
│
▼
PENDING_APPROVAL ──── MSP rejects ────▶ CANCELLED
│
┌───────────────┴───────────────┐
card payment succeeds MSP approves
(Stripe webhook) │
└───────────────┬───────────────┘
▼
APPROVED
│ MSP advances fulfilment (forward only)
▼
PROCESSING ─▶ SHIPPED ─▶ DELIVERED
│ NET30 / NET60 only
▼
invoiced once
- Approve and reject are only possible from
PENDING_APPROVAL. CANCELLEDandDELIVEREDare terminal.- Reaching
DELIVEREDis what makes a net-terms order invoiceable. The nightlymsp-order-invoicingjob invoicesDELIVEREDNET30/NET60 orders that carry no invoice yet, in one guarded transaction, so an order can never be billed twice.
Cart states are ACTIVE → CONVERTED_TO_QUOTE (quote request) or ACTIVE → ABANDONED (you cleared it, or checkout succeeded). Abandoning keeps the history rather than deleting rows.
Pricing, tax and currency
The order of operations for one product:
- Convert the MSP’s buy cost into your sell currency (no rate → the product is unpriceable).
- Apply the FX buffer, if the account sets one and the sale is cross-currency.
- Apply the base multiplier, then category markup, then brand markup, then the vendor override.
- Enforce the account’s minimum-margin floor and its rounding rule.
- Apply your organization’s pricing rules — contract price, discount, or volume tier at this line’s quantity. Volume breaks apply at the quantity in your cart, not at quantity 1.
A per-product custom price set by your provider wins outright: no markup, no rounding.
Tax is applied when the order is placed, from your organization’s country and province through the shared tax engine, and recorded on the order. Where the jurisdiction is not configured, tax is held rather than guessed: the order is written with zero tax and a [TAX HOLD] note, and the invoicing job refuses to bill it until a human reviews it. A configured Canadian province is never held.
Currency resolves as: your organization’s sellCurrency → your account’s defaultCurrency → the seller’s default. Supported currencies are CAD, USD and EUR; anything else clamps to CAD. FX rates carry a 48-hour freshness bound on the money path — if fx-rate-sync has not confirmed a rate inside that window, the line becomes unpriceable and checkout refuses it rather than charging a card at a stale rate. Placed orders snapshot the rate table they used, so they re-price identically forever.
Payment methods
| Method | What happens at checkout | How it is settled |
|---|---|---|
| Credit card | A Stripe Connect destination-charge PaymentIntent is minted for the total. | payment_intent.succeeded marks the transaction settled. |
| NET30/NET60 | Nothing is charged. The order is placed on account. | Invoiced by the nightly job once DELIVERED. |
The checkout page offers Pay by card and Purchase on account (NET30). If your provider has not connected Stripe, the card option disappears and the page says so plainly — the order is placed on account and a PO number field appears, which is written into the order notes.
Card safety properties worth knowing:
- The request body carries only
{ orderId }. The amount charged is always the order’s server-computedtotalCents. - The transaction moves
PENDING → PROCESSINGwhen the intent is minted and only ever reachesSETTLEDfrom the webhook. A webhook redelivery is a guarded no-op. - If the card leg fails after the order was written, the order still exists and is uncharged. The checkout page names it, says explicitly that your card was not charged, and keeps your cart so you can request a quote instead.
Troubleshooting
| What you see | What it means |
|---|---|
The store pricing service returned an unusable price list | The pricing reply was malformed or short. Treated as unavailable rather than partially trusted. |
Product not found or unavailable on add-to-cart | Your provider has hidden the product, blocked its vendor, or it has no sellable price in your currency. It is not a missing product id. |
Quantity must be at least 1 | Use DELETE to remove a line; a quantity of 0 is not an update. |
Cart is empty | Convert-to-quote was called with no lines and no bundles. |
These items are no longer available and must be removed from the cart: … | Convert-to-quote refused because at least one line can no longer be sold. The SKUs are listed. |
This bundle is not available to buy. | A component of the bundle cannot be resolved or priced for you. |
No exchange rate to price <SKU> in <CUR>. | No FX rate inside the 48-hour freshness bound converts that product’s cost currency into yours. |
Product has no sellable price: <SKU>. Set a cost price, a pricing profile or a custom price. | The product resolves to zero. Your provider must set a cost price, a pricing profile or a per-product price. |
An order must contain at least one item or bundle | Checkout was called with both arrays empty. |
Invalid checkout request: … | The body carried an unknown key. The schema is strict precisely so a client-supplied price is rejected rather than silently honoured. |
Organization (404) on checkout | The org is not owned by the store’s account, or you are a customer-portal caller trying to order for another org. Indistinguishable on purpose. |
The selling MSP has not connected a Stripe account to receive card payments (409) | Your provider has not finished Stripe Connect onboarding. Your order exists and is uncharged — request a quote or contact them. |
Payment service is unreachable; the order was placed but the card was not charged (502) | Same outcome: the order is real and PENDING_APPROVAL, no money moved. |
Store order already has a payment intent or is not awaiting payment (409) | A second intent was requested for the same order. The first one is still the live one. |
Order cannot be approved in current status / …rejected… | The order has already left PENDING_APPROVAL. |
Cannot move a <status> order to <status>. Approve the order first, or it is already closed. | Fulfilment is forward-only from APPROVED. |
Access Denied on /org/shop | The signed-in person’s maximum portal level is customer. Use /user/shop. |
Limits and known behaviour
Cart and checkout
-
The cart shows no tax line. It previously computed
subtotal * 0.05and labelled it “GST (5%)” for every buyer in every jurisdiction. Real tax is computed server-side at order placement from your organization’s country and province — 13% in Ontario, 14.975% in Quebec, held entirely where the jurisdiction is unconfigured. The cart now shows the subtotal only. -
The checkout review step shows the cart subtotal only. Tax, fees and the final total are computed when the order is placed, so you see them after the order exists — before entering card details, but not before committing to the order.
-
Buy the bundle from Bundles & Kits instead, or add one loose product.
-
Bundles are flagged. You find out at quote-request or checkout.
-
Nothing merges two carts. The cart is keyed on person + organization. Switching organization gives you a different cart.
Catalogue and prices
- Adding one fails with “Product not found or unavailable”. The same is true of a product whose provider has set no cost price.
- “Approval required” is a label, not a gate.
REQUIRES_APPROVALproducts carry a badge on the shop card and the line “This item needs manager approval before it ships” on the product page, but nothing in the cart or the checkout engine treats them differently — onlyHIDEis refused. Every order already needs your provider’s approval, and a card order containing such a product is auto-approved by the payment webhook. - A blocked vendor’s product 404s in the list but opens at its detail URL. The list applies the blocked-vendor filter;
GET /store/products/:productIdapplies only theHIDEcheck. The price shows; adding to cart fails. - Spend analytics is labelled CAD regardless of your currency. The API returns the reporting
currencyand anexcludedByCurrencybreakdown of orders it left out, but the client type carries neither, so/org/spendformats every figure with the default currency and never discloses excluded orders.
Orders and payment
- You cannot cancel your own order. There is no buyer-facing cancel; only your provider can reject a
PENDING_APPROVALorder. There is no reorder either. - Rejecting an order cancels its outstanding card payment. Reject cancels the PaymentIntent before it records the rejection, so confirming a card after your provider rejected the order can no longer capture money against a cancelled order. Rejection is never blocked by the payment leg: if the intent cannot be cancelled the order is still rejected and the reason is recorded on it and returned to the rejecting operator, so a payment that already settled surfaces as “a refund is required” rather than being silently left alone.
- Rejecting appends to the order notes rather than replacing them, so your own notes and any
[TAX HOLD]marker survive the rejection. - Payment terms are buyer-chosen with no credit check.
paymentMethodis taken from the request, and there are still no per-organization terms — the Bundles & Kits dropdown offersNET60alongsideNET30, and your provider’s approval step is the only control on which terms you take. That remains a policy decision to make before opening the store.PREPAIDis now refused: it was charged by nothing and invoiced by nothing, so it produced goods with no billing event at all. Checkout answers400 … cannot be settledfor it and it is gone from the dropdown. - The card option only appears when your provider can actually take a card. It is gated on the selling MSP’s Stripe Connect status, not on whether the platform has a Stripe key. Previously a card option was shown that always failed and left an unpaid order behind.
- The order detail omits the payment method, notes and shipping address, all of which the API returns.
- The bundle “Buy now” path does not handle a failed card leg the way the cart checkout does: it shows the raw error without telling you the order was created.
aiTriageResultis no longer returned to buyers. Your provider’s internal risk assessment — includingfraudIndicatorsandclientHealthScoreabout your organization — is staff-only on both the order list and the order detail. No buyer page ever rendered it, but it was on the wire.
Reachability
- Use the portal-prefixed paths.
- The native mobile app intentionally has no shop entry point. The catalog mixes physical products, software, licenses, subscriptions and services, so acquisition remains in the web portal. Mobile contains no catalog, cart, checkout or external purchase prompt; it handles review-only invoices, restricted quote decisions and delivery tracking instead.
/user/cartand/user/checkoutappear in no navigation. They are reachable only from the mini-cart inside/user/shop./user/*carries no portal guard — any authenticated person can load the end-user storefront. Server-side tenant scoping, not the page, is what protects the data.
Styling
- The storefront’s status colours (
--theme-danger-*,--theme-warning-*,--theme-success-*,--theme-info-*) are never defined anywhere; every use relies on its hard-coded light-theme fallback, which is low-contrast on a dark surface.
Test coverage
187 test cases across 27 files cover the buyer journey directly, inside a wider 1,040 cases across 37 storefront-relevant files. The load-bearing ones: