Your Finance Stack Is Lying to You
Accidental Complexity in Fintech
Posted: 2025-05-22Your Finance Stack Is Lying to You
Why Untyped Data, Floats, and “Excel Thinking” Are Quietly Costing You Millions
At a recent fintech conference, I was struck by how much engineering effort, across both startups and enterprises, is spent cleaning up avoidable messes. Not building competitive advantages. Just damage control:
- Rounding errors in reports
- Mismatched payment records
- Inconsistent handling of currency and tax
- Broken reconciliation across systems
Entire teams, and even companies, exist just to fix these issues. The root cause?
Untyped, context-free data.
The Excel Legacy That Won’t Die
Most financial systems still think like spreadsheets. In Excel, 100 can mean anything: euros, dollars, cents, basis points. There’s no schema. No units. No context.
That thinking leaks into production systems:
- APIs with vague fields like amount, value, or fee
- float(8) columns for monetary values
- Boolean values stored as "yes"/"no"
- Timestamps with no timezone
- Magic numbers passed around without meaning
It might be fast to prototype. But it creates long-term ambiguity, and ambiguity compounds into bugs, delays, and failures.
Essential vs Accidental Complexity
Essential complexity is built into the (financial) domain itself, like multiple currencies, tax rules, compliance steps.
Accidental complexity is everything we add on top: floats for money, ambiguous JSON fields, brittle one-off scripts. This article shows how accidental complexity quietly overwhelms the essential.
It’s Worse in Big Companies
This isn’t just a startup problem.
Large enterprises, such as banks, insurance firms, payment providers, layer complex architectures on top of the same assumptions.
To deal with the resulting chaos, they adopt:
- ESBs (Enterprise Service Buses) to transform one vague schema into another
- iPaaS (Integration Platform as a Service) tools to sync inconsistent fields across internal platforms
- Custom middleware and manual workarounds just to keep systems aligned
The irony? Many of these systems exist solely to translate "value": 100 into another format of "value": 100.
It’s not domain complexity. It’s accidental complexity, caused by a lack of enforced structure.
B2B Invoicing Is Still Email and PDFs
If you think we’ve moved past this, look at B2B invoicing.
In 2025, most companies still send and receive invoices via PDFs attached to emails.
Some are exported from ERPs. Others are typed manually in Word. On the receiving end:
- Amounts are extracted with OCR or regex
- PO numbers are verified manually
- Payment terms and VAT rates are interpreted via email follow-ups
Even with e-invoicing formats like Peppol or Factur-X available, adoption is partial and enforcement is weak. Most systems fall back to parsing visual representations of meaning, not actual structured data.
This leads to:
- Duplicate or late payments
- Misrouted funds
- Fraud via spoofed invoices
- Manual reconciliation loops that delay accounting closes
We’re throwing machine learning and automation at a problem that starts with: Why are we still emailing pictures of invoices?
Floats, the Silent Saboteurs
A particularly nasty example: using floats for money.
0.1 + 0.2 != 0.3
It’s the reason your payment gateway and your ledger might silently disagree.
Using floating-point types for financial calculations leads to:
- Rounding errors
- Reconciliation drift
- Bugs that only surface under load, scale, or edge cases
Use integers for minor units (e.g. cents) or proper decimal types. Never float.
LLMs Are Not Helping
Large language models like ChatGPT can now write your APIs, schemas, and database models.
But unless you’re careful, they’ll replicate the same bad patterns. Just faster:
- amount: number with no context
- float instead of decimal
- JSON blobs without units, validation, or structure
LLMs autocomplete based on frequency, not correctness. Without strong engineering guardrails, you’re just mass-producing technical debt.
Databases and the Illusion of Structure
Even when your application code is well-typed, your database schema often isn’t. Relational databases may enforce column types (float, int, varchar), but they rarely capture meaning.
A column named amount might hold:
- Gross or net values
- In different currencies
- As float or decimal
- With or without tax
- In cents or full units
Unless you enforce units and context explicitly, via naming conventions, type systems, or documentation, your schema becomes a silent source of ambiguity.
And in NoSQL databases like MongoDB, it gets worse.
Mongo stores everything as JSON-like documents. There’s no enforced schema unless you layer one on top. So teams end up storing arbitrary fields like:
{ "value": 99.99 }
What is value here? Dollars? Euros? A discount rate? A tax multiplier? Your system won’t tell you. It just stores whatever someone wrote last week.
And since Mongo supports flexible updates, it’s easy to gradually evolve documents into inconsistent messes. One document has "amount": "100", another has amount: 100.0, and a third has:
{
"amount": { "value": 10000,
"currency": "USD",
"unit": "cents" }
}
Now your query logic needs to branch, and bugs start to breed.
JSON: Ubiquitous and Ambiguous
JSON is everywhere: in APIs, logs, NoSQL databases, Kafka streams, and config files. But it doesn’t support real numbers.
JSON has one “number” type. There’s no distinction between:
- Integer or float
- Decimal or exponential
- Currency, percent, duration, or count
This means:
- "amount": 99.99 could be a float (in JS), a Decimal (in Python), or a BigDecimal (in Java)
- Many serializers silently round, truncate, or lose precision
- You can’t even tell if 99.99 is a valid business value without knowing the intended type
Worse, many languages parse JSON numbers as floats by default. That means even if you store exact values, your application might read them imprecisely.
You can’t trust what you can’t type.
What You Should Do Instead
Typed systems are better systems.
1. Make units explicit
To encode 100.00 Euro:
{
"amount": 10000,
"currency": "EUR",
"minor_unit": true
}
2. Use the right types
- Replace float with decimal
- Use real bool and datetime types
- Never store “truth” as "yes" or "Y"
3. Model the domain
- Create types like VatRate, TransactionAmount, IsoTimestamp
- Don’t let illegal states be representable
4. Validate at the edges
- APIs should reject ambiguity, not propagate it
- Fail fast when types or units are missing
Type Theory, Briefly
Types aren’t just for compilers. They encode meaning. They help humans and machines distinguish between:
- 100 SEK vs 100%
- net_total vs gross_total
- sent_at vs due_date
Use types if you have them
In languages with strong type systems you can make these distinctions explicit:
// ISO 4217 currency codes (could use an enum)
type CurrencyCode = 'USD' | 'EUR' | 'GBP' | 'JPY';
interface Money {
// always in the smallest currency unit
// (e.g. cents, pence)
amountMinor: number;
currency: CurrencyCode;
}
When done well, the compiler helps you prevent bugs. Your system becomes self-documenting and harder to misuse.
Fake types if you don't
Even in dynamically typed languages, you can fake it:
- Use structs, classes, or schema validators
- Separate field names with intent (e.g. gross_eur_cents)
- Build linter rules that enforce domain conventions
An example in Erlang
%%%-----------------------------------------------------------------
%%% money.hrl — record definition
%%%-----------------------------------------------------------------
-record(money, {
amount_minor :: integer(), % always in minor units (cents, öre)
currency :: currency() % ISO-4217 atom
}).
-type currency() :: usd | eur | gbp | sek.
-export_type([currency/0, money/0]).
-type money() :: #money{}.
%%%-----------------------------------------------------------------
%%% money.erl
%%%-----------------------------------------------------------------
-module(money).
-compile([export_all]). % concise for the demo
-include("money.hrl").
-spec new(integer(), currency()) -> money().
new(AmountMinor, Cur) when
is_integer(AmountMinor),
AmountMinor >= 0,
Cur =:= usd; Cur =:= eur; Cur =:= gbp; Cur =:= sek ->
#money{amount_minor = AmountMinor, currency = Cur}.
-spec add(money(), money()) -> money().
add(#money{currency = C, amount_minor = A1},
#money{currency = C, amount_minor = A2}) ->
#money{currency = C, amount_minor = A1 + A2};
add(_, _) ->
error({currency_mismatch}).
-spec format(money()) -> binary().
format(#money{amount_minor = Amt, currency = Cur}) ->
Major = Amt div 100,
Minor = Amt rem 100,
lists:flatten(io_lib:format("~p.~2..0B ~p", [Major, Minor, Cur])).
Or at least pretend
Naming–as–Type, when you really have nothing else. If the tech stack is a bash script, SQL view, or low-code platform, fall back to semantic variable names:
ALTER TABLE invoices
ADD COLUMN gross_eur_cents BIGINT NOT NULL,
ADD COLUMN vat_rate_pct NUMERIC(5,2) NOT NULL;
Now, in most modern SQL dialects you can use domains and composite types to achieve type safety.
-- 1. A domain for minor-unit amounts (cents, öre, pence …)
CREATE DOMAIN minor_money
AS bigint -- large enough for 9 223 372 036 854 775 807 cents
CHECK (VALUE >= 0);
-- 2. A domain for currency codes (exactly three uppercase A–Z letters)
CREATE DOMAIN currency_code
AS char(3)
CHECK (VALUE ~ '^[A-Z]{3}$');
-- 3. A composite type that glues the two together
CREATE TYPE money_value AS (
amount_minor minor_money,
currency currency_code
);
-- 4. Example table using the new type
CREATE TABLE invoice_line (
id serial PRIMARY KEY,
description text NOT NULL,
total_net money_value NOT NULL,
vat_amount money_value NOT NULL,
total_gross money_value NOT NULL
);
INSERT INTO invoice_line (description, total_net, vat_amount, total_gross)
VALUES
('10 kg mozzarella',
ROW(100000, 'EUR')::money_value, -- €1 000.00 net
ROW( 25000, 'EUR')::money_value, -- € 250.00 VAT
ROW(125000, 'EUR')::money_value); -- €1 250.00 gross
The Real Problem
Modern languages make it easy to move fast without structure. That’s fine in early prototypes. But financial systems are long-lived. Without structure, every layer you build sits on sand.
The cost? Every ambiguous field becomes a future incident. Every vague type becomes a blocker to automation.
You can pay that cost now; through type safety and clear structure. Or you can pay it later; through failed audits, reconciliation hell, and broken trust.
Performance Myths
If you're concerned about throughput or message size: remember that using JSON or XML already dwarfs the overhead of a type‑safe add(Money) function.
Machine‑level addition is fast, but the bugs caused by mixing currencies or losing precision will cost far more.
Closing Thought
Finance runs on trust. Trust requires clarity. Clarity requires types.
If you’re still shipping Excel-style data into your APIs, databases, and ledgers then you’re shipping risk.
Typed data isn’t a nice-to-have. It’s a trust contract.
I barely scratched the surface here. If you’d like deeper material, whether a full book or a hands-on course, please let me know.
Need a second pair of eyes on your data contracts or payment architecture? Let’s talk → info@happihacking.se
Happi Hacking AB
KIVRA: 556912-2707
106 31 Stockholm