diff --git a/migrations/20260630_add_sandbox_mode.sql b/migrations/20260630_add_sandbox_mode.sql new file mode 100644 index 00000000..56a123fa --- /dev/null +++ b/migrations/20260630_add_sandbox_mode.sql @@ -0,0 +1,6 @@ + +-- Add is_sandbox column to api_keys table +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS is_sandbox BOOLEAN DEFAULT FALSE NOT NULL; + +-- Add is_sandbox column to transactions table +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS is_sandbox BOOLEAN DEFAULT FALSE NOT NULL; diff --git a/src/auth/apikeys.ts b/src/auth/apikeys.ts index 11e52dbb..9e9cec99 100644 --- a/src/auth/apikeys.ts +++ b/src/auth/apikeys.ts @@ -209,6 +209,9 @@ export interface ApiKey { * Omit to allow all routes (subject to scope checks). */ allowedRoutes?: Array<{ method: string; pathPrefix: string }>; + + /** Whether this is a sandbox API key */ + isSandbox?: boolean; } // ─── Factory & Helpers ──────────────────────────────────────────────────────── @@ -227,6 +230,8 @@ export interface CreateApiKeyOptions { allowedTimeWindow?: TimeWindow; allowedIpCidrs?: string[]; allowedRoutes?: Array<{ method: string; pathPrefix: string }>; + /** Whether this is a sandbox API key */ + isSandbox?: boolean; } /** @@ -256,6 +261,7 @@ export function createApiKey( allowedTimeWindow: options.allowedTimeWindow, allowedIpCidrs: options.allowedIpCidrs, allowedRoutes: options.allowedRoutes, + isSandbox: options.isSandbox, }; user.apiKeys.push(newKey); diff --git a/src/controllers/transactionController.ts b/src/controllers/transactionController.ts index af494792..46c740fe 100644 --- a/src/controllers/transactionController.ts +++ b/src/controllers/transactionController.ts @@ -600,6 +600,7 @@ async function processTransactionRequest( ? buildIdempotencyExpiry() : null, locationMetadata: (req as any).geoLocation ?? null, + isSandbox: (req as any).isSandbox ?? false, }); void monitorTransactionForAML(transaction); void applyTravelRule(transaction); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 92c6a0f0..0dc1ef10 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -90,7 +90,7 @@ export const requireAuth = async ( // 1. Look up from the database first (scoped keys) try { const result = await queryRead( - `SELECT permissions, is_active, expires_at + `SELECT permissions, is_active, expires_at, is_sandbox FROM api_keys WHERE key = $1 LIMIT 1`, @@ -108,6 +108,7 @@ export const requireAuth = async ( (req as AuthRequest).user = { id: "api-key-user", role: "admin" }; (req as any).apiKeyPermissions = row.permissions; + (req as any).isSandbox = row.is_sandbox; return next(); } } catch (err) { diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 0d299395..bc963826 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -85,6 +85,7 @@ const TRANSACTION_SELECT_COLUMNS = ` user_id AS "userId", idempotency_key AS "idempotencyKey", idempotency_expires_at AS "idempotencyExpiresAt", + is_sandbox AS "isSandbox", created_at AS "createdAt", updated_at AS "updatedAt" `; @@ -161,6 +162,7 @@ export function mapTransactionRow(row: any): any { idempotencyExpiresAt: row.idempotencyExpiresAt ? new Date(row.idempotencyExpiresAt) : null, + isSandbox: row.isSandbox ?? false, createdAt: new Date(row.createdAt), updatedAt: row.updatedAt ? new Date(row.updatedAt) : null, }; @@ -228,9 +230,9 @@ export class TransactionModel { reference_number, provider_reference, type, amount, currency, original_amount, converted_amount, phone_number, provider, stellar_address, status, tags, notes, user_id, - idempotency_key, idempotency_expires_at, metadata, location_metadata + idempotency_key, idempotency_expires_at, metadata, location_metadata, is_sandbox ) VALUES ( - $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18 + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19 ) RETURNING *`, [ ref, @@ -251,6 +253,7 @@ export class TransactionModel { data.idempotencyExpiresAt ?? null, JSON.stringify(metadata), data.locationMetadata ? JSON.stringify(data.locationMetadata) : null, + data.isSandbox ?? false, ], ); diff --git a/src/queue/worker.ts b/src/queue/worker.ts index c3a07172..74375fca 100644 --- a/src/queue/worker.ts +++ b/src/queue/worker.ts @@ -23,6 +23,7 @@ import { capturePersistentFailure } from "./dlq"; import { queryRead, queryWrite } from "../config/database"; import subscriptionModel from "../models/subscription"; import logger from "../utils/logger"; +import { sandboxService } from "../services/sandbox/sandboxService"; const transactionModel = new TransactionModel(); const mobileMoneyService = new MobileMoneyService(); @@ -172,8 +173,8 @@ async function sendTransactionPush( error, }); } - } catch (pushError) { - console.error(`[${transactionId}] Push notification failed:`, pushError); + } catch (pushErr) { + console.error(`[${transactionId}] Push notification failed:`, pushErr); } } @@ -222,6 +223,11 @@ async function processTransaction( const log = logger.child(logFields); log.info({ type, provider }, `[RabbitMQ] Processing transaction`); + // Fetch transaction to check if it's a sandbox transaction + const txRow = await transactionModel.findById(transactionId); + const isSandbox = txRow?.isSandbox ?? false; + log.info({ isSandbox }, `[Sandbox] Transaction sandbox mode: ${isSandbox}`); + const maxAttempts = Math.max( 1, parseInt(process.env.MAX_RETRY_ATTEMPTS || "3", 10), @@ -251,7 +257,6 @@ async function processTransaction( }; // Resolve sender name for sanction screening (best-effort; falls back to phone number) - const txRow = await transactionModel.findById(transactionId); const senderName = (txRow?.userId ? await resolveKycName(txRow.userId) : null) ?? phoneNumber; // Receiver is the mobile money account holder identified by their phone number @@ -287,59 +292,33 @@ async function processTransaction( } }; - const stellarResult = await withRetry( - () => { - // Use high-throughput pool service when available; falls back to single-account mode - const issuerSecret = process.env.STELLAR_ISSUER_SECRET?.trim(); - if (highThroughputService.isServiceInitialized() && issuerSecret) { - const issuerKp = require("stellar-sdk").Keypair.fromSecret(issuerSecret); - return highThroughputService.submitPayment({ - sourceAccount: issuerKp.publicKey(), - sourceSecret: issuerSecret, - destination: stellarAddress, - asset: "native", - amount: String(amount), - }).then(r => ({ hash: r.hash, submittedAt: new Date() })); - } - return stellarService.sendPayment(stellarAddress, amount, senderName, receiverName); - }, - retryConfig, - ); - - // Store Stellar transaction details in metadata - if (stellarResult.hash) { - const currentMetadata = - (await transactionModel.findById(transactionId))?.metadata || {}; - const updatedMetadata = { - ...currentMetadata, - stellar: { - transactionHash: stellarResult.hash, - submittedAt: stellarResult.submittedAt?.toISOString(), - feeBumps: [], - }, - }; - await transactionModel.updateMetadata(transactionId, updatedMetadata); - } - - await updateProgress(transactionId, 90); try { await updateProgress(transactionId, 10); if (type === "deposit") { await updateProgress(transactionId, 20); - const mobileMoneyResult = await withRetry(async () => { - const result = await mobileMoneyService.initiatePayment( + let mobileMoneyResult; + if (isSandbox) { + mobileMoneyResult = await sandboxService.simulateInitiatePayment( provider, phoneNumber, amount, - requestId, ); - if (!result.success) { - throw new Error(getProviderFailureMessage(result)); - } - return result; - }, retryConfig); + } else { + mobileMoneyResult = await withRetry(async () => { + const result = await mobileMoneyService.initiatePayment( + provider, + phoneNumber, + amount, + requestId, + ); + if (!result.success) { + throw new Error(getProviderFailureMessage(result)); + } + return result; + }, retryConfig); + } // Issue #515: Log provider response time in transaction metadata if (mobileMoneyResult.providerResponseTimeMs !== undefined) { @@ -361,25 +340,34 @@ async function processTransaction( } await updateProgress(transactionId, 70); - await withRetry( - () => { - // Use high-throughput pool service when available; falls back to single-account mode - const issuerSecret = process.env.STELLAR_ISSUER_SECRET?.trim(); - if (highThroughputService.isServiceInitialized() && issuerSecret) { - const issuerKp = require("stellar-sdk").Keypair.fromSecret(issuerSecret); - return highThroughputService.submitPayment({ - sourceAccount: issuerKp.publicKey(), - sourceSecret: issuerSecret, - destination: stellarAddress, - asset: "native", - amount: String(amount), - }); - } - return stellarService.sendPayment(stellarAddress, amount, senderName, receiverName); - }, - retryConfig, - ); + let stellarResult; + if (isSandbox) { + stellarResult = await sandboxService.simulateStellarPayment( + stellarAddress, + amount, + ); + } else { + stellarResult = await withRetry( + () => { + // Use high-throughput pool service when available; falls back to single-account mode + const issuerSecret = process.env.STELLAR_ISSUER_SECRET?.trim(); + if (highThroughputService.isServiceInitialized() && issuerSecret) { + const issuerKp = require("stellar-sdk").Keypair.fromSecret(issuerSecret); + return highThroughputService.submitPayment({ + sourceAccount: issuerKp.publicKey(), + sourceSecret: issuerSecret, + destination: stellarAddress, + asset: "native", + amount: String(amount), + }).then(r => ({ hash: r.hash, submittedAt: new Date() })); + } + return stellarService.sendPayment(stellarAddress, amount, senderName, receiverName); + }, + retryConfig, + ); + } + // Store Stellar transaction details in metadata if (stellarResult.hash) { const currentMetadata = (await transactionModel.findById(transactionId))?.metadata || {}; @@ -430,18 +418,71 @@ async function processTransaction( } else { await updateProgress(transactionId, 20); - const mobileMoneyResult = await withRetry(async () => { - const result = await mobileMoneyService.sendPayout( + let stellarResult; + if (isSandbox) { + stellarResult = await sandboxService.simulateStellarPayment( + stellarAddress, + amount, + ); + } else { + stellarResult = await withRetry( + () => { + // Use high-throughput pool service when available; falls back to single-account mode + const issuerSecret = process.env.STELLAR_ISSUER_SECRET?.trim(); + if (highThroughputService.isServiceInitialized() && issuerSecret) { + const issuerKp = require("stellar-sdk").Keypair.fromSecret(issuerSecret); + return highThroughputService.submitPayment({ + sourceAccount: issuerKp.publicKey(), + sourceSecret: issuerSecret, + destination: stellarAddress, + asset: "native", + amount: String(amount), + }).then(r => ({ hash: r.hash, submittedAt: new Date() })); + } + return stellarService.sendPayment(stellarAddress, amount, senderName, receiverName); + }, + retryConfig, + ); + } + + // Store Stellar transaction details in metadata + if (stellarResult.hash) { + const currentMetadata = + (await transactionModel.findById(transactionId))?.metadata || {}; + const updatedMetadata = { + ...currentMetadata, + stellar: { + transactionHash: stellarResult.hash, + submittedAt: stellarResult.submittedAt?.toISOString(), + feeBumps: [], + }, + }; + await transactionModel.updateMetadata(transactionId, updatedMetadata); + } + + await updateProgress(transactionId, 50); + + let mobileMoneyResult; + if (isSandbox) { + mobileMoneyResult = await sandboxService.simulateSendPayout( provider, phoneNumber, amount, - requestId, ); - if (!result.success) { - throw new Error(getProviderFailureMessage(result)); - } - return result; - }, retryConfig); + } else { + mobileMoneyResult = await withRetry(async () => { + const result = await mobileMoneyService.sendPayout( + provider, + phoneNumber, + amount, + requestId, + ); + if (!result.success) { + throw new Error(getProviderFailureMessage(result)); + } + return result; + }, retryConfig); + } // Issue #515: Log provider response time in transaction metadata if (mobileMoneyResult.providerResponseTimeMs !== undefined) { @@ -455,8 +496,6 @@ async function processTransaction( ); } - await updateProgress(transactionId, 50); - if (!mobileMoneyResult.success) { throw new Error(getProviderFailureMessage(mobileMoneyResult)); } diff --git a/src/services/sandbox/sandboxService.ts b/src/services/sandbox/sandboxService.ts new file mode 100644 index 00000000..55deeb93 --- /dev/null +++ b/src/services/sandbox/sandboxService.ts @@ -0,0 +1,102 @@ +export type SandboxOutcome = "success" | "insufficient_funds" | "timeout"; + +export interface SandboxResult { + success: boolean; + providerResponseTimeMs: number; + error?: string; + providerReference?: string; +} + +export class SandboxService { + private phoneNumberOutcomes: Map; + private defaultOutcome: SandboxOutcome; + + constructor() { + // Default mappings: specific phone numbers trigger specific outcomes + this.phoneNumberOutcomes = new Map([ + ["+1234567890", "success"], + ["+1234567891", "insufficient_funds"], + ["+1234567892", "timeout"], + ]); + this.defaultOutcome = "success"; + } + + /** + * Get the outcome for a phone number + */ + getOutcome(phoneNumber: string): SandboxOutcome { + // Normalize the phone number (remove non-digit characters except leading +) + const normalized = phoneNumber.trim(); + return this.phoneNumberOutcomes.get(normalized) || this.defaultOutcome; + } + + /** + * Simulate a mobile money payment (deposit) + */ + async simulateInitiatePayment( + provider: string, + phoneNumber: string, + amount: string, + ): Promise { + const startTime = Date.now(); + await new Promise(resolve => setTimeout(resolve, Math.random() * 200 + 50)); // Simulate network delay + const endTime = Date.now(); + + const outcome = this.getOutcome(phoneNumber); + + switch (outcome) { + case "success": + return { + success: true, + providerResponseTimeMs: endTime - startTime, + providerReference: `sandbox-ref-${Date.now()}`, + }; + case "insufficient_funds": + return { + success: false, + providerResponseTimeMs: endTime - startTime, + error: "Insufficient funds in mobile money account", + }; + case "timeout": + return { + success: false, + providerResponseTimeMs: endTime - startTime, + error: "Provider timeout", + }; + default: + return { + success: true, + providerResponseTimeMs: endTime - startTime, + providerReference: `sandbox-ref-${Date.now()}`, + }; + } + } + + /** + * Simulate a mobile money payout (withdrawal) + */ + async simulateSendPayout( + provider: string, + phoneNumber: string, + amount: string, + ): Promise { + // Same as initiate payment for now + return this.simulateInitiatePayment(provider, phoneNumber, amount); + } + + /** + * Simulate a Stellar payment + */ + async simulateStellarPayment( + destination: string, + amount: string, + ): Promise<{ hash: string; submittedAt: Date }> { + await new Promise(resolve => setTimeout(resolve, Math.random() * 200 + 50)); + return { + hash: `sandbox-tx-hash-${Date.now()}`, + submittedAt: new Date(), + }; + } +} + +export const sandboxService = new SandboxService();