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.
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
Instruções da origem · Visualização somente leitura
name
stacks-commerce
description
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.
license
MIT
compatibility
Bun >= 1.3.0, TypeScript
allowed-tools
Read Edit Write Bash Grep Glob
Stacks Commerce
Comprehensive e-commerce module with 15 sub-modules and 20+ models.
// Methods, rates, zones, routes, drivers, live tracking
tax
// Tax rates and breakdown
waitlists
// Waitlists
restaurant
// Restaurant features (lives at waitlists/restaurant)
devices
// Print device management
receipts
// Receipt records
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
Drivers
Digital deliveries
License keys
Live tracking (commerce.shippings.tracking) - see below
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'thrownewHttpError(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.
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 driver's device.await tracking.recordDriverPing({
driverId, latitude, longitude, speed, accuracy,
})
await tracking.completeStop(stop.id) // order -> DELIVERED, route closes itself
What recordDriverPing does
One entry point, so a tracking page never shows a position its ETA disagrees
with. Per fix it: appends to driver_pings, updates the driver'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 driver
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
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
driver rather than Infinity.
Integration with Payments
Commerce works with @stacksjs/payments for Stripe integration:
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. Drivers 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 })