Part 2 — Core Engineering

10 Stack Specifics: Next.js, Supabase, Turborepo

Nine chapters taught you ideas: ledgers, isolation, idempotency, reconciliation, tenancy, sync, imports, caching, testing. This chapter puts them onto real tools, and draws one line harder than any other: your business rules live in a framework-free package that a test can run without a database. Everything else is plumbing you can replace.

In this chapter10 sections · about 50 min
  1. What you need to know first
  2. The shape of the repository
  3. The framework-free core
  4. Wiring the core into Next.js: server actions vs route handlers
  5. tRPC, server actions, or REST?
  6. Supabase Postgres: what you get, and where it bites
  7. Zod: one definition of "valid," shared by both sides
  8. Timezone discipline: instants versus calendar dates
  9. Decimal arithmetic: never, ever floats
  10. The build order: what to learn, in what sequence

What you need to know first

JavaScript is a programming language, invented to make web pages interactive: click a button, something moves. Every browser on earth runs it, and that universality is why it escaped the browser and now runs servers and build tools too.

Node.js is a program that runs JavaScript outside a browser. JavaScript is the language. Node is a speaker of that language who also holds keys to the building: it can open files, listen on network ports, and connect to Postgres, none of which a browser tab may do.

TypeScript is JavaScript plus labels. In plain JavaScript you can write order.quanitty (misspelled) and nothing complains until a customer's order breaks at 2am. TypeScript lets you declare that an order has a field quantity which is a number, and a checker finds the typo in a second. A type is a written-down promise about the shape of a value. TypeScript is JavaScript with those promises added, stripped away before anything runs. For an ERP, where a wrong number is money, types are the cheapest safety net there is.

React, Next.js, and the server/client line

React is a library for building user interfaces from small reusable pieces called components. A component is a function returning a description of what should appear on screen ("a table with these rows"), and React works out the minimum change needed to make the screen match. You never hand-write "find that cell and change its text."

Next.js is a framework: a pre-built skeleton of an application. Foundation, load-bearing walls, wiring, plumbing: you furnish the rooms. It decides how web addresses map to pages, how code reaches browsers, and how data is fetched before a page renders. A library (React, Zod) is a tool you call. A framework is a house you move into, and it calls you.

Server-side code runs on your machine in the data center and can hold database passwords. Client-side code runs in the customer's browser, where they can read every line and change any value before sending it. Hence the most important habit in web work: never trust anything the client sends, and never put a secret in client-side code. A merchandiser can open developer tools and set a hidden price field to zero, so your server must re-check every number it is handed.

Packages, monorepos, and schemas

npm is the package registry for JavaScript, a giant public library of reusable code. A package is a folder of code published under a name, like zod or decimal.js. You list what you want in package.json and an install command fetches it. A package manager is the tool that does the fetching: npm itself, or a faster alternative such as pnpm. Your own code can be shaped like a package too, which is the trick this chapter leans on.

A monorepo is one folder (one Git repository) holding several related projects side by side so they can share code without publishing anything: one repository with three directories instead of three repositories. Turborepo knows which project depends on which, runs tasks in the right order, and caches results so unchanged projects are never rebuilt. Its convention is apps/ for things you deploy and packages/ for the libraries they share.

A schema is the written-down shape of data. In Postgres it is your tables, columns and types: order_lines has a whole-number quantity and a unit_price that is a decimal with four places. In Zod it is the shape you expect an incoming request to have. Same idea, two places: this is valid. Anything else is rejected.

Tenants, idempotency, and deploying

Two words from earlier chapters run through this one. A tenant is one customer company on your system, and its data must never mix with another company's (chapter 5). An operation is idempotent when running it twice has the same effect as running it once, so a retry cannot double-count anything (chapter 3). An idempotency key is the unique string you attach to a request so the server can recognize the repeat.

Deploying means making your code run on a computer the public can reach: you push to GitHub, a service builds it and starts the application at a real web address. "It works on my machine" and "it is deployed" are different achievements, and most of this chapter's pain (connection pooling especially) appears only on the second.

The shape of the repository

Start with the folder layout. It encodes the whole argument.

apparel-erp/
├── package.json           # scripts and shared dev tools
├── pnpm-workspace.yaml    # which folders are projects
├── turbo.json             # how to build/test/lint, and what to cache
│
├── apps/
│   ├── admin/             # Next.js app: the internal ERP UI
│   ├── worker/            # plain Node process: outbox drain, Shopify sync
│   └── api/               # (later) versioned public REST API for customers
│
└── packages/
    ├── core/              # ← THE DOMAIN. No framework. No database driver.
    │   └── src/
    │       ├── inventory/ats.ts          # pure functions
    │       ├── inventory/ports.ts        # repository interfaces
    │       ├── orders/reserve-order.ts   # use cases
    │       └── money.ts                  # decimal helpers
    ├── db/                # Postgres implementations of core's interfaces
    ├── contracts/         # Zod schemas shared by client and server
    └── tsconfig/          # shared TypeScript settings

turbo.json holds each task's recipe: to build apps/admin, first build packages/core, and if core has not changed, reuse the cache. apps/ holds what you deploy: admin is the Next.js site staff log into, worker a plain Node program draining the outbox.

(A transaction is a group of database changes that all take effect together or not at all. The outbox is chapter 4's pattern: instead of calling Shopify in the middle of a transaction, you write the message you want to send into a table inside that same transaction, and a background worker sends it afterwards.)

packages/ holds shared code that is never published:

  • core (the domain)
  • db (Postgres, no business rules)
  • contracts (Zod schemas both sides import)

Reading the root package.json

// package.json  (repository root)
// Versions are current as of July 2026; check for newer ones.
{
  "name": "apparel-erp",
  "private": true,
  "packageManager": "pnpm@11.17.0",
  "devDependencies": {
    "turbo": "^2.10.7",
    "typescript": "^7.0.2"
  },
  "scripts": {
    "build": "turbo run build",
    "test":  "turbo run test",
    "dev":   "turbo run dev"
  }
}

Read that file top to bottom:

  • "name" is what the repository calls itself.
  • "private": true means "never publish this to npm."
  • "packageManager" pins the exact package manager version.
  • "devDependencies" lists tools needed only while you work, and they never ship to a customer's browser.
  • The ^ in "^2.10.7" is a version range: install 2.10.7 or any later 2.x release, but never 3.x, because a change in the first number is allowed to break things.
  • "scripts" gives short names to long commands, so pnpm run build runs turbo run build, which builds every project in the right order.

Those three pinned versions were the current releases in July 2026. Look up the current ones before you copy these into a repository of your own.

# pnpm-workspace.yaml  (repository root)
packages:
  - "apps/*"
  - "packages/*"

pnpm-workspace.yaml is what turns a folder of folders into a monorepo. Every directory beneath those paths that has its own package.json becomes a project, so when apps/admin asks for @erp/core it is wired to the local folder rather than downloaded.

Which file holds that list matters: pnpm reads only pnpm-workspace.yaml. npm, Yarn and Bun instead take the list from a "workspaces" array inside package.json, and pnpm will not read that array. Put the list in the wrong place and pnpm prints "The workspaces field in package.json is not supported by pnpm. Create a pnpm-workspace.yaml file instead", then fails to resolve every local import. (Verified against pnpm 11, July 2026.)

The framework-free core

Core principle

Domain logic (what available-to-sell means, when an order may be reserved, how an invoice total is computed) lives in packages/core as plain TypeScript. It imports no framework, no database driver, no HTTP library, and it does not know Next.js exists. Everything else in your stack is a delivery mechanism wrapped around it.

Two reasons. Testability: a rule that touches no database runs in a fraction of a millisecond, so a whole suite of such tests finishes while you are still typing. Portability: the same reservation rule must run in the admin UI, in the worker, and in a bulk importer. Weld it into a server action and the worker grows a second, slightly different copy, which is how a company ends up with two answers to "how much can we sell?"

Pure functions

A pure function depends only on its inputs and changes nothing outside itself: no database writes, no clock, no randomness, no logging. Same arguments, same answer, every time.

// packages/core/src/inventory/ats.ts
import Decimal from "decimal.js";

export type Sku = string;

/** A point-in-time inventory position for one SKU in one warehouse. */
export interface AtsPosition {
  sku: Sku;
  onHand: Decimal;    // physically counted in the building
  allocated: Decimal; // already promised to open orders
  inbound: Decimal;   // on a confirmed purchase order, not yet received
}

export interface OrderLine  { lineId: string; sku: Sku; quantity: Decimal }
export interface LineAllocation {
  lineId: string; sku: Sku; allocated: Decimal; shortfall: Decimal;
}

/** Sellable right now. Inbound stock is deliberately excluded. */
export function availableToSell(p: AtsPosition): Decimal {
  const ats = p.onHand.minus(p.allocated);
  return ats.isNegative() ? new Decimal(0) : ats;
}

/**
 * Decide how much of each line we can promise, given today's positions.
 * Pure: no I/O, no clock, no randomness. Lines are processed in the order
 * given, so the caller controls fairness (e.g. sort by order date first).
 */
export function planAllocation(
  lines: readonly OrderLine[],
  positions: readonly AtsPosition[],
  opts: { allowPartial: boolean },
): LineAllocation[] {
  const remaining = new Map<Sku, Decimal>(
    positions.map((p) => [p.sku, availableToSell(p)]),
  );

  return lines.map((line) => {
    const pool = remaining.get(line.sku) ?? new Decimal(0);
    const takeable = Decimal.min(pool, line.quantity);
    const take = opts.allowPartial
      ? takeable
      : takeable.equals(line.quantity) ? line.quantity : new Decimal(0);

    remaining.set(line.sku, pool.minus(take));
    return {
      lineId: line.lineId,
      sku: line.sku,
      allocated: take,
      shortfall: line.quantity.minus(take),
    };
  });
}

A SKU (stock keeping unit) is the code for one exact sellable thing: this style, in this color, in this size. TEE-BLK-M is the black tee in medium. Decimal is exact arithmetic, a number that does not drift. An interface like AtsPosition is a contract about shape. It exists only at type-check time and produces no JavaScript.

availableToSell is chapter 8's definition of available-to-sell (ATS, the quantity you may still promise a buyer) in three lines, floored at zero so a glitch cannot yield a negative figure a later step reads as extra stock. inbound is excluded, because mixing future receipts into availability is how firms oversell.

In planAllocation the Map tracks what is still unclaimed within this run, since two lines may want the same SKU and the second must see what the first consumed. When allowPartial is false we give everything or nothing, because wholesale buyers often refuse partial shipments. That is a business policy, so it becomes an explicit input. Nothing here touches the outside world: no await, no SQL, no tenant lookup. The only thing this code can get wrong is the business rule, which is exactly the thing a test can check.

Repository interfaces: the ports out of the core

Real work needs data. The core must ask for positions and record movements, but it must not know how. The repository pattern solves this: the core declares an interface describing the operations it needs, and another package supplies a Postgres-backed implementation. The core depends on the promise and stays ignorant of the plumbing.

// packages/core/src/inventory/ports.ts
import type { AtsPosition, Sku } from "./ats";
import type Decimal from "decimal.js";

export type TenantId = string;

/** One append-only row in the inventory ledger (chapter 1). */
export interface StockMovement {
  sku: Sku;
  warehouseId: string;
  delta: Decimal;           // +receipt, -shipment, +/-adjustment
  reason: "receipt" | "shipment" | "reservation" | "release" | "adjustment";
  referenceType: "order" | "purchase_order" | "count";
  referenceId: string;
}

export interface InventoryRepository {
  loadPositions(
    tenantId: TenantId,
    skus: readonly Sku[],
  ): Promise<AtsPosition[]>;

  /**
   * Append movements atomically. Must be idempotent on idempotencyKey:
   * calling twice with the same key applies the movements once (chapter 3).
   */
  appendMovements(
    tenantId: TenantId,
    movements: readonly StockMovement[],
    idempotencyKey: string,
  ): Promise<{ applied: boolean }>;
}

export interface OutboxRepository {
  enqueue(
    tenantId: TenantId,
    event: { type: string; payload: unknown; idempotencyKey: string },
  ): Promise<void>;
}

/** The clock is handed in, so a test can freeze it. */
export interface Clock { now(): Date }

import type vanishes at runtime, so no real dependency sneaks in. StockMovement is chapter 1's ledger row as a type (a signed delta, a reason, a pointer to whatever caused it) with no quantityAfter field, because in a ledger the current level is derived by summing the movements. (Promise is JavaScript's word for a value that is not ready yet, and await waits for one.)

The comment on appendMovements makes idempotency part of the contract. Clock exists because calling new Date() inside domain logic makes "what happens on the 1st" untestable. You cannot rewind the real clock, but you can pass in a fake one.

Dependency injection and a use case

Dependency injection is a grand name for a small habit: the tools a function needs are handed to it as arguments rather than grabbed. A plumber who brings their own wrench can work in any house.

// packages/core/src/orders/reserve-order.ts
import { planAllocation, type OrderLine } from "../inventory/ats";
import type {
  Clock, InventoryRepository, OutboxRepository, StockMovement, TenantId,
} from "../inventory/ports";

export interface ReserveOrderDeps {
  inventory: InventoryRepository;
  outbox: OutboxRepository;
  clock: Clock;
}

export interface ReserveOrderCommand {
  tenantId: TenantId;
  orderId: string;
  warehouseId: string;
  lines: readonly OrderLine[];
  allowPartial: boolean;
  idempotencyKey: string;
}

export type ReserveOrderResult =
  | { status: "reserved"; shortfalls: { sku: string; qty: string }[] }
  | { status: "rejected"; reason: "insufficient_stock" };

export async function reserveOrder(
  deps: ReserveOrderDeps,
  cmd: ReserveOrderCommand,
): Promise<ReserveOrderResult> {
  const skus = [...new Set(cmd.lines.map((l) => l.sku))];
  const positions = await deps.inventory.loadPositions(cmd.tenantId, skus);
  const plan = planAllocation(cmd.lines, positions, {
    allowPartial: cmd.allowPartial,
  });

  if (plan.every((p) => p.allocated.isZero())) {
    return { status: "rejected", reason: "insufficient_stock" };
  }

  const movements: StockMovement[] = plan
    .filter((p) => p.allocated.greaterThan(0))
    .map((p) => ({
      sku: p.sku,
      warehouseId: cmd.warehouseId,
      delta: p.allocated.negated(),   // reservation removes sellable stock
      reason: "reservation" as const,
      referenceType: "order" as const,
      referenceId: cmd.orderId,
    }));

  await deps.inventory.appendMovements(
    cmd.tenantId, movements, cmd.idempotencyKey,
  );

  await deps.outbox.enqueue(cmd.tenantId, {
    type: "order.reserved",
    payload: { orderId: cmd.orderId, at: deps.clock.now().toISOString() },
    idempotencyKey: `${cmd.idempotencyKey}:outbox`,
  });

  return {
    status: "reserved",
    shortfalls: plan
      .filter((p) => p.shortfall.greaterThan(0))
      .map((p) => ({ sku: p.sku, qty: p.shortfall.toString() })),
  };
}

The first argument is deps, the wrench bag. The second is cmd, everything about this request. Separating them makes the function reusable: website and worker pass different deps and identical cmd shapes. ReserveOrderResult is a union, which in TypeScript means "one of these shapes": a success shape or a rejection shape. TypeScript then forces callers to handle both, and "we forgot the failure path" becomes a compile error.

negated() writes a reservation as a negative delta, because it removes sellable stock exactly as a shipment does. Only the reason differs, so a release can reverse it. The outbox enqueue derives its key from the command's, so a retry cannot double-publish, and reserveOrder never learns whether it runs in a web request or a nightly batch.

A unit test with no database at all

A unit test runs one piece of code with known inputs and checks the output. The last three blocks bought you this test: no Postgres, no Docker, no network, no fixtures. (Docker is a tool for running services such as a database in a sealed box on your laptop. Fixtures are sample rows you load before a test so it has something to read.)

// packages/core/src/orders/reserve-order.test.ts
import { describe, it, expect } from "vitest";
import Decimal from "decimal.js";
import { reserveOrder } from "./reserve-order";
import type { InventoryRepository, OutboxRepository, StockMovement }
  from "../inventory/ports";

/** A fake repository: an in-memory stand-in that honours the interface. */
function fakeInventory(stock: Record<string, [number, number]>) {
  const appended: StockMovement[] = [];
  const seenKeys = new Set<string>();

  const repo: InventoryRepository = {
    async loadPositions(_tenantId, skus) {
      return skus.map((sku) => {
        const [onHand, allocated] = stock[sku] ?? [0, 0];
        return {
          sku,
          onHand: new Decimal(onHand),
          allocated: new Decimal(allocated),
          inbound: new Decimal(0),
        };
      });
    },
    async appendMovements(_tenantId, movements, idempotencyKey) {
      if (seenKeys.has(idempotencyKey)) return { applied: false };
      seenKeys.add(idempotencyKey);
      appended.push(...movements);
      return { applied: true };
    },
  };
  return { repo, appended };
}

const fakeOutbox = (): OutboxRepository => ({ async enqueue() {} });
const frozenClock = { now: () => new Date("2026-03-02T09:00:00Z") };

describe("reserveOrder", () => {
  it("allocates in line order and reports the shortfall", async () => {
    // 40 sellable of the M, 0 of the L.
    const { repo, appended } = fakeInventory({
      "TEE-BLK-M": [50, 10],
      "TEE-BLK-L": [5, 5],
    });

    const result = await reserveOrder(
      { inventory: repo, outbox: fakeOutbox(), clock: frozenClock },
      {
        tenantId: "t_northwind",
        orderId: "ord_1001",
        warehouseId: "wh_main",
        lines: [
          { lineId: "l1", sku: "TEE-BLK-M", quantity: new Decimal(30) },
          { lineId: "l2", sku: "TEE-BLK-M", quantity: new Decimal(25) },
          { lineId: "l3", sku: "TEE-BLK-L", quantity: new Decimal(12) },
        ],
        allowPartial: true,
        idempotencyKey: "ord_1001:reserve:v1",
      },
    );

    expect(result.status).toBe("reserved");
    expect(appended).toHaveLength(2);                  // l3 got nothing
    expect(appended[0].delta.toString()).toBe("-30");
    expect(appended[1].delta.toString()).toBe("-10");  // 40 - 30 left
    expect(result).toMatchObject({
      shortfalls: [
        { sku: "TEE-BLK-M", qty: "15" },
        { sku: "TEE-BLK-L", qty: "12" },
      ],
    });
  });
});

vitest is the test runner: describe groups, it declares a test, expect states what must be true. fakeInventory is the trick: an object satisfying InventoryRepository with a plain JavaScript object as its database. TypeScript checks it against the interface, so the fake cannot drift: change the interface and this file stops compiling. It honors the idempotency clause too, so you can call reserveOrder twice and assert stock moved once.

Follow the numbers by hand and the test reads as a story. The M has 50 on hand with 10 already promised, so 40 are sellable. The L has 5 on hand and 5 promised, so none are.

Line 1 wants 30 and gets 30, leaving 10. Line 2 wants 25, gets the last 10, and records a shortfall of 15. Line 3 wants 12 of the L and gets nothing, so no movement is written for it. Hence two movements, not three. (Those numbers are the real output: run the code and you get deltas of −30 and −10, and shortfalls of 15 and 12.) A test like this finishes in a few milliseconds.

Wiring the core into Next.js: server actions vs route handlers

Next.js gives you two ways to run server code. A server action (Next's docs also say Server Function) is an ordinary async function marked 'use server'. You import it into a component and call it like a normal function. Next turns that call into an HTTP request. No URL, no JSON, no fetch. It is for your UI talking to your server.

A route handler is a file named route.ts exporting functions named after HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. It gives a stable URL and control over status codes, headers and the raw body, for anyone who is not your UI: a webhook, a customer's script, a health check.

(A webhook is an HTTP request another company's system makes to a URL you own, to tell you something happened, such as Shopify calling you the moment an order is placed.)

A server action, step by step

// apps/admin/app/orders/actions.ts
"use server";

import Decimal from "decimal.js";
import { updateTag } from "next/cache";
import { reserveOrder } from "@erp/core/orders/reserve-order";
import { reserveOrderInput } from "@erp/contracts/orders";
import { getSession } from "@/lib/auth";
import { makeDeps } from "@/lib/deps";

export async function reserveOrderAction(
  _prev: unknown,
  formData: FormData,
) {
  // 1. AUTHENTICATE — a server action is a public HTTP endpoint.
  const session = await getSession();
  if (!session) return { ok: false as const, error: "Not signed in" };

  // 2. VALIDATE at the boundary. Never trust the browser.
  const parsed = reserveOrderInput.safeParse({
    orderId: formData.get("orderId"),
    warehouseId: formData.get("warehouseId"),
    revision: Number(formData.get("revision")),
    allowPartial: formData.get("allowPartial") === "on",
    cancelDate: formData.get("cancelDate"),
    customerEmail: formData.get("customerEmail"),
    lines: JSON.parse(String(formData.get("lines") ?? "[]")),
  });
  if (!parsed.success) {
    return { ok: false as const, issues: parsed.error.issues };
  }

  // 3. CONVERT decimal strings into exact Decimal values.
  const lines = parsed.data.lines.map((l) => ({
    lineId: l.lineId,
    sku: l.sku,
    quantity: new Decimal(l.quantity),
  }));

  // 4. AUTHORISE — tenant comes from the session, NEVER from the form.
  const result = await reserveOrder(makeDeps(), {
    tenantId: session.tenantId,
    orderId: parsed.data.orderId,
    warehouseId: parsed.data.warehouseId,
    allowPartial: parsed.data.allowPartial,
    lines,
    idempotencyKey:
      `${parsed.data.orderId}:reserve:${parsed.data.revision}`,
  });

  // 5. Invalidate the cached ATS views (chapter 8).
  updateTag(`ats:${session.tenantId}`);
  return { ok: true as const, result };
}

"use server" marks every exported function as server-only. Next compiles each one into an endpoint (a URL on your server that accepts requests) and gives the browser only a stub, a tiny placeholder function that makes the network call. The bundle (the JavaScript file the browser downloads) therefore never contains this code.

Step 2 uses formData, the browser's collection of submitted form fields, and safeParse, which returns a result object saying "valid, here is the data" or "invalid, here are the problems" instead of throwing an error.

Step 1 matters more than it looks, because the framework does not authenticate for you. Next's own guidance is blunt: "Always authenticate and authorize users before performing sensitive server-side operations. Read authentication from cookies or headers rather than accepting tokens as function parameters."

Step 3 is the seam between the outside world and the core. Zod hands back quantities as decimal strings (the Zod section explains why), and reserveOrder wants exact Decimal values, so the conversion happens here, once, at the edge. Step 4 holds the most important line: tenantId: session.tenantId. Take it from the form instead and a user can type another company's id and read their inventory. That is chapter 5's threat model in one field. The idempotency key is order plus revision, so a double-click submits the same key and the second attempt applies nothing.

Step 5 marks the tenant's cached ATS stale: chapter 8's invalidation, in framework terms. In Next.js 16 use updateTag inside a server action, which expires the tag immediately so the user sees their own write on the next render. Its sibling revalidateTag(tag, "max") is for route handlers and webhooks, where serving slightly stale data while fresh data loads in the background is fine. The old one-argument revalidateTag(tag) still runs, but Next's docs now mark it deprecated.

Verifying a Shopify webhook

Shopify has no session cookie and cannot call a server action. It needs a URL.

// apps/admin/app/api/webhooks/shopify/route.ts
import crypto from "node:crypto";
import { recordExternalEvent }
  from "@erp/core/integrations/record-external-event";
import { makeDeps } from "@/lib/deps";

export const runtime = "nodejs";   // node:crypto needs Node, not edge

export async function POST(request: Request) {
  // Read the RAW body. The signature covers the exact bytes sent,
  // so nothing may parse or re-serialise them first.
  const raw = await request.text();

  const sent = request.headers.get("x-shopify-hmac-sha256") ?? "";
  const expected = crypto
    .createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET!)
    .update(raw, "utf8")
    .digest("base64");

  const ok =
    sent.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
  if (!ok) return new Response("bad signature", { status: 401 });

  // Store and acknowledge fast. Do the real work in apps/worker.
  await recordExternalEvent(makeDeps(), {
    source: "shopify",
    externalAccount: request.headers.get("x-shopify-shop-domain") ?? "",
    // Dedupe on the DELIVERY id, per Shopify's docs.
    idempotencyKey: request.headers.get("x-shopify-webhook-id") ?? "",
    // The EVENT id groups deliveries from one merchant action.
    eventId: request.headers.get("x-shopify-event-id") ?? "",
    topic: request.headers.get("x-shopify-topic") ?? "unknown",
    payload: raw,
  });

  return new Response(null, { status: 200 });
}

The folder path becomes the URL and the exported function name the method it answers. runtime = "nodejs" asks for the full Node environment. Next can also run route handlers on a lighter "edge" runtime that lacks Node's built-in modules, and node:crypto is one of those modules. await request.text() reads the body as raw text before anything parses it. Verification depends on the exact bytes Shopify sent, and turning the text into objects and then back into text can move a space or reorder a field, which breaks the check on every legitimate webhook.

An HMAC is a fingerprint of a message computed with a shared secret: Shopify computes one over the body, you compute your own, and if they match then the message came from Shopify and nobody altered it. Shopify sends it in the X-Shopify-Hmac-SHA256 header, written in base64, a way of writing raw bytes as ordinary letters and digits so they survive being sent as text. timingSafeEqual compares in constant time, so an attacker cannot learn the signature by timing responses. It throws on unequal lengths, hence the length check first.

Then the chapter-4 pattern: record the event and return quickly. Shopify's own docs set the budget (a one-second connection timeout and a five-second timeout for the whole request) and expect a 200 OK to acknowledge receipt. Anything slower is retried, and a retry arriving mid-work is how duplicate movements are born. Note which header becomes the idempotency key: Shopify says to use X-Shopify-Webhook-Id to deduplicate individual deliveries, while X-Shopify-Event-Id correlates deliveries that came from the same merchant action. Dedupe on the delivery id. Keep the event id for tracing.

Server actions are public endpoints

Marking a function 'use server' publishes it. Next compiles it into an HTTP endpoint that anyone able to reach your site can call with any arguments they like. Every action must authenticate, validate its inputs, and take the tenant from the session. Treat each one as a public API, because that is what it is.

tRPC, server actions, or REST?

RPC is Remote Procedure Call: calling a function on another computer should look like calling one here. tRPC is a TypeScript-first RPC toolkit: define procedures on the server and the client gets autocomplete and type checking with no code generation, because both sides are one TypeScript project. REST is the conventional HTTP style: nouns as URLs (/v1/orders/1234), verbs as methods, JSON in and out. OpenAPI is a standard file format describing a REST API machine-readably, from which you generate docs, clients in any language, and mock servers.

ConcernServer actionstRPCVersioned REST + OpenAPI
Who calls itYour own Next.js UI onlyAny TypeScript client in the monorepoAnyone, in any language
Type safetyEnd to end, within one appEnd to end, across apps, no codegenVia clients generated from the OpenAPI document
Setup costZero, built inSmall: a router, a context, one route handlerReal: versioning, docs, keys, rate limits, deprecation
Works with plain forms / no JavaScriptYes, built inNoNo
Stable URLs an outsider can depend onNo, identifiers change between buildsNot for outside consumersYes, that is the point
Cost of a breaking changeFree, both sides deploy togetherFree within the monorepoExpensive: version and support the old shape
Use it forAdmin UI mutations and formsA shared internal API across several appsCustomer and partner integrations

The practical sequence: start with server actions only. They are free, built in, and enough for one admin app for a long time. Add tRPC the day a second TypeScript client appears. Add REST only when someone outside your control needs access.

// packages/api/src/router.ts   — tRPC v11, once a second client exists
import { initTRPC, TRPCError } from "@trpc/server";
import {
  reserveOrderInput, atsQueryInput, toReserveCommand,
} from "@erp/contracts/orders";
import { reserveOrder } from "@erp/core/orders/reserve-order";
import { readAtsSnapshot } from "@erp/core/inventory/read-ats";
import type { Ctx } from "./context";

const t = initTRPC.context<Ctx>().create();

/** Middleware: every procedure below is authenticated and tenant-scoped. */
const authed = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session) throw new TRPCError({ code: "UNAUTHORIZED" });
  return next({ ctx: { ...ctx, tenantId: ctx.session.tenantId } });
});

export const appRouter = t.router({
  inventory: t.router({
    ats: authed
      .input(atsQueryInput)                       // a Zod schema
      .query(({ ctx, input }) =>
        readAtsSnapshot(ctx.deps, { tenantId: ctx.tenantId, ...input })),
  }),
  orders: t.router({
    reserve: authed
      .input(reserveOrderInput)
      // The input carries decimal STRINGS; the core wants exact
      // Decimal values, so convert here exactly as the server
      // action does in its step 3.
      .mutation(({ ctx, input }) =>
        reserveOrder(ctx.deps, toReserveCommand(ctx.tenantId, input))),
  }),
});

// The client imports ONLY this type:
export type AppRouter = typeof appRouter;

initTRPC.context<Ctx>().create() declares what the per-request context holds: session, database handle, dependency bag. t.procedure.use(...) defines middleware (code that runs before every procedure built on it), so unauthenticated calls are rejected centrally and the tenant id lives in context where no procedure can forget it.

.query() is for reads, .mutation() for writes: tRPC's only verb distinction, because in RPC the meaning lives in the name. toReserveCommand is the shared helper that turns the validated decimal strings into exact Decimal values and builds the idempotency key, so the tRPC entry point and the server action feed the core identical commands.

Exporting the router's type and nothing else gives the client full autocomplete while shipping zero server code, and every procedure body is one line delegating to @erp/core.

When to add the REST surface

Add it when someone outside your codebase needs programmatic access: a buyer polling stock, a marketplace pulling your catalog. You then accept three obligations:

  • Versioning: the URL carries /v1/ and its shape never breaks, whatever your database does.
  • A published contract: an OpenAPI document generated from the same Zod schemas, so it cannot drift.
  • A deprecation policy: how long /v1/ lives after /v2/.

When you add it, it is a thin translation over @erp/core. Three surfaces, one brain.

Supabase Postgres: what you get, and where it bites

Supabase is managed Postgres with useful things pre-attached, and it is a good starting point for one reason: it is real Postgres. Everything from chapters 1 to 8 works unchanged, and pg_dump (Postgres's own command for writing an entire database out to a file) moves you to any other host.

What comes attached:

  • Row-level security enforced by the database itself, chapter 5's mechanism: ENABLE ROW LEVEL SECURITY plus policies that act like an invisible WHERE clause on every query.
  • Auth primitives: accounts, sessions, and JWTs. (A JWT, or JSON Web Token, is a small signed blob of facts about the signed-in user. The facts inside are called claims, and policies read them with auth.uid() and auth.jwt().)
  • Point-in-time recovery, a paid add-on that continuously archives the write-ahead log (the running record Postgres writes before it changes any data) using the WAL-G tool. Supabase's documentation says it backs those files up at two-minute intervals by default (checked July 2026), which is what lets you restore to a chosen moment with seconds of granularity rather than to last night's snapshot.
  • And PowerSync compatibility, since PowerSync drives chapter 6's offline sync by reading Postgres's logical replication stream, the live feed of row changes that Postgres can publish to other systems.

Two production gotchas. First, write (SELECT auth.uid()) = user_id rather than auth.uid() = user_id: wrapping the call in a sub-select lets the Postgres planner run it once per statement instead of once per row, and Supabase's own published benchmark shows a query dropping from 179ms to 9ms (Supabase RLS performance docs, July 2026).

Second, add an index on every column your policies reference, the tenant column above all. The same benchmark page measures 171ms falling to under 0.1ms.

The service_role key needs a rule of its own: it is the administrative key Supabase issues to your server, and a client created with it and no signed-in user's token bypasses row-level security completely. Keep it in server-side code and out of the browser.

Connection pooling: the thing that breaks on deploy day

A connection pool is a set of already-open database connections kept ready for reuse. Opening one is expensive (handshake, authentication, a whole server-side process), and a Postgres server accepts only a limited number at once: the PostgreSQL manual puts the default max_connections at "typically 100", and hosted plans commonly sit in the tens to low hundreds.

The problem is serverless hosting: your app runs as many short-lived function instances that the platform starts on demand and throws away, and each one wants a connection. Supabase's answer is Supavisor, a pooler that sits in front of the database and shares a small number of real connections among many thousands of client connections.

Picking the wrong one of its three connection strings is the classic deploy-day outage.

ModeHost / portHow it lends connectionsUse it forPrepared statements
Directdb.<ref>.supabase.co:5432A dedicated connection for the life of your processMigrations, pg_dump, long-lived serversYes
Supavisor — session modeaws-<region>.pooler.supabase.com:5432One real connection held for your whole sessionA direct-connection substitute on IPv4-only networksYes
Supavisor — transaction modeaws-<region>.pooler.supabase.com:6543Borrowed per transaction, then returnedServerless and edge functions, many transient clientsNo

That IPv4 row needs a word of explanation. IPv4 and IPv6 are the two generations of internet address, and some networks and hosting platforms still speak only the older IPv4. Supabase direct connections are IPv6 unless you buy the IPv4 add-on, while the Supavisor pooler answers on IPv4 everywhere, so session mode is the drop-in replacement when your host cannot reach the database directly.

# apps/admin/.env — three URLs, three jobs. Do not mix them up.
# Copy the exact hosts from the Supabase dashboard. The pooler host
# has the form aws-N-REGION.pooler.supabase.com: both the number and
# the region differ per project, so never type it from memory.

# 1. Serverless request handling. Port 6543 = transaction mode.
DATABASE_URL="postgresql://postgres.REF:PW@POOLER_HOST:6543/postgres"

# 2. Schema migrations and pg_dump. Direct, port 5432, no pooler.
DIRECT_URL="postgresql://postgres:PW@db.REF.supabase.co:5432/postgres"

# 3. The long-running worker. Session mode, port 5432 on the pooler.
WORKER_URL="postgresql://postgres.REF:PW@POOLER_HOST:5432/postgres"

Port 6543 is transaction mode, used by server actions and route handlers: they run briefly, do one transaction, and vanish. DIRECT_URL on 5432 bypasses the pooler, which is what migrations need. A migration is a script that changes your database's structure, adding a table or an index.

Migrations issue session-level commands such as CREATE INDEX CONCURRENTLY (which builds an index without blocking reads and writes on the table, and which Postgres refuses to run inside a transaction) and advisory locks (a flag one connection holds so a second copy of the migration waits). Both assume they keep the same connection across several statements, and a pooler that reassigns your connection mid-migration breaks them.

The worker uses session mode: one long-lived process holding a few connections.

The prepared-statement gotcha

A prepared statement is a query sent to Postgres once to be parsed and planned, then executed repeatedly with different values. It is faster, and the values travel separately from the query text, which is the standard defense against SQL injection, the attack where someone types '; drop table orders; -- into a form field and your code pastes it straight into a query.

Postgres stores the plan on the connection, under a name like s1, which is precisely why transaction mode cannot support them: your driver prepares s1 on connection A, the transaction ends, A goes back to the pool, and your next call lands on B, asks for s1, and gets "prepared statement s1 does not exist". Supabase's docs say it flatly: "Transaction mode does not support prepared statements. To avoid errors, turn off prepared statements for your connection library."

// packages/db/src/client.ts
import { Pool } from "pg";
import postgres from "postgres";

/**
 * node-postgres ("pg"): does NOT use prepared statements by default,
 * so ordinary pool.query(text, values) calls are safe under
 * transaction pooling. Danger appears only if you opt in with `name`.
 */
export const pgPool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,                  // per instance — keep this SMALL
  idleTimeoutMillis: 10_000,
  connectionTimeoutMillis: 5_000,
});

// SAFE under transaction pooling — parameterised, but unnamed:
export const findStyle = (tenantId: string, styleCode: string) =>
  pgPool.query(
    `select id, style_code, name
       from styles
      where tenant_id = $1 and style_code = $2`,
    [tenantId, styleCode],
  );

// UNSAFE under transaction pooling — `name` creates a *named* prepared
// statement bound to one physical connection:
//   pgPool.query({ name: "find_style", text: "...", values: [...] })

/** postgres.js ("postgres"): prepares by default. Must be turned OFF. */
export const sql = postgres(process.env.DATABASE_URL!, {
  prepare: false,          // ← required on port 6543
  max: 5,
});

/** Migrations get their own client on DIRECT_URL. Prepared is fine. */
export const migrationSql = postgres(process.env.DIRECT_URL!, { max: 1 });

max: 5 is per serverless instance, so a hundred instances at five each means five hundred connections hitting Supavisor. Keep it small. findStyle uses $1/$2 placeholders with a separate values array. That is parameterization, which stops SQL injection, and it does not create a named prepared statement, so it passes safely through the pooler. The commented-out call is the trap: a name property tells node-postgres to cache the plan on one physical connection. postgres.js prepares everything by default, so prepare: false is mandatory on port 6543.

The two-hour bug you can skip

"prepared statement s1 already exists" (or "does not exist"), appearing only in production and only under load, always means one thing: prepared statements running through a transaction-mode pooler. Disable prepared statements on the pooled connection, and always run migrations on the direct one.

Zod: one definition of "valid," shared by both sides

Zod is a schema library. Describe the shape of data once and get two things: a runtime check that rejects bad input, and a TypeScript type derived from that same description. Write the shape twice, once as a hand-written type and once as a hand-written validator, and they will eventually disagree: the validator will bless something the type calls impossible. Zod 4 is the current stable line (4.4.3 as of July 2026).

// packages/contracts/src/orders.ts   — imported by browser AND server
import { z } from "zod";

/** A quantity: a decimal string, not a JS number. Explained below. */
const quantityString = z
  .string()
  .regex(/^-?\d+(\.\d{1,3})?$/, { error: "Up to 3 decimal places" });

/** Money: a decimal string, max 4 places, matching NUMERIC(14,4). */
const moneyString = z
  .string()
  .regex(/^-?\d+(\.\d{1,4})?$/, { error: "Up to 4 decimal places" });

export const orderLineInput = z.object({
  lineId: z.uuid(),
  sku: z.string().min(1).max(64).regex(/^[A-Z0-9-]+$/, {
    error: "SKUs are uppercase letters, digits and hyphens",
  }),
  quantity: quantityString,
  unitPrice: moneyString,
});

export const reserveOrderInput = z.object({
  orderId: z.uuid(),
  revision: z.int().nonnegative(),
  warehouseId: z.uuid(),
  allowPartial: z.boolean().default(false),
  cancelDate: z.iso.date().nullable(),   // a DATE, not a timestamp
  lines: z.array(orderLineInput).min(1).max(500),
  customerEmail: z.email(),
});

// The type is DERIVED. There is no second source of truth.
export type ReserveOrderInput = z.infer<typeof reserveOrderInput>;
// { orderId: string; revision: number; warehouseId: string;
//   allowPartial: boolean; cancelDate: string | null;
//   lines: {...}[]; customerEmail: string }

Take the pieces in turn. z.object({...}) says "an object with exactly these fields," z.array(x).min(1).max(500) says "a list of between 1 and 500 of those," and .regex(...) checks a string against a regular expression, a compact pattern language for text. Read /^-?\d+(\.\d{1,4})?$/ as: an optional minus sign, one or more digits, then optionally a dot and one to four more digits, and nothing else.

Zod 4 idioms, and why money is a string

Several idioms changed in version 4. z.uuid(), z.email() and z.iso.date() are now top-level functions. The older z.string().uuid() and z.string().email() forms still work, but the migration guide marks them deprecated. A single error key replaces v3's message, invalid_type_error, required_error and errorMap. z.iso.date() accepts a YYYY-MM-DD string and nothing else. It rejects 2026-03-15T00:00:00Z outright, which alone prevents the next section's bug. (ISO 8601 is the standard for writing dates as text: 2026-03-15 for a date, 2026-03-15T14:30:00Z for an instant, where Z means UTC.)

The money and quantity fields accept strings for a concrete reason: JSON's one numeric type is the same lossy float as JavaScript's, so {"unitPrice": 24.35} is already approximate before your code sees it. z.infer<typeof reserveOrderInput> is the payoff: adding a field updates the type, and every construction site missing it stops compiling.

The discipline that follows is validate at boundaries only. Parse untrusted data where it enters: server action, route handler, webhook, CSV importer, sync payload. Past that line it is typed and trusted, and packages/core never re-validates. Doing so creates a second definition of "valid" that will drift.

Timezone discipline: instants versus calendar dates

An ERP holds two completely different kinds of temporal value, and conflating them causes one of the most embarrassing bugs in wholesale software: orders that cancel themselves a day early.

An instant is a moment on the world's timeline: when this order was created, when this shipment scanned out. It is the same moment everywhere. Only its rendering differs. Store instants as timestamptz, in UTC (Coordinated Universal Time: effectively Greenwich with no daylight saving), and render them in local time at the last moment.

A civil date is a calendar day with no time and no zone: a cancel date, a season start, an invoice due date. A buyer who writes "cancel 15 March" means the day, wherever they stand. Store civil dates as DATE.

-- packages/db/migrations/0012_orders_dates.sql

create table wholesale_orders (
  id              uuid primary key default gen_random_uuid(),
  tenant_id       uuid not null references tenants(id),
  order_number    text not null,

  -- INSTANTS: moments on the timeline. Always timestamptz, stored UTC.
  created_at      timestamptz not null default now(),
  submitted_at    timestamptz,

  -- CIVIL DATES: calendar days agreed with the buyer. No time. No zone.
  ship_start_date date not null,
  cancel_date     date not null,

  constraint cancel_after_start check (cancel_date >= ship_start_date),
  unique (tenant_id, order_number)
);

-- The tenant's own wall-clock zone, for rendering instants.
alter table tenants add column time_zone text not null default 'UTC';

create index wholesale_orders_cancel_idx
  on wholesale_orders (tenant_id, cancel_date);

-- "Which orders cancel today?" -- evaluated in the TENANT's zone.
select o.id, o.order_number, o.cancel_date
from wholesale_orders o
join tenants t on t.id = o.tenant_id
where o.tenant_id = $1
  and o.cancel_date <= (now() at time zone t.time_zone)::date;

A few Postgres words first. uuid is a long random identifier, and gen_random_uuid() makes a fresh one for each row, so two machines can create rows without ever colliding. references tenants(id) tells the database this column must point at a real tenant. A check constraint is a rule the database enforces on every write. Here it says a cancel date can never fall before the ship-start date. unique (tenant_id, order_number) lets two different tenants both use order number 1001 while stopping one tenant from using it twice.

The name timestamptz is misleading. The column holds one absolute instant, normalized to UTC, and no zone travels with it. date stores year, month and day with no time and no zone: 15 March is 15 March, full stop. The final query asks "which orders have canceled?" correctly: now() gives the instant, at time zone t.time_zone converts it to the tenant's wall clock, ::date takes the calendar day, and you compare date to date. Two tenants get different answers at the same instant, which is right.

"The order canceled a day early"

Store cancel_date as timestamptz and the failure runs like this. Someone in New York enters 15 March. The browser sends 2026-03-15T00:00:00, and on that date New York is four hours behind UTC, so the stored instant is 2026-03-15T04:00:00Z. A buyer in Los Angeles, seven hours behind UTC, sees that instant rendered as 2026-03-14, 21:00 the previous evening. The screen says the order cancels on the 14th, and a nightly job comparing that instant to local midnight kills live orders a day early. A civil date has no time, so give it no time to lose.

The driver-level trap

Even with a correct DATE column, your driver can reintroduce the bug on the way out. A driver is the library your code uses to talk to the database. Here it is pg, also called node-postgres.

As of July 2026, pg 8.22.0 depends on pg-types 2.2.0, which registers a parser for Postgres type 1082 (date) and turns '2026-03-15' into a JavaScript Date. That parser comes from the small postgres-date package, whose source comment is explicit: "Force YYYY-MM-DD dates to be parsed as local time".

node-postgres's own documentation confirms the consequence: it "converts DATE and TIMESTAMP columns into the local time of the node process set at process.env.TZ". Run that on a server set to Europe/Berlin and '2026-03-15' arrives as the instant 2026-03-14T23:00:00Z: call toISOString() on it, or render it in UTC, and the screen says the 14th.

A server set to America/Los_Angeles has the mirror-image fault: the same column value becomes 2026-03-15T07:00:00Z, which still reads as the 15th in most of the world but shows as the 14th, 6pm, to a buyer in Honolulu.

// packages/db/src/types.ts  — import this ONCE, before any query runs.
import pg from "pg";

const DATE_OID = 1082;      // Postgres `date`
const NUMERIC_OID = 1700;   // Postgres `numeric` / `decimal`

// Keep DATE as the plain string Postgres sent. No Date object, no zone.
pg.types.setTypeParser(DATE_OID, (value: string) => value);

// NUMERIC is already a string by default (pg registers no parser for
// 1700). Set it explicitly so an upgrade cannot silently change that.
pg.types.setTypeParser(NUMERIC_OID, (value: string) => value);

// ---------------------------------------------------------------
// Date-only arithmetic, with no local time anywhere near it.
export type CivilDate = string;   // strictly "YYYY-MM-DD"

const toUtcMs = (s: CivilDate) => {
  const [y, m, d] = s.split("-").map(Number);
  return Date.UTC(y, m - 1, d);   // pure calendar math, no local zone
};

export function addDays(date: CivilDate, days: number): CivilDate {
  const ms = toUtcMs(date) + days * 86_400_000;
  return new Date(ms).toISOString().slice(0, 10);
}

export function daysBetween(a: CivilDate, b: CivilDate): number {
  return Math.round((toUtcMs(b) - toUtcMs(a)) / 86_400_000);
}

/** Render an instant in the tenant's zone. Formatting is a UI concern. */
export function formatInstant(iso: string, timeZone: string): string {
  return new Intl.DateTimeFormat("en-US", {
    timeZone, dateStyle: "medium", timeStyle: "short",
  }).format(new Date(iso));
}

setTypeParser registers a function for one Postgres type identified by its OID, the numeric id Postgres gives every data type. Returning the value untouched means "hand me the raw text," so a DATE arrives as "2026-03-15", immune to every timezone in the world. The NUMERIC line is belt-and-braces, since pg-types registers no parser for 1700 today.

addDays works entirely in UTC, where every day is exactly 86,400,000 milliseconds long, so no daylight-saving change can shift it. The naive version — build a Date, call setDate(getDate() + n), then toISOString() — quietly loses a day whenever the span crosses a clock change: in a process set to Europe/London, 27 March 2026 plus three days returns 29 March instead of 30 March, because the clocks go forward on the 29th. Store UTC, compute in UTC, format at the edge.

The Temporal API: safe on the server, early in browsers

Temporal is JavaScript's proper replacement for Date, and it draws exactly this chapter's distinction: Temporal.PlainDate for a calendar day, Temporal.Instant for a moment, Temporal.ZonedDateTime for both. As of July 2026 it ships in Chrome and Edge 144 and later and Firefox 139 and later. No released version of Safari has it (Safari Technology Preview keeps it behind a flag), and neither do Opera or Samsung Internet, which is why caniuse puts global support at about 67% of browsers. On the server you can use it today through a polyfill (a library that supplies a missing built-in feature): temporal-polyfill 1.0.1, or the standards champions' own @js-temporal/polyfill. Keep dates as strings at the boundary and adopting Temporal later stays a contained change.

Decimal arithmetic: never, ever floats

A floating-point number is how computers store fractions: binary digits plus an exponent, like scientific notation in base 2. JavaScript has exactly one number type and it is a 64-bit float. Binary cannot represent most decimal fractions exactly, for the same reason base 10 cannot write one third. In binary, 0.1 repeats forever and gets truncated to fit.

// Every line below is real output, re-run in Node.js, July 2026.

0.1 + 0.2;                       // 0.30000000000000004
0.1 + 0.2 === 0.3;               // false

let total = 0;
for (let i = 0; i < 10; i++) total += 0.1;
total;                           // 0.9999999999999999   (not 1)

0.07 * 100;                      // 7.000000000000001

// --- Now with real apparel money. 12 units at $24.35, seven lines. ---
12 * 24.35;                      // 292.20000000000005
let invoice = 0;
for (let i = 0; i < 7; i++) invoice += 12 * 24.35;
invoice;                         // 2045.4000000000003  (should be 2045.40)

// --- And here is where a penny actually disappears. ---
1.005 * 100;                     // 100.49999999999999
Math.round(1.005 * 100) / 100;   // 1        ← expected 1.01
(1.005).toFixed(2);              // "1.00"   ← toFixed does NOT save you
(0.615).toFixed(2);              // "0.61"   ← nor here

Number.MAX_SAFE_INTEGER;         // 9007199254740991  ← exactness ends

Read the middle block carefully, because it is the one that costs money. One line of 12 units at $24.35 should be exactly $292.20. The float says 292.20000000000005, and seven such lines compound to 2045.4000000000003. Alone that rounds away harmlessly, but it will not stay alone: it gets multiplied by a tax rate, split across a partial shipment, compared against a payment, and eventually a chapter-4 reconciliation job reports a one-cent discrepancy nobody can explain.

The last block is worse because it is silent. 1.005 cannot be stored exactly and the nearest float sits fractionally below it, so multiplying by 100 gives 100.49999999999999, rounding gives 100, and your $1.005 line becomes $1.00. toFixed(2) does not rescue you: it also yields "1.00", and turns 0.615 into "0.61". MAX_SAFE_INTEGER is why raw integers are no free escape either: past about nine quadrillion, whole numbers stop being exact.

Core principle

No money value and no quantity value is ever a JavaScript number: not in a column, not in a JSON payload, not in a variable, not for a moment. The path is: NUMERIC in Postgres → string over the wire → exact decimal type in memory → string back to Postgres. A number anywhere in that chain is a defect, and your types should say so.

Which representation to use

RepresentationHow it worksStrengthsWeaknessesVerdict for an apparel ERP
JavaScript number (float) 64-bit binary floating point Fast; no library needed Cannot represent 0.1; errors compound; rounding silently wrong Never. Not for money, not for quantities
Integer minor units (cents) Store 2435 for $24.35; arithmetic in whole cents Exact for + and −; JSON-safe below 253 Division needs an allocation rule; unit prices often need 4 places; the number of minor units differs by currency (the yen has none, the Kuwaiti dinar has three) Excellent for totals and payments; awkward for per-unit costing
NUMERIC(14,4) + decimal.js Exact decimal in Postgres; arbitrary precision in TypeScript Exact; 4-place prices and fractional yardage; scale enforced by the column; explicit rounding modes Needs a boundary mapping layer; slower than floats The default choice. Prices, quantities, costs, tax
dinero.js v2 Immutable money objects: amount + currency + scale Every amount carries its currency, so adding USD to EUR throws "Objects must have the same currency" instead of quietly producing nonsense; allocate() splits without losing pennies Money only, not quantities; needs conversion at the database edge Great for invoice and payment logic; pair with decimal.js for quantities
big.js / bignumber.js Same family as decimal.js, same author big.js is tiny (about 6 kB minified); bignumber.js adds other bases big.js lacks some operations; three similar libraries invite mixing Fine alternatives, but pick one and forbid the others

Two words from that table. NUMERIC(14,4) is a Postgres column holding up to 14 digits in total, 4 of them after the decimal point, so up to ten digits before it, which is more than any apparel invoice needs. Immutable means an object is never changed in place: every dinero operation returns a new money object and leaves the original alone, so a value cannot be edited behind your back.

All four libraries are maintained. As of July 2026 the current releases are decimal.js 10.6.0, big.js 7.0.1, bignumber.js 11.1.5, and dinero.js 2.0.2. Version 2 left a years-long alpha with 2.0.0 on 2 March 2026. Dinero's allocate splits $10.00 three ways into 3.34, 3.33 and 3.33, so the pennies still sum to the original: the operation naive division always gets wrong. (Checked against dinero.js 2.0.2 in July 2026.)

// packages/core/src/money.ts
import Decimal from "decimal.js";

// Bankers' rounding by default: ties go to the even digit, so errors
// cancel over many lines instead of accumulating upward.
Decimal.set({ precision: 34, rounding: Decimal.ROUND_HALF_EVEN });

export type Money = Decimal;        // scale 4, matching NUMERIC(14,4)
export type Quantity = Decimal;     // scale 3, matching NUMERIC(14,3)

// Strings only. Accepting a number here would reopen the float hole.
export const money = (v: string): Money => new Decimal(v);

export function lineTotal(unitPrice: Money, quantity: Quantity): Money {
  return unitPrice
    .times(quantity)
    .toDecimalPlaces(4, Decimal.ROUND_HALF_UP);
}

/** Round to whole cents only at the moment of billing. */
export function toCents(m: Money): bigint {
  const c = m.times(100).toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
  return BigInt(c.toFixed(0));
}

// --- Verified behaviour ---
// new Decimal("0.1").plus("0.2").toString()          === "0.3"
// new Decimal("24.35").times(12).toString()          === "292.2"
// seven such lines summed                            === "2045.4"
// new Decimal("1.005")
//   .toDecimalPlaces(2, ROUND_HALF_UP).toFixed(2)    === "1.01"
// new Decimal("59.97").times("0.08875")
//   .toDecimalPlaces(2, ROUND_HALF_UP).toFixed(2)    === "5.32"

precision: 34 matches the 34 significant digits of decimal128, the IEEE standard format for exact decimal numbers. ROUND_HALF_EVEN is bankers' rounding (2.5 to 2, 3.5 to 4), so ties split evenly instead of drifting upward.

lineTotal rounds to 4 places to match the column, and toCents collapses to whole cents once, at billing. Rounding early and often is how a total stops matching its lines. (bigint is JavaScript's exact whole-number type, written with an n suffix as in 2435n, and it has no size limit, which makes it the right home for a count of cents.)

The commented block is verified output: every float failure above comes out right, including 1.005 becoming 1.01 and a $59.97 line at 8.875% tax giving exactly $5.32.

Enforcing it end to end

// packages/db/src/repositories/inventory.repository.ts
import "../types";              // registers the DATE/NUMERIC parsers
import Decimal from "decimal.js";
import type { Pool } from "pg";
import type { AtsPosition, InventoryRepository, StockMovement, TenantId }
  from "@erp/core/inventory/ports";

/** Postgres NUMERIC arrives as a STRING. That is the contract. */
interface PositionRow {
  sku: string;
  on_hand: string;      // ← not number
  allocated: string;    // ← not number
  inbound: string;      // ← not number
}

export function makeInventoryRepository(pool: Pool): InventoryRepository {
  return {
    async loadPositions(tenantId: TenantId, skus) {
      const { rows } = await pool.query<PositionRow>(
        `select sku,
            sum(delta) filter (
              where reason in ('receipt','shipment','adjustment')
            ) as on_hand,
            -sum(delta) filter (
              where reason in ('reservation','release')
            ) as allocated,
            coalesce(sum(inbound_qty), 0) as inbound
           from inventory_ledger
          where tenant_id = $1 and sku = any($2::text[])
          group by sku`,
        [tenantId, skus],
      );

      return rows.map((r): AtsPosition => ({
        sku: r.sku,
        onHand: new Decimal(r.on_hand ?? "0"),
        allocated: new Decimal(r.allocated ?? "0"),
        inbound: new Decimal(r.inbound ?? "0"),
      }));
    },

    async appendMovements(tenantId, movements, key) {
      const client = await pool.connect();
      try {
        await client.query("begin");
        const claimed = await client.query(
          `insert into idempotency_keys (tenant_id, key)
                values ($1, $2) on conflict do nothing returning key`,
          [tenantId, key],
        );
        if (claimed.rowCount === 0) {
          await client.query("rollback");
          return { applied: false };
        }
        for (const m of movements) {
          await client.query(
            `insert into inventory_ledger
               (tenant_id, sku, warehouse_id, delta, reason,
                reference_type, reference_id, idempotency_key)
             values ($1,$2,$3,$4::numeric,$5,$6,$7,$8)`,
            [tenantId, m.sku, m.warehouseId,
             m.delta.toFixed(3),   // Decimal → string, never number
             m.reason, m.referenceType, m.referenceId, key],
          );
        }
        await client.query("commit");
        return { applied: true };
      } catch (e) {
        await client.query("rollback");
        throw e;
      } finally {
        client.release();               // ← back to the pool, always
      }
    },
  };
}

The query sums the ledger three ways: shipments and receipts give on_hand, reservations and releases give allocated (negated, because a reservation is stored as a negative delta), and inbound_qty (the expected-receipt figure carried on confirmed purchase-order rows) gives inbound, which never counts toward what you may sell.

interface PositionRow is where the discipline becomes mechanical: declaring on_hand: string means that if anyone writes r.on_hand * 2, TypeScript refuses to compile it.

The declaration matches what the driver hands you. pg-types registers no parser for OID 1700, and node-postgres returns any type it has no parser for as the raw text Postgres sent. That is deliberate, because a Postgres NUMERIC holds values no JavaScript number can represent. The same reasoning makes int8 (Postgres's 64-bit integer) a string.

pool.connect() takes one connection so every statement runs on the same one. begin opens a transaction, commit makes everything in it permanent, and rollback throws it all away. Those three words are meaningless if the statements between them land on different connections.

The idempotency claim is one insert with on conflict do nothing returning key: if the key already exists, rowCount is 0 and we roll back. That is chapter 3's guarantee enforced by a unique constraint inside the database, rather than by a check in application code that two simultaneous requests can race past.

m.delta.toFixed(3) goes exact decimal → exact text → exact NUMERIC, and client.release() in a finally block returns the connection on every path, including the failing ones.

The build order: what to learn, in what sequence

Order matters more here than in most software, because these topics constrain each other. Build them out of sequence and you will rewrite your schema three times.

First: ledger modeling and isolation levels (chapters 1–2). These shape the schema, the hardest thing to change later. Once inventory is an append-only ledger of signed movements rather than a mutable quantity_on_hand column, everything follows: audit is free, corrections are new rows, concurrency stops being a lost-update problem, and "what did we think we had on 3 March?" becomes a where created_at <= ... clause. Build: the ledger table, movement append, a position query, and a concurrency test firing 50 simultaneous reservations at 10 units that proves you never oversell.

Second: the Shopify reconciliation pattern (chapters 3–4). Take one integration all the way: webhook verified, event stored under the provider's delivery id as idempotency key, worker processes it exactly once, outbox publishes your changes, and a scheduled job compares both systems and reports drift.

Do this once, properly, and you have the template for every integration that follows: the 3PL (a third-party logistics provider, an outside warehouse that picks and ships for you), the payment processor, the EDI feed (Electronic Data Interchange, a family of decades-old standard message formats, such as X12 in North America, that big retailers still use to send purchase orders). They differ in payload. They are identical in structure. Skip it and you hire someone to fix mismatches by hand.

Tenancy and sync come last

Third: RLS multi-tenancy (chapter 5). The tables are settled, so put the walls up: enable row-level security on every tenant-scoped table, write the policies, index the tenant column, and prove in tests that tenant A cannot see tenant B's rows through any path, whether query, join, aggregate or foreign key lookup. RLS is third because policies are written against a settled schema, but it must come before real customers: retrofitting tenancy onto a live system is dangerous work.

Fourth and last: offline sync (chapter 6). It is last because it is the hardest thing in the book and depends on everything else being stable. It needs a settled schema, because the client mirrors it; idempotency, because a reconnecting device replays writes; a conflict-resolution story, answerable only once you know what your ledger means; and tenancy, because the sync stream must be filtered by the database's own rules. Attempt it early and you will debug distributed state machines against a weekly-changing schema.

What you now know. How to model facts so they cannot be lost, how to make concurrent writes safe, how to make retries harmless, how to keep two systems honest about each other, how to keep tenants apart in the database, how to keep working when the network does not, how to accept messy human data, how to make reads fast without making them wrong, and how to prove it all still works tomorrow. None of those are Next.js or Supabase skills. They will outlive every tool named here.

Your first vertical slice

Start with the smallest complete slice: one tenant, one style with a few SKUs, an append-only inventory ledger, a wholesale order that reserves against it in a transaction, and one server action calling one pure function in packages/core with a unit test and no database. Perhaps two hundred lines.

It will feel too small. Build it anyway: it is a vertical slice (one thin path that touches every layer, from the click to the ledger row), and every feature after it varies a path you have already walked.

Keep the discipline: rules in the core, plumbing at the edges, decimals never floats, dates never instants, tenants never trusted from the client.

The layering rule that keeps the project testable The layering rule that keeps the project testable. Each band may use the one below it and must know nothing about the one above. The green band is the point of the whole arrangement: your actual business rules live in ordinary functions that take values and return values, with no database connection and no web framework anywhere near them. That is what lets you test the allocation logic in milliseconds, and reuse the identical code in a background worker where there is no HTTP request at all. DEPENDENCIES POINT INWARD, ALWAYS React components — buttons, forms, tables know about pixels; know nothing about allocation rules Server actions / route handlers (Next.js) authenticate · set tenant · validate input · call the core Application services open the transaction · orchestrate · write the outbox row packages/core — the domain pure functions: canAllocate(), priceLine(), nextStatus() no database, no HTTP, no framework — testable in milliseconds Repositories — the only code that speaks SQL interfaces defined by the core, implemented outside it PostgreSQL outer layers may call inward inner layers must not call outward
The layering rule that keeps the project testable. Each band may use the one below it and must know nothing about the one above. The green band is the point of the whole arrangement: your actual business rules live in ordinary functions that take values and return values, with no database connection and no web framework anywhere near them. That is what lets you test the allocation logic in milliseconds, and reuse the identical code in a background worker where there is no HTTP request at all.

Field notes & further reading

Exercise

1. The core package, proven portable. Create packages/core with no dependency on Next.js, pg, or any HTTP library. Its package.json should list only decimal.js. Write availableToSell, planAllocation, and a reserveOrder use case taking its repositories as arguments. Write a Vitest suite with an in-memory fake repository covering four cases: full allocation, partial allocation with a shortfall, outright rejection, and a repeated idempotency key applied only once. Then call the same reserveOrder from a server action in apps/admin and from a script in apps/worker. When you are done: the suite runs green in under a second with no database anywhere, and the identical rule executes correctly from both a browser click and a command line.

2. Break it, then make it unbreakable. On a scratch branch, add a cancel_date timestamptz column and a float-based line total. Enter a 15 March cancel date with TZ=Australia/Sydney, read it back with TZ=America/New_York, and watch the date slide to the 14th. (Direction matters: a date entered in New York and read in Sydney stays on the 15th, which is why this bug hides until the wrong pair of offices looks at it.) Separately, sum seven invoice lines of 12 units at $24.35 using JavaScript numbers. Now fix both: change the column to DATE, register setTypeParser overrides for OIDs 1082 and 1700, switch to NUMERIC(14,4) with decimal.js, and type every money and quantity row field as string. When you are done: the cancel date reads as "2026-03-15" in every timezone you can set, the total is exactly 2045.40, and multiplying a money field by a number is a compile error rather than a runtime surprise.