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


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)

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.


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

5.2 New tables

-- ═══════════════════════════════════════════════════════════
-- 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.

-- 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


6. The billing engine

6.1 One resolver, four call sites

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

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
// 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)

6.6 Cloud rebilling

rebill_amount = round(provider_cost * (1 + markup_pct / 100), 2)

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

6.9 Reference implementation, ported from the prototype

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 AccountsPageKpiTile × 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 AccountOverviewTabKpiTile, 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 OnboardWizardStepBar, 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 RequestQueueRequestRow, 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
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
Customer override modal PUT /customers/{id}/tx-fee-rates/{rateId} customer_tx_fee_rate W
Set volume inline number PUT /tx-usage/{customerId}/{rateId}/{period} tx_usage_period W
Import volumes CSV POST /tx-usage/import tx_usage_period W×n
Close period button POST /tx-usage/{period}/close tx_usage_period W×n

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
Unlist toggle PATCH /catalog-items/{id} {isListed:false} catalog_item W
Retire menu PATCH /catalog-items/{id} {isSellable:false} catalog_item W

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

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
Add/edit wallet `POST PATCH /settings/wallets` crypto_wallet W, audit_log A
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 "; 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 ".
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 " 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

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

// 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}/raise200

{ "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}/approve200

{ "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

{ "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

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


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

// 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


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)

13.5 Security


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


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.

-- 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)

Appsgroup_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 APIsgroup_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
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

-- 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.