From 9f9cf7388433d3bd07eb8aef58db238cdfe62896 Mon Sep 17 00:00:00 2001 From: Miracle Nnaji Date: Sat, 30 May 2026 12:58:04 +0100 Subject: [PATCH 1/3] feat: implement QuickBooks Online OAuth 2.0 flow with encrypted tokens and automated refresh --- .env.example | 11 +++ src/config/env.ts | 30 ++++++ src/index.ts | 11 ++- src/queue/accountingTokenRefreshQueue.ts | 55 +++++++++++ src/queue/accountingTokenRefreshWorker.ts | 52 ++++++++++ src/queue/index.ts | 10 ++ src/routes/accounting.ts | 71 ++++++++------ src/services/accounting.ts | 97 ++++++++++++++++--- .../mobilemoney/mobileMoneyService.ts | 6 +- 9 files changed, 300 insertions(+), 43 deletions(-) create mode 100644 src/queue/accountingTokenRefreshQueue.ts create mode 100644 src/queue/accountingTokenRefreshWorker.ts diff --git a/.env.example b/.env.example index cb32c241..25059cf0 100644 --- a/.env.example +++ b/.env.example @@ -484,6 +484,17 @@ JWT_SECRET=replace-with-a-secure-jwt-secret # Provider balance cache TTL (seconds) PROVIDER_BALANCE_CACHE_TTL=60 +# --------------------------------------------------------------------------- +# Accounting Integrations (QuickBooks & Xero) +# --------------------------------------------------------------------------- +QUICKBOOKS_CLIENT_ID=your_quickbooks_client_id +QUICKBOOKS_CLIENT_SECRET=your_quickbooks_client_secret +QUICKBOOKS_REDIRECT_URI=http://localhost:3000/api/accounting/quickbooks/callback + +XERO_CLIENT_ID=your_xero_client_id +XERO_CLIENT_SECRET=your_xero_client_secret +XERO_REDIRECT_URI=http://localhost:3000/api/accounting/xero/callback + # --------------------------------------------------------------------------- # Centralized Logging — Loki Transport (feature/centralized-logging) # --------------------------------------------------------------------------- diff --git a/src/config/env.ts b/src/config/env.ts index bb98570f..456820d2 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -110,6 +110,30 @@ export const env = cleanEnv(process.env, { desc: "API key for third-party AML/sanction screening provider (e.g. Elliptic, Chainalysis)", example: "ell_live_xxxxxxxxxxxx", }), + QUICKBOOKS_CLIENT_ID: str({ + default: "", + desc: "QuickBooks Online OAuth 2.0 Client ID", + }), + QUICKBOOKS_CLIENT_SECRET: str({ + default: "", + desc: "QuickBooks Online OAuth 2.0 Client Secret", + }), + QUICKBOOKS_REDIRECT_URI: str({ + default: "http://localhost:3000/api/accounting/quickbooks/callback", + desc: "QuickBooks Online OAuth 2.0 Redirect URI", + }), + XERO_CLIENT_ID: str({ + default: "", + desc: "Xero OAuth 2.0 Client ID", + }), + XERO_CLIENT_SECRET: str({ + default: "", + desc: "Xero OAuth 2.0 Client Secret", + }), + XERO_REDIRECT_URI: str({ + default: "http://localhost:3000/api/accounting/xero/callback", + desc: "Xero OAuth 2.0 Redirect URI", + }), }); // Re-export specific values for convenience @@ -132,4 +156,10 @@ export const { INDEX_REINDEX_MIN_SIZE_MB, INDEX_REINDEX_MAX_SCAN_COUNT, INDEX_REINDEX_MAX_ACTIVE_CONNECTIONS, + QUICKBOOKS_CLIENT_ID, + QUICKBOOKS_CLIENT_SECRET, + QUICKBOOKS_REDIRECT_URI, + XERO_CLIENT_ID, + XERO_CLIENT_SECRET, + XERO_REDIRECT_URI, } = env; diff --git a/src/index.ts b/src/index.ts index 33110c9f..a235dc39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,6 +88,8 @@ import exchangeRateBufferRoutes from "./routes/exchangeRateBuffers"; import adminAssetRoutes from "./routes/admin/assets"; import settingsRoutes from "./routes/settings"; import merchantWebhooksRouter from "./routes/merchantWebhooks"; +import accountingRoutes from "./routes/accounting"; + @@ -403,6 +405,7 @@ app.use("/api/exchange-rate-buffers", exchangeRateBufferRoutes); app.use("/api/admin/assets", adminAssetRoutes); app.use("/api/settings", settingsRoutes); app.use("/api/merchant/webhooks", merchantWebhooksRouter); +app.use("/api/accounting", accountingRoutes); // Subscriptions management app.use("/api/subscriptions", subscriptionsRoutes); @@ -586,9 +589,13 @@ async function initializeRuntime(): Promise { await layeredCache.init(); console.log("Layered cache (L1/L2) initialized"); - const { startProviderBalanceAlertWorker, scheduleProviderBalanceAlertJob } = - await import("./queue"); + const { + startProviderBalanceAlertWorker, + scheduleProviderBalanceAlertJob, + startAccountingTokenRefreshWorker, + } = await import("./queue"); startProviderBalanceAlertWorker(); + startAccountingTokenRefreshWorker(); await scheduleProviderBalanceAlertJob(); console.log("Provider balance alert queue initialized"); } catch (err) { diff --git a/src/queue/accountingTokenRefreshQueue.ts b/src/queue/accountingTokenRefreshQueue.ts new file mode 100644 index 00000000..c7a22a88 --- /dev/null +++ b/src/queue/accountingTokenRefreshQueue.ts @@ -0,0 +1,55 @@ +import { Queue, JobsOptions } from "bullmq"; +import { queueOptions } from "./config"; + +export const ACCOUNTING_TOKEN_REFRESH_QUEUE_NAME = "accounting-token-refresh"; + +export const accountingTokenRefreshQueue = new Queue( + ACCOUNTING_TOKEN_REFRESH_QUEUE_NAME, + queueOptions +); + +export interface AccountingTokenRefreshJobData { + connectionId: string; + provider: "quickbooks" | "xero"; +} + +/** + * Adds a job to refresh an accounting token. + * + * @param connectionId The ID of the connection to refresh + * @param provider The accounting provider + * @param delayMs Delay in milliseconds (e.g., 10 minutes before expiry) + */ +export async function addAccountingTokenRefreshJob( + connectionId: string, + provider: "quickbooks" | "xero", + delayMs: number +): Promise { + const jobOptions: JobsOptions = { + delay: delayMs, + removeOnComplete: true, + removeOnFail: { + age: 24 * 3600, // keep failed jobs for 24 hours + }, + attempts: 3, + backoff: { + type: "exponential", + delay: 5000, + }, + }; + + await accountingTokenRefreshQueue.add( + `refresh-${connectionId}`, + { connectionId, provider }, + jobOptions + ); +} + +export async function removeAccountingTokenRefreshJob(connectionId: string): Promise { + const jobs = await accountingTokenRefreshQueue.getJobs(["delayed", "waiting"]); + for (const job of jobs) { + if (job.data.connectionId === connectionId) { + await job.remove(); + } + } +} diff --git a/src/queue/accountingTokenRefreshWorker.ts b/src/queue/accountingTokenRefreshWorker.ts new file mode 100644 index 00000000..cea860d5 --- /dev/null +++ b/src/queue/accountingTokenRefreshWorker.ts @@ -0,0 +1,52 @@ +import { Worker, Job } from "bullmq"; +import { queueOptions } from "./config"; +import { ACCOUNTING_TOKEN_REFRESH_QUEUE_NAME, AccountingTokenRefreshJobData } from "./accountingTokenRefreshQueue"; +import { AccountingService } from "../services/accounting"; +import { logger } from "../services/logger"; + +let worker: Worker | null = null; + +export function startAccountingTokenRefreshWorker(): void { + if (worker) return; + + const accountingService = new AccountingService(); + + worker = new Worker( + ACCOUNTING_TOKEN_REFRESH_QUEUE_NAME, + async (job: Job) => { + const { connectionId, provider } = job.data; + + logger.info(`Processing token refresh for ${provider} connection ${connectionId}`); + + try { + if (provider === "quickbooks") { + await accountingService.refreshQuickBooksToken(connectionId); + } else if (provider === "xero") { + await accountingService.refreshXeroToken(connectionId); + } else { + throw new Error(`Unsupported accounting provider: ${provider}`); + } + + logger.info(`Successfully refreshed tokens for ${provider} connection ${connectionId}`); + } catch (error) { + logger.error(`Failed to refresh tokens for ${provider} connection ${connectionId}:`, error); + throw error; // Re-throw to trigger BullMQ retry + } + }, + queueOptions + ); + + worker.on("failed", (job, err) => { + logger.error(`Accounting token refresh job ${job?.id} failed:`, err); + }); + + logger.info("Accounting token refresh worker started"); +} + +export async function closeAccountingTokenRefreshWorker(): Promise { + if (worker) { + await worker.close(); + worker = null; + logger.info("Accounting token refresh worker closed"); + } +} diff --git a/src/queue/index.ts b/src/queue/index.ts index 27e30184..d90715ac 100644 --- a/src/queue/index.ts +++ b/src/queue/index.ts @@ -11,9 +11,14 @@ import { startProviderBalanceAlertWorker, } from "./providerBalanceAlertWorker"; import { closeAccountMergeWorker } from "./accountMergeWorker"; +import { + startAccountingTokenRefreshWorker, + closeAccountingTokenRefreshWorker, +} from "./accountingTokenRefreshWorker"; export async function shutdownQueue(): Promise { console.log("Shutting down queues..."); + await closeAccountingTokenRefreshWorker().catch(() => undefined); await closeProviderBalanceAlertWorker().catch(() => undefined); await closeProviderBalanceAlertQueue().catch(() => undefined); await closeAccountMergeWorker().catch(() => undefined); @@ -74,5 +79,10 @@ export { closeAccountMergeWorker, } from "./accountMergeWorker"; +export { + startAccountingTokenRefreshWorker, + closeAccountingTokenRefreshWorker, +}; + // Trace-ID propagation utilities export { withTraceId, traceIdFromJob, childLoggerWithTrace, TRACE_ID_KEY } from "./trace"; diff --git a/src/routes/accounting.ts b/src/routes/accounting.ts index 8fa440eb..d7f8d935 100644 --- a/src/routes/accounting.ts +++ b/src/routes/accounting.ts @@ -3,14 +3,16 @@ import { AccountingService, AccountingProvider } from "../services/accounting"; import { requireAuth } from "../middleware/auth"; import { validateRequest } from "../middleware/validation"; import { z } from "zod"; +import { v4 as uuidv4 } from "uuid"; const router = Router(); const accountingService = new AccountingService(); // Validation schemas -const connectQuickBooksSchema = z.object({ +const connectQuickBooksCallbackSchema = z.object({ code: z.string(), realmId: z.string(), + state: z.string(), }); const connectXeroSchema = z.object({ @@ -33,42 +35,51 @@ const syncDataSchema = z.object({ router.use(requireAuth); // Get authorization URLs -router.get("/auth/quickbooks/url", async (req: Request, res: Response, next: NextFunction) => { +router.get("/quickbooks/auth", async (req: Request, res: Response, next: NextFunction) => { try { - const authUrl = accountingService.getQuickBooksAuthUrl(); - res.json({ authUrl }); + const state = uuidv4(); + // Store state in session for CSRF protection + (req.session as any).qbOAuthState = state; + + const authUrl = accountingService.getQuickBooksAuthUrl(state); + res.redirect(authUrl); } catch (error) { next(error); } }); -router.get("/auth/xero/url", async (req: Request, res: Response, next: NextFunction) => { +router.get("/xero/auth", async (req: Request, res: Response, next: NextFunction) => { try { - const authUrl = accountingService.getXeroAuthUrl(); - res.json({ authUrl }); + const state = uuidv4(); + (req.session as any).xeroOAuthState = state; + + const authUrl = accountingService.getXeroAuthUrl(state); + res.redirect(authUrl); } catch (error) { next(error); } }); // Handle OAuth callbacks -router.post( - "/auth/quickbooks/callback", - validateRequest(connectQuickBooksSchema), +router.get( + "/quickbooks/callback", async (req: Request, res: Response, next: NextFunction) => { try { - const { code, realmId } = req.body; + const { code, realmId, state } = req.query as { code: string; realmId: string; state: string }; const userId = (req as any).user.id; + // Validate state + const savedState = (req.session as any).qbOAuthState; + if (!state || state !== savedState) { + return res.status(400).json({ error: "Invalid state parameter" }); + } + delete (req.session as any).qbOAuthState; + const connection = await accountingService.handleQuickBooksCallback(code, realmId, userId); - res.status(201).json({ - connection: { - id: connection.id, - provider: connection.provider, - isActive: connection.isActive, - createdAt: connection.createdAt, - }, + res.json({ + message: "QuickBooks connected successfully", + connectionId: connection.id, }); } catch (error) { next(error); @@ -76,23 +87,25 @@ router.post( } ); -router.post( - "/auth/xero/callback", - validateRequest(connectXeroSchema), +router.get( + "/xero/callback", async (req: Request, res: Response, next: NextFunction) => { try { - const { code } = req.body; + const { code, state } = req.query as { code: string; state: string }; const userId = (req as any).user.id; + // Validate state + const savedState = (req.session as any).xeroOAuthState; + if (!state || state !== savedState) { + return res.status(400).json({ error: "Invalid state parameter" }); + } + delete (req.session as any).xeroOAuthState; + const connection = await accountingService.handleXeroCallback(code, userId); - res.status(201).json({ - connection: { - id: connection.id, - provider: connection.provider, - isActive: connection.isActive, - createdAt: connection.createdAt, - }, + res.json({ + message: "Xero connected successfully", + connectionId: connection.id, }); } catch (error) { next(error); diff --git a/src/services/accounting.ts b/src/services/accounting.ts index c77b9b97..2eaef9d4 100644 --- a/src/services/accounting.ts +++ b/src/services/accounting.ts @@ -2,6 +2,9 @@ import { pool } from "../config/database"; import { redisClient } from "../config/redis"; import axios from "axios"; import { v4 as uuidv4 } from "uuid"; +import { encryptField, decryptField } from "../utils/encryption"; +import { addAccountingTokenRefreshJob, removeAccountingTokenRefreshJob } from "../queue/accountingTokenRefreshQueue"; +import { logger } from "./logger"; export enum AccountingProvider { QUICKBOOKS = "quickbooks", @@ -85,7 +88,7 @@ export class AccountingService { } // OAuth2 Authorization URLs - getQuickBooksAuthUrl(): string { + getQuickBooksAuthUrl(state: string = uuidv4()): string { const scopes = [ "com.intuit.quickbooks.accounting", "com.intuit.quickbooks.payment", @@ -96,17 +99,18 @@ export class AccountingService { redirect_uri: this.quickbooksRedirectUri, response_type: "code", scope: scopes, - state: uuidv4(), + state, }); return `https://appcenter.intuit.com/connect/oauth2?${params.toString()}`; } - getXeroAuthUrl(): string { + getXeroAuthUrl(state: string = uuidv4()): string { const scopes = [ "accounting.transactions", "accounting.reports.read", "accounting.settings", + "offline_access", ].join(" "); const params = new URLSearchParams({ @@ -114,7 +118,7 @@ export class AccountingService { redirect_uri: this.xeroRedirectUri, response_type: "code", scope: scopes, - state: uuidv4(), + state, }); return `https://login.xero.com/identity/connect/authorize?${params.toString()}`; @@ -143,8 +147,11 @@ export class AccountingService { }; await this.saveConnection(connection); + await this.scheduleTokenRefresh(connection); + return connection; } catch (error) { + logger.error(`QuickBooks OAuth callback failed: ${error}`); throw new Error(`QuickBooks OAuth failed: ${error}`); } } @@ -174,12 +181,33 @@ export class AccountingService { }; await this.saveConnection(connection); + await this.scheduleTokenRefresh(connection); + return connection; } catch (error) { + logger.error(`Xero OAuth callback failed: ${error}`); throw new Error(`Xero OAuth failed: ${error}`); } } + private async scheduleTokenRefresh(connection: AccountingConnection): Promise { + // Refresh 10 minutes before expiry + const refreshBufferMs = 10 * 60 * 1000; + const delayMs = connection.expiresAt.getTime() - Date.now() - refreshBufferMs; + + // If token is already expired or expires very soon, refresh immediately (small delay for safety) + const finalDelayMs = Math.max(5000, delayMs); + + await removeAccountingTokenRefreshJob(connection.id); + await addAccountingTokenRefreshJob( + connection.id, + connection.provider, + finalDelayMs + ); + + logger.info(`Scheduled token refresh for connection ${connection.id} in ${finalDelayMs}ms`); + } + // Exchange authorization code for tokens private async exchangeQuickBooksCode(code: string): Promise { const response = await axios.post( @@ -259,12 +287,24 @@ export class AccountingService { } ); - await this.updateConnectionTokens(connectionId, { + const updatedConnection: AccountingConnection = { + ...connection, accessToken: response.data.access_token, refreshToken: response.data.refresh_token, expiresAt: new Date(Date.now() + response.data.expires_in * 1000), + updatedAt: new Date(), + }; + + await this.updateConnectionTokens(connectionId, { + accessToken: updatedConnection.accessToken, + refreshToken: updatedConnection.refreshToken, + expiresAt: updatedConnection.expiresAt, }); + + await this.scheduleTokenRefresh(updatedConnection); + logger.info(`Successfully refreshed QuickBooks token for connection ${connectionId}`); } catch (error) { + logger.error(`QuickBooks token refresh failed for ${connectionId}: ${error}`); throw new Error(`QuickBooks token refresh failed: ${error}`); } } @@ -292,12 +332,24 @@ export class AccountingService { } ); - await this.updateConnectionTokens(connectionId, { + const updatedConnection: AccountingConnection = { + ...connection, accessToken: response.data.access_token, refreshToken: response.data.refresh_token, expiresAt: new Date(Date.now() + response.data.expires_in * 1000), + updatedAt: new Date(), + }; + + await this.updateConnectionTokens(connectionId, { + accessToken: updatedConnection.accessToken, + refreshToken: updatedConnection.refreshToken, + expiresAt: updatedConnection.expiresAt, }); + + await this.scheduleTokenRefresh(updatedConnection); + logger.info(`Successfully refreshed Xero token for connection ${connectionId}`); } catch (error) { + logger.error(`Xero token refresh failed for ${connectionId}: ${error}`); throw new Error(`Xero token refresh failed: ${error}`); } } @@ -479,6 +531,9 @@ export class AccountingService { // Database operations private async saveConnection(connection: AccountingConnection): Promise { + const encryptedAccessToken = encryptField(connection.accessToken); + const encryptedRefreshToken = encryptField(connection.refreshToken); + await pool.query( `INSERT INTO accounting_connections (id, user_id, provider, realm_id, tenant_id, access_token, refresh_token, expires_at, is_active, created_at, updated_at) @@ -495,8 +550,8 @@ export class AccountingService { connection.provider, connection.realmId, connection.tenantId, - connection.accessToken, - connection.refreshToken, + encryptedAccessToken, + encryptedRefreshToken, connection.expiresAt, connection.isActive, connection.createdAt, @@ -509,9 +564,12 @@ export class AccountingService { connectionId: string, tokens: { accessToken: string; refreshToken: string; expiresAt: Date } ): Promise { + const encryptedAccessToken = encryptField(tokens.accessToken); + const encryptedRefreshToken = encryptField(tokens.refreshToken); + await pool.query( "UPDATE accounting_connections SET access_token = $1, refresh_token = $2, expires_at = $3, updated_at = $4 WHERE id = $5", - [tokens.accessToken, tokens.refreshToken, tokens.expiresAt, new Date(), connectionId] + [encryptedAccessToken, encryptedRefreshToken, tokens.expiresAt, new Date(), connectionId] ); } @@ -525,7 +583,16 @@ export class AccountingService { return null; } - return result.rows[0]; + const row = result.rows[0]; + return { + ...row, + accessToken: decryptField(row.access_token) || row.access_token, + refreshToken: decryptField(row.refresh_token) || row.refresh_token, + expiresAt: new Date(row.expires_at), + isActive: row.is_active, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; } async getUserConnections(userId: string): Promise { @@ -534,7 +601,15 @@ export class AccountingService { [userId] ); - return result.rows; + return result.rows.map(row => ({ + ...row, + accessToken: decryptField(row.access_token) || row.access_token, + refreshToken: decryptField(row.refresh_token) || row.refresh_token, + expiresAt: new Date(row.expires_at), + isActive: row.is_active, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + })); } private async createSyncLog(syncLog: SyncLog): Promise { diff --git a/src/services/mobilemoney/mobileMoneyService.ts b/src/services/mobilemoney/mobileMoneyService.ts index a138b6f5..ed5bf940 100644 --- a/src/services/mobilemoney/mobileMoneyService.ts +++ b/src/services/mobilemoney/mobileMoneyService.ts @@ -37,7 +37,11 @@ export interface MobileMoneyProvider { ): Promise<{ success: boolean; data?: unknown; error?: unknown }>; sendBatchPayout?( items: BatchPayoutItem[], - ): Promise<{ success: boolean; results: BatchPayoutResult[]; error?: unknown }>; + ): Promise<{ + success: boolean; + results: BatchPayoutResult[]; + error?: unknown; + }>; getTransactionStatus( referenceId: string, ): Promise<{ status: ProviderTransactionStatus }>; From 6ef0ea43db649bef064d7e603720c67b4ea09cfa Mon Sep 17 00:00:00 2001 From: Miracle Nnaji Date: Sat, 30 May 2026 13:57:03 +0100 Subject: [PATCH 2/3] chore(conflict): resolve conflicts --- src/services/accounting.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/services/accounting.ts b/src/services/accounting.ts index 3d8b5740..3d43c7b6 100644 --- a/src/services/accounting.ts +++ b/src/services/accounting.ts @@ -2244,7 +2244,7 @@ export class AccountingService { provider: string; createdAt: Date; }, - mappings: CategoryMapping[] + mappings: CategoryMapping[], ): Promise { const connectionData = await this.getConnection(connection.id); const txnDate = transaction.createdAt.toISOString().split("T")[0]; @@ -2304,7 +2304,7 @@ export class AccountingService { "Xero-tenant-id": connectionData!.tenantId, "Content-Type": "application/json", }, - } + }, ); } @@ -2320,7 +2320,7 @@ export class AccountingService { referenceNumber: string; provider: string; createdAt: Date; - } + }, ): Promise { const freshConnection = await this.getConnection(connection.id); const txnDate = transaction.createdAt.toISOString().split("T")[0]; @@ -2351,12 +2351,17 @@ export class AccountingService { "Xero-tenant-id": freshConnection!.tenantId, "Content-Type": "application/json", }, - } + }, ); } - private getMappedCategory(mappings: CategoryMapping[], mobileMoneyCategory: string): string | null { - const mapping = mappings.find(m => m.mobileMoneyCategory === mobileMoneyCategory); + private getMappedCategory( + mappings: CategoryMapping[], + mobileMoneyCategory: string, + ): string | null { + const mapping = mappings.find( + (m) => m.mobileMoneyCategory === mobileMoneyCategory, + ); return mapping ? mapping.accountingCategoryId : null; } @@ -2428,7 +2433,10 @@ export class AccountingService { } else if (connection.provider === AccountingProvider.XERO) { if (transaction.type === "withdraw") { const mappings = await this.getCategoryMappings(connection.id); - const withdrawalAccountId = this.getMappedCategory(mappings, "withdrawal"); + const withdrawalAccountId = this.getMappedCategory( + mappings, + "withdrawal", + ); if (withdrawalAccountId) { await this.syncWithdrawalToXeroBill(fresh, transaction, mappings); From 9bfc0081b649654734bbcc220fba2dfc1f79e79d Mon Sep 17 00:00:00 2001 From: Miracle Nnaji Date: Sat, 30 May 2026 14:03:31 +0100 Subject: [PATCH 3/3] fix(lint): resolved the gitguardian issue --- src/stellar/sep02.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/stellar/sep02.ts b/src/stellar/sep02.ts index 49c05601..620d8364 100644 --- a/src/stellar/sep02.ts +++ b/src/stellar/sep02.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from "express"; import { Pool } from "pg"; import { z } from "zod"; import crypto from "crypto"; +import { getNetworkPassphrase } from "../config/stellar"; // ── Validation Schema ──────────────────────────────────────────────────────── @@ -161,13 +162,9 @@ export function createFederationRouter(db: Pool): Router { // ── TOML Helper ────────────────────────────────────────────────────────────── export function buildStellarToml(): string { - const network = process.env.STELLAR_NETWORK || "testnet"; - const isMainnet = network === "mainnet"; - const passphrase = isMainnet - ? "Public Global Stellar Network ; September 2015" - : "Test SDF Network ; September 2015"; + const passphrase = getNetworkPassphrase(); const domain = process.env.STELLAR_FEDERATION_DOMAIN || "mobilemoney.com"; - + return [ `FEDERATION_SERVER="https://${domain}/federation"`, `NETWORK_PASSPHRASE="${passphrase}"`