Billing — Obscura Cloud
Status: built, not yet shipped. Branch
feat/billing. The ledger, the gateway seam, the
Midtrans adapter, the webhook and the operator console are done and verified end to end against a
real Postgres and in a browser. Tenant-facing invoices, the renewal cron and dunning are not.Enterprise is unaffected: it sells a licence, not a subscription. None of this is wired there.
The shape, in one paragraph
An operator prices a plan (control.plan_prices), puts a tenant on it (control.subscriptions),
and each period becomes an invoice (control.invoices). An invoice is collected either through
a payment gateway through OUR OWN payment UI (control.payments) or by bank transfer recorded by
hand.
Nothing in the system suspends a customer for not paying.
Where the money lives, and why it is not in the tenant's schema
Every other fact about a customer lives in their own schema. The ledger does not, for four reasons
that each decide it on their own:
- It is the operator's record. A tenant administrator must not be able to edit what they owe,
andcontrol.*is the one namespace a tenant's connection cannot reach at all. - You bill people who have stopped paying. A suspended tenant's schema is not served; its
invoices still have to be readable, chaseable and payable. - Purging a tenant destroys its schema. Financial records have statutory retention that
outlives the customer relationship — which is also whytenant_idhere is deliberately not a
foreign key with a cascade. - The webhook cannot name a tenant. A payment provider has ONE notification URL per merchant
account and Cloud routes by Host. This is the same failure the
2026-07-30 boundary audit found in three other
places. A control-plane ledger is the only design where that callback has anywhere to land.
Configuration
# The provider. "" (unset) is a supported way to run: invoices are still issued and tracked,
# there is simply no Pay button — which is how a business that invoices by transfer operates.
BILLING_PROVIDER=midtrans # midtrans | mock | (unset)
# Midtrans. Prefer the _FILE form: this key authenticates our API calls AND salts every
# notification signature, so it is the highest-value secret in the block.
MIDTRANS_SERVER_KEY_FILE=/run/secrets/midtrans_server_key
# The PUBLIC half of the pair, served to the payer's browser so it can tokenise a card number
# directly against Midtrans. Not a secret — but leaving it UNSET is a supported choice, and the
# only one that turns cards off: with no client key the card method is not listed, the tokeniser
# script is never loaded, and every other method works unchanged.
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxx
# Selects the LIVE endpoints. Defaults to SANDBOX, deliberately: a production deployment left
# in sandbox takes fake money (recoverable); a test deployment pointed at production charges
# real cards (not). Preflight flags BOTH mistakes — see `./deploy/obscura doctor`.
MIDTRANS_PRODUCTION=true
Cards are the customer's own ceremony. The operator console neither lists the card method nor
can start a card checkout (it 400s): the console exists to generate payment codes an operator reads
to a customer, and a card read over the phone is exactly the practice browser-side tokenisation
exists to end. Every other method is available in both places.
Point Midtrans's Payment Notification URL at https://<apex>/api/v1/billing/notify. The apex is
correct and deliberate: the route is tenant-exempt because the ledger is control-plane.
With no provider configured, /api/v1/billing/notify does not exist (404). A deployment that
cannot receive a legitimate notification should not answer on that path.
Swapping the provider
The port is internal/billing/app/gateway.go. Three decisions make a swap an adapter rather than a
rewrite, and each is a place the naive design leaks:
- Charges in our own UI, and the card number never reaches this server. The payer chooses a
method (virtual account, Mandiri bill, QRIS, e-wallet, card) inside Obscura and we render what
they pay against — Midtrans Core API underneath, no hosted page in the middle of the product.
Cards were added 2026-08-04 and keep the property the earlier "no cards" rule was protecting:
the payer's BROWSER tokenises the number directly against the gateway with the public client
key, we charge the resulting one-time token with 3-D Secure unconditionally on, and the
challenge opens in a new tab (its pages navigate to the card issuer's own domain, which no
frame-srcallowlist can name in advance). What this system ever holds is a token that expires
in minutes and a masked PAN. A hosted-page provider still fits — instructions carry a
redirect_url kind. The port carries one card-shaped field,ChargeRequest.CardToken, passed
through and never stored; everything else stays provider-neutral. - The adapter verifies its own notifications. This is the decisive one. Midtrans signs with
SHA-512 over four body fields plus the server key; Xendit uses a static token in a header.
A port that returned "here is the parsed body, you check it" would put Midtrans's scheme in the
HTTP handler.ParseNotificationtherefore takes the raw request and returns a verdict. - Statuses are ours.
pending | settled | failed | expired | refunded. Midtrans's
capture/settlement/deny/cancel/expireand Xendit'sPAID/EXPIREDare mapped inside
their adapters; nothing above that line learns a provider's words.
A Xendit adapter is finished when it passes adapters/conformance_test.go unchanged. That suite
is written only against the port and already runs against two adapters whose authentication is
deliberately nothing alike.
Deliberately NOT in the port: customers, stored payment methods, or subscriptions held at the
provider. Recurring billing lives here, because the ledger has to survive a provider switch — a
subscription held at Midtrans would have to be rebuilt at Xendit, and during the switch neither
system would be the record.
Money
Integer minor units plus an explicit currency. No float, at any layer.
- IDR's exponent is 0 — the rupiah has no subunit in practice, and Midtrans's
gross_amountis
whole rupiah, so an IDR amount here is the number the customer reads. - An unknown currency is refused, never assumed to be 2dp. That assumption is a silent 100x.
Money.GatewayAmount()converts for a provider, and refuses a sub-unit amount for a gateway
that takes whole units rather than truncating somebody's money.- Monthly periods clamp: 31 Jan + 1 month is 28 Feb (29 in a leap year), not 3 March as Go's
AddDatewould have it. Otherwise a customer's billing date moves permanently.
What being unpaid does — and does not do
| Does | marks the subscription past_due, puts the invoice on the operator's overdue list |
| Does not | suspend the tenant, cancel the subscription, delete anything, block any feature |
Same posture as the expiry sweep (migration 00008) and quotas (00011). A background job that darks a
paying customer over a failed card is the incident nobody asked for. A cancelled subscription
keeps serving to the end of the period already paid for — cutting someone off on the day they
cancel bills them for time they cannot use.
Safety properties, and where they are tested
- One period is billed once.
IssueInvoiceis idempotent on (subscription, period start), which
is what makes the renewal sweep safe to re-run after a crash and safe for an operator to click
twice. —TestIssuingTwiceForOnePeriodBillsOnce - A settlement must match what was billed. A mismatched amount is refused: it means two
deployments sharing a merchant account, a sandbox pointed at production, or a replayed cheaper
charge. —TestASettlementForTheWrongAmountDoesNotPay - One settlement is applied once. Providers retry; three deliveries produce one payment row and
one paid invoice. —TestARepeatedSettlementIsAppliedOnce - A late notification cannot un-pay. Providers deliver out of order. —
TestALateNotification… - Two Pay clicks reuse one checkout, or the provider holds two live charges for one bill. —
TestCheckoutIsReusedWhilePending - An unverified notification is refused before anything is looked up, and every adapter answers
with the same code so a prober learns neither which check failed nor which provider is behind the
endpoint. — the conformance suite
Operator API
All under /api/v1/control, behind the control token.
GET /billing/prices # catalogue + which gateway is configured
PUT /billing/prices # create/update an offer
GET /billing/invoices # the collections list (overdue counted)
GET /tenants/{id}/billing # subscription + invoices
PUT /tenants/{id}/billing/subscription # {"price_id": "..."}
DELETE /tenants/{id}/billing/subscription # stop renewing; serves to period end
POST /tenants/{id}/billing/invoices # bill the current period (idempotent)
GET /billing/methods # what the gateway offers a payer
POST /billing/invoices/{id}/checkout {method} # → payment attempt incl. instructions
POST /billing/invoices/{id}/void
POST /billing/invoices/{id}/manual-payment # {"reference": "BCA 2026-06-14 ref 88213"}
Manual payment exists because a great many Indonesian B2B customers pay by transfer. Without it an
operator's only way to record reality would be to edit a status by hand, which leaves no record of
what actually arrived.
Using it (operator console)
Billing in the side nav is the catalogue and the collections list. Each tenant has a Billing
tab with their subscription and invoices — and that is where every action lives, because issuing,
chasing and voiding are decisions about a specific customer and taking them from a global list means
deciding without the context. The outstanding list therefore links to the tenant rather than
carrying its own Pay button.
- Add a price on the Billing page. Amounts are whole rupiah — the field says so, because
"1500000" and "15000.00" meaning the same money in different currencies is how a price ends up
wrong by 100x. - Subscribe from the tenant's Billing tab. It starts the period and does NOT invoice: the first
bill goes out through the same path as every renewal, so there is only one piece of code that
turns a period into money. - Issue this period is safe to click twice — the same period is only ever billed once.
- Payment details creates the attempt for a chosen method and shows the VA/bill/QR to read out
or send. It is deliberately not paid from here:
the operator is not the payer. Clicking again returns the same link rather than starting a second
charge. - Mark paid records a bank transfer, and asks for the reference — that reference is the only
link between this system saying "paid" and a statement saying so. - Void withdraws a bill that should never have been sent. It cannot be undone; issuing again
produces a new number.
Every act that changes what a customer owes or can pay is recorded in control.operator_audit
against that customer, with the invoice number, so it appears on their own History tab.
Renewal and reconcile — the job queue
Both run on River, Postgres-backed. Its tables live in the control
schema, not public — AssertPublicIsClean refuses to boot a Cloud database with any table in
public, because tenant connections run search_path = <tenant>, public and anything there is
readable from inside every tenant. River's Schema option makes that configuration rather than a
fork. No Redis: this deployment has none, and adding one would be a container, a failure mode and a
backup concern for a modular monolith.
Started only in Cloud, with billing configured, on the worker role. Enterprise starts no
runner — a job runner with nothing to run is a moving part that can only fail.
- Renewal (
billing_renewal) is scheduled AT each period end and chains the next cycle.
Nothing scans the subscription table; a subscription renewing in eleven months costs one row. - Reconcile (
billing_reconcile) is the fallback for a webhook that never arrived. It snoozes
with a growing delay — 1m → 5m → 30m → 2h — until the charge resolves or its checkout expires.
Snooze rather than error: an unpaid invoice is not a failure and must not show as one.
⚠️ The job args name the PERIOD, and that is load-bearing. Two failures found by running it:
- Without it, every renewal of a subscription had identical arguments, so River's unique-by-args
suppressed the job each cycle queues for the next — deduplicated against the very job inserting
it. Renewal ran once and then stopped, silently, forever. - Without it, a REDELIVERED job (at-least-once is the queue's contract) billed an extra period,
because the worker advanced "whatever period the subscription is on now" a second time. The
unique index stops two invoices for one period; it cannot stop a job stepping forward twice.
Failure posture: a queue failure at BOOT is fatal — nothing would ever renew and nobody would
know. A queue failure at ENQUEUE is logged and swallowed — a subscription with no job queued is
recoverable by hand, whereas refusing to subscribe somebody because a scheduler hiccuped is an
outage caused by the scheduler.
Dunning
An invoice arms its own reminder sequence when it is issued: on the due date, then +3, +7, +14
days, then it stops. Each step is a separate job carrying its step number.
- It stops on its own when the invoice is no longer open — paid, voided, written off. There is
no "cancel the reminders" path because it is not needed. - It stops for good after the fourth. A person deciding what to do about a customer who has
ignored four reminders is the right next step, not a fifth. - A redelivered job does not chase twice. The send is CLAIM-then-send against a unique
(invoice, step)row (migration 00014). A crash between claim and send loses one reminder, which
is the right way round: a missing chase is invisible, a duplicate is something a customer mentions. - Step 1 also labels the subscription
past_due— one job does "this is now overdue", the label
and the letter, so nothing can drift from anything else about what overdue means.
⚠️ It sends through the DEPLOYMENT's relay, never the customer's own. The context carries no
tenant, so the SMTP resolver falls back to the operator's relay. Binding the tenant would push a
demand for payment through the customer's own mail server, and would fail outright for a customer
with no relay configured or one who has been suspended — disproportionately the customer being
chased.
The letter escalates in tone but not in facts, links the customer's own billing page when there is
a gateway (a
chase with no way to pay is an accusation rather than an invitation), and never threatens
suspension — nothing here suspends anybody for non-payment, and implying otherwise is a promise
the software will not keep.
Tax (PPN) and the Indonesian invoice
BILLING_TAX_RATE_BP=1100 # BASIS POINTS. 1100 = 11%. 0 (the default) = charge no tax.
BILLING_TAX_LABEL=PPN
BILLING_SELLER_NAME="PT ..." # required once a rate is set
BILLING_SELLER_NPWP="01.234..." # required once a rate is set
BILLING_SELLER_ADDRESS="..."
⚠️ The rate defaults to ZERO and is not hardcoded. Indonesian PPN has moved recently and the law
has used a DPP nilai lain mechanism where the headline and effective rates differ — confirm the
current rate and your PKP status with your tax advisor. This software applies the number it is
given; it does not know the law.
Zero by default because the mistakes are not symmetric: charging tax you should not have means
holding money that belongs to somebody else, while not charging tax you should have is a number you
correct going forward. An implausible rate (110000 for 11%) and a missing seller NPWP are refused
at boot.
The rate is frozen onto each invoice. Recomputing an old invoice from today's configuration
would silently restate what a customer was charged the next time the rate moves.
🔴 This system does NOT issue a Faktur Pajak
It issues a commercial invoice — the document the customer pays against — and the document says
so on its face: "Dokumen ini bukan Faktur Pajak. Faktur Pajak diterbitkan terpisah melalui
e-Faktur."
A Faktur Pajak carries a serial number (NSFP) issued by DJP and is raised through the government's
own e-Faktur application. Nothing outside e-Faktur can mint one, and a document that looked like one
would be worse than useless: the customer's accountant would claim input tax against it and hear
about it from their tax office.
So the split is — this system bills and records everything needed to raise the matching Faktur Pajak
(both NPWPs, the DPP, the tax), and stores its number once it exists so the two reconcile. The buyer
identity comes from the tenant's verification (KYB) record, which already holds the legal name,
NPWP and address, checked by a person.
PPh 23 withholding
Indonesian B2B customers routinely withhold 2% PPh 23 on services and remit it themselves, so the
money that arrives is short of the invoice by design and the customer is not in arrears. The
invoice asks for the bukti potong, and control.payments records the withheld amount and slip
reference so such an invoice can settle without anyone chasing a customer who has paid.
Presentation
Rupiah are grouped with dots — Rp 1.500.000. Written with commas an Indonesian reader sees a
decimal, which on this invoice is a factor of a thousand. Labels are bilingual (Indonesian first),
because the payer's finance team works in Indonesian while the contract may be in English.
What a tenant sees (Administration → Billing)
Their subscription, their invoices, the invoice document, and a Pay button. That is the whole
list — issuing, voiding, marking paid and cancelling belong to the provider, and the panel says
who to contact rather than hiding buttons that do not exist.
⚠️ Its one real risk: the ledger is control-plane, so ONE table holds every customer's invoices and
the id in the URL comes from the caller. The tenant binding is not enough on its own — every
route checks ownership and answers 404 rather than 403, so it cannot be used to discover which
invoice ids exist. TestATenantCannotReachAnotherTenantsInvoice covers it, and fails when the check
is removed.
The River dependency
Reviewed 2026-07-31 before building further on it.
Clean: no telemetry, no outbound network, no unsafe/cgo/os/exec; govulncheck reports
nothing affecting us; ~27k lines of non-test Go, small enough to fork if it were ever abandoned;
Postgres-only, so no Redis.
Three things to know:
- 🔴 MPL-2.0 — the only copyleft licence in the tree (every other dependency is MIT/Apache/BSD).
Linking is explicitly fine for a proprietary Larger Work (§3.3), so Obscura ships normally. The
obligation attaches to MODIFIED FILES — and this repo has an established habit of vendoring and
patching (third_party/pdfsigncarries three patches).TestMPLDependenciesAreNotVendored
enforces the rule so it is not rediscovered by accident months from now. - ⚠️ testify, go-spew, go-difflib and a YAML parser are linked into the production binary
(+2.6 MB, 18 testify symbols). River'sproducer.go— real running code — imports
rivershared/testsignal, which importsriversharedtest, which importstestify/require. Not a
vulnerability; it is test-framework code in a shipped artifact, reachable only via a test-signal
path. Worth raising upstream. - ⚠️ Pre-1.0 (v0.41.1). No API-stability promise; pin the version and read the changelog before
upgrading. Its own go.mod requires Go 1.25.
Not built yet
- Per-customer negotiated price. One live price per (plan, currency, interval), and subscribing
to an archived one is refused, so a tender price or a bespoke deal still means a new plan code —
which copies modules and pollutes the drift report. The reseller CHANNEL discount is built (a
percentage on the partner); this is the other half. See PRICING.md §12 #3. - e-Faktur integration. The Faktur Pajak number is recorded by hand once the operator raises it;
nothing talks to e-Faktur.
Built since this document last said otherwise
Kept here because this file previously listed them as missing, and somebody planning work off it
would have planned work that is done:
- Proration. An add-on attached mid-period is billed for the remainder of the period, as an
off-cycle invoice with no subscription id (migration 00029 +ProrateTenant). It is NOT a plan
change — changing the base plan is still cancel-and-resubscribe. - Renewal, dunning and tenant-facing invoices. All three exist: the renewal is a scheduled job
per subscription, dunning is a finite four-step chase, and/me/billingshows a customer their
own invoices with online payment. - Trials, multi-year terms, partner discounts and AI metering. See PRICING.md §12.