If you've worked on a payment product, a banking app, or anything involving money, you've written this code before.
An IBAN validator you copied from Stack Overflow. A Luhn check for card numbers someone found in a gist. A currency formatter that works in en-US but breaks for de-DE. A sort code regex that someone on the team "maintains." A loan calculator buried in a utility file no one touches.
Every fintech team builds this stuff. Then rebuilds it. Then inherits the third version and tries to figure out why it exists.
I built finprim to fix this.
What finprim is
finprim is a zero-dependency TypeScript library that handles the financial primitives every app needs:
- IBAN validation (80+ countries) and formatting
- BIC/SWIFT validation
- UK sort code and account number validation
- Card number validation (Luhn algorithm + network detection)
- EU VAT number validation
- US ABA routing number validation
- Loan/EMI calculations with full amortization schedules
- Multi-locale currency formatting and parsing
One library. Consistent API. Fully typed.
npm install finprim
import { validateIBAN, validateCardNumber, formatCurrency } from 'finprim'
validateIBAN('GB29NWBK60161331926819')
// { valid: true, value: 'GB29NWBK60161331926819', formatted: 'GB29 NWBK 6016 1331 9268 19', countryCode: 'GB' }
validateCardNumber('4532015112830366')
// { valid: true, formatted: '4532 0151 1283 0366', network: 'Visa', last4: '0366' }
formatCurrency(1000.5, 'EUR', 'de-DE')
// '1.000,50 €'
No config. No setup. Just import and use.
The TypeScript part you'll actually care about
Here's where it gets good. Every validator returns a discriminated union:
type ValidationResult<T> =
| { valid: true; value: T; formatted: string }
| { valid: false; error: string }
And the values are branded types — not just strings:
type IBAN = string & { __brand: 'IBAN' }
type SortCode = string & { __brand: 'SortCode' }
This means TypeScript will catch you if you try to pass an unvalidated string where a validated IBAN is expected:
function processPayment(iban: IBAN, amount: number) {
// ...
}
// This won't compile — userInput is just a string
processPayment(userInput, 100) // Error!
// You must validate first
const result = validateIBAN(userInput)
if (result.valid) {
processPayment(result.value, 100) // result.value is typed as IBAN
}
This is the kind of thing that prevents entire categories of production bugs.
Works with your existing stack
The core is zero-dependency. But if you're using Zod, React, or NestJS, there are optional integrations:
Zod
import { z } from 'zod'
import { ibanSchema, sortCodeSchema, currencySchema } from 'finprim/zod'
const PaymentSchema = z.object({
iban: ibanSchema,
sortCode: sortCodeSchema,
amount: z.number().positive(),
currency: currencySchema,
})
React hooks for form inputs
import { useIBANInput, useCardNumberInput } from 'finprim/react'
function PaymentForm() {
const iban = useIBANInput()
const card = useCardNumberInput()
return (
<form>
<input
value={iban.value}
onChange={iban.onChange}
aria-invalid={iban.valid === false}
placeholder="GB29 NWBK 6016 1331 9268 19"
/>
<input
value={card.formatted}
onChange={card.onChange}
aria-invalid={card.valid === false}
placeholder="4532 0151 1283 0366"
/>
</form>
)
}
The hook formats as you type and validates in real time.
NestJS pipes
import { IbanValidationPipe } from 'finprim/nest'
@Get('account/:iban')
findByIban(@Param('iban', IbanValidationPipe) iban: string) {
return this.service.findByIban(iban)
}
Loan calculations
Financial apps often need EMI calculations. This one's baked in:
import { calculateEMI, getLoanSchedule } from 'finprim'
// Monthly payment on a £100,000 loan at 10% APR over 12 months
const monthly = calculateEMI(100_000, 10, 12)
// Full amortization schedule
const schedule = getLoanSchedule(100_000, 10, 12)
// [{ month: 1, payment: 8791.59, principal: 8124.92, interest: 833.33, balance: 91875.08 }, ...]
Production-grade by default
A few things that matter in real apps:
- All string validators reject inputs longer than 256 chars (no unbounded input processing)
- Numeric helpers validate bounds (no
Infinity, noNaN) - The library never logs or persists input data
- Format helpers are safe to call without validation (useful for display-only flows)
Try it
npm install finprim
- npm: npmjs.com/package/finprim
- GitHub: github.com/tintolee/finprim
If it saves you time, a star on GitHub goes a long way. And if you find an edge case or want to add a validator, PRs are open.
Top comments (1)
This is a real problem - everyone writes their own version of 'is this a valid currency amount' and they all have slightly different edge cases. The Dinero.js approach is solid but I've seen teams get burned by library version drift when the library's assumptions about precision don't match the payment processor's. What's your take on handling currency conversion in the same lib vs keeping it separate?