Commerce
@verevoir/commerce provides abstract e-commerce primitives — products, baskets, orders, and payments — with pluggable pricing and tax engines.
Installation
npm install @verevoir/commerce
Commerce has zero runtime dependencies. It does not depend on @verevoir/schema or @verevoir/storage — all types are self-contained.
Overview
Commerce operates on five core concepts:
- Money — every price, payment, and balance carries an explicit currency (
{ amount, currency }) - Product — abstract interface; you define the shape, commerce needs
id,type, andbasePrice - Basket — mutable collection of line items with automatic price and tax resolution
- Order — immutable snapshot of a basket with balance tracking and payment application
- Payment — records applied against orders with a status lifecycle
Two engine interfaces provide extensibility:
- PricingEngine — ordered pipeline that transforms unit price per item (discounts, loyalty, dynamic pricing)
- TaxEngine — per-item tax calculation (VAT, sales tax, zero-rated categories)
Both are optional. Without a pricing engine, the product's base price is used. Without a tax engine, tax is zero.
Step 1 — Define Products
Commerce's Product interface requires three fields:
interface Product {
readonly id: string;
readonly type: string;
readonly basePrice: Money;
}
Your domain products will have many more fields — commerce ignores them. The pattern is to define your content model with @verevoir/schema and map stored documents to commerce products:
import { defineBlock, text, number, select, boolean } from '@verevoir/schema';
export const product = defineBlock({
name: 'product',
fields: {
name: text('Product Name').max(200),
description: text('Description').optional(),
type: select('Category', ['general', 'food', 'books', 'clothing']).default('general'),
price: number('Price').min(0).default(0),
currency: select('Currency', ['GBP', 'USD', 'EUR']).default('GBP'),
available: boolean('Available').default(true),
},
});
Then map to a commerce product:
import type { Product } from '@verevoir/commerce';
import { money } from '@verevoir/commerce';
import type { Document } from '@verevoir/storage';
function toCommerceProduct(doc: Document): Product {
const data = doc.data as Record<string, unknown>;
return {
id: doc.id,
type: data.type as string,
basePrice: money(data.price as number, data.currency as string),
};
}
Commerce never sees your storage layer or schema. It receives plain Product objects.
Step 2 — Create a Basket
A basket is a collection of line items. Create one, then add products:
import { createBasket, addItem, removeItem, updateItemQuantity, basketTotal } from '@verevoir/commerce';
let basket = createBasket('basket-1');
// Add 2 units of a product
basket = addItem(basket, product, 2);
// Add another product
basket = addItem(basket, anotherProduct, 1);
// Remove a product entirely
basket = removeItem(basket, product.id);
// Change quantity (requires the product for price recalculation)
basket = updateItemQuantity(basket, anotherProduct.id, 3, anotherProduct);
Adding the same product again increases its quantity rather than creating a duplicate line item.
Get basket totals:
const totals = basketTotal(basket);
// { subtotal: Money, tax: Money, total: Money } | null
if (totals) {
console.log(`Subtotal: ${totals.subtotal.amount} ${totals.subtotal.currency}`);
console.log(`Tax: ${totals.tax.amount} ${totals.tax.currency}`);
console.log(`Total: ${totals.total.amount} ${totals.total.currency}`);
}
Returns null if the basket is empty.
Step 3 — Configure Pricing Engines
Pricing engines form an ordered pipeline. Each engine sees the result of the previous one:
import type { PricingEngine, CommerceConfig } from '@verevoir/commerce';
import { fixedDiscountEngine, percentageDiscountEngine } from '@verevoir/commerce';
// Built-in: fixed discount per unit (floors at zero)
const fiver = fixedDiscountEngine(money(5, 'GBP'));
// Built-in: percentage discount (0-1 range; 0.1 = 10% off)
const tenPercent = percentageDiscountEngine(0.1);
// Pipeline: apply 10% off first, then take £5 off the result
const config: CommerceConfig = {
pricingEngines: [tenPercent, fiver],
};
basket = addItem(basket, product, 1, config);
Custom Pricing Engine
Implement the PricingEngine interface for domain-specific logic:
const loyaltyEngine: PricingEngine = {
resolve(currentPrice, product, quantity, context) {
const tier = context.loyaltyTier as string | undefined;
if (tier === 'gold') {
return multiplyMoney(currentPrice, 0.85); // 15% loyalty discount
}
return currentPrice;
},
};
const config: CommerceConfig = {
pricingEngines: [loyaltyEngine],
context: { loyaltyTier: 'gold' },
};
The context object passes domain-specific data (customer info, discount codes, campaign state) to engines without coupling the engine interface to your domain.
Step 4 — Configure Tax
Tax is calculated per item after pricing resolves. Rates can vary by product category — in the UK, food and books are zero-rated while most goods carry 20% VAT:
import { flatRateTaxEngine, productTypeTaxEngine } from '@verevoir/commerce';
// Simple: same rate for everything
const simpleTax = flatRateTaxEngine(0.2); // 20%
// Per-category: lookup by product.type with a default fallback
const ukVat = productTypeTaxEngine(
{
food: 0,
books: 0,
clothing: 0.2,
general: 0.2,
},
0.2, // default rate for unknown types
);
const config: CommerceConfig = {
taxEngine: ukVat,
};
Custom Tax Engine
const jurisdictionTax: TaxEngine = {
calculate(unitPrice, product, context) {
const region = context.region as string;
const rate = lookupTaxRate(region, product.type);
return multiplyMoney(unitPrice, rate);
},
};
Tax is stored per unit on the line item. The line total includes tax: (unitPrice + tax) * quantity.
Step 5 — Convert to Order
When the customer is ready to pay, convert the basket to an order:
import { convertToOrder, orderTotals, isFullyPaid } from '@verevoir/commerce';
const order = convertToOrder(basket, 'order-1');
This freezes the line items — price and tax are locked at the values calculated when items were added. The basket can be discarded or reused.
The order's balance starts at the total of all line items:
const totals = orderTotals(order);
// { subtotal, tax, total }
console.log(order.balance); // same as totals.total initially
console.log(isFullyPaid(order)); // false
An empty basket cannot be converted — convertToOrder throws.
Step 6 — Apply Payments
Payments are records applied against an order. A payment has a status lifecycle:
| Status | Balance Effect | Use Case |
|---|---|---|
pending | None | Payment initiated, awaiting gateway confirmation |
confirmed | Reduces balance | Gateway confirmed successful payment |
failed | None | Payment was declined |
refunded | Increases balance | Previously confirmed payment reversed |
import { applyPayment, updatePaymentStatus, isFullyPaid, changeOwed } from '@verevoir/commerce';
// Apply a pending payment (no balance change yet)
let paid = applyPayment(order, {
id: 'pay-1',
amount: money(50, 'GBP'),
status: 'pending',
callbackUrls: {
finalise: 'https://example.com/api/payments/pay-1/finalise',
cancel: 'https://example.com/api/payments/pay-1/cancel',
refund: 'https://example.com/api/payments/pay-1/refund',
},
});
// Gateway confirms — balance reduces
paid = updatePaymentStatus(paid, 'pay-1', 'confirmed');
console.log(isFullyPaid(paid)); // true if balance <= 0
Overpayment and Change
Balance can go negative — this means change is owed. This happens with cash purchases or pre-paid cards:
// Order total is £45, customer pays £50 cash
paid = applyPayment(order, {
id: 'cash-1',
amount: money(50, 'GBP'),
status: 'confirmed',
});
console.log(paid.balance); // { amount: -5, currency: 'GBP' }
console.log(isFullyPaid(paid)); // true
console.log(changeOwed(paid)); // { amount: 5, currency: 'GBP' }
Split Payments
Apply multiple payments against the same order:
let order = convertToOrder(basket, 'order-1');
// balance: £100
order = applyPayment(order, {
id: 'card-1',
amount: money(60, 'GBP'),
status: 'confirmed',
});
// balance: £40
order = applyPayment(order, {
id: 'voucher-1',
amount: money(40, 'GBP'),
status: 'confirmed',
});
// balance: £0
Callback URIs
callbackUrls on a payment carry the URIs your payment gateway should notify to finalise, cancel, or refund. Commerce does not call these — it stores them so your gateway integration layer can read them.
Step 7 — Recalculate
When pricing or tax config changes (e.g., a discount code is applied after items were added), recalculate the entire basket:
import { recalculateBasket } from '@verevoir/commerce';
// Products array must include every product currently in the basket
const products = basket.items.map((item) => productMap.get(item.productId)!);
const updated = recalculateBasket(basket, products, newConfig);
This re-runs the pricing pipeline and tax engine for every item. Use it when:
- A discount code is applied or removed
- Tax jurisdiction changes (e.g., shipping address updated)
- Engine context changes (loyalty tier upgrade)
Money Helpers
All arithmetic enforces currency consistency — adding GBP to USD throws:
import { money, addMoney, subtractMoney, multiplyMoney, zeroMoney } from '@verevoir/commerce';
const price = money(25, 'GBP');
const discount = money(5, 'GBP');
const net = subtractMoney(price, discount); // { amount: 20, currency: 'GBP' }
const doubled = multiplyMoney(price, 2); // { amount: 50, currency: 'GBP' }
const zero = zeroMoney('GBP'); // { amount: 0, currency: 'GBP' }
// This throws — currency mismatch
addMoney(money(10, 'GBP'), money(10, 'USD'));
Putting It Together
A complete example using @verevoir/schema for content-managed products, @verevoir/storage for persistence, and @verevoir/commerce for basket/order logic:
import { defineBlock, text, number, select, boolean } from '@verevoir/schema';
import { MemoryAdapter } from '@verevoir/storage';
import {
money,
createBasket,
addItem,
convertToOrder,
applyPayment,
basketTotal,
isFullyPaid,
productTypeTaxEngine,
percentageDiscountEngine,
} from '@verevoir/commerce';
import type { Product, CommerceConfig } from '@verevoir/commerce';
// 1. Define and store products
const productBlock = defineBlock({
name: 'product',
fields: {
name: text('Name').max(200),
type: select('Category', ['general', 'food', 'books']).default('general'),
price: number('Price').min(0),
currency: select('Currency', ['GBP']).default('GBP'),
available: boolean('Available').default(true),
},
});
const storage = new MemoryAdapter();
const bookDoc = await storage.create('product', {
name: 'TypeScript Handbook',
type: 'books',
price: 30,
currency: 'GBP',
available: true,
});
const shirtDoc = await storage.create('product', {
name: 'Conference T-Shirt',
type: 'general',
price: 25,
currency: 'GBP',
available: true,
});
// 2. Map to commerce products
function toProduct(doc: { id: string; data: Record<string, unknown> }): Product {
return {
id: doc.id,
type: doc.data.type as string,
basePrice: money(doc.data.price as number, doc.data.currency as string),
};
}
const book = toProduct(bookDoc);
const shirt = toProduct(shirtDoc);
// 3. Configure engines
const config: CommerceConfig = {
pricingEngines: [percentageDiscountEngine(0.1)], // 10% off everything
taxEngine: productTypeTaxEngine({ books: 0, general: 0.2 }, 0.2),
};
// 4. Build basket
let basket = createBasket('basket-1');
basket = addItem(basket, book, 2, config); // 2x book @ £27 (10% off £30), 0% VAT
basket = addItem(basket, shirt, 1, config); // 1x shirt @ £22.50 (10% off £25), 20% VAT
const totals = basketTotal(basket)!;
// subtotal: £76.50, tax: £4.50, total: £81
// 5. Convert and pay
const order = convertToOrder(basket, 'order-1');
const paid = applyPayment(order, {
id: 'card-1',
amount: money(81, 'GBP'),
status: 'confirmed',
});
console.log(isFullyPaid(paid)); // true
Further Reading
- Integration Guide — connecting content models, storage, and editor
- Defining Content Models — blocks, fields, validation
- Access Control — auth, policies, and workflows