#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.
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:
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:
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.
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:
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.
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:
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:
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:
1@import "@alepha/ui/styles.css";2@import "@alepha/commerce/styles.css";
apps/examples/shop is a whole shop built on all of this.