Stacks Commerce
Comprehensive e-commerce module with 15 sub-modules and 36 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 tracking
restaurant 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 all
fetchById(id) - get one
store(data) - create, plus bulkStore
update(id, data) - update, plus bulkUpdate
destroy(id) - delete, plus bulkDestroy
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 |
SKU, options, pricing |
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 max_uses, is_active and the
start/end dates in the WHERE clause, so the database decides the race. A
max_uses of NULL means unlimited. Do not fetch, check and then call
update() 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 50
One 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 to
refunded 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 itself
What 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 from
PROCESSING 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 works with @stacksjs/payments for Stripe integration:
import { Payment } from '@stacksjs/payments'
await Payment.charge(customer, order.totalAmount, paymentMethodId)
Dashboard Routes
All commerce models have dashboard views at /dashboard/commerce/*.
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 with sqlHelpers(env.DB_CONNECTION || 'sqlite').param(n).
- Booleans. A
schema.boolean() attribute becomes a real BOOLEAN on
Postgres, so is_active = 1 is operator does not exist: boolean = integer.
Use sqlHelpers(...).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:migrations after changing a commerce model, and read the
SQL before applying it
- Order
observe: true emits events on create/update/delete
- Products have JSON fields for allergens and nutritionalInfo
- Cart expiry is tracked via
expiresAt; cleanupAbandonedCarts is the sweeper
- Coupon types:
fixed_amount or percentage
- 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)
fetchById in 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-tdd for the seam to test it at, and
/stacks-review before it merges. /stacks-payments covers the Stripe side.
1---2name: stacks-commerce3description: 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.4license: MIT5---67# Stacks Commerce89Comprehensive e-commerce module with 15 sub-modules and 36 models.1011## Key Paths12- Core package: `storage/framework/core/commerce/src/`13- Default functions: `storage/framework/defaults/functions/commerce/`14- Default models: `storage/framework/defaults/app/Models/commerce/`1516## Commerce Namespace1718```typescript19import { commerce } from '@stacksjs/commerce'2021// 15 sub-modules22commerce.products // Products, variants, units, manufacturers, reviews, categories23commerce.carts // The pre-checkout basket24commerce.orders // Orders, order items, export, checkout guards25commerce.customers // Customer records26commerce.coupons // Coupons, including atomic redemption27commerce.payments // Payment records, refunds28commerce.giftCards // Gift cards, balance, redemption and reload29commerce.auctions // Benefit auctions: lots, bids, proxy bidding, pledges30commerce.shippings // Methods, rates, zones, routes, couriers, live tracking31commerce.tax // Tax rates and breakdown32commerce.waitlists // Waitlists33commerce.restaurant // Restaurant features (lives at waitlists/restaurant)34commerce.devices // Print device management35commerce.receipts // Receipt records36commerce.errors // Error tracking37```3839`restaurant` is re-exported from `waitlists/restaurant` rather than having a40directory of its own, which is why the source tree shows one fewer directory41than the namespace has keys. `tests/commerce.test.ts` asserts the exact key set,42so adding a sub-module means updating that count.4344## Sub-Module Operations4546Each sub-module typically provides:47- `fetchAll()` - list all48- `fetchById(id)` - get one49- `store(data)` - create, plus `bulkStore`50- `update(id, data)` - update, plus `bulkUpdate`51- `destroy(id)` - delete, plus `bulkDestroy`5253### Products Sub-Module54- Products: items, variants, units55- Manufacturers56- Reviews57- Categories58- Product waitlists5960### Orders Sub-Module61- Order CRUD62- Order items63- Order export6465### Shipping Sub-Module66- Shipping methods67- Shipping rates (weight-based)68- Shipping zones69- Delivery routes and their stops70- Couriers71- Digital deliveries72- License keys73- **Live tracking** (`commerce.shippings.tracking`) - see below7475## Commerce Models (20+)7677| Model | Key Fields | Relationships |78|-------|-----------|---------------|79| Product | name, price, inventoryCount, allergens(JSON) | belongsTo: Category, Manufacturer; hasMany: Review, ProductUnit, ProductVariant |80| ProductVariant | SKU, options, pricing | belongsTo: Product |81| ProductUnit | unit pricing | belongsTo: Product |82| Category | name, slug, isActive, displayOrder | hasMany: Product |83| Cart | status, total, currency(USD), expiresAt | hasMany: CartItem; belongsTo: Customer |84| CartItem | quantity, unitPrice, totalPrice | belongsTo: Cart |85| Order | status, totalAmount, orderType, deliveryAddress | hasMany: OrderItem, Payment; belongsTo: Customer |86| OrderItem | quantity, price | belongsTo: Order, Product |87| Coupon | code(unique), discountType, discountValue, usageLimit | hasMany: Order |88| GiftCard | code(unique), initialBalance, currentBalance, isReloadable | belongsTo: Customer |89| Customer | name, email, totalSpent, status | hasMany: Order, GiftCard, Review, Payment |90| Manufacturer | manufacturer info | hasMany: Product |91| Review | rating(1-5), content, isVerifiedPurchase, helpfulVotes | belongsTo: Product, Customer |92| ShippingRate | weightFrom, weightTo, rate | belongsTo: ShippingMethod, ShippingZone |93| DeliveryRoute | stops, totalDistance, status(planned/active/completed), startedAt | belongsTo: Courier; hasMany: DeliveryStop, CourierPing |94| DeliveryStop | sequence, status, address, latitude, longitude, etaAt, arrivedAt | belongsTo: DeliveryRoute, Order |95| Courier | name, phone, vehicleNumber, status, latitude, longitude, heading, lastPingAt | hasMany: DeliveryRoute, CourierPing |96| CourierPing | latitude, longitude, heading, speed, accuracy, recordedAt | belongsTo: Courier, DeliveryRoute |97| TaxRate | name, rate(0-100), type(VAT/GST/Sales Tax) | |98| LicenseKey | key(XXXX-XXXX-XXXX-XXXX-XXXX), template, status | belongsTo: Customer, Product, Order |99| DigitalDelivery | downloadLimit, expiryDays, automaticDelivery | |100| WaitlistProduct | product waitlist tracking | |101| Receipt | receipt records | |102103## Money paths104105Three operations move money, and all three are written as a **single conditional106UPDATE** rather than read-check-write. Concurrency here is not theoretical: two107parallel requests against a read-then-write redemption both see the pre-state108and both succeed, which is a coupon redeemed past its limit or a gift card spent109twice.110111### Redeeming a coupon112113```ts114const result = await commerce.coupons.redeem(couponId)115116if (!result.ok) {117 // 'not-found' | 'inactive' | 'expired' | 'limit-reached'118 throw new HttpError(400, `Coupon cannot be redeemed: ${result.reason}`)119}120// result.coupon reflects the post-redemption state.121```122123`redeem` bumps `usage_count` and enforces `max_uses`, `is_active` and the124start/end dates in the WHERE clause, so the database decides the race. A125`max_uses` of `NULL` means unlimited. Do not fetch, check and then call126`update()` to increment: that is the exact pattern this replaced.127128### Spending or reloading a gift card129130```ts131await commerce.giftCards.updateBalance(cardId, -25) // redeem 25132await commerce.giftCards.updateBalance(cardId, 50) // reload 50133```134135One entry point for both directions, and they are deliberately asymmetric:136137| | Redemption (`amount < 0`) | Reload (`amount > 0`) |138|---|---|---|139| Allowed status | `ACTIVE` | `ACTIVE`, or `USED` when the card is `isReloadable` |140| `lastUsedDate` | stamped | left alone |141| Balance floor | cannot go below 0 | n/a |142| Expiry | refused past `expiryDate` | refused past `expiryDate` |143144The balance lands on 0 and the status flips to `USED` in the same statement. A145reloadable card can be revived from `USED`; a non-reloadable one cannot, and146throws `Gift card is not reloadable`. `deactivate(id)` is the terminal state and147reports whether a row actually changed.148149### Recording a refund150151`commerce.payments.recordRefund(id, amount)` takes **integer minor units** and152enforces `refund_amount + amount <= amount` in the WHERE clause, so operators153cannot over-refund a payment by racing each other. It flips the status to154`refunded` or `partiallyRefunded` depending on where the total lands.155156## Carts157158`commerce.carts` is the pre-checkout basket, with the same CRUD shape as every159other sub-module plus bulk variants. `commerce.orders` owns what happens after160checkout, and `orders/guards.ts` holds the pre-flight checks that run between161the two, including `cleanupAbandonedCarts({ olderThanDays, limit })` for the162sweeper you schedule daily.163164## Live Delivery Tracking165166`commerce.shippings.tracking` is the moving part of shipping: position ingest,167the stop lifecycle, and the fan-out that drives a customer's tracking map.168169```ts170import { commerce } from '@stacksjs/commerce'171172const { tracking } = commerce.shippings173174// Put an order on a route, then set the vehicle moving.175const stop = await tracking.assignStop({176 deliveryRouteId: route.id,177 orderId: order.id,178 address: '3821 Grand View Blvd, Los Angeles CA 90066',179 latitude: 34.0128,180 longitude: -118.4361,181})182await tracking.startRoute(route.id)183await tracking.startStop(stop.id) // order -> OUT_FOR_DELIVERY184185// One call per position fix from the courier's device.186await tracking.recordCourierPing({187 courierId, latitude, longitude, speed, accuracy,188})189190await tracking.completeStop(stop.id) // order -> DELIVERED, route closes itself191```192193### What `recordCourierPing` does194195One entry point, so a tracking page never shows a position its ETA disagrees196with. Per fix it: appends to `courier_pings`, updates the courier's denormalised197present position, recomputes the served stop's ETA, broadcasts the position,198and latches `delivery:nearby` / `delivery:arrived` so each fires exactly once.199200A fix reporting worse than 250m accuracy is stored but does not move the courier201or trip a threshold.202203### Two fan-outs, on purpose204205| Path | Carries | Why |206|---|---|---|207| Realtime channel (`@stacksjs/realtime`) | `delivery:position`, plus every state change | Fires every few seconds per delivery; only browsers care |208| Event bus (`@stacksjs/events`) | `delivery:assigned`, `:started`, `:nearby`, `:arrived`, `:completed`, `:failed` | Where notifications, analytics and fulfilment subscribe |209210Position never reaches the event bus. Subscribe to the state changes to send an211SMS without being woken several times a minute per active delivery.212213Channels are `order.{id}` for a customer's page and `delivery-route.{id}` for a214dispatch map, both private: authorise them in your `setWsAuthenticator`.215216### Order status217218`OUT_FOR_DELIVERY` sits between `SHIPPED` and `DELIVERED`, reachable from219`PROCESSING` too (a local kitchen goes straight out on its own van), and falls220back to `SHIPPED` when a drop fails and the parcel returns to the depot.221`canTransition` enforces it.222223### Geodesy224225`distanceInMeters`, `bearingInDegrees`, `estimateSecondsRemaining`, `isWithin`226and `hasCoordinates` are exported for building dispatch views. The ETA pads227straight-line distance by a detour factor and returns `null` for a stationary228courier rather than `Infinity`.229230## Integration with Payments231Commerce works with `@stacksjs/payments` for Stripe integration:232```typescript233import { Payment } from '@stacksjs/payments'234await Payment.charge(customer, order.totalAmount, paymentMethodId)235```236237## Dashboard Routes238All commerce models have dashboard views at `/dashboard/commerce/*`.239240## Gotchas241242### Writing raw SQL here243244Two dialect traps have each shipped a broken money path, so both are now guarded245by `src/tests/sql-dialect-portability.test.ts`:246247- **Placeholders.** Postgres numbers them (`$1`); a literal `?` is a syntax248 error. Render them with `sqlHelpers(env.DB_CONNECTION || 'sqlite').param(n)`.249- **Booleans.** A `schema.boolean()` attribute becomes a real `BOOLEAN` on250 Postgres, so `is_active = 1` is `operator does not exist: boolean = integer`.251 Use `sqlHelpers(...).boolTrue` / `.boolFalse`.252253Both pass on SQLite, which is what the tests run against, so neither shows up254locally. That is the whole reason the source-level test exists.255256### Reading back a row you just inserted257258Use `insertedId(result)` from `utils/inserted-id`. Couriers disagree: SQLite259reports `lastInsertRowid`, MySQL reports `insertId`, and Postgres reports260neither without a `RETURNING` clause (fall back to the `uuid` the row was261written with).262263**Never read a row count as an id.** `numInsertedOrUpdatedRows` says how many264rows changed, not which one, so a successful single-row insert reports `1` and265the caller fetches row 1 of the table instead of the new record. `mutationCount`266is the helper for when the count is genuinely what you want.267268### Everything else269270- Commerce models are auto-generated - edit definitions, not generated files271- Run `buddy generate:migrations` after changing a commerce model, and read the272 SQL before applying it273- Order `observe: true` emits events on create/update/delete274- Products have JSON fields for allergens and nutritionalInfo275- Cart expiry is tracked via `expiresAt`; `cleanupAbandonedCarts` is the sweeper276- Coupon types: `fixed_amount` or `percentage`277- Gift card codes are unique and auto-generated278- License keys follow XXXX-XXXX-XXXX-XXXX-XXXX format279- Product dashboard is highlighted (`dashboard: { highlight: true }`)280- Default seeder counts: Product(10), Order(20), Review(50), Payment(50),281 GiftCard(20), Customer(20), Coupon(15)282- `fetchById` in most sub-modules returns the raw row, so columns arrive283 snake_cased even though the declared type is camelCase284285## Downstream286287> Touching a money path? `/stacks-tdd` for the seam to test it at, and288> `/stacks-review` before it merges. `/stacks-payments` covers the Stripe side.