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