Subscription and manual billing API
Subscription persists organization agreements and entirely manual billing in PostgreSQL. Payments change invoice balances; administrators separately control access. There are no gateways, automatic charges, renewal invoices, expiry jobs, or billing notifications. All amounts are signed 64-bit integer BDT minor units (BDT 1,800 = 180000).
Setup and authorization
Apply migrations with go run ./tools/cli database migrate. Subscription has its own embedded migration set and goose_subscription_version table. Existing migration sources are unchanged. No sample agreements or commercial prices are installed.
Public visitors can read active public plans. Every platform billing operation requires an authenticated active account, active platform administrator membership, and the corresponding admin.plan.*, admin.subscription.*, admin.invoice.*, or admin.payment.* catalog permission. Billing uses Module.Permissions(), never the legacy allow-all authorization placeholder. Current platform memberships do not infer owner authority from a role name. Provision explicit grants through Authorization. Organization admin memberships have Authorization's protected owner authority only in their organization, and cannot use platform billing routes.
Organization subscription/history reads require active identity and organization membership, active organization, and org.subscription.read / org.invoice.list. Other-tenant resources are hidden with 404. Draft invoices, internal notes, correction reasons, and platform actor IDs are absent from the organization invoice DTO. Read eligibility does not depend on the subscription being active.
Shared offerings and snapshots
Plans have a unique immutable slug, BDT price, monthly/yearly descriptive interval, visibility, and features. Public reads exclude private and archived offerings. Archiving prevents assignment. Existing agreements keep the assigned plan snapshot, price, and overrides, even after a catalog edit or archive.
Both max_members and max_doctors are required in a plan's complete feature map. Limits are exact nonnegative integers; zero permits no capacity, null is unlimited. Other keys can be boolean flags or nonnegative integer/null limits. Missing optional features grant no entitlement. Strings, nested objects, fractions, overflow, and inexact floating-point capacities are rejected. Active ordinary memberships and active doctor affiliations consume separate capacities; account suspension does not change those counts. An administrator membership also consumes a member slot.
Creation snapshots an active offering for an active organization with explicit access start/end. Negotiated price defaults to the offering price. PATCH preserves omitted fields. Assigning a different plan refreshes the snapshot, adopts its price, and clears old overrides before applying explicitly supplied values. An overrides object replaces the entire map: {} clears overrides. Null inside the map means unlimited.
Subscription lifecycle and request-time eligibility
One non-deleted stored active/trialing/past_due agreement owns the current slot, including future-dated agreements. Creation/reactivation holds the organization lock and retires an elapsed current record before claiming this slot. A database partial unique index is the final concurrency constraint; its predicate does not use now().
Access start is inclusive and end exclusive. Trial access ends at the earlier trial end. Active/trialing records grant writes only within these dates. Past_due, canceled, expired, deleted, missing, and future-start subscriptions do not. Read responses expose is_writable, read_only_reason, and effective time expiry without changing storage. Trial end does not automatically convert to paid access. Invoice balance never changes access status.
Extension only moves the access end later than the existing end and server time. It never creates a charge. An elapsed stored-active/trialing/past_due record becomes stored expired during extension and stays read-only until an explicit eligible PATCH reactivation. Reactivation requires future applicable expiry, an active organization, capacity at least current usage, and no other current agreement. PATCH-canceled records may reactivate under these rules. DELETE records terminal soft cancellation and cannot be reversed. Canceled/deleted records cannot be extended; all financial history remains.
trial_ends_at PATCH distinguishes omission from null: omission preserves the value, null clears it, and a timestamp replaces it. Trialing always requires a valid end after start and within the access term. Switching to active clears the old trial end unless an explicit timestamp is supplied. Access start and organization are immutable; monthly/yearly labels do not roll the access term or schedule any operation.
Invoices, receipts, and corrections
Create a draft for an explicit positive amount, covered start/end, invoice date, due date, and optional admin notes. Due date is on/after invoice date. Invoice periods may cover historical or renewal service outside the current access term. A PostgreSQL sequence assigns a unique server number at draft creation; gaps are allowed. These are service charges, not a separately specified tax-invoice product.
Only drafts allow charge edits. Issue changes draft to open and records server time. Open invoices accept received payments using cash, bank_transfer, bkash, nagad, card, or other. Methods describe externally received money; no gateway transaction occurs. paid_at is the trusted admin-entered received time and cannot be future-dated. Reference is optional and is not globally unique. Partial payment leaves the invoice open; exact settlement marks paid. Overpayment conflicts. Invoice paid_at is the server time when it became fully settled, distinct from each payment's received time. An overdue flag is derived from open balance and due date and does not suspend access.
Payments cannot be edited or deleted. Void an incorrect entry with a reason, retaining its original facts, trusted actor and timestamp. The invoice may reopen. Record a replacement separately. A database trigger protects received-payment facts. Draft or open invoices may be voided only after all recorded payments have been voided. Repeated void conflicts. Voiding does not return money. Refunds, tax, credit, proration, coupons, multiple currencies, and write-off remain outside scope.
Idempotency-Key is required on subscription creation, invoice creation, and payment recording. Generate one key per logical command and reuse it after a lost response. Keys are scoped by authenticated actor, operation, and resource/organization. A same-key, same-payload retry returns its committed result; a different payload conflicts. Results and financial writes commit atomically. Retrying a payment after it has since been voided still returns the original creation result and never creates another entry.
Payment recording, payment voiding, and invoice voiding always lock the invoice first. Balances/status are recalculated from non-voided payment rows inside that transaction. Billing changes and approved audit events commit together; audit failure rolls everything back. Events live in the initiating administrator's history with the affected tenant recorded separately. Feature maps and plan snapshots are canonical JSON strings in audit changes, preserving Audit's restricted-value schema and exact integer amounts.
Read-only enforcement and rollout
SUBSCRIPTION_ENFORCEMENT=shadow is the default. It evaluates/logs proposed restrictions while existing organizations receive real persisted billing data. After migrating, reviewing usage, provisioning actual agreements and permissions, set SUBSCRIPTION_ENFORCEMENT=enforce and restart. No fixtures ever grant capacity/access. In enforce mode, dependency failures propagate as service errors and never grant writes. Rollback can return to shadow while preserving committed billing history and retry keys; do not drop tables or replay payments.
The only shared eligibility predicate is SubscriptionService.IsActiveSubscription(ctx, organizationID). Only business use cases invoke it. HTTP handlers translate errors and repositories bind transaction reads; neither contains a subscription gate. Every protected decision runs after the organization row lock with transaction-bound Subscription providers. Assignments acquire Identity account locks first, then organization, then relationships. Downgrades take the same organization lock. Batch assignment counts new/reactivated affiliations only, preserving active ones.
Implemented-operation inventory:
| Operation | Subscription policy |
|---|---|
| Public plans; authorized organization billing/history; existing member/doctor/organization reads | Preserve normal authorization, including after expiry |
| Legacy team creation and member removal | Protected use-case check; expiry blocks mutations |
| Platform doctor batch assignment and affiliation activation/deactivation | Protected check; capacity applies only to new/reactivated slots |
| Platform ordinary-member provisioning | Precheck before Identity provisioning, authoritative check under organization lock |
| Doctor registration redeeming an organization invitation | Protected check/capacity; failure rolls back account, profile, invitation, and affiliation |
| Organization role, grant, and override mutations | Authorized use-case check in the locked transaction |
| First organization administrator with no existing members/current agreement | Explicit initial setup exception under organization lock |
| Identity/session recovery, organization context selection, professional profile/account operations | Preserve existing context/identity rules |
| Platform billing and platform organization lifecycle/metadata administration | Separately authorized control-plane operations remain available |
| Patient/prescription clinical writes, portal invitations/join acceptance, member reactivation, team/settings PATCH | Still contract stubs; no side effects. Implementers must use the same protected predicate before enabling them |
At full capacity, deactivation/removal remains possible while the subscription is eligible. Expiry still blocks those organization business mutations. Read responses explain the restriction; the backend remains authoritative. This repository contains no frontend application, and commercial backfill/production activation require actual agreed facts.
Wire conventions and routes
Resource IDs are UUIDs; dates use RFC 3339. Resource success responses use {message,status_code,data}; lists use {message,status_code,data:[],pagination:{next_cursor}}. Archive/delete return 200 with an empty data object. Requests use cursor/limit, default 20/max 100, newest first. Cursors are bound to filters/scope/page size. Admin plan filters are is_public/is_active; subscription filters are organization_id/status; invoice filters add subscription_id; payment filters add invoice_id. All paths below start with /api/v1.
| Method | Path | Success |
|---|---|---|
| GET | /subscriptions/plans | 200 |
| GET | /subscriptions/plans/{planId} | 200 |
| GET | /admin/subscription-plans | 200 |
| POST | /admin/subscription-plans | 201 |
| GET | /admin/subscription-plans/{planId} | 200 |
| PATCH | /admin/subscription-plans/{planId} | 200 |
| DELETE | /admin/subscription-plans/{planId} | 200 |
| GET | /admin/subscriptions | 200 |
| POST | /admin/subscriptions | 201 |
| GET | /admin/subscriptions/{subscriptionId} | 200 |
| PATCH | /admin/subscriptions/{subscriptionId} | 200 |
| DELETE | /admin/subscriptions/{subscriptionId} | 200 |
| POST | /admin/subscriptions/{subscriptionId}/extend | 200 |
| GET | /admin/invoices | 200 |
| POST | /admin/subscriptions/{subscriptionId}/invoices | 201 |
| GET | /admin/subscriptions/{subscriptionId}/invoices | 200 |
| GET | /admin/invoices/{invoiceId} | 200 |
| PATCH | /admin/invoices/{invoiceId} | 200 |
| POST | /admin/invoices/{invoiceId}/issue | 200 |
| POST | /admin/invoices/{invoiceId}/void | 200 |
| GET | /admin/payments | 200 |
| POST | /admin/invoices/{invoiceId}/payments | 201 |
| GET | /admin/invoices/{invoiceId}/payments | 200 |
| GET | /admin/payments/{paymentId} | 200 |
| POST | /admin/payments/{paymentId}/void | 200 |
| GET | /organizations/{organizationId}/subscription | 200 |
| GET | /organizations/{organizationId}/billing-history | 200 |
Invalid credentials return 401, permission/subscription denials 403, missing/scoped-hidden resources 404, lifecycle/idempotency/capacity conflicts 409, invalid input 422, unavailable dependencies 503, and unexpected failures 500. The stable read-only problem detail is subscription_read_only. Billing routes no longer return 501. The shared Subscription DTO is wrapped consistently in both platform and organization responses.
Operator reconciliation
Run this read-only diagnostic against an authorized operational connection. It reports inconsistencies rather than silently changing financial history:
SELECT i.id, i.amount_minor, i.paid_minor, i.balance_minor,
COALESCE(sum(p.amount_minor) FILTER (WHERE p.status='recorded'),0) AS received
FROM subscription_invoices i
LEFT JOIN subscription_payments p ON p.invoice_id=i.id
GROUP BY i.id
HAVING i.paid_minor <> COALESCE(sum(p.amount_minor) FILTER (WHERE p.status='recorded'),0)
OR i.balance_minor <> i.amount_minor-i.paid_minor
OR (i.status='paid') <> (i.paid_minor=i.amount_minor);Use the dedicated PostgreSQL HTTP/integration tests and race-enabled short suite. No billing background workers, broker consumers, or reconciliation jobs are installed.