# Ledgerline Billing, Marketplace & CRM — Combined Requirements & Build Specification

**Product:** Ledgerline — fintech platform-as-a-service (apps, external APIs, managed cloud)
**Stack:** React 18 (web) · .NET 8 Web API · PostgreSQL 15 · EF Core 8 · Hangfire (workers) · Serilog
**Reference prototype:** `ledgerline-billing-prototype.html` — open it. Every screen in this document is that prototype. Persona switcher top-right toggles provider console ↔ customer portal.
**Version:** 1.0 · September 2026

---

## How to use this document

Written to be handed to three audiences at once, and to be fed directly to an AI coding assistant.

| You are… | Read |
|---|---|
| Executive / product | §1–§4, §7 screen purposes, §16 decisions, §14 delivery plan |
| Backend engineer | §5 + §18 (tables), §6 (billing engine), §8 + §19 (API ↔ tables), §9 (.NET structure), §20 (seed) |
| Frontend engineer | §7 (screens: fields, actions, components), §8 (API), §10 (React structure) |
| QA | §7 acceptance criteria, §11 state machines, §13 test matrix, §20 seed data |
| AI assistant | §17 — generation prompts, in order. §18–§20 for tables and seed. |

**Conventions used throughout**

- `Customer` = the account (a fintech company that buys from us). Never called "client" in code.
- `CustomerContact` = a person at that account.
- `CatalogItem` = one sellable thing in the marketplace. Two categories only: **App** and **API**. There is no third category.
- `SubscriptionItem` = one `CatalogItem` attached to one `Customer` at an agreed price and term.
- **Term** is `Monthly` or `Annual`. **There is no lifetime term.** Enforced by a DB CHECK constraint.
- Every money column is `NUMERIC(18,4)`, currency is USD everywhere, rounding to 2dp happens only at line and total level.
- **Bold rules** are non-negotiable invariants. They live in the domain layer and in DB constraints, not in the UI.

---

## 1. Executive summary

Today revenue is assembled by hand: someone reads a contract, opens a spreadsheet, remembers which modules a customer switched on last month, asks the cloud team what AWS cost, asks compliance how many Sumsub checks ran, and types an invoice. Setup fees get missed. Customers ask for a new module by email and nobody bills it for two months.

This project builds one system where the **customer record is the master**, and everything chargeable hangs off it:

| Revenue stream | What it is | Billed |
|---|---|---|
| **Apps** | Platform modules and marketplace add-ons — Core & wallets, Cards, Banks, Payments, Exchange, Multi-tenancy, Treasury, MoneyOS, Payroll | Recurring, monthly or annual, + one-time setup fee per item |
| **APIs** | Headless products sold to customers who build their own front end — Cards, Payments, Banks, Exchange, Wallets | Recurring tier fee + metered overage in arrears + setup fee |
| **Cloud** | AWS / Azure / GCP accounts we operate on the customer's behalf | Provider cost + management markup, in arrears |
| **Transactions** | Pass-through per-event fees — Sumsub KYC, AML screening, processing, card issuance, payout | Cost + margin, metered, in arrears |

**Four things this spec insists on**

1. **One marketplace, two categories.** Apps and APIs are rows in the same table with the same lifecycle. Adding a module is a data change, not a deploy.
2. **The setup fee is tracked per subscription item, not per customer.** The single most common billing bug in this product class: a customer-level "setup charged" flag means every module added after the first invoice is silently free forever.
3. **An invoice is editable until it is raised, and never after.** Discounts are applied at draft time, by a human, with an audit trail.
4. **A customer request is a first-class object.** The portal raises it, an admin approves it, and billing starts from the approval date — not from the request date, and not from a forgotten email.

**Shape of the work.** Roughly five months to full scope. The first working slice — onboard a customer, subscribe apps, raise and pay one invoice — lands in about six weeks.

---

## 2. Scope

### 2.1 In scope

| Group | Capability |
|---|---|
| CRM | Accounts, contacts, stages, owners, meetings, calls, notes, follow-ups |
| Onboarding | Guided 5-step wizard that creates account + subscription + cloud + fees + first invoice draft |
| Catalog | Marketplace of Apps and APIs, list prices, setup fees, included volumes, overage rates |
| Subscriptions | Per-customer items, negotiated price overrides, term switching, effective dating |
| Requests | Portal-raised requests for a new app or API; approve/decline; billing starts on approval |
| Metering | API call counts, transaction event counts, cloud provider costs, all per period |
| Invoicing | Draft builder with full line editing, line discounts, invoice discount, four invoice types |
| Payments | Bank transfer and crypto (USDT/USDC/BTC), proof submission, admin verification |
| Receivables | Ageing buckets, statements, reminders, dunning, late fees |
| Portal | Customer-facing subscription, usage, requests, invoices, payment |

### 2.2 Out of scope (v1, named so nobody assumes)

- Automated card/direct-debit collection. Payment is bank transfer or crypto, verified by a human.
- Multi-currency. Everything is USD. FX is a v2 item and the schema leaves room (`currency_code` columns exist and are constrained to `'USD'`).
- Revenue recognition / deferred revenue schedules for annual terms. v1 invoices annual up front and records it; ASC 606 schedules are v2.
- Automatic cloud cost ingestion from AWS/Azure/GCP billing APIs. v1 imports a monthly figure (manual entry or CSV); the connector is v2 and the schema is already shaped for it.
- Self-service signup. Accounts are created by our team.

### 2.3 Things that must never happen

These are the failure modes that cost money or trust. Each has a test named after it in §13.

- A setup fee billed **twice** for the same subscription item.
- A setup fee **never** billed because a customer-level flag was already set.
- A line edit applied to an invoice that has already been **issued**.
- Metered overage billed **in advance**, before the period closed.
- A request approved with billing backdated to the request date.
- Cloud rebilled at a markup the customer never agreed to, because the markup was read live instead of snapshotted onto the invoice line.
- A payment marked **Paid** without a reference that ties it to a bank statement or a chain transaction.

---

## 3. Domain model and vocabulary

```
Customer ─┬─ CustomerContact          (people)
          ├─ CustomerActivity         (meetings, calls, notes, follow-ups)
          ├─ Subscription ── SubscriptionItem ──> CatalogItem
          │                      └─ SubscriptionItemChange  (audit)
          ├─ ChangeRequest ──────────> CatalogItem
          ├─ CloudAccount ── CloudCostPeriod
          ├─ TxUsagePeriod ─────────> TxFeeRate
          ├─ ApiUsagePeriod ────────> CatalogItem (kind = Api)
          └─ Invoice ──┬─ InvoiceLine
                       ├─ InvoiceStatusHistory
                       └─ Payment
```

**Vocabulary, fixed**

| Term | Meaning | Not called |
|---|---|---|
| Customer | The buying company | Client, tenant, org |
| Catalog item | One sellable app or API | Module, product, SKU |
| Subscription item | A catalog item attached to a customer | Line, entitlement |
| Term | Monthly or Annual | Plan, cycle |
| Setup fee | One-time charge per subscription item | Onboarding fee, implementation |
| Period | The billing month, e.g. `2026-09` | Cycle, month |
| Raise | Move an invoice from Draft to Issued | Send, publish, finalise |
| Rebill | Pass a cloud cost on with markup | Mark up, resell |

---

## 4. Roles

| Role | Can do | Cannot do |
|---|---|---|
| **Account owner** (sales/CS) | Create accounts, log activity, change subscription items, propose price overrides, build draft invoices | Raise an invoice above their discount authority; verify a payment |
| **Billing operator** | Everything above, plus raise invoices, verify payments, run dunning, edit catalog prices | Change tax settings, change bank/wallet details |
| **Finance admin** | Everything, plus settings, bank accounts, wallets, tax rate, write-offs, credit notes | — |
| **Auditor** | Read everything, export | Mutate anything |
| **Customer user** (portal) | View own subscription, usage, invoices; raise requests; submit payment proof | See cost/margin on cloud or transaction fees; see other customers |

**Non-negotiable:** cost columns (`CloudCostPeriod.provider_cost`, `TxFeeRate.unit_cost`) are **never** serialised into any portal response. Enforced by separate DTOs, and by an architecture test that fails the build if a portal controller returns a type containing those fields.

---

## 5. Data model

### 5.1 Conventions for every table

- Primary keys are `uuid` (`gen_random_uuid()`).
- Every table has `created_at_utc timestamptz not null default now()`, `created_by uuid null`.
- Mutable tables add `updated_at_utc`, `updated_by`, `row_version bytea` (EF concurrency token).
- Soft delete only where stated (`is_archived bool`); everything else is hard-deleted or never deleted.
- Every customer-scoped table carries `customer_id` and is indexed on it. Row-level security policy `customer_isolation` is enabled on portal-facing connections.

### 5.2 New tables

```sql
-- ═══════════════════════════════════════════════════════════
-- 5.2.1  CRM
-- ═══════════════════════════════════════════════════════════

CREATE TYPE customer_stage AS ENUM ('Lead','Discovery','Pilot','Live','Churned');
CREATE TYPE customer_status AS ENUM ('Prospect','Trial','Active','Suspended','Closed');
CREATE TYPE billing_cycle AS ENUM ('Monthly','Annual');

CREATE TABLE customer (
    customer_id        uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    code               text NOT NULL UNIQUE,              -- 'C-1001', human reference
    display_name       text NOT NULL,                     -- 'GlobalBridge'
    legal_name         text NOT NULL,                     -- 'GlobalBridge Financial Services Ltd'
    country_code       char(2) NOT NULL,
    website            text NULL,
    stage              customer_stage NOT NULL DEFAULT 'Lead',
    status             customer_status NOT NULL DEFAULT 'Prospect',
    owner_user_id      uuid NOT NULL,                     -- our staff, FK to identity
    source             text NULL,                         -- 'Inbound','Outbound','Referral','Partner','Event'
    tax_registration   text NULL,                         -- GST / VAT / EIN / TRN
    currency_code      char(3) NOT NULL DEFAULT 'USD' CHECK (currency_code = 'USD'),
    standard_discount_pct numeric(5,2) NOT NULL DEFAULT 0
                          CHECK (standard_discount_pct >= 0 AND standard_discount_pct <= 60),
    billing_cycle      billing_cycle NOT NULL DEFAULT 'Monthly',
    payment_terms_days int NOT NULL DEFAULT 14,
    cloud_managed      bool NOT NULL DEFAULT false,
    customer_since     date NULL,
    notes              text NULL,
    created_at_utc     timestamptz NOT NULL DEFAULT now(),
    updated_at_utc     timestamptz NULL,
    row_version        bytea NOT NULL DEFAULT '\x00'
);
CREATE INDEX ix_customer_stage  ON customer(stage) WHERE status <> 'Closed';
CREATE INDEX ix_customer_owner  ON customer(owner_user_id);

CREATE TABLE customer_contact (
    contact_id     uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id    uuid NOT NULL REFERENCES customer ON DELETE CASCADE,
    full_name      text NOT NULL,
    job_title      text NULL,
    email          citext NOT NULL,
    phone          text NULL,
    is_primary     bool NOT NULL DEFAULT false,
    is_billing     bool NOT NULL DEFAULT false,   -- receives invoices
    portal_user_id uuid NULL,                     -- set when they can log in
    is_archived    bool NOT NULL DEFAULT false,
    created_at_utc timestamptz NOT NULL DEFAULT now()
);
-- Exactly one primary contact per customer.
CREATE UNIQUE INDEX ux_contact_primary ON customer_contact(customer_id)
    WHERE is_primary AND NOT is_archived;
CREATE INDEX ix_contact_customer ON customer_contact(customer_id);

CREATE TYPE activity_type AS ENUM ('Meeting','Call','Note','Email');

CREATE TABLE customer_activity (
    activity_id     uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id     uuid NOT NULL REFERENCES customer ON DELETE CASCADE,
    type            activity_type NOT NULL,
    title           text NOT NULL,
    occurred_on     date NOT NULL,
    attendees       text NULL,                 -- free text; contact_ids in activity_attendee
    body            text NULL,
    follow_up_text  text NULL,
    follow_up_due   date NULL,
    follow_up_done  bool NOT NULL DEFAULT false,
    follow_up_done_at timestamptz NULL,
    logged_by       uuid NOT NULL,
    created_at_utc  timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT ck_followup_pair CHECK (
        (follow_up_text IS NULL AND follow_up_due IS NULL) OR follow_up_text IS NOT NULL)
);
CREATE INDEX ix_activity_customer_date ON customer_activity(customer_id, occurred_on DESC);
CREATE INDEX ix_activity_open_followup ON customer_activity(follow_up_due)
    WHERE follow_up_text IS NOT NULL AND NOT follow_up_done;

CREATE TABLE activity_attendee (      -- optional join to real contacts
    activity_id uuid NOT NULL REFERENCES customer_activity ON DELETE CASCADE,
    contact_id  uuid NOT NULL REFERENCES customer_contact ON DELETE CASCADE,
    PRIMARY KEY (activity_id, contact_id)
);

-- ═══════════════════════════════════════════════════════════
-- 5.2.2  Marketplace catalog  (Apps + APIs, one table)
-- ═══════════════════════════════════════════════════════════

CREATE TYPE catalog_kind  AS ENUM ('App','Api');
CREATE TYPE billing_term  AS ENUM ('Monthly','Annual');   -- NO 'Lifetime'. Ever.
CREATE TYPE overage_basis AS ENUM ('PerCall','Per1000');

CREATE TABLE catalog_item (
    catalog_item_id  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    code             text NOT NULL UNIQUE,        -- 'AP01','AI03'
    kind             catalog_kind NOT NULL,
    group_name       text NOT NULL,               -- 'Apps' | 'External APIs'
    display_name     text NOT NULL,               -- 'Core & wallets'
    description      text NOT NULL,
    is_core          bool NOT NULL DEFAULT false, -- cannot be removed once subscribed
    setup_fee        numeric(18,4) NOT NULL DEFAULT 0 CHECK (setup_fee >= 0),
    price_monthly    numeric(18,4) NOT NULL CHECK (price_monthly >= 0),
    price_annual     numeric(18,4) NOT NULL CHECK (price_annual  >= 0),
    -- metering, APIs only
    included_calls   bigint NULL CHECK (included_calls >= 0),
    overage_rate     numeric(18,6) NULL CHECK (overage_rate >= 0),
    overage_basis    overage_basis NULL,
    sort_order       int NOT NULL DEFAULT 0,
    is_listed        bool NOT NULL DEFAULT true,  -- visible in portal marketplace
    is_sellable      bool NOT NULL DEFAULT true,  -- can be newly subscribed
    created_at_utc   timestamptz NOT NULL DEFAULT now(),
    updated_at_utc   timestamptz NULL,
    row_version      bytea NOT NULL DEFAULT '\x00',
    CONSTRAINT ck_group_matches_kind CHECK (
        (kind = 'App' AND group_name = 'Apps') OR
        (kind = 'Api' AND group_name = 'External APIs')),
    CONSTRAINT ck_api_metering CHECK (
        (kind = 'Api'  AND included_calls IS NOT NULL
                       AND overage_rate   IS NOT NULL
                       AND overage_basis  IS NOT NULL) OR
        (kind = 'App'  AND included_calls IS NULL
                       AND overage_rate   IS NULL
                       AND overage_basis  IS NULL))
);

-- List price changes are history, never an in-place overwrite of what was billed.
CREATE TABLE catalog_item_price_history (
    price_history_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    catalog_item_id  uuid NOT NULL REFERENCES catalog_item,
    effective_from   date NOT NULL,
    setup_fee        numeric(18,4) NOT NULL,
    price_monthly    numeric(18,4) NOT NULL,
    price_annual     numeric(18,4) NOT NULL,
    included_calls   bigint NULL,
    overage_rate     numeric(18,6) NULL,
    changed_by       uuid NOT NULL,
    reason           text NULL,
    created_at_utc   timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_price_history_item ON catalog_item_price_history(catalog_item_id, effective_from DESC);

-- ═══════════════════════════════════════════════════════════
-- 5.2.3  Subscriptions
-- ═══════════════════════════════════════════════════════════

CREATE TYPE subscription_status AS ENUM ('Trial','Active','Suspended','Cancelled');
CREATE TYPE setup_state         AS ENUM ('Due','Billed','Waived');

CREATE TABLE subscription (
    subscription_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id     uuid NOT NULL REFERENCES customer,
    status          subscription_status NOT NULL DEFAULT 'Trial',
    started_on      date NOT NULL,
    trial_ends_on   date NULL,
    cancelled_on    date NULL,
    created_at_utc  timestamptz NOT NULL DEFAULT now(),
    row_version     bytea NOT NULL DEFAULT '\x00'
);
-- One live subscription per customer. Historic cancelled ones may remain.
CREATE UNIQUE INDEX ux_subscription_live ON subscription(customer_id)
    WHERE status IN ('Trial','Active','Suspended');

CREATE TABLE subscription_item (
    subscription_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    subscription_id  uuid NOT NULL REFERENCES subscription ON DELETE CASCADE,
    customer_id      uuid NOT NULL REFERENCES customer,          -- denormalised for RLS + indexes
    catalog_item_id  uuid NOT NULL REFERENCES catalog_item,
    term             billing_term NOT NULL,
    -- Negotiated price. Defaults to catalog list price at the time of subscribing.
    unit_price       numeric(18,4) NOT NULL CHECK (unit_price >= 0),
    list_price_at_subscribe numeric(18,4) NOT NULL,              -- for "override vs list" reporting
    -- Setup fee is per item. This is the fix for the single worst bug in this product class.
    setup_fee        numeric(18,4) NOT NULL DEFAULT 0 CHECK (setup_fee >= 0),
    setup_state      setup_state NOT NULL DEFAULT 'Due',
    setup_billed_on_invoice_id uuid NULL,                        -- FK added after invoice table
    setup_waived_reason text NULL,
    -- Effective dating drives proration.
    effective_from   date NOT NULL,
    effective_to     date NULL,                                  -- NULL = still active
    term_renews_on   date NULL,                                  -- annual terms only
    source           text NOT NULL DEFAULT 'Admin',              -- 'Admin' | 'Onboarding' | 'Request'
    source_request_id uuid NULL,
    created_at_utc   timestamptz NOT NULL DEFAULT now(),
    updated_at_utc   timestamptz NULL,
    row_version      bytea NOT NULL DEFAULT '\x00',
    CONSTRAINT ck_setup_billed_ref CHECK (
        (setup_state = 'Billed' AND setup_billed_on_invoice_id IS NOT NULL) OR
        (setup_state <> 'Billed')),
    CONSTRAINT ck_setup_waived_reason CHECK (
        (setup_state = 'Waived' AND setup_waived_reason IS NOT NULL) OR
        (setup_state <> 'Waived')),
    CONSTRAINT ck_effective_range CHECK (effective_to IS NULL OR effective_to >= effective_from)
);
-- A customer cannot hold the same catalog item twice at the same time.
CREATE UNIQUE INDEX ux_subitem_active ON subscription_item(customer_id, catalog_item_id)
    WHERE effective_to IS NULL;
CREATE INDEX ix_subitem_customer ON subscription_item(customer_id);
CREATE INDEX ix_subitem_setup_due ON subscription_item(customer_id)
    WHERE setup_state = 'Due' AND effective_to IS NULL;

-- Every add / remove / term change / price override, append-only.
CREATE TYPE sub_change_kind AS ENUM ('Added','Removed','TermChanged','PriceChanged','SetupWaived');

CREATE TABLE subscription_item_change (
    change_id   uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    subscription_item_id uuid NOT NULL REFERENCES subscription_item ON DELETE CASCADE,
    customer_id uuid NOT NULL,
    kind        sub_change_kind NOT NULL,
    old_value   jsonb NULL,
    new_value   jsonb NULL,
    effective_on date NOT NULL,
    reason      text NULL,
    changed_by  uuid NOT NULL,
    created_at_utc timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_subchange_item ON subscription_item_change(subscription_item_id, created_at_utc DESC);

-- ═══════════════════════════════════════════════════════════
-- 5.2.4  Customer requests (portal → admin approval)
-- ═══════════════════════════════════════════════════════════

CREATE TYPE request_status AS ENUM ('Pending','Approved','Declined','Cancelled');

CREATE TABLE change_request (
    request_id       uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    code             text NOT NULL UNIQUE,            -- 'RQ-101'
    customer_id      uuid NOT NULL REFERENCES customer,
    catalog_item_id  uuid NOT NULL REFERENCES catalog_item,
    requested_term   billing_term NOT NULL,
    justification    text NULL,
    status           request_status NOT NULL DEFAULT 'Pending',
    requested_by_contact_id uuid NULL REFERENCES customer_contact,
    requested_at_utc timestamptz NOT NULL DEFAULT now(),
    decided_by       uuid NULL,
    decided_at_utc   timestamptz NULL,
    decision_note    text NULL,
    -- Set on approval. Billing starts here, never earlier.
    effective_from   date NULL,
    created_subscription_item_id uuid NULL REFERENCES subscription_item,
    quoted_price     numeric(18,4) NOT NULL,          -- price shown to customer when they asked
    quoted_setup_fee numeric(18,4) NOT NULL,
    row_version      bytea NOT NULL DEFAULT '\x00',
    CONSTRAINT ck_decision_fields CHECK (
        (status = 'Pending'  AND decided_at_utc IS NULL) OR
        (status <> 'Pending' AND decided_at_utc IS NOT NULL)),
    CONSTRAINT ck_approved_creates_item CHECK (
        (status = 'Approved' AND effective_from IS NOT NULL
                             AND created_subscription_item_id IS NOT NULL) OR
        (status <> 'Approved'))
);
-- One open request per customer per item. Stops double-requesting.
CREATE UNIQUE INDEX ux_request_open ON change_request(customer_id, catalog_item_id)
    WHERE status = 'Pending';
CREATE INDEX ix_request_pending ON change_request(status, requested_at_utc)
    WHERE status = 'Pending';

-- ═══════════════════════════════════════════════════════════
-- 5.2.5  Metering — API usage
-- ═══════════════════════════════════════════════════════════

-- Raw counters land daily (from the API gateway), aggregated per period for billing.
CREATE TABLE api_usage_daily (
    customer_id     uuid NOT NULL REFERENCES customer,
    catalog_item_id uuid NOT NULL REFERENCES catalog_item,
    usage_date      date NOT NULL,
    call_count      bigint NOT NULL DEFAULT 0 CHECK (call_count >= 0),
    error_count     bigint NOT NULL DEFAULT 0,
    ingested_at_utc timestamptz NOT NULL DEFAULT now(),
    source          text NOT NULL DEFAULT 'gateway',
    PRIMARY KEY (customer_id, catalog_item_id, usage_date)
);
CREATE INDEX ix_usage_daily_period ON api_usage_daily(customer_id, usage_date);

CREATE TYPE period_state AS ENUM ('Open','Closed','Billed');

CREATE TABLE api_usage_period (
    usage_period_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id     uuid NOT NULL REFERENCES customer,
    catalog_item_id uuid NOT NULL REFERENCES catalog_item,
    period_key      char(7) NOT NULL,                   -- '2026-09'
    call_count      bigint NOT NULL DEFAULT 0,
    -- Snapshots taken at close, so a later catalog price change cannot rewrite history.
    included_calls_snapshot bigint NOT NULL,
    overage_rate_snapshot   numeric(18,6) NOT NULL,
    overage_basis_snapshot  overage_basis NOT NULL,
    overage_units   numeric(18,4) NOT NULL DEFAULT 0,   -- calls, or thousands of calls
    overage_amount  numeric(18,4) NOT NULL DEFAULT 0,
    state           period_state NOT NULL DEFAULT 'Open',
    closed_at_utc   timestamptz NULL,
    billed_on_invoice_id uuid NULL,
    UNIQUE (customer_id, catalog_item_id, period_key)
);
CREATE INDEX ix_api_period_billable ON api_usage_period(period_key, state);

-- ═══════════════════════════════════════════════════════════
-- 5.2.6  Cloud rebilling
-- ═══════════════════════════════════════════════════════════

CREATE TYPE cloud_provider AS ENUM ('AWS','Azure','GCP','OCI');

CREATE TABLE cloud_account (
    cloud_account_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id      uuid NOT NULL REFERENCES customer,
    provider         cloud_provider NOT NULL,
    account_ref      text NOT NULL,                     -- AWS account id / Azure sub id / GCP project
    region           text NULL,
    markup_pct       numeric(5,2) NOT NULL DEFAULT 0
                     CHECK (markup_pct >= 0 AND markup_pct <= 100),
    note             text NULL,
    is_active        bool NOT NULL DEFAULT true,
    created_at_utc   timestamptz NOT NULL DEFAULT now(),
    updated_at_utc   timestamptz NULL,
    row_version      bytea NOT NULL DEFAULT '\x00',
    UNIQUE (provider, account_ref)
);
CREATE INDEX ix_cloud_customer ON cloud_account(customer_id) WHERE is_active;

CREATE TABLE cloud_cost_period (
    cloud_cost_id    uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    cloud_account_id uuid NOT NULL REFERENCES cloud_account,
    customer_id      uuid NOT NULL REFERENCES customer,
    period_key       char(7) NOT NULL,
    provider_cost    numeric(18,4) NOT NULL CHECK (provider_cost >= 0),  -- NEVER exposed to portal
    markup_pct_snapshot numeric(5,2) NOT NULL,          -- snapshot, not a live read
    rebill_amount    numeric(18,4) NOT NULL,            -- provider_cost * (1 + markup/100), 2dp
    source           text NOT NULL DEFAULT 'Manual',    -- 'Manual' | 'CSV' | 'Connector'
    evidence_url     text NULL,                         -- link to provider invoice PDF
    state            period_state NOT NULL DEFAULT 'Open',
    closed_at_utc    timestamptz NULL,
    billed_on_invoice_id uuid NULL,
    created_at_utc   timestamptz NOT NULL DEFAULT now(),
    UNIQUE (cloud_account_id, period_key)
);
CREATE INDEX ix_cloud_period_billable ON cloud_cost_period(period_key, state);

-- ═══════════════════════════════════════════════════════════
-- 5.2.7  Transaction fees (Sumsub, processing, issuance, payouts)
-- ═══════════════════════════════════════════════════════════

CREATE TABLE tx_fee_rate (
    tx_fee_rate_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    code           text NOT NULL UNIQUE,        -- 'SUMSUB_KYC'
    display_name   text NOT NULL,               -- 'Sumsub — KYC identity check'
    vendor         text NOT NULL,               -- 'Sumsub','Scheme','Ledgerline'
    unit_label     text NOT NULL,               -- 'check','screening','transaction','card','payout'
    unit_cost      numeric(18,6) NOT NULL CHECK (unit_cost >= 0),   -- NEVER exposed to portal
    unit_price     numeric(18,6) NOT NULL CHECK (unit_price >= 0),
    is_active      bool NOT NULL DEFAULT true,
    created_at_utc timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT ck_price_ge_cost CHECK (unit_price >= unit_cost)     -- no negative margin by accident
);

-- Customer-specific override, optional. Falls back to tx_fee_rate.
CREATE TABLE customer_tx_fee_rate (
    customer_id    uuid NOT NULL REFERENCES customer,
    tx_fee_rate_id uuid NOT NULL REFERENCES tx_fee_rate,
    unit_price     numeric(18,6) NOT NULL,
    effective_from date NOT NULL,
    PRIMARY KEY (customer_id, tx_fee_rate_id, effective_from)
);

CREATE TABLE tx_usage_period (
    tx_usage_id    uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id    uuid NOT NULL REFERENCES customer,
    tx_fee_rate_id uuid NOT NULL REFERENCES tx_fee_rate,
    period_key     char(7) NOT NULL,
    quantity       numeric(18,4) NOT NULL DEFAULT 0 CHECK (quantity >= 0),
    unit_cost_snapshot  numeric(18,6) NOT NULL,
    unit_price_snapshot numeric(18,6) NOT NULL,
    cost_amount    numeric(18,4) NOT NULL DEFAULT 0,
    bill_amount    numeric(18,4) NOT NULL DEFAULT 0,
    state          period_state NOT NULL DEFAULT 'Open',
    source         text NOT NULL DEFAULT 'Manual',
    billed_on_invoice_id uuid NULL,
    UNIQUE (customer_id, tx_fee_rate_id, period_key)
);
CREATE INDEX ix_tx_period_billable ON tx_usage_period(period_key, state);

-- ═══════════════════════════════════════════════════════════
-- 5.2.8  Invoicing
-- ═══════════════════════════════════════════════════════════

CREATE TYPE invoice_type   AS ENUM ('Combined','AppsAndApis','Cloud','Transactions');
CREATE TYPE invoice_status AS ENUM ('Draft','Issued','PendingVerification','Paid','Overdue','Void');
CREATE TYPE line_category  AS ENUM ('Apps','Apis','Setup','Overage','Cloud','Transactions','Credit','Other');

CREATE TABLE invoice (
    invoice_id     uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    invoice_number text NULL UNIQUE,             -- NULL while Draft; assigned on raise
    customer_id    uuid NOT NULL REFERENCES customer,
    type           invoice_type NOT NULL,
    period_key     char(7) NOT NULL,             -- '2026-09'
    period_label   text NOT NULL,                -- 'Sept 2026', printed
    issue_date     date NULL,                    -- NULL while Draft
    due_date       date NULL,
    status         invoice_status NOT NULL DEFAULT 'Draft',
    currency_code  char(3) NOT NULL DEFAULT 'USD' CHECK (currency_code = 'USD'),
    -- Discount is an invoice-level human decision, captured with who and why.
    discount_pct   numeric(5,2) NOT NULL DEFAULT 0
                   CHECK (discount_pct >= 0 AND discount_pct <= 100),
    discount_reason text NULL,
    tax_label      text NOT NULL DEFAULT 'VAT',
    tax_pct        numeric(5,2) NOT NULL DEFAULT 0,
    -- Computed and frozen at raise time. Recomputed live while Draft.
    subtotal       numeric(18,4) NOT NULL DEFAULT 0,
    discount_amount numeric(18,4) NOT NULL DEFAULT 0,
    tax_amount     numeric(18,4) NOT NULL DEFAULT 0,
    total          numeric(18,4) NOT NULL DEFAULT 0,
    amount_paid    numeric(18,4) NOT NULL DEFAULT 0,
    notes          text NULL,                    -- printed on the invoice
    internal_note  text NULL,                    -- never printed, never sent to portal
    -- Snapshot of who we invoiced, so a later CRM edit does not rewrite history.
    bill_to_legal_name text NULL,
    bill_to_address    text NULL,
    bill_to_tax_reg    text NULL,
    bill_to_email      citext NULL,
    created_by     uuid NOT NULL,
    raised_by      uuid NULL,
    raised_at_utc  timestamptz NULL,
    voided_by      uuid NULL,
    void_reason    text NULL,
    created_at_utc timestamptz NOT NULL DEFAULT now(),
    updated_at_utc timestamptz NULL,
    row_version    bytea NOT NULL DEFAULT '\x00',
    CONSTRAINT ck_issued_has_number CHECK (
        (status = 'Draft' AND invoice_number IS NULL) OR
        (status <> 'Draft' AND invoice_number IS NOT NULL
                           AND issue_date IS NOT NULL AND due_date IS NOT NULL)),
    CONSTRAINT ck_discount_reason CHECK (discount_pct = 0 OR discount_reason IS NOT NULL)
);
CREATE INDEX ix_invoice_customer   ON invoice(customer_id, period_key);
CREATE INDEX ix_invoice_status     ON invoice(status) WHERE status IN ('Issued','Overdue','PendingVerification');
CREATE INDEX ix_invoice_drafts     ON invoice(created_by) WHERE status = 'Draft';
-- A customer gets at most one invoice of each type per period. Stops double-billing a month.
CREATE UNIQUE INDEX ux_invoice_customer_period_type ON invoice(customer_id, period_key, type)
    WHERE status <> 'Void';

CREATE TABLE invoice_line (
    invoice_line_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    invoice_id      uuid NOT NULL REFERENCES invoice ON DELETE CASCADE,
    line_no         int  NOT NULL,
    category        line_category NOT NULL,
    description     text NOT NULL,
    quantity        numeric(18,4) NOT NULL DEFAULT 1,
    unit_price      numeric(18,6) NOT NULL,        -- may be negative for Credit lines
    line_discount_pct numeric(5,2) NOT NULL DEFAULT 0
                      CHECK (line_discount_pct >= 0 AND line_discount_pct <= 100),
    amount          numeric(18,4) NOT NULL,        -- qty * unit_price * (1 - disc/100), 2dp
    -- Provenance. Nullable because a human can add a free-text line.
    source_kind     text NULL,                     -- 'SubscriptionItem','SetupFee','ApiOverage','CloudPeriod','TxPeriod','Manual'
    source_id       uuid NULL,
    period_from     date NULL,                     -- proration window, printed when partial
    period_to       date NULL,
    is_prorated     bool NOT NULL DEFAULT false,
    created_at_utc  timestamptz NOT NULL DEFAULT now(),
    UNIQUE (invoice_id, line_no),
    CONSTRAINT ck_credit_negative CHECK (
        (category = 'Credit' AND unit_price <= 0) OR (category <> 'Credit'))
);
CREATE INDEX ix_line_invoice ON invoice_line(invoice_id, line_no);
CREATE INDEX ix_line_source  ON invoice_line(source_kind, source_id);

-- Append-only. Nothing ever updates a row here.
CREATE TABLE invoice_status_history (
    history_id   bigserial PRIMARY KEY,
    invoice_id   uuid NOT NULL REFERENCES invoice ON DELETE CASCADE,
    from_status  invoice_status NULL,
    to_status    invoice_status NOT NULL,
    changed_by   uuid NOT NULL,
    reason       text NULL,
    snapshot     jsonb NOT NULL,          -- totals at the moment of transition
    created_at_utc timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_status_hist_invoice ON invoice_status_history(invoice_id, history_id);

-- Now the deferred FKs from §5.2.3/5.2.5/5.2.6/5.2.7:
ALTER TABLE subscription_item  ADD CONSTRAINT fk_subitem_setup_invoice
    FOREIGN KEY (setup_billed_on_invoice_id) REFERENCES invoice;
ALTER TABLE api_usage_period    ADD CONSTRAINT fk_apiperiod_invoice
    FOREIGN KEY (billed_on_invoice_id) REFERENCES invoice;
ALTER TABLE cloud_cost_period   ADD CONSTRAINT fk_cloudperiod_invoice
    FOREIGN KEY (billed_on_invoice_id) REFERENCES invoice;
ALTER TABLE tx_usage_period     ADD CONSTRAINT fk_txperiod_invoice
    FOREIGN KEY (billed_on_invoice_id) REFERENCES invoice;

CREATE TABLE credit_note (
    credit_note_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    credit_number  text NOT NULL UNIQUE,
    customer_id    uuid NOT NULL REFERENCES customer,
    against_invoice_id uuid NULL REFERENCES invoice,
    amount         numeric(18,4) NOT NULL CHECK (amount > 0),
    reason         text NOT NULL,
    issued_by      uuid NOT NULL,
    issued_at_utc  timestamptz NOT NULL DEFAULT now()
);

-- ═══════════════════════════════════════════════════════════
-- 5.2.9  Payments
-- ═══════════════════════════════════════════════════════════

CREATE TYPE payment_method AS ENUM ('BankTransfer','Crypto','Offset','WriteOff');
CREATE TYPE payment_state  AS ENUM ('Submitted','Verified','Rejected');

CREATE TABLE bank_account (
    bank_account_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    label           text NOT NULL,           -- 'Emirates NBD — primary USD'
    bank_name       text NOT NULL,
    beneficiary     text NOT NULL,
    account_number  text NOT NULL,
    iban            text NULL,
    swift_bic       text NOT NULL,
    branch_note     text NULL,
    currency_code   char(3) NOT NULL DEFAULT 'USD',
    is_active       bool NOT NULL DEFAULT true,
    sort_order      int NOT NULL DEFAULT 0
);

CREATE TABLE crypto_wallet (
    wallet_id      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    asset          text NOT NULL,            -- 'USDT','USDC','BTC'
    chain          text NOT NULL,            -- 'Ethereum (ERC-20)','Tron (TRC-20)','Polygon','Bitcoin'
    address        text NOT NULL,
    memo_tag       text NULL,
    min_confirmations int NOT NULL DEFAULT 12,
    is_active      bool NOT NULL DEFAULT true,
    sort_order     int NOT NULL DEFAULT 0,
    UNIQUE (asset, chain, address)
);

-- A quote is locked when the payment screen renders, so the customer cannot be
-- charged a different crypto amount than the one they were shown.
CREATE TABLE crypto_quote (
    quote_id       uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    invoice_id     uuid NOT NULL REFERENCES invoice,
    wallet_id      uuid NOT NULL REFERENCES crypto_wallet,
    usd_amount     numeric(18,4) NOT NULL,
    asset_amount   numeric(28,10) NOT NULL,
    rate_usd       numeric(28,10) NOT NULL,
    rate_source    text NOT NULL,
    locked_at_utc  timestamptz NOT NULL DEFAULT now(),
    expires_at_utc timestamptz NOT NULL
);
CREATE INDEX ix_quote_invoice ON crypto_quote(invoice_id, locked_at_utc DESC);

CREATE TABLE payment (
    payment_id     uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    invoice_id     uuid NOT NULL REFERENCES invoice,
    customer_id    uuid NOT NULL REFERENCES customer,
    method         payment_method NOT NULL,
    state          payment_state NOT NULL DEFAULT 'Submitted',
    amount_claimed numeric(18,4) NOT NULL,
    amount_verified numeric(18,4) NULL,
    -- Bank
    bank_account_id uuid NULL REFERENCES bank_account,
    wire_reference  text NULL,
    -- Crypto
    wallet_id      uuid NULL REFERENCES crypto_wallet,
    quote_id       uuid NULL REFERENCES crypto_quote,
    tx_hash        text NULL,
    confirmations  int NULL,
    -- Audit
    submitted_by_contact_id uuid NULL REFERENCES customer_contact,
    submitted_at_utc timestamptz NOT NULL DEFAULT now(),
    verified_by    uuid NULL,
    verified_at_utc timestamptz NULL,
    rejected_reason text NULL,
    evidence_url   text NULL,
    row_version    bytea NOT NULL DEFAULT '\x00',
    CONSTRAINT ck_payment_reference CHECK (
        (method = 'BankTransfer' AND wire_reference IS NOT NULL) OR
        (method = 'Crypto'       AND tx_hash IS NOT NULL AND wallet_id IS NOT NULL) OR
        (method IN ('Offset','WriteOff'))),
    CONSTRAINT ck_verified_fields CHECK (
        (state = 'Verified' AND amount_verified IS NOT NULL AND verified_at_utc IS NOT NULL) OR
        (state <> 'Verified'))
);
-- The same chain transaction can only be claimed once, across all customers.
CREATE UNIQUE INDEX ux_payment_txhash ON payment(tx_hash)
    WHERE tx_hash IS NOT NULL AND state <> 'Rejected';
CREATE INDEX ix_payment_pending ON payment(state) WHERE state = 'Submitted';
CREATE INDEX ix_payment_invoice ON payment(invoice_id);

-- ═══════════════════════════════════════════════════════════
-- 5.2.10  Collections
-- ═══════════════════════════════════════════════════════════

CREATE TYPE dunning_step AS ENUM ('Reminder1','Reminder2','FinalNotice','Suspended');

CREATE TABLE dunning_event (
    dunning_id   uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    invoice_id   uuid NOT NULL REFERENCES invoice,
    customer_id  uuid NOT NULL REFERENCES customer,
    step         dunning_step NOT NULL,
    days_overdue int NOT NULL,
    channel      text NOT NULL,             -- 'Email','InApp'
    sent_to      citext NOT NULL,
    sent_at_utc  timestamptz NOT NULL DEFAULT now(),
    triggered_by text NOT NULL              -- 'Worker' | user id
);
CREATE INDEX ix_dunning_invoice ON dunning_event(invoice_id, sent_at_utc DESC);

-- ═══════════════════════════════════════════════════════════
-- 5.2.11  Platform settings and plumbing
-- ═══════════════════════════════════════════════════════════

CREATE TABLE billing_settings (              -- single row, id = 1
    id                   int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
    company_legal_name   text NOT NULL,
    company_address      text NOT NULL,
    company_tax_line     text NOT NULL,
    billing_email        citext NOT NULL,
    tax_label            text NOT NULL DEFAULT 'VAT',
    tax_pct              numeric(5,2) NOT NULL DEFAULT 5,
    payment_terms_days   int NOT NULL DEFAULT 14,
    late_fee_pct_month   numeric(5,2) NOT NULL DEFAULT 1.5,
    dunning_days         int[] NOT NULL DEFAULT '{3,7,14}',
    suspend_after_days   int NOT NULL DEFAULT 30,
    annual_discount_note text NOT NULL DEFAULT 'Annual terms are priced 15% below twelve monthly payments.',
    invoice_number_format text NOT NULL DEFAULT 'INV-{yyyy}-{seq:0000}',
    updated_at_utc       timestamptz NULL,
    updated_by           uuid NULL
);

CREATE TABLE number_sequence (
    sequence_key text PRIMARY KEY,           -- 'invoice:2026', 'credit:2026', 'request'
    next_value   bigint NOT NULL DEFAULT 1
);

CREATE TABLE audit_log (
    audit_id     bigserial PRIMARY KEY,
    entity_type  text NOT NULL,
    entity_id    uuid NOT NULL,
    customer_id  uuid NULL,
    action       text NOT NULL,              -- 'Created','Updated','Raised','Verified','Approved'
    actor_id     uuid NOT NULL,
    actor_role   text NOT NULL,
    before_json  jsonb NULL,
    after_json   jsonb NULL,
    ip_address   inet NULL,
    created_at_utc timestamptz NOT NULL DEFAULT now(),
    prev_hash    bytea NULL,
    row_hash     bytea NOT NULL              -- sha256(prev_hash || canonical(row))
);
CREATE INDEX ix_audit_entity ON audit_log(entity_type, entity_id, audit_id);

CREATE TABLE outbox_message (
    message_id   uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    topic        text NOT NULL,
    payload      jsonb NOT NULL,
    available_at_utc timestamptz NOT NULL DEFAULT now(),
    attempts     int NOT NULL DEFAULT 0,
    processed_at_utc timestamptz NULL,
    last_error   text NULL
);
CREATE INDEX ix_outbox_pending ON outbox_message(available_at_utc)
    WHERE processed_at_utc IS NULL;
```

### 5.3 Enforce the invariants in the database, not only in code

Application code changes. Constraints do not.

```sql
-- 1. An invoice that is not a Draft is immutable except for status, payment and void fields.
CREATE OR REPLACE FUNCTION trg_invoice_immutable() RETURNS trigger AS $$
BEGIN
    IF OLD.status <> 'Draft' THEN
        IF (NEW.customer_id, NEW.type, NEW.period_key, NEW.discount_pct, NEW.tax_pct,
            NEW.subtotal, NEW.total, NEW.issue_date, NEW.due_date, NEW.invoice_number)
           IS DISTINCT FROM
           (OLD.customer_id, OLD.type, OLD.period_key, OLD.discount_pct, OLD.tax_pct,
            OLD.subtotal, OLD.total, OLD.issue_date, OLD.due_date, OLD.invoice_number)
        THEN
            RAISE EXCEPTION 'Invoice % is % and cannot be edited. Void it and raise a new one.',
                OLD.invoice_number, OLD.status;
        END IF;
    END IF;
    RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER invoice_immutable BEFORE UPDATE ON invoice
    FOR EACH ROW EXECUTE FUNCTION trg_invoice_immutable();

-- 2. Lines cannot be touched once the invoice has left Draft.
CREATE OR REPLACE FUNCTION trg_line_locked() RETURNS trigger AS $$
DECLARE s invoice_status;
BEGIN
    SELECT status INTO s FROM invoice
        WHERE invoice_id = COALESCE(NEW.invoice_id, OLD.invoice_id);
    IF s <> 'Draft' THEN
        RAISE EXCEPTION 'Cannot modify lines of a % invoice', s;
    END IF;
    RETURN COALESCE(NEW, OLD);
END $$ LANGUAGE plpgsql;

CREATE TRIGGER line_locked BEFORE INSERT OR UPDATE OR DELETE ON invoice_line
    FOR EACH ROW EXECUTE FUNCTION trg_line_locked();

-- 3. A setup fee can never go from Billed back to Due.
CREATE OR REPLACE FUNCTION trg_setup_no_regress() RETURNS trigger AS $$
BEGIN
    IF OLD.setup_state = 'Billed' AND NEW.setup_state <> 'Billed' THEN
        RAISE EXCEPTION 'Setup fee for subscription item % is already billed on invoice %',
            OLD.subscription_item_id, OLD.setup_billed_on_invoice_id;
    END IF;
    RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER setup_no_regress BEFORE UPDATE ON subscription_item
    FOR EACH ROW EXECUTE FUNCTION trg_setup_no_regress();

-- 4. Audit log and status history are append-only.
CREATE RULE audit_no_update AS ON UPDATE TO audit_log DO INSTEAD NOTHING;
CREATE RULE audit_no_delete AS ON DELETE TO audit_log DO INSTEAD NOTHING;
CREATE RULE hist_no_update  AS ON UPDATE TO invoice_status_history DO INSTEAD NOTHING;
CREATE RULE hist_no_delete  AS ON DELETE TO invoice_status_history DO INSTEAD NOTHING;
```

> **Why trigger 3 matters.** The prototype's `markSetupBilled()` runs only when an invoice is *raised*, not when it is saved as a draft. Without a DB guard, a future "recalculate drafts" job could reset the flag and bill the same $6,000 twice. The constraint makes that impossible regardless of what the service layer does.

### 5.4 EF Core configuration notes

- Map every enum with `HasPostgresEnum<T>()` in `OnModelCreating`, not as strings.
- `row_version` → `.IsRowVersion()` on aggregates: `Customer`, `Invoice`, `SubscriptionItem`, `ChangeRequest`, `Payment`, `CatalogItem`, `CloudAccount`.
- `Invoice` owns `InvoiceLine` (`OwnsMany` is wrong here — use a real entity with cascade delete, because lines are queried directly for the AR report).
- Money: `.HasPrecision(18, 4)`. Never `double`. Never `decimal` without precision.
- Global query filter for the portal connection: `.HasQueryFilter(x => x.CustomerId == _ctx.CustomerId)` on every customer-scoped entity, plus Postgres RLS as a second line of defence.
- Never `.Include()` `CloudCostPeriod.ProviderCost` in a portal-facing projection. Use explicit `Select` into portal DTOs.

---

## 6. The billing engine

### 6.1 One resolver, four call sites

```csharp
public interface IChargeResolver
{
    // Pure. No DB writes. Given a customer and a period, produce the charge set.
    Task<ChargeSet> ResolveAsync(Guid customerId, PeriodKey period,
                                 InvoiceType type, CancellationToken ct);
}
```

| Call site | Why |
|---|---|
| `GET /customers/{id}/charges/preview` | Account screen "next invoice, estimated" panel (S2) |
| `POST /invoices/draft` | Invoice builder pre-fill (S17) |
| `POST /billing/runs` | Month-end batch that drafts everyone |
| `GET /portal/me/charges` | Portal overview so the customer sees the same number we do |

**All four must produce identical output for identical input.** One golden-file test per scenario in §13.3.

### 6.2 The charge types

```csharp
public sealed record ChargeSet(
    IReadOnlyList<Charge> Charges,
    IReadOnlyList<string> Warnings);     // e.g. "Cloud period 2026-09 still Open"

public sealed record Charge(
    LineCategory Category,
    string       Description,
    decimal      Quantity,
    decimal      UnitPrice,
    string?      SourceKind,
    Guid?        SourceId,
    DateOnly?    PeriodFrom,
    DateOnly?    PeriodTo,
    bool         IsProrated);
```

| Category | Source | Timing | Prorated |
|---|---|---|---|
| `Apps` | `subscription_item` where `catalog_item.kind = App`, term Monthly | In advance | Yes, on first and last period |
| `Apps` | term Annual | In advance, once per term on `term_renews_on` | Yes on first |
| `Apis` | `subscription_item` where `kind = Api` | In advance | Yes |
| `Setup` | `subscription_item.setup_state = 'Due'` | On the next invoice after the item starts | Never |
| `Overage` | `api_usage_period` state `Closed` | In arrears | Never |
| `Cloud` | `cloud_cost_period` state `Closed` | In arrears | Never |
| `Transactions` | `tx_usage_period` state `Closed` | In arrears | Never |
| `Credit` | manual | Any | Never |

> **In advance vs in arrears is the rule that keeps the numbers honest.** Recurring fees are charged for the month ahead. Everything metered is charged for the month behind. A September invoice therefore reads: September subscription + August overage + August cloud + August transaction fees. The invoice `period_key` is the *issue* period; each line carries its own `period_from`/`period_to`.

### 6.3 Proration

```
daily_rate      = unit_price / days_in_period
prorated_amount = round(daily_rate * chargeable_days, 2)
chargeable_days = days between max(effective_from, period_start)
                            and min(effective_to ?? period_end, period_end), inclusive
```

Rules:

1. Proration applies only to `Monthly` term items, and only in the period where `effective_from` or `effective_to` falls inside the period.
2. Annual items are **not** prorated month to month. They are billed in full on `term_renews_on`, and prorated only when the term starts mid-year.
3. A prorated line must set `is_prorated = true` and print its window: *"Cards (monthly) — 12 Sept to 30 Sept"*.
4. Removing an item mid-period generates a **credit line** on the next invoice for the unused days, never a negative subscription line.
5. `days_in_period` uses the actual calendar month. No 30/360.

### 6.4 The setup-fee lifecycle — read this twice

This is the defect the prototype was corrected for, and it is worth stating as a state machine.

```
                 item created (admin, onboarding, or approved request)
                                    │
                                    ▼
                            ┌───────────────┐
              waive ───────▶│    Waived     │   (needs reason + finance role)
              (auth)        └───────────────┘
                                    ▲
                                    │
                            ┌───────────────┐        invoice raised
   setup_fee > 0  ─────────▶│      Due      │ ──────────────────────────▶ ┌────────┐
                            └───────────────┘   (line present, status      │ Billed │
                                    │            Draft → Issued)           └────────┘
                    item removed    │                                           │
                    before billing  ▼                                           │
                            ┌───────────────┐                     ✗ no transition out
                            │   (deleted)   │                       (DB trigger)
                            └───────────────┘
```

**The rules, as invariants:**

| # | Rule | Enforced by |
|---|---|---|
| SF-1 | Setup is tracked on `subscription_item`, never on `customer` or `subscription` | Schema — there is no customer-level flag to get wrong |
| SF-2 | A draft invoice containing setup lines does **not** mark them billed | `MarkSetupBilledAsync` called only in the `Raise` transaction |
| SF-3 | Raising an invoice marks exactly the items whose setup line is on that invoice | Match by `invoice_line.source_kind='SetupFee' AND source_id = subscription_item_id` — never by name |
| SF-4 | `Billed` is terminal | Trigger `setup_no_regress` |
| SF-5 | Removing an item while `Due` drops the fee; no ghost charge later | `effective_to` set, and the resolver filters `effective_to IS NULL` |
| SF-6 | Setup lines never appear on `Cloud` or `Transactions` type invoices | Resolver: setup is only emitted when `type IN (Combined, AppsAndApis)` |
| SF-7 | Each setup line names its item: *"Cards — one-time setup fee"* | Resolver builds the description from `catalog_item.display_name` |
| SF-8 | A waiver requires a reason and a finance role | CHECK constraint + policy |

```csharp
// The only correct implementation. Note: called inside the Raise transaction.
public async Task MarkSetupBilledAsync(Invoice invoice, CancellationToken ct)
{
    var setupItemIds = invoice.Lines
        .Where(l => l.Category == LineCategory.Setup && l.SourceKind == "SetupFee")
        .Select(l => l.SourceId!.Value)
        .ToHashSet();

    if (setupItemIds.Count == 0) return;

    var items = await _db.SubscriptionItems
        .Where(i => setupItemIds.Contains(i.SubscriptionItemId) && i.SetupState == SetupState.Due)
        .ToListAsync(ct);

    foreach (var item in items)
    {
        item.SetupState = SetupState.Billed;
        item.SetupBilledOnInvoiceId = invoice.InvoiceId;
    }
    // Any id in setupItemIds that was NOT Due is a bug: log it loudly, do not silently skip.
    var unexpected = setupItemIds.Except(items.Select(i => i.SubscriptionItemId)).ToList();
    if (unexpected.Count > 0)
        _log.LogError("Setup lines on {Invoice} referenced items not in Due state: {Ids}",
                      invoice.InvoiceNumber, unexpected);
}
```

### 6.5 Metering and overage

```
overage_units  = basis == Per1000
                 ? max(0, call_count - included_calls) / 1000.0
                 : max(0, call_count - included_calls)
overage_amount = round(overage_units * overage_rate, 2)
```

- `included_calls`, `overage_rate` and `overage_basis` are **snapshotted onto `api_usage_period` when the period closes**. A catalog price change in October must not alter what September's overage cost.
- Periods close on the 1st at 02:00 UTC via `CloseUsagePeriodsWorker`. Only `Closed` periods are billable.
- Overage never appears on an invoice for the current, open period. The portal may *show* running overage with the label "estimated, not yet billed".
- Tier changes mid-period: the allowance is prorated by days on each tier. Implement as two period rows with distinct snapshots when `term`/tier changes; simpler and auditable.

### 6.6 Cloud rebilling

```
rebill_amount = round(provider_cost * (1 + markup_pct / 100), 2)
```

- `markup_pct_snapshot` is written to `cloud_cost_period` at import time. The invoice reads the snapshot, never `cloud_account.markup_pct`.
- Line description is generated and includes the account ref and the markup so the customer can reconcile: *"AWS 4471-9982-1130 — Sept 2026 usage incl. 12% management"*.
- `provider_cost` is never returned by any portal endpoint. The portal sees only `rebill_amount`.
- If `evidence_url` is set, the invoice PDF gets a "provider statement attached" note.

### 6.7 Transaction fees

```
cost_amount = round(quantity * unit_cost_snapshot,  2)     -- internal only
bill_amount = round(quantity * unit_price_snapshot, 2)
margin      = bill_amount - cost_amount                    -- internal only
```

Resolution order for `unit_price`: `customer_tx_fee_rate` (latest `effective_from` ≤ period end) → `tx_fee_rate.unit_price`.

### 6.8 Discounts and tax — the order of operations

This order is fixed. Changing it changes every invoice total in the system.

```
1.  line_amount    = round(qty × unit_price × (1 − line_discount_pct/100), 2)
2.  subtotal       = Σ line_amount
3.  discount_amount= round(subtotal × invoice.discount_pct / 100, 2)
4.  taxable_base   = subtotal − discount_amount
5.  tax_amount     = round(taxable_base × tax_pct / 100, 2)
6.  total          = taxable_base + tax_amount
7.  balance_due    = total − amount_paid
```

- Rounding is **half away from zero**, 2dp, at each numbered step. Never round once at the end.
- Line discount and invoice discount stack. A 10% line discount plus a 5% invoice discount on $1,000 is $855.00, not $850.00.
- `discount_pct > 0` requires `discount_reason`. Enforced by CHECK.
- Discount authority: account owner ≤ 10%, billing operator ≤ 25%, finance admin any. Above your limit the Raise button is disabled with an explanation (AC-17.12).

### 6.9 Reference implementation, ported from the prototype

```csharp
public static decimal LineAmount(decimal qty, decimal unitPrice, decimal lineDiscountPct)
    => Round2(qty * unitPrice * (1m - lineDiscountPct / 100m));

public static InvoiceTotals Totals(IEnumerable<InvoiceLine> lines,
                                   decimal invoiceDiscountPct, decimal taxPct)
{
    var subtotal = Round2(lines.Sum(l => l.Amount));
    var discount = Round2(subtotal * invoiceDiscountPct / 100m);
    var taxable  = subtotal - discount;
    var tax      = Round2(taxable * taxPct / 100m);
    return new InvoiceTotals(subtotal, discount, tax, taxable + tax);
}

private static decimal Round2(decimal v)
    => Math.Round(v, 2, MidpointRounding.AwayFromZero);
```

Prototype equivalents: `lineAmt()`, `invTotals()`, `priceSub()`, `buildLines()`, `setupDue()`, `markSetupBilled()`. See Appendix A for the full map.

---

## 7. Screens

Every screen below exists in the prototype. For each: what it is, the fields with validation, every action wired to an API and the tables it touches, acceptance criteria, and the React components to build.

**Screen index**

| Console (provider admin) | | Portal (customer) |
|---|---|---|
| S1 Accounts | S13 Cloud accounts | S24 Portal overview |
| S2 Account · Overview | S14 Transaction fees | S25 Portal apps & APIs |
| S3 Account · Contacts | S15 Marketplace | S26 Portal my requests |
| S4 Account · Meetings & notes | S16 Catalog item editor | S27 Portal cloud & transactions |
| S5 Account · Apps & APIs | S17 **Invoice builder** | S28 Portal invoices |
| S6 Account · Cloud | S18 Invoices | S29 Payment (shared) |
| S7 Account · Billing | S19 Invoice document | |
| S8 Meetings & notes (global) | S20 Payment (shared) | |
| S9 Onboarding wizard (5 steps) | S21 Payments & proofs | |
| S10 Dashboard | S22 Accounts receivable | |
| S11 Subscriptions | S23 Settings | |
| S12 Requests | | |

---

### S1 · Accounts

**What it is.** The customer master list and the entry point to everything else. Four KPI tiles, a search/stage filter, and a table where every row summarises the commercial state of one account.

**Fields (filter bar)**

| Field | Control | Source / default | Validation | Notes |
|---|---|---|---|---|
| Search | text | empty | max 100 chars | Matches `display_name`, `legal_name`, `country_code`, owner name — server-side `ILIKE` |
| Stage | select | `All` | enum | `All, Lead, Discovery, Pilot, Live, Churned` |
| Owner | select | `All` | uuid | Populated from staff directory |
| Has open balance | toggle | off | — | Filters to customers with an open invoice |

**Columns:** Account (name + country + legal name) · Stage pill · Owner · Apps count · APIs count · Cloud providers · Monthly recurring · Open balance · Last activity · Open button.

**Actions**

| Action | Trigger | API | Tables | Result |
|---|---|---|---|---|
| Apply filters | Enter / Apply | `GET /customers?q=&stage=&owner=&hasBalance=` | `customer` R, `subscription_item` R, `invoice` R, `customer_activity` R | Table re-renders; filter state in URL query |
| Open account | row click / Open | route | — | → S2 |
| Onboard a customer | header button | route | — | → S9 |
| Export CSV | header menu | `GET /customers/export` | as above | Streams CSV, logs to `audit_log` |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-1.1 | MRR is the **normalised** monthly figure: monthly items at face value, annual items at `price / 12`. The column header says so. |
| AC-1.2 | Open balance excludes `Draft` and `Void` invoices. |
| AC-1.3 | An account with `cloud_managed = false` shows "Self-managed", not an empty cell. |
| AC-1.4 | Filter state survives a browser refresh (read from query string). |
| AC-1.5 | List is paginated at 50, server-side. Sorting on MRR and open balance is server-side. |
| AC-1.6 | Stage counts in the KPI tiles match the filtered set, not the global set, when a filter is active. |

**API**
```
GET /api/v1/customers?q=&stage=&owner=&hasBalance=&page=&size=&sort=
  → { items:[{ customerId, code, displayName, legalName, countryCode, stage, status,
               ownerName, appCount, apiCount, cloudProviders:[], mrr, openBalance,
               lastActivity:{type,date} }], total, page, size }
```

**Components** `AccountsPage` → `KpiTile` × 4, `AccountFilterBar`, `AccountTable`, `StagePill`, `MoneyCell`

**Tables** `customer` R · `subscription_item` R · `catalog_item` R · `invoice` R · `customer_activity` R · `cloud_account` R

---

### S2 · Account · Overview

**What it is.** The commercial summary of one customer: four KPI tiles, the account facts panel, and a "next steps" panel that surfaces open follow-ups, pending requests and — critically — **unbilled setup fees**.

**Fields (Account panel, read-only here; edited in the modal)**

Registered entity · Country · Website · Stage · Account owner · Source · Tax registration · Customer since · Billing cycle · Standard discount % · Cloud (managed/self).

**KPI tiles**

| Tile | Computation |
|---|---|
| Monthly recurring | `Σ monthly items + Σ annual items / 12` |
| Cloud, rebilled | `Σ cloud_cost_period.rebill_amount` for the current open period |
| Transaction fees | `Σ tx_usage_period.bill_amount` for the current open period |
| **Setup fees unbilled** | `Σ subscription_item.setup_fee WHERE setup_state = 'Due' AND effective_to IS NULL` |

**Actions**

| Action | Trigger | API | Tables | Result |
|---|---|---|---|---|
| Edit details | button → modal | `PATCH /customers/{id}` | `customer` W, `audit_log` A | Optimistic concurrency on `row_version`; 409 on conflict |
| Log activity | button → modal | `POST /customers/{id}/activities` | `customer_activity` W | Appears immediately on S4 timeline |
| Complete follow-up | checkbox | `POST /activities/{id}/complete` | `customer_activity` W | Row strikes through, tile count drops |
| Raise an invoice | button | route with `?customer=` | — | → S17 with builder pre-filled |
| Open their portal | button | `POST /support/impersonate` | `audit_log` A | Opens portal in impersonation mode, banner shown, **read-only by default** |
| Suspend / reinstate | menu | `POST /customers/{id}/suspend` | `customer` W, `subscription` W, `audit_log` A | Suspension disables API keys via outbox event |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-2.1 | The unbilled-setup tile lists the items by name in its subtitle when count ≤ 3, otherwise "n items". |
| AC-2.2 | A warning note appears in Next steps whenever `setupDue > 0`, with a link that opens S17 pre-filled for that customer. |
| AC-2.3 | Pending requests for this customer appear in Next steps with a link to S12. |
| AC-2.4 | Editing the account never rewrites `bill_to_*` on invoices already issued. |
| AC-2.5 | Impersonation writes an `audit_log` row with `action='Impersonate'` before the portal loads. |
| AC-2.6 | The "next invoice, estimated" figure equals what S17 will draft, to the cent. Shared resolver (§6.1). |

**API**
```
GET   /api/v1/customers/{id}                  → account + KPIs + setupDue breakdown
PATCH /api/v1/customers/{id}                  { displayName, legalName, ... , rowVersion }
GET   /api/v1/customers/{id}/charges/preview?period=2026-09
POST  /api/v1/customers/{id}/activities
POST  /api/v1/activities/{activityId}/complete
POST  /api/v1/customers/{id}/suspend          { reason }
```

**Components** `AccountOverviewTab` → `KpiTile`, `AccountFactsPanel`, `NextStepsPanel`, `SetupFeeWarning` ★, `EditAccountModal`, `LogActivityModal`

**Tables** `customer` RW · `subscription_item` R · `cloud_cost_period` R · `tx_usage_period` R · `invoice` R · `change_request` R · `customer_activity` RW · `audit_log` A

---

### S3 · Account · Contacts

**What it is.** The people at this customer. One is primary; one or more receive invoices.

**Fields (add/edit modal)**

| Field | Control | Validation |
|---|---|---|
| Full name | text | required, 2–120 chars |
| Job title | text | optional, ≤ 80 |
| Email | email | required, RFC 5322, unique per customer |
| Phone | tel | optional, E.164 preferred, stored as typed |
| Primary contact | checkbox | setting it unsets the previous primary in the same transaction |
| Receives invoices | checkbox | at least one billing contact required before an invoice can be raised |
| Portal access | checkbox | issues an invite email; creates `portal_user_id` on acceptance |

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Add contact | `POST /customers/{id}/contacts` | `customer_contact` W | If first contact, auto-primary |
| Edit contact | `PATCH /contacts/{contactId}` | `customer_contact` W | — |
| Make primary | `POST /contacts/{contactId}/make-primary` | `customer_contact` W ×2 | Single transaction, honours `ux_contact_primary` |
| Invite to portal | `POST /contacts/{contactId}/invite` | `outbox_message` W, `audit_log` A | Email with signed, 72-hour link |
| Archive | `DELETE /contacts/{contactId}` | `customer_contact` W | Soft; cannot archive the last billing contact |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-3.1 | Exactly one primary contact at all times. Enforced by partial unique index, not by the UI. |
| AC-3.2 | Archiving the only billing contact is refused with a specific message, not a generic 400. |
| AC-3.3 | An invoice cannot be raised if the customer has zero billing contacts — blocks at S17 with a link here. |

**Tables** `customer_contact` RW · `outbox_message` W · `audit_log` A

---

### S4 · Account · Meetings & notes

**What it is.** The CRM timeline for one account: meetings, calls, notes and emails, newest first, with follow-ups that surface on S2 and S8.

**Fields (Log activity modal)**

| Field | Control | Default | Validation |
|---|---|---|---|
| Type | select | Meeting | `Meeting, Call, Note, Email` |
| Date | date | today | not more than 1 year future |
| Who was there | text / contact multi-select | primary contact | free text allowed; matched contacts also written to `activity_attendee` |
| Title | text | — | required, 3–140 |
| What happened | textarea | — | required, ≤ 8000, markdown-lite rendered |
| Follow-up | text | empty | if set, due date required |
| Due by | date | +7 days | required when follow-up set |

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Log activity | `POST /customers/{id}/activities` | `customer_activity` W, `activity_attendee` W | Prepends to timeline |
| Mark follow-up done | `POST /activities/{id}/complete` | `customer_activity` W | Pill flips to Done |
| Edit activity | `PATCH /activities/{id}` | `customer_activity` W | Allowed for 24h by the author, then read-only |
| Filter | client-side + `GET ?type=` | `customer_activity` R | All / Open follow-ups / Meetings / Calls / Notes |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-4.1 | Timeline is sorted by `occurred_on` DESC, tie-broken by `created_at_utc` DESC. |
| AC-4.2 | An activity with an open follow-up renders with the amber marker; done ones with the neutral marker. |
| AC-4.3 | System-generated notes (request approved, invoice raised, plan changed) appear in the same timeline, labelled `System`, and cannot be edited. |
| AC-4.4 | Body text is escaped. A note containing `<script>` renders as text. |

**Tables** `customer_activity` RW · `activity_attendee` RW

---

### S5 · Account · Apps & APIs  ★ high value

**What it is.** The subscription editor. Two groups — **Apps** and **External APIs** — each item showing list prices, the setup fee, a term toggle and a negotiated price box. For API items it also renders a live usage meter. The right rail totals the change and, when something new has been switched on, shows the unbilled setup fee with a link to raise it.

**Fields, per catalog item row**

| Field | Control | Source | Validation |
|---|---|---|---|
| Enabled | checkbox | `subscription_item` exists and `effective_to IS NULL` | Core items cannot be unchecked once on |
| Term | radio Monthly/Annual | `subscription_item.term` | Switching rewrites `unit_price` to the new list price unless overridden |
| Client price | number | `subscription_item.unit_price` | ≥ 0, ≤ 3× list (warn above list), 2dp |
| — | read-only labels | `catalog_item` | `List $X/mo · $Y/yr · one-time setup $Z` |
| Setup status | pill | `setup_state` | `Setup $Z not yet billed` (amber) / `Setup billed` (green) |
| Usage meter | bar | `api_usage_period` current | APIs only; shows calls / included and overage rate |

**Right rail**

Apps monthly · Apps annual terms · APIs monthly · APIs annual terms · API overage to date · **Recurring per month** · **Setup fees not yet billed** · warning + "raise it now" link · Save.

**Actions**

| Action | Trigger | API | Tables | Result |
|---|---|---|---|---|
| Enable item | checkbox on | `POST /customers/{id}/subscription/items` `{catalogItemId, term, unitPrice?, effectiveFrom}` | `subscription_item` W, `subscription_item_change` A, `api_usage_period` W (if API), `audit_log` A | Creates item with `setup_state='Due'`; toast names the fee |
| Disable item | checkbox off | `DELETE /subscription/items/{itemId}` `{effectiveTo, reason}` | `subscription_item` W (sets `effective_to`), `subscription_item_change` A | Pending setup fee disappears; a credit line is queued if mid-period and already billed |
| Change term | radio | `PATCH /subscription/items/{itemId}` `{term}` | `subscription_item` W, `subscription_item_change` A | Price resets to that term's list price |
| Override price | number blur | `PATCH /subscription/items/{itemId}` `{unitPrice, reason}` | as above | Row marked `override` on S11 |
| Waive setup | row menu | `POST /subscription/items/{itemId}/waive-setup` `{reason}` | `subscription_item` W, `audit_log` A | Finance role only |
| Save | button | — | — | All mutations are immediate; Save re-validates and navigates back |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-5.1 | Enabling an item **always** creates it with `setup_state = 'Due'`, whatever the customer's history. There is no customer-level setup flag to consult. |
| AC-5.2 | The toast on enable names the amount: *"Cards enabled — one-time setup fee $6,000 goes on the next invoice."* |
| AC-5.3 | The right rail shows the unbilled setup total whenever it is non-zero, in amber, with the item names. |
| AC-5.4 | Switching term rewrites the price to the new list price **unless** the current price is already an override, in which case the user is asked. |
| AC-5.5 | A price above list renders a warning icon, not an error. Above 3× list is refused. |
| AC-5.6 | Core items (`is_core`) cannot be disabled; the checkbox is disabled with a tooltip. |
| AC-5.7 | Disabling an item that has **not** been setup-billed removes the pending fee entirely. Test SF-5. |
| AC-5.8 | Every mutation writes a `subscription_item_change` row with before/after JSON. |
| AC-5.9 | The usage meter turns amber above 80% and red at 100% of included calls. |

**API**
```
GET    /api/v1/customers/{id}/subscription
POST   /api/v1/customers/{id}/subscription/items
PATCH  /api/v1/subscription/items/{itemId}
DELETE /api/v1/subscription/items/{itemId}
POST   /api/v1/subscription/items/{itemId}/waive-setup
GET    /api/v1/customers/{id}/usage?period=2026-09
```

**Components** `SubscriptionEditor` ★ → `CatalogGroupSection`, `CatalogItemRow`, `TermToggle`, `PriceOverrideInput`, `SetupStatePill` ★, `UsageMeter`, `ChargeSummaryRail` ★

**Tables** `catalog_item` R · `subscription_item` RW · `subscription_item_change` A · `api_usage_period` RW · `audit_log` A

---

### S6 · Account · Cloud

**What it is.** The cloud accounts we operate for this customer, their provider cost for the period, the management markup, and the rebilled figure.

**Fields (cloud account modal)**

| Field | Control | Validation |
|---|---|---|
| Provider | select | `AWS, Azure, GCP, OCI` |
| Account / subscription id | text | required, unique per provider |
| Region | text | optional |
| Provider cost this period | number | ≥ 0, 2dp — **admin only** |
| Management markup % | number | 0–100, 2dp |
| Note | text | optional |
| Evidence URL | url | optional, link to provider statement |

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Add cloud account | `POST /customers/{id}/cloud-accounts` | `cloud_account` W, `audit_log` A | Also sets `customer.cloud_managed = true` |
| Edit | `PATCH /cloud-accounts/{id}` | `cloud_account` W | Markup change applies to **future** periods only |
| Record period cost | `PUT /cloud-accounts/{id}/periods/{period}` `{providerCost, evidenceUrl}` | `cloud_cost_period` W | Snapshots markup, computes rebill |
| Import CSV | `POST /cloud-costs/import` | `cloud_cost_period` W ×n | Validates account refs, reports unmatched |
| Close period | `POST /cloud-costs/{period}/close` | `cloud_cost_period` W | `Open → Closed`; only then billable |
| Remove | `DELETE /cloud-accounts/{id}` | `cloud_account` W | Refused if an open period exists |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-6.1 | Rebilled = `round(cost × (1 + markup/100), 2)` and updates live as either input changes. |
| AC-6.2 | Editing `markup_pct` never alters an already-closed period. The period keeps its snapshot. |
| AC-6.3 | An `Open` period is visible but cannot be invoiced; the builder shows a warning instead of a line. |
| AC-6.4 | `provider_cost` and margin appear nowhere in the portal response (S27). Architecture test enforces this. |
| AC-6.5 | Customers with `cloud_managed = false` show an explanatory empty state, not a blank table. |

**Tables** `cloud_account` RW · `cloud_cost_period` RW · `customer` W · `audit_log` A

---

### S7 · Account · Billing

**What it is.** Every invoice for this account with its type, period, due date, total, status and ageing bucket.

**Columns** Invoice · Type · Period · Due · Total · Status · Age · Open.

**Actions**

| Action | API | Result |
|---|---|---|
| Raise an invoice | route | → S17 pre-filled |
| Open invoice | route | → S19 |
| Edit draft | route | → S17 in edit mode (drafts only) |
| Send statement | `POST /customers/{id}/statement` | Emails open items, logs `dunning_event` when overdue |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-7.1 | Drafts are visually distinct and show "—" for Age. |
| AC-7.2 | The Edit action exists only on `Draft`. On any other status the button is absent, not disabled. |
| AC-7.3 | Voided invoices remain listed, struck through, with the void reason on hover. |

**Tables** `invoice` R · `invoice_line` R · `payment` R

---

### S8 · Meetings & notes (global)

**What it is.** Every conversation across the whole book, newest first, filterable. This is how a manager sees what the team did this week and what is still open.

**Filters** Everything · Open follow-ups · Meetings · Calls · Notes. Plus owner and date-range in the query string.

**Actions**

| Action | API | Result |
|---|---|---|
| Filter | `GET /activities?type=&owner=&from=&to=&openOnly=` | Re-render |
| Open account | route | → S4 for that customer |
| Mark follow-up done | `POST /activities/{id}/complete` | Removes from the Open filter |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-8.1 | Default view is "Open follow-ups" when any are overdue, otherwise "Everything". |
| AC-8.2 | Overdue follow-ups (due < today, not done) are visually distinct from merely open ones. |
| AC-8.3 | Paginated at 50 with infinite scroll; server-side ordering. |

**Tables** `customer_activity` R · `customer` R

---

### S9 · Onboarding wizard (5 steps)

**What it is.** The single path that creates everything a new customer needs, ending in an editable first invoice. Five steps with a running total rail: **Company → Contact → Apps & APIs → Cloud & fees → Review**.

#### S9.1 Company

| Field | Control | Validation |
|---|---|---|
| Display name | text | required, unique-ish (warn on near match) |
| Registered entity | text | required |
| Country | select | ISO-3166 alpha-2 |
| Website | url | optional |
| Account owner | select | required, defaults to current user |
| Source | select | Inbound / Outbound / Referral / Partner / Event |
| Stage | select | Lead / Discovery / Pilot / Live |
| Tax registration | text | optional, format hint per country |
| Standard discount % | number | 0–60, requires approval above the user's authority |
| Billing cycle | select | Monthly / Annual |
| Start date | date | defaults today, not > 90 days future |
| Start on a 30-day pilot | checkbox | when on: `status = Trial`, no invoice raised, setup deferred |

#### S9.2 Contact

Name · Role · Email for invoices (required) · Phone · Opening CRM note (free text, becomes the first `customer_activity`).

#### S9.3 Apps & APIs

The same picker as S5, grouped **Apps** / **External APIs**, with per-item term radio and price override. Nothing is pre-selected except items flagged `is_core`.

#### S9.4 Cloud & fees

| Field | Control | Notes |
|---|---|---|
| We manage their cloud | checkbox | reveals the block below |
| Provider / Account ref / Region | — | as S6 |
| Expected monthly cost | number | seeds the first `cloud_cost_period` as `Open` |
| Management markup % | number | 0–100 |
| Transaction fees they will be charged | checkbox list | each with an expected volume input |

#### S9.5 Review

Read-only summary of all four steps plus the running total: Apps monthly · APIs monthly · Annual terms · Cloud rebilled · Transaction fees · Discount · **Per month** · One-time setup · **First invoice**.

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Continue | client validation | — | Blocks with a specific message per step (AC-9.2) |
| Back | — | — | Never loses entered data |
| Discard | — | — | Confirm dialog; nothing persisted |
| Create account | `POST /customers/onboard` | `customer` W, `subscription` W, `subscription_item` W×n, `customer_contact` W, `cloud_account` W, `cloud_cost_period` W, `tx_usage_period` W×n, `customer_activity` W, `invoice` W (Draft), `invoice_line` W×n, `audit_log` A | **One transaction.** Then redirects to S17 with the draft open |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-9.1 | The whole creation is one database transaction. A failure at invoice draft stage leaves no orphan customer. |
| AC-9.2 | Step validation: step 1 needs a name; step 2 needs an invoice email; step 3 needs ≥ 1 item. Each blocks with its own message. |
| AC-9.3 | Every created `subscription_item` has `setup_state = 'Due'`, so the first invoice carries **one setup line per item**, named for that item. |
| AC-9.4 | Pilot mode creates the account with `status = Trial`, raises **no** invoice, and creates a follow-up activity "Pilot review call" dated +30 days. |
| AC-9.5 | Non-pilot creation lands the user in S17 with the draft already built — never on a blank invoice screen. |
| AC-9.6 | The running total rail matches the drafted invoice to the cent. |
| AC-9.7 | A discount above the user's authority blocks creation with an approval prompt, it does not silently reduce the discount. |

**API**
```
POST /api/v1/customers/onboard
{
  company:{ displayName, legalName, countryCode, website, ownerUserId, source, stage,
            taxRegistration, standardDiscountPct, billingCycle, startDate, isPilot },
  contact:{ fullName, jobTitle, email, phone, openingNote },
  items:[ { catalogItemId, term, unitPrice? } ],
  cloud:{ managed, provider, accountRef, region, expectedMonthlyCost, markupPct } | null,
  txFees:[ { txFeeRateId, expectedQuantity } ]
}
→ 201 { customerId, code, subscriptionId, draftInvoiceId | null }
```

**Components** `OnboardWizard` → `StepBar`, `CompanyStep`, `ContactStep`, `CatalogStep` (reuses `CatalogItemRow`), `CloudAndFeesStep`, `ReviewStep`, `RunningTotalRail` ★

**Tables** as listed in the Create action above.

---

### S10 · Dashboard

**What it is.** The provider's commercial overview: four KPI tiles, two action banners (unbilled setup fees, pending requests), and revenue by account split across the four streams.

**KPI tiles**

| Tile | Computation | Drill-down |
|---|---|---|
| Monthly recurring | Σ normalised MRR across `status = Active` | → S11 |
| Cloud rebilled | Σ rebill for current period; subtitle shows cost and margin | → S13 |
| Transaction fees | Σ bill for current period; subtitle shows cost and margin | → S14 |
| Receivables | Σ open invoice totals; subtitle counts overdue and drafts | → S22 |

**Banners**

| Banner | Condition | Action |
|---|---|---|
| Unbilled setup fees | any customer with `setupDue > 0` | Lists the accounts and amounts; "Bill them" → S17 for the first |
| Requests waiting | `change_request.status = 'Pending'` count > 0 | "Review requests" → S12 |
| Overdue receivables | any `Overdue` invoice | "Send reminders" runs dunning for all overdue |

**Revenue by account table** Account · Stage · Apps · APIs · Overage · Cloud · Transactions · **Run rate**.

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Load | `GET /dashboard?period=2026-09` | `customer`, `subscription_item`, `api_usage_period`, `cloud_cost_period`, `tx_usage_period`, `invoice`, `change_request` — all R | Single aggregate response, cached 60s |
| Run billing for period | `POST /billing/runs` `{period, dryRun}` | `invoice` W×n, `invoice_line` W×n | Creates **drafts** for every active customer without one; never raises |
| Send reminders | `POST /collections/dunning/run` | `dunning_event` W, `outbox_message` W | Emails per §12 |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-10.1 | "Run billing" creates drafts only. The word "draft" appears in the confirmation. |
| AC-10.2 | A second run for the same period is idempotent: existing drafts are skipped, not duplicated (`ux_invoice_customer_period_type`). |
| AC-10.3 | Margin figures (cloud, transactions) appear only for roles with `viewMargin` permission. |
| AC-10.4 | Run rate = MRR + overage + cloud + transactions for the current period. The tooltip spells that out. |

**Tables** listed above, all R except the run action.

---

### S11 · Subscriptions

**What it is.** Every subscribed line across the whole book, one row per `subscription_item`. This is the report finance uses to answer "what are we actually billing for?".

**Columns** Account · Item · Category (App/API pill) · Group · Term · Since · Price · Monthly equivalent · **Setup fee** · **Setup status** · Pricing (list/override).

**Filters** account, category, term, setup status (`Due`/`Billed`/`Waived`), pricing (list/override).

**Actions**

| Action | API | Result |
|---|---|---|
| Filter / sort | `GET /subscription-items?...` | Server-side |
| Change | route | → S5 for that account |
| Export | `GET /subscription-items/export` | CSV including setup status |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-11.1 | Setup status is shown per line, as `Billed` (green) or `On next invoice` (amber) or `Waived` (grey). |
| AC-11.2 | Filtering by "Setup: Due" produces exactly the set that S10's banner totals. |
| AC-11.3 | Monthly equivalent divides annual prices by 12; the column header says so. |
| AC-11.4 | "Override" is computed as `unit_price <> list_price_at_subscribe`, not against today's list price. |

**Tables** `subscription_item` R · `catalog_item` R · `customer` R

---

### S12 · Requests  ★ high value

**What it is.** The approval queue. A customer asked for an app or API from their portal; approving it adds the item to their subscription **dated today** and queues the setup fee.

**Columns** Request code · Account + requester · Item + category pill · Term · Asked on · Why (justification) · Price · **Setup fee** · Status · Approve/Decline.

**Actions**

| Action | Trigger | API | Tables | Result |
|---|---|---|---|---|
| Approve | button | `POST /requests/{id}/approve` `{effectiveFrom?, unitPrice?, note?}` | `change_request` W, `subscription_item` W, `subscription_item_change` A, `api_usage_period` W (API), `customer_activity` W, `audit_log` A, `outbox_message` W | Item live from `effectiveFrom` (default today), `setup_state='Due'` |
| Decline | button | `POST /requests/{id}/decline` `{reason}` | `change_request` W, `customer_activity` W, `outbox_message` W | Subscription untouched; customer emailed the reason |
| Amend price | inline | part of approve payload | as above | Approving at a negotiated price, not the quoted one, requires a note |
| Filter | tabs | `GET /requests?status=` | `change_request` R | Pending / Approved / Declined / All |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-12.1 | Approval creates the subscription item with `effective_from = today` by default. **Never** the request date. Backdating is possible but requires an explicit date and a note, and is capped at 30 days. |
| AC-12.2 | The created item has `setup_state = 'Due'`; the confirmation toast names the fee. |
| AC-12.3 | Declining changes nothing on the subscription. Test asserts item count unchanged. |
| AC-12.4 | Both outcomes write a `customer_activity` row so the account timeline tells the story. |
| AC-12.5 | Approving a request for an item the customer already holds is refused (409) with a clear message. |
| AC-12.6 | The sidebar badge count equals `Pending` and updates without a full page reload after a decision. |
| AC-12.7 | If the catalog price changed between request and approval, the queue shows both the quoted price and the current price, and asks which to apply. |

**API**
```
GET  /api/v1/requests?status=Pending&page=&size=
POST /api/v1/requests/{id}/approve   { effectiveFrom?, unitPrice?, note? }  → 200 { subscriptionItemId }
POST /api/v1/requests/{id}/decline   { reason }                             → 200
```

**Components** `RequestQueue` → `RequestRow`, `ApproveDialog` ★ (shows quoted vs current price, effective date, setup fee), `DeclineDialog`

**Tables** `change_request` RW · `subscription_item` W · `subscription_item_change` A · `api_usage_period` W · `customer_activity` W · `catalog_item` R · `audit_log` A · `outbox_message` W

---

### S13 · Cloud accounts

**What it is.** Every managed cloud account across all customers, with period costs, markups, rebilled amounts and margin. Three KPI tiles: provider cost, rebilled, management margin.

**Columns** Account (customer) · Provider pill · Reference · Region · Cost · Markup % · Rebilled · Note · Edit.

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Edit cost/markup | modal | `PUT /cloud-accounts/{id}/periods/{period}` | `cloud_cost_period` W | Rebilled recomputes live |
| Import costs | `POST /cloud-costs/import` (CSV) | `cloud_cost_period` W×n | Report: matched, unmatched, changed |
| Close period | `POST /cloud-costs/{period}/close` | `cloud_cost_period` W×n | Makes the period billable |
| Add account | modal | `POST /customers/{id}/cloud-accounts` | `cloud_account` W | — |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-13.1 | Margin = rebilled − cost, shown only with `viewMargin`. |
| AC-13.2 | Average markup in the KPI subtitle is weighted by cost, not a plain mean. |
| AC-13.3 | Closing a period is irreversible in the UI; re-opening requires finance role and writes an audit row. |
| AC-13.4 | CSV import is idempotent on `(account_ref, period)`; re-importing updates an `Open` period and refuses a `Closed` one. |

**Tables** `cloud_account` RW · `cloud_cost_period` RW · `customer` R · `audit_log` A

---

### S14 · Transaction fees

**What it is.** Two tables. **Metered volumes** — what each customer consumed this period, with cost, billable and margin. **Fee rate card** — the master list of per-event charges with our cost and the customer price.

**Fields (rate card row)**

| Field | Control | Validation |
|---|---|---|
| Fee | text | required, unique code |
| Vendor | text | `Sumsub`, `Scheme`, `Ledgerline` |
| Unit | text | `check`, `screening`, `transaction`, `card`, `payout` |
| Our cost | number | ≥ 0, 6dp |
| Customer price | number | ≥ cost (CHECK), 6dp |
| Markup % | computed | `(price/cost − 1) × 100` |

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Edit rate | inline | `PATCH /tx-fee-rates/{id}` | `tx_fee_rate` W, `audit_log` A | Applies to **future** periods only |
| Customer override | modal | `PUT /customers/{id}/tx-fee-rates/{rateId}` | `customer_tx_fee_rate` W | Effective-dated |
| Set volume | inline number | `PUT /tx-usage/{customerId}/{rateId}/{period}` | `tx_usage_period` W | Recomputes cost/bill with snapshots |
| Import volumes | CSV | `POST /tx-usage/import` | `tx_usage_period` W×n | Same idempotency rule as cloud |
| Close period | button | `POST /tx-usage/{period}/close` | `tx_usage_period` W×n | Makes billable |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-14.1 | Changing a rate never alters a `Closed` period; snapshots win. |
| AC-14.2 | `unit_price < unit_cost` is refused by the database, not just the form. |
| AC-14.3 | Portal (S27) shows quantity and price only. Cost and margin columns are absent from the response payload. |
| AC-14.4 | Sumsub-style vendor fees can be zero-margin (price = cost) without error. |

**Tables** `tx_fee_rate` RW · `customer_tx_fee_rate` RW · `tx_usage_period` RW · `customer` R · `audit_log` A

---

### S15 · Marketplace

**What it is.** The master catalog, tabbed **Everything / Apps / External APIs**, grouped into the two groups. Apps show setup, monthly, annual and subscriber count. APIs additionally show included calls and overage rate.

**Columns** Item (name + description) · Category pill · Setup · Monthly · Annual (+ % off) · Included · Overage · Subscribers · Edit.

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Add item | → S16 | `POST /catalog-items` | `catalog_item` W, `catalog_item_price_history` A | — |
| Edit item | → S16 | `PATCH /catalog-items/{id}` | as above | Price change records history, never rewrites existing subscriptions |
| Unlist | toggle | `PATCH /catalog-items/{id}` `{isListed:false}` | `catalog_item` W | Hidden from portal, existing subscribers unaffected |
| Retire | menu | `PATCH /catalog-items/{id}` `{isSellable:false}` | `catalog_item` W | Cannot be newly subscribed |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-15.1 | Exactly two groups are offered: **Apps** and **External APIs**. There is no third option anywhere in the UI or API. |
| AC-15.2 | Annual saving % is computed and displayed: `(1 − annual / (monthly × 12)) × 100`. |
| AC-15.3 | Changing a list price does **not** change any existing `subscription_item.unit_price`. A banner says so on save. |
| AC-15.4 | Subscriber count links to a filtered S11. |
| AC-15.5 | An item with subscribers cannot be deleted, only unlisted or retired. |

**Tables** `catalog_item` RW · `catalog_item_price_history` A · `subscription_item` R

---

### S16 · Catalog item editor

**What it is.** The modal behind Add/Edit on S15. Category drives which fields are shown.

**Fields**

| Field | Control | Validation | Shown when |
|---|---|---|---|
| Category | select | `App` \| `API` | always |
| Group | select | auto-set from category, read-only | always |
| Name | text | required, ≤ 80, unique | always |
| Code | text | required, `^[A-Z]{2}[0-9]{2}$`, unique, immutable after create | always |
| Description | textarea | required, ≤ 400 | always |
| Core item | checkbox | — | App only |
| Setup fee | number | ≥ 0 | always |
| Monthly price | number | ≥ 0, required | always |
| Annual price | number | ≥ 0; auto-suggests `round(monthly × 12 × 0.85)` | always |
| Included calls | number | ≥ 0, required | API only |
| Overage rate | number | ≥ 0, required, 6dp | API only |
| Overage basis | select | `Per 1,000 calls` \| `Per call` | API only |
| Effective from | date | defaults today; price history keyed on it | on price change |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-16.1 | Switching category to API reveals the metering block and forces `group = External APIs`; switching to App hides it and forces `group = Apps`. |
| AC-16.2 | Saving an API without metering fields is refused by the DB CHECK, and by the form first. |
| AC-16.3 | The annual suggestion is a suggestion: the user may overwrite it. |
| AC-16.4 | There is no lifetime field, and no term other than monthly/annual, anywhere on this form. |
| AC-16.5 | Every save writes a `catalog_item_price_history` row when any price field changed. |

**Tables** `catalog_item` RW · `catalog_item_price_history` A

---

### S17 · Invoice builder  ★★ the highest-value screen in the system

**What it is.** Where a human turns computed charges into an invoice they are willing to send. The lines are drafted from the subscription, closed cloud periods, closed usage periods and unbilled setup fees — and then **every field is editable**: description, quantity, rate, a per-line discount, the invoice-level discount, the dates, the printed note. Nothing is billed until Raise.

**Header fields**

| Field | Control | Default | Validation | Notes |
|---|---|---|---|---|
| Account | select | from route `?customer=` or first | required | Locked when editing an existing draft |
| Invoice covers | select | `Combined` | `Combined, Apps & APIs, Cloud, Transactions` | Changing it re-drafts the lines |
| Period | text/month | current period | `YYYY-MM` | Drives which closed periods are pulled |
| Issue date | date | today | ≤ today + 7 | |
| Due date | date | issue + `payment_terms_days` | > issue date | |
| **Invoice discount %** | number | `customer.standard_discount_pct` | 0–100, ≤ user authority | Requires a reason when > 0 |
| Discount reason | text | empty | required when discount > 0 | Printed on the invoice |
| Note printed on invoice | text | empty | ≤ 300 | |
| Internal note | text | empty | ≤ 1000 | Never printed, never sent to portal |

**Line table — every cell editable**

| Column | Control | Validation |
|---|---|---|
| Category | select | `Apps, APIs, Setup, Overage, Cloud, Transactions, Credit, Other` |
| Description | text | required, ≤ 200 |
| Qty | number | ≥ 0 (Credit lines may be 1), 4dp |
| Rate | number | ≥ 0 except Credit which must be ≤ 0, 6dp |
| Disc % | number | 0–100 |
| Amount | computed | `round(qty × rate × (1 − disc/100), 2)`, read-only |
| Remove | button | — |

**Totals rail** Account · Covers · Subtotal · Invoice discount − · Tax · **Total** · Save as draft · **Raise invoice**.

**Actions**

| Action | Trigger | API | Tables | Result |
|---|---|---|---|---|
| Draft lines | on open / account or type change | `POST /invoices/draft-preview` `{customerId, type, period}` | resolver reads: `subscription_item`, `catalog_item`, `api_usage_period`, `cloud_cost_period`, `tx_usage_period` | Returns lines; **writes nothing** |
| Edit a cell | input | local state | — | Amount and totals update without a round-trip |
| Add a line | button | local | — | Blank `Other` line |
| Add a credit | button | local | — | `Credit` line, negative rate |
| Remove line | button | local | — | Renumbers `line_no` |
| Reset from subscription | button | `POST /invoices/draft-preview` | as above | Discards manual edits after confirm |
| Save as draft | button | `POST /invoices` or `PUT /invoices/{id}` `{status:'Draft'}` | `invoice` W, `invoice_line` W, `invoice_status_history` A | **Does not** mark setup fees billed |
| **Raise invoice** | button | `POST /invoices/{id}/raise` | `invoice` W, `invoice_status_history` A, `subscription_item` W (setup→Billed), `api_usage_period` W, `cloud_cost_period` W, `tx_usage_period` W (all → Billed), `number_sequence` W, `outbox_message` W, `audit_log` A | One transaction. Number assigned, PDF queued, email queued |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-17.1 | Opening the builder for a customer produces lines identical to the S2 "next invoice" estimate. |
| AC-17.2 | Every line field is editable while the invoice is `Draft`. |
| AC-17.3 | Line amount recomputes on every keystroke, without a server round-trip; totals recompute from the line set. |
| AC-17.4 | Line discount and invoice discount stack in the order defined in §6.8. A worked example is in the test matrix. |
| AC-17.5 | An invoice discount > 0 without a reason cannot be saved. |
| AC-17.6 | Changing "Invoice covers" to `Cloud` produces **only** `Cloud` lines — no setup, no subscription, no transactions. |
| AC-17.7 | Setup lines appear **one per unbilled item**, named for the item, never lumped into a single "setup fee" line. |
| AC-17.8 | A warning banner appears whenever setup lines are present, stating the total and that they are only marked billed on Raise. |
| AC-17.9 | Saving as Draft leaves `setup_state = 'Due'`, all periods `Closed`, and assigns no invoice number. |
| AC-17.10 | Raising assigns the next number atomically from `number_sequence`, sets `issue_date`, snapshots `bill_to_*` from the CRM, and marks setup + periods billed — all in one transaction. |
| AC-17.11 | Raising is refused if: no lines; no billing contact; any referenced period is still `Open`; the customer is `Closed`. Each has its own error code. |
| AC-17.12 | A discount above the user's authority disables Raise with an explanation and an "ask for approval" action. |
| AC-17.13 | Re-raising an already-raised invoice returns 409, not a duplicate. Idempotency-Key required. |
| AC-17.14 | The builder never mutates anything before Save. Closing the tab loses the edits and changes no data. |

**API**
```
POST /api/v1/invoices/draft-preview   { customerId, type, period }      → { lines:[], warnings:[] }
POST /api/v1/invoices                 { customerId, type, period, issueDate, dueDate,
                                        discountPct, discountReason, notes, internalNote, lines:[] }
                                                                        → 201 { invoiceId, status:'Draft' }
PUT  /api/v1/invoices/{id}            (same body; Draft only)           → 200
POST /api/v1/invoices/{id}/raise      Idempotency-Key required          → 200 { invoiceNumber, status:'Issued' }
POST /api/v1/invoices/{id}/void       { reason }                        → 200
```

**Components** `InvoiceBuilder` ★★ → `InvoiceHeaderForm`, `EditableLineTable` ★, `LineRow`, `TotalsRail` ★, `SetupFeeBanner` ★, `RaiseConfirmDialog`

**Tables** read: `customer`, `customer_contact`, `subscription_item`, `catalog_item`, `api_usage_period`, `cloud_cost_period`, `tx_usage_period`, `billing_settings` · write on save: `invoice`, `invoice_line`, `invoice_status_history` · write on raise: additionally `subscription_item`, the three period tables, `number_sequence`, `outbox_message`, `audit_log`

> **Why this screen carries the most risk.** It is the only place where computed billing meets human judgement. Every rule in §6 is visible here, and every mistake here is visible to the customer. Build the resolver first, test it with golden files (§13.3), and only then wire the UI.

---

### S18 · Invoices

**What it is.** Every invoice with status tabs and a "covers" filter. Drafts carry an Edit action; nothing else does.

**Tabs** All · Draft · Issued · Pending verification · Paid · Overdue · Void.
**Covers filter** Everything · Combined · Apps & APIs · Cloud · Transactions.

**Columns** Invoice · Account · Covers · Period · Due · Discount · Total · Status · Edit/Open.

**Actions**

| Action | API | Result |
|---|---|---|
| Filter | `GET /invoices?status=&type=&customerId=&period=&page=` | Server-side |
| Open | route | → S19 |
| Edit (Draft only) | route | → S17 edit mode |
| Run billing for period | `POST /billing/runs` | Drafts for everyone missing one |
| Bulk raise | `POST /invoices/raise-batch` `{invoiceIds[]}` | Sequential, per-invoice result list |
| Export | `GET /invoices/export` | CSV for the finance system |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-18.1 | Draft rows show no invoice number (they have none) — the id is shown instead, greyed. |
| AC-18.2 | Bulk raise reports per-invoice success/failure; one failure does not abort the rest. |
| AC-18.3 | The Overdue tab is computed from `due_date < today AND status = 'Issued'`, and a nightly worker flips the stored status (§11). |

**Tables** `invoice` R · `customer` R

---

### S19 · Invoice document

**What it is.** The printable tax invoice: company block, bill-to snapshot, lines grouped by category, totals, payment instructions, and the payment record once settled.

**Sections** Header (type, number, dates, period, covers, currency) · Billed to (snapshot) · Reference (account code, owner, billing cycle) · Lines grouped by category · Totals · Printed note · Payment instructions or settlement record.

**Actions**

| Action | Availability | API | Result |
|---|---|---|---|
| Print / save PDF | always | client print, or `GET /invoices/{id}/pdf` | Server PDF is the canonical artefact |
| Edit lines | Draft only | route | → S17 |
| Raise invoice | Draft only | `POST /invoices/{id}/raise` | As S17 |
| Pay this invoice | not Draft, not Paid | route | → S20/S29 |
| Confirm payment | Pending verification, admin | `POST /payments/{id}/verify` | → Paid |
| Send reminder | Issued/Overdue, admin | `POST /invoices/{id}/remind` | `dunning_event` W |
| Void | admin, not Paid | `POST /invoices/{id}/void` `{reason}` | Status → Void, setup fees **stay Billed** (see AC-19.5) |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-19.1 | A Draft renders with the title "Draft invoice" and a banner stating it is not sent and not in receivables. |
| AC-19.2 | Bill-to uses the snapshot on the invoice, not the live CRM record. |
| AC-19.3 | Lines are grouped by category with a subheading row, in the order Apps, APIs, Setup, Overage, Cloud, Transactions, Credit, Other. |
| AC-19.4 | Payment instructions show bank details **and** crypto networks; the invoice number is quoted as the reference. |
| AC-19.5 | Voiding does **not** reset setup fees to Due — a voided invoice may already have been sent. Re-billing requires a new invoice with a manual setup line and a note. Documented on the void dialog. |
| AC-19.6 | The PDF is byte-identical for the same invoice across regenerations (deterministic rendering, no timestamps in the body). |

**Tables** `invoice` R · `invoice_line` R · `payment` R · `billing_settings` R · `bank_account` R · `crypto_wallet` R

---

### S20 / S29 · Payment  ★ shared by console and portal

**What it is.** Where the amount due is turned into a transfer. Two methods, each with everything needed to pay and a place to declare what was sent.

**Bank transfer fields (read-only, copyable)** Beneficiary · Bank + branch · Account number · IBAN · SWIFT/BIC · **Payment reference = invoice number**.

**Crypto fields** Asset/network selector (USDT-ERC20, USDT-TRC20, USDC-Polygon, BTC) · Amount in asset (from locked quote) · Address + QR · Confirmations required · Rate lock countdown.

**Confirmation fields**

| Field | Control | Validation |
|---|---|---|
| Wire reference / UTR | text | required for bank; 6–64 chars |
| Transaction hash | text | required for crypto; hex, 40–80 chars; **globally unique** |
| Amount sent | number | defaults to invoice total; must be within 1% or flagged as partial |
| Evidence | file | optional, PDF/PNG ≤ 5 MB, virus-scanned |

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Select method | local | — | Panel switches |
| Select wallet | `POST /invoices/{id}/crypto-quote` `{walletId}` | `crypto_quote` W | Locks rate for 30 minutes, starts countdown |
| Copy field | clipboard | — | Toast confirmation |
| Submit payment | `POST /invoices/{id}/payments` | `payment` W, `invoice` W (→ PendingVerification), `invoice_status_history` A, `outbox_message` W | Admin sees it in S21 |
| Back to invoice | route | — | — |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-20.1 | Submitting without a reference/hash is refused with a message naming the field. |
| AC-20.2 | The crypto amount shown is the one stored on the quote. If the quote expires, the screen re-quotes and tells the user the amount changed. |
| AC-20.3 | A network warning is shown: *"Send only USDT on Tron. Anything sent on another network is unrecoverable."* |
| AC-20.4 | A transaction hash already claimed on any invoice is rejected (409, unique index). |
| AC-20.5 | Submitting moves the invoice to `PendingVerification`, never straight to `Paid`. |
| AC-20.6 | The QR encodes the correct URI scheme per asset (`ethereum:`, `tron:`, `bitcoin:`), including amount where the scheme supports it. |
| AC-20.7 | The same screen serves admin and portal; the portal version hides the internal note and shows no cost data. |

**Tables** `invoice` RW · `payment` W · `crypto_quote` W · `bank_account` R · `crypto_wallet` R · `invoice_status_history` A · `outbox_message` W

---

### S21 · Payments & proofs

**What it is.** The reconciliation queue. Submitted proofs wait here until a human matches them to a bank statement or a chain explorer and confirms.

**Waiting table** Invoice · Account · Amount · Method pill · Reference/hash · Not found / Confirm.
**History table** Invoice · Account · Date · Method · Reference · Amount · Status.

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Confirm | `POST /payments/{id}/verify` `{amountVerified}` | `payment` W, `invoice` W (→ Paid or partially paid), `invoice_status_history` A, `audit_log` A, `outbox_message` W | Receipt emailed |
| Not found | `POST /payments/{id}/reject` `{reason}` | `payment` W (→ Rejected), `invoice` W (→ back to Issued/Overdue) | Customer notified with the reason |
| Open on explorer | link | — | Deep link to the chain explorer for the hash |
| Record offline payment | `POST /invoices/{id}/payments` `{method:'Offset'|'WriteOff'}` | `payment` W, `invoice` W | Finance role only, reason required |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-21.1 | Confirming with `amountVerified < total` leaves the invoice open with `amount_paid` increased, status stays `Issued`/`Overdue`, and the remaining balance is shown. |
| AC-21.2 | Confirming with `amountVerified ≥ total` sets `Paid` and stamps `verified_at_utc`. |
| AC-21.3 | Rejecting returns the invoice to its **previous** status, computed from the due date, not blindly to `Issued`. |
| AC-21.4 | Verification requires the billing-operator role. Attempting it as an account owner returns 403. |
| AC-21.5 | The verifier's identity is written to `payment.verified_by` and `audit_log`. Self-verification of a payment you submitted via impersonation is blocked. |

**Tables** `payment` RW · `invoice` RW · `invoice_status_history` A · `audit_log` A · `outbox_message` W

---

### S22 · Accounts receivable

**What it is.** What is owed, how old it is, and who to chase. Four ageing tiles, ageing by account, and the open-invoice list sorted oldest first.

**Buckets** `Current` (not yet due) · `1–30 days` · `31–60 days` · `60+ days`, computed from `due_date` vs today.

**Columns (by account)** Account · Owner · Invoice count · Current · 1–30 · 31–60 · 60+ · Total · Statement.
**Columns (open invoices)** Invoice · Account · Covers · Due · Age · Bucket · Amount · Status · Remind / Mark paid.

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Send statement | `POST /customers/{id}/statement` | `outbox_message` W, `audit_log` A | PDF of open items emailed to billing contacts |
| Remind | `POST /invoices/{id}/remind` | `dunning_event` W, `outbox_message` W | Uses the template for the current step |
| Send reminders to everyone overdue | `POST /collections/dunning/run` | as above ×n | Respects per-invoice cooldown of 72h |
| Mark paid | `POST /invoices/{id}/payments` `{method:'Offset'}` | `payment` W, `invoice` W | Finance only, reason required |
| Write off | `POST /invoices/{id}/write-off` `{reason}` | `payment` W, `invoice` W, `credit_note` W | Finance only |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-22.1 | Drafts and voids are excluded from every AR figure. |
| AC-22.2 | Bucket totals sum to the receivables tile on S10, exactly. |
| AC-22.3 | Ageing is computed from `due_date`, not `issue_date`. |
| AC-22.4 | `PendingVerification` invoices remain in AR with their bucket, flagged so nobody chases a customer who has already paid. |
| AC-22.5 | A statement lists only open items and shows the total outstanding as at today's date. |

**Tables** `invoice` R · `payment` R · `customer` R · `dunning_event` W · `credit_note` W

---

### S23 · Settings

**What it is.** Invoice identity, tax and collections policy, the bank accounts and the crypto wallets shown to customers.

**Fields**

| Group | Field | Validation |
|---|---|---|
| Identity | Legal name, address, tax line, billing email | all required |
| Tax | Tax label, tax rate % | 0–30, 2dp |
| Collections | Payment terms (days), late fee %/month, dunning days array, suspend after days | terms 0–90; dunning ascending |
| Numbering | Invoice number format | must contain `{seq}`; validated by a dry-run render |
| Bank accounts | label, bank, beneficiary, account, IBAN, SWIFT, active | SWIFT `^[A-Z]{6}[A-Z0-9]{2,5}$` |
| Wallets | asset, chain, address, min confirmations, active | address validated per chain (checksum for EVM, base58 for BTC/Tron) |

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Save settings | `PATCH /settings/billing` | `billing_settings` W, `audit_log` A | Finance role only |
| Add/edit bank account | `POST|PATCH /settings/bank-accounts` | `bank_account` W, `audit_log` A | Two-person rule (§16 D4) |
| Add/edit wallet | `POST|PATCH /settings/wallets` | `crypto_wallet` W, `audit_log` A | Two-person rule |
| Deactivate | `PATCH .../{id}` `{isActive:false}` | as above | Hidden from new payments; historic payments keep the reference |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-23.1 | Changing the tax rate affects **drafts and future invoices only**. Issued invoices keep their `tax_pct`. |
| AC-23.2 | A wallet address change requires a second finance user to approve before it becomes active. Payment details are the highest-value fraud target in this system. |
| AC-23.3 | Every settings change writes an audit row with before/after. |
| AC-23.4 | Deactivating the last active bank account is refused. |

**Tables** `billing_settings` RW · `bank_account` RW · `crypto_wallet` RW · `audit_log` A

---

### S24 · Portal · Overview

**What it is.** What the customer sees first: their recurring cost, cloud and transaction charges for the period, anything outstanding, a heads-up about a queued setup fee, their subscription list and their API meters.

**Tiles** Recurring (per month) · Cloud this period · Transaction fees · Amount due.

**Banners** open invoice with Pay now · pending requests · **queued setup fee** naming the items.

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-24.1 | The customer sees the same recurring figure the console shows for them, from the same resolver. |
| AC-24.2 | Cloud shows the **rebilled** amount only. `provider_cost` and markup % are absent from the payload, not merely hidden in the UI. |
| AC-24.3 | A queued setup fee is disclosed before it appears on an invoice: *"A one-time setup fee of $6,000 for recently added items will appear on your next invoice: Cards."* |
| AC-24.4 | Overage is labelled "estimated, not yet billed" while the period is open. |
| AC-24.5 | Drafts are invisible to the customer. |

**API** `GET /api/v1/portal/me/overview`
**Tables** `customer` R · `subscription_item` R · `catalog_item` R · `api_usage_period` R · `cloud_cost_period` R (rebill only) · `tx_usage_period` R (bill only) · `invoice` R · `change_request` R

---

### S25 · Portal · Apps & APIs

**What it is.** The marketplace as the customer sees it: everything listed, ticks on what they have, prices on everything, and a **Request** button on what they do not. Terms are visible but not editable — only a request can change a subscription.

**Request modal fields**

| Field | Control | Validation |
|---|---|---|
| Term | radio Monthly/Annual | required; shows both prices |
| Why do you need it? | textarea | ≤ 1000, optional but encouraged |
| Acknowledgement | static text | states the setup fee and that billing starts on approval |

**Actions**

| Action | API | Tables | Result |
|---|---|---|---|
| Request | `POST /portal/requests` `{catalogItemId, term, justification}` | `change_request` W, `outbox_message` W, `customer_activity` W | Row becomes "Requested <date>"; admin badge increments |
| Cancel request | `POST /portal/requests/{id}/cancel` | `change_request` W | Only while Pending |

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-25.1 | The modal states the one-time setup fee and that **billing starts only when our team approves**. |
| AC-25.2 | A second request for an item already requested is blocked by the partial unique index and surfaced as "already requested on <date>". |
| AC-25.3 | Unlisted catalog items (`is_listed = false`) do not appear. |
| AC-25.4 | The customer cannot change a term or a price from the portal. Those controls are disabled with an explanation. |
| AC-25.5 | Requesting an item they already hold is impossible — the row shows the tick, not the button. |

**Tables** `catalog_item` R · `subscription_item` R · `change_request` RW · `api_usage_period` R

---

### S26 · Portal · My requests

**What it is.** What they asked for and where each request stands.

**Columns** Request · Item + category · Term · Asked · Reason · Price · Setup fee · Status (+ "Live since <date>" when approved).

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-26.1 | An approved request shows the effective date, so the customer can reconcile the prorated line on their next invoice. |
| AC-26.2 | A declined request shows the reason our team gave. |
| AC-26.3 | The list is scoped by RLS; a crafted request id for another customer returns 404, not 403. |

**Tables** `change_request` R · `catalog_item` R

---

### S27 · Portal · Cloud & transactions

**What it is.** The pass-through charges for the period, so the customer can see what is coming before the invoice arrives.

**Cloud table** Provider · Account · Region · **Rebilled amount** · period.
**Transaction table** Fee · Provider · Volume · Unit price · Amount.

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-27.1 | No cost column. No markup column. No margin. The DTO does not contain the fields. |
| AC-27.2 | A note explains that cloud is billed at cost plus an agreed management percentage, without stating the percentage unless the contract says to disclose it (`cloud_account.disclose_markup`, v1.1). |
| AC-27.3 | Open periods are labelled "in progress, not yet billed". |

**Tables** `cloud_cost_period` R (rebill only) · `cloud_account` R (provider, ref, region only) · `tx_usage_period` R (qty, price, bill only) · `tx_fee_rate` R (name, vendor, unit only)

---

### S28 · Portal · Invoices & payments

**What it is.** Their invoice history and the way to pay. Drafts never appear.

**Columns** Invoice · Covers · Period · Due · Total · Status · Pay/View.

**Acceptance criteria**

| ID | Requirement |
|---|---|
| AC-28.1 | `Draft` and `Void` invoices are excluded from the query, not filtered in the browser. |
| AC-28.2 | `Pending verification` shows "we're checking your payment", with the reference they submitted. |
| AC-28.3 | The Pay action is present only when a balance is outstanding. |
| AC-28.4 | Invoice PDF download is served through a signed, expiring URL scoped to that customer. |

**Tables** `invoice` R · `payment` R

---

## 8. API reference

### 8.1 Conventions

- Base path `/api/v1`. Bearer token required on every route.
- **Tenant comes from the token**, never from a route or body parameter. Portal tokens carry `customer_id`; console tokens carry staff roles.
- Every mutating endpoint requires an `Idempotency-Key` header. Replays return the original response.
- Money in JSON is a **string** decimal (`"1234.56"`), never a float.
- Dates are ISO-8601; periods are `"2026-09"`.
- Concurrency: `If-Match` with the base64 `rowVersion` on `PATCH`/`PUT` of aggregates. Mismatch → `409`.
- Pagination: `?page=1&size=50`, response `{ items, total, page, size }`.

### 8.2 Endpoint list

```
# ── CRM ───────────────────────────────────────────────────
GET    /customers                              ?q&stage&owner&hasBalance&page&size&sort
POST   /customers                              (bare create; prefer /onboard)
GET    /customers/{id}
PATCH  /customers/{id}
POST   /customers/{id}/suspend                 { reason }
POST   /customers/onboard                      (the wizard, one transaction)
GET    /customers/{id}/contacts
POST   /customers/{id}/contacts
PATCH  /contacts/{contactId}
POST   /contacts/{contactId}/make-primary
POST   /contacts/{contactId}/invite
DELETE /contacts/{contactId}
GET    /activities                             ?type&owner&from&to&openOnly
POST   /customers/{id}/activities
PATCH  /activities/{id}
POST   /activities/{id}/complete

# ── Catalog ───────────────────────────────────────────────
GET    /catalog-items                          ?kind&listed&sellable
POST   /catalog-items
PATCH  /catalog-items/{id}
GET    /catalog-items/{id}/price-history

# ── Subscription ──────────────────────────────────────────
GET    /customers/{id}/subscription
POST   /customers/{id}/subscription/items      { catalogItemId, term, unitPrice?, effectiveFrom }
PATCH  /subscription/items/{itemId}            { term?, unitPrice?, reason? }
DELETE /subscription/items/{itemId}            { effectiveTo, reason }
POST   /subscription/items/{itemId}/waive-setup{ reason }
GET    /subscription-items                     ?customerId&kind&term&setupState&pricing

# ── Requests ──────────────────────────────────────────────
GET    /requests                               ?status
POST   /requests/{id}/approve                  { effectiveFrom?, unitPrice?, note? }
POST   /requests/{id}/decline                  { reason }
POST   /portal/requests                        { catalogItemId, term, justification }
POST   /portal/requests/{id}/cancel

# ── Metering ──────────────────────────────────────────────
POST   /usage/api/ingest                       (gateway → daily counters, batch)
GET    /customers/{id}/usage                   ?period
POST   /usage/{period}/close
PUT    /cloud-accounts/{id}/periods/{period}   { providerCost, evidenceUrl? }
POST   /cloud-costs/import                     (CSV)
POST   /cloud-costs/{period}/close
PUT    /tx-usage/{customerId}/{rateId}/{period}{ quantity }
POST   /tx-usage/import                        (CSV)
POST   /tx-usage/{period}/close
GET    /tx-fee-rates
PATCH  /tx-fee-rates/{id}
PUT    /customers/{id}/tx-fee-rates/{rateId}   { unitPrice, effectiveFrom }

# ── Cloud ─────────────────────────────────────────────────
GET    /cloud-accounts                         ?customerId&provider
POST   /customers/{id}/cloud-accounts
PATCH  /cloud-accounts/{id}
DELETE /cloud-accounts/{id}

# ── Invoicing ─────────────────────────────────────────────
POST   /invoices/draft-preview                 { customerId, type, period }   ← writes nothing
GET    /invoices                               ?status&type&customerId&period
POST   /invoices                               (save draft)
GET    /invoices/{id}
PUT    /invoices/{id}                          (Draft only)
POST   /invoices/{id}/raise                    ← the transaction that matters
POST   /invoices/raise-batch                   { invoiceIds:[] }
POST   /invoices/{id}/void                     { reason }
GET    /invoices/{id}/pdf
POST   /billing/runs                           { period, dryRun }

# ── Payments ──────────────────────────────────────────────
POST   /invoices/{id}/crypto-quote             { walletId }
POST   /invoices/{id}/payments                 { method, amountClaimed, wireReference|txHash, ... }
GET    /payments                               ?state
POST   /payments/{id}/verify                   { amountVerified }
POST   /payments/{id}/reject                   { reason }

# ── Collections ───────────────────────────────────────────
GET    /receivables/ageing                     ?asOf
POST   /customers/{id}/statement
POST   /invoices/{id}/remind
POST   /collections/dunning/run
POST   /invoices/{id}/write-off                { reason }

# ── Settings ──────────────────────────────────────────────
GET    /settings/billing
PATCH  /settings/billing
GET    /settings/bank-accounts
POST   /settings/bank-accounts
PATCH  /settings/bank-accounts/{id}
GET    /settings/wallets
POST   /settings/wallets
PATCH  /settings/wallets/{id}

# ── Portal (customer token) ───────────────────────────────
GET    /portal/me/overview
GET    /portal/me/subscription
GET    /portal/me/catalog
GET    /portal/me/usage                        ?period
GET    /portal/me/cloud-and-fees               ?period
GET    /portal/me/invoices
GET    /portal/me/invoices/{id}
```

### 8.3 Key payloads

**`POST /invoices/draft-preview`**
```jsonc
// request
{ "customerId": "…", "type": "Combined", "period": "2026-09" }

// response
{
  "lines": [
    { "category":"Apps",  "description":"Core & wallets (monthly)", "quantity":"1",
      "unitPrice":"1800.00", "lineDiscountPct":"0", "amount":"1800.00",
      "sourceKind":"SubscriptionItem", "sourceId":"…", "isProrated":false },
    { "category":"Setup", "description":"Cards — one-time setup fee", "quantity":"1",
      "unitPrice":"6000.00", "lineDiscountPct":"0", "amount":"6000.00",
      "sourceKind":"SetupFee", "sourceId":"<subscriptionItemId>", "isProrated":false },
    { "category":"Overage","description":"Payments API overage — 18,400 calls","quantity":"18.4",
      "unitPrice":"1.600000","lineDiscountPct":"0","amount":"29.44",
      "sourceKind":"ApiOverage","sourceId":"…","periodFrom":"2026-08-01","periodTo":"2026-08-31" },
    { "category":"Cloud", "description":"AWS 4471-9982-1130 — Aug 2026 usage incl. 12% management",
      "quantity":"1","unitPrice":"5399.02","lineDiscountPct":"0","amount":"5399.02",
      "sourceKind":"CloudPeriod","sourceId":"…" }
  ],
  "warnings": [ "Transaction period 2026-08 for Sumsub — AML screening is still Open and was not included." ]
}
```

**`POST /invoices/{id}/raise`** → `200`
```jsonc
{ "invoiceId":"…", "invoiceNumber":"INV-2026-0007", "status":"Issued",
  "issueDate":"2026-09-10", "dueDate":"2026-09-24",
  "total":"25095.00",
  "setupFeesMarkedBilled":[ { "subscriptionItemId":"…", "itemName":"Cards", "amount":"6000.00" } ],
  "periodsMarkedBilled":{ "api":2, "cloud":1, "transactions":3 } }
```

**`POST /requests/{id}/approve`** → `200`
```jsonc
{ "requestId":"…", "status":"Approved",
  "subscriptionItemId":"…", "effectiveFrom":"2026-09-11",
  "unitPrice":"1200.00", "term":"Monthly",
  "setupFee":{ "amount":"1500.00", "state":"Due",
               "note":"Will appear on the next invoice as 'Wallets API — one-time setup fee'" },
  "firstInvoiceEstimate":{ "prorated":"760.00", "periodFrom":"2026-09-11","periodTo":"2026-09-30" } }
```

### 8.4 Error contract

```jsonc
{ "type":"https://errors.ledgerline.io/invoice-not-draft",
  "title":"Invoice cannot be edited",
  "status":409,
  "code":"INVOICE_NOT_DRAFT",
  "detail":"INV-2026-0005 was issued on 2026-09-01. Void it and raise a new invoice.",
  "instance":"/api/v1/invoices/…",
  "traceId":"00-…" }
```

| Code | HTTP | Meaning |
|---|---|---|
| `INVOICE_NOT_DRAFT` | 409 | Edit or raise attempted on a non-draft |
| `INVOICE_ALREADY_RAISED` | 409 | Duplicate raise; idempotency replay returns the original 200 instead |
| `INVOICE_NO_LINES` | 422 | Raise with an empty line set |
| `NO_BILLING_CONTACT` | 422 | Raise with nobody to send it to |
| `PERIOD_STILL_OPEN` | 422 | A referenced usage/cloud/tx period is not `Closed` |
| `DISCOUNT_ABOVE_AUTHORITY` | 403 | Discount exceeds the caller's limit |
| `SETUP_ALREADY_BILLED` | 409 | Attempt to re-bill a `Billed` setup fee |
| `ITEM_ALREADY_SUBSCRIBED` | 409 | Adding or approving a duplicate catalog item |
| `REQUEST_ALREADY_OPEN` | 409 | Second pending request for the same item |
| `TX_HASH_ALREADY_CLAIMED` | 409 | Chain transaction used on another payment |
| `QUOTE_EXPIRED` | 410 | Crypto quote older than 30 minutes |
| `CONCURRENCY_CONFLICT` | 409 | `rowVersion` mismatch |

---

## 9. .NET backend structure

### 9.1 The central architectural decision

Billing calculation is **pure**. It takes a snapshot of state and produces a charge set. It touches no database, no clock, no configuration. Everything that reads or writes is a thin shell around it.

This is not architectural taste. It is the only way to make month-end reproducible: you can replay September's inputs in 2029 and get September's invoice, byte for byte.

```
Input snapshot ──▶ ChargeResolver (pure) ──▶ ChargeSet ──▶ InvoiceDraftBuilder ──▶ Invoice (Draft)
                                                                                        │
                                                                        human edits ────┤
                                                                                        ▼
                                                                              RaiseInvoiceHandler
                                                                              (one transaction)
```

### 9.2 Project layout

```
src/
  Ledgerline.Domain/                 ← no dependencies. Ever.
    Customers/          Customer.cs, CustomerContact.cs, CustomerActivity.cs
    Catalog/            CatalogItem.cs, CatalogKind.cs, BillingTerm.cs
    Subscriptions/      Subscription.cs, SubscriptionItem.cs, SetupState.cs
                        SubscriptionItem.MarkSetupBilled(), .ChangeTerm(), .Override()
    Requests/           ChangeRequest.cs  (Approve/Decline are methods on the aggregate)
    Billing/
      ChargeResolver.cs      ← pure, the heart of §6
      Proration.cs
      Money.cs               ← Round2, all arithmetic
      InvoiceTotals.cs
    Invoicing/          Invoice.cs, InvoiceLine.cs
                        Invoice.AddLine(), .Recalculate(), .Raise(), .Void()
    Payments/           Payment.cs, CryptoQuote.cs
    Metering/           ApiUsagePeriod.cs, CloudCostPeriod.cs, TxUsagePeriod.cs

  Ledgerline.Application/            ← use cases, MediatR handlers, DTOs, validators
    Invoicing/Commands/  SaveDraftInvoice, RaiseInvoice, VoidInvoice
    Invoicing/Queries/   DraftPreview, GetInvoice, ListInvoices
    Requests/Commands/   ApproveRequest, DeclineRequest
    Billing/             RunBillingForPeriod
    Abstractions/        IChargeResolver, IInvoiceNumberGenerator, IClock, ICurrentUser

  Ledgerline.Infrastructure/
    Persistence/        LedgerlineDbContext, configurations, migrations, RLS setup
    Numbering/          InvoiceNumberGenerator (SELECT … FOR UPDATE on number_sequence)
    Pdf/                QuestPDF invoice renderer
    Email/              Templated sender reading notification_template
    Crypto/             RateProvider (quote locking)
    Outbox/             OutboxDispatcher

  Ledgerline.Api/
    Controllers/        one per resource group in §8.2
    Portal/             separate controllers, separate DTOs, separate policy
    Middleware/         IdempotencyMiddleware, TenantResolution, ProblemDetails

  Ledgerline.Workers/
    CloseUsagePeriodsWorker     (1st of month, 02:00 UTC)
    OverdueSweepWorker          (daily, 03:00 UTC)
    DunningWorker               (daily, 09:00 local per customer)
    OutboxDispatcherWorker      (every 10s)
    AnnualRenewalWorker         (daily — items where term_renews_on = today)

tests/
  Ledgerline.Domain.Tests/            ← the golden files live here
  Ledgerline.Application.Tests/
  Ledgerline.Api.IntegrationTests/    ← Testcontainers, real Postgres
  Ledgerline.Architecture.Tests/
```

### 9.3 The raise transaction — the one that must be right

```csharp
public async Task<RaiseResult> Handle(RaiseInvoice cmd, CancellationToken ct)
{
    await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);

    var invoice = await _db.Invoices
        .Include(i => i.Lines)
        .SingleAsync(i => i.InvoiceId == cmd.InvoiceId, ct);

    // 1. Guards — each with its own error code (§8.4)
    if (invoice.Status != InvoiceStatus.Draft) throw new DomainException("INVOICE_NOT_DRAFT");
    if (invoice.Lines.Count == 0)              throw new DomainException("INVOICE_NO_LINES");
    await _guards.EnsureBillingContactAsync(invoice.CustomerId, ct);
    await _guards.EnsureReferencedPeriodsClosedAsync(invoice, ct);
    _authority.EnsureDiscountAllowed(invoice.DiscountPct);

    // 2. Number, dates, bill-to snapshot
    invoice.InvoiceNumber = await _numbers.NextAsync($"invoice:{_clock.UtcNow:yyyy}", ct);
    invoice.IssueDate     = _clock.Today;
    invoice.DueDate       = _clock.Today.AddDays(await _settings.TermsDaysAsync(invoice.CustomerId, ct));
    await _snapshots.ApplyBillToAsync(invoice, ct);

    // 3. Freeze totals
    invoice.Recalculate();

    // 4. Consume what this invoice bills — order matters for readable failures
    await _setup.MarkSetupBilledAsync(invoice, ct);            // §6.4
    await _periods.MarkPeriodsBilledAsync(invoice, ct);        // api / cloud / tx → Billed

    // 5. Transition + audit + side effects via outbox
    invoice.Transition(InvoiceStatus.Issued, _user.Id, reason: null);
    _db.OutboxMessages.Add(OutboxMessage.InvoiceRaised(invoice));
    _audit.Record("Invoice", invoice.InvoiceId, "Raised", before: null, after: invoice.Snapshot());

    await _db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
    return RaiseResult.From(invoice);
}
```

**Four things that must be true of this method**

1. Everything is in one transaction. A crash after numbering but before marking setup billed would bill the fee twice next month.
2. The PDF and the email are **not** generated inline. They go through the outbox; a failing SMTP server must not roll back an invoice.
3. `MarkSetupBilledAsync` matches on `source_id`, never on description text.
4. `Recalculate()` runs after all edits and before the status change, so stored totals always match the stored lines.

### 9.4 Concurrency

- Two operators raising the same draft: second gets `409 CONCURRENCY_CONFLICT` from the `row_version` token.
- Two operators approving the same request: the partial unique index `ux_subitem_active` rejects the duplicate subscription item; catch `UniqueViolation` and map to `ITEM_ALREADY_SUBSCRIBED`.
- Invoice numbering: `SELECT next_value FROM number_sequence WHERE sequence_key = $1 FOR UPDATE`. Never `MAX(invoice_number) + 1`.
- Crypto payment submitted twice: `ux_payment_txhash` rejects the second.

---

## 10. React frontend structure

### 10.1 Folder layout

```
src/
  app/            router, query client, auth, theme tokens
  api/            generated client (see 10.2), hooks per resource
  components/
    money/        MoneyCell, MoneyInput      ← decimal strings, never floats
    pills/        StatusPill, KindPill, TermPill, SetupStatePill ★
    data/         DataTable, KpiTile, AgeingBar, UsageMeter
    forms/        Field, Select, DateField, PercentInput
  features/
    accounts/     AccountsPage, AccountDetail(tabs), EditAccountModal
    activities/   Timeline, LogActivityModal
    subscription/ SubscriptionEditor ★, CatalogItemRow, ChargeSummaryRail ★
    onboarding/   OnboardWizard (5 steps), RunningTotalRail
    requests/     RequestQueue, ApproveDialog ★
    catalog/      MarketplacePage, CatalogItemEditor
    cloud/        CloudAccountsPage, CloudAccountModal
    txfees/       TxFeesPage, RateCardTable
    invoicing/    InvoiceBuilder ★★, EditableLineTable ★, TotalsRail ★,
                  InvoiceList, InvoiceDocument
    payments/     PaymentPage (shared), PaymentQueue
    receivables/  AgeingPage, StatementDialog
    settings/     SettingsPage
    portal/       Overview, Catalog, Requests, CloudAndFees, Invoices
```

### 10.2 Types are generated, never hand-written

```json
// package.json
"scripts": {
  "api:types": "openapi-typescript http://localhost:5080/swagger/v1/swagger.json -o src/api/schema.d.ts"
}
```

Run it in CI. A drift between the C# DTO and the TS type must break the build, not production.

### 10.3 The three components to build once and reuse

| Component | Used by | Why it must be shared |
|---|---|---|
| `CatalogItemRow` ★ | S5, S9.3, S25 | Three screens render the same item with different affordances (edit / select / request). One component, one `mode` prop. Divergence here is how the portal ends up showing a stale price. |
| `EditableLineTable` ★ | S17 | Line-level editing with live amounts. Controlled inputs, local state, debounced validation, no server round-trip per keystroke. |
| `SetupStatePill` ★ | S5, S11, S12, S17, S24 | The setup fee is the thing this system gets wrong. Rendering it identically everywhere is how people learn to trust it. |

### 10.4 Data layer rules

1. **Money is a string end to end.** `MoneyInput` parses on blur, stores a decimal string. Never `parseFloat`.
2. TanStack Query with `staleTime: 0` for anything billing-related. A cached invoice total is a wrong invoice total.
3. Mutations invalidate by tag: raising an invoice invalidates `invoices`, `receivables`, `dashboard`, `customer:{id}`.
4. The invoice builder holds line state locally and posts the whole line set. No per-line PATCH endpoints — partial saves create half-invoices.
5. Optimistic updates are allowed for CRM (activities, contacts) and forbidden for anything that moves money.

### 10.5 Frontend rules that are not negotiable

- Never compute an invoice total in the browser for display **after** save — read the server's stored totals. The browser computes only while drafting.
- Never render a value the API did not send. If margin is absent, there is no margin UI.
- Every destructive action (void, write-off, remove item, decline request) uses a typed confirmation, not a bare `confirm()`.
- Currency symbol and decimals come from the response, not from a hardcoded `$`.

---

## 11. State machines

### 11.1 Invoice

```
        create draft
            │
            ▼
       ┌─────────┐  raise   ┌──────────┐  payment submitted ┌──────────────────────┐
       │  Draft  │─────────▶│  Issued  │───────────────────▶│ PendingVerification  │
       └─────────┘          └──────────┘                    └──────────────────────┘
            │                    │  ▲                            │            │
            │ void               │  │ due date passes            │ verified   │ rejected
            │                    ▼  │                            ▼            │
            │               ┌──────────┐                     ┌───────┐        │
            └──────────────▶│ Overdue  │────────────────────▶│ Paid  │◀───────┘
                     void   └──────────┘     verified        └───────┘
                                 │
                                 │ void (finance only, reason required)
                                 ▼
                            ┌────────┐
                            │  Void  │   terminal
                            └────────┘
```

| Transition | Trigger | Side effects |
|---|---|---|
| Draft → Issued | `POST /raise` | number assigned, setup → Billed, periods → Billed, PDF + email queued |
| Issued → Overdue | `OverdueSweepWorker`, daily | dunning eligibility begins |
| Issued/Overdue → PendingVerification | customer submits proof | admin queue entry |
| PendingVerification → Paid | admin verifies, `amountVerified ≥ total` | receipt emailed, AR reduced |
| PendingVerification → Issued/Overdue | admin rejects | customer emailed the reason; **status recomputed from due date** |
| any (not Paid) → Void | finance | setup fees stay `Billed` (AC-19.5) |

### 11.2 Subscription item setup fee

`Due → Billed` (on raise) · `Due → Waived` (finance, reason) · `Billed` is terminal (DB trigger).

### 11.3 Change request

`Pending → Approved` (creates item, effective today) · `Pending → Declined` (reason) · `Pending → Cancelled` (by the customer).

### 11.4 Usage / cloud / transaction period

`Open → Closed` (worker or manual, monthly) · `Closed → Billed` (on raise). Only `Closed` is billable. `Billed` is terminal.

---

## 12. Notifications

Templates live in the database so wording changes are not deploys.

| Event | To | Channel | Template key |
|---|---|---|---|
| Invoice raised | billing contacts | Email + portal | `invoice.raised` |
| Payment received (submitted) | billing contacts | Email | `payment.submitted` |
| Payment verified | billing contacts | Email | `payment.verified` |
| Payment rejected | billing contacts | Email | `payment.rejected` |
| Reminder 1 / 2 / final | billing contacts | Email | `dunning.step{n}` |
| Request received | ops queue | In-app badge | — |
| Request approved | requester + billing | Email + portal | `request.approved` |
| Request declined | requester | Email | `request.declined` |
| Setup fee queued | billing contacts | Portal banner only | `setup.queued` |
| Usage above 80% of allowance | technical contacts | Email | `usage.threshold` |
| Account suspended | primary contact | Email | `account.suspended` |

Rules: every email is sent through the outbox; a template renders with `{{invoiceNumber}}`, `{{total}}`, `{{dueDate}}`, `{{customerName}}`, `{{portalLink}}`; per-invoice dunning cooldown is 72 hours; nothing is sent for `Draft` invoices, ever.

---

## 13. Test matrix

### 13.1 Invariants — one test each, named after the rule

| Test | Asserts |
|---|---|
| `SetupFee_IsTrackedPerItem_NotPerCustomer` | Adding a second app to a customer with a prior raised invoice produces a setup line for the new item |
| `SetupFee_DraftDoesNotMarkBilled` | Save draft with setup lines → `setup_state` still `Due` |
| `SetupFee_RaiseMarksExactlyTheItemsOnTheInvoice` | Two due items, invoice carries one → only that one flips |
| `SetupFee_CannotBeBilledTwice` | Raise twice (second invoice) → second draft has no setup line for that item |
| `SetupFee_BilledIsTerminal` | Direct `UPDATE` back to `Due` → DB exception |
| `SetupFee_RemovedBeforeBilling_Disappears` | Add then remove → no setup line ever |
| `SetupFee_NeverOnCloudOnlyInvoice` | Type `Cloud` → zero `Setup` lines |
| `Invoice_IssuedIsImmutable` | Update lines/total on an issued invoice → DB exception |
| `Invoice_OnePerCustomerPeriodType` | Two `Combined` invoices for 2026-09 → unique violation |
| `Request_ApprovalStartsBillingToday` | Approve a request raised 10 days ago → `effective_from = today`, prorated line |
| `Request_DeclineChangesNothing` | Item count before == after |
| `Overage_NeverBilledForOpenPeriod` | Open period → warning, no line |
| `Cloud_MarkupSnapshotWins` | Change markup after close → closed period amount unchanged |
| `Portal_NeverSeesCost` | Serialize every portal DTO → assert no `providerCost`/`unitCost`/`margin` property |
| `Discount_StacksInDefinedOrder` | 10% line + 5% invoice on $1,000 → `855.00` |
| `Rounding_HalfAwayFromZero_PerStep` | `2.675 → 2.68`; step-wise rounding, not end rounding |

### 13.2 Concurrency (Testcontainers, real Postgres, no mocks)

| Test | Expectation |
|---|---|
| Two raises of the same draft in parallel | One 200, one 409; one invoice number consumed |
| Two approvals of the same request | One item created; other → `ITEM_ALREADY_SUBSCRIBED` |
| Two payments with the same tx hash | One accepted, one 409 |
| Billing run executed twice for a period | Second is a no-op; no duplicate drafts |
| Numbering under 50 parallel raises | 50 unique, gapless numbers |

### 13.3 Charge resolver — golden files

One JSON input snapshot and one expected `ChargeSet` per scenario, committed to the repo:

`combined-full`, `apps-only`, `api-only-customer`, `cloud-only`, `transactions-only`, `first-invoice-with-setup`, `mid-period-addition-prorated`, `mid-period-removal-credit`, `annual-renewal`, `trial-no-invoice`, `overage-per-call`, `overage-per-1000`, `zero-usage`, `discount-stacking`, `customer-rate-override`.

A change to any golden file requires a reviewer from finance. That is the point.

### 13.4 Architecture tests (CI, fail the build)

- No portal controller returns a type with a property named `ProviderCost`, `UnitCost`, `CostAmount`, `Margin`, `MarkupPct` or `InternalNote`.
- `Ledgerline.Domain` references no EF Core, no `DateTime.Now`, no `HttpContext`.
- Every enum persisted has a DB enum type; no `string` status columns.
- Every mutating controller action has `[Idempotent]`.
- Every money property is `decimal` with `[Precision(18,4)]`.

### 13.5 Security

- Portal token cannot read another customer's invoice (404, not 403).
- RLS is on for the portal connection string; a raw SQL query without a tenant filter returns zero rows.
- Impersonation is read-only unless explicitly elevated, and always audited.
- Wallet address change requires a second approver.

---

## 14. Delivery plan

| Phase | Weeks | Ships | Done when |
|---|---|---|---|
| **0 · Foundations** | 1–2 | Schema, migrations, RLS, auth, audit, outbox, seed | `dotnet test` green on an empty DB |
| **1 · Catalog + CRM** | 3–4 | S1–S4, S15, S16 | An account and contacts can be created; catalog is editable |
| **2 · Subscription + setup fee** | 5–6 | S5, S11, resolver, golden files | Setup-fee invariants pass end to end |
| **3 · First invoice** | 7–9 | S17, S18, S19, numbering, PDF | A real invoice can be drafted, edited, raised, printed |
| **4 · Payments + AR** | 10–11 | S20, S21, S22, dunning | Money in, reconciled, chased |
| **5 · Metering** | 12–13 | usage ingest, S13, S14, period close | Overage and pass-through bill correctly in arrears |
| **6 · Portal + requests** | 14–16 | S24–S28, S12 | A customer requests, we approve, it bills from that date |
| **7 · Onboarding wizard** | 17–18 | S9 | One transaction creates account → draft invoice |
| **8 · Hardening** | 19–20 | Load, security review, runbooks | Non-functional targets met |

**The first useful slice** is phases 0–3: onboard manually, subscribe apps, raise one invoice. About six weeks.

---

## 15. Failure modes — read before writing code

| # | Failure | Why it happens | Mitigation |
|---|---|---|---|
| 1 | **Setup fee billed once per customer instead of once per item** | A `setupCharged` boolean on the customer or subscription | Per-item state + DB trigger + six named tests |
| 2 | Setup fee billed twice | Draft save marks it billed; user re-drafts | Mark only in the raise transaction; `Billed` terminal |
| 3 | Invoice edited after sending | No status guard on the update path | DB trigger, not just a service check |
| 4 | Overage billed for an open period | Resolver reads live counters | Only `Closed` periods are billable; warnings surface the rest |
| 5 | Cloud markup changed retroactively | Invoice reads `cloud_account.markup_pct` live | Snapshot at period close; invoice reads the snapshot |
| 6 | Customer sees our cost | A shared DTO between console and portal | Separate DTOs + architecture test |
| 7 | Duplicate invoice for a month | Two operators, or a re-run of the billing job | Unique index on (customer, period, type) |
| 8 | Gaps or duplicates in invoice numbers | `MAX()+1` under concurrency | `number_sequence` with `FOR UPDATE` |
| 9 | Payment marked paid without evidence | Convenience shortcut in the UI | Reference/hash required by CHECK; verification is a separate role |
| 10 | Same chain transaction claimed twice | No uniqueness on hash | Partial unique index |
| 11 | Approval backdated, revenue lost | "Effective from the request date" seemed fair | Default today, cap backdating at 30 days, require a note |
| 12 | Rounding drift between preview and invoice | Two implementations of the same maths | One `Money.Round2`, one resolver, golden files |
| 13 | Email failure rolls back an invoice | Sending inline in the transaction | Outbox |
| 14 | Tax change rewrites history | Reading settings live at print time | `tax_pct` stored per invoice |

### Non-functional targets

| Metric | Target |
|---|---|
| Draft preview (50-line invoice) | p95 < 400 ms |
| Raise invoice | p95 < 800 ms |
| Dashboard | p95 < 600 ms (60s cache) |
| Monthly billing run, 500 customers | < 5 minutes |
| Usage ingest | 5,000 rows/s sustained |
| PDF generation | p95 < 2 s, async |

### Alert on these

- Any `Billed → Due` attempt (should be impossible; if it fires, something bypassed EF).
- Invoice-number gap detected by the nightly integrity job.
- A period still `Open` on the 5th of the following month.
- Payment in `Submitted` for more than 3 business days.
- Outbox message with `attempts > 5`.
- Any portal response containing a cost-shaped field (runtime canary, in addition to the build-time test).

---

## 16. Decisions needed before build

| # | Decision | Options | Recommendation |
|---|---|---|---|
| D1 | Annual term billing | Full amount up front vs monthly instalments | **Up front.** Simpler, matches the 15% discount rationale. Deferred-revenue schedules in v2. |
| D2 | Proration on removal | Credit line vs no refund | **Credit line** on the next invoice. Fair and auditable. |
| D3 | Who may approve requests | Any operator vs billing role only | **Billing operator or above.** It creates revenue. |
| D4 | Bank/wallet change control | Single admin vs two-person | **Two-person.** Highest-value fraud target. |
| D5 | Cloud markup disclosure | Always show % to customer vs contractual | **Per-account flag**, default hidden, contract decides |
| D6 | Backdating an approval | Forbidden vs capped | **Capped at 30 days with a note** |
| D7 | Invoice numbering | Global vs per-year | **Per-year** `INV-2026-0001`, resets each January |
| D8 | Void vs credit note | Void and re-raise vs credit note | **Void before payment, credit note after** |
| D9 | Trial conversion | Manual vs automatic on day 30 | **Manual**, with a follow-up task created at onboarding |
| D10 | Portal payment methods | Both vs bank only initially | **Both from day one.** Crypto is the differentiator for this customer base. |

---

## 17. Generation guide

Feed these to the coding assistant **in order**. Each step compiles and tests green before the next.

### Step 1 — Domain
> Create `Ledgerline.Domain` targeting net8.0 with no package references. Implement the entities in §5.2 as C# classes with private setters and behaviour methods: `SubscriptionItem.MarkSetupBilled(Guid invoiceId)`, `.Waive(string reason)`, `.ChangeTerm(BillingTerm, decimal listPrice)`, `.EndOn(DateOnly)`; `Invoice.AddLine(...)`, `.Recalculate()`, `.Raise(string number, DateOnly issue, DateOnly due)`, `.Void(string reason)`; `ChangeRequest.Approve(DateOnly effectiveFrom, decimal price)`, `.Decline(string reason)`. Implement `Money.Round2` using `MidpointRounding.AwayFromZero`. Throw `DomainException` with the codes from §8.4. No DB, no clock, no IO.

### Step 2 — Charge resolver
> Implement `ChargeResolver` per §6.2–§6.8 as a pure function over an input snapshot record. Cover: monthly and annual apps, API tier fees, per-item setup fees for items in `Due`, closed API overage (both bases), closed cloud periods with snapshot markup, closed transaction periods, proration on first and last period, invoice-type filtering. Return warnings for open periods. Add the 15 golden-file tests in §13.3.

### Step 3 — Persistence
> Create `LedgerlineDbContext` with EF Core 8 and Npgsql. Map every table from §5.2 including Postgres enums, partial unique indexes, CHECK constraints and the four triggers from §5.3 in a migration. Add RLS policies for the portal role. Configure `[Precision(18,4)]` on all money.

### Step 4 — Invoicing use cases
> Implement `DraftPreview`, `SaveDraftInvoice`, `RaiseInvoice`, `VoidInvoice` as MediatR handlers. `RaiseInvoice` must follow §9.3 exactly: guards, numbering via `FOR UPDATE`, bill-to snapshot, recalculate, `MarkSetupBilledAsync`, `MarkPeriodsBilledAsync`, transition, outbox, audit, one transaction. Write the concurrency tests from §13.2 against Testcontainers Postgres.

### Step 5 — Requests
> Implement `ApproveRequest` and `DeclineRequest`. Approval creates a `SubscriptionItem` with `effective_from` defaulting to today and `setup_state = Due`, writes a `customer_activity` row, and returns the prorated first-invoice estimate. Enforce AC-12.1 through AC-12.7.

### Step 6 — API surface
> Scaffold controllers for §8.2 with `ProblemDetails`, idempotency middleware, tenant resolution from the token, and **separate portal controllers with separate DTOs**. Add the architecture tests from §13.4.

### Step 7 — Workers
> Implement `CloseUsagePeriodsWorker`, `OverdueSweepWorker`, `DunningWorker`, `OutboxDispatcherWorker`, `AnnualRenewalWorker` with Hangfire. All idempotent, all safe to run twice.

### Step 8 — Shared React components
> Build `MoneyCell`/`MoneyInput` (decimal strings only), `StatusPill`, `SetupStatePill`, `CatalogItemRow` with a `mode` prop (`edit` | `select` | `request`), `UsageMeter`, `DataTable`. Generate types from the OpenAPI document.

### Step 9 — Invoice builder
> Build S17 per §7: header form, `EditableLineTable` with live per-line amounts, totals rail, setup-fee banner, save-draft and raise actions. All 14 acceptance criteria. Line state is local; the whole set posts on save.

### Step 10 — Remaining screens
> Build in this order: S1–S4 (CRM), S5 (subscription editor), S15/S16 (catalog), S18/S19 (invoices), S20/S21 (payments), S22 (AR), S12 (requests), S13/S14 (cloud, fees), S24–S28 (portal), S9 (wizard last — it composes the others).

### Prompt suffix — paste with every step

> Constraints: money is `decimal` with precision 18,4 in C# and a decimal **string** in JSON — never `double`, never `parseFloat`. The setup fee is per `subscription_item` and is marked `Billed` **only** inside the raise transaction. An invoice that is not `Draft` is immutable. Cost and margin fields must never appear in a portal DTO. Every mutating endpoint is idempotent. Rounding is half-away-from-zero at each step of §6.8, not once at the end. Write the test before the implementation where a rule in §13.1 applies.

---

## 18. Reference tables

Three small platform-owned tables that §8 exposes but §5 did not define. Seeded once per environment, identical for every customer, never edited by a customer.

```sql
-- Notification copy, so wording changes are not deploys.
CREATE TABLE notification_template (
    template_key  text NOT NULL,
    channel       text NOT NULL,             -- 'Email','InApp','Portal'
    language_code text NOT NULL DEFAULT 'en',
    subject       text NULL,
    body          text NOT NULL,             -- {{invoiceNumber}} {{total}} {{dueDate}} {{portalLink}}
    PRIMARY KEY (template_key, channel, language_code)
);

-- Named reasons, so "why was this discounted" is analysable rather than free text.
CREATE TABLE discount_reason (
    reason_code   text PRIMARY KEY,          -- 'MIGRATION','COMPETITIVE','GOODWILL','VOLUME','PILOT'
    display_name  text NOT NULL,
    max_pct       numeric(5,2) NOT NULL,     -- ceiling for this reason
    requires_role text NOT NULL,             -- 'BillingOperator' | 'FinanceAdmin'
    is_active     bool NOT NULL DEFAULT true
);

-- Discount authority by role. Read by the Raise guard and by S17 to disable the button.
CREATE TABLE discount_authority (
    role_name  text PRIMARY KEY,             -- 'AccountOwner','BillingOperator','FinanceAdmin'
    max_pct    numeric(5,2) NOT NULL
);
```

> `discount_authority` is a table rather than a constant because the ceiling is a commercial policy, and commercial policies change more often than code deploys.

---

## 19. API → table reference

`R` read · `W` write · `A` append-only.

### 19.1 CRM

| Endpoint | Reads | Writes |
|---|---|---|
| `GET /customers` | `customer`, `subscription_item`, `catalog_item`, `invoice`, `cloud_account`, `customer_activity` | — |
| `GET /customers/{id}` | as above + `cloud_cost_period`, `tx_usage_period`, `change_request` | — |
| `PATCH /customers/{id}` | `customer` | `customer` W, `audit_log` A |
| `POST /customers/onboard` | `catalog_item`, `tx_fee_rate`, `billing_settings` | `customer`, `subscription`, `subscription_item`, `customer_contact`, `cloud_account`, `cloud_cost_period`, `tx_usage_period`, `customer_activity`, `invoice`, `invoice_line`, `invoice_status_history` A, `audit_log` A — **one transaction** |
| `POST /customers/{id}/activities` | — | `customer_activity` W, `activity_attendee` W |

### 19.2 Subscription

| Endpoint | Reads | Writes |
|---|---|---|
| `GET /customers/{id}/subscription` | `subscription`, `subscription_item`, `catalog_item`, `api_usage_period` | — |
| `POST .../subscription/items` | `catalog_item`, `subscription` | `subscription_item` W (`setup_state='Due'`), `subscription_item_change` A, `api_usage_period` W, `audit_log` A |
| `PATCH /subscription/items/{id}` | `catalog_item` | `subscription_item` W, `subscription_item_change` A, `audit_log` A |
| `DELETE /subscription/items/{id}` | — | `subscription_item` W (`effective_to`), `subscription_item_change` A |
| `POST .../waive-setup` | — | `subscription_item` W, `audit_log` A |

### 19.3 Requests

| Endpoint | Reads | Writes |
|---|---|---|
| `POST /portal/requests` | `catalog_item`, `subscription_item` | `change_request` W, `customer_activity` W, `outbox_message` W |
| `POST /requests/{id}/approve` | `change_request`, `catalog_item`, `subscription` | `change_request` W, `subscription_item` W, `subscription_item_change` A, `api_usage_period` W, `customer_activity` W, `audit_log` A, `outbox_message` W |
| `POST /requests/{id}/decline` | `change_request` | `change_request` W, `customer_activity` W, `outbox_message` W |

### 19.4 Invoicing — the important rows

| Endpoint | Reads | Writes |
|---|---|---|
| `POST /invoices/draft-preview` | `subscription_item`, `catalog_item`, `api_usage_period`, `cloud_cost_period`, `tx_usage_period`, `customer`, `billing_settings` | **nothing** |
| `POST /invoices` (save draft) | as above | `invoice` W, `invoice_line` W, `invoice_status_history` A |
| `PUT /invoices/{id}` | `invoice` | `invoice` W, `invoice_line` W (replace set) — blocked unless `Draft` |
| `POST /invoices/{id}/raise` | `invoice`, `invoice_line`, `customer`, `customer_contact`, `billing_settings`, `discount_authority` | `invoice` W, `invoice_status_history` A, **`subscription_item` W (setup → Billed)**, `api_usage_period` W, `cloud_cost_period` W, `tx_usage_period` W, `number_sequence` W, `outbox_message` W, `audit_log` A |
| `POST /invoices/{id}/void` | `invoice` | `invoice` W, `invoice_status_history` A, `audit_log` A — setup fees **unchanged** |
| `POST /billing/runs` | resolver inputs for every active customer | `invoice` W×n, `invoice_line` W×n (drafts only) |

### 19.5 Payments and collections

| Endpoint | Reads | Writes |
|---|---|---|
| `POST /invoices/{id}/crypto-quote` | `crypto_wallet`, `invoice` | `crypto_quote` W |
| `POST /invoices/{id}/payments` | `invoice`, `crypto_quote`, `bank_account` | `payment` W, `invoice` W (→ PendingVerification), `invoice_status_history` A, `outbox_message` W |
| `POST /payments/{id}/verify` | `payment`, `invoice` | `payment` W, `invoice` W, `invoice_status_history` A, `audit_log` A, `outbox_message` W |
| `POST /payments/{id}/reject` | `payment`, `invoice` | `payment` W, `invoice` W (recomputed status), `outbox_message` W |
| `GET /receivables/ageing` | `invoice`, `payment`, `customer` | — |
| `POST /collections/dunning/run` | `invoice`, `customer_contact`, `notification_template` | `dunning_event` W, `outbox_message` W |

### 19.6 Workers

| Worker | Reads | Writes |
|---|---|---|
| `CloseUsagePeriodsWorker` | `api_usage_daily`, `catalog_item`, `cloud_account`, `tx_fee_rate`, `customer_tx_fee_rate` | `api_usage_period` W (snapshot + close), `cloud_cost_period` W, `tx_usage_period` W |
| `OverdueSweepWorker` | `invoice` | `invoice` W (Issued → Overdue), `invoice_status_history` A |
| `DunningWorker` | `invoice`, `dunning_event`, `billing_settings` | `dunning_event` W, `outbox_message` W |
| `AnnualRenewalWorker` | `subscription_item` | `subscription_item` W (`term_renews_on` roll), `outbox_message` W |
| `OutboxDispatcherWorker` | `outbox_message` | `outbox_message` W |

---

## 20. Seed data

### 20.1 Tier 1 — catalog (every environment, idempotent on `code`)

**Apps** — `group_name = 'Apps'`, `kind = 'App'`

| Code | Name | Setup | Monthly | Annual | Core |
|---|---|---|---|---|---|
| AP01 | Core & wallets | 6,000 | 1,800 | 18,360 | ✓ |
| AP02 | Cards | 6,000 | 2,400 | 24,480 | |
| AP03 | Banks | 4,500 | 1,900 | 19,380 | |
| AP04 | Payments | 3,500 | 1,600 | 16,320 | |
| AP05 | Exchange | 5,000 | 2,200 | 22,440 | |
| AP06 | Multi-tenancy | 8,000 | 3,000 | 30,600 | |
| AP07 | Treasury | 1,500 | 1,200 | 12,240 | |
| AP08 | MoneyOS | 1,000 | 900 | 9,180 | |
| AP09 | Payroll | 750 | 650 | 6,630 | |

**External APIs** — `group_name = 'External APIs'`, `kind = 'Api'`

| Code | Name | Setup | Monthly | Annual | Included | Overage | Basis |
|---|---|---|---|---|---|---|---|
| AI01 | Cards API | 2,000 | 1,500 | 15,300 | 250,000 | 2.10 | Per1000 |
| AI02 | Payments API | 2,000 | 1,800 | 18,360 | 250,000 | 1.60 | Per1000 |
| AI03 | Banks API | 1,500 | 2,800 | 28,560 | 25,000 | 0.28 | **PerCall** |
| AI04 | Exchange API | 1,500 | 2,000 | 20,400 | 150,000 | 4.20 | Per1000 |
| AI05 | Wallets API | 1,500 | 1,200 | 12,240 | 500,000 | 0.65 | Per1000 |

Annual = `round(monthly × 12 × 0.85)`. Banks API is deliberately `PerCall` so the per-call code path is always exercised by seed data.

### 20.2 Tier 1 — transaction fee rate card

| Code | Name | Vendor | Unit | Cost | Price |
|---|---|---|---|---|---|
| SUMSUB_KYC | Sumsub — KYC identity check | Sumsub | check | 1.10 | 1.60 |
| SUMSUB_AML | Sumsub — AML screening | Sumsub | screening | 0.35 | 0.60 |
| TXN_PROCESS | Transaction processing fee | Ledgerline | transaction | 0.04 | 0.09 |
| CARD_ISSUE | Card issuance fee | Scheme | card | 1.80 | 3.00 |
| PAYOUT_FEE | Payout fee | Ledgerline | payout | 0.12 | 0.25 |

### 20.3 Tier 1 — discount authority and reasons

| Role | Max % |
|---|---|
| AccountOwner | 10 |
| BillingOperator | 25 |
| FinanceAdmin | 60 |

Reasons: `MIGRATION` (25, BillingOperator) · `COMPETITIVE` (25, BillingOperator) · `VOLUME` (20, BillingOperator) · `PILOT` (100, FinanceAdmin) · `GOODWILL` (15, BillingOperator).

### 20.4 Tier 2 — company settings, banks, wallets

Company: *Ledgerline Technologies FZ-LLC*, Unit 704 Innovation Hub, Dubai Internet City, TRN 100482930500003. Tax `VAT` 5%. Terms 14 days. Late fee 1.5%/month. Dunning `{3,7,14}`. Suspend after 30.

Banks: Emirates NBD (primary USD, SWIFT `EBILAEAD`) · JPMorgan Chase NY (USD correspondent, SWIFT `CHASUS33`).
Wallets: USDT/Ethereum (12 conf) · USDT/Tron (19) · USDC/Polygon (60) · BTC/Bitcoin (3).

### 20.5 Tier 3 — dev and QA seed (guard against production)

Five customers, chosen to exercise the edge cases rather than to look realistic.

| Customer | Model | Why it exists |
|---|---|---|
| **GlobalBridge** (C-1001) | Apps + APIs, 10% discount, AWS managed, monthly | The full combined invoice: subscription + overage on two bases + cloud + three transaction fees |
| **Paybitz** (C-1002) | Apps only, annual terms, Azure managed | Annual billing, and a customer who wants **cloud on a separate invoice** |
| **FastXe** (C-1003) | API only, 5% discount, GCP managed | No apps at all — proves the API-only path and an overdue invoice for AR |
| **Paybase** (C-1004) | Apps + APIs, AWS managed, mixed terms | A **mid-period addition** so proration and a fresh setup fee are always in the fixture |
| **Rapidz** (C-1005) | Pilot, self-managed cloud | Trial state: no invoice, setup deferred, follow-up task due |

```csharp
public static class DevScenarios
{
    // GlobalBridge Sept 2026 → Combined invoice with every line category present
    public const string FullCombined = "GlobalBridge combined";

    // Add Cards to GlobalBridge AFTER their August invoice was raised.
    // The September draft MUST contain "Cards — one-time setup fee $6,000".
    // This is the regression fixture for the customer-level-flag bug.
    public const string SetupAfterFirstInvoice = "Add Cards post-invoice";

    // FastXe INV-2026-0006, due 2026-08-15 → 26 days overdue, bucket 1–30
    public const string Overdue = "FastXe overdue";

    // Paybitz cloud billed on its own invoice, apps on another, same period
    public const string SplitInvoices = "Paybitz split";

    // Rapidz: pilot, zero invoices, one open follow-up
    public const string TrialNoInvoice = "Rapidz pilot";

    // Approve a pending request dated 3 days ago → item effective TODAY, prorated
    public const string ApprovalStartsToday = "Approve GlobalBridge Exchange API";
}
```

### 20.6 Verification queries — run after seeding, in every environment

```sql
-- 1. No lifetime term survived anywhere. Must return 0.
SELECT count(*) FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'billing_term' AND e.enumlabel ILIKE '%life%';

-- 2. Exactly two catalog groups. Must return 2, named Apps and External APIs.
SELECT DISTINCT group_name FROM catalog_item ORDER BY 1;

-- 3. Every API item has complete metering. Must return 0.
SELECT count(*) FROM catalog_item
WHERE kind = 'Api' AND (included_calls IS NULL OR overage_rate IS NULL OR overage_basis IS NULL);

-- 4. No setup fee is marked Billed without an invoice. Must return 0.
SELECT count(*) FROM subscription_item
WHERE setup_state = 'Billed' AND setup_billed_on_invoice_id IS NULL;

-- 5. No setup fee billed twice (same item on two invoices). Must return 0.
SELECT count(*) FROM (
  SELECT source_id FROM invoice_line l JOIN invoice i USING (invoice_id)
  WHERE l.source_kind = 'SetupFee' AND i.status <> 'Void'
  GROUP BY source_id HAVING count(*) > 1) d;

-- 6. No issued invoice without a number, date or due date. Must return 0.
SELECT count(*) FROM invoice
WHERE status <> 'Draft' AND (invoice_number IS NULL OR issue_date IS NULL OR due_date IS NULL);

-- 7. Stored totals match stored lines on every issued invoice. Must return 0.
SELECT count(*) FROM invoice i
WHERE i.status <> 'Draft'
  AND round(i.subtotal, 2) <> (SELECT round(coalesce(sum(amount), 0), 2)
                               FROM invoice_line WHERE invoice_id = i.invoice_id);

-- 8. No invoice line references an Open period. Must return 0.
SELECT count(*) FROM invoice_line l JOIN invoice i USING (invoice_id)
JOIN cloud_cost_period c ON c.cloud_cost_id = l.source_id
WHERE l.source_kind = 'CloudPeriod' AND i.status <> 'Draft' AND c.state = 'Open';

-- 9. Invoice numbers are gapless per year. Must return 0 gaps.
WITH n AS (SELECT substring(invoice_number from '\d{4}$')::int AS seq,
                  substring(invoice_number from 'INV-(\d{4})')::int AS yr
           FROM invoice WHERE invoice_number IS NOT NULL)
SELECT count(*) FROM (
  SELECT yr, seq, lag(seq) OVER (PARTITION BY yr ORDER BY seq) AS prev FROM n) g
WHERE prev IS NOT NULL AND seq <> prev + 1;

-- 10. Every customer with an issued invoice has a billing contact. Must return 0.
SELECT count(DISTINCT i.customer_id) FROM invoice i
WHERE i.status <> 'Draft' AND NOT EXISTS (
  SELECT 1 FROM customer_contact c
  WHERE c.customer_id = i.customer_id AND c.is_billing AND NOT c.is_archived);
```

> Queries **4, 5 and 9** should also be automated tests, not just a checklist. Query 5 is the one that keeps "a setup fee is charged exactly once, per item" true after a future refactor.

---

## Appendix A — Prototype to production map

The prototype is a single HTML file with in-memory state. Every symbol in it has a production equivalent.

| Prototype symbol | Production equivalent |
|---|---|
| `catalog[]` (one array, `kind: 'app' \| 'api'`) | `catalog_item` table, two groups |
| `item(id)`, `apps()`, `apis()` | `ICatalogRepository` |
| `subs[].items[]` | `subscription_item` table |
| `hasItem(cid, iid)` | `ISubscriptionRepository.GetActiveItemAsync` |
| `setupDue(cid)`, `setupDueTotal(cid)` | `SubscriptionItem` query `WHERE setup_state = 'Due' AND effective_to IS NULL` |
| `markSetupBilled(cid, lines)` | `SetupFeeService.MarkSetupBilledAsync` — §6.4, matched on `source_id` |
| `priceSub(cid)` | `ChargeResolver` (recurring portion) |
| `buildLines(cid, type)` | `ChargeResolver.ResolveAsync` + `InvoiceDraftBuilder` |
| `lineAmt(l)` | `Money.LineAmount` |
| `invTotals(inv)` | `Money.Totals` — §6.9 |
| `commitInvoice(status)` | `SaveDraftInvoice` / `RaiseInvoice` handlers |
| `raiseInvoice()`, `raiseExisting(id)` | `POST /invoices/{id}/raise` |
| `decideRequest(rid, ok)` | `ApproveRequest` / `DeclineRequest` handlers |
| `cloudBilled(c)` | `rebill_amount` computed at period close with a markup snapshot |
| `usageOf`, `perCall` | `api_usage_period` with `overage_basis` snapshot |
| `submitPayment(id)` | `POST /invoices/{id}/payments` |
| `markPaid(id)`, `rejectPayment(id)` | `POST /payments/{id}/verify` / `/reject` |
| `ageOf(inv)`, `bucketOf(inv)` | `GET /receivables/ageing` |
| `createCustomer()` | `POST /customers/onboard` — one transaction |
| `activities[]` | `customer_activity` table |
| Persona switcher | Demo device only — **not shipped**. Production uses separate tokens and audited impersonation. |
| In-memory `seq` counters | `number_sequence` with `FOR UPDATE` |

## Appendix B — Reading order for a new engineer

1. Open `ledgerline-billing-prototype.html`. Onboard a customer, add an app to an existing customer, raise their invoice, pay it, verify it. Twice.
2. This document: §6 (the billing engine) and §11 (state machines).
3. §6.4 again. The setup-fee lifecycle is the thing this system exists to get right.
4. §9 (backend) or §10 (frontend), depending on your role.
5. §15 (failure modes) before writing any code.
6. §13.1 — write the test before the feature for anything listed there.
