// Sample company seeder — Task 2 of docs/superpowers/plans/2026-08-01-launch.md
// (spec: docs/superpowers/specs/2026-08-01-launch-design.md's "Sample
// company" section). Creates "Riverbend Design Studio", a fictional design
// agency, with ~14 months of realistic activity, entirely through the SAME
// HTTP routes a real user drives (app.inject against the running Fastify
// instance — exactly like tests/server/*.test.ts) — never a bespoke
// "insert rows directly" shortcut. The ONE deliberate exception is flagging
// the new row `is_sample = true` right after creation: no existing route has
// any reason to ever set that column (see db/migrations/0014's header), so a
// single adminPool UPDATE is the honest way to do it, not a workaround for
// something a route already offers.
//
// Why app.inject and not a plain fetch() to a real port: this module is
// called from two places — POST /api/sample-company (production) and
// tests/server/sample.test.ts (a throwaway DB) — and both already have a
// live FastifyInstance in hand (the real app, built by buildApp()). Routing
// through .inject() means every invariant those routes already enforce
// (RLS, balanced-entry triggers, immutability, per-company counters) is
// exercised for real, not re-derived here.
//
// Determinism: every choice that affects the BOOKS (which customer/vendor,
// which line items, which day of the month, which invoices go overdue vs.
// paid) is driven by a tiny seeded LCG (rng() below), never Math.random() —
// re-running this seeder against a fresh database always produces byte-
// identical amounts/dates/counts, which is what makes the invariant test
// (tests/server/sample.test.ts) and the "screenshots stay fresh" promise
// (dates are relative to `today`, but the STORY told by the numbers never
// drifts) both hold. Math.random() is used in exactly one place — the
// multipart boundary string for the CSV upload — which is wire-format noise,
// never a business decision.
//
// Money is always decimal strings built from a literal catalog or via
// bigint-cents arithmetic (addAmounts below) — never a JS float, per
// CLAUDE.md §2, matching every route module's own convention.
//
// Ordering constraint (why bank import + reconcile run LAST): the ledger
// core's assert_not_reconciled() trigger (db/migrations/0011_bank_rec.sql)
// refuses to post any NEW entry into a money account for a date at or before
// that account's reconciled_through. Every invoice/bill/payment/manual-entry
// — and every stock receipt, build and item sale in the inventory chapter —
// in this seeder is posted BEFORE the one reconciliation this company gets,
// so nothing here can ever collide with that guard — reconciling earlier and
// then trying to backfill an older transaction would.

import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import type { Pool } from "pg";

const COMPANY_NAME = "Riverbend Design Studio";

// Fixed seed: "Riverbend" is not random, and neither is this — re-running the
// seeder against a fresh DB must reproduce the exact same books every time
// (see file header).
const RNG_SEED = 0x52697665; // "Rive" as bytes, arbitrary but fixed

export interface SeedSampleCompanyOk {
  ok: true;
  companyId: string;
  name: string;
  counts: {
    customers: number;
    vendors: number;
    invoices: number;
    invoicesPaid: number;
    invoicesPartial: number;
    invoicesOverdueOpen: number;
    bills: number;
    billPayments: number;
    checksPrinted: number;
    checksQueuedToPrint: number;
    manualEntries: number;
    reconciledMonths: number;
    items: number;
    stockedItems: number;
    purchaseOrders: number;
    builds: number;
  };
}
export interface SeedSampleCompanyRefused {
  ok: false;
  status: number;
  error: string;
}
export type SeedSampleCompanyResult = SeedSampleCompanyOk | SeedSampleCompanyRefused;

// ---------------------------------------------------------------------------
// deterministic PRNG + small helpers
// ---------------------------------------------------------------------------

/** Numerical-Recipes LCG — tiny, deterministic, good enough for "which of
 *  these five customers" style choices. Never used for money amounts
 *  directly (those come from a literal catalog); only for INDICES/DAYS. */
function makeRng(seed: number): () => number {
  let state = seed >>> 0;
  return () => {
    state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
    return state / 4294967296;
  };
}

function pick<T>(rng: () => number, arr: readonly T[]): T {
  return arr[Math.floor(rng() * arr.length)]!;
}

function randInt(rng: () => number, min: number, max: number): number {
  return min + Math.floor(rng() * (max - min + 1));
}

// ---------------------------------------------------------------------------
// float-free money helpers (mirrors bank.ts's centsToStr/toDbAmountFormat)
// ---------------------------------------------------------------------------

function centsFromDecimal(s: string): bigint {
  const neg = s.startsWith("-");
  const unsigned = neg ? s.slice(1) : s;
  const [whole, fracRaw = ""] = unsigned.split(".");
  const cents = BigInt(whole || "0") * 100n + BigInt((fracRaw + "00").slice(0, 2) || "0");
  return neg ? -cents : cents;
}
function decimalFromCents(c: bigint): string {
  const neg = c < 0n;
  const abs = neg ? -c : c;
  const whole = abs / 100n;
  const frac = (abs % 100n).toString().padStart(2, "0");
  return `${neg ? "-" : ""}${whole.toString()}.${frac}`;
}
function addAmounts(a: string, b: string): string {
  return decimalFromCents(centsFromDecimal(a) + centsFromDecimal(b));
}

/** account_book_balance_asof() returns a numeric(19,4) string ("1234.5600")
 *  but POST .../reconcile's endingBalance field only accepts up to 2 decimal
 *  places (bank.ts's signedAmountSchema) — every amount in this seeder is
 *  whole-cent, so the trailing 2 digits are always "00"; this just drops
 *  them rather than rounding (there is nothing to round). */
function toTwoDecimals(numeric4: string): string {
  const neg = numeric4.startsWith("-");
  const unsigned = neg ? numeric4.slice(1) : numeric4;
  const [whole, fracRaw = ""] = unsigned.split(".");
  const twoDp = (fracRaw + "00").slice(0, 2);
  return `${neg ? "-" : ""}${whole}.${twoDp}`;
}

// ---------------------------------------------------------------------------
// date helpers — local calendar dates, never UTC (only the day matters here,
// never a time-of-day, so there's no timezone hazard to worry about; this
// mirrors reports.ts/invoices.ts's own "let the database's clock decide"
// philosophy by keeping every date a plain YYYY-MM-DD string as soon as it's
// computed, same shape the API expects).
// ---------------------------------------------------------------------------

function isoDate(d: Date): string {
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}
function addDays(base: Date, days: number): Date {
  const d = new Date(base);
  d.setDate(d.getDate() + days);
  return d;
}
function addMonths(base: Date, months: number): Date {
  const d = new Date(base);
  d.setMonth(d.getMonth() + months);
  return d;
}
function startOfMonth(d: Date): Date {
  return new Date(d.getFullYear(), d.getMonth(), 1);
}
function endOfMonth(d: Date): Date {
  return new Date(d.getFullYear(), d.getMonth() + 1, 0);
}
function csvDate(d: Date): string {
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${m}/${day}/${d.getFullYear()}`;
}

/**
 * NOTHING THIS SEEDER RECORDS MAY BE DATED AFTER TODAY.
 *
 * Most dates here are built as `monthDate(offset) + n days`, where
 * `monthDate(0)` is the FIRST of the current month — so for any company seeded
 * early in a month, `monthDate(0) + randInt(1, 26)` lands in the future. That
 * really happened: the shipped sample carried a customer payment and a postage
 * payment dated a week and a half ahead of the seed, which is the first thing a
 * stranger meets in the transaction register and reads as a broken program, not
 * as a demo.
 *
 * The clamp lives in the POSTING HELPERS (createAndIssueInvoice,
 * receivePayment, createAndIssueBill, payBill*, moneyIn/moneyOut/transfer) and
 * not at the ~40 call sites, so a date arithmetic slip in some future chapter
 * cannot reintroduce it. tests/server/sample.test.ts pins the invariant.
 *
 * It applies to TRANSACTION dates only. A DUE DATE in the future is the whole
 * point of an unpaid invoice — clamping those would empty the "current" bucket
 * of the aging report and delete a feature from the tour.
 *
 * `today` carries a time-of-day and every computed date is midnight, so a date
 * landing on today compares as earlier and passes through untouched.
 */
function notFuture(date: Date, today: Date): Date {
  return date > today ? today : date;
}

// ---------------------------------------------------------------------------
// app.inject helpers
// ---------------------------------------------------------------------------

/** Everything the HTTP helpers need. Split out from Ctx so the inventory
 *  chapter (exported below, and driven against an ALREADY-seeded company by
 *  one-off scripts) never has to invent an rng it does not use — drawing from
 *  the shared stream is exactly the mistake that would rewrite the rest of the
 *  seeder's story. */
interface ApiCtx {
  app: FastifyInstance;
  companyId: string;
}

interface Ctx extends ApiCtx {
  rng: () => number;
}

async function apiPost(ctx: ApiCtx, url: string, payload: unknown): Promise<any> {
  const res = await ctx.app.inject({
    method: "POST", url, headers: { "x-company-id": ctx.companyId }, payload: payload ?? {},
  });
  if (res.statusCode >= 400) {
    throw new Error(`sample seeder: POST ${url} failed (${res.statusCode}): ${res.body}`);
  }
  return res.json();
}

async function apiPut(ctx: ApiCtx, url: string, payload: unknown): Promise<any> {
  const res = await ctx.app.inject({
    method: "PUT", url, headers: { "x-company-id": ctx.companyId }, payload: payload ?? {},
  });
  if (res.statusCode >= 400) {
    throw new Error(`sample seeder: PUT ${url} failed (${res.statusCode}): ${res.body}`);
  }
  return res.json();
}

async function apiGet(ctx: ApiCtx, url: string): Promise<any> {
  const res = await ctx.app.inject({ method: "GET", url, headers: { "x-company-id": ctx.companyId } });
  if (res.statusCode >= 400) {
    throw new Error(`sample seeder: GET ${url} failed (${res.statusCode}): ${res.body}`);
  }
  return res.json();
}

/** Builds a hand-rolled multipart/form-data body — same technique as
 *  tests/server/bankrec.test.ts's multipartPayload, duplicated here rather
 *  than imported (this file ships in the production server, tests/ does
 *  not). The boundary string is the one place this module uses Math.random()
 *  — see the file header for why that's fine (wire-format noise, not a
 *  business decision). */
function multipartPayload(
  fileBuffer: Buffer, filename: string, fields: Record<string, string>,
): { payload: Buffer; contentType: string } {
  const boundary = `----sampleSeedBoundary${randomUUID().replace(/-/g, "")}`;
  const parts: Buffer[] = [];
  for (const [key, value] of Object.entries(fields)) {
    parts.push(Buffer.from(
      `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`,
    ));
  }
  parts.push(Buffer.from(
    `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: text/plain\r\n\r\n`,
  ));
  parts.push(fileBuffer);
  parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
  return { payload: Buffer.concat(parts), contentType: `multipart/form-data; boundary=${boundary}` };
}

async function apiPostMultipart(
  ctx: ApiCtx, url: string, fileBuffer: Buffer, filename: string, fields: Record<string, string>,
): Promise<any> {
  const { payload, contentType } = multipartPayload(fileBuffer, filename, fields);
  const res = await ctx.app.inject({
    method: "POST", url,
    headers: { "x-company-id": ctx.companyId, "content-type": contentType },
    payload,
  });
  if (res.statusCode >= 400) {
    throw new Error(`sample seeder: POST ${url} (multipart) failed (${res.statusCode}): ${res.body}`);
  }
  return res.json();
}

// ---------------------------------------------------------------------------
// chart lookups
// ---------------------------------------------------------------------------

interface AccountRef { id: string; code: string; name: string; type: string }
interface GroupedAccounts { money: AccountRef[]; income: AccountRef[]; spending: AccountRef[]; other: AccountRef[] }

function findAccountId(grouped: GroupedAccounts, name: string): string {
  for (const group of [grouped.money, grouped.income, grouped.spending, grouped.other]) {
    const found = group.find((a) => a.name === name);
    if (found) return found.id;
  }
  throw new Error(`sample seeder: account "${name}" not found in the starter chart`);
}

// ---------------------------------------------------------------------------
// catalog data — fictional design-agency vocabulary, per the plan
// ---------------------------------------------------------------------------

const CUSTOMERS: Array<{ name: string; email: string; termsDays: number }> = [
  { name: "Maple & Co. Bakery", email: "hello@mapleandco.example", termsDays: 15 },
  { name: "Northwind Outfitters", email: "ap@northwindoutfitters.example", termsDays: 30 },
  { name: "Bluepeak Dental Group", email: "billing@bluepeakdental.example", termsDays: 30 },
  { name: "Cedar Ridge Realty", email: "accounts@cedarridgerealty.example", termsDays: 45 },
  { name: "Fernwood Yoga Studio", email: "studio@fernwoodyoga.example", termsDays: 15 },
];

const VENDORS: Array<{ name: string; email: string; termsDays: number; track1099: boolean }> = [
  { name: "Union Square Properties", email: "billing@unionsquareprop.example", termsDays: 0, track1099: false },
  { name: "CloudSuite Software", email: "billing@cloudsuite.example", termsDays: 15, track1099: false },
  { name: "Dana Reyes Freelance Design", email: "dana@danareyesdesign.example", termsDays: 15, track1099: true },
  { name: "Bright Print Supply", email: "orders@brightprintsupply.example", termsDays: 30, track1099: false },
  { name: "Harbor Business Insurance", email: "billing@harborbizins.example", termsDays: 30, track1099: false },
];

interface LineTemplate { description: string; quantity: string; unitPrice: string }

// ---------------------------------------------------------------------------
// Inventory catalog — the studio's small print-and-frame line.
//
// Riverbend is a design studio, so its stock is printed goods, frames and
// packaging: it sells framed prints alongside the design work, which is a
// completely ordinary sideline for a studio and keeps the fiction honest.
//
// The foil seal is deliberately SUB-CENT ($0.0042 each, bought 5,000 at a
// time). Sub-cent unit costs are real, they are the reason
// db/migrations/0018_inventory.sql gave inventory_movements.unit_cost six
// decimal places, and the Items screen's $0.0042-style formatting has nothing
// to show unless the sample actually holds one.
//
// Every quantity/cost below is chosen so the weighted average lands EXACTLY —
// no rounding anywhere — which makes the numbers hand-checkable:
//   print  (40 @ 14.00 + 24 @ 15.50) / 64  = 932.00  / 64   = 14.5625
//   frame  (30 @ 22.50 + 18 @ 24.00) / 48  = 1107.00 / 48   = 23.0625
//   box    (100 @ 1.85 + 150 @ 2.10) / 250 = 500.00  / 250  = 2.00
//   seal   (5000 @ .0042 + 3000 @ .0060) / 8000 = 39.00 / 8000 = 0.004875
// The box is the one a visitor should look at: neither $1.85 nor $2.10, but
// $2.00 — which is the entire point of average costing and impossible to see
// from a single receipt.
// ---------------------------------------------------------------------------

interface StockedItemSpec {
  key: string;
  name: string;
  sku: string;
  description: string;
  purchaseCost: string;
  /** Omitted for a part that is never sold on its own. */
  salesPrice?: string;
}

const STOCKED_ITEMS: readonly StockedItemSpec[] = [
  {
    key: "print", name: "Fine Art Print — 18x24", sku: "PRT-1824",
    description: "Archival pigment print on cotton rag, unframed.",
    purchaseCost: "14.00", salesPrice: "48.00",
  },
  {
    key: "frame", name: "Oak Gallery Frame — 18x24", sku: "FRM-1824",
    description: "Solid oak frame with museum glass, ready to hang.",
    purchaseCost: "22.50", salesPrice: "65.00",
  },
  {
    key: "box", name: "Kraft Mailer Box — Large", sku: "BOX-LG",
    description: "Rigid kraft shipping box sized for a framed 18x24.",
    purchaseCost: "1.85", salesPrice: "4.00",
  },
  {
    key: "seal", name: "Foil Seal Sticker", sku: "STK-FOIL",
    description: "Riverbend foil seal for closing packaging. Bought by the roll.",
    purchaseCost: "0.0042",
  },
];

const ASSEMBLY_ITEM = {
  name: "Framed Print, Boxed — 18x24",
  sku: "KIT-1824",
  description: "A print in an oak frame, sealed and boxed ready to ship.",
  salesPrice: "145.00",
} as const;

const SERVICE_ITEM = {
  name: "Studio Consultation — hourly",
  sku: "SVC-HOUR",
  description: "On-site art selection and hanging plan. Nothing to stock.",
  salesPrice: "125.00",
} as const;

/** What one Framed Print, Boxed is made from. */
const BILL_OF_MATERIALS: ReadonlyArray<{ key: string; qtyPer: string }> = [
  { key: "print", qtyPer: "1" },
  { key: "frame", qtyPer: "1" },
  { key: "box", qtyPer: "1" },
  { key: "seal", qtyPer: "2" },
];

interface ReceiptLineSpec { key: string; quantity: string; unitCost: string }

/** Ordered on the purchase order and received by entering the bill. */
const FIRST_RECEIPT: readonly ReceiptLineSpec[] = [
  { key: "print", quantity: "40", unitCost: "14.00" },
  { key: "frame", quantity: "30", unitCost: "22.50" },
  { key: "box", quantity: "100", unitCost: "1.85" },
  { key: "seal", quantity: "5000", unitCost: "0.0042" },
];

/** A later restock at HIGHER prices — this is what turns "average cost" from
 *  a synonym for "what we paid" into a visibly weighted number. */
const SECOND_RECEIPT: readonly ReceiptLineSpec[] = [
  { key: "print", quantity: "24", unitCost: "15.50" },
  { key: "frame", quantity: "18", unitCost: "24.00" },
  { key: "box", quantity: "150", unitCost: "2.10" },
  { key: "seal", quantity: "3000", unitCost: "0.0060" },
];

const INVOICE_LINE_CATALOG: LineTemplate[] = [
  { description: "Brand identity package", quantity: "1", unitPrice: "2400.00" },
  { description: "Website design — 40 hrs @ $95", quantity: "40", unitPrice: "95.00" },
  { description: "Logo design", quantity: "1", unitPrice: "850.00" },
  { description: "Social media graphics package", quantity: "1", unitPrice: "600.00" },
  { description: "Monthly retainer — marketing services", quantity: "1", unitPrice: "1500.00" },
  { description: "Business card design", quantity: "1", unitPrice: "225.00" },
  { description: "Email template design — 6 templates", quantity: "6", unitPrice: "150.00" },
  { description: "SEO audit and recommendations", quantity: "1", unitPrice: "950.00" },
  { description: "Print collateral — brochure design", quantity: "1", unitPrice: "780.00" },
  { description: "Packaging design revisions — 3 hrs", quantity: "3", unitPrice: "125.00" },
];

// ---------------------------------------------------------------------------
// main entry point
// ---------------------------------------------------------------------------

export async function seedSampleCompany(
  app: FastifyInstance, adminPool: Pool,
): Promise<SeedSampleCompanyResult> {
  // -------------------------------------------------------------------------
  // Refusal check — a read, not a write, so it doesn't count against the
  // "one direct SQL write" note above: no route exposes "does the sample
  // company already exist", and this has to run before anything else.
  // -------------------------------------------------------------------------
  const existing = await adminPool.query<{ id: string }>(
    "SELECT id FROM companies WHERE name = $1 OR is_sample = true LIMIT 1",
    [COMPANY_NAME],
  );
  if ((existing.rowCount ?? 0) > 0) {
    return { ok: false, status: 409, error: "The sample company already exists." };
  }

  const rng = makeRng(RNG_SEED);
  const today = new Date();

  // -------------------------------------------------------------------------
  // 1. Company + starter chart, both through real routes.
  // -------------------------------------------------------------------------
  const created = await app.inject({
    method: "POST", url: "/api/companies", payload: { name: COMPANY_NAME },
  });
  if (created.statusCode >= 400) {
    throw new Error(`sample seeder: POST /api/companies failed (${created.statusCode}): ${created.body}`);
  }
  const companyId = (created.json() as { id: string }).id;

  // The one deliberate direct-SQL write — see this file's header and
  // db/migrations/0014's header for why.
  await adminPool.query("UPDATE companies SET is_sample = true WHERE id = $1", [companyId]);

  const ctx: Ctx = { app, companyId, rng };

  await apiPost(ctx, "/api/c/setup/fresh", { template: "small-business" });

  const grouped = (await apiGet(ctx, "/api/c/accounts?grouped=1")) as GroupedAccounts;
  const checkingId = findAccountId(grouped, "Checking Account");
  const savingsId = findAccountId(grouped, "Savings Account");
  const serviceIncomeId = findAccountId(grouped, "Service Income");
  const otherIncomeId = findAccountId(grouped, "Other Income");
  const ownerEquityId = findAccountId(grouped, "Owner's Equity");
  const ownerDrawId = findAccountId(grouped, "Owner's Distributions");
  const rentId = findAccountId(grouped, "Rent");
  const softwareId = findAccountId(grouped, "Software Subscriptions");
  const contractLaborId = findAccountId(grouped, "Contract Labor");
  const officeSuppliesId = findAccountId(grouped, "Office Supplies");
  const insuranceId = findAccountId(grouped, "Insurance");
  const bankFeesId = findAccountId(grouped, "Bank Fees");
  const mealsId = findAccountId(grouped, "Meals and Entertainment");
  const eduId = findAccountId(grouped, "Continuing Education");
  const postageId = findAccountId(grouped, "Postage and Shipping");

  // -------------------------------------------------------------------------
  // 2. Customers + vendors.
  // -------------------------------------------------------------------------
  const customerIds: string[] = [];
  for (const c of CUSTOMERS) {
    const res = await apiPost(ctx, "/api/c/customers", {
      name: c.name, email: c.email, termsDays: c.termsDays,
    });
    customerIds.push(res.id as string);
  }

  const vendorIds: string[] = [];
  for (const v of VENDORS) {
    const res = await apiPost(ctx, "/api/c/vendors", {
      name: v.name, email: v.email, termsDays: v.termsDays, track1099: v.track1099,
    });
    vendorIds.push(res.id as string);
  }
  const [unionSquareId, cloudSuiteId, danaReyesId, brightPrintId, harborInsuranceId] = vendorIds as [
    string, string, string, string, string,
  ];

  // -------------------------------------------------------------------------
  // checkingTouches: every posted entry that carries exactly one line on the
  // Checking Account, tracked as we create it (never re-derived by querying
  // the DB — see the file header on why everything routes through the API).
  // This feeds the "last month" bank statement's engineered ~60% auto-suggest
  // rate: real matches are lines whose (date, amount) exactly reproduce one
  // of these; the rest are invented bank-only activity with no match.
  // -------------------------------------------------------------------------
  const checkingTouches: Array<{ date: Date; amount: string; description: string }> = [];

  /** Clamped here, once, rather than at every call site — see notFuture().
   *  The DUE date is deliberately left alone: an unpaid invoice due next week
   *  is what fills the aging report's "current" bucket. */
  const postingDate = (d: Date): Date => notFuture(d, today);

  async function createAndIssueInvoice(
    customerId: string, invoiceDate: Date, dueDate: Date | null, lines: LineTemplate[],
  ): Promise<{ invoiceId: string; total: string }> {
    const res = await apiPost(ctx, "/api/c/invoices", {
      customerId,
      invoiceDate: isoDate(postingDate(invoiceDate)),
      dueDate: dueDate ? isoDate(dueDate) : undefined,
      lines: lines.map((l) => ({
        description: l.description, quantity: l.quantity, unitPrice: l.unitPrice,
        incomeAccountId: serviceIncomeId,
      })),
    });
    await apiPost(ctx, `/api/c/invoices/${res.invoiceId}/issue`, {});
    // res.total is a numeric(19,4)-formatted string ("2400.0000") — every
    // *Schema in payments.ts/bill-payments.ts only accepts up to 2 decimal
    // places, so this is normalized right here, once, rather than at every
    // call site that goes on to use it as a payment amount.
    return { invoiceId: res.invoiceId, total: toTwoDecimals(res.total) };
  }

  async function receivePayment(
    customerId: string, rawDate: Date, amount: string, applications: Array<{ invoiceId: string; amount: string }>,
  ): Promise<void> {
    // The CLAMPED date goes into checkingTouches too, so the statement CSV
    // built from those touches always reproduces the date actually posted.
    const date = postingDate(rawDate);
    await apiPost(ctx, "/api/c/payments", {
      customerId, paymentDate: isoDate(date), amount, depositAccountId: checkingId, applications,
    });
    checkingTouches.push({ date, amount, description: "Customer payment" });
  }

  async function createAndIssueBill(
    vendorId: string, refNo: string, billDate: Date, dueDate: Date, categoryAccountId: string,
    description: string, unitPrice: string,
  ): Promise<{ billId: string; total: string }> {
    const res = await apiPost(ctx, "/api/c/bills", {
      vendorId, refNo, billDate: isoDate(postingDate(billDate)), dueDate: isoDate(dueDate),
      lines: [{ description, quantity: "1", unitPrice, categoryAccountId }],
    });
    await apiPost(ctx, `/api/c/bills/${res.billId}/issue`, {});
    // See the identical note in createAndIssueInvoice above.
    return { billId: res.billId, total: toTwoDecimals(res.total) };
  }

  async function payBillElectronic(vendorId: string, billId: string, amount: string, rawDate: Date): Promise<void> {
    const date = postingDate(rawDate);
    await apiPost(ctx, "/api/c/bill-payments", {
      vendorId, paymentDate: isoDate(date), amount, moneyAccountId: checkingId, method: "electronic",
      applications: [{ billId, amount }],
    });
    checkingTouches.push({ date, amount: `-${amount}`, description: "Vendor payment" });
  }

  async function payBillCheckQueued(vendorId: string, billId: string, amount: string, rawDate: Date): Promise<string> {
    const date = postingDate(rawDate);
    const res = await apiPost(ctx, "/api/c/bill-payments", {
      vendorId, paymentDate: isoDate(date), amount, moneyAccountId: checkingId, method: "check", toPrint: true,
      applications: [{ billId, amount }],
    });
    checkingTouches.push({ date, amount: `-${amount}`, description: "Vendor payment (check)" });
    return res.paymentId as string;
  }

  async function moneyIn(
    rawDate: Date, amount: string, intoAccountId: string, categoryAccountId: string, who?: string, memo?: string,
  ): Promise<void> {
    const date = postingDate(rawDate);
    await apiPost(ctx, "/api/c/entries", {
      kind: "money-in", date: isoDate(date), amount, intoAccountId, categoryAccountId, who, memo,
    });
    if (intoAccountId === checkingId) checkingTouches.push({ date, amount, description: memo ?? who ?? "Deposit" });
  }
  async function moneyOut(
    rawDate: Date, amount: string, fromAccountId: string, categoryAccountId: string, who?: string, memo?: string,
  ): Promise<void> {
    const date = postingDate(rawDate);
    await apiPost(ctx, "/api/c/entries", {
      kind: "money-out", date: isoDate(date), amount, fromAccountId, categoryAccountId, who, memo,
    });
    if (fromAccountId === checkingId) {
      checkingTouches.push({ date, amount: `-${amount}`, description: memo ?? who ?? "Payment" });
    }
  }
  async function transfer(rawDate: Date, amount: string, fromAccountId: string, toAccountId: string, memo: string): Promise<void> {
    const date = postingDate(rawDate);
    await apiPost(ctx, "/api/c/entries", { kind: "transfer", date: isoDate(date), amount, fromAccountId, toAccountId, memo });
    if (fromAccountId === checkingId) checkingTouches.push({ date, amount: `-${amount}`, description: memo });
    if (toAccountId === checkingId) checkingTouches.push({ date, amount, description: memo });
  }

  const monthDate = (offset: number): Date => startOfMonth(addMonths(today, -offset));

  // -------------------------------------------------------------------------
  // 3. Invoices — ~25 total, mix of paid / 2 partial / 2 overdue open /
  //    1 with a customer credit / a handful open-current.
  // -------------------------------------------------------------------------
  let invoicesPaid = 0;
  let invoicesPartial = 0;
  let invoicesOverdueOpen = 0;
  const INVOICE_COUNT = 25;
  const invoiceOffsets = Array.from({ length: INVOICE_COUNT }, (_, idx) =>
    Math.max(0, 13 - Math.floor((idx * 14) / INVOICE_COUNT)));

  for (let idx = 0; idx < INVOICE_COUNT; idx++) {
    const offset = invoiceOffsets[idx]!;
    const customerId = pick(rng, customerIds);
    const lineCount = randInt(rng, 1, 2);
    const lines: LineTemplate[] = [];
    for (let n = 0; n < lineCount; n++) lines.push(pick(rng, INVOICE_LINE_CATALOG));

    if (idx === 0 || idx === 1) {
      // 2 overdue, open, unpaid: dated well in the past with short terms so
      // the due date has clearly already passed relative to `today`.
      const invoiceDate = addDays(monthDate(offset), randInt(rng, 1, 10));
      const dueDate = addDays(invoiceDate, 15);
      await createAndIssueInvoice(customerId, invoiceDate, dueDate, lines);
      invoicesOverdueOpen++;
      continue;
    }
    if (idx === 2 || idx === 3) {
      // 2 partial payments: pay less than the total, leave it partial.
      const invoiceDate = addDays(monthDate(offset), randInt(rng, 1, 20));
      const { invoiceId, total } = await createAndIssueInvoice(customerId, invoiceDate, null, lines);
      const paymentAmount = decimalFromCents(centsFromDecimal(total) / 2n);
      await receivePayment(customerId, addDays(invoiceDate, randInt(rng, 3, 10)), paymentAmount, [
        { invoiceId, amount: paymentAmount },
      ]);
      invoicesPartial++;
      continue;
    }
    if (idx === 4) {
      // 1 with a customer credit: pay MORE than the invoice total, applying
      // only the total — the remainder becomes unapplied customer credit.
      const invoiceDate = addDays(monthDate(offset), randInt(rng, 1, 20));
      const { invoiceId, total } = await createAndIssueInvoice(customerId, invoiceDate, null, lines);
      const paymentAmount = addAmounts(total, "150.00");
      await receivePayment(customerId, addDays(invoiceDate, randInt(rng, 2, 8)), paymentAmount, [
        { invoiceId, amount: total },
      ]);
      invoicesPaid++;
      continue;
    }

    // Everything else: mostly paid in full shortly after issue; a handful
    // (roughly every 4th) left open and not yet due ("current" aging
    // bucket) — those deliberately IGNORE `offset` and use a genuinely
    // recent invoiceDate instead (a due date computed from a 9-months-ago
    // invoiceDate would already be overdue no matter how many terms days
    // get added, which is not what "current" means).
    const leaveOpen = offset > 0 && idx % 4 === 0;
    if (leaveOpen) {
      const invoiceDate = addDays(today, -randInt(rng, 1, 20));
      const dueDate = addDays(invoiceDate, 45); // comfortably in the future
      await createAndIssueInvoice(customerId, invoiceDate, dueDate, lines);
      continue;
    }
    const invoiceDate = addDays(monthDate(offset), randInt(rng, 1, 26));
    const { invoiceId, total } = await createAndIssueInvoice(customerId, invoiceDate, null, lines);
    await receivePayment(customerId, addDays(invoiceDate, randInt(rng, 2, 12)), total, [
      { invoiceId, amount: total },
    ]);
    invoicesPaid++;
  }

  // -------------------------------------------------------------------------
  // 4. Bills — recurring rent + software (12 months each), a handful of
  //    one-off vendor bills (contract labor / supplies / insurance),
  //    including 2 due-within-7-days and 1 overdue, all unpaid.
  // -------------------------------------------------------------------------
  let billCount = 0;
  let billPaymentCount = 0;
  const checkQueuePaymentIds: string[] = [];
  let refCounter = 1000;
  const nextRef = (prefix: string): string => `${prefix}-${refCounter++}`;

  // Recurring rent: paid every month, offsets 12..1 (12 bills).
  for (let offset = 12; offset >= 1; offset--) {
    const billDate = addDays(monthDate(offset), 2);
    const dueDate = billDate; // net 0
    const { billId, total } = await createAndIssueBill(
      unionSquareId, nextRef("RENT"), billDate, dueDate, rentId, "Studio rent — monthly", "1800.00",
    );
    billCount++;
    await payBillElectronic(unionSquareId, billId, total, addDays(billDate, 1));
    billPaymentCount++;
  }

  // Recurring software: paid every month EXCEPT offset 2, which is left
  // unpaid and is therefore the "1 overdue" bill (due date well in the past).
  for (let offset = 12; offset >= 1; offset--) {
    const billDate = addDays(monthDate(offset), 4);
    const dueDate = addDays(billDate, 15);
    const { billId, total } = await createAndIssueBill(
      cloudSuiteId, nextRef("SW"), billDate, dueDate, softwareId, "Design software subscription", "89.00",
    );
    billCount++;
    if (offset === 2) continue; // deliberately unpaid -> overdue
    await payBillElectronic(cloudSuiteId, billId, total, addDays(billDate, 2));
    billPaymentCount++;
  }

  // One-off: Dana Reyes contract labor (1099) — both paid by check, queued
  // for the print run.
  for (const offset of [9, 4]) {
    const billDate = addDays(monthDate(offset), randInt(rng, 5, 15));
    const dueDate = addDays(billDate, 15);
    const { billId, total } = await createAndIssueBill(
      danaReyesId, nextRef("DR"), billDate, dueDate, contractLaborId,
      "Freelance illustration — contract work", "650.00",
    );
    billCount++;
    const paymentId = await payBillCheckQueued(danaReyesId, billId, total, addDays(billDate, 5));
    billPaymentCount++;
    checkQueuePaymentIds.push(paymentId);
  }

  // One-off: Bright Print Supply — one paid (also queued as a check, to make
  // three), one left due-within-7-days, unpaid.
  {
    const paidBillDate = addDays(monthDate(7), randInt(rng, 3, 15));
    const paidDueDate = addDays(paidBillDate, 30);
    const { billId, total } = await createAndIssueBill(
      brightPrintId, nextRef("BP"), paidBillDate, paidDueDate, officeSuppliesId,
      "Print materials and office supplies", "145.30",
    );
    billCount++;
    const paymentId = await payBillCheckQueued(brightPrintId, billId, total, addDays(paidBillDate, 4));
    billPaymentCount++;
    checkQueuePaymentIds.push(paymentId);

    const dueSoonBillDate = addDays(today, -20);
    const dueSoonDueDate = addDays(today, 5);
    await createAndIssueBill(
      brightPrintId, nextRef("BP"), dueSoonBillDate, dueSoonDueDate, officeSuppliesId,
      "Print materials and office supplies", "212.75",
    );
    billCount++;
  }

  // One-off: Harbor Business Insurance — two paid, one left due-within-7-days.
  for (const offset of [10, 6]) {
    const billDate = addDays(monthDate(offset), randInt(rng, 1, 10));
    const dueDate = addDays(billDate, 30);
    const { billId, total } = await createAndIssueBill(
      harborInsuranceId, nextRef("HBI"), billDate, dueDate, insuranceId,
      "Quarterly business insurance premium", "420.00",
    );
    billCount++;
    await payBillElectronic(harborInsuranceId, billId, total, addDays(billDate, 3));
    billPaymentCount++;
  }
  {
    const dueSoonBillDate = addDays(today, -25);
    const dueSoonDueDate = addDays(today, 3);
    await createAndIssueBill(
      harborInsuranceId, nextRef("HBI"), dueSoonBillDate, dueSoonDueDate, insuranceId,
      "Quarterly business insurance premium", "420.00",
    );
    billCount++;
  }

  // Check-print run: exactly 3 queued check payments, assigned sequential
  // numbers in one run — the plan's "one checks-to-print run of 3 checks".
  await apiPost(ctx, "/api/c/checks/print", {
    accountId: checkingId, paymentIds: checkQueuePaymentIds,
  });

  // -------------------------------------------------------------------------
  // Two more checks, WRITTEN BUT NOT PRINTED.
  //
  // Deliberately created AFTER the print run above, so no future edit can
  // sweep them into it: this block cannot be reordered above that call without
  // the queue emptying again, which is exactly how it emptied the first time.
  //
  // Until these existed the sample could not demonstrate check printing AT
  // ALL. Every check it held had already been printed, so "Checks to print"
  // read "No checks queued to print" for every account, and the print route
  // correctly refused each already-printed payment. Check printing is
  // something QuickBooks charges money for; showing a stranger an empty queue
  // removes a differentiator from the tour and from the marketing shot.
  //
  // TWO, not one, so the three-to-a-page check stock has something to lay out
  // and the queue reads like a Friday-afternoon run rather than one orphan.
  // Together with the three printed above, the sample now shows BOTH states:
  // a completed run with its numbers assigned, and a queue still waiting.
  //
  // Both go to vendors this studio already pays by cheque — the three printed
  // ones went to these same two — so the story stays consistent: most
  // suppliers are paid electronically, the freelancer and the print shop get a
  // cheque.
  //
  // Dated in the CURRENT month, which is what makes them plausible as still
  // waiting (a cheque written in March and never printed would be a bug in the
  // story). That also puts them after last month's reconciliation, which is
  // the only window a money-account posting can occupy if this ever has to be
  // replayed against an already-reconciled company.
  // -------------------------------------------------------------------------
  const queuedCheckPaymentIds: string[] = [];
  {
    const billDate = addDays(today, -9);
    const { billId, total } = await createAndIssueBill(
      danaReyesId, nextRef("DR"), billDate, addDays(billDate, 15), contractLaborId,
      "Freelance illustration — contract work", "650.00",
    );
    billCount++;
    queuedCheckPaymentIds.push(
      await payBillCheckQueued(danaReyesId, billId, total, addDays(today, -2)),
    );
    billPaymentCount++;
  }
  {
    const billDate = addDays(today, -7);
    const { billId, total } = await createAndIssueBill(
      brightPrintId, nextRef("BP"), billDate, addDays(billDate, 30), officeSuppliesId,
      "Print materials and office supplies", "318.60",
    );
    billCount++;
    queuedCheckPaymentIds.push(
      await payBillCheckQueued(brightPrintId, billId, total, addDays(today, -2)),
    );
    billPaymentCount++;
  }

  // -------------------------------------------------------------------------
  // 5. ~20 manual entries spread across months (money-in / money-out / transfer).
  // -------------------------------------------------------------------------
  let manualEntryCount = 0;
  const MANUAL_ENTRY_OFFSETS = [11, 10, 9, 8, 8, 7, 6, 6, 5, 5, 4, 4, 3, 3, 2, 2, 1, 1, 1, 0];
  for (let idx = 0; idx < MANUAL_ENTRY_OFFSETS.length; idx++) {
    const offset = MANUAL_ENTRY_OFFSETS[idx]!;
    const date = addDays(monthDate(offset), randInt(rng, 1, 26));
    const kind = idx % 5;
    if (kind === 0) {
      await moneyOut(date, "15.00", checkingId, bankFeesId, "Bank", "Monthly service fee");
    } else if (kind === 1) {
      await moneyOut(date, "68.40", checkingId, mealsId, "Client lunch");
    } else if (kind === 2) {
      await moneyIn(date, "42.15", checkingId, otherIncomeId, "Bank", "Interest earned");
    } else if (kind === 3 && idx % 10 === 3) {
      await moneyOut(date, "1200.00", checkingId, ownerDrawId, "Owner", "Owner draw");
    } else if (kind === 3) {
      await moneyOut(date, "295.00", checkingId, eduId, "Design conference", "Continuing education");
    } else {
      if (idx % 8 === 4) {
        await transfer(date, "2000.00", checkingId, savingsId, "Moved to savings");
      } else {
        await moneyOut(date, "22.15", checkingId, postageId, "Postage and shipping");
      }
    }
    manualEntryCount++;
  }
  // One owner contribution near the start of the history — the money the
  // studio was started with.
  //
  // $25,000, not the $5,000 this originally said. At $5,000 the sample ran its
  // Checking Account into overdraft and finished the fourteen months about
  // $2,500 in the red, which is the FIRST number a stranger sees: the home
  // screen leads with the bank balance. A demo business that looks insolvent
  // argues against the product on the product's own front page. $25,000 is what
  // a founder actually puts into a small design studio, it is a round number
  // rather than a figure reverse-engineered to land somewhere flattering, and
  // it leaves roughly $18,000 in checking after fourteen months of trading and
  // ~$2,600 of stock purchases — a going concern, not a windfall.
  await moneyIn(addDays(monthDate(13), 3), "25000.00", checkingId, ownerEquityId, "Owner", "Owner contribution");
  manualEntryCount++;

  // -------------------------------------------------------------------------
  // 6. Inventory — items, a purchase order received by entering the bill, a
  //    second receipt at a different cost, an assembly build, and two sales
  //    that put cost of goods sold on the P&L.
  //
  //    WHY IT SITS HERE, between the manual entries and the bank statement:
  //    the ordering constraint in this file's header. Every entry this chapter
  //    posts touches the Checking Account (the two supplier bills are paid
  //    electronically), and assert_not_reconciled() refuses a new entry into a
  //    money account on or before that account's reconciled_through. Section 7
  //    is where the one reconciliation happens, so everything here has to be
  //    posted first — and every date below is deliberately OLDER than last
  //    month or newer than it, never inside it, so the engineered ~60%
  //    auto-suggest rate of that statement is not disturbed either.
  //
  //    NO rng() is consumed in this chapter, on purpose: the seeded LCG's
  //    stream is shared with sections 3-5 above, so drawing from it here would
  //    silently rewrite every invoice, bill and manual entry that comes after
  //    it in the stream. Fixed dates and a literal catalog instead.
  //
  //    Sales tax stays OFF. Riverbend is deliberately a non-taxed services
  //    business (the launch spec's decision) so the tax features show as
  //    switched off; selling goods does not change that here.
  // -------------------------------------------------------------------------
  const inventory = await seedInventoryChapter({
    app, companyId, today,
    salesIncomeAccountId: findAccountId(grouped, "Sales Income"),
    serviceIncomeAccountId: serviceIncomeId,
    supplierVendorId: brightPrintId,
    framedPrintCustomerId: customerIds[3]!, // Cedar Ridge Realty
    loosePrintCustomerId: customerIds[0]!, // Maple & Co. Bakery
    nextRefNo: () => nextRef("BP"),
    payBill: payBillElectronic,
    receivePayment,
  });
  billCount += inventory.bills;
  billPaymentCount += inventory.billPayments;
  invoicesPaid += inventory.invoicesPaid;

  // -------------------------------------------------------------------------
  // 7. Bank statement — last full month, engineered so ~60% of lines
  //    auto-suggest (their (date, amount) exactly reproduces a tracked
  //    checkingTouches entry from that same month), confirmed via
  //    confirm-all; the rest are new bank-only activity, recorded via
  //    create-entry; then reconciled to the penny using the live book
  //    balance as the statement's ending balance.
  // -------------------------------------------------------------------------
  const lastMonthStart = monthDate(1);
  const lastMonthEnd = endOfMonth(lastMonthStart);
  const inLastMonth = checkingTouches.filter((t) => t.date >= lastMonthStart && t.date <= lastMonthEnd);
  // Take up to 6 distinct-amount touches from that month as the "known"
  // matches — distinct amounts avoid two candidates for the same statement
  // line (the matching engine requires EXACTLY one candidate to auto-suggest).
  const seenAmounts = new Set<string>();
  const knownMatches: typeof checkingTouches = [];
  for (const t of inLastMonth) {
    if (seenAmounts.has(t.amount)) continue;
    seenAmounts.add(t.amount);
    knownMatches.push(t);
    if (knownMatches.length === 6) break;
  }
  const phantomLines = [
    { date: addDays(lastMonthStart, 6), amount: "-18.42", description: "DEBIT CARD PURCHASE - OFFICE SUPPLY CO" },
    { date: addDays(lastMonthStart, 12), amount: "-42.17", description: "ACH DEBIT - UTILITY CO" },
    { date: addDays(lastMonthStart, 18), amount: "3.11", description: "INTEREST PAID" },
    { date: addDays(lastMonthStart, 22), amount: "-64.29", description: "DEBIT CARD PURCHASE - PRINT SHOP" },
  ];
  const statementRows = [
    ...knownMatches.map((t) => ({ date: t.date, description: t.description.toUpperCase(), amount: t.amount })),
    ...phantomLines,
  ].sort((a, b) => a.date.getTime() - b.date.getTime());

  const csvHeader = "Date,Description,Amount\n";
  const csvBody = statementRows.map((r) => `${csvDate(r.date)},${r.description},${r.amount}`).join("\n");
  const statementCsv = `${csvHeader}${csvBody}\n`;

  const importRes = await apiPostMultipart(
    ctx, "/api/c/bank/statements", Buffer.from(statementCsv), "riverbend-checking-last-month.csv",
    {
      accountId: checkingId,
      headerRows: "1", dateCol: "0", descriptionCol: "1", amountMode: "signed",
      amountCol: "2", dateFormat: "MDY",
    },
  );
  const lastMonthStatementId = importRes.statementId as string;

  await apiPost(ctx, `/api/c/bank/statements/${lastMonthStatementId}/confirm-all`, {});

  const linesRes = await apiGet(ctx, `/api/c/bank/statements/${lastMonthStatementId}/lines?status=unmatched`);
  for (const line of linesRes.lines as Array<{ id: string; amount: string }>) {
    const categoryAccountId = line.amount.startsWith("-") ? bankFeesId : otherIncomeId;
    await apiPost(ctx, `/api/c/bank/lines/${line.id}/create-entry`, {
      categoryAccountId, memo: "Recorded from bank statement",
    });
  }

  const throughDate = isoDate(lastMonthEnd);
  const bookBalanceRes = await apiGet(ctx, `/api/c/bank/accounts/${checkingId}/book-balance?asOf=${throughDate}`);
  await apiPost(ctx, `/api/c/bank/statements/${lastMonthStatementId}/reconcile`, {
    endingBalance: toTwoDecimals(bookBalanceRes.bookBalance), throughDate,
  });

  // -------------------------------------------------------------------------
  // 8. Current month — a second, small, deliberately UNRECONCILED statement,
  //    so the home screen shows a "needs review" state.
  // -------------------------------------------------------------------------
  const currentMonthStart = monthDate(0);
  // Clamped like every posting date, for the same reason: this statement is
  // what the reconcile-first home screen shows, and a bank line dated next week
  // reads as a broken program. These are imported statement rows, not entries,
  // so nothing posts — but a stranger reading the screen cannot tell that, and
  // should not have to. Seeded on the 1st or 2nd of a month the three rows
  // collapse onto the same day, which is what a real short statement looks like
  // anyway.
  const openRows = [
    { date: notFuture(addDays(currentMonthStart, 2), today), description: "DEBIT CARD PURCHASE - COFFEE SHOP", amount: "-6.75" },
    { date: notFuture(addDays(currentMonthStart, 5), today), description: "ACH DEBIT - PHONE CO", amount: "-89.10" },
    { date: notFuture(addDays(currentMonthStart, 9), today), description: "DEPOSIT - CLIENT PAYMENT", amount: "450.00" },
  ];
  const openCsv = `${csvHeader}${openRows.map((r) => `${csvDate(r.date)},${r.description},${r.amount}`).join("\n")}\n`;
  await apiPostMultipart(ctx, "/api/c/bank/statements", Buffer.from(openCsv), "riverbend-checking-current-month.csv", {
    accountId: checkingId,
  });

  return {
    ok: true,
    companyId,
    name: COMPANY_NAME,
    counts: {
      customers: customerIds.length,
      vendors: vendorIds.length,
      invoices: INVOICE_COUNT + inventory.invoices,
      invoicesPaid,
      invoicesPartial,
      invoicesOverdueOpen,
      bills: billCount,
      billPayments: billPaymentCount,
      checksPrinted: checkQueuePaymentIds.length,
      checksQueuedToPrint: queuedCheckPaymentIds.length,
      manualEntries: manualEntryCount,
      reconciledMonths: 1,
      items: inventory.items,
      stockedItems: inventory.stockedItems,
      purchaseOrders: inventory.purchaseOrders,
      builds: inventory.builds,
    },
  };
}

// ---------------------------------------------------------------------------
// The inventory chapter, exported.
//
// It is a separate, exported function for one reason: it also has to be
// runnable against a company that was seeded BEFORE this chapter existed (a
// developer's dev database, where Riverbend already has 129 entries and the
// seeder's own 409 refusal means it can never run again). Sharing the function
// rather than the prose is what makes a freshly-seeded sample and a
// retro-fitted one the same books, instead of two hand-copied stories that
// drift the first time one of them is edited.
//
// The caller supplies the things that are the SEEDER's business, not this
// chapter's: which vendor, which customers, how vendor reference numbers are
// numbered, and how a payment is recorded (the seeder also tracks each one as
// a checking-account touch for its bank statement; a retro-fit script does
// not, and must not).
//
// It draws NO random numbers — see the section 6 comment in the seeder.
// ---------------------------------------------------------------------------

export interface InventoryChapterOptions {
  app: FastifyInstance;
  companyId: string;
  /** Everything is dated relative to this, so the sample never looks stale. */
  today: Date;
  /** Income account the stocked items sell through ("Sales Income"). */
  salesIncomeAccountId: string;
  /** Income account the service item bills its time to ("Service Income"). */
  serviceIncomeAccountId: string;
  /** The supplier both stock receipts come from. */
  supplierVendorId: string;
  /** Buys the framed prints and pays in full. */
  framedPrintCustomerId: string;
  /** Buys loose prints and frames and leaves the invoice open. */
  loosePrintCustomerId: string;
  /** The vendor's own reference number for each supplier bill. */
  nextRefNo: () => string;
  payBill: (
    vendorId: string, billId: string, amount: string, date: Date,
  ) => Promise<void>;
  receivePayment: (
    customerId: string, date: Date, amount: string,
    applications: Array<{ invoiceId: string; amount: string }>,
  ) => Promise<void>;
}

export interface InventoryChapterCounts {
  items: number;
  stockedItems: number;
  purchaseOrders: number;
  builds: number;
  bills: number;
  billPayments: number;
  invoices: number;
  invoicesPaid: number;
}

export async function seedInventoryChapter(
  opts: InventoryChapterOptions,
): Promise<InventoryChapterCounts> {
  const ctx: ApiCtx = { app: opts.app, companyId: opts.companyId };
  const today = opts.today;
  const monthDate = (offset: number): Date => startOfMonth(addMonths(today, -offset));
  // Same rule as the seeder's own helpers — nothing is dated after today. Every
  // date below is already months in the past; this is here so it stays true if
  // one of them is ever edited. Due dates are deliberately NOT clamped.
  const postingDate = (d: Date): Date => notFuture(d, today);

  // The starter chart has no Inventory or Cost of Goods Sold account — the
  // 'general' template deliberately ships neither (server/src/templates/
  // small-business.ts), because most small businesses stock nothing. Adding
  // them through the real chart route is exactly what a user would do on the
  // day they started carrying stock, and it puts them in the conventional
  // QB-compatible slots (1300 asset, 5000 direct cost).
  const inventoryAccountId = (await apiPost(ctx, "/api/c/accounts", {
    name: "Inventory", type: "asset", subtype: "other-current-asset", code: "1300",
  })).id as string;
  const cogsAccountId = (await apiPost(ctx, "/api/c/accounts", {
    name: "Cost of Goods Sold", type: "expense", subtype: "cost-of-goods-sold", code: "5000",
  })).id as string;
  
  const itemIds = new Map<string, string>();
  for (const spec of STOCKED_ITEMS) {
    const res = await apiPost(ctx, "/api/c/items", {
      name: spec.name, sku: spec.sku, kind: "inventory",
      inventoryAccountId, cogsAccountId, incomeAccountId: opts.salesIncomeAccountId,
      purchaseCost: spec.purchaseCost, salesPrice: spec.salesPrice,
      description: spec.description,
    });
    itemIds.set(spec.key, res.id as string);
  }

  const assemblyItemId = (await apiPost(ctx, "/api/c/items", {
    name: ASSEMBLY_ITEM.name, sku: ASSEMBLY_ITEM.sku, kind: "inventory",
    inventoryAccountId, cogsAccountId, incomeAccountId: opts.salesIncomeAccountId,
    salesPrice: ASSEMBLY_ITEM.salesPrice, description: ASSEMBLY_ITEM.description,
  })).id as string;

  // A service item: no quantity, no inventory account, no cost account — so the
  // Items screen shows what a non-stocked line looks like next to the stocked
  // ones, and an invoice can mix the two.
  const serviceItemId = (await apiPost(ctx, "/api/c/items", {
    name: SERVICE_ITEM.name, sku: SERVICE_ITEM.sku, kind: "service",
    incomeAccountId: opts.serviceIncomeAccountId,
    salesPrice: SERVICE_ITEM.salesPrice, description: SERVICE_ITEM.description,
  })).id as string;

  await apiPut(ctx, `/api/c/items/${assemblyItemId}/components`, {
    components: BILL_OF_MATERIALS.map((c) => ({
      itemId: itemIds.get(c.key)!, qtyPer: c.qtyPer,
    })),
  });

  // --- Purchase order, then received BY ENTERING THE BILL ------------------
  // Spec as-built decision 1: there is no separate receipt document and no
  // second journal entry — /receive hands back a prefilled bill draft, and the
  // bill is what moves both the stock and the money, in one transaction.
  const poOrderDate = addDays(monthDate(5), 5);
  const purchaseOrder = await apiPost(ctx, "/api/c/purchase-orders", {
    vendorId: opts.supplierVendorId,
    orderDate: isoDate(postingDate(poOrderDate)),
    expectedDate: isoDate(addDays(poOrderDate, 10)),
    memo: "Print-and-frame restock",
    lines: FIRST_RECEIPT.map((l) => ({
      itemId: itemIds.get(l.key)!, quantity: l.quantity, unitCost: l.unitCost,
    })),
  });
  const purchaseOrderId = purchaseOrder.id as string;
  await apiPost(ctx, `/api/c/purchase-orders/${purchaseOrderId}/issue`, {});

  const receivePrefill = await apiPost(ctx, `/api/c/purchase-orders/${purchaseOrderId}/receive`, {});
  const receiptDate = addDays(poOrderDate, 7);
  const receiptBill = await apiPost(ctx, "/api/c/bills", {
    vendorId: receivePrefill.vendorId,
    refNo: opts.nextRefNo(),
    billDate: isoDate(postingDate(receiptDate)),
    dueDate: isoDate(addDays(receiptDate, 30)),
    memo: receivePrefill.memo,
    // Straight from the prefill, po_line_id and all — which is what closes the
    // purchase order out.
    lines: (receivePrefill.lines as Array<{
      poLineId: string | null; itemId: string | null; quantity: string; unitPrice: string;
    }>).map((l) => ({
      poLineId: l.poLineId ?? undefined,
      itemId: l.itemId ?? undefined,
      quantity: l.quantity,
      unitPrice: l.unitPrice,
    })),
  });
  await apiPost(ctx, `/api/c/bills/${receiptBill.billId}/issue`, {});
  await opts.payBill(
    opts.supplierVendorId, receiptBill.billId, toTwoDecimals(receiptBill.total), addDays(receiptDate, 20),
  );

  // --- Second receipt, at HIGHER unit costs --------------------------------
  // The whole reason this exists: after this bill the average cost of every
  // stocked part is a weighted average that matches neither receipt's price.
  // A viewer who only ever sees one receipt cannot tell average costing from
  // "the price we paid".
  const restockDate = addDays(monthDate(3), 8);
  const restockBill = await apiPost(ctx, "/api/c/bills", {
    vendorId: opts.supplierVendorId,
    refNo: opts.nextRefNo(),
    billDate: isoDate(postingDate(restockDate)),
    dueDate: isoDate(addDays(restockDate, 30)),
    memo: "Restock — supplier raised prices",
    lines: SECOND_RECEIPT.map((l) => ({
      itemId: itemIds.get(l.key)!, quantity: l.quantity, unitPrice: l.unitCost,
    })),
  });
  await apiPost(ctx, `/api/c/bills/${restockBill.billId}/issue`, {});
  await opts.payBill(
    opts.supplierVendorId, restockBill.billId, toTwoDecimals(restockBill.total), addDays(restockDate, 20),
  );

  // --- Build 12 framed prints ----------------------------------------------
  // The preview is called first for the same reason a user clicks it first:
  // it is a real route and driving it proves the parts list and the stock
  // suffice before anything posts.
  const buildDate = addDays(monthDate(3), 20);
  await apiPost(ctx, "/api/c/builds/preview", { assemblyItemId, qty: "12" });
  await apiPost(ctx, "/api/c/builds", {
    assemblyItemId, qty: "12", builtOn: isoDate(postingDate(buildDate)),
    memo: "Gallery-ready framed prints",
  });

  // --- Two sales, so cost of goods sold appears on the P&L ------------------
  // Cedar Ridge Realty buys framed prints for its offices, plus two hours of
  // hanging-plan consultation: one invoice, one stocked line and one service
  // line, which is what a mixed invoice looks like.
  const framedInvoiceDate = addDays(monthDate(2), 10);
  const framedInvoice = await apiPost(ctx, "/api/c/invoices", {
    customerId: opts.framedPrintCustomerId,
    invoiceDate: isoDate(postingDate(framedInvoiceDate)),
    dueDate: isoDate(addDays(framedInvoiceDate, 45)),
    memo: "Office art package",
    lines: [
      { itemId: assemblyItemId, quantity: "4", unitPrice: ASSEMBLY_ITEM.salesPrice },
      { itemId: serviceItemId, quantity: "2", unitPrice: SERVICE_ITEM.salesPrice },
    ],
  });
  await apiPost(ctx, `/api/c/invoices/${framedInvoice.invoiceId}/issue`, {});
  const framedInvoiceTotal = toTwoDecimals(framedInvoice.total);
  await opts.receivePayment(
    opts.framedPrintCustomerId, addDays(framedInvoiceDate, 12), framedInvoiceTotal,
    [{ invoiceId: framedInvoice.invoiceId, amount: framedInvoiceTotal }],
  );

  // Maple & Co. buys loose prints and frames for the bakery walls. Left OPEN
  // and recent, so the item history shows a sale that is still on the aging
  // report — and so the stocked lines are not all hidden behind paid invoices.
  const looseInvoiceDate = addDays(today, -3);
  const looseInvoice = await apiPost(ctx, "/api/c/invoices", {
    customerId: opts.loosePrintCustomerId,
    invoiceDate: isoDate(postingDate(looseInvoiceDate)),
    dueDate: isoDate(addDays(looseInvoiceDate, 15)),
    memo: "Prints and frames for the shop",
    lines: [
      { itemId: itemIds.get("print")!, quantity: "6", unitPrice: "48.00" },
      { itemId: itemIds.get("frame")!, quantity: "3", unitPrice: "65.00" },
    ],
  });
  await apiPost(ctx, `/api/c/invoices/${looseInvoice.invoiceId}/issue`, {});

  return {
    // 4 parts + 1 finished good + 1 service.
    items: STOCKED_ITEMS.length + 2,
    stockedItems: STOCKED_ITEMS.length + 1,
    purchaseOrders: 1,
    builds: 1,
    bills: 2,
    billPayments: 2,
    invoices: 2,
    invoicesPaid: 1,
  };
}
