Use when building e-commerce features in Stacks - the commerce namespace with 15 sub-modules (products, carts, orders, customers, coupons, payments, gift cards, auctions, shipping, tax, waitlists, restaurant, devices, receipts, errors), 20+ commerce models, checkout and redemption logic, or the commerce configuration. Covers @stacksjs/commerce.
Install
npx skillscat add stacksjs/stacks/stacks-commerce Install via the SkillsCat registry.
Stacks Commerce
Comprehensive e-commerce module with 15 sub-modules and 37 models.
Key Paths
- Core package:
storage/framework/core/commerce/src/ - Default functions:
storage/framework/defaults/functions/commerce/ - Default models:
storage/framework/defaults/app/Models/commerce/
Commerce Namespace
import { commerce } from '@stacksjs/commerce'
// 15 sub-modules
commerce.products // Products, variants, units, manufacturers, reviews, categories
commerce.carts // The pre-checkout basket
commerce.orders // Orders, order items, export, checkout guards
commerce.customers // Customer records
commerce.coupons // Coupons, including atomic redemption
commerce.payments // Payment records, refunds
commerce.giftCards // Gift cards, balance, redemption and reload
commerce.auctions // Benefit auctions: lots, bids, proxy bidding, pledges
commerce.shippings // Methods, rates, zones, routes, couriers, live tracking
commerce.tax // Tax rates and breakdown
commerce.waitlists // Waitlists
commerce.restaurant // Restaurant features (lives at waitlists/restaurant)
commerce.devices // Print device management
commerce.receipts // Receipt records
commerce.errors // Error trackingrestaurant is re-exported from waitlists/restaurant rather than having a
directory of its own, which is why the source tree shows one fewer directory
than the namespace has keys. tests/commerce.test.ts asserts the exact key set,
so adding a sub-module means updating that count.
Sub-Module Operations
Each sub-module typically provides:
fetchAll()- list allfetchById(id)- get onestore(data)- create, plusbulkStoreupdate(id, data)- update, plusbulkUpdatedestroy(id)- delete, plusbulkDestroy
Products Sub-Module
- Products: items, variants, units
- Manufacturers
- Reviews
- Categories
- Product waitlists
Orders Sub-Module
- Order CRUD
- Order items
- Order export
Shipping Sub-Module
- Shipping methods
- Shipping rates (weight-based)
- Shipping zones
- Delivery routes and their stops
- Couriers
- Digital deliveries
- License keys
- Live tracking (
commerce.shippings.tracking) - see below
Commerce Models (20+)
| Model | Key Fields | Relationships |
|---|---|---|
| Product | name, price, inventoryCount, allergens(JSON) | belongsTo: Category, Manufacturer; hasMany: Review, ProductUnit, ProductVariant |
| ProductVariant | variant, type, options, status, sku (unique per product), price / compareAtPrice (minor units; null price inherits the product's), inventoryCount (null = untracked) | belongsTo: Product |
| ProductUnit | unit pricing | belongsTo: Product |
| Category | name, slug, isActive, displayOrder | hasMany: Product |
| Cart | status, total, currency(USD), expiresAt | hasMany: CartItem; belongsTo: Customer |
| CartItem | quantity, unitPrice, totalPrice | belongsTo: Cart |
| Order | status, totalAmount, orderType, deliveryAddress | hasMany: OrderItem, Payment; belongsTo: Customer |
| OrderItem | quantity, price | belongsTo: Order, Product |
| Coupon | code(unique), discountType, discountValue, usageLimit | hasMany: Order |
| GiftCard | code(unique), initialBalance, currentBalance, isReloadable | belongsTo: Customer |
| Customer | name, email, totalSpent, status | hasMany: Order, GiftCard, Review, Payment |
| Manufacturer | manufacturer info | hasMany: Product |
| Review | rating(1-5), content, isVerifiedPurchase, helpfulVotes | belongsTo: Product, Customer |
| ShippingRate | weightFrom, weightTo, rate | belongsTo: ShippingMethod, ShippingZone |
| DeliveryRoute | stops, totalDistance, status(planned/active/completed), startedAt | belongsTo: Courier; hasMany: DeliveryStop, CourierPing |
| DeliveryStop | sequence, status, address, latitude, longitude, etaAt, arrivedAt | belongsTo: DeliveryRoute, Order |
| Courier | name, phone, vehicleNumber, status, latitude, longitude, heading, lastPingAt | hasMany: DeliveryRoute, CourierPing |
| CourierPing | latitude, longitude, heading, speed, accuracy, recordedAt | belongsTo: Courier, DeliveryRoute |
| TaxRate | name, rate(0-100), type(VAT/GST/Sales Tax) | |
| LicenseKey | key(XXXX-XXXX-XXXX-XXXX-XXXX), template, status | belongsTo: Customer, Product, Order |
| DigitalDelivery | downloadLimit, expiryDays, automaticDelivery | |
| WaitlistProduct | product waitlist tracking | |
| Receipt | receipt records |
Money paths
Three operations move money, and all three are written as a single conditional
UPDATE rather than read-check-write. Concurrency here is not theoretical: two
parallel requests against a read-then-write redemption both see the pre-state
and both succeed, which is a coupon redeemed past its limit or a gift card spent
twice.
Redeeming a coupon
const result = await commerce.coupons.redeem(couponId)
if (!result.ok) {
// 'not-found' | 'inactive' | 'expired' | 'limit-reached'
throw new HttpError(400, `Coupon cannot be redeemed: ${result.reason}`)
}
// result.coupon reflects the post-redemption state.redeem bumps usage_count and enforces usage_limit, is_active and the
start/end dates in the WHERE clause, so the database decides the race. Ausage_limit of NULL means unlimited. Do not fetch, check and then callupdate() to increment: that is the exact pattern this replaced.
Spending or reloading a gift card
await commerce.giftCards.updateBalance(cardId, -25) // redeem 25
await commerce.giftCards.updateBalance(cardId, 50) // reload 50One entry point for both directions, and they are deliberately asymmetric:
Redemption (amount < 0) |
Reload (amount > 0) |
|
|---|---|---|
| Allowed status | ACTIVE |
ACTIVE, or USED when the card is isReloadable |
lastUsedDate |
stamped | left alone |
| Balance floor | cannot go below 0 | n/a |
| Expiry | refused past expiryDate |
refused past expiryDate |
The balance lands on 0 and the status flips to USED in the same statement. A
reloadable card can be revived from USED; a non-reloadable one cannot, and
throws Gift card is not reloadable. deactivate(id) is the terminal state and
reports whether a row actually changed.
Recording a refund
commerce.payments.recordRefund(id, amount) takes integer minor units and
enforces refund_amount + amount <= amount in the WHERE clause, so operators
cannot over-refund a payment by racing each other. It flips the status torefunded or partiallyRefunded depending on where the total lands.
Carts
commerce.carts is the pre-checkout basket, with the same CRUD shape as every
other sub-module plus bulk variants. commerce.orders owns what happens after
checkout, and orders/guards.ts holds the pre-flight checks that run between
the two, including cleanupAbandonedCarts({ olderThanDays, limit }) for the
sweeper you schedule daily.
Live Delivery Tracking
commerce.shippings.tracking is the moving part of shipping: position ingest,
the stop lifecycle, and the fan-out that drives a customer's tracking map.
import { commerce } from '@stacksjs/commerce'
const { tracking } = commerce.shippings
// Put an order on a route, then set the vehicle moving.
const stop = await tracking.assignStop({
deliveryRouteId: route.id,
orderId: order.id,
address: '3821 Grand View Blvd, Los Angeles CA 90066',
latitude: 34.0128,
longitude: -118.4361,
})
await tracking.startRoute(route.id)
await tracking.startStop(stop.id) // order -> OUT_FOR_DELIVERY
// One call per position fix from the courier's device.
await tracking.recordCourierPing({
courierId, latitude, longitude, speed, accuracy,
})
await tracking.completeStop(stop.id) // order -> DELIVERED, route closes itselfWhat recordCourierPing does
One entry point, so a tracking page never shows a position its ETA disagrees
with. Per fix it: appends to courier_pings, updates the courier's denormalised
present position, recomputes the served stop's ETA, broadcasts the position,
and latches delivery:nearby / delivery:arrived so each fires exactly once.
A fix reporting worse than 250m accuracy is stored but does not move the courier
or trip a threshold.
Two fan-outs, on purpose
| Path | Carries | Why |
|---|---|---|
Realtime channel (@stacksjs/realtime) |
delivery:position, plus every state change |
Fires every few seconds per delivery; only browsers care |
Event bus (@stacksjs/events) |
delivery:assigned, :started, :nearby, :arrived, :completed, :failed |
Where notifications, analytics and fulfilment subscribe |
Position never reaches the event bus. Subscribe to the state changes to send an
SMS without being woken several times a minute per active delivery.
Channels are order.{id} for a customer's page and delivery-route.{id} for a
dispatch map, both private: authorise them in your setWsAuthenticator.
Order status
OUT_FOR_DELIVERY sits between SHIPPED and DELIVERED, reachable fromPROCESSING too (a local kitchen goes straight out on its own van), and falls
back to SHIPPED when a drop fails and the parcel returns to the depot.canTransition enforces it.
Geodesy
distanceInMeters, bearingInDegrees, estimateSecondsRemaining, isWithin
and hasCoordinates are exported for building dispatch views. The ETA pads
straight-line distance by a detour factor and returns null for a stationary
courier rather than Infinity.
Integration with Payments
Commerce takes payments through @stacksjs/payments, on whichever provider config.payment.driver names:
import { Payment } from '@stacksjs/payments'
await Payment.charge(customer, { amount: order.totalAmount, currency: 'usd' }, paymentMethodId, { reference: `order-${order.id}` })The provider's webhook reaches orders through POST /webhooks/payments (see stacks-payments).
Dashboard Routes
Commerce dashboard pages use /commerce/* on the dashboard server, for example/commerce/products and /commerce/orders. Their data Actions use/api/dashboard/*. Verify the actual page registry instead of assuming that
every commerce model has an automatically generated dashboard view.
Enable the feature and choose the native seam
Read config/commerce.ts and the feature lifecycle before adding a parallel
cart or checkout library. buddy commerce:install enables the feature and
copies its owned scaffold paths without overwriting existing application work
by default. The @stacksjs/features manifest owns its tables and the migration
runner gates disabled feature migrations. Changing enabled alone does not
prove that a route bundle or view has been mounted.
The namespace's helpers, model methods and provider APIs are separate seams.
Use the existing checkout guards, order state transitions, idempotency and
provider webhook Actions for an order flow; creating a local payment row does
not establish that a provider collected money. Public storefront data should
carry the product/customer/site scope chosen at its boundary.
For variant selection, shipping zones/rates, auctions, digital deliveries or
loyalty rules, inspect the selected submodule's exported operations and its
tests rather than extrapolating a generic CRUD interface. Native models cover
more domain features than the short table here lists. Feature-specific routes
and jobs also need their enabled feature, tables and worker prerequisites.
German tills: TSE signing and the DSFinV-K export
Three pure modules for a till in Germany (KassenSichV, § 146a AO). They hold the rules, not a certification: whether a till is compliant depends on the whole system and its operator.
@stacksjs/commerce/fiskaly: a client for fiskaly SIGN DE v2, a cloud TSE.fiskalySignDe({ apiKey, apiSecret, baseUrl?, fetch? })caches and refreshes its token;provisionTss(api, { tssId, clientId, clientSerialNumber, adminPin, adminPuk? })takes a TSS from nothing (or from where an earlier attempt stopped; keep the PUK it answers) to INITIALIZED with a client;startTransaction/finishTransactionsign a sale (revisions 1 and 2).FiskalyError.outagesays when a sale should go on unsigned and be marked.@stacksjs/commerce/kassensichv: the VAT keys (DE_VAT_KEYS: 1 = 19%, 2 = 7%, 5/6/7 = 0%),processData(Beleg^75.33_7.99_0.00_0.00_0.00^10.00:Bar_64.30:Unbar),fiskalyReceiptFor(amounts per VAT rate and payment type),receiptQrCode(the twelve-fieldV0;…QR content of DSFinV-K Anhang I),receiptVatLinesandTSE_FAILURE_NOTICE.@stacksjs/commerce/dsfinvk: the export.DSFINVK_TABLESare the twenty tables of the official index.xml (DSFinV-K 2.4: files, columns, order, types, lengths, decimals);dsfinvkExport(closings)builds every CSV plusindex.xmlandgdpdu-01-09-2004.dtdfrom a till's cash point closings, totals per business case and payment type included. Rows are validated (mandatory columns, lengths, enumerations, time formats); a broken export throwsDsfinvkErrorinstead of being written. Zip the files yourself (zipEntriesin@stacksjs/storage).
import { fiskalyReceiptFor, processData, receiptQrCode } from '@stacksjs/commerce/kassensichv'
import { fiskalySignDe } from '@stacksjs/commerce/fiskaly'
const vat = [{ key: 1, grossCents: 1500 }, { key: 2, grossCents: 500 }]
const payments = [{ kind: 'Unbar', cents: 2000 }]
const api = fiskalySignDe({ apiKey, apiSecret })
await api.startTransaction(tssId, txId, clientId)
const tx = await api.finishTransaction(tssId, txId, clientId, fiskalyReceiptFor({ vat, payments }))
const qr = receiptQrCode({ clientSerialNumber, processType: 'Kassenbeleg-V1', processData: processData({ vat, payments }), transactionNumber: tx.number, signatureCounter: Number(tx.signature!.counter), start: tx.time_start, end: tx.time_end!, signatureAlgorithm: tx.signature!.algorithm, logTimeFormat: tx.log!.timestamp_format, signature: tx.signature!.value, publicKey: tx.signature!.public_key })Traps: amounts are gross cents (German prices include VAT). A refund is a new receipt with negative amounts, referenced to the sale (references, REF_TYP Transaktion); AVBelegstorno is not for TSE tills. A transaction the TSE could not sign still goes into the export, with tse.error (TSE_TA_FEHLER) and no signature. fiskaly client serials (and KASSE_SERIENNR) may not contain _ or / (isClientSerialNumber).
Gotchas
Writing raw SQL here
Two dialect traps have each shipped a broken money path, so both are now guarded
by src/tests/sql-dialect-portability.test.ts:
- Placeholders. Postgres numbers them (
$1); a literal?is a syntax
error. Render them withsqlHelpers(env.DB_CONNECTION || 'sqlite').param(n). - Booleans. A
schema.boolean()attribute becomes a realBOOLEANon
Postgres, sois_active = 1isoperator does not exist: boolean = integer.
UsesqlHelpers(...).boolTrue/.boolFalse.
Both pass on SQLite, which is what the tests run against, so neither shows up
locally. That is the whole reason the source-level test exists.
Reading back a row you just inserted
Use insertedId(result) from utils/inserted-id. Couriers disagree: SQLite
reports lastInsertRowid, MySQL reports insertId, and Postgres reports
neither without a RETURNING clause (fall back to the uuid the row was
written with).
Never read a row count as an id. numInsertedOrUpdatedRows says how many
rows changed, not which one, so a successful single-row insert reports 1 and
the caller fetches row 1 of the table instead of the new record. mutationCount
is the helper for when the count is genuinely what you want.
Everything else
- Commerce models are auto-generated - edit definitions, not generated files
- Run
buddy generate:migrationsafter changing a commerce model, and read the
SQL before applying it - Order
observe: trueemits events on create/update/delete - Products have JSON fields for allergens and nutritionalInfo
- Cart expiry is tracked via
expiresAt;cleanupAbandonedCartsis the sweeper - Coupon types:
fixed_amountorpercentage - Gift card codes are unique and auto-generated
- License keys follow XXXX-XXXX-XXXX-XXXX-XXXX format
- Product dashboard is highlighted (
dashboard: { highlight: true }) - Default seeder counts: Product(10), Order(20), Review(50), Payment(50),
GiftCard(20), Customer(20), Coupon(15) fetchByIdin most sub-modules returns the raw row, so columns arrive
snake_cased even though the declared type is camelCase
Downstream
Touching a money path?
/stacks-tddfor the seam to test it at, and/stacks-reviewbefore it merges./stacks-paymentscovers the Stripe side.
Input and inventory abstractions
parseSalesTaxSetup(body, { maxRate?, maxRates? }) requires an explicit rates
array, rejects coerced prices/text and duplicate normalized component codes,
and accepts [] only as deliberate removal. HQ-style stores can set maxRate
to 30; the general default is 100. This validates configuration, not the legal
rate applicable to a sale. parseTaxRateWriteData is also used by native tax
store/update/bulk operations so false form booleans cannot select a default.
restockedQuantity(current, units, maximum?) adds positive whole units,
refusing untracked stock and overflow. commerce.products.items.restock(id, units)
uses that rule under a row lock and reads back on its own connection;updateInventory validates absolute quantities through the same boundary.
Pure narrow entries are commerce/inventory and commerce/tax-input.