npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@codefusion-cc/commerce

v0.3.0

Published

The core of a CodeFusion shop on Cloudflare Workers and D1: integrations as plugins (configuration checked, secrets sealed per row, shop events, webhooks, feeds, scheduled work, errors for the panel), the catalog's core (products, availability, GPSR, Omni

Readme

@codefusion-cc/commerce

The core of a CodeFusion shop on Cloudflare Workers and D1, taken out of dieselpaul so every shop runs the same tested code. One shop is one Worker with one D1 database (see the commerce plan in the repository's ROADMAP.md). Today the package holds the shop's integrations, the core of its catalog (categories, producers, products, attributes, variants, availability, the stock ledger, GPSR and the Omnibus price history), the storefront's reads of it (a product list with filters, counts and pages, and one product whole), its orders and coupons (the tables and an order's status changes) and its database migrations. Checkout, coupons' rules, returns and the panel follow.

npm install @codefusion-cc/commerce

Migrations

The package brings its own D1 migrations. They are copied into the shop's migrations, into a directory that sorts before the shop's own, so the package's tables exist before a shop migration uses them:

// package.json
"scripts": { "postinstall": "codefusion-commerce-migrations" }

// wrangler.jsonc, the D1 binding
"migrations_dir": "migrations",
"migrations_pattern": "migrations/**/*.sql"
migrations/0000_commerce/

codefusion-commerce-migrations copies them into migrations/0000_commerce after every install, since Wrangler does not follow symbolic links. --check fails when the copy differs from the packages. A shop's tests that build the schema without Wrangler take the files in Wrangler's order from migrationFiles() (@codefusion-cc/commerce/migrations).

A kind package (@codefusion-cc/commerce-invoices, commerce-shipping…) with tables of its own ships them the same way: numbered from 0001 in its migrations directory, listed in its files. The command copies the migrations of every installed @codefusion-cc/commerce-* package into migrations/0000_commerce/<package>/, and Wrangler applies them after the package's and before the shop's. A provider's table may refer to the package's (an order's id), never to another provider's or the shop's. The directory is part of each migration's name in Wrangler's records, so a provider package never changes its name. When a provider is uninstalled, its copy goes and its tables stay.

A package migration only adds: tables, indexes and triggers IF NOT EXISTS, so a shop that made one itself keeps it (with the package's columns: a shop's own columns stay, and its own migrations add them), and columns no shop made itself. A released CREATE TABLE never changes: a later column of the package is an ALTER TABLE in a new migration, which reaches every shop, and a shop never adds a column of that name itself.

A new migration alone is not a breaking release. A shop's deploy applies its migrations before its Worker (wrangler d1 migrations apply in the deploy job), so a migration that only adds what no shop has is a minor changeset, and Renovate may merge it. It is breaking (a major changeset, a new minor while the package is in preview) only when a shop must change something to take it: its code no longer fits the schema, or its own migrations make a table of the same name without IF NOT EXISTS. On a new database the package's migration runs first, and the shop's own CREATE TABLE would then fail. So before the package takes over a table a shop already has, that shop's migration says CREATE TABLE IF NOT EXISTS, in a change of its own that alters no database (a migration already applied never runs again); the package's release then asks nothing of the shop.

Integrations

An integration (a payment gateway, a courier, an invoicing program, a marketplace) is a definition the panel shows and, when it does anything on the server, a plugin with its Worker code. The owner turns it on in the panel and enters its keys. Its secrets are sealed in the shop's integrations table with @codefusion-cc/sealed, bound to the row.

import { createIntegrationStore, createIntegrations, type Plugin } from '@codefusion-cc/commerce'

// The table, its secrets and errors: no plugin in it, so the plugins' own modules can import it.
export const store = createIntegrationStore<Env>({
  db: env => env.DB,
  sealingKey: env => env.INTEGRATIONS_KEY, // codefusion-console cloudflare workers:edit -- sh -c 'npx -p @codefusion-cc/sealed codefusion-sealed-key | npx wrangler secret put INTEGRATIONS_KEY'
  onNewError: (env, { id, message, seq }) => notifyOwner(env, { id, message, seq }),
  log: ({ event, id, text, level }) => console[level](`plugin ${event} ${id} ${text}`),
})

type ShopEvents = { orderCreated: { order: Order; items: OrderItem[] } }
type ShopPlugin = Plugin<Env, ShopEvents, Ctx> & { payment?: PaymentGateway }

// The plugins over it.
export const integrations = createIntegrations<Env, ShopPlugin>({
  store,
  definitions: INTEGRATIONS, // every IntegrationDef the panel lists
  plugins: () => PLUGINS, // their Worker code, read on first use (plugin modules may import this one)
})

// After answering the customer:
ctx.waitUntil(integrations.emit(env, site, 'orderCreated', () => loadOrder(id))) // the ids that failed, or null: reached none
// From the shop's cron:
await integrations.runScheduled(env, site)
// /api/hooks/<id>/<path>: the handler verifies the provider's signature itself.
const found = await integrations.handler(env, site, id, 'hooks', path)

Saving from the panel: validateConfig checks the fields (field errors as codes the shop words, or the field's own message), sealSecrets seals what was entered (null: the Worker has no key), and saveStatement writes it. Run that statement alone or in one batch with the shop's change log. groupConflict names the integration of the same group that must be turned off first. Two saves at the same moment can both pass that check, so write with integrations.saveStatement(env, def, enabled, config, sealed), the store's statement exclusive with the rest of the group (exclusiveWith): turning one on writes nothing while another of the group is on (meta.changes 0: answer 409), in the same statement. enabledInGroup takes the first of the shop's definitions should more than one be on.

A shop that kept integration secrets before @codefusion-cc/sealed (in its earlier <iv>.<ciphertext> format) sets legacySecrets: true on the store: those open once and are sealed again, bound to their row.

The panel's page imports the definitions and checks from @codefusion-cc/commerce/browser, which has nothing of the database or the secrets.

Providers

This package holds the plugin mechanism and no provider: nothing of any payment gateway, carrier, invoicing program or marketplace, nor of any kind of them. Providers come in one package per kind, each with its kind's contract at its root and one entry per provider, so a shop imports only the providers it uses and its Worker bundles no other (the rule: ROADMAP.md, Commerce):

| Kind | Contract | Providers | | --- | --- | --- | | @codefusion-cc/commerce-payments | PaymentGateway, the verdicts of notifications | /payu, /przelewy24, /tpay | | @codefusion-cc/commerce-invoices | InvoiceProvider, the invoice draft, the shared settings | /fakturownia, /infakt, /wfirma | | @codefusion-cc/commerce-shipping | The shipping calendar, carriers and tracking | /shipx (InPost), /ups | | @codefusion-cc/commerce-marketplaces | MarketplaceContext | /allegro |

Each provider's panel definition is also in /<provider>/browser, for the panel's page without the provider's code.

// The shop's plugins (its Worker): only the providers it offers are imported, so only they are bundled.
import { payu } from '@codefusion-cc/commerce-payments/payu'
import { fakturownia } from '@codefusion-cc/commerce-invoices/fakturownia'

// The panel's page: their definitions alone.
import { payuDef } from '@codefusion-cc/commerce-payments/payu/browser'
import { fakturowniaDef } from '@codefusion-cc/commerce-invoices/fakturownia/browser'

A plugin's context (PluginContext) is also a ProviderContext (config, secrets, fetch), which is what the provider clients of the commerce packages take. A shop hands its plugin's context to them as it is.

  • fetch is the Worker's own, unless the store gets a fetch option. A shop's tests send every provider to its fake there, in one place. A provider that has not answered within 15 s (timeoutMs on the store; 0: no limit) is given up, the request rejecting with a TimeoutError as a dropped connection does; a request with a signal of its own keeps it. timedFetch(fetch, ms) gives the same limit to a context the shop builds without the store.
  • saveSecrets(change) is for a plugin whose provider rotates its keys (Allegro's tokens). It changes the plugin's own sealed secrets (store.updateSecrets), and writes only when the row still holds what was read, so a save from the panel meanwhile is never overwritten.
  • cachedToken(key, issue) keeps an OAuth client-credentials token in the Worker's memory until a minute before it runs out. Parallel requests share one request for it. forgetToken(key) drops a token the provider refused.
  • sharedWork(key, work) runs work once for every request asking for the same key at the same moment (a refresh token that works once, a signing key). A request waits at most 10 seconds for another request's work and then does it itself, because in Workers a request that ends can leave its work unsettled for good.
  • ProviderError carries the answer's status, retryAfter for a rate limit, and uncertain. uncertain means there was no answer or the provider failed (5xx), so the request may still have been carried out: ask the provider before trying again.

Amounts are kept in minor units. Providers write them as decimal text, and decimalAmount / parseAmount (./browser) convert between the two without a float, so no grosz is gained or lost. splitName gives a person's first and last names from one field, the same way for every provider.

@codefusion-cc/commerce/testing has recordingFetch(answer), the base of every package's fake provider. It records each request the way the provider receives it (headers in any form, any body as text) and answers from the fake.

Catalog

0002_catalog.sql makes the shop's categories, producers and products, and price_history with the triggers that write it on every price change. A shop adds the columns only it has in its own migrations (a workshop's services, deposits, part numbers). Amounts are in the currency's smallest unit.

Availability. A product is sold from the shelf (stock: counted units, shared by every sales channel) or made to order (made_to_order: never sold out, made after a lead time in days, the product's or its category's). Units on the shelf of a made-to-order product (a returned print, a few made ahead) go out first:

import { fulfilment, leadDays } from '@codefusion-cc/commerce/browser'

fulfilment(product, 3) // { shelf: 1, made: 2 }, or null: the order cannot be taken
leadDays(product, 3, category.lead_days) // 0 when the shelf covers it, else the days to make it

Omnibus. A reduction is shown next to the lowest price of the 30 days before it, counted in calendar days from midnight in the shop's time zone. Store the lowest price with every price save, then show it from the row:

import { createOmnibus } from '@codefusion-cc/commerce'
import { priceReduction, reductionBase } from '@codefusion-cc/commerce/browser'

// Once per Worker, in the shop's time zone:
export const omnibus = createOmnibus({ timeZone: 'Europe/Warsaw' })
// Before saving prices (one query for any number of products; lowestPriceOnSave for one):
const lowest = await omnibus.lowestPricesOnSave(env.DB, saves)
// On the product page:
const reduction = priceReduction(product.price, reductionBase(product)) // { lowest, percent } or null

GPSR. producerSafetyGaps lists what a producer's data lacks for art. 19 (codes the shop words), producerSafetyMissingSql is the same rule for the panel's lists, and SAFETY_MAX bounds a product's warnings.

Attributes. What a shop says about its products beyond name and price (an army, a print technology, a part number) is an attribute: a choice (with its choices), text, a number or a flag, and a filter in the storefront when facet is set. attributeValues checks what the panel or an import sends (codes the shop words), and saveAttributesStatements stores it in the product's batch; productAttributes reads many products in one query:

import { attributeValues, productAttributes, readAttributes, saveAttributesStatements } from '@codefusion-cc/commerce'

const { attributes, choices } = await readAttributes(env.DB)
const { attributeIds, values, errors } = attributeValues(attributes, choices, body.attributes) // { armia: ['krasnoludy'], modele: '10' }
await env.DB.batch([saveProduct, ...saveAttributesStatements(env.DB, id, attributeIds, values)])
const byProduct = await productAttributes(env.DB, ids) // id → { armia: ['krasnoludy'], modele: ['10'] }

Variants. Products that are one item in different versions (a squad of 10 or of 7 with its command) stay products of their own, with their prices, stock and orders, tied by a group: checkVariantGroup, newVariantGroupStatements and leaveVariantGroupStatements keep groups whole (a group of one is removed), and variantsOf lists a product's variants for its page.

Writing the catalog. One writer for a panel's save and an import: checkCatalog cleans and checks categories, producers, attributes with their choices, text blocks, products with their attribute values and text blocks, and variant groups (every error at once, as codes the shop words), and catalogStatements makes the batch. Rows are found by slug, products by id, and written again when they exist, so the same input twice leaves the same rows; a field left out keeps its value, and nothing is removed for being absent. An import has no database to bind to: it gives the same functions scriptDatabase() and writes the statements as a file with sqlScript (slugify makes the slugs of names):

import { catalogStatements, refuseShopInUseStatement, scriptDatabase, shopInUseStatement, sqlScript } from '@codefusion-cc/commerce'
import { checkCatalog, slugify } from '@codefusion-cc/commerce/browser'

const checked = checkCatalog({ categories, producers, attributes, products, variantGroups }) // { catalog } or { errors }
const own = { reason: 'import', ref: 'old-shop.example', actor: 'Catalog import' }
// In the panel: await env.DB.batch(catalogStatements(env.DB, { products: [product] }, { reason: 'correction', actor }))
const db = scriptDatabase()
writeFileSync('import.sql', sqlScript([refuseShopInUseStatement(db, own), ...catalogStatements(db, checked.catalog, own)]))
// wrangler d1 execute <database> --file import.sql: the whole file or nothing of it

An import writes prices and stock again, so it is for a shop not yet in use: shopInUseStatement counts the orders and the stock changes that are not the import's own, for the script to refuse with the reason in its words, and refuseShopInUseStatement, first in the file, fails the file itself on such a shop. Neither sees a price or a text changed by hand: nothing in the tables tells those from the import's.

A product also takes what its page shows: a slug (isProductSlug: lowercase letters, digits and hyphens, at most PRODUCT_SLUG_MAX characters; one product per slug, so a batch that gives a slug another product has fails whole, and '' is none), its specs (up to SPECS_MAX lines of [label, value], in the order shown) and its text_blocks (slugs, in the order shown, at most PRODUCT_TEXT_BLOCKS_MAX). A text block is a text several products show, written once: textBlocks: [{ slug, kind, title, body }] in the same input, or the shop's own already in the database. A list a product gives replaces the one stored ([] clears it); a list left out keeps it.

Stock ledger. Every change of a product's stock is a row of stock_moves (how much, what was left, why, about what, by whom), written by triggers wherever it comes from. A batch says why with stockChange; a change without it is still logged, with an empty reason. stockMoves pages through a product's ledger, newest first:

import { stockChange, stockMoves } from '@codefusion-cc/commerce'

await env.DB.batch([
  insertOrder,
  ...stockChange(env.DB, { reason: 'order', ref: { sql: 'SELECT number FROM orders WHERE token = ?', params: [token] } }, takeStock),
])
const moves = await stockMoves(env.DB, productId, { limit: 20 }) // next page: { before: moves.at(-1).id }

Reading the catalog

0007_storefront.sql adds what a storefront reads beyond a name and a price: a product's slug and specs, the text blocks (text_blocks, product_text_blocks) and an index for every order of the list. listProducts answers one question: filters, a search, an order and a page of active products, in a fixed number of queries whatever the page holds, and with every value of the question bound. A question comes from a visitor, so a refused one is an answer, not a crash:

import { listProducts } from '@codefusion-cc/commerce'
import { parseProductQuery } from '@codefusion-cc/commerce/browser'

// GET /api/products?category=figures&attr.army=orcs&price_max=9000&orderable=1&q=orc+squad&sort=price_asc
const parsed = parseProductQuery(new URL(request.url).searchParams)
if ('error' in parsed) return Response.json(parsed.error, { status: 400 }) // { field: 'limit', error: 'limit' }
const page = await listProducts(env.DB, parsed.query, { facets: true }) // { products, next, total, facets }
  • Filters. categories and producers (slugs), attributes (attribute slug → values: a choice's slug, a text, a number's stored text, '1' for a flag), priceMin and priceMax (both included), orderable, onShelf, flags (bestseller, new, promo, which counts only with a reduction the price history proves), conditions, ids and search. The values of one filter widen the list; the filters together narrow it. A search word is plain text found in products.search, which the shop fills with searchText; parseProductQuery turns a visitor's q into words with searchWords and cuts it to the limits instead of refusing it.
  • Orders and pages. newest, bestsellers, price_asc, price_desc and name (the first PRODUCT_NAME_ORDER_CHARS characters, ASCII case ignored). Each walks an index, and next is the cursor to pass as after, so without filters page 100 costs what page 1 does (with filters the matching products are sorted for each page, so a page costs what the matches do); a product added or sold meanwhile neither repeats nor skips the others. A page is 24 products by default, up to PRODUCT_PAGE_MAX from parseProductQuery and PRODUCT_READ_MAX from the Worker's own calls.
  • Counts. facets: true gives each filter's choices with the number of products each would give with every other filter applied but not itself, so choosing one army leaves the other armies' counts standing; likewise the price range, and what can be ordered or is on the shelf. It reads the whole matching list several times: cache the answer. total counts all matching products on the first page only (a caller that pages has it already): total: true asks on every page, total: false never; attributes: true adds each product's values.
  • Errors. { error: { field, error } }, error being invalid, too_many, too_long, slug, amount, sort, limit or cursor for the shop to word. A failing database rejects.
  • Address and cache key. productQueryParams(query) writes a question as parameters in one spelling (lists sorted, defaults left out), which parseProductQuery reads back: two questions that ask the same give the same text.
  • The whole list. for await (const page of productPages(env.DB, query)) reads it in pages of up to PRODUCT_READ_MAX for a feed or a sitemap, and throws on a question checkProductQuery refuses.
  • The short lists. readCategories(env.DB) gives the shop's categories in its order (its menu), and readProducers(env.DB) its producers by name with their GPSR data: a shop has a handful of each, so one query each.

readProduct reads one product whole, by id or by slug: its specification, text blocks in the page's order, attribute values, producer with its GPSR data, category, and its variants that are for sale (itself included; with inactive, the withdrawn ones too, marked):

const product = await readProduct(env.DB, { slug }) // or { id }; null when there is none or it is withdrawn
const preview = await readProduct(env.DB, { id }, { inactive: true }) // the panel's preview

Its price, stock and availability are the database's at that moment, so a page built from an earlier list read does not offer what sold out since. A product's lead_days in these answers is its own or else its category's, so leadDays(product, qty) needs no category.

Orders

Migration 0005_orders.sql holds the columns every shop's order has (orders, order_items) and its coupons (coupons, coupon_uses, whose triggers keep a coupon's limits in the order's own transaction). A shop adds its own columns in its own migrations: who ordered (a column referring to its accounts, which the package does not own), deposits, payment terms, and invoice or shipment numbers until the provider packages keep them. Columns of the package's own: channel and external_id (an order from a marketplace, one per its order there), ship_by (the day an order with made-to-order items must go out by) and order_items.from_shelf (the units taken from stock; the rest are made to order, and NULL means all of them). Migration 0008_order_country.sql adds orders.country, the country an order goes to (ISO 3166-1 alpha-2 in capitals, nothing else): createOrder writes it, and the orders a shop had before, or still inserts with a statement of its own that names none, are PL.

Making an order. createOrder is the one place a cart becomes an order, for the checkout, the staff's panel and a marketplace's orders. The page sends product ids and quantities with the prices it showed; the prices, the availability and the stock are read from D1 at that moment, and the delivery's price and the free-delivery threshold from the shop's settings. The order, its number (ZS/2026/00042: the prefix, the year where the shop is, the id) and access token, its items (qty and from_shelf), the stock taken from the shelf (in the ledger, reason order, with the order's number), the ship-by date when units are made to order, and whatever the shop adds are one batch: all of it or none. The batch applies only while each product still has the price and availability that were read and the shelf still has what the order takes; otherwise nothing is written and the order is counted again, so of two orders for the last unit one gets it and the other is made to order or refused, by the product's availability. An order costs the database two round trips: one batch that reads, one that writes. Everything a customer can cause is a result:

import { createOrder } from '@codefusion-cc/commerce'
import { nextDispatch } from '@codefusion-cc/commerce-shipping'

// { key, items: [{ id, qty, price }], customer: { email, phone, name, street, postcode, city, … }, delivery, payment, terms, shipping }
const result = await createOrder(env.DB, await request.json(), {
  payments: [{ id: 'payu', prepaid: true }, { id: 'transfer', prepaid: true }, { id: 'cod', prepaid: false }],
  numberPrefix: 'ZS',
  timeZone: 'Europe/Warsaw',
  country: 'PL', // the shop's own: of an order whose form asks for none
  countries: ['PL', 'DE', 'CZ'], // where it takes orders to; only its own unless said
  nextDispatch, // the shop's shipping calendar, for the ship-by date
})
switch (result.status) {
  case 'created': // 201: result.order (id, number, token, status, total, shipBy), result.lines, result.results (what
    // the database answered to each of the shop's own statements); send the mail, start the payment
  case 'existing': // 200: the order this key made before; result.differs when the form sent now is another one
  case 'invalid': // 422: result.errors, [{ field, code, index?, part? }], worded by the shop
  case 'unavailable': // 409: result.lines, [{ id, reason: 'gone' | 'stock', available }]
  case 'price_changed': // 409: result.lines, [{ id, seen, price }], and what the cart and its delivery come to now
  case 'refused': // 409: the shop's own reason (below)
  case 'busy': // 503 with Retry-After: the products changed under the order five times in a row; the page sends it again
}

The key is one crypto.randomUUID() per opened checkout form, sent with every try: a form sent twice, at once or after a lost answer, is one order. existing answers that order with its access token, so the key is a secret of one checkout: take it from the request's body only, never from a URL, and never log it. differs compares what the package writes (the customer, the methods, the cart, the prices seen); name the form's fields that only your shop has in formColumns ({ locker }) and they are compared too. A price the customer saw is held to the price the order is made at: the shop's own (unitPrice) when it has one, and the delivery price extend gives. checkOrder (also in ./browser) is the input's check alone, for the page to run before it sends, and orderAmounts what a cart comes to with its delivery, for the cart and the Worker alike. The order's country is the form's (customer.country, two letters in any case, kept in capitals) or the shop's own, must be one the shop takes orders to, is the one delivery is offered and priced for, and is kept with the order (orders.country). The status is awaiting_payment for a prepaid payment method and new otherwise. For an order the staff make, confirmPrices: false asks for no prices seen, termsVia: 'link' for no terms accepted yet, and actor names them in the ledger; channel and externalId mark a marketplace's order (one per id there).

What only one shop has goes through hooks, in the same batch: productColumns (its own columns of products, whole numbers or text, read and guarded with the rest), sellable, unitPrice (a trade account's prices), and extend, which sees the order about to be written (OrderDraft) and gives its part of it:

extend: async (draft) => ({
  errors: locker ? [] : [{ field: 'locker', code: 'required' }], // → invalid
  refuse: overLimit ? { credit } : undefined, // → refused, with this reason
  shipping: coupon?.freeShipping ? 0 : undefined, // instead of the settings' price
  discount: coupon?.discount, // taken off the total; at most the products' subtotal
  surcharge: deposit, // added to it
  columns: { user_id: user.id, deposit, coupon_code: coupon?.code ?? '' }, // columns the package does not write
  itemColumns: (line) => ({ core_deposit: line.product.core_deposit as number }),
  statements: [enqueueMail(env.DB, mail, { fill: draft.number })], // run only if the order is made; one failing undoes it
  refusal: (error) => (String(error).includes('credit_limit') ? { credit } : null), // a trigger's RAISE → refused
})

The order's confirmation. orderConfirmation(order, words) lays an order out as a mail's subject and blocks, in the shape renderMail of @codefusion-cc/mail takes: the opening paragraphs, the table of lines down to the total, the shop's own details (how to pay), a button to the order's page, what the customer typed, the seller, the shop's legal blocks, and the documents (the terms, the withdrawal form) in the mail itself or as a link. The package has no words: every label, the amounts' format and the documents come in words, one set per language of the shop. orderMailOf(draft, extension, number) makes the order from what extend sees, with the delivery, discount, surcharge and total createOrder writes (orderTotals), so the mail and the order cannot disagree. What a visitor typed is one line per field and never a link, and a line nobody can see (a zero-width space alone) is left out, so nothing typed into a note reads as a paragraph of the shop's in the text version.

import { OUTBOX_SLOT, enqueueMail, renderMail } from '@codefusion-cc/mail'
import { orderConfirmation, orderMailOf, type OrderMailWords } from '@codefusion-cc/commerce'

const words: OrderMailWords<MailBlock> = {
  subject: (order) => `Zamówienie ${order.number}`,
  intro: (order) => [`Dziękujemy za zamówienie ${order.number}.`],
  money: (minor) => formatPrice(minor), // a discount arrives negative
  discount: 'Rabat', delivery: (id) => DELIVERY[id], free: 'bezpłatnie', surcharge: 'Kaucja', total: 'Do zapłaty',
  details: (order) => [{ text: PAYMENT[order.payment] }],
  page: { label: 'Zobacz zamówienie', url: `${env.SITE_URL}/zamowienie/${draft.token}` },
  customer: { title: 'Dane zamawiającego', taxId: (id) => `NIP: ${id}`, country: (code) => COUNTRY[code] },
  notes: 'Uwagi',
  seller: { title: 'Sprzedawca', company: settings.company, taxId: (id) => `NIP: ${id}` },
  legal: () => [{ small: WITHDRAWAL_RIGHT }],
  documents: [{ title: 'Regulamin', blocks: [{ document: terms }] }, { title: 'Formularz odstąpienia', lines: FORM }],
}
// in extend:
const content = orderConfirmation(orderMailOf(draft, own, OUTBOX_SLOT), words)
const mail = { to: draft.customer.email, kind: 'order', ...renderMail(theme, content) }
return { ...own, statements: [enqueueMail(env.DB, mail, { fill: draft.number })] }

The package does not depend on @codefusion-cc/mail: the shop installs it and joins the two, as its README's "A shop sends an order confirmation" shows step by step (the migration, the scheduled run, what the owner provides). An order whose customer gave no address (required without email) gets no mail: check draft.customer.email before queueing.

Statuses. ORDER_STATUSES (the shop words them), and the sets the shop's rules use: SHIPPABLE_STATUSES (an order goes out from them when the staff make its shipment), UNPAID_STATUSES (cancelling refunds nothing) and FULFILLED_STATUSES (the withdrawal period runs from the first of them). Any status may follow any other; what a change does besides the status follows from the two (statusEffects).

Status changes. orderStatusStatements is one batch for the panel, the customer cancelling on the order's page and the shop's own jobs: cancelling gives the units taken from the shelf back to stock (in the stock ledger, cancel) and the coupon use back to the pool, restoring takes both again, the first shipped or completed status records fulfilled_at, and cancelling records who and when. Every statement applies only while the order still holds what was read (seen), so a double click, a payment arriving meanwhile or two staff at once change nothing twice. A batch the database refuses says why in statusChangeError:

import { orderStatusStatements, statusChangeError } from '@codefusion-cc/commerce'

const { statements, update } = orderStatusStatements(env.DB, {
  id,
  seen: { status: order.status, admin_notes: order.admin_notes }, // the order as the panel showed it
  status: 'cancelled',
  by: 'staff',
  actor: user.name,
  set: { admin_notes: notes }, // the shop's other columns, values or { sql, params }
})
const results = await env.DB.batch([...statements, logChange]).catch((error) => {
  const reason = statusChangeError(error) // 'out_of_stock' | 'coupon_uses_limit' | 'coupon_email_limit' | null
  throw reason ? new HttpError(409, MESSAGES[reason]) : error
})
if (!results[update].meta.changes) throw new HttpError(409, 'The order changed meanwhile.')

Settings

Migration 0006_settings.sql is the settings table: one row of text per key. The package reads and checks the keys a shop needs before it takes an order (SETTING_KEYS); a shop keeps settings of its own under other keys in the same table, and the package never loads or writes those.

| Keys | Kept as | | --- | --- | | shop_name, company, street, city, return_name, return_street, return_city | text, trimmed, at most SETTING_TEXT_MAX characters, no control characters, line separators or direction marks | | postcode, return_postcode, country, return_country | a postcode in capitals; a country as ISO 3166-1 alpha-2 | | tax_id | a Polish NIP's 10 digits (check digit verified) or another EU country's VAT number with its letters (normalizeTaxId) | | bank_account | an IBAN without spaces, check digits verified; a Polish number's 26 digits get their "PL" (normalizeIban; groupedAccount shows it in fours) | | email, phone | one bare address (below); a number of 9–15 digits | | shipping_methods | a JSON list of { id, price, enabled, free, countries }: the shop's own id (it words and draws the method), the price in minor units, whether the shop offers it (left out: no), whether the free-delivery threshold waives it (left out: yes), and the countries it goes to (none: all) | | free_shipping | the order value from which delivery is free, in minor units; 0: never | | shipping_cutoff, shipping_days | "13:00" and "1,2,3,4,5" (parseCutoff, parseShippingDays); no cutoff: the shop promises no shipping time |

An e-mail address, in the settings and in an order, is one bare address: what passes is what @codefusion-cc/mail sends to as typed. A tag ([email protected]), dots, an apostrophe, letters of any alphabet in the name and the domain, and an ending of two letters or more pass. Refused as format: markup; a name with the address in brackets (Jan <[email protected]>); a second address after a comma or a semicolon; a name in quotes ("jan kowalski"@example.com) or with a comment; a domain in numbers (jan@[127.0.0.1], [email protected]) or without a dot; what a typing mistake leaves ([email protected], [email protected]., a dot at either end of the name or two in a row); a hyphen at a label's end, an underscore, a slash or a % in the domain; a name over 64 characters, a label over 63, an address over 254. A page that checks the field itself uses the same rule, or it lets through what the Worker refuses.

import { publicSettings, readSettings, shippingCost } from '@codefusion-cc/commerce'

const { settings, problems } = await readSettings(env.DB, { country: 'PL' }) // the shop's defaults for keys with no row
if (problems.length) console.warn('settings', problems) // stored values no check would have saved
const cost = shippingCost(settings.shipping, order.delivery, subtotal, order.country) // null: not offered
return json(publicSettings(settings)) // the shop's public endpoint: no bank account, no method that is off
  • Reading never fails on what the table holds. A missing key is the shop's default, then '' or 0. A stored value that would not pass the check is not used: it reads as the default and is named in problems, and an unsound delivery method is left out while the others stay. A broken price is never a free delivery. readSettings rejects only when the database does; defaults that are not valid throw (a mistake in the shop's code).
  • Saving is checkSettings(body) (a result: the text to save, or each refused key with a code the shop words), then settingsStatements(db, values) in the shop's batch. Only the keys given are checked, so a panel saves one section. The shop adds what a panel needs around it (who may save, a stale-edit check, its change log).
  • A storefront reads the public endpoint's answer through publicShipping(answer.shipping) (@codefusion-cc/commerce/browser), which is null for anything that is not delivery as publicSettings gives it, and counts with the same shippingCost.
  • A shop that had the table before the package keeps it and every row (IF NOT EXISTS). Its own migration that made the table must say CREATE TABLE IF NOT EXISTS too (Migrations, above). Its rows under the package's keys are read as they are when they pass the check, so it moves onto the package key by key.

Staff panel (@codefusion-cc/commerce/react)

The staff panel's scaffold: one React kit and shell that every shop's admin pages are built from. React is an optional peer dependency (>=19); the entry takes in no Worker code. Import the stylesheet once:

import '@codefusion-cc/commerce/panel.css'
import { PanelProvider, PanelShell, createPanelClient } from '@codefusion-cc/commerce/react'
  • PanelProvider takes the client (createPanelClient({ messages, base }): timeout, 401 -> onUnauthorized, a reauth 403 confirmed once and retried, any other 403 -> onForbidden and the provider's denied state, a body that is no JSON -> ApiError(502), no answer -> ApiError(0)), the copy (PanelCopy: every string, none built in for a shop), the links of the app's router (PanelLinks; browserLinks for plain pages), the icons and the number and date format.
  • The kit: Button, Field, Select, RowList, Pagination, ListFilters, FloatingSave, EmptyState, ErrorState, OfflineNote (a line shown while the browser has no connection), useForm and useAction (a double click acts once; a failed request's code is offline or no_answer when no answer came), usePanelQuery (the latest path's answer, kept through a failed reload).
  • PanelShell is the sidebar, the phone drawer (a native modal dialog that holds the page still behind it), the skip link and the no-access page: shown when the app says denied and also, by itself, once the Worker answers a 403 that is no password question (the person lost their role mid-session). It lists no section, only the footer's way out; forms and actions (useForm, useAction) stop sending; noAccessActions puts the app's own buttons under the text (sign out, sign in as someone else). usePanelDenied() says it to a screen of its own. The client combines its time limit and the caller's cancellation by hand where the browser has no AbortSignal.timeout or AbortSignal.any.
  • panel.css is plain CSS (about 25 kB), built from the kit's classes, all prefixed cfp:; an app needs no Tailwind. The shop's look is the tokens --cf-panel-page, -surface, -raised, -ink, -muted, -line, -accent, -on-accent, -ok, -warn, -danger, -info (blue unless set), -radius and -font-display, set on :root (or any element above the provider), for light and dark. Without them the panel follows color-scheme in neutral colours. The tests check every text pair for 4.5:1 under the tokens of dieselpaul and bigusson.

The panel's orders

Worker side (the package's root export): ordersPart({ db, authorize, mail?, steps?, columns?, changed?, entities? }) returns four routes (PanelRoute[]), which mountPanel(router, '/api/panel', routes, adapt) adds to the shop's router. authorize(request, 'orders', 'read' | 'write') is the shop's check; it returns who acts ({ id, name }) or throws. Refusals are PanelErrors with a code (PanelErrorCode) and data; adapt answers them in the shop's words as { error, code, ...data }, which ApiError on the page carries (code, data).

  • GET /orders?status=&q=&sort=newest|oldest&cursor=&limit= is a page by cursor on the order's id, limit + 1 rows read from the index orders_status_id (status, id) (migration 0009_panel_changes), so the ten-thousandth page costs what the first does; no total is counted. A search (number, name, company, email, phone, tracking number, coupon; %, _ and Polish letters are plain text) reads the orders it matches, newest first, up to the limit.
  • GET /orders/:id is the order, its lines, its mails, its latest changes and the steps the status machine allows.
  • PATCH /orders/:id changes status, admin_notes, carrier, tracking_number. Each field it sets comes with the value the page showed (expected_status, expected_notes, expected_carrier, expected_tracking): fields the other person changed to something else answer 409 order_changed with conflicts and the order as it is now; a step the machine (ORDER_STEPS, or the shop's steps; ANY_ORDER_STEP for no rules) has no edge for is step_not_allowed. The change and its row in the log are one batch, and the log row is written only when the change took effect. The log is the table panel_changes (who, when, the fields before and after); who acts comes from authorize, never from the request. The table carries the panel's name so that it never meets a changes table a shop made itself: that shop keeps its table and its migrations as they are, and moves its rows over in a migration of its own when its panel starts to log through the package.
  • POST /orders/:id/mail/:mail/resend sends a mail again through the shop's outbox (mail: { list, resend }), only one of this order that has not gone out.

React side (./react): OrdersPage (status chips, search, sort, show more, and "mark as paid" on a row awaiting payment with the amount and the transfer title shown first) and OrderPage (who ordered and where it goes, status steps, shipment, notes, lines, mails, log). Both take copy: OrdersCopy (the package ships no words) and the shop's route (orderTo, backTo). OrderPage takes sections for the shop's own panels (a deposit, shipments, invoices) and mail={false} to show the mails elsewhere (OrderMail). When two people change one order the second sees theirs and keeps their own text, and chooses "Keep mine" or "Use theirs".

The order page opens with the customer: name, phone and e-mail as links, what the customer wrote, then delivery, address, payment, the invoice data and the shop's own columns. Codes are worded through copy.detail.values (by field: delivery, payment, channel, country, or a column of the shop; a code worded as '' is left out, such as the shop's own country). deliveryColumns names the shop's columns that say where the order goes (a parcel locker), so they stand with the delivery.

Of the steps the shop allows from a status, one is the main button: the next one in the usual order (new → in progress, awaiting payment → paid, in progress → shipped or ready for pickup, then completed). pickup={order => …} says whether the customer collects the order, so the page offers ready_for_pickup and not shipped, or the other way round; without it both are offered. The other steps forward are small buttons. A step back (copy.back), restoring a cancelled order (copy.restore) and cancelling (copy.cancel, with shipped for an order already sent) ask first.

The panel's settings

Worker side: settingsPart({ db, authorize, placeholder?, required?, postcodes?, defaults?, changed?, entities? }) returns three routes in the area settings (authorize(request, 'settings', 'read' | 'write')):

  • GET /settings: the values of the 19 keys of the Settings section above, the revision of each group, the rules and the five latest changes.
  • PUT /settings/:group: a save of one group of PANEL_SETTINGS_GROUPS (company: the company and how to reach it; returns: the return address; transfer: the bank account; delivery: the methods, the free-delivery threshold, the cutoff and the shipping days), as { rev, values } with only the keys the person changed.
  • GET /settings/changes: the log, 20 rows a page, ?before= the id of the last row shown.

A group that is none is not_found. A key that is not a setting of the group, a body that is no object or is over 100 000 characters is invalid (data.field); values that fail the checks are invalid with data.errors, every wrong one at once (PanelSettingProblem: the checkSettings codes, placeholder for a value that still holds the shop's migration text, required for a key the shop wants filled). postcodes holds a postcode to a form by its country ({ PL: '\\d{2}-\\d{3}' }, the whole postcode must match); a country without a rule takes any, and a return address with no country of its own is in the company's. A save sets a delivery method's price and its two switches: one that adds, drops, renames or reorders a method, or moves its countries, is invalid (data.field: 'shipping_methods', data.reason: 'methods'). The methods themselves come from the shop's code (defaults) or its migrations.

Each group has its own revision, a token in the row settings_rev.<group> (not one of the keys), so a save of one group never refuses a save of another. A save is one batch: the token is replaced by a fresh one only if it is still the one the page read, every key is written only while the fresh token is in place, and the log row (panel_changes, entity settings: who, which keys, before and after) goes in with them. Of two saves of a group from one revision exactly one writes; the other gets 409 settings_changed with the settings as they are now. The same values again change nothing (changed: false, same revision). changed({ actor, group, before, after, values }) runs in the background after a save, for a cached page or a mail to the owner (values: every setting as it is now, so the hook reads nothing). The bank account is logged like the other settings, in full: the package has no second confirmation for it, the shop's authorize is the gate and changed is where the shop tells its owner.

React side: SettingsPage({ copy, base, page?, slugs? }), a page for each group and one for the log, each at its own address (<base>/<slug>; the shop's router gives the slug as page, and slugs names them in the shop's language):

<Route path="/panel/settings/:page?" element={<Settings />} />
const Settings = () => <SettingsPage copy={settingsCopy} base="/panel/settings" page={useParams().page} />

The start page leads with what the shop still has to fill in (a required setting that is empty or holds the placeholder), each entry a link to its field, then the list of pages with what is set on each. On a wide screen the list stays beside the open page; on a phone it is the start page and a page has a way back. A page checks its save as the Worker will (checkPanelSettings, the same function), puts each problem beside its field, sends only what changed with its group's revision, and on settings_changed takes the other person's changes in, keeps its own and names the settings both changed with an offer to take theirs. Leaving a page with changes asks first (holdLeave). The log and a conflict name a change of the delivery methods method by method, and an account number reads in fours. copy is SettingsCopy, the shop's words, including problem(key, code, index); the shop words the delivery methods' ids (copy.delivery.methods).