Initial implementation: Brazil SWIFT Operations Platform
- Complete monorepo structure with pnpm workspaces and Turborepo - All packages implemented: types, utils, rules-engine, iso20022, treasury, risk-models, audit - React web application with TypeScript and Tailwind CSS - Full Brazil regulatory compliance (BCB requirements) - ISO 20022 message support (pacs.008, pacs.009, pain.001) - Treasury and subledger management - Risk, capital, and liquidity stress allocation - Audit logging and BCB reporting - E&O +10% uplift implementation
This commit is contained in:
130
packages/utils/src/currency.ts
Normal file
130
packages/utils/src/currency.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Currency conversion and USD equivalent calculation utilities
|
||||
*/
|
||||
|
||||
import Decimal from 'decimal.js';
|
||||
|
||||
export interface ExchangeRate {
|
||||
fromCurrency: string;
|
||||
toCurrency: string;
|
||||
rate: number;
|
||||
effectiveDate: Date;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface CurrencyConverter {
|
||||
convert(amount: number, fromCurrency: string, toCurrency: string, date?: Date): number;
|
||||
getUSDEquivalent(amount: number, currency: string, date?: Date): number;
|
||||
getRate(fromCurrency: string, toCurrency: string, date?: Date): number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple in-memory currency converter with configurable rates
|
||||
* In production, this would integrate with a real-time FX rate service
|
||||
*/
|
||||
export class SimpleCurrencyConverter implements CurrencyConverter {
|
||||
private rates: Map<string, ExchangeRate> = new Map();
|
||||
private defaultUSDRates: Record<string, number> = {
|
||||
USD: 1.0,
|
||||
BRL: 0.2,
|
||||
EUR: 1.1,
|
||||
GBP: 1.27,
|
||||
};
|
||||
|
||||
constructor(initialRates?: ExchangeRate[]) {
|
||||
if (initialRates) {
|
||||
initialRates.forEach((rate) => this.addRate(rate));
|
||||
}
|
||||
this.initializeDefaultRates();
|
||||
}
|
||||
|
||||
private initializeDefaultRates(): void {
|
||||
const now = new Date();
|
||||
Object.entries(this.defaultUSDRates).forEach(([currency, rate]) => {
|
||||
if (currency !== 'USD') {
|
||||
this.addRate({
|
||||
fromCurrency: currency,
|
||||
toCurrency: 'USD',
|
||||
rate,
|
||||
effectiveDate: now,
|
||||
source: 'default',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addRate(rate: ExchangeRate): void {
|
||||
const key = this.getRateKey(rate.fromCurrency, rate.toCurrency);
|
||||
this.rates.set(key, rate);
|
||||
const inverseKey = this.getRateKey(rate.toCurrency, rate.fromCurrency);
|
||||
this.rates.set(inverseKey, {
|
||||
...rate,
|
||||
fromCurrency: rate.toCurrency,
|
||||
toCurrency: rate.fromCurrency,
|
||||
rate: 1 / rate.rate,
|
||||
});
|
||||
}
|
||||
|
||||
private getRateKey(fromCurrency: string, toCurrency: string): string {
|
||||
return `${fromCurrency}:${toCurrency}`;
|
||||
}
|
||||
|
||||
getRate(fromCurrency: string, toCurrency: string, date?: Date): number | null {
|
||||
if (fromCurrency === toCurrency) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
const key = this.getRateKey(fromCurrency, toCurrency);
|
||||
const rate = this.rates.get(key);
|
||||
|
||||
if (!rate) {
|
||||
if (fromCurrency !== 'USD' && toCurrency !== 'USD') {
|
||||
const fromToUSD = this.getRate(fromCurrency, 'USD', date);
|
||||
const usdToTo = this.getRate('USD', toCurrency, date);
|
||||
if (fromToUSD && usdToTo) {
|
||||
return fromToUSD * usdToTo;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return rate.rate;
|
||||
}
|
||||
|
||||
convert(amount: number, fromCurrency: string, toCurrency: string, date?: Date): number {
|
||||
if (fromCurrency === toCurrency) {
|
||||
return amount;
|
||||
}
|
||||
|
||||
const rate = this.getRate(fromCurrency, toCurrency, date);
|
||||
if (rate === null) {
|
||||
throw new Error(
|
||||
`No exchange rate available for ${fromCurrency} to ${toCurrency}`
|
||||
);
|
||||
}
|
||||
|
||||
const amountDecimal = new Decimal(amount);
|
||||
const rateDecimal = new Decimal(rate);
|
||||
return amountDecimal.mul(rateDecimal).toNumber();
|
||||
}
|
||||
|
||||
getUSDEquivalent(amount: number, currency: string, date?: Date): number {
|
||||
if (currency === 'USD') {
|
||||
return amount;
|
||||
}
|
||||
return this.convert(amount, currency, 'USD', date);
|
||||
}
|
||||
}
|
||||
|
||||
let defaultConverter: CurrencyConverter | null = null;
|
||||
|
||||
export function getDefaultConverter(): CurrencyConverter {
|
||||
if (!defaultConverter) {
|
||||
defaultConverter = new SimpleCurrencyConverter();
|
||||
}
|
||||
return defaultConverter;
|
||||
}
|
||||
|
||||
export function setDefaultConverter(converter: CurrencyConverter): void {
|
||||
defaultConverter = converter;
|
||||
}
|
||||
83
packages/utils/src/dates.ts
Normal file
83
packages/utils/src/dates.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Date utilities for effective date logic and rolling windows
|
||||
*/
|
||||
|
||||
import { addDays, isAfter, isBefore, isWithinInterval, subDays } from 'date-fns';
|
||||
|
||||
export function isEffectiveDate(date: Date, effectiveDate: Date, expiryDate?: Date): boolean {
|
||||
if (isBefore(date, effectiveDate)) {
|
||||
return false;
|
||||
}
|
||||
if (expiryDate && isAfter(date, expiryDate)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface RollingWindow {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
days: number;
|
||||
}
|
||||
|
||||
export function calculateRollingWindow(
|
||||
referenceDate: Date,
|
||||
windowDays: number
|
||||
): RollingWindow {
|
||||
const startDate = subDays(referenceDate, windowDays);
|
||||
return {
|
||||
startDate,
|
||||
endDate: referenceDate,
|
||||
days: windowDays,
|
||||
};
|
||||
}
|
||||
|
||||
export function isWithinRollingWindow(
|
||||
date: Date,
|
||||
window: RollingWindow
|
||||
): boolean {
|
||||
return isWithinInterval(date, {
|
||||
start: window.startDate,
|
||||
end: window.endDate,
|
||||
});
|
||||
}
|
||||
|
||||
export function filterDatesInWindow(
|
||||
dates: Date[],
|
||||
window: RollingWindow
|
||||
): Date[] {
|
||||
return dates.filter((date) => isWithinRollingWindow(date, window));
|
||||
}
|
||||
|
||||
export function calculateRetentionExpiry(
|
||||
creationDate: Date,
|
||||
retentionDays: number
|
||||
): Date {
|
||||
return addDays(creationDate, retentionDays);
|
||||
}
|
||||
|
||||
export function shouldArchive(
|
||||
creationDate: Date,
|
||||
archivalAfterDays: number,
|
||||
currentDate: Date = new Date()
|
||||
): boolean {
|
||||
const archivalDate = addDays(creationDate, archivalAfterDays);
|
||||
return isAfter(currentDate, archivalDate);
|
||||
}
|
||||
|
||||
export function shouldDelete(
|
||||
creationDate: Date,
|
||||
retentionDays: number,
|
||||
currentDate: Date = new Date()
|
||||
): boolean {
|
||||
const expiryDate = calculateRetentionExpiry(creationDate, retentionDays);
|
||||
return isAfter(currentDate, expiryDate);
|
||||
}
|
||||
|
||||
export function formatISO20022Date(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export function formatISO20022DateTime(date: Date): string {
|
||||
return date.toISOString().split('.')[0];
|
||||
}
|
||||
128
packages/utils/src/eo-uplift.ts
Normal file
128
packages/utils/src/eo-uplift.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Errors & Omissions (E&O) Uplift calculator
|
||||
*
|
||||
* E&O uplift is a +10% exposure buffer applied to transaction amounts
|
||||
* to account for errors and omissions outside of direct operations.
|
||||
*
|
||||
* Treatment: Off-balance-sheet, non-booked risk buffer
|
||||
*/
|
||||
|
||||
import Decimal from 'decimal.js';
|
||||
import type { EOUplift, TransactionEOUplift, BatchEOUplift } from '@brazil-swift-ops/types';
|
||||
|
||||
export const DEFAULT_EO_UPLIFT_RATE = 0.10; // 10%
|
||||
|
||||
/**
|
||||
* Calculate E&O uplift for a single transaction
|
||||
*/
|
||||
export function calculateTransactionEOUplift(
|
||||
baseAmount: number,
|
||||
currency: string,
|
||||
upliftRate: number = DEFAULT_EO_UPLIFT_RATE,
|
||||
usdEquivalent?: number
|
||||
): TransactionEOUplift {
|
||||
// Use Decimal.js for precise calculations
|
||||
const baseDecimal = new Decimal(baseAmount);
|
||||
const rateDecimal = new Decimal(upliftRate);
|
||||
const upliftDecimal = baseDecimal.mul(rateDecimal);
|
||||
const adjustedDecimal = baseDecimal.add(upliftDecimal);
|
||||
|
||||
const upliftAmount = upliftDecimal.toNumber();
|
||||
const adjustedExposure = adjustedDecimal.toNumber();
|
||||
|
||||
// Calculate USD equivalent for uplift if provided
|
||||
let upliftUsdEquivalent: number | undefined;
|
||||
if (usdEquivalent !== undefined) {
|
||||
const usdBaseDecimal = new Decimal(usdEquivalent);
|
||||
const usdUpliftDecimal = usdBaseDecimal.mul(rateDecimal);
|
||||
upliftUsdEquivalent = usdBaseDecimal.add(usdUpliftDecimal).toNumber();
|
||||
}
|
||||
|
||||
return {
|
||||
transactionId: '', // Will be set by caller
|
||||
baseAmount,
|
||||
currency,
|
||||
upliftRate,
|
||||
upliftAmount,
|
||||
adjustedExposure,
|
||||
usdEquivalent: upliftUsdEquivalent,
|
||||
treatment: 'off_balance_sheet',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate E&O uplift for a batch of transactions
|
||||
*/
|
||||
export function calculateBatchEOUplift(
|
||||
baseAmount: number,
|
||||
currency: string,
|
||||
transactionCount: number,
|
||||
upliftRate: number = DEFAULT_EO_UPLIFT_RATE,
|
||||
usdEquivalent?: number
|
||||
): BatchEOUplift {
|
||||
const baseDecimal = new Decimal(baseAmount);
|
||||
const rateDecimal = new Decimal(upliftRate);
|
||||
const upliftDecimal = baseDecimal.mul(rateDecimal);
|
||||
const adjustedDecimal = baseDecimal.add(upliftDecimal);
|
||||
|
||||
const upliftAmount = upliftDecimal.toNumber();
|
||||
const adjustedExposure = adjustedDecimal.toNumber();
|
||||
|
||||
// Calculate USD equivalent for uplift if provided
|
||||
let upliftUsdEquivalent: number | undefined;
|
||||
if (usdEquivalent !== undefined) {
|
||||
const usdBaseDecimal = new Decimal(usdEquivalent);
|
||||
const usdUpliftDecimal = usdBaseDecimal.mul(rateDecimal);
|
||||
upliftUsdEquivalent = usdBaseDecimal.add(usdUpliftDecimal).toNumber();
|
||||
}
|
||||
|
||||
return {
|
||||
batchId: '', // Will be set by caller
|
||||
baseAmount,
|
||||
currency,
|
||||
transactionCount,
|
||||
upliftRate,
|
||||
upliftAmount,
|
||||
adjustedExposure,
|
||||
usdEquivalent: upliftUsdEquivalent,
|
||||
treatment: 'off_balance_sheet',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate E&O uplift for a simple amount (no transaction context)
|
||||
*/
|
||||
export function calculateEOUplift(
|
||||
baseAmount: number,
|
||||
upliftRate: number = DEFAULT_EO_UPLIFT_RATE
|
||||
): EOUplift {
|
||||
const baseDecimal = new Decimal(baseAmount);
|
||||
const rateDecimal = new Decimal(upliftRate);
|
||||
const upliftDecimal = baseDecimal.mul(rateDecimal);
|
||||
const adjustedDecimal = baseDecimal.add(upliftDecimal);
|
||||
|
||||
return {
|
||||
baseAmount,
|
||||
upliftRate,
|
||||
upliftAmount: upliftDecimal.toNumber(),
|
||||
adjustedExposure: adjustedDecimal.toNumber(),
|
||||
treatment: 'off_balance_sheet',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply E&O uplift to an array of transaction amounts
|
||||
*/
|
||||
export function applyEOUpliftToAmounts(
|
||||
amounts: number[],
|
||||
upliftRate: number = DEFAULT_EO_UPLIFT_RATE
|
||||
): { baseAmount: number; upliftAmount: number; adjustedExposure: number }[] {
|
||||
return amounts.map((amount) => {
|
||||
const uplift = calculateEOUplift(amount, upliftRate);
|
||||
return {
|
||||
baseAmount: uplift.baseAmount,
|
||||
upliftAmount: uplift.upliftAmount,
|
||||
adjustedExposure: uplift.adjustedExposure,
|
||||
};
|
||||
});
|
||||
}
|
||||
10
packages/utils/src/index.ts
Normal file
10
packages/utils/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @brazil-swift-ops/utils
|
||||
*
|
||||
* Shared utilities for the Brazil SWIFT Operations Platform
|
||||
*/
|
||||
|
||||
export * from './currency';
|
||||
export * from './dates';
|
||||
export * from './validation';
|
||||
export * from './eo-uplift';
|
||||
151
packages/utils/src/validation.ts
Normal file
151
packages/utils/src/validation.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Brazilian ID validation utilities (CPF/CNPJ)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate CPF (Cadastro de Pessoa Física) format and checksum
|
||||
* CPF format: XXX.XXX.XXX-XX (11 digits)
|
||||
*/
|
||||
export function validateCPF(cpf: string): { valid: boolean; formatted?: string; error?: string } {
|
||||
// Remove non-numeric characters
|
||||
const cleaned = cpf.replace(/\D/g, '');
|
||||
|
||||
// Check length
|
||||
if (cleaned.length !== 11) {
|
||||
return { valid: false, error: 'CPF must have 11 digits' };
|
||||
}
|
||||
|
||||
// Check for invalid patterns (all same digits)
|
||||
if (/^(\d)\1{10}$/.test(cleaned)) {
|
||||
return { valid: false, error: 'CPF cannot have all same digits' };
|
||||
}
|
||||
|
||||
// Validate checksum digits
|
||||
let sum = 0;
|
||||
let remainder: number;
|
||||
|
||||
// Validate first check digit
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
sum += parseInt(cleaned.substring(i - 1, i)) * (11 - i);
|
||||
}
|
||||
remainder = (sum * 10) % 11;
|
||||
if (remainder === 10 || remainder === 11) remainder = 0;
|
||||
if (remainder !== parseInt(cleaned.substring(9, 10))) {
|
||||
return { valid: false, error: 'Invalid CPF checksum (first digit)' };
|
||||
}
|
||||
|
||||
// Validate second check digit
|
||||
sum = 0;
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
sum += parseInt(cleaned.substring(i - 1, i)) * (12 - i);
|
||||
}
|
||||
remainder = (sum * 10) % 11;
|
||||
if (remainder === 10 || remainder === 11) remainder = 0;
|
||||
if (remainder !== parseInt(cleaned.substring(10, 11))) {
|
||||
return { valid: false, error: 'Invalid CPF checksum (second digit)' };
|
||||
}
|
||||
|
||||
// Format CPF
|
||||
const formatted = `${cleaned.substring(0, 3)}.${cleaned.substring(3, 6)}.${cleaned.substring(6, 9)}-${cleaned.substring(9, 11)}`;
|
||||
|
||||
return { valid: true, formatted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CNPJ (Cadastro Nacional da Pessoa Jurídica) format and checksum
|
||||
* CNPJ format: XX.XXX.XXX/XXXX-XX (14 digits)
|
||||
*/
|
||||
export function validateCNPJ(cnpj: string): { valid: boolean; formatted?: string; error?: string } {
|
||||
// Remove non-numeric characters
|
||||
const cleaned = cnpj.replace(/\D/g, '');
|
||||
|
||||
// Check length
|
||||
if (cleaned.length !== 14) {
|
||||
return { valid: false, error: 'CNPJ must have 14 digits' };
|
||||
}
|
||||
|
||||
// Check for invalid patterns (all same digits)
|
||||
if (/^(\d)\1{13}$/.test(cleaned)) {
|
||||
return { valid: false, error: 'CNPJ cannot have all same digits' };
|
||||
}
|
||||
|
||||
// Validate checksum digits
|
||||
let length = cleaned.length - 2;
|
||||
let numbers = cleaned.substring(0, length);
|
||||
const digits = cleaned.substring(length);
|
||||
let sum = 0;
|
||||
let pos = length - 7;
|
||||
|
||||
// Validate first check digit
|
||||
for (let i = length; i >= 1; i--) {
|
||||
sum += parseInt(numbers.charAt(length - i)) * pos--;
|
||||
if (pos < 2) pos = 9;
|
||||
}
|
||||
let result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
|
||||
if (result !== parseInt(digits.charAt(0))) {
|
||||
return { valid: false, error: 'Invalid CNPJ checksum (first digit)' };
|
||||
}
|
||||
|
||||
// Validate second check digit
|
||||
length = length + 1;
|
||||
numbers = cleaned.substring(0, length);
|
||||
sum = 0;
|
||||
pos = length - 7;
|
||||
for (let i = length; i >= 1; i--) {
|
||||
sum += parseInt(numbers.charAt(length - i)) * pos--;
|
||||
if (pos < 2) pos = 9;
|
||||
}
|
||||
result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
|
||||
if (result !== parseInt(digits.charAt(1))) {
|
||||
return { valid: false, error: 'Invalid CNPJ checksum (second digit)' };
|
||||
}
|
||||
|
||||
// Format CNPJ
|
||||
const formatted = `${cleaned.substring(0, 2)}.${cleaned.substring(2, 5)}.${cleaned.substring(5, 8)}/${cleaned.substring(8, 12)}-${cleaned.substring(12, 14)}`;
|
||||
|
||||
return { valid: true, formatted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Brazilian tax ID (CPF or CNPJ)
|
||||
*/
|
||||
export function validateBrazilianTaxId(taxId: string): {
|
||||
valid: boolean;
|
||||
type?: 'CPF' | 'CNPJ';
|
||||
formatted?: string;
|
||||
error?: string;
|
||||
} {
|
||||
const cleaned = taxId.replace(/\D/g, '');
|
||||
|
||||
if (cleaned.length === 11) {
|
||||
const cpfResult = validateCPF(taxId);
|
||||
return {
|
||||
...cpfResult,
|
||||
type: cpfResult.valid ? 'CPF' : undefined,
|
||||
};
|
||||
} else if (cleaned.length === 14) {
|
||||
const cnpjResult = validateCNPJ(taxId);
|
||||
return {
|
||||
...cnpjResult,
|
||||
type: cnpjResult.valid ? 'CNPJ' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Tax ID must be 11 digits (CPF) or 14 digits (CNPJ)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tax ID for display (CPF or CNPJ)
|
||||
*/
|
||||
export function formatTaxId(taxId: string): string {
|
||||
const cleaned = taxId.replace(/\D/g, '');
|
||||
if (cleaned.length === 11) {
|
||||
return `${cleaned.substring(0, 3)}.${cleaned.substring(3, 6)}.${cleaned.substring(6, 9)}-${cleaned.substring(9, 11)}`;
|
||||
} else if (cleaned.length === 14) {
|
||||
return `${cleaned.substring(0, 2)}.${cleaned.substring(2, 5)}.${cleaned.substring(5, 8)}/${cleaned.substring(8, 12)}-${cleaned.substring(12, 14)}`;
|
||||
}
|
||||
return taxId;
|
||||
}
|
||||
Reference in New Issue
Block a user