diff --git a/.env.example b/.env.example index 3868340064..05f555eb04 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,14 @@ YAPEAL_ROOT_CA= YAPEAL_WEBHOOK_API_KEY= YAPEAL_ACCOUNT_IDENTIFIER= +# Relio Bank Integration (Ed25519 signed API) +# Base URL: https://api.develio.ch/v1 (test) or https://api.relio.ch/v1 (prod) +# Private key uses
as line separator for single-line storage +RELIO_BASE_URL= +RELIO_API_KEY= +RELIO_PRIVATE_KEY= +RELIO_ORGANIZATION_ID= + PAYMENT_URL=https://dev.payment.dfx.swiss SERVICES_URL=https://dev.app.dfx.swiss;https://dev.services.dfx.swiss diff --git a/scripts/test-relio.ts b/scripts/test-relio.ts new file mode 100644 index 0000000000..10cc18201d --- /dev/null +++ b/scripts/test-relio.ts @@ -0,0 +1,211 @@ +/** + * Relio API Integration Test + * + * Run with: npx ts-node scripts/test-relio.ts + * + * Reads configuration from .env file automatically. + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import axios from 'axios'; + +// Load .env file manually (to avoid dotenv dependency issues) +const envPath = path.join(process.cwd(), '.env'); +if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, 'utf8'); + for (const line of envContent.split('\n')) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex); + const value = trimmed.substring(eqIndex + 1); + if (!process.env[key]) { + process.env[key] = value; + } + } + } + } +} + +// Configuration from environment +const config = { + baseUrl: process.env.RELIO_BASE_URL, + apiKey: process.env.RELIO_API_KEY, + privateKey: process.env.RELIO_PRIVATE_KEY?.split('
').join('\n'), + organizationId: process.env.RELIO_ORGANIZATION_ID, +}; + +// Initialize private key +let privateKey: crypto.KeyObject | undefined; +try { + if (config.privateKey) { + privateKey = crypto.createPrivateKey({ + key: config.privateKey, + format: 'pem', + type: 'pkcs8', + }); + } +} catch (e) { + console.error('Failed to load private key:', e); +} + +// Signing functions (same as in RelioService) +function buildCanonicalBody(body: unknown): string { + if (!body) return ''; + if (typeof body === 'object') { + if (Array.isArray(body)) return JSON.stringify(body); + const obj = body as Record; + const sortedKeys = Object.keys(obj).sort(); + const sortedObj: Record = {}; + for (const key of sortedKeys) { + sortedObj[key] = obj[key]; + } + return JSON.stringify(sortedObj); + } + if (typeof body === 'string') return body; + return JSON.stringify(body); +} + +function createCanonicalString(method: string, originalUrl: string, body?: unknown): string { + return `${method.toUpperCase()}${originalUrl}${buildCanonicalBody(body)}`; +} + +function signRequest(canonicalString: string): string { + if (!privateKey) throw new Error('Private key not initialized'); + const signature = crypto.sign(null, Buffer.from(canonicalString, 'utf8'), privateKey); + return signature.toString('base64'); +} + +async function callApi(endpoint: string, method: string = 'GET', data?: unknown): Promise { + const [pathPart, queryPart] = endpoint.split('?'); + const originalUrl = `/v1/${pathPart}${queryPart ? '?' + queryPart : ''}`; + const canonicalString = createCanonicalString(method, originalUrl, data); + const signature = signRequest(canonicalString); + + console.log('\n--- Request Details ---'); + console.log('Endpoint:', endpoint); + console.log('Method:', method); + console.log('Original URL (for signing):', originalUrl); + console.log( + 'Canonical String:', + canonicalString.length > 100 ? canonicalString.substring(0, 100) + '...' : canonicalString, + ); + console.log('Signature:', signature.substring(0, 50) + '...'); + + const response = await axios({ + url: `${config.baseUrl}/${endpoint}`, + method: method as any, + data, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-api-key': config.apiKey, + 'x-signature': signature, + }, + }); + + return response.data; +} + +// Test functions +async function testAuthContext(): Promise { + console.log('\n' + '='.repeat(60)); + console.log('TEST: GET /v1/auth/context'); + console.log('='.repeat(60)); + + try { + const result = await callApi('auth/context', 'GET'); + console.log('\n✓ SUCCESS!'); + console.log('Response:', JSON.stringify(result, null, 2)); + return true; + } catch (error: any) { + console.log('\n✗ FAILED!'); + console.log('Error:', error.response?.data || error.message); + return false; + } +} + +async function testGetAccounts(): Promise { + console.log('\n' + '='.repeat(60)); + console.log('TEST: GET /v1/accounts'); + console.log('='.repeat(60)); + + try { + const result = await callApi('accounts?pageNumber=1&pageSize=10', 'GET'); + console.log('\n✓ SUCCESS!'); + console.log('Response:', JSON.stringify(result, null, 2)); + return true; + } catch (error: any) { + console.log('\n✗ FAILED!'); + console.log('Error:', error.response?.data || error.message); + return false; + } +} + +async function testGetWallets(): Promise { + console.log('\n' + '='.repeat(60)); + console.log('TEST: GET /v1/wallets'); + console.log('='.repeat(60)); + + try { + const result = await callApi('wallets?pageNumber=1&pageSize=10', 'GET'); + console.log('\n✓ SUCCESS!'); + console.log('Response:', JSON.stringify(result, null, 2)); + return true; + } catch (error: any) { + console.log('\n✗ FAILED!'); + console.log('Error:', error.response?.data || error.message); + return false; + } +} + +// Main +async function main() { + console.log('='.repeat(60)); + console.log('RELIO API INTEGRATION TEST'); + console.log('='.repeat(60)); + + // Check configuration + console.log('\nConfiguration:'); + console.log(' Base URL:', config.baseUrl || 'MISSING'); + console.log(' API Key:', config.apiKey ? 'Loaded' : 'MISSING'); + console.log(' Private Key:', privateKey ? 'Loaded' : 'MISSING or INVALID'); + console.log(' Organization ID:', config.organizationId || 'MISSING'); + + if (!config.baseUrl || !config.apiKey || !privateKey || !config.organizationId) { + console.log('\nMissing required configuration. Please set environment variables:'); + console.log(' RELIO_BASE_URL=https://api.develio.ch/v1'); + console.log(' RELIO_API_KEY='); + console.log(' RELIO_PRIVATE_KEY='); + console.log(' RELIO_ORGANIZATION_ID='); + process.exit(1); + } + + // Run tests + const results: boolean[] = []; + + results.push(await testAuthContext()); + results.push(await testGetAccounts()); + results.push(await testGetWallets()); + + // Summary + console.log('\n' + '='.repeat(60)); + console.log('SUMMARY'); + console.log('='.repeat(60)); + const passed = results.filter((r) => r).length; + const failed = results.filter((r) => !r).length; + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + + if (failed > 0) { + console.log('\nSome tests failed. Check the errors above.'); + process.exit(1); + } else { + console.log('\nAll tests passed! The Relio integration is working correctly.'); + } +} + +main().catch(console.error); diff --git a/src/config/config.ts b/src/config/config.ts index 654c72fee4..23cf1c3a97 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -992,6 +992,12 @@ export class Configuration { webhookApiKey: process.env.YAPEAL_WEBHOOK_API_KEY, accountIdentifier: process.env.YAPEAL_ACCOUNT_IDENTIFIER, }, + relio: { + baseUrl: process.env.RELIO_BASE_URL, + apiKey: process.env.RELIO_API_KEY, + privateKey: process.env.RELIO_PRIVATE_KEY?.split('
').join('\n'), + organizationId: process.env.RELIO_ORGANIZATION_ID, + }, forexFee: 0.02, }; diff --git a/src/integration/bank/bank.module.ts b/src/integration/bank/bank.module.ts index 470dcead14..3f372fe992 100644 --- a/src/integration/bank/bank.module.ts +++ b/src/integration/bank/bank.module.ts @@ -4,13 +4,14 @@ import { YapealWebhookController } from './controllers/yapeal-webhook.controller import { IbanService } from './services/iban.service'; import { OlkypayService } from './services/olkypay.service'; import { RaiffeisenService } from './services/raiffeisen.service'; +import { RelioService } from './services/relio.service'; import { YapealWebhookService } from './services/yapeal-webhook.service'; import { YapealService } from './services/yapeal.service'; @Module({ imports: [SharedModule], controllers: [YapealWebhookController], - providers: [IbanService, OlkypayService, RaiffeisenService, YapealService, YapealWebhookService], - exports: [IbanService, OlkypayService, RaiffeisenService, YapealService, YapealWebhookService], + providers: [IbanService, OlkypayService, RaiffeisenService, RelioService, YapealService, YapealWebhookService], + exports: [IbanService, OlkypayService, RaiffeisenService, RelioService, YapealService, YapealWebhookService], }) export class BankIntegrationModule {} diff --git a/src/integration/bank/dto/relio.dto.ts b/src/integration/bank/dto/relio.dto.ts new file mode 100644 index 0000000000..53df83b9a4 --- /dev/null +++ b/src/integration/bank/dto/relio.dto.ts @@ -0,0 +1,214 @@ +// --- Currency Type --- // + +export type RelioCurrency = 'CHF' | 'EUR' | 'GBP'; + +// --- Amount DTOs --- // + +export interface RelioAmount { + currency: string; + amount: string; // Minor units: "98434500" = 984345.00 +} + +// --- Auth Context DTOs --- // + +export interface RelioAuthContext { + organizations: RelioOrganization[]; +} + +export interface RelioOrganization { + id: string; + name: string; + type: string; + accounts: RelioAccountRef[]; +} + +export interface RelioAccountRef { + id: string; + wallets: RelioWalletRef[]; +} + +export interface RelioWalletRef { + id: string; + status: string; +} + +// --- Account DTOs --- // + +export interface RelioAccount { + id: string; + createdAt: string; + state: RelioAccountState; + wallets?: RelioWallet[]; +} + +export enum RelioAccountState { + ACTIVE = 'ACTIVE', + INACTIVE = 'INACTIVE', +} + +// --- Wallet DTOs --- // + +export interface RelioWallet { + id: string; + iban: string; + state: RelioWalletState; + name: string; + currency: RelioCurrency; + availableBalance: RelioAmount; + balance: RelioAmount; + createdAt: string; +} + +export enum RelioWalletState { + ACTIVE = 'ACTIVE', + INACTIVE = 'INACTIVE', +} + +export interface RelioWalletListResponse { + totalRecords: number; + pageNumber: number; + pageSize: number; + data: RelioWalletListItem[]; +} + +export interface RelioWalletListItem { + id: string; + iban: string; + name: string; + currencyCode: RelioCurrency; + state: RelioWalletState; +} + +// --- Payment Order DTOs --- // + +export interface RelioPayee { + name: string; + accountNumber: string; // IBAN + addressLine1?: string; + city?: string; + postCode?: string; + country: string; +} + +export interface RelioPaymentDetails { + payee: RelioPayee; + amount: RelioAmount; + reference?: string; +} + +export interface RelioSchedule { + startDate: string; // YYYY-MM-DD + frequency: RelioPaymentFrequency; +} + +export enum RelioPaymentFrequency { + MONTHLY = 'MONTHLY', + ANNUALLY = 'ANNUALLY', +} + +export interface RelioPaymentOrderRequest { + walletId: string; + name: string; + payment: RelioPaymentDetails; + schedule?: RelioSchedule; +} + +export interface RelioPaymentOrderResponse { + id: string; + paymentId: string; +} + +export interface RelioPaymentOrder { + id: string; + createdAtUtc: string; + updatedAtUtc: string; + paymentOrderId: string; + accountId: string; + walletId: string; + amount: RelioAmount; + reference?: string; + scheme: string; + schedule?: { + frequency: RelioPaymentFrequency; + type: string; + startDate: string; + }; + state: RelioPaymentState; + payee: { + name: string; + iban: string; + payeeType: string; + addressLine1?: string; + city?: string; + postCode?: string; + country: string; + }; + fees: RelioFee[]; + deletedAtUtc?: string; +} + +export enum RelioPaymentState { + PENDING = 'PENDING', + PROCESSING = 'PROCESSING', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED', + CANCELLED = 'CANCELLED', +} + +export interface RelioFee { + type: string; + amount: RelioAmount; +} + +// --- FX Quote DTOs --- // + +export interface RelioFxQuoteRequest { + sourceWalletId: string; + targetWalletId: string; + amount: { + type: 'SOURCE' | 'TARGET'; + value: string; + }; +} + +export interface RelioFxQuoteResponse { + id: string; + options: RelioFxQuoteOption[]; +} + +export interface RelioFxQuoteOption { + id: string; + expiresAtUtc: string; + fxRate: number; + fxMargin: number; + fxMarginPercentage: number; + cumulativeFXRate: number; + sourceAmount: RelioAmount; + sourceAmountTotal: RelioAmount; + targetAmount: RelioAmount; +} + +export interface RelioFxPaymentRequest { + quoteId: string; + quoteOptionId: string; + name: string; + reference?: string; +} + +// --- API Key DTOs --- // + +export interface RelioApiKey { + id: string; + name: string; + allowedIPs: string[]; + clientPublicKey: string; + createdAt: string; + disabledAt: string | null; +} + +export interface RelioApiKeyUpdateRequest { + name?: string; + allowedIPs?: string[]; + clientPublicKey?: string; + disabled?: boolean; +} diff --git a/src/integration/bank/services/__tests__/relio.service.spec.ts b/src/integration/bank/services/__tests__/relio.service.spec.ts new file mode 100644 index 0000000000..f578d3ded5 --- /dev/null +++ b/src/integration/bank/services/__tests__/relio.service.spec.ts @@ -0,0 +1,209 @@ +/** + * Unit tests for Relio service signing logic and amount conversion + * + * The signing functions are tested as standalone implementations + * to validate the canonical string building logic that had multiple bug fixes. + */ + +describe('RelioService', () => { + // --- Signing Logic (standalone implementation matching RelioService) --- // + + /** + * Build canonical body string with top-level sorted keys + * Matches RelioService.buildCanonicalBody() exactly + */ + function buildCanonicalBody(body: unknown): string { + if (!body) { + return ''; + } + + if (typeof body === 'object') { + if (Array.isArray(body)) { + return JSON.stringify(body); + } + + const obj = body as Record; + const sortedKeys = Object.keys(obj).sort(); + const sortedObj: Record = {}; + + for (const key of sortedKeys) { + sortedObj[key] = obj[key]; + } + + return JSON.stringify(sortedObj); + } + + if (typeof body === 'string') { + return body; + } + + return JSON.stringify(body); + } + + /** + * Create canonical request string for signing + * Matches RelioService.createCanonicalString() exactly + */ + function createCanonicalString(method: string, originalUrl: string, body?: unknown): string { + const canonicalBody = buildCanonicalBody(body); + return `${method.toUpperCase()}${originalUrl}${canonicalBody}`; + } + + // --- Amount Conversion (matching RelioService) --- // + + function convertRelioAmount(amount: string): number { + const numericAmount = parseInt(amount, 10); + if (isNaN(numericAmount)) return 0; + return numericAmount / 100; + } + + function toRelioAmount(amount: number): string { + return Math.round(amount * 100).toString(); + } + + // --- Tests for buildCanonicalBody --- // + + describe('buildCanonicalBody', () => { + it('should return empty string for null', () => { + expect(buildCanonicalBody(null)).toBe(''); + }); + + it('should return empty string for undefined', () => { + expect(buildCanonicalBody(undefined)).toBe(''); + }); + + it('should return "{}" for empty object (truthy check)', () => { + // This was a bug fix - empty object {} is truthy, so it should return '{}' + expect(buildCanonicalBody({})).toBe('{}'); + }); + + it('should sort top-level object keys alphabetically', () => { + const body = { zebra: 1, apple: 2, mango: 3 }; + expect(buildCanonicalBody(body)).toBe('{"apple":2,"mango":3,"zebra":1}'); + }); + + it('should NOT sort nested object keys (top-level only)', () => { + const body = { b: { z: 1, a: 2 }, a: 'first' }; + // Top-level keys sorted: a, b + // Nested object z, a stays as-is in the original order + expect(buildCanonicalBody(body)).toBe('{"a":"first","b":{"z":1,"a":2}}'); + }); + + it('should stringify arrays as-is without sorting', () => { + const body = [3, 1, 2]; + expect(buildCanonicalBody(body)).toBe('[3,1,2]'); + }); + + it('should stringify array of objects as-is', () => { + const body = [{ b: 1, a: 2 }]; + expect(buildCanonicalBody(body)).toBe('[{"b":1,"a":2}]'); + }); + + it('should return string as-is', () => { + expect(buildCanonicalBody('already a string')).toBe('already a string'); + }); + + it('should stringify numbers', () => { + expect(buildCanonicalBody(42)).toBe('42'); + }); + + it('should stringify true', () => { + expect(buildCanonicalBody(true)).toBe('true'); + }); + + it('should return empty string for false (falsy)', () => { + // false is falsy, so it returns '' (same as null/undefined) + expect(buildCanonicalBody(false)).toBe(''); + }); + + it('should handle complex nested structure with top-level sorting only', () => { + const body = { + walletId: 'wallet-123', + amount: { currency: 'CHF', value: '1000' }, + name: 'Test Payment', + }; + // Top-level keys sorted: amount, name, walletId + expect(buildCanonicalBody(body)).toBe( + '{"amount":{"currency":"CHF","value":"1000"},"name":"Test Payment","walletId":"wallet-123"}', + ); + }); + }); + + // --- Tests for createCanonicalString --- // + + describe('createCanonicalString', () => { + it('should create canonical string for GET request without body', () => { + const result = createCanonicalString('GET', '/v1/auth/context'); + expect(result).toBe('GET/v1/auth/context'); + }); + + it('should create canonical string for GET request with query params', () => { + const result = createCanonicalString('GET', '/v1/wallets?pageNumber=1&pageSize=10'); + expect(result).toBe('GET/v1/wallets?pageNumber=1&pageSize=10'); + }); + + it('should uppercase the method', () => { + const result = createCanonicalString('get', '/v1/auth/context'); + expect(result).toBe('GET/v1/auth/context'); + }); + + it('should create canonical string for POST request with body', () => { + const body = { targetWalletId: 'target', sourceWalletId: 'source' }; + const result = createCanonicalString('POST', '/v1/quotes-fx', body); + // Body keys sorted: sourceWalletId, targetWalletId + expect(result).toBe('POST/v1/quotes-fx{"sourceWalletId":"source","targetWalletId":"target"}'); + }); + + it('should handle DELETE request', () => { + const result = createCanonicalString('DELETE', '/v1/accounts/123/payments/456'); + expect(result).toBe('DELETE/v1/accounts/123/payments/456'); + }); + }); + + // --- Tests for Amount Conversion --- // + + describe('convertRelioAmount', () => { + it('should convert minor units to major units', () => { + expect(convertRelioAmount('98434500')).toBe(984345); + }); + + it('should handle cents correctly', () => { + expect(convertRelioAmount('10050')).toBe(100.5); + }); + + it('should handle zero', () => { + expect(convertRelioAmount('0')).toBe(0); + }); + + it('should return 0 for invalid input', () => { + expect(convertRelioAmount('invalid')).toBe(0); + }); + + it('should return 0 for empty string', () => { + expect(convertRelioAmount('')).toBe(0); + }); + }); + + describe('toRelioAmount', () => { + it('should convert major units to minor units', () => { + expect(toRelioAmount(984345)).toBe('98434500'); + }); + + it('should handle decimals correctly', () => { + expect(toRelioAmount(100.5)).toBe('10050'); + }); + + it('should round to nearest cent', () => { + expect(toRelioAmount(100.555)).toBe('10056'); + expect(toRelioAmount(100.554)).toBe('10055'); + }); + + it('should handle zero', () => { + expect(toRelioAmount(0)).toBe('0'); + }); + + it('should handle small amounts', () => { + expect(toRelioAmount(0.01)).toBe('1'); + }); + }); +}); diff --git a/src/integration/bank/services/relio.service.ts b/src/integration/bank/services/relio.service.ts new file mode 100644 index 0000000000..bfdee4a855 --- /dev/null +++ b/src/integration/bank/services/relio.service.ts @@ -0,0 +1,321 @@ +import { Injectable } from '@nestjs/common'; +import { Method } from 'axios'; +import * as crypto from 'crypto'; +import { Config } from 'src/config/config'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { HttpService } from 'src/shared/services/http.service'; +import { + RelioAccount, + RelioAuthContext, + RelioCurrency, + RelioFxPaymentRequest, + RelioFxQuoteRequest, + RelioFxQuoteResponse, + RelioPaymentOrderRequest, + RelioPaymentOrderResponse, + RelioWallet, + RelioWalletListResponse, +} from '../dto/relio.dto'; + +export interface RelioBalanceInfo { + walletId: string; + iban: string; + currency: string; + availableBalance: number; + totalBalance: number; +} + +@Injectable() +export class RelioService { + private readonly logger = new DfxLogger(RelioService); + private privateKey: crypto.KeyObject | undefined; + + constructor(private readonly http: HttpService) { + this.initializePrivateKey(); + } + + private initializePrivateKey(): void { + const keyPem = Config.bank.relio?.privateKey; + if (!keyPem) return; + + try { + this.privateKey = crypto.createPrivateKey({ + key: keyPem, + format: 'pem', + type: 'pkcs8', + }); + } catch (e) { + this.logger.error('Failed to initialize Relio Ed25519 private key:', e); + } + } + + // --- AVAILABILITY CHECK --- // + + isAvailable(): boolean { + const { baseUrl, apiKey, privateKey, organizationId } = Config.bank.relio ?? {}; + return !!(baseUrl && apiKey && privateKey && organizationId && this.privateKey); + } + + // --- AUTH METHODS --- // + + async getAuthContext(): Promise { + return this.callApi('auth/context', 'GET'); + } + + // --- ACCOUNT METHODS --- // + + async getAccounts(pageNumber = 1, pageSize = 1000): Promise { + return this.callApi(`accounts?pageNumber=${pageNumber}&pageSize=${pageSize}`, 'GET'); + } + + async getAccount(accountId: string): Promise { + return this.callApi(`accounts/${accountId}`, 'GET'); + } + + // --- WALLET METHODS --- // + + async getWallets(pageNumber = 1, pageSize = 1000): Promise { + return this.callApi(`wallets?pageNumber=${pageNumber}&pageSize=${pageSize}`, 'GET'); + } + + async getWallet(walletId: string): Promise { + return this.callApi(`wallets/${walletId}`, 'GET'); + } + + async getBalances(): Promise { + const walletsResponse = await this.getWallets(); + + // Fetch all wallet details in parallel for better performance + const wallets = await Promise.all(walletsResponse.data.map((item) => this.getWallet(item.id))); + + return wallets.map((wallet) => ({ + walletId: wallet.id, + iban: wallet.iban, + currency: wallet.currency, + availableBalance: this.convertRelioAmount(wallet.availableBalance.amount), + totalBalance: this.convertRelioAmount(wallet.balance.amount), + })); + } + + // --- PAYMENT METHODS --- // + + async createPaymentOrder(request: RelioPaymentOrderRequest): Promise { + return this.callApi('payment-orders', 'POST', request); + } + + async sendPayment( + walletId: string, + amount: number, + currency: RelioCurrency, + recipientName: string, + recipientIban: string, + recipientCountry: string, + reference?: string, + recipientAddress?: string, + recipientCity?: string, + recipientPostCode?: string, + ): Promise { + const request: RelioPaymentOrderRequest = { + walletId, + name: `Payment to ${recipientName}`, + payment: { + payee: { + name: recipientName, + accountNumber: recipientIban, + country: recipientCountry, + ...(recipientAddress && { addressLine1: recipientAddress }), + ...(recipientCity && { city: recipientCity }), + ...(recipientPostCode && { postCode: recipientPostCode }), + }, + amount: { + currency, + amount: this.toRelioAmount(amount), + }, + ...(reference && { reference }), + }, + }; + + return this.createPaymentOrder(request); + } + + async cancelScheduledPayment(accountId: string, paymentId: string): Promise { + await this.callApi(`accounts/${accountId}/payments-not-executed/${paymentId}`, 'DELETE'); + } + + // --- FX METHODS --- // + + async getFxQuote( + sourceWalletId: string, + targetWalletId: string, + amount: string, + amountType: 'SOURCE' | 'TARGET' = 'SOURCE', + ): Promise { + const request: RelioFxQuoteRequest = { + sourceWalletId, + targetWalletId, + amount: { + type: amountType, + value: amount, + }, + }; + + return this.callApi('quotes-fx', 'POST', request); + } + + async executeFxPayment(quoteId: string, quoteOptionId: string, name: string, reference?: string): Promise { + const request: RelioFxPaymentRequest = { + quoteId, + quoteOptionId, + name, + ...(reference && { reference }), + }; + + await this.callApi('payment-orders/from-quote', 'POST', request); + } + + // --- AMOUNT CONVERSION --- // + + /** + * Convert Relio amount string (minor units) to number + * e.g., "98434500" -> 984345.00 + */ + convertRelioAmount(amount: string): number { + const numericAmount = parseInt(amount, 10); + if (isNaN(numericAmount)) return 0; + return numericAmount / 100; + } + + /** + * Convert number to Relio amount string (minor units) + * e.g., 984345.00 -> "98434500" + */ + toRelioAmount(amount: number): string { + return Math.round(amount * 100).toString(); + } + + // --- ED25519 SIGNING --- // + + /** + * Create canonical request string for signing + * Format: ${METHOD}${originalUrl}${sortedBody} + * + * Based on Relio NodeJS example: + * - originalUrl = path + queryString AS-IS (no sorting!) + * - body keys sorted at top-level only + * + * @see Relio API documentation - "Requests signing in NodeJS" section + */ + private createCanonicalString(method: string, originalUrl: string, body?: unknown): string { + const canonicalBody = this.buildCanonicalBody(body); + return `${method.toUpperCase()}${originalUrl}${canonicalBody}`; + } + + /** + * Build canonical body string with top-level sorted keys + * + * From Relio NodeJS example (page 4): + * ``` + * let body = ""; + * if (request.body) { + * if (typeof request.body === "object") { + * const sortedKeys = Object.keys(request.body).sort(); + * body = JSON.stringify( + * sortedKeys.reduce((acc, key) => ((acc[key] = request.body[key]), acc), {}) + * ); + * } else if (typeof request.body === "string") { + * body = request.body; + * } + * } + * ``` + * + * Key behaviors (matching NodeJS example exactly): + * - null/undefined → '' (falsy check: if (request.body)) + * - empty object {} → '{}' (truthy, gets stringified) + * - arrays → JSON.stringify as-is (no sorting) + * - objects → sort top-level keys only, then stringify + * - strings → use as-is + */ + private buildCanonicalBody(body: unknown): string { + // Matches: if (request.body) - falsy values return empty string + if (!body) { + return ''; + } + + // Matches: if (typeof request.body === "object") + if (typeof body === 'object') { + // Arrays: stringify as-is without sorting + if (Array.isArray(body)) { + return JSON.stringify(body); + } + + // Objects (including empty {}): sort top-level keys and stringify + const obj = body as Record; + const sortedKeys = Object.keys(obj).sort(); + const sortedObj: Record = {}; + + for (const key of sortedKeys) { + sortedObj[key] = obj[key]; + } + + return JSON.stringify(sortedObj); + } + + // Matches: else if (typeof request.body === "string") + if (typeof body === 'string') { + return body; + } + + // Other primitives (number, boolean) - stringify + return JSON.stringify(body); + } + + /** + * Sign the canonical string with Ed25519 private key + */ + private signRequest(canonicalString: string): string { + if (!this.privateKey) { + throw new Error('Relio Ed25519 private key not initialized'); + } + + const signature = crypto.sign(null, Buffer.from(canonicalString, 'utf8'), this.privateKey); + return signature.toString('base64'); + } + + // --- API CALL METHOD --- // + + private async callApi(endpoint: string, method: Method = 'GET', data?: unknown): Promise { + if (!this.isAvailable()) { + throw new Error('Relio is not configured'); + } + + const { baseUrl, apiKey } = Config.bank.relio; + + // Build originalUrl for signing: /v1/path?query (query params AS-IS, no sorting) + // This matches the Relio NodeJS example exactly + const [pathPart, queryPart] = endpoint.split('?'); + const originalUrl = `/v1/${pathPart}${queryPart ? '?' + queryPart : ''}`; + + // Create canonical string and sign + const canonicalString = this.createCanonicalString(method, originalUrl, data); + const signature = this.signRequest(canonicalString); + + try { + return await this.http.request({ + url: `${baseUrl}/${endpoint}`, + method, + data, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'x-signature': signature, + }, + }); + } catch (error) { + const message = error?.response?.data ? JSON.stringify(error.response.data) : error?.message || error; + + this.logger.error(`Relio API error (${method} ${endpoint}): ${message}`); + throw new Error(`Relio API error (${method} ${endpoint}): ${message}`); + } + } +}