Files
brazil-swift-ops/apps/web/src/pages/TransactionsPage.tsx
defiQUG 12427713ff Complete bank selector dropdown and SWIFT/BIC registry
- 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)
2026-01-23 17:03:31 -08:00

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>
);
}