DATA_MODEL — billing-service
Conforms to 06 Data Models, ADR-0002 Multi-Tenancy, standards/NAMING.md. Storage is Cloud SQL Postgres 16 with two physical schema patterns:
- Per-tenant schema
tenant_<uuid>_billingfor all guest folio data (folios, charges, payments, refunds, invoices, credit notes, settlements, cash drawers, sessions, daily reconciliations, outbox).- Central schema
billing_centralfor platform subscription billing (subscriptions, subscription invoices, usage records, plans, dunning history, central outbox).Per-tenant schemas are created by the
tenant-provisioning-job(Cloud Run Job) onmelmastoon.tenant.created.v1from a templated DDL bundle. PgBouncer setssearch_pathper connection based on theX-Tenant-Idheader propagated through the request context. RLS is also enabled inside each per-tenant schema as defense-in-depth (mistakes that leak a connection across tenants still fail at the row level).
1. ID prefixes (additions to NAMING.md)
| Prefix | Aggregate / table |
|---|---|
fol_ | folios |
chg_ | folio_charges |
fpm_ | folio_payments |
frd_ | folio_refunds |
inv_doc_ | invoices (existing) |
ln_ | invoice_lines |
cnt_ | credit_notes |
cnl_ | credit_note_lines |
set_ | settlements |
cdr_ | cash_drawers |
cds_ | cash_drawer_sessions |
dcr_ | daily_cash_reconciliations |
sub_ | subscriptions |
sin_ | subscription_invoices |
sil_ | subscription_invoice_lines |
usg_ | usage_records |
pln_ | plans |
All ULIDs; bigint money columns are suffixed _micro per NAMING §6.
2. Per-tenant schema template (tenant_<uuid>_billing)
Provisioned by db/tenants/billing.tmpl.sql substituting :tenant_id. Migrations bumped via the per-tenant migration job; every tenant runs the same forward DDL.
2.1 folios
CREATE TABLE folios (
id text PRIMARY KEY CHECK (id LIKE 'fol_%'),
tenant_id text NOT NULL CHECK (tenant_id LIKE 't_%'),
property_id text NOT NULL CHECK (property_id LIKE 'prop_%'),
reservation_id text NOT NULL CHECK (reservation_id LIKE 'res_%'),
currency char(3) NOT NULL,
fx_snapshot jsonb NOT NULL, -- { baseCurrency, rates, takenAt, source }
status text NOT NULL CHECK (status IN ('pending','open','balance_due','settled','closed','re_opened')),
opened_at timestamptz NOT NULL,
closed_at timestamptz,
reopened_count int NOT NULL DEFAULT 0,
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, reservation_id)
);
CREATE INDEX folios_tenant_property_idx ON folios (tenant_id, property_id);
CREATE INDEX folios_status_idx ON folios (tenant_id, status) WHERE status IN ('open','balance_due');
ALTER TABLE folios ENABLE ROW LEVEL SECURITY;
CREATE POLICY folios_tenant_isolation ON folios
USING (tenant_id = current_setting('app.tenant_id', true));
2.2 folio_charges
CREATE TABLE folio_charges (
id text PRIMARY KEY CHECK (id LIKE 'chg_%'),
folio_id text NOT NULL REFERENCES folios(id) ON DELETE RESTRICT,
tenant_id text NOT NULL,
kind text NOT NULL CHECK (kind IN ('room_night','tax','fee','mini_bar','restaurant','laundry','service','adjustment','late_fee')),
description jsonb NOT NULL, -- { default, locales: { ps,ar,en,fr,fa,tg } }
quantity int NOT NULL CHECK (quantity > 0),
unit_price_micro bigint NOT NULL CHECK (unit_price_micro >= 0),
gross_micro bigint NOT NULL CHECK (gross_micro >= 0),
currency char(3) NOT NULL,
tax_code text NOT NULL,
tax_rate_num bigint NOT NULL,
tax_rate_den bigint NOT NULL,
tax_amount_micro bigint NOT NULL,
tax_jurisdiction text NOT NULL,
posted_at timestamptz NOT NULL,
posted_by text NOT NULL,
source_kind text NOT NULL CHECK (source_kind IN ('rate_plan','pos','manual','event')),
source_ref text,
voided_at timestamptz,
voided_by text,
void_reason text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX folio_charges_folio_idx ON folio_charges (folio_id);
CREATE INDEX folio_charges_tenant_posted_idx ON folio_charges (tenant_id, posted_at);
ALTER TABLE folio_charges ENABLE ROW LEVEL SECURITY;
CREATE POLICY folio_charges_tenant_isolation ON folio_charges
USING (tenant_id = current_setting('app.tenant_id', true));
2.3 folio_payments
CREATE TABLE folio_payments (
id text PRIMARY KEY CHECK (id LIKE 'fpm_%'),
folio_id text NOT NULL REFERENCES folios(id) ON DELETE RESTRICT,
tenant_id text NOT NULL,
method text NOT NULL CHECK (method IN ('cash','card','paypal','mfs','bank_transfer','on_account')),
amount_micro bigint NOT NULL CHECK (amount_micro > 0),
currency char(3) NOT NULL,
external_payment_id text, -- payment-gateway-service paymentId
cash_session_id text REFERENCES cash_drawer_sessions(id),
recorded_at timestamptz NOT NULL,
recorded_by text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK ( (method = 'cash' AND cash_session_id IS NOT NULL AND external_payment_id IS NULL)
OR (method = 'on_account')
OR (method NOT IN ('cash','on_account') AND external_payment_id IS NOT NULL AND cash_session_id IS NULL) )
);
CREATE INDEX folio_payments_folio_idx ON folio_payments (folio_id);
CREATE INDEX folio_payments_external_idx ON folio_payments (external_payment_id) WHERE external_payment_id IS NOT NULL;
CREATE INDEX folio_payments_session_idx ON folio_payments (cash_session_id) WHERE cash_session_id IS NOT NULL;
CREATE INDEX folio_payments_recorded_idx ON folio_payments (tenant_id, recorded_at);
ALTER TABLE folio_payments ENABLE ROW LEVEL SECURITY;
CREATE POLICY folio_payments_tenant_isolation ON folio_payments
USING (tenant_id = current_setting('app.tenant_id', true));
2.4 folio_refunds
CREATE TABLE folio_refunds (
id text PRIMARY KEY CHECK (id LIKE 'frd_%'),
folio_id text NOT NULL REFERENCES folios(id) ON DELETE RESTRICT,
tenant_id text NOT NULL,
amount_micro bigint NOT NULL CHECK (amount_micro > 0),
currency char(3) NOT NULL,
reason text NOT NULL,
external_refund_id text,
cash_session_id text REFERENCES cash_drawer_sessions(id),
refunded_at timestamptz NOT NULL,
refunded_by text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX folio_refunds_folio_idx ON folio_refunds (folio_id);
CREATE INDEX folio_refunds_external_idx ON folio_refunds (external_refund_id) WHERE external_refund_id IS NOT NULL;
ALTER TABLE folio_refunds ENABLE ROW LEVEL SECURITY;
CREATE POLICY folio_refunds_tenant_isolation ON folio_refunds
USING (tenant_id = current_setting('app.tenant_id', true));
2.5 invoices, invoice_lines, credit_notes, credit_note_lines
CREATE TABLE invoices (
id text PRIMARY KEY CHECK (id LIKE 'inv_doc_%'),
tenant_id text NOT NULL,
folio_id text NOT NULL REFERENCES folios(id),
number text NOT NULL,
customer jsonb NOT NULL, -- { class, name, email, phone, vatNumber, taxRegistration, address, preferredLocale }
currency char(3) NOT NULL,
subtotal_micro bigint NOT NULL,
tax_total_micro bigint NOT NULL,
grand_total_micro bigint NOT NULL,
locale text NOT NULL,
template text NOT NULL CHECK (template IN ('standard','government','corporate','agent','sharia')),
pdf_uri text,
issued_at timestamptz NOT NULL,
voided_at timestamptz,
void_reason text,
version int NOT NULL DEFAULT 1,
UNIQUE (tenant_id, number)
);
CREATE INDEX invoices_folio_idx ON invoices (folio_id);
CREATE INDEX invoices_tenant_issued_idx ON invoices (tenant_id, issued_at);
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY invoices_tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id', true));
CREATE TABLE invoice_lines (
id text PRIMARY KEY CHECK (id LIKE 'ln_%'),
invoice_id text NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
charge_id text REFERENCES folio_charges(id),
description jsonb NOT NULL,
quantity int NOT NULL,
unit_price_micro bigint NOT NULL,
gross_micro bigint NOT NULL,
tax_code text NOT NULL,
tax_amount_micro bigint NOT NULL,
ordering int NOT NULL
);
CREATE INDEX invoice_lines_invoice_idx ON invoice_lines (invoice_id, ordering);
CREATE TABLE credit_notes (
id text PRIMARY KEY CHECK (id LIKE 'cnt_%'),
tenant_id text NOT NULL,
invoice_id text NOT NULL REFERENCES invoices(id),
number text NOT NULL,
total_micro bigint NOT NULL,
currency char(3) NOT NULL,
reason text NOT NULL,
pdf_uri text,
issued_at timestamptz NOT NULL,
UNIQUE (tenant_id, number)
);
ALTER TABLE credit_notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY credit_notes_tenant_isolation ON credit_notes
USING (tenant_id = current_setting('app.tenant_id', true));
CREATE TABLE credit_note_lines (
id text PRIMARY KEY CHECK (id LIKE 'cnl_%'),
credit_note_id text NOT NULL REFERENCES credit_notes(id) ON DELETE CASCADE,
original_line_id text NOT NULL REFERENCES invoice_lines(id),
amount_micro bigint NOT NULL,
reason text
);
2.6 settlements
CREATE TABLE settlements (
id text PRIMARY KEY CHECK (id LIKE 'set_%'),
tenant_id text NOT NULL,
folio_id text NOT NULL REFERENCES folios(id) UNIQUE,
per_currency_totals jsonb NOT NULL, -- [{ currency, charges_micro, payments_micro, refunds_micro }]
residual_micro bigint NOT NULL,
residual_currency char(3) NOT NULL,
closed_at timestamptz NOT NULL
);
ALTER TABLE settlements ENABLE ROW LEVEL SECURITY;
CREATE POLICY settlements_tenant_isolation ON settlements
USING (tenant_id = current_setting('app.tenant_id', true));
2.7 cash_drawers & cash_drawer_sessions & daily_cash_reconciliations
CREATE TABLE cash_drawers (
id text PRIMARY KEY CHECK (id LIKE 'cdr_%'),
tenant_id text NOT NULL,
property_id text NOT NULL,
label text NOT NULL,
currency char(3) NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, property_id, label)
);
CREATE TABLE cash_drawer_sessions (
id text PRIMARY KEY CHECK (id LIKE 'cds_%'),
drawer_id text NOT NULL REFERENCES cash_drawers(id),
tenant_id text NOT NULL,
status text NOT NULL CHECK (status IN ('open','pending_close','closed','reconciliation_blocked')),
opening_float_micro bigint NOT NULL,
expected_closing_micro bigint,
counted_closing_micro bigint,
variance_micro bigint,
variance_threshold_micro bigint NOT NULL,
currency char(3) NOT NULL,
opened_by text NOT NULL,
opened_at timestamptz NOT NULL,
initiated_close_by text,
initiated_close_at timestamptz,
closed_by text,
co_signer text,
closed_at timestamptz,
step_up_token_id text,
shift_label text,
version int NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX cash_session_one_open_per_drawer
ON cash_drawer_sessions (drawer_id) WHERE status IN ('open','pending_close','reconciliation_blocked');
ALTER TABLE cash_drawer_sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY cash_session_tenant_isolation ON cash_drawer_sessions
USING (tenant_id = current_setting('app.tenant_id', true));
CREATE TABLE daily_cash_reconciliations (
id text PRIMARY KEY CHECK (id LIKE 'dcr_%'),
tenant_id text NOT NULL,
property_id text NOT NULL,
business_date date NOT NULL,
folio_cash_micro bigint NOT NULL,
session_cash_micro bigint NOT NULL,
variance_micro bigint NOT NULL,
status text NOT NULL CHECK (status IN ('match','variance','mismatch')),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, property_id, business_date)
);
2.8 _outbox (per-tenant)
CREATE TABLE _outbox (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
event_type text NOT NULL,
subject text NOT NULL,
payload jsonb NOT NULL,
trace_id text,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
CREATE INDEX outbox_unpublished_idx ON _outbox (created_at) WHERE published_at IS NULL;
2.9 _inbox (per-tenant)
CREATE TABLE _inbox (
key text PRIMARY KEY, -- 'inbox:billing:<event-type>:<id>' or 'req:<route>:<idem-key>'
tenant_id text NOT NULL,
result jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX inbox_tenant_created_idx ON _inbox (tenant_id, created_at);
3. Central schema billing_central (subscription billing)
CREATE SCHEMA IF NOT EXISTS billing_central;
SET search_path = billing_central, public;
CREATE TABLE plans (
code text PRIMARY KEY, -- 'STARTER_PER_ROOM','PRO_PER_ROOM','ENTERPRISE_FLAT'
base jsonb NOT NULL, -- { kind, amount?, perRoomAmount? }
meters jsonb NOT NULL DEFAULT '[]'::jsonb,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE subscriptions (
id text PRIMARY KEY CHECK (id LIKE 'sub_%'),
tenant_id text NOT NULL UNIQUE,
plan_code text NOT NULL REFERENCES plans(code),
state text NOT NULL CHECK (state IN ('current','grace','past_due','suspended','cancelled')),
currency char(3) NOT NULL,
cycle_anchor smallint NOT NULL CHECK (cycle_anchor BETWEEN 1 AND 28),
grace_period_days int NOT NULL DEFAULT 7,
hard_cap_micro bigint,
payment_method_token text, -- opaque token to payment-gateway-service
created_at timestamptz NOT NULL DEFAULT now(),
suspended_at timestamptz,
cancelled_at timestamptz,
version int NOT NULL DEFAULT 1
);
CREATE INDEX subscriptions_state_idx ON subscriptions (state);
CREATE TABLE subscription_invoices (
id text PRIMARY KEY CHECK (id LIKE 'sin_%'),
subscription_id text NOT NULL REFERENCES subscriptions(id),
tenant_id text NOT NULL,
period text NOT NULL, -- 'YYYY-MM'
currency char(3) NOT NULL,
subtotal_micro bigint NOT NULL,
tax_total_micro bigint NOT NULL DEFAULT 0,
grand_total_micro bigint NOT NULL,
status text NOT NULL CHECK (status IN ('open','paid','failed','void')),
pdf_uri text,
issued_at timestamptz NOT NULL,
due_at timestamptz NOT NULL,
paid_at timestamptz,
attempt_count int NOT NULL DEFAULT 0,
next_retry_at timestamptz,
dunning_history jsonb NOT NULL DEFAULT '[]'::jsonb,
UNIQUE (subscription_id, period)
);
CREATE INDEX subscription_invoices_status_idx ON subscription_invoices (status, due_at);
CREATE INDEX subscription_invoices_retry_idx ON subscription_invoices (next_retry_at) WHERE next_retry_at IS NOT NULL;
CREATE TABLE subscription_invoice_lines (
id text PRIMARY KEY CHECK (id LIKE 'sil_%'),
invoice_id text NOT NULL REFERENCES subscription_invoices(id) ON DELETE CASCADE,
kind text NOT NULL, -- 'base_flat','base_per_room','ai_overage','storage_overage'
units numeric(20,4) NOT NULL DEFAULT 0,
unit_price_micro bigint NOT NULL,
amount_micro bigint NOT NULL,
ordering int NOT NULL
);
-- Usage records partitioned by period (YYYY-MM) for affordable retention pruning.
CREATE TABLE usage_records (
id text NOT NULL CHECK (id LIKE 'usg_%'),
tenant_id text NOT NULL,
period text NOT NULL, -- 'YYYY-MM'
meter text NOT NULL CHECK (meter IN ('rooms','ai_tokens','storage_bytes')),
units numeric(20,4) NOT NULL,
measured_at timestamptz NOT NULL,
source_event_id text,
PRIMARY KEY (tenant_id, period, meter, id)
) PARTITION BY LIST (period);
CREATE INDEX usage_records_lookup_idx
ON usage_records (tenant_id, period, meter);
-- Concrete partition example:
CREATE TABLE usage_records_2026_04 PARTITION OF usage_records FOR VALUES IN ('2026-04');
-- Central outbox & inbox mirror the per-tenant ones.
CREATE TABLE _outbox (LIKE billing_central.usage_records EXCLUDING ALL); -- placeholder; actual columns mirror per-tenant outbox
4. Tenant lifecycle hooks
| Event | Schema action |
|---|---|
tenant.created.v1 | tenant-provisioning-job runs db/tenants/billing.tmpl.sql substituting :tenant_id; sets app.tenant_id GUC default; seeds default cash_drawers from property bootstrap event later |
tenant.suspended.v1 | application-level read-only flag in subscriptions.state (no DDL) |
tenant.deleted.v1 | per ADR-0002: schema is archived (renamed tenant_<uuid>_billing__archived_<yyyymmdd>) for 90 days then dropped; subscription rows marked cancelled |
5. Money & FX columns rules
- All money is
bigint_micro(1e6). Tax, gross, payment, refund, settlement totals all use the same scale. - Each money-bearing row carries its own
currency char(3); CHECK constraints enforce alignment with the parent (e.g.,folio.currency) only for payment / refund computations at the application layer; multi-currency payments to a single folio are allowed and reconciled viaFXSnapshot. - The FX snapshot is stored once on the folio (and embedded in events). Subsequent rate changes do not retroactively alter the folio.
6. Indexing strategy
| Goal | Index |
|---|---|
| Hot read: in-house folios per property | folios (tenant_id, property_id, status) WHERE status='open' |
| Folio detail view | folio_charges (folio_id), folio_payments (folio_id), folio_refunds (folio_id) |
| Daily reconciliation join | folio_payments (tenant_id, recorded_at), cash_drawer_sessions (drawer_id, status) |
| Dunning sweep | subscription_invoices (status, due_at) and (next_retry_at) |
| Usage aggregation | partitioned usage_records (tenant_id, period, meter) |
| Audit queries | _outbox (created_at) WHERE published_at IS NULL |
7. Constraints encoding domain invariants
| Invariant | DB-level enforcement |
|---|---|
| Closed folio rejects mutations | application layer + trigger folio_charges_no_insert_when_closed/payments/refunds (reads folios.status) |
| One open cash session per drawer | unique partial index cash_session_one_open_per_drawer |
| Cash payment requires session, non-cash requires external id | row-level CHECK on folio_payments |
| Invoice number unique per tenant | UNIQUE (tenant_id, number) on invoices and credit_notes |
| Settlement 1:1 with closed folio | UNIQUE (folio_id) |
| Refund > 0, charge unit_price ≥ 0 | CHECK (amount_micro > 0) etc. |
8. Retention & archival
| Class | Hot retention | Archival |
|---|---|---|
| Folios + charges + payments + refunds | 7 years (regulatory) | After 7 years → moved to billing-archive-<tenantId> GCS bucket as parquet, then dropped from Postgres |
| Invoices + credit notes (PDFs) | 7 years in file-storage-service | Cold-tier GCS after 1 year |
_outbox | 30 days hot | dropped (audit-service is the long-term tail) |
_inbox | 7 days | rolling cleanup job |
daily_cash_reconciliations | 7 years | as folios |
usage_records | 25 months hot (24 cycles + buffer) | older partitions detached and parquet-archived |
subscription_invoices | 7 years | as folios |
9. Backups & PITR
- Cloud SQL automatic daily backups + transaction log retention 7 days for PITR ≤ 1 minute.
- Cross-region replica (
asia-south1primary →europe-west3replica) for disaster recovery; failover RTO ≤ 15 min, RPO ≤ 1 min. - Per-tenant logical exports nightly to per-tenant bucket for offboarding readiness (DSAR).
10. Migration tooling
- Per-tenant DDL bundle in
db/tenants/billing.tmpl.sql; thebilling-tenant-migratorCloud Run Job iteratestenants.active=true, applies forward migrations under advisory lock per tenant. - Central DDL in
db/central/billing-central.sql; standarddrizzle-kit migrate. - See MIGRATION_PLAN for the legacy folio importer.
11. Cross-references
- Aggregate model: DOMAIN_MODEL.
- Use cases that touch each table: APPLICATION_LOGIC.
- Schema-per-tenant rationale: ADR-0002 §2.
- Money & FX rules: 10 Payments §4.