@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
Maintainers
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/commerceMigrations
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.
fetchis the Worker's own, unless the store gets afetchoption. A shop's tests send every provider to its fake there, in one place. A provider that has not answered within 15 s (timeoutMson the store; 0: no limit) is given up, the request rejecting with aTimeoutErroras 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.ProviderErrorcarries the answer'sstatus,retryAfterfor a rate limit, anduncertain.uncertainmeans 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 itOmnibus. 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 nullGPSR. 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 itAn 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.
categoriesandproducers(slugs),attributes(attribute slug → values: a choice's slug, a text, a number's stored text,'1'for a flag),priceMinandpriceMax(both included),orderable,onShelf,flags(bestseller,new,promo, which counts only with a reduction the price history proves),conditions,idsandsearch. The values of one filter widen the list; the filters together narrow it. A search word is plain text found inproducts.search, which the shop fills withsearchText;parseProductQueryturns a visitor'sqinto words withsearchWordsand cuts it to the limits instead of refusing it. - Orders and pages.
newest,bestsellers,price_asc,price_descandname(the firstPRODUCT_NAME_ORDER_CHARScharacters, ASCII case ignored). Each walks an index, andnextis the cursor to pass asafter, 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 toPRODUCT_PAGE_MAXfromparseProductQueryandPRODUCT_READ_MAXfrom the Worker's own calls. - Counts.
facets: truegives 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.totalcounts all matching products on the first page only (a caller that pages has it already):total: trueasks on every page,total: falsenever;attributes: trueadds each product's values. - Errors.
{ error: { field, error } },errorbeinginvalid,too_many,too_long,slug,amount,sort,limitorcursorfor 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), whichparseProductQueryreads 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 toPRODUCT_READ_MAXfor a feed or a sitemap, and throws on a questioncheckProductQueryrefuses. - The short lists.
readCategories(env.DB)gives the shop's categories in its order (its menu), andreadProducers(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 previewIts 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.readSettingsrejects 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), thensettingsStatements(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 aspublicSettingsgives it, and counts with the sameshippingCost. - 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 sayCREATE TABLE IF NOT EXISTStoo (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'PanelProvidertakes the client (createPanelClient({ messages, base }): timeout,401->onUnauthorized, areauth403confirmed once and retried, any other403->onForbiddenand 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;browserLinksfor 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),useFormanduseAction(a double click acts once; a failed request'scodeisofflineorno_answerwhen no answer came),usePanelQuery(the latest path's answer, kept through a failed reload). PanelShellis 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 saysdeniedand also, by itself, once the Worker answers a403that 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;noAccessActionsputs 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 noAbortSignal.timeoutorAbortSignal.any.panel.cssis plain CSS (about 25 kB), built from the kit's classes, all prefixedcfp:; 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),-radiusand-font-display, set on:root(or any element above the provider), for light and dark. Without them the panel followscolor-schemein 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 + 1rows read from the indexorders_status_id (status, id)(migration0009_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/:idis the order, its lines, its mails, its latest changes and the steps the status machine allows.PATCH /orders/:idchangesstatus,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 answer409 order_changedwithconflictsand the order as it is now; a step the machine (ORDER_STEPS, or the shop'ssteps;ANY_ORDER_STEPfor no rules) has no edge for isstep_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 tablepanel_changes(who, when, the fields before and after); who acts comes fromauthorize, never from the request. The table carries the panel's name so that it never meets achangestable 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/resendsends 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 ofPANEL_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).
