Skip to content

Commit 9945f03

Browse files
committed
feat: EU VAT, US routing, loan/EMI, format helpers, NestJS pipes; security: input guards and bounds
Made-with: Cursor
1 parent 7c6ebde commit 9945f03

32 files changed

Lines changed: 926 additions & 122 deletions

README.md

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,15 @@ finprim is the open source version of what your team has already written three t
2020
- ✅ UK sort code and account number validation
2121
- ✅ BIC/SWIFT validation
2222
- ✅ Card number validation (Luhn, network detection, formatting)
23+
- ✅ EU VAT number format validation (member states)
24+
- ✅ US ABA routing number validation
25+
- ✅ Loan/EMI calculation and schedule
26+
- ✅ Format-only helpers (IBAN, sort code, account number) for display
2327
- ✅ Currency validation and formatting with locale support
2428
- ✅ Branded types for compile-time correctness
2529
- ✅ Zod schemas out of the box
2630
- ✅ Optional React hooks for form inputs
31+
- ✅ Optional NestJS validation pipes
2732
- ✅ Zero dependencies at the core
2833
- ✅ Tree-shakeable ESM and CJS builds
2934
- ✅ Fully typed
@@ -56,6 +61,13 @@ import {
5661
validateBIC,
5762
validateCardNumber,
5863
validateCurrencyCode,
64+
validateEUVAT,
65+
validateUSRoutingNumber,
66+
formatIBAN,
67+
formatSortCode,
68+
formatUKAccountNumber,
69+
calculateEMI,
70+
getLoanSchedule,
5971
} from 'finprim'
6072

6173
const iban = validateIBAN('GB29NWBK60161331926819')
@@ -69,6 +81,19 @@ const account = validateUKAccountNumber('31926819')
6981

7082
const card = validateCardNumber('4532015112830366')
7183
// { valid: true, value: '...', formatted: '4532 0151 1283 0366', network: 'Visa', last4: '0366' }
84+
85+
const vat = validateEUVAT('DE123456789')
86+
// { valid: true, value: 'DE123456789', formatted: 'DE 123456789', countryCode: 'DE' }
87+
88+
const routing = validateUSRoutingNumber('021000021')
89+
// { valid: true, value: '021000021', formatted: '021000021' }
90+
91+
formatIBAN('GB29NWBK60161331926819') // 'GB29 NWBK 6016 1331 9268 19'
92+
formatSortCode('601613') // '60-16-13'
93+
formatUKAccountNumber('31926819') // '3192 6819'
94+
95+
const emi = calculateEMI(100_000, 10, 12) // monthly payment
96+
const schedule = getLoanSchedule(100_000, 10, 12) // array of { month, payment, principal, interest, balance }
7297
```
7398

7499
### Currency Formatting
@@ -101,7 +126,7 @@ if (iban.valid) {
101126
### Zod Schemas
102127

103128
```ts
104-
import { ibanSchema, sortCodeSchema, accountNumberSchema, currencySchema } from 'finprim/zod'
129+
import { ibanSchema, sortCodeSchema, accountNumberSchema, currencySchema, vatSchema, routingNumberSchema } from 'finprim/zod'
105130

106131
const PaymentSchema = z.object({
107132
iban: ibanSchema,
@@ -132,6 +157,20 @@ function PaymentForm() {
132157
}
133158
```
134159

160+
### NestJS Pipes
161+
162+
```ts
163+
import { IbanValidationPipe, SortCodeValidationPipe, createValidationPipe } from 'finprim/nest'
164+
import { validateIBAN } from 'finprim'
165+
166+
@Get('iban/:iban')
167+
findByIban(@Param('iban', IbanValidationPipe) iban: string) {
168+
return this.service.findByIban(iban)
169+
}
170+
171+
const MyPipe = createValidationPipe(validateIBAN)
172+
```
173+
135174
---
136175

137176
## API Reference
@@ -146,8 +185,25 @@ function PaymentForm() {
146185
| `validateCurrencyCode(input)` | `string` | `ValidationResult<CurrencyCode>` |
147186
| `validateBIC(input)` | `string` | `ValidationResult<BIC>` |
148187
| `validateCardNumber(input)` | `string` | `CardValidationResult` (includes `network`, `last4` when valid) |
188+
| `validateEUVAT(input)` | `string` | `VATValidationResult` (includes `countryCode` when valid) |
189+
| `validateUSRoutingNumber(input)` | `string` | `ValidationResult<RoutingNumber>` |
149190

150-
### Formatting
191+
### Formatting & display
192+
193+
| Function | Input | Returns |
194+
|----------|-------|---------|
195+
| `formatIBAN(input)` | `string` | `string` (space-separated, no validation) |
196+
| `formatSortCode(input)` | `string` | `string` (XX-XX-XX) |
197+
| `formatUKAccountNumber(input)` | `string` | `string` (XXXX XXXX) |
198+
199+
### Loan
200+
201+
| Function | Input | Returns |
202+
|----------|-------|---------|
203+
| `calculateEMI(principal, annualRatePercent, months)` | `number`, `number`, `number` | `number` |
204+
| `getLoanSchedule(principal, annualRatePercent, months)` | `number`, `number`, `number` | `LoanScheduleEntry[]` |
205+
206+
### Formatting (currency)
151207

152208
| Function | Input | Returns |
153209
|----------|-------|---------|
@@ -165,6 +221,7 @@ Validation results include a `formatted` string when valid (e.g. IBAN and card n
165221
| `finprim` | Core validators and formatters | none |
166222
| `finprim/zod` | Zod schemas | `zod` |
167223
| `finprim/react` | React hooks | `react` |
224+
| `finprim/nest` | NestJS validation pipes | `@nestjs/common` |
168225

169226
---
170227

@@ -182,8 +239,11 @@ Validation results include a `formatted` string when valid (e.g. IBAN and card n
182239

183240
- [x] SWIFT / BIC validation
184241
- [x] Luhn algorithm for card number validation
185-
- [ ] EU VAT number validation
186-
- [ ] NestJS pipe integration
242+
- [x] EU VAT number validation
243+
- [x] NestJS pipe integration
244+
- [x] US routing number validation
245+
- [x] Loan/EMI calculation
246+
- [x] Format-only helpers
187247
- [ ] More locale coverage
188248

189249
---
@@ -202,6 +262,15 @@ npm run dev
202262

203263
---
204264

265+
## Security
266+
267+
- **Input length**: All string validators reject input longer than 256 characters to limit memory and CPU use.
268+
- **Type checking**: Validators require non-empty strings; numeric helpers (e.g. loan/currency) require finite numbers and sane bounds.
269+
- **No sensitive logging**: The library does not log or persist input; use it in a way that avoids logging full card or account numbers.
270+
- **Format helpers**: `formatIBAN`, `formatSortCode`, and `formatUKAccountNumber` cap input length and accept only strings to avoid abuse.
271+
272+
---
273+
205274
## License
206275

207276
MIT

chatbot/cli.ts

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
1-
import * as readline from 'readline'
2-
import path from "path";
1+
import * as readline from 'node:readline'
2+
import path from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import * as dotenv from 'dotenv'
35
import OpenAI from 'openai'
4-
import { validateIBAN } from 'finprim'
5-
import { validateUKSortCode, validateUKAccountNumber } from 'finprim'
6-
import { fileURLToPath } from "url";
6+
import { validateIBAN, validateUKSortCode, validateUKAccountNumber } from 'finprim'
77

8-
import * as dotenv from "dotenv";
9-
const __filename = fileURLToPath(import.meta.url);
10-
const __dirname = path.dirname(__filename);
8+
const __filename = fileURLToPath(import.meta.url)
9+
const __dirname = path.dirname(__filename)
1110

12-
dotenv.config({
13-
path: path.join(__dirname, ".env"),
14-
});
11+
dotenv.config({ path: path.join(__dirname, '.env') })
1512

16-
console.log('API Key:', process.env.OPENAI_API_KEY)
13+
const apiKey = process.env.OPENAI_API_KEY
14+
if (!apiKey) {
15+
console.error('Missing OPENAI_API_KEY. Set it in .env or the environment.')
16+
process.exit(1)
17+
}
1718

18-
const openai = new OpenAI({
19-
apiKey: process.env.OPENAI_API_KEY
20-
})
19+
const openai = new OpenAI({ apiKey })
2120

2221
const tools: OpenAI.Tool[] = [
2322
{

demo/src/App.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { Header } from './components/Header.tsx'
2-
import { Footer } from './components/Footer.tsx'
3-
import { IBANSection } from './components/IBANSection.tsx'
4-
import { UKPaymentSection } from './components/UKPaymentSection.tsx'
5-
import { CreditCardSection } from './components/CreditCardSection.tsx'
6-
import { CurrencySection } from './components/CurrencySection.tsx'
1+
import { Header } from './components/Header'
2+
import { Footer } from './components/Footer'
3+
import { IBANSection } from './components/IBANSection'
4+
import { UKPaymentSection } from './components/UKPaymentSection'
5+
import { CreditCardSection } from './components/CreditCardSection'
6+
import { CurrencySection } from './components/CurrencySection'
77

88
export default function App() {
99
return (

demo/src/components/CreditCardSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useCallback } from 'react'
22
import { useCreditCardInput } from 'finprim/react'
33
import type { CardIssuer } from 'finprim'
4-
import { FieldGroup } from './FieldGroup.tsx'
4+
import { FieldGroup } from './FieldGroup'
55

66
function issuerLabel(issuer: CardIssuer): string {
77
switch (issuer) {

demo/src/components/CurrencySection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState } from 'react'
22
import { useCurrencyInput } from 'finprim/react'
33
import type { SupportedCurrency } from 'finprim'
4-
import { FieldGroup } from './FieldGroup.tsx'
4+
import { FieldGroup } from './FieldGroup'
55

66
const CURRENCIES: SupportedCurrency[] = ['GBP', 'EUR', 'USD', 'JPY', 'CHF', 'CAD', 'AUD', 'NZD']
77

demo/src/components/IBANSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useCallback } from 'react'
22
import { useIBANInput } from 'finprim/react'
3-
import { FieldGroup } from './FieldGroup.tsx'
3+
import { FieldGroup } from './FieldGroup'
44

55
export function IBANSection() {
66
const { formatted, valid, error, onChange: hookChange } = useIBANInput()

demo/src/components/UKPaymentSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useCallback } from 'react'
22
import { useSortCodeInput, useAccountNumberInput } from 'finprim/react'
3-
import { FieldGroup } from './FieldGroup.tsx'
3+
import { FieldGroup } from './FieldGroup'
44

55
function fakeEvent(value: string): React.ChangeEvent<HTMLInputElement> {
66
return { target: { value } } as React.ChangeEvent<HTMLInputElement>

demo/src/main.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { StrictMode } from 'react'
22
import { createRoot } from 'react-dom/client'
33
import './index.css'
4-
import App from './App.tsx'
4+
import App from './App'
55

66
createRoot(document.getElementById('root')!).render(
77
<StrictMode>

example/web/src/App.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ const EXAMPLES: FinprimExample[] = [
4444
];
4545

4646
export default function App() {
47-
const [activeId, setActiveId] = useState<string>(EXAMPLES[0].id);
48-
const activeExample = EXAMPLES.find((ex) => ex.id === activeId);
47+
const [activeId, setActiveId] = useState<string>(EXAMPLES[0].id)
48+
const activeExample = EXAMPLES.find((ex) => ex.id === activeId)
49+
const ActiveComponent = activeExample?.component
4950

5051
return (
5152
<div className="flex min-h-screen bg-gray-50 text-gray-900">
@@ -74,7 +75,7 @@ export default function App() {
7475
</header>
7576

7677
<ExampleCard>
77-
{activeExample ? <activeExample.component /> : <p>Select an example</p>}
78+
{ActiveComponent ? <ActiveComponent /> : <p>Select an example</p>}
7879
</ExampleCard>
7980
</main>
8081
</div>

example/web/src/LoanCalculator.tsx

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,63 @@
1-
import { useState } from 'react';
2-
// import { calculateEMI } from 'finprim'; // Uncomment when ready
1+
import { useState } from 'react'
2+
import { calculateEMI } from 'finprim'
33

4-
export const LoanCalculator = () => {
5-
const [principal, setPrincipal] = useState(10000);
6-
const [rate, setRate] = useState(5);
7-
const [months, setMonths] = useState(12);
8-
// const [result, setResult] = useState<number | null>(null);
4+
export function LoanCalculator() {
5+
const [principal, setPrincipal] = useState(10000)
6+
const [rate, setRate] = useState(5)
7+
const [months, setMonths] = useState(12)
8+
const [result, setResult] = useState<number | null>(null)
99

1010
const handleCalculate = () => {
11-
// TODO: Replace with finprim logic
12-
// const emi = calculateEMI(principal, rate, months);
13-
// setResult(emi);
14-
alert("finprim logic goes here!");
15-
};
11+
const emi = calculateEMI(principal, rate, months)
12+
setResult(emi)
13+
}
1614

1715
return (
1816
<div className="flex max-w-md flex-col gap-5">
1917
<div className="flex flex-col gap-2">
2018
<label className="text-sm font-semibold text-gray-700">Principal Amount ($)</label>
21-
<input
22-
type="number"
23-
value={principal}
24-
onChange={(e) => setPrincipal(Number(e.target.value))}
25-
className="rounded-lg border border-gray-300 p-2.5 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
19+
<input
20+
type="number"
21+
value={principal}
22+
onChange={(e) => setPrincipal(Number(e.target.value))}
23+
className="rounded-lg border border-gray-300 p-2.5 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
2624
/>
2725
</div>
2826

2927
<div className="flex gap-4">
3028
<div className="flex flex-col gap-2 flex-1">
3129
<label className="text-sm font-semibold text-gray-700">Rate (%)</label>
32-
<input
33-
type="number"
34-
value={rate}
35-
onChange={(e) => setRate(Number(e.target.value))}
36-
className="rounded-lg border border-gray-300 p-2.5 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
30+
<input
31+
type="number"
32+
value={rate}
33+
onChange={(e) => setRate(Number(e.target.value))}
34+
className="rounded-lg border border-gray-300 p-2.5 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
3735
/>
3836
</div>
3937
<div className="flex flex-col gap-2 flex-1">
4038
<label className="text-sm font-semibold text-gray-700">Months</label>
41-
<input
42-
type="number"
43-
value={months}
44-
onChange={(e) => setMonths(Number(e.target.value))}
45-
className="rounded-lg border border-gray-300 p-2.5 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
39+
<input
40+
type="number"
41+
value={months}
42+
onChange={(e) => setMonths(Number(e.target.value))}
43+
className="rounded-lg border border-gray-300 p-2.5 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
4644
/>
4745
</div>
4846
</div>
4947

50-
<button
51-
onClick={handleCalculate}
48+
<button
49+
onClick={handleCalculate}
5250
className="mt-2 rounded-lg bg-indigo-600 px-4 py-3 font-bold text-white transition-colors hover:bg-indigo-700"
5351
>
5452
Calculate Payment
5553
</button>
56-
57-
{/* Result Card */}
54+
5855
<div className="mt-4 rounded-xl border border-indigo-100 bg-indigo-50 p-6 text-center">
5956
<p className="text-sm font-medium text-indigo-800">Monthly Payment</p>
6057
<p className="mt-1 text-4xl font-black text-indigo-900">
61-
{/* {result !== null ? `$${result.toFixed(2)}` : '$0.00'} */}
58+
{result !== null ? `$${result.toFixed(2)}` : '$0.00'}
6259
</p>
6360
</div>
6461
</div>
65-
);
66-
};
62+
)
63+
}

0 commit comments

Comments
 (0)