189 lines
6.7 KiB
TypeScript
189 lines
6.7 KiB
TypeScript
import { v4 as uuidv4 } from 'uuid';
|
|
import {
|
|
buildMoneySupplySnapshot,
|
|
GL_CODES,
|
|
isIbanValid,
|
|
m2FiatToTokenLoadAmount,
|
|
m3TokenLoadAmount,
|
|
resolveInstrumentGl,
|
|
resolveM4OutboundJournal,
|
|
rollExternalTransferVerbiage,
|
|
swiftKindForRequest,
|
|
type ExternalTransferRequest,
|
|
type SettlementRecord,
|
|
} from '@dbis/settlement-core';
|
|
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
|
import { fetchGlBalances, postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
|
|
import { HybxProductionRail } from '../adapters/hybx-production';
|
|
import { archiveIso20022, requestTokenMint } from '../adapters/omnl';
|
|
import { buildMt102Stub, buildMt103Stub, forwardSwiftToListener } from '../adapters/swift';
|
|
import { settlementStore } from '../store/settlement-store';
|
|
|
|
export async function processExternalTransfer(
|
|
input: ExternalTransferRequest,
|
|
): Promise<SettlementRecord> {
|
|
const cfg = loadConfig();
|
|
const profile = loadOfficeProfile();
|
|
const officeId = settlementOfficeId(input.officeId);
|
|
const req: ExternalTransferRequest = { ...input, officeId };
|
|
|
|
const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
|
if (existing?.phase === 'SETTLED') return existing;
|
|
|
|
const now = new Date().toISOString();
|
|
let record: SettlementRecord = existing ?? {
|
|
settlementId: uuidv4(),
|
|
idempotencyKey: req.idempotencyKey,
|
|
phase: 'RECEIVED',
|
|
request: req,
|
|
errors: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
const advance = (phase: SettlementRecord['phase'], patch: Partial<SettlementRecord> = {}) => {
|
|
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
|
settlementStore.save(record);
|
|
};
|
|
|
|
try {
|
|
if (!isIbanValid(req.creditorIban)) {
|
|
throw new Error(`Invalid creditor IBAN: ${req.creditorIban}`);
|
|
}
|
|
advance('VALIDATED');
|
|
|
|
const verbiage = rollExternalTransferVerbiage(req);
|
|
advance('VERBIAGE_ROLLED', { verbiageDocument: verbiage });
|
|
|
|
const amount = parseFloat(req.amount) || 0;
|
|
const tf = req.tradeFinance;
|
|
const gl = tf ? resolveInstrumentGl(tf.instrument) : null;
|
|
const m4Journal = !gl
|
|
? resolveM4OutboundJournal(req.moneyLayers, {
|
|
glM1: profile.settlement.glM1 ?? cfg.moneySupply.glM1,
|
|
glM2: profile.settlement.glM2 ?? cfg.moneySupply.glM2,
|
|
glM4NearMoney: profile.settlement.glM4NearMoney ?? cfg.moneySupply.glM4NearMoney ?? cfg.moneySupply.glSettlement,
|
|
})
|
|
: null;
|
|
const debitGl = gl?.debitGl ?? m4Journal?.debitGl ?? profile.settlement.glM2 ?? cfg.moneySupply.glM2;
|
|
const creditGl = gl?.creditGl ?? m4Journal?.creditGl ?? profile.settlement.glM4NearMoney ?? cfg.moneySupply.glSettlement;
|
|
|
|
if (amount > 0) {
|
|
const [debitGlId, creditGlId] = await Promise.all([
|
|
resolveGlAccountId(debitGl),
|
|
resolveGlAccountId(creditGl),
|
|
]);
|
|
const je = await postJournalEntry({
|
|
officeId: req.officeId,
|
|
transactionDate: req.valueDate,
|
|
referenceNumber: req.idempotencyKey,
|
|
comments: verbiage.slice(0, 500),
|
|
debits: [{ glAccountId: debitGlId, amount }],
|
|
credits: [{ glAccountId: creditGlId, amount }],
|
|
});
|
|
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
|
} else {
|
|
advance('FINERACT_POSTED');
|
|
}
|
|
|
|
if (cfg.rails.hybx.enabled && req.rail !== 'INTERNAL') {
|
|
const hybx = new HybxProductionRail();
|
|
const pay = await hybx.dispatchPayment({
|
|
idempotencyKey: req.idempotencyKey,
|
|
amount: req.amount,
|
|
currency: req.currency,
|
|
creditorIban: req.creditorIban,
|
|
creditorBic: req.creditorBic,
|
|
beneficiaryName: req.beneficiaryName,
|
|
remittanceInfo: req.remittanceInfo,
|
|
rail: req.rail,
|
|
});
|
|
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
|
|
} else {
|
|
advance('HYBX_RAIL_DISPATCHED');
|
|
}
|
|
|
|
const swiftKind = swiftKindForRequest(req);
|
|
const swiftRaw =
|
|
swiftKind === 'MT102'
|
|
? buildMt102Stub({
|
|
senderRef: req.idempotencyKey,
|
|
currency: req.currency,
|
|
amount: req.amount,
|
|
valueDate: req.valueDate,
|
|
orderingInstitution: req.orderingName ?? cfg.omnlLegalName,
|
|
beneficiary: req.beneficiaryName,
|
|
iban: req.creditorIban,
|
|
remittance: req.remittanceInfo,
|
|
})
|
|
: buildMt103Stub({
|
|
senderRef: req.idempotencyKey,
|
|
currency: req.currency,
|
|
amount: req.amount,
|
|
valueDate: req.valueDate,
|
|
orderingCustomer: req.orderingName ?? cfg.omnlLegalName,
|
|
beneficiary: req.beneficiaryName,
|
|
iban: req.creditorIban,
|
|
bic: req.creditorBic,
|
|
remittance: req.remittanceInfo,
|
|
});
|
|
|
|
if (cfg.rails.swift.enabled) {
|
|
await forwardSwiftToListener(swiftRaw);
|
|
}
|
|
const iso = await archiveIso20022({
|
|
messageType: swiftKind === 'MT102' ? 'pacs.008' : 'pacs.008',
|
|
direction: 'OUTBOUND',
|
|
raw: swiftRaw,
|
|
settlementRef: record.settlementId,
|
|
});
|
|
advance('ISO20022_ARCHIVED', { iso20022MessageId: iso.messageId });
|
|
|
|
if (req.convertToCrypto?.enabled) {
|
|
const balances = await fetchGlBalances(req.officeId);
|
|
const snap = buildMoneySupplySnapshot(req.officeId, balances);
|
|
const { loadAmount, fullyBacked } = m2FiatToTokenLoadAmount(
|
|
req.amount,
|
|
snap.M2.broadMoney,
|
|
);
|
|
const m3Check = m3TokenLoadAmount(loadAmount, snap.M2.broadMoney, snap.M3.tokenLiabilities);
|
|
if (!fullyBacked || !m3Check.fullyBacked) {
|
|
record.errors.push(
|
|
`M2/M3 backing insufficient for token load (${loadAmount}/${req.amount}, M3 headroom ${m3Check.loadAmount})`,
|
|
);
|
|
advance('FAILED');
|
|
return record;
|
|
}
|
|
const mint = await requestTokenMint({
|
|
lineId: req.convertToCrypto.tokenLineId,
|
|
amount: m3Check.loadAmount,
|
|
recipient: req.convertToCrypto.recipientAddress,
|
|
settlementRef: record.settlementId,
|
|
dryRun: !cfg.production.allowChainMintExecute,
|
|
symbol: req.convertToCrypto.symbol,
|
|
tokenAddress: req.convertToCrypto.tokenAddress,
|
|
});
|
|
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
|
} else {
|
|
advance('CHAIN_MINT_REQUESTED');
|
|
}
|
|
|
|
advance('SETTLED');
|
|
return record;
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
record.errors.push(msg);
|
|
advance('FAILED');
|
|
return record;
|
|
}
|
|
}
|
|
|
|
export async function getMoneySupply(officeId: number) {
|
|
const balances = await fetchGlBalances(officeId);
|
|
return buildMoneySupplySnapshot(officeId, balances);
|
|
}
|
|
|
|
export function getSettlementStatus(id: string): SettlementRecord | null {
|
|
return settlementStore.getById(id);
|
|
}
|