billing
billing owns money: plans, subscriptions, prepaid credit, usage metering, and
the webhooks that come in from payment processors. It is written in Ruby
because it is pure domain modelling — state machines, integers in minor units,
idempotent webhook handlers — and that is Ruby’s home turf.
Money is integers, and the refusals are the feature
Section titled “Money is integers, and the refusals are the feature”app/models/money.rb is the seed of the platform-wide rule that money is
integer minor units and never a Float. A Float cannot represent 0.10, so it
never carries an amount. Rounding is never silent, because a rounded price is a
bug that surfaces in somebody’s invoice.
Money.new(10.5, "USD") #=> raises Money::InvalidAmountErrorMoney.from_major("10.505", "USD") #=> raises: would have to roundMoney.from_major("10.5", "JPY") #=> raises: JPY has no minor unitMoney.new(1, "USD") + Money.new(1, "EUR") #=> raises Money::CurrencyMismatchErrorMoney.new(100, "USD") - Money.new(250, "USD") #=> raises: a negative by subtractionA price crosses the wire as {"price":{"amount_minor":1900,"currency":"USD"}}.
19.00 and "19.00" are both 422 — the first has already lost the fact that
it was nineteen dollars exactly, the second would mean this API has two
representations for an amount.
Three properties that are already load-bearing
Section titled “Three properties that are already load-bearing”Webhook ingestion is idempotent by construction. A UNIQUE index on
stripe_event_id turns a replay into a lookup rather than a second
billing.payment.succeeded with a second envelope id — which is the only way a
consumer deduplicating on that id can tell a duplicate from new information
about money.
Out-of-order delivery is handled. A deletion or an update arriving before its
creation is accepted, and each event keeps its own event time from the payload’s
created rather than its arrival time. The mapping is therefore derived from
(did the row exist, what was it, what is it now) — which is why an updated
delivery that turns out to be a cancellation publishes a cancellation.
billing.payment.succeeded has two payload shapes, because it is emitted
from two sources. Invoice-backed carries invoice_id and subscription_id;
Checkout-backed carries checkout_session_id. Core’s schema encodes that as a
oneOf so exactly one is present, and a consumer never has to guess which fields
to expect.
Its dependencies
Section titled “Its dependencies”billing subscribes to no events yet and declares no service dependencies.
The customers it creates reference identity’s users and accounts by uuid, which
is a data dependency, not a call — declaring a dependencies entry for a
service you never call would tell cafe to start it for nothing.
billing.payment.refunded, billing.invoice.created, billing.usage.recorded
and billing.subscription.past_due are in core’s catalog and are not
published. Arrears, for instance, are reported as a change to the subscription’s
status in billing.subscription.updated rather than as a second event; whether a
failed charge is one event or two is a question about what a consumer does with
them, and billing.payment.failed already states it.
Money code is held to a stricter bar than the rest of the platform
Section titled “Money code is held to a stricter bar than the rest of the platform”Integer minor units only, 100% branch coverage on money paths, idempotency tests
for every webhook handler that replay the same event twice, and state-machine
tests covering every subscription transition, legal and illegal. An
Idempotency-Key middleware is required on any money-mutating request, so a
client retry cannot charge twice.
Two comparisons in the plan-change logic are refused rather than guessed: different currencies, because FX is a pricing decision; and different intervals, because ten dollars a month against a hundred a year is not a cheaper plan, it is a different unit.
The gate
Section titled “The gate”bin/prime # bundle install, db:prepare, rubocop, rails testmise run security # gem audit and Brakemanbin/prime prepares the test database itself, so there is no second tier to
miss — but it does need a reachable Postgres. Running the
gates.
- Repository: github.com/cafaye/billing
- Language: Ruby
- Namespace / event source:
billing— publishes eight event types - Container port: 80 (3000 in development)