alepha@docs:~/framework/docs/guides/features$
cat 7-commerce.md | pretty
7 min read
Last commit:

#Commerce

@alepha/commerce is a headless shop: catalog, cart, checkout, orders, invoices. It runs on alepha/api/payments, so any payment provider works, and it is a set of modules you import one by one: a point-of-sale takes the core and leaves the cart behind.

bash
yarn add @alepha/commerce @alepha/ui

#Modules

Module What it adds
@alepha/commerce Catalog, categories, variants, orders, stock and interval ledgers
@alepha/commerce/cart Server-side baskets behind a signed cookie
@alepha/commerce/checkout The checkout session, addresses, VAT, the handoff to the payment rail
@alepha/commerce/shipping Delivery zones and rates
@alepha/commerce/promotions Discount codes and automatic discounts
@alepha/commerce/invoicing Gapless invoices and credit notes
@alepha/commerce/notifications The confirmation and shipping emails
@alepha/commerce/settlement The durable jobs: invoice, confirmation, credit note, reconciliation
@alepha/commerce/downloads Digital goods: expiring, counted links
@alepha/commerce/recovery Abandoned-cart reminders, with consent
@alepha/commerce/admin The back-office API; its React screens are admin/components/*
@alepha/commerce/vat The VAT arithmetic alone

A shop imports the ones it needs:

ts
 1import { AlephaCommerceAdmin } from "@alepha/commerce/admin"; 2import { AlephaCommerceInvoicing } from "@alepha/commerce/invoicing"; 3import { AlephaCommerceNotifications } from "@alepha/commerce/notifications"; 4import { AlephaCommercePromotions } from "@alepha/commerce/promotions"; 5import { AlephaCommerceSettlement } from "@alepha/commerce/settlement"; 6import { AlephaCommerceShipping } from "@alepha/commerce/shipping"; 7import { $module } from "alepha"; 8  9export const ShopApi = $module({10  name: "shop.api",11  imports: [12    // Before anything that imports the checkout: shipping substitutes into it.13    AlephaCommerceShipping,14    AlephaCommercePromotions,15    AlephaCommerceInvoicing,16    AlephaCommerceNotifications,17    AlephaCommerceSettlement,18    AlephaCommerceAdmin,19  ],20});

Invoices, confirmation emails and credit notes are jobs in AlephaCommerceSettlement: without that module, invoicing and notifications do nothing on a sale.

#Products, kinds and variants

A product has a kind, and the handler registered for it decides what selling one means. The core ships good (stock-tracked) and digital. An application adds its own by subclassing ProductKindHandler; ResourceKindHandler is the base for anything sold by the interval, a court or a seat.

A product with option groups sells through variants, each with its own SKU, stock and optional price. A line on such a product must name one:

ts
 1const ring = await catalog.create({ 2  slug: "alliance-zenith", 3  name: "Alliance Zénith", 4  price: 42000, 5  published: true, 6  optionGroups: [{ name: "Taille", values: ["50", "54"] }], 7}); 8  9const variant = await variants.create(ring.id, {10  sku: "ZENITH-54",11  options: { Taille: "54" },12});13await stock.recordIntake(ring.id, 3, { variantId: variant.id });

VariantService.generate writes the missing combinations of the option groups, and leaves what exists alone.

A product also carries compareAtPrice, metaTitle, metaDescription, weightGrams, lengthMm, widthMm and heightMm. A compare-at price reaches a storefront only when it is above the price, and only in the product's own currency. EU law wants it to be the lowest price of the last 30 days: nothing checks that, it is the merchant's claim.

The public list, GET /api/commerce/products, takes category (a slug, with everything under it), q, minPrice, maxPrice, available, currency and sort.

#Stock

Stock is a ledger, never a counter. onHand sums its movements, reserved sums the live holds, and available is one minus the other: what a storefront shows. A hold is taken when the checkout reaches payment and lives 30 minutes. On Postgres a claim takes an advisory lock; on SQLite and D1 it replays the ledger, so concurrent buyers cannot oversell either way.

#Checkout

A checkout session moves from a cart to a paid order: start, setEmail, setAddress, setShippingMethod, pay. Settlement arrives later, from the payment webhook, through payments:captured. Every action checks that the caller holds the session's cart cookie or is its signed-in user.

A capture that lands after its hold expired and its stock was sold becomes a stray capture: the order is cancelled, the money is recorded on it, and commerce:capture:strayDetected fires. Nobody is refunded automatically.

The settlement issues the invoice and sends the emails as retried jobs, the confirmation and the shipping notice alike, so a failing mail server delays a message without losing it. A nightly job deletes expired carts and abandons checkouts left open past checkoutConfig.abandonOpenAfterDays.

Delivery is a table of zones and rates. A rate has a price, an optional freeAbove, and an optional weight band, minWeightGrams and maxWeightGrams: above the floor, up to and including the ceiling, one rate per band. A cart weighs what its lines say, the variant's weight over the product's, and a line with none weighs nothing. A cart that grows out of the band its buyer chose is refused at pay(), and they choose again.

#Promotions

A promotion takes a percentage or an amount off, or makes delivery free, with an optional minimum order, validity window and usage limits. With a code the buyer asks for it; without one it applies to every cart that qualifies, and the best one wins. A cart takes one promotion, and an applied code beats an automatic one.

ts
1await promotions.create({2  name: "Bienvenue",3  code: "BIENVENUE10",4  kind: "percent",5  value: 1000, // basis points: 10 %6  usagePerCustomer: 1,7});

A discount lowers the tax-inclusive lines before VAT is computed, and the order and the invoice freeze it.

#Tax

Prices are tax-inclusive, and TaxService extracts the VAT they contain. taxConfig holds the rules:

ts
 1alepha.store.set(taxConfig.key, { 2  defaultRateBps: 2000, 3  originCountry: "FR", 4  // Past the EU's 10,000 euro threshold of cross-border B2C sales. 5  distanceSelling: true, 6  countries: { 7    DE: { standardBps: 1900, reduced: { "550": 700 } }, 8  }, 9  reverseCharge: true,10});

With distanceSelling on, a line and its delivery pay the delivery country's rate. countries starts empty on purpose, and a destination missing from it refuses the checkout: a rate a package shipped is a rate nobody updated.

With reverseCharge on, a buyer in another EU country whose number passes VIES (setVatNumber) pays net, and the invoice carries the number and the statutory mention.

#Currencies

A product has one price, in one currency, and a cart that never chose reads exactly that. To sell in others, name them and write a price for each: nothing is converted, because a figure moved by a nightly rate is not one a buyer can be shown twice.

ts
1alepha.store.set(orderConfig.key, {2  ...alepha.store.get(orderConfig.key)!,3  baseCurrency: "EUR",4  currencies: ["EUR", "CHF"],5});6 7await prices.set(ring, { currency: "CHF", price: 44000 });

A storefront sets the cart's currency with PUT /api/commerce/cart/currency and reads the catalogue with ?currency=CHF, which leaves out what has no price there. A product with no row in a currency is not on sale in it, and a variant with a price of its own needs its own row, or it would sell at the product's. Switching a cart holding such a line is refused, naming it.

A delivery rate or a promotion takes an optional currency, the shop's own when unset, and a rate is offered to carts in its currency only. A plain percentage applies everywhere; an amount or a minimum only in its own currency.

The order, the payment and the invoice are in the buyer's currency. An EU invoice in a foreign currency has to state its VAT in the seller's as well, at a rate your tax administration accepts, so substitute ExchangeRateProvider: without one the invoice goes out without that line and the log says so.

Amounts are in hundredths, so a zero-decimal currency such as JPY does not work, and sort=price reads the product's own price. Selling outside the EU also takes AddressRulesProvider, which knows the EU-27, and a rate for the destination in taxConfig.countries.

#Refunds, credit notes and returns

The back office refunds an amount, some lines, or everything left, through the payment rail first. A credit note follows by itself, for what was refunded, and the notes of a fully refunded order add up to its invoice, rate by rate. A full refund restocks only an order that never shipped.

A return goes requested, received, then refunded or refused. A customer requests one within orderConfig.withdrawalDays of delivery; receiving it restocks what can be sold again; refunding it goes by line, or everything left, delivery included, when it takes the whole order. Lines of an order that has not shipped are cancelled instead.

#Digital goods

A digital product's config.fileId names a file in alepha/api/files, in a storage no public route serves. With AlephaCommerceDownloads, each paid line gets a link in the confirmation email and on the thank-you page, which stops at its expiry, its download limit or a refund. Set downloadConfig.baseUrl, or the links in emails are relative.

#Cart reminders

AlephaCommerceRecovery sends two reminders for an abandoned cart, only to a buyer who agreed (recoveryConsent, never pre-ticked), with a one-click unsubscribe the mail client understands. Set cartRecoveryConfig.baseUrl, for the same reason.

#Documents and languages

Invoices, credit notes and the default emails read CommerceTexts, in the language of orderConfig.locale: the shop's rather than the visitor's, since a legal document follows the seller's books. French, the default, and English ship. Anything else is a dictionary away, and what it leaves out reads in English:

ts
1alepha.inject(CommerceTexts).add("de", { "invoice.title": "Rechnung" });

#Admin

AlephaCommerceAdmin serves the back-office API, under the permissions admin:commerce:read, admin:commerce:write and admin:commerce:refund. Its screens are React components you mount in your own admin:

ts
1import { AdminOrders } from "@alepha/commerce/admin/components/admin-orders";

Import the Tailwind sources once, after the design system's, and spread the catalogues into your dictionaries:

css
1@import "@alepha/ui/styles.css";2@import "@alepha/commerce/styles.css";

apps/examples/shop is a whole shop built on all of this.