@saastemly/better-commerce
v0.5.1
Published
Commerce plugin for Better Auth — carts, checkout, orders, and entitlements welded to your auth layer. The spiritual relative of Better Auth for commerce.
Maintainers
Readme
better-commerce
Commerce plugin for Better Auth — carts, checkout, orders, and entitlements welded to your auth layer. The spiritual relative of Better Auth for commerce.
import { betterAuth } from "better-auth";
import { commerce } from "@saastemly/better-commerce";
import { stripeProvider } from "@saastemly/better-commerce/providers/stripe";
export const auth = betterAuth({
// ...your existing config
plugins: [
commerce({
provider: stripeProvider({
secretKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
}),
products: [
{
id: "prod_pro",
name: "Pro License",
prices: [{ id: "price_pro_usd", currency: "usd", unitAmount: 4900 }],
entitlements: ["pro-features"],
},
],
}),
],
});// client
import { createAuthClient } from "better-auth/client";
import { commerceClient } from "@saastemly/better-commerce/client";
export const authClient = createAuthClient({ plugins: [commerceClient()] });The pricing pipeline
The kernel's centre. A pure, synchronous function — no I/O, no clock, no randomness — so it can be property-tested exhaustively:
priceCart({ context, lines, adjustments, shippingLines, taxLines }) → PricedCartresolvePricing is the thin async shell that calls the adapters in the one order their data dependencies allow (promotions → shipping → tax), then hands off to that pure core. With no adapters configured it prices lines and stops, which is the correct default for a store that has none.
commerce({
provider,
products,
pricing: {
promotions: (lines) => [percentageDiscount("save10", "10% off", 0.1, subtotalOf(lines))],
shipping: flatRateShipping(money(500, "usd")),
tax: flatRateTax(0.2, "VAT"), // swap for a Stripe Tax / Avalara adapter
},
})Five invariants hold for any input, asserted with fast-check in test/pricing.test.ts:
total = subtotal + discountTotal + taxTotal + shippingTotalsum(line.total) = subtotal + discountTotal— every discount lands on a line- No line total is ever negative; an over-large discount is absorbed, not paid out
- Allocation is exact — splitting an order-level discount never loses or invents a minor unit
- Every amount in the result shares the cart's currency
Money
Every amount is { amount: integer minor units, currency: ISO 4217 }. Arithmetic refuses to mix currencies and rejects non-integers, so 0.1 + 0.2 and "added USD to EUR" are unrepresentable rather than merely discouraged. Rounding is consulted in exactly one place — applyRate, where a rate can produce a fraction of a minor unit — and defaults to banker's rounding so totals don't drift upward across many lines.
allocate(total, weights) splits an amount so the parts sum exactly to the original (largest-remainder). This is what keeps a 10% discount on three £33.33 lines from losing a penny.
The bounds (read this before adding a feature)
Better Auth works because auth has external specs (OAuth2, OIDC, WebAuthn). Commerce has no such spec, so betterCommerce draws its own line — refined as the project matured:
The kernel owns what is anchored to identity and the money-event lifecycle. The plugin ring owns everything setup-shaped. Adapters delegate everything execution-shaped.
Kernel: customer (the Better Auth user), guest + user carts with merge-on-sign-in, the pricing pipeline, checkout orchestration, the durable order record and its state machine, entitlements ("what does this user own"), and a conformance-tested payment provider adapter interface.
Ring (setup-shaped): structured data with standard conventions, where doing it yourself grows foot-guns — the same reason Better Auth exists for auth. Products (catalog()), discounts (discounts()), fulfillment orchestration (fulfillments()), and ahead: regions/currencies, sales channels, store settings, translations, inventory, gift cards. Each ships as tables + endpoints + conventions, appears in the generated admin with no registration, and always keeps a config-only floor so nothing requires the plugin.
Adapters (execution-shaped): external execution stays behind provider contracts with executable conformance suites — payment processing (PCI), tax calculation (legal liability), carrier APIs (DeliveryProvider: rate → shipment → tracking), mail delivery. The ring owns each domain's orchestration and state; the adapter owns the call to the outside world. Nothing is "out of bounds" — the question is only which side of the provider seam a behavior lives on.
What you get
Kernel routes:
| Route | What it does |
|---|---|
| GET /commerce/cart | Current cart (guest via cookie, or the user's) |
| POST /commerce/cart/add | Add item — price server-resolved from the catalog (tier by resulting quantity), optional metadata for per-line customization; lines merge per (product, customization) |
| POST /commerce/cart/update | Change quantity (0 removes); re-resolves the price tier |
| POST /commerce/cart/address | Set the shipping address; reprices immediately (shipping/tax adapters see it) |
| POST /commerce/checkout | Price the cart, create a pending order with normalized lines, return the provider's hosted-checkout URL — idempotent per cart |
| POST /commerce/webhook | Signature-verified, at-least-once with exactly-once effects webhook → order authorized/paid/refunded, entitlements granted/revoked |
| GET /commerce/orders | The signed-in user's orders |
| GET /commerce/entitlements | The signed-in user's active entitlements |
Admin routes (/commerce/admin/*, better-auth admin role by default): list/get orders, refund, capture (manual capture, bounded by what was authorized — a partial capture releases the rest of the hold and becomes the refund ceiling), cancel, orders/expire-pending (sweep abandoned pending orders), list/revoke entitlements. Ring plugins mount their own under the same prefix and the same guard.
What an order records about money. amountTotal is what the order is WORTH; collectedTotal is what the provider actually holds — they start equal (less any credit) and an order edit moves them apart. collectedAmount(order) reads the second with a fallback for rows written before the column, and every refund path measures against it, so a shrunk order can never look fully refunded while it still has lines. Ticks — orders/expire-pending, subscriptions/process, fulfillments/release — are cron targets: with commerce({ cron: { secret } }) a scheduler without a session calls them with the header x-commerce-cron-secret (constant-time compared; secret may be a function read per request, for runtimes like Cloudflare Workers where env bindings only exist inside a request); otherwise they need an admin session like every other admin route. testApp/crons/commerce-ticks.ts is the reference schedule.
Ring plugin routes, present when the plugin is installed:
| Plugin | Route | What it does |
|---|---|---|
| catalog() | GET /commerce/products | Active products with prices, from rows + config |
| discounts() | POST /commerce/discounts/apply · /remove | Apply/remove a code; the cart reprices through the same pipeline as the order |
| fulfillments() | GET /commerce/credits · POST /commerce/admin/fulfillments/complete | Credit balances; operator completion of manual fulfillments |
| search() | GET /commerce/search?q= | Product search, open to anonymous callers, query length capped |
| shippingZones() | GET /commerce/shipping-options · POST /commerce/cart/shipping-option | What this address can be charged for shipping, and the buyer's choice |
Plus the piece everyone rebuilds badly: an anonymous visitor's cart (tracked by an httpOnly cookie) is automatically merged into their account cart on sign-in/sign-up, summing shared lines, via a Better Auth after hook.
Adapters: the conformance suite is the spec
Three adapter families, one discipline — a tiny contract, a reference implementation, and an executable suite that is the specification:
| Family | Contract | Suite | Reference |
|---|---|---|---|
| Payment | PaymentProvider (@saastemly/better-commerce/providers) | runProviderConformance (8 checks) | memoryProvider; real: Stripe, Adyen |
| Delivery | DeliveryProvider (@saastemly/better-commerce/providers/delivery) | runDeliveryConformance (5) | memoryDeliveryProvider |
| Tax | TaxProvider (@saastemly/better-commerce/providers/tax) | runTaxConformance (5) | tableTaxProvider — rates as a table with jurisdiction precedence |
Tax deserves a note: calculation is liability-shaped and stays behind the
seam forever, but tableTaxProvider shows the shape a taxRegions() plugin
would feed a real engine — a naive provider multiplies the rates it is handed,
Avalara or Stripe Tax ignore them. taxCalculator(provider) bridges any
provider into the pricing pipeline's tax slot; the destination arrives from
the cart's shipping address, so tax appears the moment the address lands.
Payment providers
The PaymentProvider interface (@saastemly/better-commerce/providers) is deliberately tiny: createCheckout, getCheckout, refund, verifyWebhook, plus optional capabilities the kernel uses only when present — capture/cancelAuthorization (auth-then-capture), cancelCheckout, and chargeSaved (off-session charge of a method the checkout saved with savePaymentMethod; subscription renewals). What makes an adapter trustworthy is not the interface — it's passing the executable conformance suite (the ActiveMerchant move):
import { runProviderConformance, defaultSampleCheckout } from "@saastemly/better-commerce/testing";
await runProviderConformance({
provider: myProvider,
sampleCheckout: () => defaultSampleCheckout(),
completeCheckout: (id) => myHarness.simulatePayment(id),
refundPayment: (paymentId) => myHarness.simulateRefund(paymentId),
});10 numbered checks: hosted-URL creation, open→complete lifecycle, signed-event normalization, tampered-payload rejection, forged-signature rejection, stable event ids across redelivery (the consumer idempotency key), payment-id exposure, refund round-trip, manual-capture round-trip (when capture exists), and saved-method charging (when chargeSaved exists: the completion event names the customer, the charge succeeds, the same idempotency key never charges twice, an unknown customer fails without throwing, the charge refunds). It is framework-agnostic — call it from bun test, vitest, or anything else.
Shipped providers:
stripeProvider— Stripe Checkout via plainfetch+ Web Crypto (no SDK; runs on Node, Bun, Deno, and Cloudflare Workers). Webhook signatures verified with timing-safe HMAC and timestamp tolerance. Saved methods viasetup_future_usage, off-session charges as confirmed PaymentIntents under an Idempotency-Key. Verified against a live Stripe sandbox (test/live, 10 checks): a hosted session opens, retrieves and expires; the order reference and line totals come back on the session exactly as the webhook path needs them; a manual-capture session is accepted; the advertised handler carries the real account id; a bad delegated token returnsfailedrather than throwing; a refund of an unknown payment surfaces as an error. And the whole lifecycle, end to end (2026-09-02, viastripe listen --forward-to localhost:5173/api/auth/commerce/webhook): a buyer paid a real hosted Stripe checkout with a test card; Stripe sent a genuinely signedcheckout.session.completed; the kernel verified the signature, settled the order, granted the entitlement and awarded loyalty points. An admin refund then moved real money at Stripe, and thecharge.refundedwebhook that followed produced exactly one refund row — the admin route had already recorded it — before revoking the entitlement and clawing the points back. That last detail is the exactly-once discipline holding against a real provider, which no mock can demonstrate.
And the money path, over real PaymentIntents: an authorization captures and the captured payment refunds, a repeated refund idempotency key returns the same refund rather than moving money twice, cancelAuthorization releases a hold, and an off-session chargeSaved — the subscription renewal path — charges once per key and fails as a value for an unknown customer.
adyenProvider(@saastemly/better-commerce/providers/adyen) — Pay by Link + HMAC-verified notifications; manual capture as an account setting; tokenization throughstorePaymentMethodModewith off-session/paymentsContAuth charges.
Delivery carriers (@saastemly/better-commerce/providers/<slug>, each passing runDeliveryConformance against a mocked vendor; fetch-only, Web Crypto only): easyPostProvider, shippoProvider, fedExProvider, upsProvider, dhlExpressProvider, uspsProvider, doorDashDriveProvider, stuartProvider, aramexProvider, royalMailProvider. Carriers with signed or shared-secret pushes implement verifyWebhook; the rest are polled through track. Tax (runTaxConformance): tableTaxProvider (rates as data), taxJarProvider, avaTaxProvider. Every external adapter is verified against the vendor's published API with a mocked fetch; test/live/ runs the same suites against real test accounts when the matching environment variables are set.
memoryProvider— the reference implementation, for tests and local dev. It signs its webhooks with the samet=,v1=HMAC scheme, so your tests exercise real signature verification. Its hosted page defaults to<origin of successUrl>/api/dev/pay?checkoutId=…, so if your app mounts a dev pay route the local flow has the same shape as production — leave the app, pay, provider fires a signed webhook, land back onsuccessUrl. Override withhostedPageUrl.
Auth-then-capture
Custom goods should not be charged at the cart — they should be charged when
the proof is approved. commerce({ captureMethod: "manual" }) makes checkout
authorize instead of settle: the provider holds the funds, the order waits
in authorized (the cart converts so it cannot be checked out twice, and
nothing is granted or fulfilled yet), and an operator settles it with
POST /commerce/admin/orders/capture — which runs everything a webhook
settlement would (entitlements, onOrderPaid, fulfillment, credits) inside
one transaction — or releases it with POST /commerce/admin/orders/cancel.
The generated admin shows Capture and Cancel on authorized orders.
Stripe, Adyen and the memory provider implement capture /
cancelAuthorization; conformance check 9 covers the round-trip.
Why checkout redirects away
createCheckout returns the provider's hosted payment URL rather than betterCommerce rendering a card form. That is deliberate: a card form on your own domain moves you from PCI SAQ A to SAQ A-EP, which under PCI DSS 4.0.1 sits much closer to full SAQ D — a decision a library should not make for you. Hosted pages also bring wallets (Apple/Google Pay, Link), local methods (iDEAL, SEPA, Klarna), SCA/3DS challenge flows and the provider's fraud engine, each of which differs per provider.
Provider-swappability comes from the adapter contract, not from owning the page: switching Stripe → Polar is a one-line change and nothing in the cart, pricing, orders, entitlements or UI moves. Owning the payment UI would make portability worse, since we would have to normalize each provider's element SDK, 3DS handling and error taxonomy.
The plugin ring
betterCommerce takes plugins the way Better Auth does — capabilities layer on the kernel without the kernel branching on them:
import { commerce } from "@saastemly/better-commerce";
import { discounts } from "@saastemly/better-commerce/plugins/discounts";
commerce({
provider,
products,
plugins: [
discounts({
rules: [{ code: "WELCOME10", label: "Welcome 10% off", percentOff: 10 }],
}),
],
})A CommercePlugin contributes tables (merged into the schema, so migrations
and better-admin-ui's generated panel pick them up with
no registration), endpoints, pricing adapters (promotions resolvers chain ahead
of the app's own; shipping/tax are single-slot), and an onOrderTransition
hook that runs inside the same unit of work as the status write — the event
carries the writing adapter, so a plugin's bookkeeping commits or rolls back
with the transition.
Hooks a ring plugin can take (CommercePlugin): onCartLine, onCartAddress, onCartMerge, onOrderCreated, onOrderTransition, onFulfillmentTransition, onRefundRecorded (every refund row, partial included, inside the writing transaction), creditLines, priceOverride, savePaymentMethod, defaultCurrency, and decorateOrders — a batched hook that adds fields to the buyer and admin order views under plugins.<pluginId>, so a decoration can never shadow a kernel field.
discounts() is the reference: rules live as data in a discount table
(percentOff/amountOff, product narrowing, time windows, automatic rules,
maxRedemptions counted on the paid transition) plus optional config rules;
customers apply codes via POST /commerce/discounts/apply and the cart
reprices through the same pipeline that prices the order.
fulfillments() runs what happens after the money: a paid order line
routes to a fulfillment provider and a fulfillment row walks its own machine
(pending → fulfilled|shipped → delivered, refunds cancel what has not
completed). The built-ins are the auth-anchored deliveries no generic commerce
stack can do: manualFulfillment() (operator completes), emailLinkFulfillment()
(download links through your mailer), authFulfillment() (the buyer paid for
who they get to BE — roles, permissions, memberships, applied inside the
webhook transaction), creditsFulfillment() (consumable credits in an
append-only ledger with balances at GET /commerce/credits and clawback on
refund). Physical carriers plug in through the DeliveryProvider contract
(@saastemly/better-commerce/providers/delivery) — getRates → createShipment → track —
with its own conformance suite (runDeliveryConformance), a memory reference
carrier, and deliveryFulfillment() bridging a rate into a shipped row with
tracking. getRates takes an optional AbortSignal, so a quote the store
stopped waiting for is actually cancelled rather than left running unread —
which on Workers is billable. Every HTTP carrier honours it, token refreshes
and 401 retries included; a carrier that ignores it still satisfies the
contract, and one that quotes from a local tariff table has nothing to
cancel. It ships what the buyer paid for: when the shipping charge names
the rate it was quoted from, that rate is bought, and only an order with no
recorded rate is re-quoted at fulfilment time. Re-quoting hours later can pick
a different carrier from the one the buyer chose and was charged for, which is
a promise broken at the doorstep rather than a pricing detail.
The rest of the ring follows the same shape — tables as data, screens for free,
no behaviour until rows exist: catalog() (products and prices as rows beside
the config list, plus variants: options, values, variant-scoped prices and
images, with the VARIANT as the sellable thing so cart, pricing, inventory,
fulfilment and returns work on it unchanged, and a grouped view at
GET /commerce/catalog/products for product pages), regions() (currency by
country, cart reprices on switch), inventory() (levels and reservations,
commit on paid, release on cancel — and locations: aggregate availability,
ranked allocation that splits a line across warehouses when no single one
covers it, and atomic transfers), store() + translations() (settings and
locale strings), giftCards() (credit lines the provider never sees),
loyalty() (points earned on the net total, redeemed as a credit line capped
after gift cards, clawed back proportionally on refund — partial refunds
included), salesChannels(), priceLists() (sale/override prices per group or
window through the priceOverride slot), orderEdits() (an operator's edits,
claims and exchanges on a paid order: staged actions, then one confirm that
rewrites the lines, reprices, and settles the difference), and returns() —
the buyer's road to the kernel's
refunds: POST /commerce/returns opens a request for specific lines, the
operator approves/receives/refunds it (/commerce/admin/returns/*), and the
refund books through the same orderRefund row the admin refund uses, under a
key derived from the return so retries cannot double-pay. subscriptions()
is bookkeeping over ordinary orders: a paid line for a product with a
subscription plan opens one; ahead of each period end the tick
(POST /commerce/admin/subscriptions/process, cron it) drops a renewal line
into the buyer's cart and the same checkout/webhook/paid transition extends
the period; unpaid past grace → canceled and the product's entitlements
revoke. When the provider implements chargeSaved (Stripe, memory), the
origin checkout saves the method and the tick renews off-session
(Stripe, Adyen and memory all implement it): it creates the renewal order
itself, charges under a per-period idempotency key,
and settles through the same paid transition; a decline falls back to the
cart line and fires onRenewalFailed, so a failed renewal is never silent.
bundles() makes a kit its components at
a set price: POST /commerce/bundles/add puts tagged component lines in the
cart and the plugin's promotions resolver prices each intact group at the
bundle price, so fulfillment, inventory, returns and entitlements all run per
component with no special case. Pre-orders are a fulfillment hold rather
than a plugin: a product with preOrder.availableAt is paid now and its
fulfillment row waits pending until POST /commerce/admin/fulfillments/release
(cron it) runs the provider on or after that date. merchants() makes the store a marketplace without making the kernel one: the
platform stays the merchant of record, so there is still one cart, one charge
on its own provider account, one order and one refund path — and what each
seller is owed lives in an append-only ledger, paid out by a PayoutProvider
adapter whose default implementation is a manual bank transfer. Nothing splits
funds at charge time, which is why all thirteen payment, delivery and tax
adapters keep working unchanged.
acp() (@saastemly/better-commerce/acp) exposes the Agentic Commerce Protocol —
one agent checkout session is one cart, and completing it mints an order
through the same function the hosted checkout uses and settles it through the
same function the provider webhook uses, so an agent order and a browser
order produce identical entitlements, fulfilment rows and inventory commits.
Its executable spec is runAcpConformance from @saastemly/better-commerce/testing.
Merchandising depth fills in the rest of what an operator expects:
catalog() also carries a category tree plus tags, types and collections
(deleting a parent re-parents its children rather than orphaning the branch);
discounts() carries campaigns, a budget shared across promotions and
decremented as redemptions land; shippingZones() decides what a buyer can be
charged for shipping and records the charged rate id on the order, which is
what lets fulfilment ship it; taxRegions() is tax by country and province
with product classes and overrides; customerGroups() is segments with
currency-scoped spend rules, so a rule written in one currency cannot
silently qualify a buyer spending another; and draftOrders() lets an operator
build a cart on a buyer's behalf and convert it at prices the operator set.
Platform services
Six modules that are infrastructure rather than commerce, each an adapter contract with an executable conformance suite:
runtime()— cache and locking. The lock is what makes a cron tick running in two isolates take the work once. The catalog cache TTL is capped at 30 seconds and every catalog write invalidates it, because the catalog carries prices and the price a cart line is written with is the price that gets charged. Adapters: memory, Cloudflare KV, and a database lock.events()— an outbox. The event row is written inside the transaction that caused it, so it shares that transaction's fate; delivery is a separate ticked pass with backoff and dead letters. The lease protects the ROW, never the handler's external effect, so handlers must still be idempotent. With no subscribers and nojournal, it deliberately writes nothing.workflows()— durable sagas. Every step's effect sits behind a UNIQUE slot, so a retry cannot run it twice, and a failure compensates backwards through the steps that already ran.workflowStep.positionis EXECUTION order, not the step's index in the definition: a deploy may APPEND steps to a workflow with live runs, but inserting, reordering or removing one unwinds the affected run. Ship a new workflowidinstead.notifications()— templated sends with per-parcel keys, so a two-parcel order sends two emails and a retry sends none. Adapters: memory and Resend.files()— presigned upload, confirm and delete, with the provider recorded on each row so a store that migrated adapters cannot delete through the wrong one. Adapters: memory, S3 and R2.search()— index sync over a provider, with the buyer query capped so an anonymous request cannot burn unbounded CPU. Adapters: memory and Meilisearch.
External systems wrap as adapters: businessCentralProvider
(providers/business-central) serves BC items as the catalog and turns paid
orders into BC sales orders.
Wishlists
wishlists() is small, with three decisions that are easy to get wrong:
- Saving twice is not two entries. A heart icon gets double-clicked, and two tabs beat a read-then-insert, so one entry per (person, product, variant) is a UNIQUE slot and saving again returns the existing entry.
- The price shown is today's price. A wishlist that remembered the price
when you saved it would quietly lie every time the catalog changed, and the
buyer would find out at checkout. Entries store the product id; prices
resolve on read. A product that has left the catalog is reported as
available: falserather than silently dropped. - Checking is one query.
GET /commerce/wishlist/check?productIds=a,b,ctakes a list, because the obvious per-product version is an N+1 the moment a listing page renders twenty hearts.
There is deliberately no move to cart. It sounds like the thing a wishlist is
for, but it needs a second way into the cart — and a second way into the cart is
how a wishlist becomes the route by which somebody buys something out of stock.
The client already has cart/add and wishlist/remove.
Reviews
reviews() is a table and four routes, plus the one part that is not CRUD.
A badge saying verified purchase is the only reason a shopper trusts a
rating, and it is worthless the moment a client can claim it. So verified is
DERIVED — the endpoint looks for a paid order of that product by that user
and writes what it finds. It is not a field in the request body at all, so a
caller can send verified: true all day and nothing happens.
reviews({
autoApprove: false, // hold reviews for moderation (the default)
purchasersOnly: false, // or refuse anyone without a paid order
})Three other decisions worth knowing:
- One review per person per product, enforced by a UNIQUE slot rather than a read-then-insert, because two tabs are enough to lose that race. Reviewing again edits the existing review, which is what a person means by it — and an edit goes back through moderation, because otherwise an approved review is a writable public surface.
- The average is computed over every approved review, not the current page. A rating that changed when you turned the page would be worse than no rating.
- An unpaid order is not a purchase.
Rate limiting is OFF unless you say otherwise
Better Auth defaults rateLimit.enabled to process.env.NODE_ENV ===
"production", read once at module load. A Cloudflare Worker sets no
NODE_ENV, so on Workers the default is off in a deployed app exactly as it
is in dev, and every rule any plugin declares is silently inert. Nothing
errors; the ceilings simply never apply.
betterAuth({
// ...
rateLimit: { enabled: true, storage: "database" },
})storage defaults to in-memory, which on a platform that runs many isolates is
a quota per isolate rather than a quota. "database" puts the counters in
the same database as everything else.
This matters here because two buyer routes take a code from an anonymous
caller and tell them whether it was right. discounts() and giftCards() both
ship rules for their own routes, and those rules do nothing until the line
above exists:
| Route | Why it is metered |
|---|---|
| POST /commerce/discounts/apply · /remove | Discount codes are chosen by people — WELCOME10, SUMMER — so they are guessable by design |
| GET /commerce/gift-cards/balance · POST /apply | A gift card code is a bearer instrument, and the balance route answers "does this exist and what is it worth" |
A ring plugin contributes rules through CommercePlugin["rateLimit"], merged
in installation order — Better Auth takes the first matching rule, so a plugin
installed earlier wins a path two of them claim.
Design notes
- Money is always integer minor units, carried as a
Moneyvalue with its currency. - Orders store normalized lines (
orderLine) andorderAdjustmentrows for discounts, tax and shipping — line identity is what makes partial refunds and returns expressible. - The cart and the order are priced by the same function, so the total shown and the total charged cannot drift apart.
- Orders are created by webhooks, idempotently: every event id is recorded; redeliveries are no-ops; entitlements are granted exactly once. Never trust the success-page redirect.
- Routes are hand-prefixed under
/commerce/*so this plugin can coexist with@polar-sh/better-auth,@dodopayments/better-auth, and the official Stripe plugin. Native per-plugin namespacing is proposed upstream in better-auth#11094. - Guest checkout works (no account required); entitlements are only granted when the order has a user.
- ACP: the checkout/order model is shaped to map onto the Agentic Commerce Protocol (see
src/acp.tsfor the mapping). Full ACP endpoint conformance — making any betterCommerce app an ACP-compliant merchant sellable through AI agents — is the v0.2 headline.
Roadmap
- v0.2 — ACP endpoint layer (checkout-session create/update/complete, delegated payment) with conformance tests against the versioned ACP spec. Design decided 2026-09-02: docs/ACP-DESIGN.md —
acp()ring plugin over the kernel's cart→order path, spec-first (vendored 2026-04-17 schema, zod twins, executable conformance A1–A23), delegated payment as achargeTokenprovider capability with Stripe as the reference; ~21 h in 7 phases. ~~Discounts sub-plugin~~ — shipped as the first ring plugin. - v0.3 — ~~
subscriptions()~~ shipped (renewals as ordinary orders, off-session viachargeSaved); ~~multi-merchant~~ shipped asmerchants(), a consignment ledger rather than split charges; ~~customer portal~~ shipped; interop with the official Stripe/Polar plugins. - Platform parity — ~~workflow engine, event bus, notifications, file storage, search, cache and locking~~ all shipped as ring plugins with conformance suites; ~~category tree, campaigns, draft orders, customer groups, shipping zones, tax regions~~ shipped. What Medusa still has that we do not: a hosted admin application (ours is generated), a plugin marketplace, and the long tail of first-party integrations.
- Upstream — implement
pathPrefixroute namespacing in better-auth core (offered in #11094).
Development
bun install
bun run test # conformance + integration suites (real betterAuth instance, memory adapter)
bun run typecheck
bun test test/cart.test.ts # one file, while you work on it
STRIPE_TEST_SECRET_KEY=sk_test_... bun run test # additionally runs the remote Stripe suitebun run test is bun test --parallel --no-isolate, which is what Bun
recommends for a suite this size: --parallel gives one worker per core and
--no-isolate lets each worker evaluate the imports once instead of once per
file. On ~2,290 tests across 84 files that is 16 s instead of 58 s. The
suite is written to survive it — no test depends on a fresh module registry,
and the few module-level caches (resetRuntimeRegistry, the rate-limit
buckets) are reset by the tests that touch them. bun run test:isolated is
the same suite with a fresh global per file, for when you suspect that
assumption has been broken.
