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
Written to be handed to three audiences at once, and to be fed directly to an AI coding assistant.
| You are… | Read |
|---|---|
| Executive / product | §1–§4, §7 screen purposes, §16 decisions, §14 delivery plan |
| Backend engineer | §5 + §18 (tables), §6 (billing engine), §8 + §19 (API ↔ tables), §9 (.NET structure), §20 (seed) |
| Frontend engineer | §7 (screens: fields, actions, components), §8 (API), §10 (React structure) |
| QA | §7 acceptance criteria, §11 state machines, §13 test matrix, §20 seed data |
| AI assistant | §17 — generation prompts, in order. §18–§20 for tables and seed. |
Conventions used throughout
Customer = the account (a fintech company that buys from us). Never called "client" in code.CustomerContact = a person at that account.CatalogItem = one sellable thing in the marketplace. Two categories only: App and API. There is no third category.SubscriptionItem = one CatalogItem attached to one Customer at an agreed price and term.Monthly or Annual. There is no lifetime term. Enforced by a DB CHECK constraint.NUMERIC(18,4), currency is USD everywhere, rounding to 2dp happens only at line and total level.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
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.
| 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 |
currency_code columns exist and are constrained to 'USD').These are the failure modes that cost money or trust. Each has a test named after it in §13.
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 |
| 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.
uuid (gen_random_uuid()).created_at_utc timestamptz not null default now(), created_by uuid null.updated_at_utc, updated_by, row_version bytea (EF concurrency token).is_archived bool); everything else is hard-deleted or never deleted.customer_id and is indexed on it. Row-level security policy customer_isolation is enabled on portal-facing connections.-- ═══════════════════════════════════════════════════════════
-- 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;
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.
HasPostgresEnum<T>() in OnModelCreating, not as strings.row_version → .IsRowVersion() on aggregates: Customer, Invoice, SubscriptionItem, ChangeRequest, Payment, CatalogItem, CloudAccount.Invoice owns InvoiceLine (OwnsMany is wrong here — use a real entity with cascade delete, because lines are queried directly for the AR report)..HasPrecision(18, 4). Never double. Never decimal without precision..HasQueryFilter(x => x.CustomerId == _ctx.CustomerId) on every customer-scoped entity, plus Postgres RLS as a second line of defence..Include() CloudCostPeriod.ProviderCost in a portal-facing projection. Use explicit Select into portal DTOs.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.
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_keyis the issue period; each line carries its ownperiod_from/period_to.
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:
Monthly term items, and only in the period where effective_from or effective_to falls inside the period.term_renews_on, and prorated only when the term starts mid-year.is_prorated = true and print its window: "Cards (monthly) — 12 Sept to 30 Sept".days_in_period uses the actual calendar month. No 30/360.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);
}
overage_units = basis == Per1000
? max(0, call_count - included_calls) / 1000.0
: max(0, call_count - included_calls)
overage_amount = round(overage_units * overage_rate, 2)
included_calls, overage_rate and overage_basis are snapshotted onto api_usage_period when the period closes. A catalog price change in October must not alter what September's overage cost.CloseUsagePeriodsWorker. Only Closed periods are billable.term/tier changes; simpler and auditable.rebill_amount = round(provider_cost * (1 + markup_pct / 100), 2)
markup_pct_snapshot is written to cloud_cost_period at import time. The invoice reads the snapshot, never cloud_account.markup_pct.provider_cost is never returned by any portal endpoint. The portal sees only rebill_amount.evidence_url is set, the invoice PDF gets a "provider statement attached" note.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.
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
discount_pct > 0 requires discount_reason. Enforced by CHECK.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.
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 |
What it is. The customer master list and the entry point to everything else. Four KPI tiles, a search/stage filter, and a table where every row summarises the commercial state of one account.
Fields (filter bar)
| Field | Control | Source / default | Validation | Notes |
|---|---|---|---|---|
| Search | text | empty | max 100 chars | Matches display_name, legal_name, country_code, owner name — server-side ILIKE |
| Stage | select | All |
enum | All, Lead, Discovery, Pilot, Live, Churned |
| Owner | select | All |
uuid | Populated from staff directory |
| Has open balance | toggle | off | — | Filters to customers with an open invoice |
Columns: Account (name + country + legal name) · Stage pill · Owner · Apps count · APIs count · Cloud providers · Monthly recurring · Open balance · Last activity · Open button.
Actions
| Action | Trigger | API | Tables | Result |
|---|---|---|---|---|
| Apply filters | Enter / Apply | GET /customers?q=&stage=&owner=&hasBalance= |
customer R, subscription_item R, invoice R, customer_activity R |
Table re-renders; filter state in URL query |
| Open account | row click / Open | route | — | → S2 |
| Onboard a customer | header button | route | — | → S9 |
| Export CSV | header menu | GET /customers/export |
as above | Streams CSV, logs to audit_log |
Acceptance criteria
| ID | Requirement |
|---|---|
| AC-1.1 | MRR is the normalised monthly figure: monthly items at face value, annual items at price / 12. The column header says so. |
| AC-1.2 | Open balance excludes Draft and Void invoices. |
| AC-1.3 | An account with cloud_managed = false shows "Self-managed", not an empty cell. |
| AC-1.4 | Filter state survives a browser refresh (read from query string). |
| AC-1.5 | List is paginated at 50, server-side. Sorting on MRR and open balance is server-side. |
| AC-1.6 | Stage counts in the KPI tiles match the filtered set, not the global set, when a filter is active. |
API
GET /api/v1/customers?q=&stage=&owner=&hasBalance=&page=&size=&sort=
→ { items:[{ customerId, code, displayName, legalName, countryCode, stage, status,
ownerName, appCount, apiCount, cloudProviders:[], mrr, openBalance,
lastActivity:{type,date} }], total, page, size }
Components AccountsPage → KpiTile × 4, AccountFilterBar, AccountTable, StagePill, MoneyCell
Tables customer R · subscription_item R · catalog_item R · invoice R · customer_activity R · cloud_account R
What it is. The commercial summary of one customer: four KPI tiles, the account facts panel, and a "next steps" panel that surfaces open follow-ups, pending requests and — critically — unbilled setup fees.
Fields (Account panel, read-only here; edited in the modal)
Registered entity · Country · Website · Stage · Account owner · Source · Tax registration · Customer since · Billing cycle · Standard discount % · Cloud (managed/self).
KPI tiles
| Tile | Computation |
|---|---|
| Monthly recurring | Σ monthly items + Σ annual items / 12 |
| Cloud, rebilled | Σ cloud_cost_period.rebill_amount for the current open period |
| Transaction fees | Σ tx_usage_period.bill_amount for the current open period |
| Setup fees unbilled | Σ subscription_item.setup_fee WHERE setup_state = 'Due' AND effective_to IS NULL |
Actions
| Action | Trigger | API | Tables | Result |
|---|---|---|---|---|
| Edit details | button → modal | PATCH /customers/{id} |
customer W, audit_log A |
Optimistic concurrency on row_version; 409 on conflict |
| Log activity | button → modal | POST /customers/{id}/activities |
customer_activity W |
Appears immediately on S4 timeline |
| Complete follow-up | checkbox | POST /activities/{id}/complete |
customer_activity W |
Row strikes through, tile count drops |
| Raise an invoice | button | route with ?customer= |
— | → S17 with builder pre-filled |
| Open their portal | button | POST /support/impersonate |
audit_log A |
Opens portal in impersonation mode, banner shown, read-only by default |
| Suspend / reinstate | menu | POST /customers/{id}/suspend |
customer W, subscription W, audit_log A |
Suspension disables API keys via outbox event |
Acceptance criteria
| ID | Requirement |
|---|---|
| AC-2.1 | The unbilled-setup tile lists the items by name in its subtitle when count ≤ 3, otherwise "n items". |
| AC-2.2 | A warning note appears in Next steps whenever setupDue > 0, with a link that opens S17 pre-filled for that customer. |
| AC-2.3 | Pending requests for this customer appear in Next steps with a link to S12. |
| AC-2.4 | Editing the account never rewrites bill_to_* on invoices already issued. |
| AC-2.5 | Impersonation writes an audit_log row with action='Impersonate' before the portal loads. |
| AC-2.6 | The "next invoice, estimated" figure equals what S17 will draft, to the cent. Shared resolver (§6.1). |
API
GET /api/v1/customers/{id} → account + KPIs + setupDue breakdown
PATCH /api/v1/customers/{id} { displayName, legalName, ... , rowVersion }
GET /api/v1/customers/{id}/charges/preview?period=2026-09
POST /api/v1/customers/{id}/activities
POST /api/v1/activities/{activityId}/complete
POST /api/v1/customers/{id}/suspend { reason }
Components AccountOverviewTab → KpiTile, AccountFactsPanel, NextStepsPanel, SetupFeeWarning ★, EditAccountModal, LogActivityModal
Tables customer RW · subscription_item R · cloud_cost_period R · tx_usage_period R · invoice R · change_request R · customer_activity RW · audit_log A
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 |
| 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
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
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
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
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
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
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.
| 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 |
Name · Role · Email for invoices (required) · Phone · Opening CRM note (free text, becomes the first customer_activity).
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.
| 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 |
Read-only summary of all four steps plus the running total: Apps monthly · APIs monthly · Annual terms · Cloud rebilled · Transaction fees · Discount · Per month · One-time setup · First invoice.
Actions
| Action | API | Tables | Result |
|---|---|---|---|
| Continue | client validation | — | Blocks with a specific message per step (AC-9.2) |
| Back | — | — | Never loses entered data |
| Discard | — | — | Confirm dialog; nothing persisted |
| Create account | POST /customers/onboard |
customer W, subscription W, subscription_item W×n, customer_contact W, cloud_account W, cloud_cost_period W, tx_usage_period W×n, customer_activity W, invoice W (Draft), invoice_line W×n, audit_log A |
One transaction. Then redirects to S17 with the draft open |
Acceptance criteria
| ID | Requirement |
|---|---|
| AC-9.1 | The whole creation is one database transaction. A failure at invoice draft stage leaves no orphan customer. |
| AC-9.2 | Step validation: step 1 needs a name; step 2 needs an invoice email; step 3 needs ≥ 1 item. Each blocks with its own message. |
| AC-9.3 | Every created subscription_item has setup_state = 'Due', so the first invoice carries one setup line per item, named for that item. |
| AC-9.4 | Pilot mode creates the account with status = Trial, raises no invoice, and creates a follow-up activity "Pilot review call" dated +30 days. |
| AC-9.5 | Non-pilot creation lands the user in S17 with the draft already built — never on a blank invoice screen. |
| AC-9.6 | The running total rail matches the drafted invoice to the cent. |
| AC-9.7 | A discount above the user's authority blocks creation with an approval prompt, it does not silently reduce the discount. |
API
POST /api/v1/customers/onboard
{
company:{ displayName, legalName, countryCode, website, ownerUserId, source, stage,
taxRegistration, standardDiscountPct, billingCycle, startDate, isPilot },
contact:{ fullName, jobTitle, email, phone, openingNote },
items:[ { catalogItemId, term, unitPrice? } ],
cloud:{ managed, provider, accountRef, region, expectedMonthlyCost, markupPct } | null,
txFees:[ { txFeeRateId, expectedQuantity } ]
}
→ 201 { customerId, code, subscriptionId, draftInvoiceId | null }
Components OnboardWizard → StepBar, CompanyStep, ContactStep, CatalogStep (reuses CatalogItemRow), CloudAndFeesStep, ReviewStep, RunningTotalRail ★
Tables as listed in the Create action above.
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.
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
What it is. The approval queue. A customer asked for an app or API from their portal; approving it adds the item to their subscription dated today and queues the setup fee.
Columns Request code · Account + requester · Item + category pill · Term · Asked on · Why (justification) · Price · Setup fee · Status · Approve/Decline.
Actions
| Action | Trigger | API | Tables | Result |
|---|---|---|---|---|
| Approve | button | POST /requests/{id}/approve {effectiveFrom?, unitPrice?, note?} |
change_request W, subscription_item W, subscription_item_change A, api_usage_period W (API), customer_activity W, audit_log A, outbox_message W |
Item live from effectiveFrom (default today), setup_state='Due' |
| Decline | button | POST /requests/{id}/decline {reason} |
change_request W, customer_activity W, outbox_message W |
Subscription untouched; customer emailed the reason |
| Amend price | inline | part of approve payload | as above | Approving at a negotiated price, not the quoted one, requires a note |
| Filter | tabs | GET /requests?status= |
change_request R |
Pending / Approved / Declined / All |
Acceptance criteria
| ID | Requirement |
|---|---|
| AC-12.1 | Approval creates the subscription item with effective_from = today by default. Never the request date. Backdating is possible but requires an explicit date and a note, and is capped at 30 days. |
| AC-12.2 | The created item has setup_state = 'Due'; the confirmation toast names the fee. |
| AC-12.3 | Declining changes nothing on the subscription. Test asserts item count unchanged. |
| AC-12.4 | Both outcomes write a customer_activity row so the account timeline tells the story. |
| AC-12.5 | Approving a request for an item the customer already holds is refused (409) with a clear message. |
| AC-12.6 | The sidebar badge count equals Pending and updates without a full page reload after a decision. |
| AC-12.7 | If the catalog price changed between request and approval, the queue shows both the quoted price and the current price, and asks which to apply. |
API
GET /api/v1/requests?status=Pending&page=&size=
POST /api/v1/requests/{id}/approve { effectiveFrom?, unitPrice?, note? } → 200 { subscriptionItemId }
POST /api/v1/requests/{id}/decline { reason } → 200
Components RequestQueue → RequestRow, ApproveDialog ★ (shows quoted vs current price, effective date, setup fee), DeclineDialog
Tables change_request RW · subscription_item W · subscription_item_change A · api_usage_period W · customer_activity W · catalog_item R · audit_log A · outbox_message W
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
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
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
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
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.
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
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
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
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
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
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
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
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 |
| 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
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
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
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)
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
/api/v1. Bearer token required on every route.customer_id; console tokens carry staff roles.Idempotency-Key header. Replays return the original response."1234.56"), never a float."2026-09".If-Match with the base64 rowVersion on PATCH/PUT of aggregates. Mismatch → 409.?page=1&size=50, response { items, total, page, size }.# ── 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}
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}/raise → 200
{ "invoiceId":"…", "invoiceNumber":"INV-2026-0007", "status":"Issued",
"issueDate":"2026-09-10", "dueDate":"2026-09-24",
"total":"25095.00",
"setupFeesMarkedBilled":[ { "subscriptionItemId":"…", "itemName":"Cards", "amount":"6000.00" } ],
"periodsMarkedBilled":{ "api":2, "cloud":1, "transactions":3 } }
POST /requests/{id}/approve → 200
{ "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" } }
{ "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 |
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)
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/
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
MarkSetupBilledAsync matches on source_id, never on description text.Recalculate() runs after all edits and before the status change, so stored totals always match the stored lines.409 CONCURRENCY_CONFLICT from the row_version token.ux_subitem_active rejects the duplicate subscription item; catch UniqueViolation and map to ITEM_ALREADY_SUBSCRIBED.SELECT next_value FROM number_sequence WHERE sequence_key = $1 FOR UPDATE. Never MAX(invoice_number) + 1.ux_payment_txhash rejects the second.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
// 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.
| 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. |
MoneyInput parses on blur, stores a decimal string. Never parseFloat.staleTime: 0 for anything billing-related. A cached invoice total is a wrong invoice total.invoices, receivables, dashboard, customer:{id}.confirm().$. 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) |
Due → Billed (on raise) · Due → Waived (finance, reason) · Billed is terminal (DB trigger).
Pending → Approved (creates item, effective today) · Pending → Declined (reason) · Pending → Cancelled (by the customer).
Open → Closed (worker or manual, monthly) · Closed → Billed (on raise). Only Closed is billable. Billed is terminal.
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 | payment.submitted |
|
| Payment verified | billing contacts | payment.verified |
|
| Payment rejected | billing contacts | payment.rejected |
|
| Reminder 1 / 2 / final | billing contacts | dunning.step{n} |
|
| Request received | ops queue | In-app badge | — |
| Request approved | requester + billing | Email + portal | request.approved |
| Request declined | requester | request.declined |
|
| Setup fee queued | billing contacts | Portal banner only | setup.queued |
| Usage above 80% of allowance | technical contacts | usage.threshold |
|
| Account suspended | primary contact | 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.
| 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 |
| 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 |
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.
ProviderCost, UnitCost, CostAmount, Margin, MarkupPct or InternalNote.Ledgerline.Domain references no EF Core, no DateTime.Now, no HttpContext.string status columns.[Idempotent].decimal with [Precision(18,4)].| 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.
| # | 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 |
| 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 |
Billed → Due attempt (should be impossible; if it fires, something bypassed EF).Open on the 5th of the following month.Submitted for more than 3 business days.attempts > 5.| # | 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. |
Feed these to the coding assistant in order. Each step compiles and tests green before the next.
Create
Ledgerline.Domaintargeting 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). ImplementMoney.Round2usingMidpointRounding.AwayFromZero. ThrowDomainExceptionwith the codes from §8.4. No DB, no clock, no IO.
Implement
ChargeResolverper §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 inDue, 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.
Create
LedgerlineDbContextwith 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.
Implement
DraftPreview,SaveDraftInvoice,RaiseInvoice,VoidInvoiceas MediatR handlers.RaiseInvoicemust follow §9.3 exactly: guards, numbering viaFOR UPDATE, bill-to snapshot, recalculate,MarkSetupBilledAsync,MarkPeriodsBilledAsync, transition, outbox, audit, one transaction. Write the concurrency tests from §13.2 against Testcontainers Postgres.
Implement
ApproveRequestandDeclineRequest. Approval creates aSubscriptionItemwitheffective_fromdefaulting to today andsetup_state = Due, writes acustomer_activityrow, and returns the prorated first-invoice estimate. Enforce AC-12.1 through AC-12.7.
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.
Implement
CloseUsagePeriodsWorker,OverdueSweepWorker,DunningWorker,OutboxDispatcherWorker,AnnualRenewalWorkerwith Hangfire. All idempotent, all safe to run twice.
Build
MoneyCell/MoneyInput(decimal strings only),StatusPill,SetupStatePill,CatalogItemRowwith amodeprop (edit|select|request),UsageMeter,DataTable. Generate types from the OpenAPI document.
Build S17 per §7: header form,
EditableLineTablewith 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.
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).
Constraints: money is
decimalwith precision 18,4 in C# and a decimal string in JSON — neverdouble, neverparseFloat. The setup fee is persubscription_itemand is markedBilledonly inside the raise transaction. An invoice that is notDraftis 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.
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_authorityis a table rather than a constant because the ceiling is a commercial policy, and commercial policies change more often than code deploys.
R read · W write · A append-only.
| 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 |
| 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 |
| 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 |
| 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) |
| 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 |
| 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 |
code)Apps — group_name = 'Apps', kind = 'App'
| Code | Name | Setup | Monthly | Annual | Core |
|---|---|---|---|---|---|
| AP01 | Core & wallets | 6,000 | 1,800 | 18,360 | ✓ |
| AP02 | Cards | 6,000 | 2,400 | 24,480 | |
| AP03 | Banks | 4,500 | 1,900 | 19,380 | |
| AP04 | Payments | 3,500 | 1,600 | 16,320 | |
| AP05 | Exchange | 5,000 | 2,200 | 22,440 | |
| AP06 | Multi-tenancy | 8,000 | 3,000 | 30,600 | |
| AP07 | Treasury | 1,500 | 1,200 | 12,240 | |
| AP08 | MoneyOS | 1,000 | 900 | 9,180 | |
| AP09 | Payroll | 750 | 650 | 6,630 |
External APIs — group_name = 'External APIs', kind = 'Api'
| Code | Name | Setup | Monthly | Annual | Included | Overage | Basis |
|---|---|---|---|---|---|---|---|
| AI01 | Cards API | 2,000 | 1,500 | 15,300 | 250,000 | 2.10 | Per1000 |
| AI02 | Payments API | 2,000 | 1,800 | 18,360 | 250,000 | 1.60 | Per1000 |
| AI03 | Banks API | 1,500 | 2,800 | 28,560 | 25,000 | 0.28 | PerCall |
| AI04 | Exchange API | 1,500 | 2,000 | 20,400 | 150,000 | 4.20 | Per1000 |
| AI05 | Wallets API | 1,500 | 1,200 | 12,240 | 500,000 | 0.65 | Per1000 |
Annual = round(monthly × 12 × 0.85). Banks API is deliberately PerCall so the per-call code path is always exercised by seed data.
| 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 |
| Role | Max % |
|---|---|
| AccountOwner | 10 |
| BillingOperator | 25 |
| FinanceAdmin | 60 |
Reasons: MIGRATION (25, BillingOperator) · COMPETITIVE (25, BillingOperator) · VOLUME (20, BillingOperator) · PILOT (100, FinanceAdmin) · GOODWILL (15, BillingOperator).
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).
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";
}
-- 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.
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 |
ledgerline-billing-prototype.html. Onboard a customer, add an app to an existing customer, raise their invoice, pay it, verify it. Twice.