Skip to main content

ADR 0013: Publish every monetary field as a money object

Context

The contract modelled money four ways. ActivityView carried Money objects; QuoteView mixed Money and NonNegativeMoney objects; AccountView, CardView, and CardTransactionView carried bare decimal strings beside a sibling currency field; MandateView carried five bare decimals under one currency constant. The same fact — an amount in a currency — had object and bare-decimal structures inside one API, and the generated contract published two names for one schema (Money-Input and Money-Output) because the decimal types declared a serialization-only JSON schema. Domain E of the contract architecture plan adds purchase amounts that can settle at zero, so the shape of an observed amount had to be settled before that surface exists.

Decision

Every monetary response field is a money object. No bare decimal beside a sibling currency survives in a response, and a regression test holds every bare decimal in the generated contract to a named allowlist. Three money schemas exist, chosen per field by what the value can be:
  • Money (amount > 0) where zero is impossible: caps, limits, requested amounts. MandateView publishes its five caps as Money objects and drops the currency constant that governed five siblings.
  • NonNegativeMoney (amount >= 0) for every observed or settled amount: account balances (nullable — null means the provider reports no such balance), card balances, card transaction fees, and activity amounts. ActivityView.amount is an unsigned magnitude whose direction is carried by kind, now a closed Literal over the adapter’s seven-value map.
  • SignedMoney (any finite decimal) for card ledger entries only: a card transaction amount carries its direction in the sign and has no kind field to move it to. Forcing it through an unsigned schema would delete a fact.
The decimal types declare one JSON schema for both validation and serialization, so the generated contract carries exactly one schema per money type. The account normalizer fails closed on negative balances, and the activity projection’s published identifier is keyed on the immutable transaction hash when one exists, so the identifier no longer changes when the provider later assigns a row id. This record also clarifies ADR 0002’s scope: its decision enumerates the provider-neutral models — account, beneficiary, quote, transfer, mandate, card, operation, error — and the Perflo connection lifecycle was never among them. perflo-connections, PerfloConnectionView, PerfloCapabilitiesView, and OnboardingView.perflo_* keep their names because the customer is authorizing Perflo specifically and consenting to it by name. No superseding ADR is required.

Consequences

AccountView, CardView, CardTransactionView, MandateView, and ActivityView all changed shape, and both generated OpenAPI documents and the web consumers changed with them. Web formatting reads money.amount and money.currency from one object instead of pairing two fields by convention. The mandate projection wraps its stored bare-decimal caps into United States dollar money objects in an explicit view function; the stored columns are unchanged. Card rows still persist a flat balance and currency pair; the card view owns the published shape. Request bodies keep bare decimals where the currency is fixed by rule: MandateCreate’s five caps and MandateExecutionCreate.amount are Perflo cash United States dollar amounts by construction, while QuoteCreate.source already carries a Money object because there the customer chooses the currency. The published request schemas now document the string form only; JSON numbers are still accepted at runtime. The activity identifier is stable for a row first observed by hash that later gains a provider id. A row first observed with an id that later gains a hash would still change its identifier; the observed provider behaviour makes that direction theoretical, and the in-page deduplication treats id and hash as one identity space either way.

Rejected alternatives

Bare decimals with a documented sibling-currency convention: the convention is invisible to the type system, and the two shapes had already diverged inside one API. Two money schemas with card amounts forced to unsigned magnitudes: card transactions carry direction in the sign and have no kind vocabulary, so the unsigned form deletes information. Money safety outranks shape coherence. A single signed money schema everywhere: it would stop the type system from rejecting a negative cap or balance at the boundary, weakening validation to gain uniformity.