Skip to content

Patient Billing (clinic → patient)

The clinic bills the patient after the service. The platform generates the bill, shows it in the patient's Portal account, and lets the patient pay it through the clinic's own payment processor. The platform never holds funds and never issues a fiscal invoice.

Designed, not built — and a scope change

This is not in the locked scope. CLAUDE.md lists F12 (billing engine) as deliberately out of scope, with the note "clinics invoice by hand", and locks the build order F1 → F2.1 → F3 → F4 → F5 → F6, with F15 after F5. This page specifies the feature so the decision can be made with the design in hand; it does not authorise building it, and nothing here is implemented.

It also reverses a settled decision: offerings was locked on 2026-08-02 as "catalog identity … no pricing, no plans, no products, no purchase path". §3 puts a price back on it. That reversal is the owner's to record.

Proposed identifier: F17 — Patient Charges. Distinct from F12, which is platform → clinic. Nothing here touches F12's engine, tables, or capability wiring.


1. The distinction the whole design rests on

A charge is not a factură.

Charge (this feature)Factură (fiscal invoice)
What it isA receivable — "this patient owes the clinic X for Y"A fiscal document under Romanian tax law
NumberingA UUIDThe clinic's own series + sequential number
Who issues itThe platformThe clinic, under its own CUI
RegulatorNoneANAF — RO e-Factura / SPV since 1 Jan 2025 (B2C)
Correctionsvoid + reissueStorno invoice

If the platform issued the second one, RestartiX would become a fiscal invoicing system operating under each clinic's tax identity, for 20k+ patients, with a submission deadline attached to every row. That is a materially different product.

It is very likely not required. Medical services exempt under Art. 292 Cod fiscal fall under Art. 319(7): issuing an invoice to an individual is not mandatory for operations exempt without right of deduction, except at the beneficiary's express request. A payment document (chitanță / bon) suffices for the ordinary visit; only when a patient asks for a factură does the e-Factura path engage — and at that point the clinic issues it in the software it already uses.

Separately, Law 317/2024 (amending OUG 28/1999) made the printed fiscal receipt optional for card payments; it remains mandatory for cash. Both of those are clinic-side obligations discharged on clinic-side equipment, not platform concerns.

Confirm with the clinic's accountant

The above is read from public sources, not from an accountant, and the exempt-vs-taxable call is per offering — a physiotherapy consultation and a paid wellness class are not the same operation. tax_treatment (§3) exists so that call is recorded per offering rather than assumed platform-wide. Do not ship against this section without the clinic's accountant confirming it.


2. Decisions (locked 2026-09-01)

#DecisionChosenRejected
PB-1Money movementOption A+ — charge ledger on the platform; payment redirects to the clinic's own hosted checkout; inbound webhook marks the charge paid. Platform never holds funds.Ledger-only (no online payment); Option B marketplace mediation (Stripe Connect, platform fee, payouts, per-clinic KYC)
PB-2Fiscal documentClinic's own software, out of band. The platform emits no fiscal document. A patient requesting a factură is served by the clinic's existing accounting software, as today.Delegating issuance via invoicing.Provider; platform-issued invoices with ANAF submission
PB-3Where price livesOn the offering, snapshotted onto the charge, overridable per appointment.Staff-typed per appointment (manual at 20k-patient scale); a separate versioned price_list_items table (heavier than one number per service)

PB-1 is what keeps this out of PSD2 territory. The platform is not in the payment chain: it knows a charge exists and later learns it was paid. No funds custody, no marketplace agreement, no payout engine, no chargeback liability, no refund obligation.

PB-2 means invoicing.Provider stays dormant. It was declared at foundation for F12 (platform → clinic) and its RegisterWithAuthority slot targets ANAF; this feature does not wire it. If PB-2 is ever revisited, that interface is the seam — it is the reason a future change is a connector rather than a redesign.


3. Pricing

sql
ALTER TABLE offerings
    ADD COLUMN price_minor_units BIGINT
        CHECK (price_minor_units IS NULL OR price_minor_units >= 0),
    ADD COLUMN tax_treatment TEXT NOT NULL DEFAULT 'exempt_medical'
        CHECK (tax_treatment IN ('exempt_medical', 'standard'));
  • Minor units, integer. payment.Money in internal/core/billing/payment is already {MinorUnits int64, Currency CurrencyCode}; every money path in the platform operates on integers. The two DECIMAL(10,2) columns in the schema (tiers.base_price, patient_tiers.external_price_hint) are informational hints that never enter arithmetic, so they do not conflict.
  • NULL price = not priced. A free offering, or one quoted case-by-case. It produces no charge automatically.
  • No currency column. Currency lives once per clinic (organization_settings.currency, already DEFAULT 'RON') and is snapshotted onto the charge. A per-offering currency is a column that can disagree with itself.
  • tax_treatment is recorded, not computed against. The platform never calculates VAT (§1).

4. The charge ledger

sql
CREATE TABLE patient_charges (
    id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id       UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    -- The ORG-SCOPED patient row, never patient_profiles: a charge is a
    -- clinical/commercial record and never crosses a clinic boundary.
    patient_id            UUID NOT NULL REFERENCES patients(id) ON DELETE RESTRICT,
    -- Nullable: staff-created charges exist with no appointment behind them.
    appointment_id        UUID REFERENCES appointments(id) ON DELETE SET NULL,
    -- Provenance of the price. SET NULL so retiring an offering never
    -- orphans money owed.
    offering_id           UUID REFERENCES offerings(id) ON DELETE SET NULL,

    -- Snapshots. The label and the amount survive a rename, a reprice and a
    -- soft-deleted offering — what was billed is what was billed (P37).
    description           TEXT NOT NULL,
    amount_minor_units    BIGINT NOT NULL CHECK (amount_minor_units > 0),
    currency              TEXT NOT NULL,
    tax_treatment         TEXT NOT NULL,

    status                TEXT NOT NULL DEFAULT 'draft',
    issued_at             TIMESTAMPTZ,
    paid_at               TIMESTAMPTZ,
    voided_at             TIMESTAMPTZ,
    void_reason           TEXT,
    settled_via           TEXT,

    created_by_principal_id UUID NOT NULL REFERENCES principals(id),
    created_at            TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at            TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT chk_patient_charges_status
        CHECK (status IN ('draft', 'issued', 'paid', 'void', 'written_off')),
    CONSTRAINT chk_patient_charges_settled_via
        CHECK (settled_via IS NULL OR settled_via IN
            ('cash', 'card_in_clinic', 'bank_transfer', 'online')),
    -- Biconditionals, in the style of 000046's cancelled_at / noshow_at.
    CONSTRAINT chk_patient_charges_paid
        CHECK ((status = 'paid') = (paid_at IS NOT NULL AND settled_via IS NOT NULL)),
    CONSTRAINT chk_patient_charges_void
        CHECK ((status = 'void') = (voided_at IS NOT NULL))
);

No deleted_at, and no DELETE policy. This follows appointments verbatim: the status enum is how a charge stops counting. void is a mistake retracted; written_off is a real debt the clinic gives up on. Collapsing them would lose the distinction the clinic's books need.

State (flat), not an event log — mutable rows queried by entity id, so no monthly partitioning (P41).

Payment attempts are separate, because one charge can be attempted many times:

sql
CREATE TABLE patient_charge_payments (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id     UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    charge_id           UUID NOT NULL REFERENCES patient_charges(id) ON DELETE CASCADE,
    integration_id      UUID NOT NULL REFERENCES organization_integrations(id) ON DELETE RESTRICT,
    provider            TEXT NOT NULL,
    provider_ref        TEXT NOT NULL,
    status              TEXT NOT NULL
                            CHECK (status IN ('pending', 'succeeded', 'failed', 'refunded')),
    amount_minor_units  BIGINT NOT NULL,
    currency            TEXT NOT NULL,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    -- Idempotency under webhook replay — the same shape as
    -- access_offer_orders' UNIQUE (org, integration, order_ref).
    UNIQUE (organization_id, provider, provider_ref)
);

5. Lifecycle

appointment.status → 'done'
      │  offering.price_minor_units IS NOT NULL

  patient_charges (status='draft')       ← in the same transaction as the
      │                                    status transition; charges are
      │  staff review + issue              app-role writable, so no deferral
      ↓                                    (unlike notify)
  status='issued'  ──────────────────────────────┐
      │                                          │
      │ patient pays in the Portal               │ patient pays at the desk
      ↓                                          ↓
  redirect → clinic's checkout           staff mark settled
      │                                    settled_via='cash'|'card_in_clinic'
      │ processor webhook → verify-by-pull        │
      ↓                                          │
  status='paid', settled_via='online'  ←─────────┘

Draft, not auto-issued. The clinic decides what actually gets billed — a comped visit, a goodwill waiver, a session run over into the next one. A per-org auto-issue toggle is a one-column addition later if the manual step proves to be friction; starting there would bill patients for visits no one reviewed.

No-show and late cancellation. appointments already carries noshow_grace_minutes and late_cancellation_hours (000046), and already distinguishes noshow / cancelled_late / cancelled_by_patient. A no-show fee is therefore a fee amount hung off the same configuration, minting a draft charge on those transitions. Whether a no-show fee is enforceable against a patient at all is a legal question for the clinic, which is a second reason drafts do not auto-issue.


6. Paying from the Portal (Option A+)

Nothing new is needed at the framework level. This is the exact shape the shop integration already ships — clinic-credentialed Cat B connection, inbound Cat D webhook, verify-by-pull, durable synchronous processing.

  1. Clinic connects its payment processor once (Netopia / EuPlătesc / Stripe under its own merchant account) — a new integration_services catalog row plus a connector, mirroring 000036. Credentials are the clinic's, encrypted as auth_secret.
  2. Patient taps Pay on an issued charge. The API asks the connector for a hosted payment session (amount, currency, charge id as the order reference) and returns the redirect URL. A pending patient_charge_payments row is written.
  3. Patient pays on the processor's page. The platform's UI is never in the card path.
  4. The processor's webhook arrives at the inbound framework. The handler pulls the authoritative transaction from the processor's API and derives paid/failed from that — never from the request body. This is the rule shoporder.NormalizedOrder already encodes: "both are derived from the authoritative pulled order, never trusted from a webhook body."
  5. A durable, synchronous Processor.ProcessPaidCharge marks the charge paid on the admin pool inside the webhook request, so the 200 is only returned after the work is committed. The fire-and-forget bus consumer is the wrong shape here for the same reason it was replaced in access-offers: a failed consumer silently loses the event after the sender already got a 200.

The return URL is not authoritative. It shows "we're confirming your payment"; the webhook decides. A patient who closes the tab still gets a paid charge.

Refunds are the clinic's, in the clinic's processor. A refund webhook flips the payment row to refunded; what that does to the charge (reopen vs. written_off) is open question 6.


7. Cross-cutting requirements

RBAC — a new permission family charges (not billing, which is taken by the platform → clinic sense; see the glossary's two-senses rule):

CodeGranted toCovers
charges.viewadmin, customer_supportSee a patient's charges and balance
charges.manageadmin, customer_supportCreate, issue, void, write off, mark settled

Whether a specialist sees prices is open question 7 — the precedent is the appointments.manage / record_fields split, where front-desk work and clinical work are deliberately separate grants.

RLS — org arm (organization_id = current_app_org_id() AND current_app_has_permission('charges', ...)) plus a patient SELECT arm reaching their own rows through patients, using the 000051 visibility helpers. Patients get no INSERT or UPDATE — a patient never authors a charge, and pays it through the processor, not by writing a row. No DELETE policy on either table.

Audit — issue, void, write off, mark settled and price override are state transitions with money attached, so all are audited. Reads are not: there is no general read log, and a charge amount does not meet the audit.ActionRead bar (§ F11.5).

Classification — every column above needs a data-classification.md entry in the same PR or make check fails. An amount owed is org-confidential and patient-linked; the plausible egress target is patient_document (a statement the patient can export). It is not eligible for marketing or telemetry egress.

Notifications — a charge_issued category (transactional, BillingScopeTenant). ⚠️ Blocked on the standing patient-email posture: patients receive no email today, and that gate is launch-blocking in its own right. Until it lifts, the patient learns about the bill by opening the Portal.

Glossarycharge, factură, and the charge-vs-invoice distinction are new vocabulary and need entries in the same PR.


8. Deliberately not in scope

  • Any fiscal document — no invoice numbering, no series, no VAT computation, no ANAF/SPV, no storno.
  • Funds custody — no platform balance, no payouts, no marketplace fee, no KYC.
  • Refund initiation from the platform.
  • Dunning — no automated chasing of unpaid charges.
  • Prepaid packages ("N sessions of Offering X remaining") — still an open item in leo-port-map §8, and it interacts with this (see below).
  • Insurer / CNAS billing — a different payer, a different document, a different feature.

9. Open questions

  1. Partial payments and consolidated statements. v1 above is one charge, one payment, in full. A patient with four unpaid visits pays four times. A "pay all" basket is a real UX want and changes the payment table's shape (a payment covering many charges). Decide before building, not after.
  2. No-show fee configuration — per offering, per calendar, or per org? And is it enforceable against a patient at all, in the clinic's counsel's view?
  3. Prepaid packages. If a patient buys 10 sessions up front, a bill-after model has to know the visit is already covered. Package tracking is unresolved in leo-port-map §8; resolving it after charges ship means retrofitting a "covered by package" branch into the mint path.
  4. Which processor first. Netopia, EuPlătesc, and Stripe are all plausible; the right answer is whatever the clinic already has a merchant account with, since Option A+ uses their account.
  5. Where this lands in the build order. F1 → F6 is locked and F15/F16 are queued behind it. §3 (pricing) and §4 (ledger) sit naturally on F5, which is built. §6 is a genuinely new feature with a per-clinic operational tail.
  6. Refund semantics. A refunded payment on a paid charge — does the charge reopen as issued, or terminate as written_off with the refund recorded against it?
  7. Do specialists see prices? See §7.
  8. Legacy migration. The ~20k migrating patients — do any arrive with outstanding balances, and if so from what source of truth?

Cross-references