- Added Bank type and BankRegistry interface - Created banks service with CRUD operations and validation - Added bank dropdown to Transactions page with ESTRBRRJ as default - Extended Transaction type with bankSwiftCode field - Added unit tests for bank registry - Bank information stored in TypeScript module (can be migrated to DB/XML)
534 lines
20 KiB
TypeScript
534 lines
20 KiB
TypeScript
import React, { useState, useMemo, useCallback } from 'react';
|
|
import { useTransactionStore } from '../stores/transactionStore';
|
|
import type { Transaction } from '@brazil-swift-ops/types';
|
|
import { calculateTransactionEOUplift, getDefaultConverter, getAllBanks, getBankBySwiftCode } 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 [selectedBankSwiftCode, setSelectedBankSwiftCode] = useState<string>('ESTRBRRJ');
|
|
|
|
const converter = getDefaultConverter();
|
|
const banks = getAllBanks();
|
|
const selectedBank = getBankBySwiftCode(selectedBankSwiftCode);
|
|
|
|
const handleSubmit = useCallback(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,
|
|
bankSwiftCode: selectedBankSwiftCode,
|
|
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);
|
|
}
|
|
}, [formData, addTransaction, converter]);
|
|
|
|
return (
|
|
<div className="px-4 py-6 sm:px-0">
|
|
<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">
|
|
{/* Bank Selection */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Bank (SWIFT/BIC) *
|
|
</label>
|
|
<select
|
|
value={selectedBankSwiftCode}
|
|
onChange={(e) => setSelectedBankSwiftCode(e.target.value)}
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2"
|
|
required
|
|
>
|
|
{banks.map((bank) => (
|
|
<option key={bank.swiftCode} value={bank.swiftCode}>
|
|
{bank.swiftCode} - {bank.institutionName} ({bank.city})
|
|
</option>
|
|
))}
|
|
</select>
|
|
{selectedBank && (
|
|
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
|
<p className="text-sm text-blue-800">
|
|
<strong>Selected Bank:</strong> {selectedBank.institutionName}
|
|
</p>
|
|
<p className="text-xs text-blue-600 mt-1">
|
|
{selectedBank.city}, {selectedBank.country} | SWIFT: {selectedBank.swiftCode}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<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>
|
|
);
|
|
}
|