██╗   ██╗███████╗██████╗ ███████╗██╗   ██╗ ██████╗ ██╗██████╗
██║   ██║██╔════╝██╔══██╗██╔════╝██║   ██║██╔═══██╗██║██╔══██╗
██║   ██║█████╗  ██████╔╝█████╗  ██║   ██║██║   ██║██║██████╔╝
╚██╗ ██╔╝██╔══╝  ██╔══██╗██╔══╝  ╚██╗ ██╔╝██║   ██║██║██╔══██╗
 ╚████╔╝ ███████╗██║  ██║███████╗ ╚████╔╝ ╚██████╔╝██║██║  ██║
  ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝  ╚═══╝   ╚═════╝ ╚═╝╚═╝  ╚═╝

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:

Two engine interfaces provide extensibility:

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:

StatusBalance EffectUse Case
pendingNonePayment initiated, awaiting gateway confirmation
confirmedReduces balanceGateway confirmed successful payment
failedNonePayment was declined
refundedIncreases balancePreviously 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:

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