You're reading docs for Nextly Alpha. APIs may change between releases.

PluginsCommerce

Commerce

A catalogue to sell from, a cart kept on the server behind a cookie, and a checkout that turns it into an order and starts its payment, with the REST routes and typed server functions a storefront uses.

Alpha, and not published yet.

Everything @nextlyhq/plugin-commerce exports is @experimental. The stability boundary is @nextlyhq/plugin-sdk (see API stability); this package's own exports are outside it and may change between alpha releases.

The commerce plugin gives a site a catalogue to sell from, a cart that lives on the server, and a checkout that turns the cart into an order, holds its stock and starts its payment. Confirming payments, fulfilment and refunds build on it and are not in this release.

Setup

import { defineConfig } from "nextly";
import { commerce } from "@nextlyhq/plugin-commerce";

export default defineConfig({
  plugins: [commerce({ currency: "USD" })],
});
OptionDefaultWhat it does
currencyrequiredThe store's ISO 4217 currency. Every price and cart is in it
cataloguethe products collectionA catalogue source of the site's own, such as an outside product API
productsSlugproductsThe built-in products collection's slug
cartLifetimeDays30How long a cart lives after its last change. At most 365, inside the 400 days browsers keep a cookie
secureCookieson in production onlyWhether the cart cookie is Secure, under the __Host- name. A browser drops a Secure cookie set over plain HTTP
cartWriteLimit60 a minuteHow many requests one client may make to each cart write route in a window, as { max, windowMs }. Raise it when shoppers share a network address
paymentsnoneThe payment provider checkout starts payments with. Without one, a checkout can be quoted but not completed
unpaidOrderMinutes30How long after it is placed an unpaid order's deadline falls. 5 to 1,440. Giving up orders past it comes in a later release
jobsStaleAfterMinutes15How long commerce's jobs may go without running before checkout stops taking orders. 1 to 1,440

Commerce keeps its records in its own tables, all named commerce__… (carts, orders, payments, stock and the work that follows a checkout), created by its migration modules. Run nextly migrate after installing or upgrading it: a production boot refuses a database that has not applied them, with PLUGIN_SCHEMA_BEHIND. In development the push creates them at boot.

The catalogue

Without a catalogue option, commerce adds a Products collection to the admin and sells from it. Each product has a name, an optional SKU, a price and its stock:

FieldNotes
priceA whole number of the currency's minor unit (cents for USD). Empty means the price is on request
stockKindcounted, one-off, untracked (the default) or to-order
stockCountHow many are on hand, for counted and one-off
maxPerLineThe most one cart line may ask for, for to-order
sellableUnticked, the product is left out of the catalogue and cannot be added to a cart

A site that keeps its products elsewhere passes its own source instead, an object that implements the CatalogueSource contract. It is used for listing, pricing and availability with nothing else changed, and the contract's test suite, at @nextlyhq/plugin-commerce/testing, proves a source against it.

Every answer from a source is checked against the contract before it is used. A source that fails to answer is reported as COMMERCE_CATALOGUE_UNAVAILABLE (503), and one that answers outside the contract as COMMERCE_PROVIDER_CONTRACT (502).

The cart

A cart is kept on the server and found by the hash of a random token in an HTTP-only, same-site cookie, commerce-cart (__Host-commerce-cart when Secure). The token is never stored, and no request parameter can reach a cart: not an id, not another shopper's token in a body. A cookie that finds no live cart, because it is forged, unknown or past its cart's deadline, is cleared, and the next change starts a fresh cart with a new token.

  • Prices come from the catalogue on every read. A line keeps the unit price shown when it was last added to or changed, and the cart says when the price now differs (priceChanged). Nothing is ever charged from a price the browser holds.
  • Availability comes from the source on every read, with the number available where there is one. A one-off item is held to one unit, and an item sold to order to its source's per-line limit. A counted item's stock is checked at checkout, which holds it.
  • An item priced on request cannot be added, and is refused with COMMERCE_PRICE_ON_REQUEST so the storefront can offer a quote instead.
  • A cart expires cartLifetimeDays after its last change and is never served after that, whether or not it has been removed yet. The cookie carries the same deadline, so it outlives neither the cart nor a closed browser. A change to cartLifetimeDays reaches each cart at its next change. A background sweep removes expired carts an hour after their deadline; it runs whenever your site runs jobs.

A cart holds at most 100 lines and 999 units on one line.

Stock counts

For a counted or one-off product, stockCount is the number of units you have that are not yet sold. Count units an unpaid order is holding, since commerce takes its own holds off; leave out units already sold that are waiting to ship. What a shopper can buy is that count less the units unpaid orders hold, never less than none.

Checkout

Checkout is two steps, both read from the cart cookie, never from ids the browser sends:

  1. Quote. Commerce prices the cart from the catalogue again and answers its lines, its subtotal and total, and a quoteKey naming exactly that summary.
  2. Complete, with the quoteKey, the buyer's email and a shipping address (a billing address too, if it differs). If the cart or any price has changed since the quote, completion is refused with COMMERCE_QUOTE_CHANGED and nothing is created: quote again and show the new total. Otherwise, in one transaction, commerce creates the order with a snapshot of its lines, numbers it, holds the stock of every counted or one-off item, and records its payment. It then starts the payment with your provider and answers the order and the payment, with the data the provider's own payment form needs.

A cart becomes one order. Completing it again, because a response was lost or a button was pressed twice, answers the same order and starts no second payment, and the cart then shows empty. Order numbers are whole numbers counting up from 1, never two alike; a checkout that fails partway can leave a number unused.

An unpaid order holds its stock, and records a deadline unpaidOrderMinutes after it is placed. Not in this release: giving up an order past its deadline, which releases its stock, and marking a paid order's units sold. Until those arrive, a held unit stays held.

When two buyers reach the last unit at once, exactly one gets it, and the other is refused with COMMERCE_OUT_OF_STOCK. If the provider cannot be reached when an order is completed, the order still stands and commerce starts the payment later, through its jobs, under the same key, so it is never started twice.

Before launch: run the jobs

Commerce finishes work after each checkout, such as starting a payment the provider could not take at first, in a background job, commerce:work, which runs when your site runs jobs. Until that job has run, and whenever it has not run for jobsStaleAfterMinutes, checkout refuses to take orders with COMMERCE_JOBS_NOT_RUNNING (503) and logs an error naming the job, since an order taken then might never finish. Quoting still works. So set up the jobs trigger, and run it once, before the store opens.

REST routes

Every route is public, answers Cache-Control: no-store and is rate-limited per client. They live under /admin/api/plugins/@nextlyhq/plugin-commerce:

MethodPathBody or queryAnswers
GET/cartthe cart
POST/cart/lines{ reference, quantity }{ message, item: cart }
PATCH/cart/lines{ reference, quantity }, 0 removes{ message, item: cart }
DELETE/cart/lines?reference={ message, item: cart }
DELETE/cart{ message, item: cart }
GET/catalogue/items?reference= once per item{ items, meta }
GET/catalogue?limit=&cursor=&search={ items, nextCursor }
POST/checkout/quotethe quote
POST/checkout/complete{ quoteKey, email, addresses: { shipping, billing? } }{ message, item: { order, payment } }

/catalogue is there when the source can be browsed, as the built-in one can. Pass its nextCursor back as cursor for the next page; a source that names the page it was asked for as the next, or a cursor that is empty or over 1,024 characters, is refused as a broken answer rather than followed. A reference is compared exactly, letter case included, on every database.

Every write needs a CSRF token, because a cart belongs to a shopper who is not signed in, and a cross-site form could otherwise change it. Fetch one from /admin/api/auth/csrf, which answers { token } and sets the matching cookie, and send it as x-csrf-token (or as csrfToken in the JSON body) from a page on your own site or an origin in NEXTLY_ALLOWED_ORIGINS. A write without it answers 403 CSRF_FAILED.

Each cart write route, and the quote, allows a client 60 requests a minute unless cartWriteLimit says otherwise, counted per route; completing a checkout allows 10 a minute, and reads take the general allowance. The counts are kept per server process, and every client shares one count unless security.trustProxy lets Nextly read client addresses (see Security).

Typed server functions

Server code calls the same actions without HTTP:

import { commerceServer } from "@nextlyhq/plugin-commerce";

export async function addToCart(token: string | null) {
  const { cart } = await commerceServer();
  return cart.add({ token, reference: "drill", quantity: 1 });
}

The cart functions take the token from the cart cookie, or null for a shopper without one, and answer the cart with what to do with the cookie: keep it, set it to a token until a deadline, or clear it. The caller reads and writes the cookie, with readCartToken, cartCookieHeader and clearCartCookieHeader from the same package. checkout.quote({ token }) and checkout.complete({ token, quoteKey, email, addresses }) take the same token. A plugin reaches them all through its own context with commerceServices(ctx).

Errors

Besides core's VALIDATION_ERROR and CSRF_FAILED, checkout answers these:

CodeStatusWhen
COMMERCE_CART_EMPTY409There is no cart, or nothing in it
COMMERCE_QUOTE_CHANGED409The cart or a price changed since the quote
COMMERCE_OUT_OF_STOCK409An item no longer has the stock asked for
COMMERCE_NOTHING_TO_PAY409The total is nothing; free orders cannot be placed yet
COMMERCE_JOBS_NOT_RUNNING503Commerce's jobs have not run recently, or ever
COMMERCE_PAYMENTS_NOT_CONFIGURED500The store has no payments provider