/* lib.jsx — shared invoice math + selectors → window.LIB */
(function () {
  const round2 = n => Math.round((n + Number.EPSILON) * 100) / 100;

  /* Net for a single line: qty × unit price, less any per-line discount. */
  function lineNet(it) {
    const gross = (Number(it.qty) || 0) * (Number(it.rate) || 0);
    const disc = gross * (Number(it.discountPct) || 0) / 100;
    return round2(gross - disc);
  }
  /* Effective tax rate for a line: its own rate if set, else the invoice default. */
  function lineTax(it, inv) {
    return it.taxRate != null ? Number(it.taxRate) : (Number(inv.taxRate) || 0);
  }

  /* Totals with a VAT breakdown grouped by rate (FacturX-shaped).
     vatMode "reverse"/"exempt" forces effective VAT to 0 (rates overridden),
     preserving the net; "standard"/undefined keeps per-line behavior.
     EN 16931 BR-CO-17: each VAT row must equal rate x base, so nets are
     accumulated unrounded per rate group, bases reconciled to the taxable
     total by largest remainder, and tax rounded once per group. */
  function totals(inv) {
    const items = inv.items || [];
    const zeroVat = inv.vatMode === "reverse" || inv.vatMode === "exempt";
    const subtotal = round2(items.reduce((s, it) => s + lineNet(it), 0));
    const discount = round2(subtotal * (Number(inv.discountPct) || 0) / 100);
    const taxable = round2(subtotal - discount);
    const factor = subtotal > 0 ? (subtotal - discount) / subtotal : 1;
    const map = {};
    for (const it of items) {
      const rate = zeroVat ? 0 : lineTax(it, inv);
      const name = zeroVat ? "" : (it.taxName || inv.taxName || "");
      const key = rate + "_" + name;
      if (!map[key]) map[key] = { rate, name, raw: 0 };
      map[key].raw += lineNet(it) * factor;
    }
    const groups = Object.values(map);
    let allocated = 0;
    for (const g of groups) { g.base = round2(g.raw); allocated = round2(allocated + g.base); }
    /* distribute leftover cents (taxable - sum of rounded bases) by largest remainder */
    let cents = Math.round((taxable - allocated) * 100);
    if (cents !== 0 && groups.length) {
      const order = groups.slice().sort((a, b) => ((b.raw - b.base) - (a.raw - a.base)) * (cents > 0 ? 1 : -1));
      let i = 0;
      while (cents !== 0) {
        const g = order[i % order.length];
        g.base = round2(g.base + (cents > 0 ? 0.01 : -0.01));
        cents += cents > 0 ? -1 : 1; i++;
      }
    }
    let tax = 0;
    for (const g of groups) { g.tax = round2(g.base * g.rate / 100); tax = round2(tax + g.tax); delete g.raw; }
    const vatBreakdown = groups.sort((a, b) => b.rate - a.rate);
    const total = round2(taxable + tax);
    const paid = Number(inv.paid) || 0;
    const balance = round2(total - paid);
    return { subtotal, discount, taxable, tax, total, paid, balance, vatBreakdown };
  }

  /* Gross line amount shown in the items column (qty × unit price). */
  function lineTotal(it) { return round2((Number(it.qty) || 0) * (Number(it.rate) || 0)); }

  function getClient(state, id) { return (state.CLIENTS || []).find(c => c.id === id) || null; }

  function daysBetween(a, b) {
    const MS = 86400000;
    return Math.round((new Date(b).setHours(0, 0, 0, 0) - new Date(a).setHours(0, 0, 0, 0)) / MS);
  }
  /* Date arithmetic in LOCAL parts (the same convention as daysBetween and
     the seed's iso()): local midnight in, local midnight out. */
  function addDays(iso, n) {
    const d = new Date(iso);
    d.setHours(0, 0, 0, 0);
    d.setDate(d.getDate() + (Number(n) || 0));
    return d.toISOString();
  }

  /* Effective status: a 'sent' invoice past its due date reads as overdue,
     and a stored 'overdue' whose due date moved forward heals back to sent. */
  function effectiveStatus(inv, today) {
    /* Credits: an issued avoir past `due` must never read overdue. */
    if (inv.kind === "credit") return inv.status === "draft" ? "draft" : "issued";
    /* Quotes: accepted/declined are STORED; 'expired' is DERIVED from the
       validity date (a quote's `due`), never stored. */
    if (inv.kind === "quote") {
      if (inv.status === "quote") return daysBetween(today, inv.due) < 0 ? "expired" : "quote";
      return inv.status;
    }
    if (inv.status === "paid" || inv.status === "draft" || inv.status === "quote") return inv.status;
    const od = daysBetween(today, inv.due) < 0;
    if (inv.status === "partial") return od ? "overdue" : "partial";
    if (inv.status === "sent" && od) return "overdue";
    if (inv.status === "overdue" && !od) return "sent";
    return inv.status;
  }

  function dueLabel(inv, today) {
    /* Credits carry no due chip; quotes read as validity (warning = expiring). */
    if (inv.kind === "credit") return null;
    if (inv.kind === "quote") {
      if (inv.status !== "quote") return null;
      const q = daysBetween(today, inv.due);
      if (q < 0) return { text: window.t("due.expired", { d: Math.abs(q) }), tone: "warning" };
      if (q === 0) return { text: window.t("due.expires.today"), tone: "warning" };
      return { text: window.t("due.expires", { d: q }), tone: q <= 7 ? "warning" : "muted" };
    }
    const d = daysBetween(today, inv.due);
    if (inv.status === "paid") return null;
    if (d < 0) return { text: window.t("due.overdue", { d: Math.abs(d) }), tone: "accent" };
    if (d === 0) return { text: window.t("due.today"), tone: "accent" };
    if (d <= 7) return { text: window.t("due.in", { d }), tone: "warning" };
    return { text: window.t("due.in", { d }), tone: "muted" };
  }

  /* Money summary across the book. Headline figures count only the company
     currency; other currencies are reported separately in fx[] so a USD
     invoice never inflates a EUR KPI at face value. */
  function summary(state, today) {
    let outstanding = 0, overdue = 0, paidMonth = 0, drafts = 0;
    let outCount = 0, overCount = 0, paidCount = 0;
    const homeCur = (state.COMPANY && state.COMPANY.currency) || "EUR";
    const fxMap = {};
    const tMonth = new Date(today).getMonth(), tYear = new Date(today).getFullYear();
    for (const inv of state.INVOICES) {
      if (inv.kind === "quote") continue;
      const tt = totals(inv);
      const eff = effectiveStatus(inv, today);
      if (inv.status === "draft") { drafts++; continue; }
      if (eff === "paid") {
        const po = inv.paidOn ? new Date(inv.paidOn) : null;
        const cur0 = inv.currency || homeCur;
        if (po && po.getMonth() === tMonth && po.getFullYear() === tYear && cur0 === homeCur) { paidMonth += tt.total; paidCount++; }
        continue;
      }
      // sent / overdue / partial → outstanding balance. Credits (effective
      // 'issued') fall through here SIGNED via docSign, so an open avoir
      // reduces Outstanding; they can never be overdue (kind-invoice only).
      const sign = docSign(inv);
      const cur = inv.currency || homeCur;
      if (cur !== homeCur) {
        if (!fxMap[cur]) fxMap[cur] = { currency: cur, outstanding: 0 };
        fxMap[cur].outstanding = round2(fxMap[cur].outstanding + sign * tt.balance);
        outCount++;
        continue;
      }
      outstanding += sign * tt.balance; outCount++;
      /* NOTE: summary.overdue is the GROSS overdue balance (no credit netting)
         — the hero "what's late" headline. The Autopilot digest's lateTotal
         (LIB.overdueSummary / dunningDigest) NETS issued credits against the
         overdue set, so the two CAN differ by an open avoir. That is intended,
         not a bug: the digest copy disambiguates ("across N clients"). Both
         now key off the SAME overdue predicate (effectiveStatus === 'overdue',
         home currency) so they never drift for any other reason. */
      if (eff === "overdue") { overdue += tt.balance; overCount++; }
    }
    return {
      outstanding: round2(outstanding), overdue: round2(overdue), paidMonth: round2(paidMonth),
      drafts, outCount, overCount, paidCount,
      fx: Object.values(fxMap),
    };
  }

  function clientBalance(state, clientId, today) {
    let bal = 0;
    for (const inv of state.INVOICES) {
      if (inv.clientId !== clientId || inv.kind === "quote") continue;
      const eff = effectiveStatus(inv, today);
      if (eff === "paid" || inv.status === "draft") continue;
      bal += docSign(inv) * totals(inv).balance;
    }
    return round2(bal);
  }

  function clientInvoices(state, clientId) {
    return state.INVOICES.filter(i => i.clientId === clientId);
  }

  /* Render a numbering pattern's tokens against a (year, seq). Supported
     tokens (case-insensitive, brace-wrapped): {YYYY} 4-digit year, {YY}
     2-digit year, {seq} / {SEQ} zero-padded sequence (default pad 3, matching
     the legacy INV-YYYY-NNN format), {seq:N} explicit pad width. Anything
     else in the pattern is literal text (the prefix, separators, etc.). */
  function renderNumbering(pattern, yr, n) {
    return String(pattern).replace(/\{(YYYY|YY|seq(?::(\d+))?)\}/gi, (m, tok, pad) => {
      const t = tok.toLowerCase();
      if (t === "yyyy") return String(yr);
      if (t === "yy") return String(yr).slice(-2);
      const width = pad != null ? Number(pad) : 3;
      return String(n).padStart(width, "0");
    });
  }

  /* Sequential numbering: invoices, quotes and credit notes (avoirs) count
     independently (gap-free sequences are a FacturX requirement); the year
     comes from the issue date. Missing sub-sequences fall back to nextSeq.
     The PATTERN now honors company.numbering instead of a hardcoded format,
     so the Settings "Numbering scheme" dropdown is real (P2-2). Two shapes are
     accepted: a plain string (the invoice pattern the Settings Select writes,
     e.g. "INV-{YYYY}-{seq}" / "{YYYY}{seq}" / "F-{seq}") and a per-kind object
     { invoice|quote|credit: { pattern } } (the config.numbering shape). When
     only the invoice string is set, quotes/credits keep their legacy
     QUO-/AVO- prefixed defaults so existing sequences never shift. */
  function nextNumber(company, kind = "invoice", issued) {
    const co = company || {};
    const yr = new Date(issued || Date.now()).getFullYear();
    const n = kind === "quote"
      ? (co.nextQuoteSeq != null ? co.nextQuoteSeq : co.nextSeq)
      : kind === "credit"
        ? (co.nextCreditSeq != null ? co.nextCreditSeq : co.nextSeq)
        : co.nextSeq;
    const num = co.numbering;
    /* Per-kind object form (config.numbering): one pattern per document kind. */
    if (num && typeof num === "object") {
      const entry = num[kind] || num.invoice;
      if (entry && entry.pattern) return renderNumbering(entry.pattern, yr, n);
    }
    /* String form (Settings dropdown) applies to INVOICES; quotes/credits keep
       their legacy prefixes so those sequences stay stable. */
    if (kind === "invoice" && typeof num === "string" && num) {
      return renderNumbering(num, yr, n);
    }
    const seq = String(n).padStart(3, "0");
    const prefix = kind === "quote" ? "QUO" : kind === "credit" ? "AVO" : "INV";
    return `${prefix}-${yr}-${seq}`;
  }

  /* ── E-invoicing readiness (FacturX / EN 16931 posture) ─────────
     Two call shapes are honored: einvoiceCheck(db, inv) and
     einvoiceCheck(inv, company). Returns { ready, ok, checks:[{id, ok}] }
     with ids: number, seller, buyer, dates, vat. */
  function einvoiceCheck(a, b) {
    let inv, seller, buyer, state = null;
    if (a && Array.isArray(a.INVOICES)) {
      state = a; inv = b || {};
      seller = inv.seller || state.COMPANY || {};
      buyer = inv.buyer || getClient(state, inv.clientId) || {};
    } else {
      inv = a || {};
      seller = inv.seller || b || {};
      buyer = inv.buyer || {};
    }
    const checks = [
      /* credits ride the FacturX rails too (type 381 avoir) */
      { id: "number", ok: /^(INV|QUO|AVO)-\d{4}-\d{3,}$/.test(inv.id || "") },
      { id: "seller", ok: !!(seller.vat && (seller.siret || seller.address)) },
      /* without db access, an identifiable client stands in for the snapshot
         (parties freeze onto the invoice at the next save) */
      { id: "buyer", ok: !!(buyer.name && (buyer.vat || buyer.reg || buyer.address)) || (!state && !inv.buyer && !!inv.clientId) },
      { id: "dates", ok: !!(inv.issued && inv.due) },
      { id: "vat", ok: (totals(inv).vatBreakdown || []).length > 0 || inv.vatMode === "reverse" || inv.vatMode === "exempt" },
    ];
    const ready = checks.every(c => c.ok);
    return { ready, ok: ready, checks };
  }

  /* ── Pay-by-QR strings ─────────────────────────────────────────
     EPC069-12 "SEPA Credit Transfer" QR (the European standard a banking
     app scans to prefill a transfer). EUR only. Falls back to a Nita
     deep-link when bank transfer is off or the currency isn't EUR. */
  function epcString(company, inv, amount) {
    const name = (company.accountName || company.name || "").slice(0, 70);
    const iban = (company.iban || "").replace(/\s+/g, "");
    const bic = (company.bic || "").replace(/\s+/g, "");
    const ref = (inv && inv.id) ? inv.id : "";
    const amt = amount != null ? `EUR${Number(amount).toFixed(2)}` : "";
    return [
      "BCD", "002", "1", "SCT", bic, name, iban, amt, "", "", ref,
    ].join("\n");
  }
  function nitaString(company, inv, amount, currency) {
    const p = (company.payment && company.payment.nita) || {};
    const to = encodeURIComponent(p.handle || p.phone || company.name || "");
    const ref = inv && inv.id ? `&ref=${encodeURIComponent(inv.id)}` : "";
    const amt = amount != null ? `&amount=${Number(amount).toFixed(2)}&cur=${currency || "EUR"}` : "";
    return `nita://pay?to=${to}${amt}${ref}`;
  }
  /* Returns { method, value, label } for the active pay-QR, or null. */
  function payQR(company, inv, amount) {
    const pm = company.payment || {};
    const cur = (inv && inv.currency) || company.currency || "EUR";
    const wantBank = pm.qrMethod !== "nita";
    if (wantBank && pm.bank && pm.bank.enabled && company.iban) {
      return { method: "bank", value: epcString(company, inv, cur === "EUR" ? amount : null), label: window.t("payqr.bank") };
    }
    if (pm.nita && pm.nita.enabled) {
      return { method: "nita", value: nitaString(company, inv, amount, cur), label: window.t("payqr.nita") };
    }
    if (pm.bank && pm.bank.enabled && company.iban) {
      return { method: "bank", value: epcString(company, inv, cur === "EUR" ? amount : null), label: window.t("payqr.bank") };
    }
    return null;
  }

  /* Unit labels (singular/plural aware enough for a demo). Services use
     time units; products use piece/box/kg/etc. so any business can bill. */
  const UNITS = [
    { value: "unit", label: "unit" },
    { value: "hour", label: "hour" },
    { value: "day", label: "day" },
    { value: "item", label: "item" },
    { value: "word", label: "word" },
    { value: "month", label: "month" },
    { value: "fixed", label: "fixed" },
    { value: "piece", label: "piece" },
    { value: "box", label: "box" },
    { value: "kg", label: "kg" },
    { value: "litre", label: "litre" },
    { value: "km", label: "km" },
    { value: "service", label: "service" },
    { value: "product", label: "product" },
  ];
  const PLURAL = { box: "boxes", kg: "kg", km: "km" };
  function unitLabel(unit, qty) {
    const u = (UNITS.find(x => x.value === unit) || {}).label || unit || "unit";
    if (u === "fixed") return "";
    const key = "unit." + u + (Number(qty) === 1 ? "" : ".plural");
    const s = window.t ? window.t(key) : key;
    if (s !== key) return s;
    if (Number(qty) === 1) return u;
    return PLURAL[u] || (u.endsWith("s") ? u : u + "s");
  }

  /* ── Party snapshot + VAT mode ─────────────────────────────────
     Freeze the seller/buyer onto an invoice at save/convert time so it
     prints what it said the day it was issued, not today's live record. */
  function snapshotParties(company, client, newClientName) {
    const c = company || {};
    const seller = { name: c.name, vat: c.vat, siret: c.siret, address: c.address };
    const buyer = client
      ? { name: client.name, vat: client.vat, reg: client.reg, address: client.address }
      : { name: newClientName };
    return { seller, buyer };
  }

  /* i18n key for the legal mention a zero-VAT mode requires, else null. */
  function vatModeMention(vatMode) {
    if (vatMode === "reverse") return "legal.reverseCharge";
    if (vatMode === "exempt") return "legal.vatExempt";
    return null;
  }

  /* ── Generalization: business type + defaults ──────────────────
     "services" leads with projects/team; "products" leads with the
     catalog + stock; "both" shows everything. Tunes the default unit. */
  function businessDefaults(company) {
    const type = (company && company.businessType) || "both";
    const defaultUnit = type === "products" ? "piece" : type === "services" ? "day" : "unit";
    return { type, defaultUnit, showCatalog: type === "products" || type === "both", showStock: type === "products" || type === "both" };
  }

  /* ── Projects + per-person team rates ──────────────────────────
     Project ids are globally unique; getProject searches every client. */
  let _seq = 1;
  function newId(prefix) { return (prefix || "x") + Date.now().toString(36) + (_seq++).toString(36); }

  function getProject(state, projectId) {
    if (!projectId) return null;
    for (const c of (state.CLIENTS || [])) {
      const p = (c.projects || []).find(x => x.id === projectId);
      if (p) return { client: c, project: p };
    }
    return null;
  }
  /* A single fallback day/unit rate for a project: explicit rate, else the
     lowest positive team rate (the team is the real source of truth). */
  function projectRate(project) {
    if (!project) return null;
    if (project.rate != null) return project.rate;
    const rates = ((project.team) || []).map(m => Number(m.rate) || 0).filter(r => r > 0);
    return rates.length ? Math.min.apply(null, rates) : null;
  }
  /* One draft line per team member, each at their OWN rate. This is what
     applies when a project is picked for an invoice (the day cost depends
     on who is on the project, not one flat rate). */
  function projectTeamLines(project, taxRate) {
    const team = (project && project.team) || [];
    return team.map(m => ({
      id: newId("li"), description: m.role ? (m.role + " - " + m.name) : (m.name || window.t("fallback.member")),
      qty: 1, rate: Number(m.rate) || 0, unit: m.unit || "day", discountPct: 0,
      taxRate: taxRate != null ? taxRate : 0, source: "project", memberId: m.id,
    }));
  }
  /* Budget consumed by a project across its non-draft invoices. */
  function projectBudget(state, project) {
    if (!project || project.budget == null) return null;
    let billed = 0;
    for (const inv of (state.INVOICES || [])) {
      if (inv.projectId !== project.id || inv.kind === "quote" || inv.status === "draft") continue;
      billed += docSign(inv) * totals(inv).total;
    }
    billed = round2(billed);
    return { budget: project.budget, billed, remaining: round2(project.budget - billed) };
  }

  /* ── Catalog (inventory) ───────────────────────────────────────── */
  function catalogToLine(item, qty) {
    return {
      id: newId("li"), description: item.name, qty: Number(qty) || 1, rate: Number(item.price) || 0,
      unit: item.unit || "unit", discountPct: 0, taxRate: item.taxRate != null ? item.taxRate : 0,
      source: "catalog", catalogId: item.id,
    };
  }
  /* Apply an invoice's catalog lines to stock (immutable). Only stock-tracked
     items (stock != null) move; floored at 0. Call when an invoice is sent. */
  function decrementStock(catalog, lines) {
    const dec = {};
    (lines || []).forEach(it => { if (it.source === "catalog" && it.catalogId) dec[it.catalogId] = (dec[it.catalogId] || 0) + (Number(it.qty) || 0); });
    if (!Object.keys(dec).length) return catalog;
    return (catalog || []).map(it => (it.stock != null && dec[it.id]) ? { ...it, stock: Math.max(0, it.stock - dec[it.id]) } : it);
  }
  function stockState(item) {
    if (!item || item.stock == null) return "none";
    if (item.stock <= 0) return "out";
    if (item.stock <= (item.lowStockAt != null ? item.lowStockAt : 0)) return "low";
    return "ok";
  }
  function lowStock(catalog) {
    return (catalog || []).filter(it => stockState(it) === "low" || stockState(it) === "out");
  }

  /* ── Expenses ──────────────────────────────────────────────────── */
  /* A billable expense becomes a normal invoice line; markup is baked into
     the stored rate so totals()/paper.jsx need no markup math. */
  function expenseToLine(expense) {
    const markup = Number(expense.markupPct) || 0;
    const rate = round2((Number(expense.amount) || 0) * (1 + markup / 100));
    const note = expense.note ? " - " + expense.note : "";
    return {
      id: newId("li"), description: (expense.merchant || window.t("fallback.expense")) + note, qty: 1, rate,
      unit: "item", discountPct: 0, taxRate: expense.taxRate != null ? expense.taxRate : 0,
      source: "expense", expenseId: expense.id, receipt: expense.receipt || null,
    };
  }
  function expenseSummary(state) {
    const ex = state.EXPENSES || [];
    let toBillCount = 0, toBillTotal = 0, unbilledCount = 0, total = 0;
    for (const e of ex) {
      total += Number(e.amount) || 0;
      /* Every billable unbilled expense counts toward "to bill", with or
         without a clientId: client assignment happens at billing time, so
         the KPI card, tab badge, and tab list all agree on one number. */
      if (e.status === "unbilled" && e.billable) { unbilledCount++; toBillCount++; toBillTotal += Number(e.amount) || 0; }
    }
    return { toBillCount, toBillTotal: round2(toBillTotal), unbilledCount, total: round2(total) };
  }

  /* ── Item normalization (preserves provenance through save) ──────
     The save/draft paths enumerate item keys, which silently drops new
     fields. Route every re-emit through cleanItem so source/receipt survive. */
  function cleanItem(it, draft) {
    const o = {
      id: it.id, description: it.description || window.t("fallback.item"), qty: Number(it.qty) || 0, rate: Number(it.rate) || 0,
      unit: it.unit || "unit", discountPct: Number(it.discountPct) || 0,
      taxRate: it.taxRate != null ? Number(it.taxRate) : ((draft && draft.taxRate) || 0),
      source: it.source || "manual",
    };
    /* Preserve the tax IDENTITY (name) through save so a reloaded invoice's
       PDF/VAT report shows "GST (5%)" not a generic "VAT 5%". Falls back to the
       draft default name when the line itself has no rate-specific override. */
    const taxName = it.taxName != null ? it.taxName : ((it.taxRate == null && draft) ? draft.taxName : null);
    if (taxName != null) o.taxName = taxName;
    if (it.catalogId) o.catalogId = it.catalogId;
    if (it.expenseId) o.expenseId = it.expenseId;
    if (it.memberId) o.memberId = it.memberId;
    if (it.receipt) o.receipt = it.receipt;
    if (it.depositInvoiceId) o.depositInvoiceId = it.depositInvoiceId;
    return o;
  }

  /* ── Audit trail (a real per-invoice event log) ─────────────────
     Use audit[] when present; buildTimeline() stays the fallback for
     legacy invoices (screen-invoices.jsx). */
  const AUDIT_STYLE = {
    created: { color: "var(--color-text-3)", icon: "file" },
    edited: { color: "var(--color-text-3)", icon: "edit" },
    sent: { color: "var(--color-info)", icon: "send" },
    reminder: { color: "var(--color-accent)", icon: "bell" },
    partial: { color: "var(--color-info)", icon: "card" },
    paid: { color: "var(--color-success)", icon: "checkCircle" },
    overdue: { color: "var(--color-accent)", icon: "alert" },
    expense: { color: "var(--color-text-2)", icon: "receipt" },
    converted: { color: "var(--color-primary)", icon: "arrowRight" },
    duplicated: { color: "var(--color-text-3)", icon: "copy" },
    ai: { color: "var(--color-primary)", icon: "sparkle" },
    recurring: { color: "var(--color-primary)", icon: "repeat" },
    chase: { color: "var(--color-primary)", icon: "sparkle" },
    accepted: { color: "var(--color-success)", icon: "checkCircle" },
    declined: { color: "var(--color-error)", icon: "x" },
    convertedTo: { color: "var(--color-info)", icon: "arrowUpRight" },
    deposit: { color: "var(--color-gold)", icon: "coins" },
    credited: { color: "var(--color-info)", icon: "refresh" },
    viewed: { color: "var(--color-info)", icon: "eye" },
    dispute: { color: "var(--color-accent)", icon: "alert" },
  };
  /* Base-split resolution: dotted types (reminder.auto, chase.on, recurring.on)
     inherit their family style unless given an explicit entry — the same
     convention Home's actIcon uses. */
  function auditStyle(type) {
    return AUDIT_STYLE[type] || AUDIT_STYLE[String(type).split(".")[0]] || AUDIT_STYLE.created;
  }
  function auditColor(type) { return auditStyle(type).color; }
  function auditIcon(type) { return auditStyle(type).icon; }
  /* The app's clock is pinned (data.js TODAY) so demo logic is deterministic;
     events stamped with wall-clock new Date() would drift a day ahead. */
  function nowISO() {
    return (window.DATA && window.DATA.TODAY) || new Date().toISOString();
  }
  function auditEvent(type, params, receipt, at) {
    const e = { id: newId("au"), type, key: "audit." + type, params: params || {}, at: at || nowISO() };
    if (receipt) e.receipt = receipt;
    return e;
  }
  function pushAudit(invoice, event) {
    return Object.assign({}, invoice, { audit: [...(invoice.audit || []), event] });
  }
  /* Localize event params at render time: numeric amounts are formatted with
     the active locale (seed data stores numbers + currency); pre-formatted
     string amounts pass through untouched for legacy events. */
  function fmtEventParams(params) {
    const p = params || {};
    if (typeof p.amount === "number") {
      return { ...p, amount: window.fmtMoney(p.amount, p.currency || "EUR") };
    }
    return p;
  }
  function timelineFromAudit(inv) {
    return (inv.audit || []).map(e => ({
      text: e.key ? window.t(e.key, fmtEventParams(e.params)) : e.text,
      date: window.fmtDate(e.at, "short"),
      color: auditColor(e.type), icon: auditIcon(e.type), receipt: e.receipt || null,
    }));
  }

  /* Localized activity-feed event (key+params; rendered via t()). Every new
     event is born unread — `read:false` drives the bell badge; anything else
     (including absent on legacy items) counts as read. */
  function activityEvent(type, params, ref, at) {
    return { id: newId("ac"), type, key: "act." + type, params: params || {}, ref: ref || null, at: at || nowISO(), read: false };
  }

  /* Percent for display in the active locale: 5.5 -> "5.5%" (EN) / "5,5%" (FR);
     integers stay clean (20 -> "20%"). Locale comes from window.getLocale
     (strings.js getter over the same LOCALE setLocale writes). */
  function fmtPct(n) {
    const v = Number(n) || 0;
    const fr = typeof window.getLocale === "function" && window.getLocale() === "fr";
    return (fr ? v.toLocaleString("fr-FR") : String(v)) + "%";
  }

  /* Local-date day string (YYYY-MM-DD). Seed dates are local midnights that
     serialize to the previous UTC evening, so slicing the raw ISO would
     shift exports back a day. */
  function isoDay(d) {
    const x = new Date(d);
    const p = n => String(n).padStart(2, "0");
    return x.getFullYear() + "-" + p(x.getMonth() + 1) + "-" + p(x.getDate());
  }
  /* Period membership for the books: lexical compare of the raw ISO stamp
     against plain YYYY-MM-DD bounds (taxPeriods). A document dated on a
     period's first local midnight serializes into the previous UTC evening
     and therefore closes with the PREVIOUS period — the convention the
     canonical demo figures are built on. The "~" pad keeps full timestamps
     on the closing day inside the period (ASCII "~" > "T"). */
  function inPeriod(at, from, to) {
    const s = String(at || "");
    return (!from || s >= from) && (!to || s <= to + "~");
  }

  /* ── F5/F6 document algebra ─────────────────────────────────────
     The single sign authority: stored line rates stay POSITIVE on a credit
     note; every renderer/aggregator applies docSign instead of storing
     negatives (rows, detail hero, paper, insights, summary). */
  function docSign(inv) { return inv && inv.kind === "credit" ? -1 : 1; }

  /* ── F2 recurrence ─────────────────────────────────────────────── */
  /* Next occurrence of a cadence, in LOCAL parts. Month-based cadences
     clamp the day-of-month to the target month's length (Jan 31 → Feb 28).
     Invalid `every` returns fromISO unchanged. */
  function nextCadence(fromISO, every) {
    const d = new Date(fromISO);
    d.setHours(0, 0, 0, 0);
    if (every === "week") { d.setDate(d.getDate() + 7); return d.toISOString(); }
    const months = every === "month" ? 1 : every === "quarter" ? 3 : every === "year" ? 12 : 0;
    if (!months) return fromISO;
    const day = d.getDate();
    const nxt = new Date(d.getFullYear(), d.getMonth() + months, 1);
    nxt.setDate(Math.min(day, new Date(nxt.getFullYear(), nxt.getMonth() + 1, 0).getDate()));
    return nxt.toISOString();
  }

  /* The single materializer (effect 10 + tests). Emits a DRAFT carrying the
     template's term gap; parties re-snapshot from the live client so the
     draft prints today's record, falling back to the template's frozen
     parties when the client no longer resolves. Never touches stock or
     status (the draft ships like any hand-made one). */
  function materializeRun(db, templateId, runDate) {
    const T = (db.INVOICES || []).find(i => i.id === templateId);
    if (!T || !T.recurrence) return { db, draftId: null };
    const co = db.COMPANY;
    const number = nextNumber(co, "invoice", runDate);
    const client = getClient(db, T.clientId);
    const parties = client ? snapshotParties(co, client) : { seller: T.seller, buyer: T.buyer };
    const draft = {
      id: number, kind: "invoice", clientId: T.clientId, projectId: T.projectId || null,
      issued: runDate, due: addDays(runDate, daysBetween(T.issued, T.due)), currency: T.currency,
      status: "draft",
      items: (T.items || []).map(it => cleanItem({ ...it, id: newId("li") }, T)),
      discountPct: T.discountPct || 0, taxRate: T.taxRate, vatMode: T.vatMode,
      notes: T.notes || "", legal: T.legal || "", paid: 0,
      recurrenceOf: T.id, recurrence: null, chase: null,
      quoteId: null, depositOf: null, depositPct: null, creditFor: null,
      seller: parties.seller, buyer: parties.buyer,
      audit: [auditEvent("recurring", { from: T.id }, null, runDate)],
    };
    const act = activityEvent("recurring", { id: number, client: client ? client.name : ((T.buyer && T.buyer.name) || "") }, number, runDate);
    return {
      db: {
        ...db,
        COMPANY: { ...co, nextSeq: (co.nextSeq || 0) + 1 },
        INVOICES: [draft, ...db.INVOICES],
        ACTIVITY: [act, ...(db.ACTIVITY || [])],
      },
      draftId: number,
    };
  }

  /* Templates whose next run lands inside the horizon. Past pointers are
     included at inDays 0 (they fire on the next advance). */
  function upcomingRuns(db, today, horizonDays = 45) {
    const out = [];
    for (const inv of (db.INVOICES || [])) {
      if (!inv.recurrence) continue;
      const inDays = Math.max(0, daysBetween(today, inv.recurrence.nextRun));
      if (inDays > horizonDays) continue;
      const client = getClient(db, inv.clientId);
      out.push({
        id: inv.id, clientId: inv.clientId,
        clientName: client ? client.name : ((inv.buyer && inv.buyer.name) || inv.newClientName || ""),
        every: inv.recurrence.every, nextRun: inv.recurrence.nextRun, inDays,
        amount: totals(inv).total, currency: inv.currency,
      });
    }
    return out.sort((a, b) => (a.nextRun < b.nextRun ? -1 : a.nextRun > b.nextRun ? 1 : 0));
  }

  /* Same client + same total + same currency, billed on a ~monthly beat
     (gaps all 25-35 days), still warm (newest within 45 days), and not
     already covered by a template — Nita's "shall I take this over?" seed.
     Signature FROZEN: consumers always pass api.today. */
  function recurrenceCandidates(db, today) {
    const groups = {};
    for (const inv of (db.INVOICES || [])) {
      if (inv.kind === "quote" || inv.kind === "credit") continue;
      const key = inv.clientId + "|" + round2(totals(inv).total) + "|" + (inv.currency || "");
      (groups[key] = groups[key] || []).push(inv);
    }
    const out = [];
    for (const key of Object.keys(groups)) {
      const g = groups[key].slice().sort((a, b) => (a.issued < b.issued ? -1 : 1));
      if (g.length < 2) continue;
      if (g.some(i => i.recurrence || i.recurrenceOf)) continue;
      let steady = true;
      for (let i = 1; i < g.length; i++) {
        const gap = daysBetween(g[i - 1].issued, g[i].issued);
        if (gap < 25 || gap > 35) { steady = false; break; }
      }
      if (!steady) continue;
      const last = g[g.length - 1];
      if (daysBetween(last.issued, today) > 45) continue;
      const amount = round2(totals(last).total);
      /* an existing template for that client at that amount owns the beat */
      if ((db.INVOICES || []).some(i => i.recurrence && i.clientId === last.clientId && round2(totals(i).total) === amount)) continue;
      const client = getClient(db, last.clientId);
      out.push({
        clientId: last.clientId,
        clientName: client ? client.name : ((last.buyer && last.buyer.name) || last.newClientName || ""),
        amount, currency: last.currency, every: "month",
        lastId: last.id, lastIssued: last.issued, count: g.length,
      });
    }
    return out.sort((a, b) => b.amount - a.amount);
  }

  /* Promote an invoice to a recurring template. No-op if missing or
     already a template. */
  function enableRecurrence(db, invoiceId, every = "month") {
    const inv = (db.INVOICES || []).find(i => i.id === invoiceId);
    if (!inv || inv.recurrence) return db;
    const rec = { every, nextRun: nextCadence(inv.issued, every) };
    return {
      ...db,
      INVOICES: db.INVOICES.map(i => i.id === invoiceId ? pushAudit({ ...i, recurrence: rec }, auditEvent("recurring.on", {})) : i),
    };
  }

  /* ── F4 autopilot dunning ──────────────────────────────────────── */
  /* The three-step ladder derived from due + COMPANY.dunning.offsets —
     schedule dates are never stored. Unsent steps expose `effectiveAt`
     (max(at, tomorrow)): the honest next-fire date when chase was toggled
     on late. Past sends render from audit, not from here. */
  function chaseSchedule(inv, company, today) {
    if (!inv || inv.kind !== "invoice" || !inv.chase) return [];
    const eff = effectiveStatus(inv, today);
    if (eff === "paid" || eff === "draft" || eff === "quote") return [];
    const offsets = (company && company.dunning && company.dunning.offsets) || [3, 10, 21];
    const done = inv.chase.step || 0;
    const tomorrow = addDays(today, 1);
    return ["gentle", "firm", "final"].map((tone, step) => {
      const at = addDays(inv.due, offsets[step] != null ? offsets[step] : 0);
      const sent = step < done;
      return { step, tone, at, effectiveAt: sent ? at : (daysBetween(at, tomorrow) > 0 ? tomorrow : at), sent };
    });
  }

  /* Home's autopilot digest. lateTotal/lateClients now come from the single
     canonical overdueSummary (nets issued credits, home-currency total,
     clients across every currency) so the digest can never disagree with the
     hero or Ask by an avoir. */
  function dunningDigest(db, today) {
    const od = overdueSummary(db, today);
    const homeCur = od.currency;
    let sentThisWeek = 0;
    for (const a of (db.ACTIVITY || [])) {
      if (a.type !== "reminder.auto") continue;
      const d = daysBetween(a.at, today);
      if (d >= 0 && d <= 6) sentThisWeek++;
    }
    let chasing = 0;
    for (const inv of (db.INVOICES || [])) {
      if (inv.kind !== "invoice" || !(inv.chase && inv.chase.enabled)) continue;
      if (effectiveStatus(inv, today) === "paid") continue;
      chasing++;
    }
    return { lateClients: od.clientCount, lateTotal: od.total, currency: homeCur, sentThisWeek, chasing };
  }

  /* ── Canonical late/overdue source (CONTRACT: LIB.overdueSummary) ──────
     ONE definition of "what is overdue" every surface (Home hero, Autopilot
     digest, Ask answers, Clients) reconciles against, killing the
     money-denominator-inconsistency where two figures disagreed by an avoir.
     `total` is home-currency only and NETS issued credits against the
     overdue set (an avoir reduces what you're owed) — the same rule
     dunningDigest.lateTotal uses. `count` = overdue kind-invoices (home
     currency); `clientCount` = distinct late clients across ALL currencies
     (a USD-only late client still counts as someone you're chasing). */
  function overdueSummary(db, today) {
    const homeCur = (db.COMPANY && db.COMPANY.currency) || "EUR";
    const lateBy = {}, overdueSet = {};
    let total = 0, count = 0;
    for (const inv of (db.INVOICES || [])) {
      if (inv.kind !== "invoice" || effectiveStatus(inv, today) !== "overdue") continue;
      lateBy[inv.clientId] = true;
      overdueSet[inv.id] = true;
      if ((inv.currency || homeCur) === homeCur) { total += totals(inv).balance; count++; }
    }
    for (const inv of (db.INVOICES || [])) {
      if (inv.kind !== "credit" || inv.status === "draft" || !inv.creditFor) continue;
      if (overdueSet[inv.creditFor] && (inv.currency || homeCur) === homeCur) total -= totals(inv).total;
    }
    return { total: round2(total), count, clientCount: Object.keys(lateBy).length, currency: homeCur };
  }

  /* CONTRACT: LIB.worstPayer(db, today) -> { client, balance, daysLate } | null.
     The client with the largest overdue balance (home currency), so Ask's
     "who's my worst payer?" and Home can name one person to chase. daysLate
     is from that client's OLDEST overdue invoice (how long you've been
     waiting). Returns null when nothing is overdue. */
  function worstPayer(db, today) {
    const homeCur = (db.COMPANY && db.COMPANY.currency) || "EUR";
    const byClient = {};
    for (const inv of (db.INVOICES || [])) {
      if (inv.kind !== "invoice" || effectiveStatus(inv, today) !== "overdue") continue;
      if ((inv.currency || homeCur) !== homeCur) continue;
      const id = inv.clientId;
      const late = Math.abs(daysBetween(inv.due, today));
      const e = byClient[id] || (byClient[id] = { id, balance: 0, daysLate: 0 });
      e.balance = round2(e.balance + totals(inv).balance);
      if (late > e.daysLate) e.daysLate = late;
    }
    let best = null;
    for (const id of Object.keys(byClient)) {
      if (!best || byClient[id].balance > best.balance) best = byClient[id];
    }
    if (!best) return null;
    return { client: getClient(db, best.id), balance: best.balance, daysLate: best.daysLate };
  }

  /* CONTRACT: LIB.spendThisMonth(db, today) -> Number.
     Total expense spend (TTC) booked in the calendar month of `today`. Sums
     EVERY non-personal expense dated this month regardless of billable/
     billed state (it answers "what did I spend?", not "what can I bill?").
     Local month parts, mirroring insights' bucketing. */
  function spendThisMonth(db, today) {
    const t0 = new Date(today);
    const m = t0.getMonth(), y = t0.getFullYear();
    let total = 0;
    for (const e of (db.EXPENSES || [])) {
      if (e.status === "personal") continue;
      const d = new Date(e.date);
      if (d.getMonth() === m && d.getFullYear() === y) total += Number(e.amount) || 0;
    }
    return round2(total);
  }

  /* THE single send write body (SendSheet.doSend and the dunning effect
     both consume it). One pass over INVOICES, re-finding by id: draft→sent
     (credits → issued), chase stamped at first send of a kind-invoice,
     stock moved ONLY on that first send, plain ACTIVITY prepend — never a
     slice. Subject/message text is never persisted. */
  function sendWrite(db, invoiceId, opts) {
    const o = opts || {};
    const mode = o.mode || "send";
    const at = o.at;
    const cur = (db.INVOICES || []).find(x => x.id === invoiceId);
    if (!cur) return db;
    const client = getClient(db, cur.clientId) || { name: cur.newClientName || ((cur.buyer && cur.buyer.name) || ""), email: "" };
    const wasDraft = cur.status === "draft";
    const event = mode === "remind" ? auditEvent("reminder", { tone: o.tone }, null, at)
      : mode === "remind.auto" ? auditEvent("reminder.auto", { tone: o.tone, email: client.email || "" }, null, at)
        : auditEvent("sent", { email: o.to }, null, at);
    const act = activityEvent(
      mode === "remind" ? "reminder" : mode === "remind.auto" ? "reminder.auto" : "sent",
      { client: client.name, id: invoiceId }, invoiceId, at
    );
    return {
      ...db,
      INVOICES: db.INVOICES.map(x => {
        if (x.id !== invoiceId) return x;
        const status = wasDraft ? (x.kind === "credit" ? "issued" : "sent") : x.status;
        const stamped = (wasDraft && x.kind === "invoice" && !x.chase)
          ? { enabled: !!(db.COMPANY.dunning && db.COMPANY.dunning.auto), step: 0 }
          : x.chase;
        const chase = (mode === "remind.auto" && stamped)
          ? { ...stamped, step: (stamped.step || 0) + 1 }
          : stamped;
        return pushAudit({ ...x, status, chase }, event);
      }),
      /* Stock moves once, when the invoice first goes out (draft → sent);
         credits and quotes never move stock. */
      CATALOG: (wasDraft && cur.kind === "invoice") ? decrementStock(db.CATALOG, cur.items) : db.CATALOG,
      ACTIVITY: [act, ...(db.ACTIVITY || [])],
    };
  }

  /* ── F5 quote lifecycle + deposits, F6 credit notes ───────────── */
  function quotePipeline(db, today) {
    const homeCur = (db.COMPANY && db.COMPANY.currency) || "EUR";
    let total = 0, openCount = 0, acceptedCount = 0;
    for (const inv of (db.INVOICES || [])) {
      if (inv.kind !== "quote") continue;
      const open = effectiveStatus(inv, today) === "quote";
      const accepted = inv.status === "accepted" && !inv.convertedTo;
      if (!open && !accepted) continue;
      if (open) openCount++; else acceptedCount++;
      if ((inv.currency || homeCur) === homeCur) total += totals(inv).total;
    }
    return { total: round2(total), count: openCount + acceptedCount, openCount, acceptedCount, currency: homeCur };
  }

  function quoteDeposits(db, quoteId) {
    return (db.INVOICES || [])
      .filter(i => i.depositOf === quoteId)
      .sort((a, b) => (a.issued < b.issued ? -1 : a.issued > b.issued ? 1 : 0));
  }

  /* Client actions on a quote. No-op unless the quote is still open. */
  function acceptQuote(db, quoteId, today) {
    const q = (db.INVOICES || []).find(i => i.id === quoteId);
    if (!q || effectiveStatus(q, today) !== "quote") return db;
    const client = getClient(db, q.clientId);
    return {
      ...db,
      INVOICES: db.INVOICES.map(i => i.id === quoteId ? pushAudit({ ...i, status: "accepted" }, auditEvent("accepted", {}, null, today)) : i),
      ACTIVITY: [activityEvent("accepted", { client: client ? client.name : (q.newClientName || ""), id: quoteId }, quoteId, today), ...(db.ACTIVITY || [])],
    };
  }
  function declineQuote(db, quoteId, today) {
    const q = (db.INVOICES || []).find(i => i.id === quoteId);
    if (!q || effectiveStatus(q, today) !== "quote") return db;
    const client = getClient(db, q.clientId);
    return {
      ...db,
      INVOICES: db.INVOICES.map(i => i.id === quoteId ? pushAudit({ ...i, status: "declined" }, auditEvent("declined", {}, null, today)) : i),
      ACTIVITY: [activityEvent("declined", { client: client ? client.name : (q.newClientName || ""), id: quoteId }, quoteId, today), ...(db.ACTIVITY || [])],
    };
  }

  /* NON-DESTRUCTIVE convert: the quote survives (status stays accepted,
     gains convertedTo) and a fresh draft invoice is born carrying the
     quote's lines plus one deduction line per non-draft deposit, so the
     back-stack never 404s and the books reconcile. */
  function convertQuote(db, quoteId, today) {
    const quote = (db.INVOICES || []).find(i => i.id === quoteId);
    if (!quote || quote.kind !== "quote") return { db, invoiceId: null };
    if (quote.convertedTo) return { db, invoiceId: quote.convertedTo };
    const co = db.COMPANY;
    const invoiceId = nextNumber(co, "invoice", today);
    const client = getClient(db, quote.clientId);
    const parties = snapshotParties(co, client, quote.newClientName || (quote.buyer && quote.buyer.name));
    const items = (quote.items || []).map(it => cleanItem({ ...it, id: newId("li") }, quote));
    for (const D of quoteDeposits(db, quoteId)) {
      if (D.status === "draft") continue;
      /* deduction is the deposit's NET so the quote's tax rate re-applies
         cleanly; depositInvoiceId survives saves via cleanItem */
      items.push({
        id: newId("li"), description: window.t("dep.deduct", { id: D.id }), qty: 1,
        rate: -totals(D).taxable, unit: "item", discountPct: 0,
        taxRate: Number(quote.taxRate) || 0, source: "manual", depositInvoiceId: D.id,
      });
    }
    const inv = {
      id: invoiceId, kind: "invoice", clientId: quote.clientId, newClientName: quote.newClientName || null,
      projectId: quote.projectId || null, issued: today,
      due: addDays(today, daysBetween(quote.issued, quote.due)), currency: quote.currency,
      status: "draft", items,
      discountPct: quote.discountPct || 0, taxRate: quote.taxRate, vatMode: quote.vatMode,
      notes: quote.notes || "", legal: quote.legal || "", paid: 0,
      recurrence: null, chase: null, quoteId: quote.id, depositOf: null, depositPct: null, creditFor: null,
      seller: parties.seller, buyer: parties.buyer,
      audit: [auditEvent("created", {}, null, today), auditEvent("converted", {}, null, today)],
    };
    let next = {
      ...db,
      COMPANY: { ...co, nextSeq: (co.nextSeq || 0) + 1 },
      INVOICES: [inv, ...db.INVOICES.map(x => x.id === quoteId
        ? pushAudit({ ...x, convertedTo: invoiceId }, auditEvent("convertedTo", { number: invoiceId }, null, today))
        : x)],
      ACTIVITY: [activityEvent("converted", { id: quoteId, number: invoiceId }, invoiceId, today), ...(db.ACTIVITY || [])],
    };
    /* billExpenses bridge: expense lines carried from the quote flip their
       expenses to billed against the REAL invoice (quotes never bill). */
    if (window.QEditor && window.QEditor.billExpenses) {
      const r = window.QEditor.billExpenses(next, inv);
      next = { ...r.working, INVOICES: r.working.INVOICES.map(x => x.id === invoiceId ? r.inv : x) };
    }
    return { db: next, invoiceId };
  }

  /* Deposit invoice from a quote: ONE net line sized so the deposit TTC
     equals pct% of the quote TTC; payable on receipt (due = issued). */
  function createDeposit(db, quoteId, pct, today) {
    const quote = (db.INVOICES || []).find(i => i.id === quoteId);
    if (!quote || quote.kind !== "quote") return { db, invoiceId: null };
    const p = Math.max(1, Math.min(100, Number(pct) || 1));
    const co = db.COMPANY;
    const invoiceId = nextNumber(co, "invoice", today);
    const client = getClient(db, quote.clientId);
    const parties = snapshotParties(co, client, quote.newClientName || (quote.buyer && quote.buyer.name));
    const taxRate = Number(quote.taxRate) || 0;
    const inv = {
      id: invoiceId, kind: "invoice", clientId: quote.clientId, newClientName: quote.newClientName || null,
      projectId: quote.projectId || null, issued: today, due: today, currency: quote.currency,
      status: "draft",
      items: [{
        id: newId("li"), description: window.t("dep.line", { pct: p, id: quoteId }), qty: 1,
        rate: round2(totals(quote).total * p / 100 / (1 + taxRate / 100)),
        unit: "item", discountPct: 0, taxRate, source: "manual",
      }],
      discountPct: 0, taxRate: quote.taxRate, vatMode: quote.vatMode,
      notes: "", legal: quote.legal || "", paid: 0,
      recurrence: null, chase: null, quoteId: null, depositOf: quoteId, depositPct: p, creditFor: null,
      seller: parties.seller, buyer: parties.buyer,
      audit: [auditEvent("created", {}, null, today)],
    };
    return {
      db: {
        ...db,
        COMPANY: { ...co, nextSeq: (co.nextSeq || 0) + 1 },
        INVOICES: [inv, ...db.INVOICES.map(x => x.id === quoteId ? pushAudit(x, auditEvent("deposit", { number: invoiceId }, null, today)) : x)],
        ACTIVITY: [activityEvent("deposit", { id: invoiceId, client: client ? client.name : (quote.newClientName || "") }, invoiceId, today), ...(db.ACTIVITY || [])],
      },
      invoiceId,
    };
  }

  /* Credit note (avoir) against an invoice: POSITIVE copies of the source
     lines (provenance preserved; docSign carries the minus everywhere).
     The source gains creditedBy, which hides the action forever. */
  function issueCredit(db, invoiceId, today) {
    const src = (db.INVOICES || []).find(i => i.id === invoiceId);
    if (!src || src.kind !== "invoice" || src.creditedBy) return { db, creditId: null };
    const co = db.COMPANY;
    const creditId = nextNumber(co, "credit", today);
    const client = getClient(db, src.clientId);
    const parties = snapshotParties(co, client, src.newClientName || (src.buyer && src.buyer.name));
    const credit = {
      id: creditId, kind: "credit", clientId: src.clientId, newClientName: src.newClientName || null,
      projectId: src.projectId || null, issued: today, due: today, currency: src.currency,
      status: "draft",
      items: (src.items || []).map(it => cleanItem({ ...it, id: newId("li") }, src)),
      discountPct: src.discountPct || 0, taxRate: src.taxRate, vatMode: src.vatMode,
      notes: "", legal: src.legal || "", paid: 0,
      recurrence: null, chase: null, quoteId: null, depositOf: null, depositPct: null, creditFor: invoiceId,
      seller: parties.seller, buyer: parties.buyer,
      audit: [auditEvent("created", {}, null, today)],
    };
    return {
      db: {
        ...db,
        COMPANY: { ...co, nextCreditSeq: (co.nextCreditSeq != null ? co.nextCreditSeq : co.nextSeq) + 1 },
        INVOICES: [credit, ...db.INVOICES.map(x => x.id === invoiceId
          ? pushAudit({ ...x, creditedBy: creditId }, auditEvent("credited", { number: creditId }, null, today))
          : x)],
        ACTIVITY: [activityEvent("credited", { id: creditId, client: client ? client.name : (src.newClientName || "") }, creditId, today), ...(db.ACTIVITY || [])],
      },
      creditId,
    };
  }

  /* ── F3 payments ledger + exports ─────────────────────────────── */
  /* Synthetic fallback so legacy paid invoices (no payments[]) never empty
     the register; the seed backfills real arrays anyway. */
  function paymentsOf(inv) {
    return inv.payments || (inv.paidOn ? [{ id: "syn-" + inv.id, amount: inv.paid, at: inv.paidOn, method: "other", synthetic: true }] : []);
  }

  /* The ONLY writer of payments[] (PaymentSheet, both markPaid closures and
     ClientPayView.payNow all route through it). Pure on the invoice: the
     caller commits via its own setDb updater (re-find by id first). */
  function recordPayment(inv, p) {
    if (!inv || inv.kind !== "invoice") return inv;
    const amount = round2(Number(p.amount) || 0);
    const pay = { id: newId("pay"), amount, at: p.at, method: p.method || "other" };
    if (p.reference) pay.reference = p.reference;
    const total = totals(inv).total;
    const paid = Math.min(total, round2((Number(inv.paid) || 0) + amount));
    const full = total - paid <= 0.005;
    const next = { ...inv, payments: [...(inv.payments || []), pay], paid, status: full ? "paid" : "partial" };
    if (full) next.paidOn = p.at;
    return pushAudit(next, auditEvent(full ? "paid" : "partial",
      { amount: window.fmtMoney(amount, inv.currency), method: pay.method, reference: pay.reference }, null, p.at));
  }

  /* Flat receipts ledger over paymentsOf, newest first. EUR totals are the
     consumer's job: foreign-currency rows render in their own currency. */
  function paymentsLedger(db, opts) {
    const o = opts || {};
    const out = [];
    for (const inv of (db.INVOICES || [])) {
      if (inv.kind !== "invoice") continue;
      const client = getClient(db, inv.clientId);
      for (const p of paymentsOf(inv)) {
        if (!inPeriod(p.at, o.from, o.to)) continue;
        const row = {
          id: p.id, invoiceId: inv.id, clientId: inv.clientId,
          clientName: client ? client.name : (inv.newClientName || ((inv.buyer && inv.buyer.name) || "")),
          amount: p.amount, currency: inv.currency, at: p.at, method: p.method,
        };
        if (p.reference) row.reference = p.reference;
        if (p.synthetic) row.synthetic = true;
        out.push(row);
      }
    }
    return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
  }

  /* CSV plumbing: quoted fields, CRLF rows (Excel-safe). */
  function csvCell(v) { return '"' + String(v == null ? "" : v).replace(/"/g, '""') + '"'; }
  function csvFrom(rows) { return rows.map(r => r.map(csvCell).join(",")).join("\r\n") + "\r\n"; }

  /* The canonical "is this an INVOICES-tab row?" predicate. The Invoices
     list header counts kind!=='quote' (invoices + credits, quotes live on
     their own tab), so every "All" export must use the SAME predicate or the
     export count (was 27) drifts from the header count (24). */
  function isInvoiceRow(inv) { return inv && inv.kind !== "quote"; }

  /* ── Export selection engine (CONTRACT: LIB.selectInvoices) ──────────
     The single source of truth for "which documents match the Export
     screen's filters". Returns an Invoice[] NEWEST-FIRST (mirrors the
     Invoices-list sort: issued desc, id desc as a stable tiebreak). Every
     filter key is optional; an absent/null key means "no constraint on that
     dimension". q is the seed shape the Export screen passes:
       clientId   string|null   single client (null = all)
       projectId  string|null   requires a clientId in practice; matched on inv.projectId
       status     string[]|null  via effectiveStatus(inv, today); empty array = match none
       types      string[]|null  ["invoice","quote","credit"]; null/empty → isInvoiceRow
                                  default (invoices + credits, quotes excluded) so the
                                  count agrees with the Invoices-list header
       basis      "issued"|"paid"  date dimension (default "issued")
       from,to    "YYYY-MM-DD"|null  inPeriod on issued, OR — when basis="paid" — keep
                                  only docs with at least one payment inside the window
       currency   string|null   exact match on inv.currency (home currency is implicit
                                  on docs with no currency set)
       ids        string[]|null  HAND-PICK override: when present, the result is the
                                  INTERSECTION of the filter matches with these ids, so a
                                  user can drop/keep individuals after filtering. An empty
                                  array (Array.isArray honored) therefore yields zero docs.
       today      "YYYY-MM-DD"   the effective-status / period clock
     Reuses getClient, effectiveStatus, inPeriod, paymentsOf, totals, docSign,
     isInvoiceRow — no new status/period logic is invented here. */
  function selectInvoices(db, q) {
    const o = q || {};
    const today = o.today || nowISO();
    const homeCur = (db.COMPANY && db.COMPANY.currency) || "EUR";
    const statusSet = Array.isArray(o.status) ? new Set(o.status) : null;
    const typeSet = (Array.isArray(o.types) && o.types.length) ? new Set(o.types) : null;
    const idSet = Array.isArray(o.ids) ? new Set(o.ids) : null;
    const basis = o.basis === "paid" ? "paid" : "issued";
    const hasWindow = !!(o.from || o.to);
    const out = [];
    for (const inv of (db.INVOICES || [])) {
      /* Type: explicit set when given, else the canonical INVOICES-tab default
         (kind !== 'quote'), so an unfiltered selection equals the list header. */
      if (typeSet) { if (!typeSet.has(inv.kind || "invoice")) continue; }
      else if (!isInvoiceRow(inv)) continue;
      if (o.clientId && inv.clientId !== o.clientId) continue;
      if (o.projectId && inv.projectId !== o.projectId) continue;
      if (o.currency && (inv.currency || homeCur) !== o.currency) continue;
      if (statusSet && !statusSet.has(effectiveStatus(inv, today))) continue;
      if (hasWindow) {
        if (basis === "paid") {
          /* keep docs that received at least one payment inside the window */
          if (!paymentsOf(inv).some(p => inPeriod(p.at, o.from, o.to))) continue;
        } else if (!inPeriod(inv.issued, o.from, o.to)) continue;
      }
      if (idSet && !idSet.has(inv.id)) continue; // hand-pick intersection
      out.push(inv);
    }
    return out.sort((a, b) => (a.issued < b.issued ? 1 : a.issued > b.issued ? -1 : (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)));
  }

  /* ── Selection summary (CONTRACT: LIB.selectionSummary) ──────────────
     Per-currency totals for the Export footer — NEVER summed across
     currencies (a EUR + USD selection reads "€17,030 · $5,700", not a
     meaningless single number). Totals are SIGNED via docSign so an issued
     avoir reduces the figure, matching every other money surface. count is the
     plain document count. byCurrency maps currency code → rounded signed total
     of each document's total. */
  function selectionSummary(db, invoices, today) {
    const homeCur = (db.COMPANY && db.COMPANY.currency) || "EUR";
    const byCurrency = {};
    const list = invoices || [];
    for (const inv of list) {
      const cur = inv.currency || homeCur;
      const amt = docSign(inv) * totals(inv).total;
      byCurrency[cur] = round2((byCurrency[cur] || 0) + amt);
    }
    return { count: list.length, byCurrency };
  }

  function csvInvoices(db, opts) {
    const o = opts || {};
    const today = o.today || nowISO();
    /* Scope filter precedence:
         ids === array  → export only those ids (the DownloadSheet's current
                          filtered list); an EMPTY array means "matched
                          nothing" and must export ZERO rows.
         ids == null/absent → no scope filter: export the whole INVOICES tab,
                          which EXCLUDES quotes (ties to the list header count).
       `ids` is read with Array.isArray so [] is honored, not coerced away.
       opts.from / opts.to (YYYY-MM-DD, optional) additionally scope by ISSUE
       date via inPeriod — the lib half of the Taxes period bug (P1-1): a
       Q2-selected pack must carry Q2 invoices, not the whole book. The date
       window applies on TOP of either scope (whole-tab or hand-picked ids). */
    const hasIds = !!(opts && Array.isArray(opts.ids));
    const idSet = hasIds ? new Set(opts.ids) : null;
    const rows = [["id", "kind", "client", "issued", "due", "currency", "net", "vat", "total", "paid", "balance", "status"]];
    for (const inv of (db.INVOICES || [])) {
      if (idSet) { if (!idSet.has(inv.id)) continue; }
      else if (!isInvoiceRow(inv)) continue;
      if ((o.from || o.to) && !inPeriod(inv.issued, o.from, o.to)) continue;
      const tt = totals(inv);
      const sign = docSign(inv);
      const client = getClient(db, inv.clientId);
      rows.push([
        inv.id, inv.kind || "invoice",
        client ? client.name : (inv.newClientName || ((inv.buyer && inv.buyer.name) || "")),
        isoDay(inv.issued), isoDay(inv.due), inv.currency || "",
        (sign * tt.taxable).toFixed(2), (sign * tt.tax).toFixed(2), (sign * tt.total).toFixed(2),
        (sign * tt.paid).toFixed(2), (sign * tt.balance).toFixed(2),
        effectiveStatus(inv, today),
      ]);
    }
    return csvFrom(rows);
  }

  /* Payments CSV. opts.from/to scope by payment date (via paymentsLedger);
     opts.ids (optional, Array.isArray honored so [] exports zero rows) further
     restricts to payments whose parent INVOICE id is in the selection — the
     Export screen passes the ticked invoice ids so the receipts ledger matches
     the documents being exported. */
  function csvPayments(db, opts) {
    const o = opts || {};
    const idSet = Array.isArray(o.ids) ? new Set(o.ids) : null;
    const rows = [["date", "invoice", "client", "amount", "currency", "method", "reference"]];
    for (const p of paymentsLedger(db, o)) {
      if (idSet && !idSet.has(p.invoiceId)) continue;
      rows.push([isoDay(p.at), p.invoiceId, p.clientName, (Number(p.amount) || 0).toFixed(2), p.currency || "", p.method || "", p.reference || ""]);
    }
    return csvFrom(rows);
  }

  /* Plausible FEC preview (pipe-separated, demo fidelity): one VE entry per
     home-currency document (411 client / 706 revenue / 44571 collected VAT,
     mirrored for an avoir) and one BQ entry per payment. */
  function fecPreview(db, opts) {
    const o = opts || {};
    const homeCur = (db.COMPANY && db.COMPANY.currency) || "EUR";
    const F = n => (Math.round((Number(n) || 0) * 100) / 100).toFixed(2);
    const day = at => isoDay(at).replace(/-/g, "");
    const acct = id => ("411" + String(id || "DIVERS").replace(/[^a-z0-9]/gi, "").toUpperCase()).slice(0, 12);
    const lines = ["JournalCode|EcritureDate|CompteNum|EcritureLib|Debit|Credit"];
    const docs = (db.INVOICES || [])
      .filter(i => (i.kind === "invoice" || i.kind === "credit") && i.status !== "draft"
        && (i.currency || homeCur) === homeCur && inPeriod(i.issued, o.from, o.to))
      .sort((a, b) => (a.issued < b.issued ? -1 : 1));
    for (const inv of docs) {
      const tt = totals(inv);
      const avoir = inv.kind === "credit";
      const client = getClient(db, inv.clientId);
      const lib = inv.id + " " + (client ? client.name : (inv.newClientName || ""));
      lines.push(["VE", day(inv.issued), acct(inv.clientId), lib, avoir ? F(0) : F(tt.total), avoir ? F(tt.total) : F(0)].join("|"));
      lines.push(["VE", day(inv.issued), "706000", lib, avoir ? F(tt.taxable) : F(0), avoir ? F(0) : F(tt.taxable)].join("|"));
      if (tt.tax) lines.push(["VE", day(inv.issued), "445710", lib, avoir ? F(tt.tax) : F(0), avoir ? F(0) : F(tt.tax)].join("|"));
    }
    for (const p of paymentsLedger(db, { from: o.from, to: o.to })) {
      if ((p.currency || homeCur) !== homeCur) continue;
      const lib = p.invoiceId + " " + p.clientName;
      lines.push(["BQ", day(p.at), "512000", lib, F(p.amount), F(0)].join("|"));
      lines.push(["BQ", day(p.at), acct(p.clientId), lib, F(0), F(p.amount)].join("|"));
    }
    return lines.join("\r\n") + "\r\n";
  }

  /* Exactly 4 files; the screen staggers Q.downloadFile calls then toasts
     toast.packExported {count:4}. */
  function accountantPack(db, opts) {
    const o = opts || {};
    const today = o.today || nowISO();
    const span = (o.from || "") + "_" + (o.to || "");
    const tr = taxReport(db, { from: o.from, to: o.to, today });
    const vatRows = [["section", "rate", "base", "tax"]];
    for (const r of tr.output.rates) vatRows.push(["output", r.rate + "%", r.base.toFixed(2), r.tax.toFixed(2)]);
    vatRows.push(["output-total", "", "", tr.output.total.toFixed(2)]);
    vatRows.push(["input-total", "", "", tr.input.total.toFixed(2)]);
    vatRows.push(["net", "", "", tr.net.toFixed(2)]);
    const csvMime = "text/csv;charset=utf-8";
    return [
      /* invoices-*.csv now scopes to the pack's date range (P1-1): a Q2 pack
         carries Q2 invoices, matching the payments/FEC/VAT files which were
         already period-scoped. No range → whole book (unchanged). */
      { name: "invoices-" + span + ".csv", content: csvInvoices(db, { today, from: o.from, to: o.to }), mime: csvMime },
      { name: "payments-" + span + ".csv", content: csvPayments(db, { from: o.from, to: o.to }), mime: csvMime },
      { name: "fec-preview-" + span + ".txt", content: fecPreview(db, { from: o.from, to: o.to }), mime: "text/plain;charset=utf-8" },
      { name: "vat-summary-" + span + ".csv", content: csvFrom(vatRows), mime: csvMime },
    ];
  }

  /* ── F1 taxes (VAT + URSSAF estimates) ─────────────────────────── */
  /* Quarters of the current year up to the current one, plus YTD; plain
     YYYY-MM-DD bounds (see inPeriod). Default selection = current quarter
     (the last quarter entry). */
  function taxPeriods(today) {
    const d = new Date(today);
    d.setHours(0, 0, 0, 0);
    const year = d.getFullYear();
    const p = n => String(n).padStart(2, "0");
    const day = x => x.getFullYear() + "-" + p(x.getMonth() + 1) + "-" + p(x.getDate());
    const out = [];
    const curQ = Math.floor(d.getMonth() / 3);
    for (let q = 0; q <= curQ; q++) {
      out.push({ key: "q" + (q + 1), labelParams: { q: q + 1, year }, from: day(new Date(year, q * 3, 1)), to: day(new Date(year, q * 3 + 3, 0)) });
    }
    out.push({ key: "ytd", labelParams: { year }, from: day(new Date(year, 0, 1)), to: day(d) });
    return out;
  }

  /* VAT estimate for a period. Output: accrual basis (issued), home
     currency only, signed via docSign; reverse/exempt documents contribute
     a 0% row (zero tax) through totals(). Input convention: expense.amount
     is TTC, so deductible VAT = amount - amount / (1 + taxRate/100),
     accumulated unrounded and rounded once. net < 0 means a VAT credit. */
  function taxReport(db, opts) {
    const o = opts || {};
    const homeCur = (db.COMPANY && db.COMPANY.currency) || "EUR";
    const rateMap = {};
    let outTotal = 0, outCount = 0;
    for (const inv of (db.INVOICES || [])) {
      if (inv.kind !== "invoice" && inv.kind !== "credit") continue;
      if (inv.status === "draft") continue;
      if ((inv.currency || homeCur) !== homeCur) continue;
      if (!inPeriod(inv.issued, o.from, o.to)) continue;
      const sign = docSign(inv);
      const tt = totals(inv);
      outCount++;
      outTotal += sign * tt.tax;
      for (const g of tt.vatBreakdown) {
        /* Group by rate+name (mirrors totals() key) so GST/QST/VAT at the same
           rate stay distinct rows in the VAT report, carrying their identity. */
        const key = g.rate + "_" + (g.name || "");
        const row = rateMap[key] || (rateMap[key] = { rate: g.rate, name: g.name || "", base: 0, tax: 0, refs: [] });
        row.base = round2(row.base + sign * g.base);
        row.tax = round2(row.tax + sign * g.tax);
        if (row.refs.indexOf(inv.id) < 0) row.refs.push(inv.id);
      }
    }
    const rates = Object.keys(rateMap).map(k => rateMap[k]).sort((a, b) => b.rate - a.rate);
    let inTotal = 0;
    const inRefs = [];
    for (const e of (db.EXPENSES || [])) {
      if (e.status === "personal") continue;
      if (!inPeriod(e.date, o.from, o.to)) continue;
      const amt = Number(e.amount) || 0;
      inTotal += amt - amt / (1 + (Number(e.taxRate) || 0) / 100);
      inRefs.push(e.id);
    }
    inTotal = round2(inTotal);
    outTotal = round2(outTotal);
    return {
      output: { total: outTotal, rates, count: outCount },
      input: { total: inTotal, count: inRefs.length, refs: inRefs },
      net: round2(outTotal - inTotal),
    };
  }

  /* Micro-entrepreneur contribution rates live HERE, not in COMPANY. */
  const URSSAF_RATES = { services: 21.2, sales: 12.3 };
  /* URSSAF turnover is CASH basis: home-currency payments received in the
     period (via paymentsLedger), against the Settings category rate. */
  function urssafReport(db, opts) {
    const o = opts || {};
    const homeCur = (db.COMPANY && db.COMPANY.currency) || "EUR";
    let turnover = 0, count = 0;
    for (const p of paymentsLedger(db, { from: o.from, to: o.to })) {
      if ((p.currency || homeCur) !== homeCur) continue;
      turnover += Number(p.amount) || 0;
      count++;
    }
    turnover = round2(turnover);
    const cat = (db.COMPANY && db.COMPANY.urssaf && db.COMPANY.urssaf.category) || "services";
    const rate = URSSAF_RATES[cat] != null ? URSSAF_RATES[cat] : URSSAF_RATES.services;
    return { turnover, rate, contribution: round2(turnover * rate / 100), count };
  }

  /* THE only VAT-deadline selector: the previous month is declared by the
     21st of the current one (past the 21st, the window rolls forward). */
  function nextDeclaration(db, today) {
    const d = new Date(today);
    d.setHours(0, 0, 0, 0);
    const due = d.getDate() <= 21
      ? new Date(d.getFullYear(), d.getMonth(), 21)
      : new Date(d.getFullYear(), d.getMonth() + 1, 21);
    const declared = new Date(due.getFullYear(), due.getMonth() - 1, 1);
    return {
      due: due.toISOString(),
      daysLeft: daysBetween(today, due.toISOString()),
      periodKey: declared.getFullYear() + "-" + String(declared.getMonth() + 1).padStart(2, "0"),
    };
  }

  /* ── F8/F9/F10 intelligence + surfaces ─────────────────────────── */
  /* The single pay-URL authority; tolerates a bare { id } or a raw id. */
  function payLink(inv) {
    const id = inv && inv.id != null ? inv.id : (inv == null ? "" : inv);
    return "nita.app/p/" + encodeURIComponent(id);
  }

  /* Six months of billed (accrual, signed, home currency) vs collected
     (cash, via paymentsLedger), plus days-to-paid, top clients and the
     month-over-month swing. Months bucket on LOCAL parts. */
  function insights(db, today) {
    const homeCur = (db.COMPANY && db.COMPANY.currency) || "EUR";
    const t0 = new Date(today);
    t0.setHours(0, 0, 0, 0);
    const months = [];
    const idx = {};
    for (let i = 5; i >= 0; i--) {
      const d = new Date(t0.getFullYear(), t0.getMonth() - i, 1);
      const key = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
      const mo = { key, m: d.getMonth(), year: d.getFullYear(), billed: 0, collected: 0 };
      months.push(mo);
      idx[key] = mo;
    }
    const keyOf = at => { const d = new Date(at); return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0"); };
    const year = t0.getFullYear();
    /* Partial-month MoM: comparing a part-elapsed current month against a
       full previous month manufactures a phantom "down 91%". Instead compare
       month-to-date against the SAME elapsed window last month. daysElapsed is
       today's day-of-month; the previous month's window is clamped to its own
       length (e.g. on the 31st, Feb's window is its 28/29 days = the whole
       month). curKey/prevKey identify those two buckets. */
    const cur0 = months[months.length - 1], prev0 = months[months.length - 2];
    const curKey = cur0.key, prevKey = prev0.key;
    const daysElapsed = t0.getDate();
    const monthLength = new Date(t0.getFullYear(), t0.getMonth() + 1, 0).getDate();
    const prevMonthLength = new Date(prev0.year, prev0.m + 1, 0).getDate();
    const prevWindowDay = Math.min(daysElapsed, prevMonthLength);
    let curMtd = 0, prevMtd = 0;
    const byClient = {};
    for (const inv of (db.INVOICES || [])) {
      if (inv.kind === "quote" || inv.status === "draft") continue;
      if ((inv.currency || homeCur) !== homeCur) continue;
      const billed = docSign(inv) * totals(inv).total;
      const k = keyOf(inv.issued);
      const mo = idx[k];
      if (mo) mo.billed = round2(mo.billed + billed);
      /* same-elapsed-days windows for the MoM denominator (day-of-issue parts) */
      const dom = new Date(inv.issued).getDate();
      if (k === curKey && dom <= daysElapsed) curMtd = round2(curMtd + billed);
      else if (k === prevKey && dom <= prevWindowDay) prevMtd = round2(prevMtd + billed);
      if (inv.clientId && new Date(inv.issued).getFullYear() === year) {
        byClient[inv.clientId] = round2((byClient[inv.clientId] || 0) + billed);
      }
    }
    for (const p of paymentsLedger(db, {})) {
      if ((p.currency || homeCur) !== homeCur) continue;
      const mo = idx[keyOf(p.at)];
      if (mo) mo.collected = round2(mo.collected + (Number(p.amount) || 0));
    }
    let daysSum = 0, daysN = 0;
    for (const inv of (db.INVOICES || [])) {
      if (inv.kind !== "invoice" || inv.status !== "paid" || !inv.paidOn) continue;
      daysSum += daysBetween(inv.issued, inv.paidOn);
      daysN++;
    }
    const topClients = Object.keys(byClient)
      .map(id => { const c = getClient(db, id); return { id, name: c ? c.name : id, total: byClient[id] }; })
      .sort((a, b) => b.total - a.total)
      .slice(0, 3);
    const cur = months[months.length - 1], prev = months[months.length - 2];
    const incomplete = daysElapsed < monthLength;
    /* Delta is MTD-vs-same-window so it is honest on a partial month; on a
       complete month curMtd === cur.billed, so the figure is unchanged. */
    return {
      months,
      daysToPaidAvg: daysN ? Math.round(daysSum / daysN) : null,
      topClients,
      mom: {
        billed: cur.billed, collected: cur.collected,
        prevBilled: prev.billed, prevCollected: prev.collected,
        /* same-elapsed-days figures the delta is actually computed from */
        mtdBilled: curMtd, prevMtdBilled: prevMtd,
        deltaPct: prevMtd ? Math.round((curMtd - prevMtd) / prevMtd * 100) : null,
        /* render hints: suppress alarm + label "N days in" while incomplete */
        incomplete, daysElapsed, monthLength,
      },
    };
  }

  /* Priority-ordered "Nita noticed" rules. Deterministic ids (a NEW target
     re-surfaces past a dismissal), filtered by db.DISMISSED, and it NEVER
     throws: each rule is feature-detected and fenced so one bad record
     cannot blank the Home column. Every ref carries the param `token` it
     replaces, so renderers substitute EntityLinks mechanically. */
  function nudges(db, today) {
    const out = [];
    const dismissed = {};
    ((db && db.DISMISSED) || []).forEach(id => { dismissed[id] = true; });
    const add = n => { if (!dismissed[n.id]) out.push(n); };
    const fence = fn => { try { fn(); } catch (e) { /* a rule never takes the surface down */ } };
    const cname = (id, fallback) => { const c = getClient(db, id); return c ? c.name : (fallback || ""); };
    const homeCur = (db.COMPANY && db.COMPANY.currency) || "EUR";

    /* 1 — most-overdue invoice NOT under autopilot (Nita is silent about
       the ones she already chases) */
    fence(() => {
      const late = (db.INVOICES || [])
        .filter(i => i.kind === "invoice" && effectiveStatus(i, today) === "overdue" && !(i.chase && i.chase.enabled))
        .sort((a, b) => (a.due < b.due ? -1 : a.due > b.due ? 1 : 0));
      const inv = late[0];
      if (!inv) return;
      const name = cname(inv.clientId, inv.newClientName);
      add({
        id: "overdue:" + inv.id, kind: "overdue", key: "nudge.overdue",
        params: { client: name, days: daysBetween(inv.due, today), id: inv.id, amount: window.fmtMoney(totals(inv).balance, inv.currency) },
        refs: [
          { kind: "client", id: inv.clientId, name, token: "client" },
          { kind: "invoice", id: inv.id, name: inv.id, token: "id" },
        ],
        cta: { key: "det.remind", nav: { kind: "remind", invoiceId: inv.id } },
      });
    });
    /* 2 — VAT declaration window (≤ 7 days or already late) */
    fence(() => {
      const d = nextDeclaration(db, today);
      if (d.daysLeft > 7) return;
      add({
        id: "vatdue:" + d.periodKey, kind: "vatdue", key: "nudge.vatdue",
        params: { days: d.daysLeft }, refs: [], badge: "accent",
        cta: { key: "nudge.vatdue.cta", nav: { kind: "route", tab: "home", route: "taxes", params: {} } },
      });
    });
    /* 3 — project budgets at ≥ 90% (accent once blown) */
    fence(() => {
      for (const c of (db.CLIENTS || [])) {
        for (const p of (c.projects || [])) {
          const b = projectBudget(db, p);
          if (!b || !b.budget || b.billed / b.budget < 0.9) continue;
          const pct = Math.round(b.billed / b.budget * 100);
          const n = {
            id: "budget:" + p.id, kind: "budget", key: "nudge.budget",
            params: { project: p.name, pct },
            refs: [{ kind: "project", id: p.id, name: p.name, token: "project" }],
            cta: { key: "nudge.budget.cta", nav: { kind: "route", tab: "clients", route: "project", params: { id: p.id } } },
          };
          if (pct >= 100) n.badge = "accent";
          add(n);
        }
      }
    });
    /* 4 — silent quotes: open, never reacted to, a week old */
    fence(() => {
      for (const q of (db.INVOICES || [])) {
        if (q.kind !== "quote" || effectiveStatus(q, today) !== "quote") continue;
        if ((q.audit || []).some(e => e.type === "viewed" || e.type === "accepted" || e.type === "declined")) continue;
        const days = daysBetween(q.issued, today);
        if (days < 7) continue;
        const name = cname(q.clientId, q.newClientName);
        add({
          id: "silentQuote:" + q.id, kind: "silentQuote", key: "nudge.silentQuote",
          params: { client: name, id: q.id, days },
          refs: [
            { kind: "client", id: q.clientId, name, token: "client" },
            { kind: "invoice", id: q.id, name: q.id, token: "id" },
          ],
          cta: { key: "nudge.silentQuote.cta", nav: { kind: "route", tab: "invoices", route: "invoice", params: { id: q.id } } },
        });
      }
    });
    /* 5 — the oldest draft past a week */
    fence(() => {
      const drafts = (db.INVOICES || [])
        .filter(i => i.status === "draft" && i.kind !== "quote")
        .sort((a, b) => (a.issued < b.issued ? -1 : 1));
      const inv = drafts[0];
      if (!inv) return;
      const days = daysBetween(inv.issued, today);
      if (days <= 7) return;
      const name = cname(inv.clientId, inv.newClientName);
      add({
        id: "staleDraft:" + inv.id, kind: "staleDraft", key: "nudge.staleDraft",
        params: { id: inv.id, client: name, days },
        refs: [
          { kind: "invoice", id: inv.id, name: inv.id, token: "id" },
          { kind: "client", id: inv.clientId, name, token: "client" },
        ],
        cta: { key: "nudge.staleDraft.cta", nav: { kind: "route", tab: "invoices", route: "invoice", params: { id: inv.id } } },
      });
    });
    /* 6 — billable expenses piling up */
    fence(() => {
      if (typeof expenseSummary !== "function") return;
      const s = expenseSummary(db);
      if (s.toBillTotal < 100) return;
      add({
        id: "expenses:" + s.toBillCount, kind: "expenses", key: "nudge.expenses",
        params: { count: s.toBillCount, amount: window.fmtMoney(s.toBillTotal, homeCur) },
        refs: [],
        cta: { key: "nudge.expenses.cta", nav: { kind: "route", tab: "home", route: "expenses", params: {} } },
      });
    });
    /* 7 — stock running out (worst = first out, else first low) */
    fence(() => {
      if (typeof lowStock !== "function") return;
      const low = lowStock(db.CATALOG);
      if (!low.length) return;
      const worst = low.find(it => stockState(it) === "out") || low[0];
      add({
        id: "stock:" + worst.id, kind: "stock", key: "nudge.stock",
        params: { name: worst.name, count: low.length },
        refs: [{ kind: "catalog", id: worst.id, name: worst.name, token: "name" }],
        cta: { key: "nudge.stock.cta", nav: { kind: "route", tab: "home", route: "catalog", params: {} } },
      });
    });
    /* 8 — recurrence candidate (reuses the rec.suggest.* copy; there are
       no nudge.recurrence keys) */
    fence(() => {
      if (typeof recurrenceCandidates !== "function") return;
      const c = recurrenceCandidates(db, today)[0];
      if (!c) return;
      add({
        id: "recurrence:" + c.clientId, kind: "recurrence", key: "rec.suggest.body",
        params: { client: c.clientName, amount: window.fmtMoney(c.amount, c.currency) },
        refs: [{ kind: "client", id: c.clientId, name: c.clientName, token: "client" }],
        cta: { key: "rec.suggest.cta", nav: { kind: "recurrence", lastId: c.lastId } },
      });
    });
    /* 9 — month-over-month swing of ±20% or more */
    fence(() => {
      if (typeof insights !== "function") return;
      const ins = insights(db, today);
      if (ins.mom.deltaPct == null || Math.abs(ins.mom.deltaPct) < 20) return;
      const cur = ins.months[ins.months.length - 1];
      add({
        id: "mom:" + cur.key, kind: "mom",
        key: ins.mom.deltaPct >= 0 ? "nudge.mom.up" : "nudge.mom.down",
        params: { pct: Math.abs(ins.mom.deltaPct) },
        refs: [],
        cta: { key: "nudge.mom.cta", nav: { kind: "route", tab: "home", route: "insights", params: {} } },
      });
    });
    return out;
  }

  /* Headless "client opened it" simulation. Anti-backlog window rule: an
     invoice fires ONLY when its `sent` audit time s >= fromISO AND
     min(s+1d, toISO) falls inside (fromISO, toISO] — documents sent before
     the advance began never retro-fire. One viewed event per invoice, ever. */
  function fireViews(db, fromISO, toISO) {
    const fired = [];
    let ACTIVITY = db.ACTIVITY || [];
    const INVOICES = (db.INVOICES || []).map(inv => {
      if (inv.kind !== "invoice" || inv.status === "draft" || inv.status === "paid") return inv;
      const audit = inv.audit || [];
      if (audit.some(e => e.type === "viewed")) return inv;
      const sentEv = audit.find(e => e.type === "sent");
      if (!sentEv || !sentEv.at) return inv;
      if (daysBetween(fromISO, sentEv.at) < 0) return inv;
      const plus1 = addDays(sentEv.at, 1);
      const at = daysBetween(plus1, toISO) >= 0 ? plus1 : toISO;
      if (!(daysBetween(fromISO, at) > 0 && daysBetween(at, toISO) >= 0)) return inv;
      const client = getClient(db, inv.clientId);
      fired.push({ id: inv.id, at });
      ACTIVITY = [activityEvent("viewed", { client: client ? client.name : (inv.newClientName || ""), id: inv.id }, inv.id, at), ...ACTIVITY];
      return pushAudit(inv, auditEvent("viewed", {}, null, at));
    });
    return fired.length ? { INVOICES, ACTIVITY, fired } : null;
  }

  /* Bell drawer feed: ACTIVITY newest-first bucketed today / this week /
     earlier (empty groups omitted); unread counts strict `read === false`
     so legacy items without the flag stay quiet. */
  function notifications(db, today) {
    const sorted = ((db && db.ACTIVITY) || []).slice().sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
    const buckets = { today: [], week: [], earlier: [] };
    for (const a of sorted) {
      const d = daysBetween(a.at, today);
      if (d <= 0) buckets.today.push(a);
      else if (d <= 7) buckets.week.push(a);
      else buckets.earlier.push(a);
    }
    return {
      groups: ["today", "week", "earlier"].filter(k => buckets[k].length).map(k => ({ key: k, items: buckets[k] })),
      unread: ((db && db.ACTIVITY) || []).filter(a => a.read === false).length,
    };
  }

  /* `ids` is an id array or 'all'. */
  function markRead(activity, ids) {
    const all = ids === "all";
    const set = {};
    if (!all) (ids || []).forEach(id => { set[id] = true; });
    return (activity || []).map(a => (all || set[a.id]) ? (a.read === true ? a : { ...a, read: true }) : a);
  }

  /* Most recent real invoice for a client (quotes AND credits excluded —
     an avoir must never become "last billed"). */
  function lastBilled(db, clientId) {
    let best = null;
    for (const inv of (db.INVOICES || [])) {
      if (inv.clientId !== clientId || (inv.kind || "invoice") !== "invoice" || inv.status === "draft") continue;
      if (!best || inv.issued > best.issued) best = inv;
    }
    return best ? { id: best.id, issued: best.issued, total: totals(best).total, status: best.status } : null;
  }

  /* The clipboard payload for the recap's Copy summary — pre-formatted. */
  function recapText(db, today) {
    const ins = insights(db, today);
    const cur = ins.months[ins.months.length - 1];
    const homeCur = (db.COMPANY && db.COMPANY.currency) || "EUR";
    return window.t("insights.share.text", {
      month: window.t("monfull." + cur.m),
      billed: window.fmtMoney(cur.billed, homeCur),
      collected: window.fmtMoney(cur.collected, homeCur),
      days: ins.daysToPaidAvg == null ? 0 : ins.daysToPaidAvg,
    });
  }

  /* ── Demo clock: scheduler engine ──────────────────────────────────
     Effects are pure db→db, run per simulated day in `order`, and stamp
     explicit `at` dates (never nowISO — the wall clock would drift). The
     engine never special-cases an effect by name; re-registering an id
     replaces it. Live reserved orders: 10 recurrence, 20 dunning, 40 viewed. */
  const SCHEDULER_EFFECTS = [];
  function registerSchedulerEffect(effect) {
    const i = SCHEDULER_EFFECTS.findIndex(e => e.id === effect.id);
    if (i >= 0) SCHEDULER_EFFECTS[i] = effect; else SCHEDULER_EFFECTS.push(effect);
    SCHEDULER_EFFECTS.sort((a, b) => (a.order || 0) - (b.order || 0));
  }

  /* Walk the clock forward day by day (clamped 1..90), threading db through
     every effect in order; ctx.report collects one entry per fired action.
     Finally pins db.clock to the target. Fresh db: clock moves, report
     stays empty. The engine never touches CATALOG, never flips draft→sent. */
  function advanceClock(db, days) {
    const n = Math.max(1, Math.min(90, Math.round(Number(days) || 1)));
    const from = db.clock || ((window.DATA && window.DATA.TODAY) || nowISO());
    const ctx = { today: addDays(from, n), report: [] };
    let next = db;
    for (let i = 1; i <= n; i++) {
      const D = addDays(from, i);
      for (const eff of SCHEDULER_EFFECTS) next = eff.run(next, D, ctx) || next;
    }
    return { db: { ...next, clock: ctx.today }, today: ctx.today, report: ctx.report };
  }

  /* Effect 10 — recurrence. Idempotent by pointer (advancing +7 twice fires
     once); max 3 materializations per template per advanceClock call, the
     pointer staying put beyond the cap (the cap itself is reported). */
  registerSchedulerEffect({
    id: "recurrence", order: 10,
    run: (db, D, ctx) => {
      let next = db;
      const runs = ctx._recurrenceRuns || (ctx._recurrenceRuns = {});
      for (const T of (db.INVOICES || [])) {
        if (!T.recurrence) continue;
        let cur = next.INVOICES.find(i => i.id === T.id);
        while (cur && cur.recurrence && daysBetween(cur.recurrence.nextRun, D) >= 0) {
          if ((runs[T.id] || 0) >= 3) {
            if (runs[T.id] === 3) { ctx.report.push({ effect: "recurrence", type: "cap", ref: T.id, at: D, params: {} }); runs[T.id] = 4; }
            break;
          }
          const runDate = cur.recurrence.nextRun;
          const r = materializeRun(next, T.id, runDate);
          next = r.db;
          const advanced = nextCadence(runDate, cur.recurrence.every);
          next = { ...next, INVOICES: next.INVOICES.map(i => i.id === T.id ? { ...i, recurrence: { ...i.recurrence, nextRun: advanced } } : i) };
          runs[T.id] = (runs[T.id] || 0) + 1;
          ctx.report.push({ effect: "recurrence", type: "recurring", ref: r.draftId, at: runDate, params: { from: T.id } });
          cur = next.INVOICES.find(i => i.id === T.id);
        }
      }
      return next;
    },
  });

  /* Effect 20 — dunning. Tone by STEP INDEX, not by overdue days (the
     manual SendSheet keeps its odDays default); writes through sendWrite,
     which bumps chase.step. Cap: 10 headless sends per advanceClock call
     (overflow reported once); skips silently: paid/draft/quote/credit,
     chase disabled, step exhausted. */
  registerSchedulerEffect({
    id: "dunning", order: 20,
    run: (db, D, ctx) => {
      let next = db;
      const offsets = (db.COMPANY && db.COMPANY.dunning && db.COMPANY.dunning.offsets) || [3, 10, 21];
      for (const I of (db.INVOICES || [])) {
        if (I.kind !== "invoice" || !I.chase || !I.chase.enabled) continue;
        const step = I.chase.step || 0;
        if (step >= 3) continue;
        if (effectiveStatus(I, D) !== "overdue") continue;
        if (daysBetween(addDays(I.due, offsets[step] != null ? offsets[step] : 0), D) < 0) continue;
        if ((ctx._dunningSent || 0) >= 10) {
          if (!ctx._dunningOverflow) { ctx._dunningOverflow = true; ctx.report.push({ effect: "dunning", type: "overflow", ref: I.id, at: D, params: {} }); }
          continue;
        }
        const tone = ["gentle", "firm", "final"][step];
        next = sendWrite(next, I.id, { mode: "remind.auto", tone, at: D });
        ctx._dunningSent = (ctx._dunningSent || 0) + 1;
        ctx.report.push({ effect: "dunning", type: "reminder.auto", ref: I.id, at: D, params: { tone } });
      }
      return next;
    },
  });

  /* Effect 40 — viewed (order 30 stays unreserved: quote expiry is DERIVED). */
  registerSchedulerEffect({
    id: "viewed", order: 40,
    run: (db, D, ctx) => {
      const r = fireViews(db, addDays(D, -1), D);
      if (!r) return db;
      r.fired.forEach(f => ctx.report.push({ effect: "viewed", type: "viewed", ref: f.id, at: f.at, params: {} }));
      return { ...db, INVOICES: r.INVOICES, ACTIVITY: r.ACTIVITY };
    },
  });

  /* ── db.config normalizer (the Postgres-migration analog) ────────
     Backfills db.config from COMPANY / window.CURRENCIES for already-persisted
     tester dbs that predate FIX 2 (DB_KEY nita_tester_db_v1 has no config),
     and tops up any missing sub-trees on partial saves. Mutates + returns db
     so every load path can call it inline. Defaults come from window.DATA. */
  function ensureConfig(db) {
    if (!db) return db;
    const D = (window.DATA && window.DATA.defaults) || {};
    const co = db.COMPANY || {};
    const cfg = db.config || (db.config = {});
    if (!Array.isArray(cfg.taxes) || !cfg.taxes.length) {
      const src = (Array.isArray(co.taxes) && co.taxes.length ? co.taxes : (D.taxes ? D.taxes() : []));
      cfg.taxes = src.map((tx, i) => ({
        id: tx.id || ("tx-" + (tx.name || "vat") + "-" + tx.rate),
        name: tx.name || "VAT", rate: Number(tx.rate) || 0,
        default: tx.default != null ? !!tx.default : Number(tx.rate) === Number(co.taxRate),
      }));
      if (!cfg.taxes.some(t => t.default) && cfg.taxes.length) {
        const m = cfg.taxes.find(t => Number(t.rate) === Number(co.taxRate));
        (m || cfg.taxes[0]).default = true;
      }
    }
    if (!Array.isArray(cfg.expenseCats) || !cfg.expenseCats.length) {
      cfg.expenseCats = D.expenseCats ? D.expenseCats() : [];
    }
    if (!Array.isArray(cfg.currencies) || !cfg.currencies.length) {
      cfg.currencies = D.currencies ? D.currencies() : Object.values(window.CURRENCIES || {});
    }
    if (!cfg.numbering) {
      cfg.numbering = {
        invoice: { pattern: co.numbering || "INV-{YYYY}-{seq}", next: co.nextSeq || 1 },
        quote: { pattern: "QUO-{YYYY}-{seq}", next: co.nextQuoteSeq || 1 },
        credit: { pattern: "AVO-{YYYY}-{seq}", next: co.nextCreditSeq || 1 },
      };
    }
    if (!Array.isArray(cfg.paymentMethods) || !cfg.paymentMethods.length) {
      cfg.paymentMethods = [
        { id: "card", label: "exp.pay.card", icon: "card" },
        { id: "cash", label: "exp.pay.cash", icon: "coins" },
        { id: "transfer", label: "exp.pay.transfer", icon: "bank" },
      ];
    }
    if (cfg.businessType == null) cfg.businessType = co.businessType || "both";
    if (cfg.terms == null) cfg.terms = co.terms != null ? co.terms : 30;
    if (cfg.locale == null) cfg.locale = db.locale || "en";
    if (cfg.theme == null) cfg.theme = co.theme || "light";
    return db;
  }

  /* Make an off-list tax rate (e.g. a typed 10%) a first-class, selectable
     tax: append {id:'tx-'+rate, name, rate, default:false} to BOTH config.taxes
     and the mirrored COMPANY.taxes (Settings reads both) when no entry already
     matches that rate+name. Idempotent — returns the same db reference unchanged
     when nothing was added, so callers can no-op safely. Toasts once on append. */
  function ensureConfigTax(db, tax) {
    if (!db || !tax) return db;
    const rate = Number(tax.rate);
    if (!isFinite(rate) || rate < 0) return db;
    const name = (tax.name || "VAT");
    const matches = (t) => Number(t.rate) === rate && (t.name || "VAT") === name;
    const cfgTaxes = (db.config && db.config.taxes) || [];
    const coTaxes = (db.COMPANY && db.COMPANY.taxes) || [];
    const inCfg = cfgTaxes.some(matches);
    const inCo = coTaxes.some(matches);
    if (inCfg && inCo) return db;
    const entry = { id: tax.id || ("tx-" + rate), name, rate, default: false };
    const out = { ...db };
    if (!inCfg) out.config = { ...(db.config || {}), taxes: [...cfgTaxes, entry] };
    if (!inCo) out.COMPANY = { ...(db.COMPANY || {}), taxes: [...coTaxes, entry] };
    if (window.Q && window.Q.toast) window.Q.toast(window.t("toast.taxAddedToConfig", { rate }), "percent");
    return out;
  }

  /* Live currency map: builtin window.CURRENCIES merged with user-added
     config.currencies (config wins). Read sites use this so a user currency
     gets a symbol/name everywhere. */
  function currencyMap(db) {
    const out = Object.assign({}, window.CURRENCIES || {});
    const list = (db && db.config && db.config.currencies) || [];
    for (const c of list) { if (c && c.code) out[c.code] = { code: c.code, symbol: c.symbol || c.code, name: c.name || c.code }; }
    return out;
  }

  window.LIB = {
    round2, totals, lineTotal, lineNet, lineTax, getClient, daysBetween, addDays, effectiveStatus,
    ensureConfig, ensureConfigTax, currencyMap,
    dueLabel, summary, clientBalance, clientInvoices, nextNumber, einvoiceCheck,
    epcString, nitaString, payQR, UNITS, unitLabel,
    snapshotParties, vatModeMention,
    businessDefaults, newId, getProject, projectRate, projectTeamLines, projectBudget,
    catalogToLine, decrementStock, stockState, lowStock,
    expenseToLine, expenseSummary, spendThisMonth, cleanItem,
    auditColor, auditIcon, auditEvent, pushAudit, fmtEventParams, timelineFromAudit, activityEvent,
    fmtPct,
    // F2 recurrence
    nextCadence, materializeRun, upcomingRuns, recurrenceCandidates, enableRecurrence,
    // F4 autopilot dunning
    chaseSchedule, dunningDigest, overdueSummary, worstPayer, sendWrite,
    // F5/F6 documents
    docSign, quotePipeline, quoteDeposits, acceptQuote, declineQuote, convertQuote, createDeposit, issueCredit,
    // F3 payments + exports
    paymentsOf, recordPayment, paymentsLedger, isInvoiceRow, selectInvoices, selectionSummary,
    csvInvoices, csvPayments, fecPreview, accountantPack,
    // F1 taxes
    taxPeriods, taxReport, urssafReport, nextDeclaration,
    // F8/F9/F10 intelligence + surfaces
    payLink, insights, nudges, fireViews, notifications, markRead, lastBilled, recapText,
    // demo clock engine
    registerSchedulerEffect, advanceClock,
  };
})();
