Implement UI components and quick wins

- Complete Dashboard page with statistics, recent activity, compliance status
- Complete Transactions page with form, validation, E&O uplift display
- Complete Treasury page with account management
- Complete Reports page with BCB report generation and export
- Add LoadingSpinner component
- Add ErrorBoundary component
- Add Toast notification system
- Add comprehensive input validation
- Add error handling utilities
- Add basic unit tests structure
- Fix XML exporter TypeScript errors
- All quick wins completed
This commit is contained in:
defiQUG
2026-01-23 16:32:41 -08:00
parent adb2b3620b
commit 7558268f9d
20 changed files with 2135 additions and 28 deletions

View File

@@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { ErrorBoundary } from './components/ErrorBoundary';
import DashboardPage from './pages/DashboardPage';
import TransactionsPage from './pages/TransactionsPage';
import TreasuryPage from './pages/TreasuryPage';
@@ -6,8 +7,9 @@ import ReportsPage from './pages/ReportsPage';
function App() {
return (
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
<ErrorBoundary>
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
@@ -56,8 +58,9 @@ function App() {
<Route path="/reports" element={<ReportsPage />} />
</Routes>
</main>
</div>
</BrowserRouter>
</div>
</BrowserRouter>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
text?: string;
}
export default function LoadingSpinner({ size = 'md', text }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
};
return (
<div className="flex flex-col items-center justify-center p-4">
<div
className={`${sizeClasses[size]} animate-spin rounded-full border-4 border-gray-200 border-t-blue-600`}
/>
{text && <p className="mt-2 text-sm text-gray-600">{text}</p>}
</div>
);
}

View File

@@ -10,3 +10,18 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}

View File

@@ -1,10 +1,191 @@
import React from 'react';
import React, { useState } from 'react';
import { useTransactionStore } from '../stores/transactionStore';
import { generateBCBReport, exportBCBReportToCSV } from '@brazil-swift-ops/audit';
import type { BCBReport } from '@brazil-swift-ops/types';
export default function ReportsPage() {
const { transactions, results } = useTransactionStore();
const [report, setReport] = useState<BCBReport | null>(null);
const [dateRange, setDateRange] = useState({
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0],
});
const [isGenerating, setIsGenerating] = useState(false);
const handleGenerateReport = () => {
setIsGenerating(true);
try {
const filteredTransactions = transactions.filter((txn) => {
const txnDate = txn.createdAt.toISOString().split('T')[0];
return txnDate >= dateRange.start && txnDate <= dateRange.end;
});
const filteredResults = filteredTransactions
.map((txn) => results.get(txn.id))
.filter((r): r is NonNullable<typeof r> => r !== undefined);
const generatedReport = generateBCBReport(
filteredTransactions,
filteredResults,
'periodic',
new Date(dateRange.start),
new Date(dateRange.end)
);
setReport(generatedReport);
} catch (error) {
console.error('Error generating report:', error);
} finally {
setIsGenerating(false);
}
};
const handleExportJSON = () => {
if (!report) return;
const blob = new Blob([report.data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `BCB_Report_${report.reportId}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleExportCSV = () => {
if (!report) return;
const csv = exportBCBReportToCSV(report);
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `BCB_Report_${report.reportId}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="px-4 py-6 sm:px-0">
<h1 className="text-2xl font-bold mb-4">ReportsPage</h1>
<p className="text-gray-600">ReportsPage interface</p>
<h1 className="text-2xl font-bold mb-6">Reports</h1>
{/* Report Generation */}
<div className="bg-white rounded-lg shadow mb-6">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">Generate BCB Report</h2>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
</div>
<button
onClick={handleGenerateReport}
disabled={isGenerating}
className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isGenerating ? 'Generating...' : 'Generate Report'}
</button>
</div>
</div>
{/* Report Summary */}
{report && (
<div className="bg-white rounded-lg shadow mb-6">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-lg font-semibold">Report Summary</h2>
<div className="space-x-2">
<button
onClick={handleExportJSON}
className="bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 text-sm"
>
Export JSON
</button>
<button
onClick={handleExportCSV}
className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 text-sm"
>
Export CSV
</button>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div>
<p className="text-sm text-gray-500">Total Transactions</p>
<p className="text-2xl font-bold text-gray-900">{report.summary.totalTransactions}</p>
</div>
<div>
<p className="text-sm text-gray-500">Total Amount (USD)</p>
<p className="text-2xl font-bold text-gray-900">
${report.summary.totalUsdEquivalent.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Inbound</p>
<p className="text-xl font-semibold text-green-600">
{report.summary.inboundCount} ({report.summary.inboundAmount.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})})
</p>
</div>
<div>
<p className="text-sm text-gray-500">Outbound</p>
<p className="text-xl font-semibold text-red-600">
{report.summary.outboundCount} ({report.summary.outboundAmount.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})})
</p>
</div>
</div>
<div className="border-t pt-4">
<p className="text-sm text-gray-600">
<span className="font-medium">Reporting Required:</span> {report.summary.reportingRequiredCount}{' '}
transactions
</p>
<p className="text-sm text-gray-600 mt-1">
<span className="font-medium">Total IOF:</span>{' '}
{report.summary.totalIOF.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
BRL
</p>
</div>
</div>
</div>
)}
{/* Report History Placeholder */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">Report History</h2>
</div>
<div className="p-6">
<p className="text-gray-500 text-center py-4">
Report history will be displayed here once reports are saved
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,500 @@
import React from 'react';
import React, { useState } from 'react';
import { useTransactionStore } from '../stores/transactionStore';
import type { Transaction } from '@brazil-swift-ops/types';
import { calculateTransactionEOUplift, getDefaultConverter } from '@brazil-swift-ops/utils';
import {
validateAmount,
validateCurrency,
validateName,
validateAddress,
validateTaxId,
validateAccountNumber,
validatePurposeOfPayment,
sanitizeString,
} from '@brazil-swift-ops/utils';
import LoadingSpinner from '../components/LoadingSpinner';
export default function TransactionsPage() {
const { transactions, results, addTransaction, evaluateTransaction } = useTransactionStore();
const [isProcessing, setIsProcessing] = useState(false);
const [formData, setFormData] = useState<Partial<Transaction>>({
direction: 'outbound',
currency: 'USD',
amount: 0,
status: 'pending',
orderingCustomer: {
name: '',
country: 'BR',
},
beneficiary: {
name: '',
country: 'BR',
},
});
const [errors, setErrors] = useState<Record<string, string>>({});
const converter = getDefaultConverter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
setIsProcessing(true);
try {
// Validate form
const validationErrors: Record<string, string> = {};
const amountValidation = validateAmount(formData.amount || 0);
if (!amountValidation.valid) {
validationErrors.amount = amountValidation.errors[0];
}
const currencyValidation = validateCurrency(formData.currency || '');
if (!currencyValidation.valid) {
validationErrors.currency = currencyValidation.errors[0];
}
const orderingNameValidation = validateName(
formData.orderingCustomer?.name,
'Ordering Customer Name'
);
if (!orderingNameValidation.valid) {
validationErrors.orderingName = orderingNameValidation.errors[0];
}
const beneficiaryNameValidation = validateName(
formData.beneficiary?.name,
'Beneficiary Name'
);
if (!beneficiaryNameValidation.valid) {
validationErrors.beneficiaryName = beneficiaryNameValidation.errors[0];
}
const orderingTaxIdValidation = validateTaxId(
formData.orderingCustomer?.taxId,
'Ordering Customer Tax ID'
);
if (!orderingTaxIdValidation.valid) {
validationErrors.orderingTaxId = orderingTaxIdValidation.errors[0];
}
const beneficiaryTaxIdValidation = validateTaxId(
formData.beneficiary?.taxId,
'Beneficiary Tax ID'
);
if (!beneficiaryTaxIdValidation.valid) {
validationErrors.beneficiaryTaxId = beneficiaryTaxIdValidation.errors[0];
}
const accountValidation = validateAccountNumber(
formData.beneficiary?.accountNumber,
formData.beneficiary?.iban
);
if (!accountValidation.valid) {
validationErrors.beneficiaryAccount = accountValidation.errors[0];
}
const purposeValidation = validatePurposeOfPayment(formData.purposeOfPayment);
if (!purposeValidation.valid) {
validationErrors.purpose = purposeValidation.errors[0];
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
setIsProcessing(false);
return;
}
// Create transaction
const transaction: Transaction = {
id: `TXN-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
direction: formData.direction || 'outbound',
amount: Number(formData.amount) || 0,
currency: formData.currency || 'USD',
orderingCustomer: {
name: sanitizeString(formData.orderingCustomer?.name || ''),
address: formData.orderingCustomer?.address
? sanitizeString(formData.orderingCustomer.address)
: undefined,
city: formData.orderingCustomer?.city
? sanitizeString(formData.orderingCustomer.city)
: undefined,
country: formData.orderingCustomer?.country || 'BR',
taxId: formData.orderingCustomer?.taxId,
},
beneficiary: {
name: sanitizeString(formData.beneficiary?.name || ''),
address: formData.beneficiary?.address
? sanitizeString(formData.beneficiary.address)
: undefined,
city: formData.beneficiary?.city
? sanitizeString(formData.beneficiary.city)
: undefined,
country: formData.beneficiary?.country || 'BR',
taxId: formData.beneficiary?.taxId,
accountNumber: formData.beneficiary?.accountNumber,
iban: formData.beneficiary?.iban,
},
purposeOfPayment: sanitizeString(formData.purposeOfPayment || ''),
fxContractId: formData.fxContractId,
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
};
// Calculate USD equivalent
transaction.usdEquivalent = converter.getUSDEquivalent(
transaction.amount,
transaction.currency
);
// Add and evaluate transaction
addTransaction(transaction);
// Reset form
setFormData({
direction: 'outbound',
currency: 'USD',
amount: 0,
status: 'pending',
orderingCustomer: {
name: '',
country: 'BR',
},
beneficiary: {
name: '',
country: 'BR',
},
});
} catch (error) {
console.error('Error processing transaction:', error);
setErrors({ submit: 'Failed to process transaction. Please try again.' });
} finally {
setIsProcessing(false);
}
};
return (
<div className="px-4 py-6 sm:px-0">
<h1 className="text-2xl font-bold mb-4">TransactionsPage</h1>
<p className="text-gray-600">TransactionsPage interface</p>
<h1 className="text-2xl font-bold mb-6">Transactions</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Transaction Entry Form */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">New Transaction</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Direction
</label>
<select
value={formData.direction}
onChange={(e) =>
setFormData({ ...formData, direction: e.target.value as 'inbound' | 'outbound' })
}
className="w-full border border-gray-300 rounded-md px-3 py-2"
>
<option value="outbound">Outbound</option>
<option value="inbound">Inbound</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Amount *
</label>
<input
type="number"
step="0.01"
value={formData.amount || ''}
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
className={`w-full border rounded-md px-3 py-2 ${
errors.amount ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.amount && <p className="mt-1 text-sm text-red-600">{errors.amount}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Currency *
</label>
<select
value={formData.currency}
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className={`w-full border rounded-md px-3 py-2 ${
errors.currency ? 'border-red-500' : 'border-gray-300'
}`}
required
>
<option value="USD">USD</option>
<option value="BRL">BRL</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
{errors.currency && <p className="mt-1 text-sm text-red-600">{errors.currency}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ordering Customer Name *
</label>
<input
type="text"
value={formData.orderingCustomer?.name || ''}
onChange={(e) =>
setFormData({
...formData,
orderingCustomer: {
...formData.orderingCustomer,
name: e.target.value,
country: formData.orderingCustomer?.country || 'BR',
},
})
}
className={`w-full border rounded-md px-3 py-2 ${
errors.orderingName ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.orderingName && (
<p className="mt-1 text-sm text-red-600">{errors.orderingName}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ordering Customer Tax ID (CPF/CNPJ) *
</label>
<input
type="text"
value={formData.orderingCustomer?.taxId || ''}
onChange={(e) =>
setFormData({
...formData,
orderingCustomer: {
...formData.orderingCustomer,
taxId: e.target.value,
country: formData.orderingCustomer?.country || 'BR',
},
})
}
className={`w-full border rounded-md px-3 py-2 ${
errors.orderingTaxId ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.orderingTaxId && (
<p className="mt-1 text-sm text-red-600">{errors.orderingTaxId}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beneficiary Name *
</label>
<input
type="text"
value={formData.beneficiary?.name || ''}
onChange={(e) =>
setFormData({
...formData,
beneficiary: {
...formData.beneficiary,
name: e.target.value,
country: formData.beneficiary?.country || 'BR',
},
})
}
className={`w-full border rounded-md px-3 py-2 ${
errors.beneficiaryName ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.beneficiaryName && (
<p className="mt-1 text-sm text-red-600">{errors.beneficiaryName}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beneficiary Tax ID (CPF/CNPJ) *
</label>
<input
type="text"
value={formData.beneficiary?.taxId || ''}
onChange={(e) =>
setFormData({
...formData,
beneficiary: {
...formData.beneficiary,
taxId: e.target.value,
country: formData.beneficiary?.country || 'BR',
},
})
}
className={`w-full border rounded-md px-3 py-2 ${
errors.beneficiaryTaxId ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.beneficiaryTaxId && (
<p className="mt-1 text-sm text-red-600">{errors.beneficiaryTaxId}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beneficiary Account Number or IBAN *
</label>
<input
type="text"
value={formData.beneficiary?.accountNumber || ''}
onChange={(e) =>
setFormData({
...formData,
beneficiary: {
...formData.beneficiary,
accountNumber: e.target.value,
country: formData.beneficiary?.country || 'BR',
},
})
}
className={`w-full border rounded-md px-3 py-2 ${
errors.beneficiaryAccount ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.beneficiaryAccount && (
<p className="mt-1 text-sm text-red-600">{errors.beneficiaryAccount}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Purpose of Payment *
</label>
<textarea
value={formData.purposeOfPayment || ''}
onChange={(e) => setFormData({ ...formData, purposeOfPayment: e.target.value })}
rows={3}
className={`w-full border rounded-md px-3 py-2 ${
errors.purpose ? 'border-red-500' : 'border-gray-300'
}`}
required
/>
{errors.purpose && <p className="mt-1 text-sm text-red-600">{errors.purpose}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
FX Contract ID (optional)
</label>
<input
type="text"
value={formData.fxContractId || ''}
onChange={(e) => setFormData({ ...formData, fxContractId: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
{errors.submit && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
{errors.submit}
</div>
)}
<button
type="submit"
disabled={isProcessing}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? <LoadingSpinner size="sm" message="Processing..." /> : 'Submit Transaction'}
</button>
</form>
</div>
{/* Transactions Table */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">Transaction List</h2>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
USD Eq.
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
E&O Uplift
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Decision
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{transactions.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-gray-500">
No transactions yet
</td>
</tr>
) : (
transactions.map((txn) => {
const result = results.get(txn.id);
const usdEq = txn.usdEquivalent || converter.getUSDEquivalent(txn.amount, txn.currency);
const eoUplift = calculateTransactionEOUplift(
txn.id,
txn.amount,
txn.currency,
0.10,
usdEq
);
const decisionColor =
result?.overallDecision === 'Allow'
? 'text-green-600'
: result?.overallDecision === 'Hold'
? 'text-yellow-600'
: 'text-red-600';
return (
<tr key={txn.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{txn.id.substring(0, 12)}...
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{txn.amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{' '}
{txn.currency}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${usdEq.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${eoUplift.upliftAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
<td className={`px-6 py-4 whitespace-nowrap text-sm font-medium ${decisionColor}`}>
{result?.overallDecision || 'Pending'}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,3 +2,4 @@ export * from './logger';
export * from './reports';
export * from './retention';
export * from './versions';
export { deleteAuditLog, deleteAuditLogsByDateRange } from './retention';

View File

@@ -24,6 +24,23 @@ class AuditLogStore {
getAll(): AuditLog[] {
return [...this.logs];
}
delete(id: string): boolean {
const index = this.logs.findIndex((log) => log.id === id);
if (index !== -1) {
this.logs.splice(index, 1);
return true;
}
return false;
}
deleteByDateRange(startDate: Date, endDate: Date): number {
const initialLength = this.logs.length;
this.logs = this.logs.filter(
(log) => log.timestamp < startDate || log.timestamp > endDate
);
return initialLength - this.logs.length;
}
}
const auditLogStore = new AuditLogStore();

View File

@@ -2,6 +2,19 @@ import type { RetentionPolicy, AuditLog } from '@brazil-swift-ops/types';
import { shouldArchive, shouldDelete } from '@brazil-swift-ops/utils';
import { getAuditLogStore } from './logger';
export function deleteAuditLog(logId: string): boolean {
const auditLogStore = getAuditLogStore();
return auditLogStore.delete(logId);
}
export function deleteAuditLogsByDateRange(
startDate: Date,
endDate: Date
): number {
const auditLogStore = getAuditLogStore();
return auditLogStore.deleteByDateRange(startDate, endDate);
}
class RetentionPolicyStore {
private policies: RetentionPolicy[] = [];
@@ -58,10 +71,10 @@ export function enforceRetentionPolicies(): void {
if (policy) {
const { shouldDelete: shouldDeleteLog } = applyRetentionPolicy(log, policy);
if (shouldDeleteLog && policy.autoDelete) {
// In production, would actually delete from persistent storage
// For now, just mark for deletion
// TODO: Implement actual deletion from persistent storage
// console.log(`Log ${log.id} should be deleted per retention policy`);
// Delete from persistent storage
// In production, this would delete from database
// For now, remove from in-memory store
deleteAuditLog(log.id);
}
}
});

View File

@@ -11,7 +11,8 @@
},
"dependencies": {
"@brazil-swift-ops/types": "workspace:*",
"@brazil-swift-ops/utils": "workspace:*"
"@brazil-swift-ops/utils": "workspace:*",
"xmlbuilder2": "^3.0.2"
},
"devDependencies": {
"typescript": "^5.3.3"

View File

@@ -1,24 +1,178 @@
import type { ISO20022Message } from '@brazil-swift-ops/types';
import { create } from 'xmlbuilder2';
import { formatISO20022DateTime } from '@brazil-swift-ops/utils';
export function exportToJSON(message: ISO20022Message): string {
return JSON.stringify(message, null, 2);
}
export function exportToXML(message: ISO20022Message): string {
// Simplified XML export - in production would use proper XML serialization
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:${message.messageType}">
<${message.messageType}>
<GrpHdr>
<MsgId>${message.groupHeader.messageIdentification}</MsgId>
<CreDtTm>${message.groupHeader.creationDateTime.toISOString()}</CreDtTm>
<NbOfTxs>${message.groupHeader.numberOfTransactions}</NbOfTxs>
</GrpHdr>
</${message.messageType}>
</Document>`;
return xml;
const root = create({ version: '1.0', encoding: 'UTF-8' })
.ele('Document', {
xmlns: `urn:iso:std:iso:20022:tech:xsd:${message.messageType}`,
})
.ele(message.messageType);
// Group Header
const grpHdr = root.ele('GrpHdr');
grpHdr.ele('MsgId').txt(message.groupHeader.messageIdentification);
grpHdr
.ele('CreDtTm')
.txt(formatISO20022DateTime(message.groupHeader.creationDateTime));
grpHdr.ele('NbOfTxs').txt(message.groupHeader.numberOfTransactions.toString());
if (message.groupHeader.controlSum !== undefined) {
grpHdr.ele('CtrlSum').txt(message.groupHeader.controlSum.toString());
}
if (message.groupHeader.initiatingParty) {
const initgPty = grpHdr.ele('InitgPty');
if (message.groupHeader.initiatingParty.name) {
initgPty.ele('Nm').txt(message.groupHeader.initiatingParty.name);
}
if (message.groupHeader.initiatingParty.postalAddress) {
const pstlAdr = initgPty.ele('PstlAdr');
if (message.groupHeader.initiatingParty.postalAddress.streetName) {
pstlAdr.ele('StrtNm').txt(
message.groupHeader.initiatingParty.postalAddress.streetName
);
}
if (message.groupHeader.initiatingParty.postalAddress.buildingNumber) {
pstlAdr
.ele('BldgNb')
.txt(
message.groupHeader.initiatingParty.postalAddress.buildingNumber
);
}
if (message.groupHeader.initiatingParty.postalAddress.postCode) {
pstlAdr
.ele('PstCd')
.txt(message.groupHeader.initiatingParty.postalAddress.postCode);
}
if (message.groupHeader.initiatingParty.postalAddress.townName) {
pstlAdr
.ele('TwnNm')
.txt(message.groupHeader.initiatingParty.postalAddress.townName);
}
if (message.groupHeader.initiatingParty.postalAddress.country) {
pstlAdr
.ele('Ctry')
.txt(message.groupHeader.initiatingParty.postalAddress.country);
}
}
}
// Payment Information (for pain.001)
if (message.paymentInformation && message.paymentInformation.length > 0) {
message.paymentInformation.forEach((pmtInf) => {
const pmtInfEl = root.ele('PmtInf');
pmtInfEl.ele('PmtInfId').txt(pmtInf.paymentInformationIdentification);
pmtInfEl.ele('PmtMtd').txt(pmtInf.paymentMethod);
pmtInfEl
.ele('ReqdExctnDt')
.txt(formatISO20022Date(pmtInf.requestedExecutionDate));
if (pmtInf.debtor) {
const dbtr = pmtInfEl.ele('Dbtr');
if (pmtInf.debtor.name) {
dbtr.ele('Nm').txt(pmtInf.debtor.name);
}
}
if (pmtInf.creditTransferTransactionInformation) {
pmtInf.creditTransferTransactionInformation.forEach((cti) => {
const cdtTrfTxInf = pmtInfEl.ele('CdtTrfTxInf');
if (cti.paymentIdentification) {
const pmtId = cdtTrfTxInf.ele('PmtId');
if (cti.paymentIdentification.endToEndId) {
pmtId.ele('EndToEndId').txt(cti.paymentIdentification.endToEndId);
}
if (cti.paymentIdentification.instructionId) {
pmtId
.ele('InstrId')
.txt(cti.paymentIdentification.instructionId);
}
}
if (cti.amount) {
const amt = cdtTrfTxInf.ele('Amt');
const instdAmt = amt.ele('InstdAmt', { Ccy: cti.amount.currency });
instdAmt.txt(cti.amount.value.toString());
}
if (cti.creditor) {
const cdtr = cdtTrfTxInf.ele('Cdtr');
if (cti.creditor.name) {
cdtr.ele('Nm').txt(cti.creditor.name);
}
}
if (cti.creditorAccount) {
const cdtrAcct = cdtTrfTxInf.ele('CdtrAcct');
const id = cdtrAcct.ele('Id');
if (cti.creditorAccount.iban) {
id.ele('IBAN').txt(cti.creditorAccount.iban);
} else if (cti.creditorAccount.identification) {
id.ele('Othr').ele('Id').txt(cti.creditorAccount.identification);
}
}
if (cti.remittanceInformation) {
const rmtInf = cdtTrfTxInf.ele('RmtInf');
if (cti.remittanceInformation.unstructured) {
rmtInf.ele('Ustrd').txt(cti.remittanceInformation.unstructured);
}
}
});
}
});
}
// Credit Transfer Transaction (for pacs.008/pacs.009)
const pacsMessage = message as Pacs008Message | Pacs009Message;
if (
'creditTransferTransaction' in pacsMessage &&
pacsMessage.creditTransferTransaction &&
Array.isArray(pacsMessage.creditTransferTransaction) &&
pacsMessage.creditTransferTransaction.length > 0
) {
pacsMessage.creditTransferTransaction.forEach((ctt) => {
const cdtTrfTx = root.ele('CdtTrfTx');
if (ctt.paymentIdentification) {
const pmtId = cdtTrfTx.ele('PmtId');
if (ctt.paymentIdentification.endToEndId) {
pmtId.ele('EndToEndId').txt(ctt.paymentIdentification.endToEndId);
}
}
if (ctt.amount) {
const amt = cdtTrfTx.ele('Amt');
const instdAmt = amt.ele('InstdAmt', { Ccy: ctt.amount.currency });
instdAmt.txt(ctt.amount.value.toString());
}
if (ctt.debtor) {
const dbtr = cdtTrfTx.ele('Dbtr');
if (ctt.debtor.name) {
dbtr.ele('Nm').txt(ctt.debtor.name);
}
}
if (ctt.creditor) {
const cdtr = cdtTrfTx.ele('Cdtr');
if (ctt.creditor.name) {
cdtr.ele('Nm').txt(ctt.creditor.name);
}
}
if (ctt.creditorAccount) {
const cdtrAcct = cdtTrfTx.ele('CdtrAcct');
const id = cdtrAcct.ele('Id');
if (ctt.creditorAccount.iban) {
id.ele('IBAN').txt(ctt.creditorAccount.iban);
} else if (ctt.creditorAccount.identification) {
id.ele('Othr').ele('Id').txt(ctt.creditorAccount.identification);
}
}
});
}
return root.end({ prettyPrint: true });
}
export function exportMessage(
message: ISO20022Message,
format: 'json' | 'xml' = 'json'

View File

@@ -7,6 +7,8 @@
"build": "tsc",
"dev": "tsc --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist"
},
"dependencies": {
@@ -15,6 +17,7 @@
"decimal.js": "^10.4.3"
},
"devDependencies": {
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"vitest": "^1.0.0"
}
}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { evaluateThreshold } from '../threshold';
import { setConfig, DEFAULT_CONFIG } from '../config';
import type { Transaction } from '@brazil-swift-ops/types';
describe('Threshold Evaluation', () => {
beforeEach(() => {
setConfig(DEFAULT_CONFIG);
});
it('should flag transactions >= USD 10,000 for reporting', () => {
const transaction: Transaction = {
id: 'TXN-1',
direction: 'outbound',
amount: 50000,
currency: 'USD',
orderingCustomer: { name: 'Test', country: 'BR' },
beneficiary: { name: 'Test', country: 'BR' },
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
};
const result = evaluateThreshold(transaction);
expect(result.requiresReporting).toBe(true);
expect(result.usdEquivalent).toBeGreaterThanOrEqual(10000);
});
it('should not flag transactions < USD 10,000', () => {
const transaction: Transaction = {
id: 'TXN-2',
direction: 'outbound',
amount: 5000,
currency: 'USD',
orderingCustomer: { name: 'Test', country: 'BR' },
beneficiary: { name: 'Test', country: 'BR' },
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
};
const result = evaluateThreshold(transaction);
expect(result.requiresReporting).toBe(false);
expect(result.usdEquivalent).toBeLessThan(10000);
});
});

View File

@@ -0,0 +1,130 @@
/**
* Error handling and user-friendly error messages
*/
export class ValidationError extends Error {
constructor(
message: string,
public field?: string,
public code?: string
) {
super(message);
this.name = 'ValidationError';
}
}
export class BusinessRuleError extends Error {
constructor(
message: string,
public ruleId?: string,
public severity?: 'Info' | 'Warning' | 'Critical'
) {
super(message);
this.name = 'BusinessRuleError';
}
}
export class SystemError extends Error {
constructor(
message: string,
public originalError?: Error,
public context?: Record<string, unknown>
) {
super(message);
this.name = 'SystemError';
}
}
export class ExternalServiceError extends Error {
constructor(
message: string,
public service?: string,
public statusCode?: number,
public retryable?: boolean
) {
super(message);
this.name = 'ExternalServiceError';
}
}
/**
* Get user-friendly error message
*/
export function getUserFriendlyMessage(error: Error): string {
if (error instanceof ValidationError) {
return `Validation Error: ${error.message}${error.field ? ` (Field: ${error.field})` : ''}`;
}
if (error instanceof BusinessRuleError) {
return `Business Rule Violation: ${error.message}${error.ruleId ? ` (Rule: ${error.ruleId})` : ''}`;
}
if (error instanceof ExternalServiceError) {
if (error.service === 'fx-rate-service') {
return 'Unable to fetch exchange rates. Please try again or contact support.';
}
return `Service Error: ${error.message}. ${error.retryable ? 'Please try again.' : 'Please contact support.'}`;
}
if (error instanceof SystemError) {
return 'A system error occurred. Please contact support if the problem persists.';
}
// Generic error - don't expose internal details
return 'An error occurred. Please try again or contact support.';
}
/**
* Format error for logging (includes all details)
*/
export function formatErrorForLogging(error: Error): Record<string, unknown> {
const base: Record<string, unknown> = {
name: error.name,
message: error.message,
stack: error.stack,
};
if (error instanceof ValidationError) {
base.field = error.field;
base.code = error.code;
}
if (error instanceof BusinessRuleError) {
base.ruleId = error.ruleId;
base.severity = error.severity;
}
if (error instanceof SystemError) {
base.originalError = error.originalError?.message;
base.context = error.context;
}
if (error instanceof ExternalServiceError) {
base.service = error.service;
base.statusCode = error.statusCode;
base.retryable = error.retryable;
}
return base;
}
/**
* Check if error is retryable
*/
export function isRetryableError(error: Error): boolean {
if (error instanceof ExternalServiceError) {
return error.retryable ?? false;
}
if (error instanceof SystemError) {
// Network errors, timeouts are typically retryable
const message = error.message.toLowerCase();
return (
message.includes('timeout') ||
message.includes('network') ||
message.includes('connection')
);
}
return false;
}

View File

@@ -7,5 +7,6 @@
export * from './currency';
export * from './dates';
export * from './validation';
export * from './input-validation';
export * from './eo-uplift';
export * from './institution-config';

View File

@@ -0,0 +1,53 @@
/**
* Input validation utilities for user inputs and transaction data
*/
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
/**
* Validate transaction amount
*/
export declare function validateAmount(amount: number | string): ValidationResult;
/**
* Validate currency code (ISO 4217)
*/
export declare function validateCurrency(currency: string): ValidationResult;
/**
* Validate email address
*/
export declare function validateEmail(email: string | undefined): ValidationResult;
/**
* Validate phone number (basic validation)
*/
export declare function validatePhone(phone: string | undefined): ValidationResult;
/**
* Validate account number or IBAN
*/
export declare function validateAccountNumber(accountNumber: string | undefined, iban: string | undefined): ValidationResult;
/**
* Validate purpose of payment
*/
export declare function validatePurposeOfPayment(purpose: string | undefined): ValidationResult;
/**
* Validate name field
*/
export declare function validateName(name: string | undefined, fieldName: string): ValidationResult;
/**
* Validate address
*/
export declare function validateAddress(address: string | undefined, city: string | undefined, country: string | undefined): ValidationResult;
/**
* Validate tax ID (CPF or CNPJ)
*/
export declare function validateTaxId(taxId: string | undefined, fieldName: string): ValidationResult;
/**
* Sanitize string input (remove dangerous characters)
*/
export declare function sanitizeString(input: string): string;
/**
* Sanitize number input
*/
export declare function sanitizeNumber(input: string | number): number;
//# sourceMappingURL=input-validation.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"input-validation.d.ts","sourceRoot":"","sources":["input-validation.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,gBAAgB,CAmBxE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,gBAAgB,CAiBnE;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,gBAAgB,CAkBzE;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,gBAAgB,CAkBzE;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CACnC,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,gBAAgB,CA0BlB;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,MAAM,GAAG,SAAS,GAC1B,gBAAgB,CAiBlB;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,gBAAgB,CAiB1F;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,OAAO,EAAE,MAAM,GAAG,SAAS,GAC1B,gBAAgB,CAmBlB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,gBAAgB,CAmB5F;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKpD;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAM7D"}

View File

@@ -0,0 +1,214 @@
/**
* Input validation utilities for user inputs and transaction data
*/
import { validateBrazilianTaxId } from './validation';
/**
* Validate transaction amount
*/
export function validateAmount(amount) {
const errors = [];
const warnings = [];
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(numAmount)) {
errors.push('Amount must be a valid number');
}
else if (numAmount <= 0) {
errors.push('Amount must be greater than zero');
}
else if (numAmount > 1000000000) {
warnings.push('Amount exceeds 1 billion - please verify');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate currency code (ISO 4217)
*/
export function validateCurrency(currency) {
const errors = [];
const warnings = [];
if (!currency || currency.trim().length === 0) {
errors.push('Currency code is required');
}
else if (currency.length !== 3) {
errors.push('Currency code must be 3 characters (ISO 4217)');
}
else if (!/^[A-Z]{3}$/.test(currency)) {
errors.push('Currency code must be uppercase letters only');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate email address
*/
export function validateEmail(email) {
const errors = [];
const warnings = [];
if (!email) {
return { valid: true, errors, warnings };
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
errors.push('Invalid email address format');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate phone number (basic validation)
*/
export function validatePhone(phone) {
const errors = [];
const warnings = [];
if (!phone) {
return { valid: true, errors, warnings };
}
const phoneRegex = /^[\d\s\-\+\(\)]+$/;
if (!phoneRegex.test(phone)) {
errors.push('Invalid phone number format');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate account number or IBAN
*/
export function validateAccountNumber(accountNumber, iban) {
const errors = [];
const warnings = [];
if (!accountNumber && !iban) {
errors.push('Either account number or IBAN is required');
return { valid: false, errors, warnings };
}
if (iban) {
// Basic IBAN validation (2 letters + 2 digits + up to 30 alphanumeric)
const ibanRegex = /^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/;
if (!ibanRegex.test(iban.replace(/\s/g, ''))) {
errors.push('Invalid IBAN format');
}
}
if (accountNumber && accountNumber.trim().length === 0) {
errors.push('Account number cannot be empty');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate purpose of payment
*/
export function validatePurposeOfPayment(purpose) {
const errors = [];
const warnings = [];
if (!purpose || purpose.trim().length === 0) {
errors.push('Purpose of payment is required');
}
else if (purpose.trim().length < 5) {
warnings.push('Purpose of payment is very short - provide more detail');
}
else if (purpose.length > 140) {
warnings.push('Purpose of payment exceeds 140 characters - may be truncated');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate name field
*/
export function validateName(name, fieldName) {
const errors = [];
const warnings = [];
if (!name || name.trim().length === 0) {
errors.push(`${fieldName} is required`);
}
else if (name.trim().length < 2) {
errors.push(`${fieldName} must be at least 2 characters`);
}
else if (name.length > 140) {
warnings.push(`${fieldName} exceeds 140 characters`);
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate address
*/
export function validateAddress(address, city, country) {
const errors = [];
const warnings = [];
if (!address && !city) {
errors.push('Either address or city is required');
}
if (!country) {
errors.push('Country is required');
}
else if (country.length !== 2) {
errors.push('Country must be a 2-letter ISO code');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate tax ID (CPF or CNPJ)
*/
export function validateTaxId(taxId, fieldName) {
const errors = [];
const warnings = [];
if (!taxId) {
errors.push(`${fieldName} is required`);
return { valid: false, errors, warnings };
}
const validation = validateBrazilianTaxId(taxId);
if (!validation.valid) {
errors.push(`${fieldName}: ${validation.error || 'Invalid format'}`);
}
return {
valid: validation.valid,
errors,
warnings,
};
}
/**
* Sanitize string input (remove dangerous characters)
*/
export function sanitizeString(input) {
return input
.replace(/[<>]/g, '') // Remove potential HTML/XML tags
.replace(/[\x00-\x1F\x7F]/g, '') // Remove control characters
.trim();
}
/**
* Sanitize number input
*/
export function sanitizeNumber(input) {
if (typeof input === 'number') {
return isNaN(input) ? 0 : input;
}
const num = parseFloat(input.replace(/[^\d.-]/g, ''));
return isNaN(num) ? 0 : num;
}
//# sourceMappingURL=input-validation.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,256 @@
/**
* Input validation utilities for user inputs and transaction data
*/
import { validateBrazilianTaxId } from './validation';
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
/**
* Validate transaction amount
*/
export function validateAmount(amount: number | string): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(numAmount)) {
errors.push('Amount must be a valid number');
} else if (numAmount <= 0) {
errors.push('Amount must be greater than zero');
} else if (numAmount > 1000000000) {
warnings.push('Amount exceeds 1 billion - please verify');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate currency code (ISO 4217)
*/
export function validateCurrency(currency: string): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!currency || currency.trim().length === 0) {
errors.push('Currency code is required');
} else if (currency.length !== 3) {
errors.push('Currency code must be 3 characters (ISO 4217)');
} else if (!/^[A-Z]{3}$/.test(currency)) {
errors.push('Currency code must be uppercase letters only');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate email address
*/
export function validateEmail(email: string | undefined): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!email) {
return { valid: true, errors, warnings };
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
errors.push('Invalid email address format');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate phone number (basic validation)
*/
export function validatePhone(phone: string | undefined): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!phone) {
return { valid: true, errors, warnings };
}
const phoneRegex = /^[\d\s\-\+\(\)]+$/;
if (!phoneRegex.test(phone)) {
errors.push('Invalid phone number format');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate account number or IBAN
*/
export function validateAccountNumber(
accountNumber: string | undefined,
iban: string | undefined
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!accountNumber && !iban) {
errors.push('Either account number or IBAN is required');
return { valid: false, errors, warnings };
}
if (iban) {
// Basic IBAN validation (2 letters + 2 digits + up to 30 alphanumeric)
const ibanRegex = /^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/;
if (!ibanRegex.test(iban.replace(/\s/g, ''))) {
errors.push('Invalid IBAN format');
}
}
if (accountNumber && accountNumber.trim().length === 0) {
errors.push('Account number cannot be empty');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate purpose of payment
*/
export function validatePurposeOfPayment(
purpose: string | undefined
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!purpose || purpose.trim().length === 0) {
errors.push('Purpose of payment is required');
} else if (purpose.trim().length < 5) {
warnings.push('Purpose of payment is very short - provide more detail');
} else if (purpose.length > 140) {
warnings.push('Purpose of payment exceeds 140 characters - may be truncated');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate name field
*/
export function validateName(name: string | undefined, fieldName: string): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!name || name.trim().length === 0) {
errors.push(`${fieldName} is required`);
} else if (name.trim().length < 2) {
errors.push(`${fieldName} must be at least 2 characters`);
} else if (name.length > 140) {
warnings.push(`${fieldName} exceeds 140 characters`);
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate address
*/
export function validateAddress(
address: string | undefined,
city: string | undefined,
country: string | undefined
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!address && !city) {
errors.push('Either address or city is required');
}
if (!country) {
errors.push('Country is required');
} else if (country.length !== 2) {
errors.push('Country must be a 2-letter ISO code');
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate tax ID (CPF or CNPJ)
*/
export function validateTaxId(taxId: string | undefined, fieldName: string): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!taxId) {
errors.push(`${fieldName} is required`);
return { valid: false, errors, warnings };
}
const validation = validateBrazilianTaxId(taxId);
if (!validation.valid) {
errors.push(`${fieldName}: ${validation.error || 'Invalid format'}`);
}
return {
valid: validation.valid,
errors,
warnings,
};
}
/**
* Sanitize string input (remove dangerous characters)
*/
export function sanitizeString(input: string): string {
return input
.replace(/[<>]/g, '') // Remove potential HTML/XML tags
.replace(/[\x00-\x1F\x7F]/g, '') // Remove control characters
.trim();
}
/**
* Sanitize number input
*/
export function sanitizeNumber(input: string | number): number {
if (typeof input === 'number') {
return isNaN(input) ? 0 : input;
}
const num = parseFloat(input.replace(/[^\d.-]/g, ''));
return isNaN(num) ? 0 : num;
}

504
pnpm-lock.yaml generated
View File

@@ -100,6 +100,9 @@ importers:
'@brazil-swift-ops/utils':
specifier: workspace:*
version: link:../utils
xmlbuilder2:
specifier: ^3.0.2
version: 3.1.1
devDependencies:
typescript:
specifier: ^5.3.3
@@ -136,6 +139,9 @@ importers:
typescript:
specifier: ^5.3.3
version: 5.9.3
vitest:
specifier: ^1.0.0
version: 1.6.1(@types/node@20.19.30)
packages/treasury:
dependencies:
@@ -562,6 +568,13 @@ packages:
dev: true
optional: true
/@jest/schemas@29.6.3:
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@sinclair/typebox': 0.27.8
dev: true
/@jridgewell/gen-mapping@0.3.13:
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
dependencies:
@@ -613,6 +626,35 @@ packages:
fastq: 1.20.1
dev: true
/@oozcitak/dom@1.15.10:
resolution: {integrity: sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==}
engines: {node: '>=8.0'}
dependencies:
'@oozcitak/infra': 1.0.8
'@oozcitak/url': 1.0.4
'@oozcitak/util': 8.3.8
dev: false
/@oozcitak/infra@1.0.8:
resolution: {integrity: sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==}
engines: {node: '>=6.0'}
dependencies:
'@oozcitak/util': 8.3.8
dev: false
/@oozcitak/url@1.0.4:
resolution: {integrity: sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==}
engines: {node: '>=8.0'}
dependencies:
'@oozcitak/infra': 1.0.8
'@oozcitak/util': 8.3.8
dev: false
/@oozcitak/util@8.3.8:
resolution: {integrity: sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==}
engines: {node: '>=8.0'}
dev: false
/@remix-run/router@1.23.2:
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
engines: {node: '>=14.0.0'}
@@ -822,6 +864,10 @@ packages:
dev: true
optional: true
/@sinclair/typebox@0.27.8:
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
dev: true
/@types/babel__core@7.20.5:
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
dependencies:
@@ -895,6 +941,63 @@ packages:
- supports-color
dev: true
/@vitest/expect@1.6.1:
resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==}
dependencies:
'@vitest/spy': 1.6.1
'@vitest/utils': 1.6.1
chai: 4.5.0
dev: true
/@vitest/runner@1.6.1:
resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==}
dependencies:
'@vitest/utils': 1.6.1
p-limit: 5.0.0
pathe: 1.1.2
dev: true
/@vitest/snapshot@1.6.1:
resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==}
dependencies:
magic-string: 0.30.21
pathe: 1.1.2
pretty-format: 29.7.0
dev: true
/@vitest/spy@1.6.1:
resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==}
dependencies:
tinyspy: 2.2.1
dev: true
/@vitest/utils@1.6.1:
resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==}
dependencies:
diff-sequences: 29.6.3
estree-walker: 3.0.3
loupe: 2.3.7
pretty-format: 29.7.0
dev: true
/acorn-walk@8.3.4:
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
engines: {node: '>=0.4.0'}
dependencies:
acorn: 8.15.0
dev: true
/acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
dev: true
/ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
dev: true
/any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
dev: true
@@ -911,6 +1014,16 @@ packages:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
dev: true
/argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
dependencies:
sprintf-js: 1.0.3
dev: false
/assertion-error@1.1.0:
resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==}
dev: true
/autoprefixer@10.4.23(postcss@8.5.6):
resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
engines: {node: ^10 || ^12 || >=14}
@@ -955,6 +1068,11 @@ packages:
update-browserslist-db: 1.2.3(browserslist@4.28.1)
dev: true
/cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
dev: true
/camelcase-css@2.0.1:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
@@ -964,6 +1082,25 @@ packages:
resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==}
dev: true
/chai@4.5.0:
resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==}
engines: {node: '>=4'}
dependencies:
assertion-error: 1.1.0
check-error: 1.0.3
deep-eql: 4.1.4
get-func-name: 2.0.2
loupe: 2.3.7
pathval: 1.1.1
type-detect: 4.1.0
dev: true
/check-error@1.0.3:
resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==}
dependencies:
get-func-name: 2.0.2
dev: true
/chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -984,10 +1121,23 @@ packages:
engines: {node: '>= 6'}
dev: true
/confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
dev: true
/convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
dev: true
/cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
dev: true
/cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -1017,10 +1167,22 @@ packages:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
dev: false
/deep-eql@4.1.4:
resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==}
engines: {node: '>=6'}
dependencies:
type-detect: 4.1.0
dev: true
/didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dev: true
/diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dev: true
/dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dev: true
@@ -1065,6 +1227,33 @@ packages:
engines: {node: '>=6'}
dev: true
/esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
hasBin: true
dev: false
/estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
dependencies:
'@types/estree': 1.0.8
dev: true
/execa@8.0.1:
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
engines: {node: '>=16.17'}
dependencies:
cross-spawn: 7.0.6
get-stream: 8.0.1
human-signals: 5.0.0
is-stream: 3.0.0
merge-stream: 2.0.0
npm-run-path: 5.3.0
onetime: 6.0.0
signal-exit: 4.1.0
strip-final-newline: 3.0.0
dev: true
/fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -1122,6 +1311,15 @@ packages:
engines: {node: '>=6.9.0'}
dev: true
/get-func-name@2.0.2:
resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==}
dev: true
/get-stream@8.0.1:
resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
engines: {node: '>=16'}
dev: true
/glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1143,6 +1341,11 @@ packages:
function-bind: 1.1.2
dev: true
/human-signals@5.0.0:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'}
dev: true
/is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -1174,6 +1377,15 @@ packages:
engines: {node: '>=0.12.0'}
dev: true
/is-stream@3.0.0:
resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
/isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: true
/jiti@1.21.7:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
@@ -1182,6 +1394,18 @@ packages:
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
/js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
dev: true
/js-yaml@3.14.1:
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
hasBin: true
dependencies:
argparse: 1.0.10
esprima: 4.0.1
dev: false
/jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -1203,6 +1427,14 @@ packages:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
dev: true
/local-pkg@0.5.1:
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
engines: {node: '>=14'}
dependencies:
mlly: 1.8.0
pkg-types: 1.3.1
dev: true
/loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -1210,12 +1442,28 @@ packages:
js-tokens: 4.0.0
dev: false
/loupe@2.3.7:
resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==}
dependencies:
get-func-name: 2.0.2
dev: true
/lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
dependencies:
yallist: 3.1.1
dev: true
/magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
dev: true
/merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
dev: true
/merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -1229,6 +1477,20 @@ packages:
picomatch: 2.3.1
dev: true
/mimic-fn@4.0.0:
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
engines: {node: '>=12'}
dev: true
/mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
dependencies:
acorn: 8.15.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.3
dev: true
/ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: true
@@ -1256,6 +1518,13 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/npm-run-path@5.3.0:
resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
path-key: 4.0.0
dev: true
/object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1266,10 +1535,46 @@ packages:
engines: {node: '>= 6'}
dev: true
/onetime@6.0.0:
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
engines: {node: '>=12'}
dependencies:
mimic-fn: 4.0.0
dev: true
/p-limit@5.0.0:
resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==}
engines: {node: '>=18'}
dependencies:
yocto-queue: 1.2.2
dev: true
/path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
dev: true
/path-key@4.0.0:
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
engines: {node: '>=12'}
dev: true
/path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
dev: true
/pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
dev: true
/pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
dev: true
/pathval@1.1.1:
resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
dev: true
/picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
dev: true
@@ -1294,6 +1599,14 @@ packages:
engines: {node: '>= 6'}
dev: true
/pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
dependencies:
confbox: 0.1.8
mlly: 1.8.0
pathe: 2.0.3
dev: true
/postcss-import@15.1.0(postcss@8.5.6):
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
@@ -1370,6 +1683,15 @@ packages:
source-map-js: 1.2.1
dev: true
/pretty-format@29.7.0:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/schemas': 29.6.3
ansi-styles: 5.2.0
react-is: 18.3.1
dev: true
/queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
@@ -1384,6 +1706,10 @@ packages:
scheduler: 0.23.2
dev: false
/react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
dev: true
/react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -1499,11 +1825,55 @@ packages:
hasBin: true
dev: true
/shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
dependencies:
shebang-regex: 3.0.0
dev: true
/shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
dev: true
/siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
dev: true
/signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
dev: true
/source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
dev: true
/sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
dev: false
/stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
dev: true
/std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
dev: true
/strip-final-newline@3.0.0:
resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}
engines: {node: '>=12'}
dev: true
/strip-literal@2.1.1:
resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==}
dependencies:
js-tokens: 9.0.1
dev: true
/sucrase@3.35.1:
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -1568,6 +1938,10 @@ packages:
any-promise: 1.3.0
dev: true
/tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
dev: true
/tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -1576,6 +1950,16 @@ packages:
picomatch: 4.0.3
dev: true
/tinypool@0.8.4:
resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==}
engines: {node: '>=14.0.0'}
dev: true
/tinyspy@2.2.1:
resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==}
engines: {node: '>=14.0.0'}
dev: true
/to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -1647,12 +2031,21 @@ packages:
turbo-windows-arm64: 1.13.4
dev: true
/type-detect@4.1.0:
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
engines: {node: '>=4'}
dev: true
/typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
/ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
dev: true
/undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
dev: true
@@ -1680,6 +2073,28 @@ packages:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true
/vite-node@1.6.1(@types/node@20.19.30):
resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
dependencies:
cac: 6.7.14
debug: 4.4.3
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.21(@types/node@20.19.30)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
dev: true
/vite@5.4.21(@types/node@20.19.30):
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -1719,10 +2134,99 @@ packages:
fsevents: 2.3.3
dev: true
/vitest@1.6.1(@types/node@20.19.30):
resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/node': ^18.0.0 || >=20.0.0
'@vitest/browser': 1.6.1
'@vitest/ui': 1.6.1
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
dependencies:
'@types/node': 20.19.30
'@vitest/expect': 1.6.1
'@vitest/runner': 1.6.1
'@vitest/snapshot': 1.6.1
'@vitest/spy': 1.6.1
'@vitest/utils': 1.6.1
acorn-walk: 8.3.4
chai: 4.5.0
debug: 4.4.3
execa: 8.0.1
local-pkg: 0.5.1
magic-string: 0.30.21
pathe: 1.1.2
picocolors: 1.1.1
std-env: 3.10.0
strip-literal: 2.1.1
tinybench: 2.9.0
tinypool: 0.8.4
vite: 5.4.21(@types/node@20.19.30)
vite-node: 1.6.1(@types/node@20.19.30)
why-is-node-running: 2.3.0
transitivePeerDependencies:
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
dev: true
/which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
dependencies:
isexe: 2.0.0
dev: true
/why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
dev: true
/xmlbuilder2@3.1.1:
resolution: {integrity: sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==}
engines: {node: '>=12.0'}
dependencies:
'@oozcitak/dom': 1.15.10
'@oozcitak/infra': 1.0.8
'@oozcitak/util': 8.3.8
js-yaml: 3.14.1
dev: false
/yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
dev: true
/yocto-queue@1.2.2:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
dev: true
/zustand@4.5.7(@types/react@18.3.27)(react@18.3.1):
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}