Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions migrations/20260630_add_sandbox_mode.sql
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions src/auth/apikeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -256,6 +261,7 @@ export function createApiKey(
allowedTimeWindow: options.allowedTimeWindow,
allowedIpCidrs: options.allowedIpCidrs,
allowedRoutes: options.allowedRoutes,
isSandbox: options.isSandbox,
};

user.apiKeys.push(newKey);
Expand Down
1 change: 1 addition & 0 deletions src/controllers/transactionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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) {
Expand Down
7 changes: 5 additions & 2 deletions src/models/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
`;
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
Expand All @@ -251,6 +253,7 @@ export class TransactionModel {
data.idempotencyExpiresAt ?? null,
JSON.stringify(metadata),
data.locationMetadata ? JSON.stringify(data.locationMetadata) : null,
data.isSandbox ?? false,
],
);

Expand Down
187 changes: 113 additions & 74 deletions src/queue/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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 || {};
Expand Down Expand Up @@ -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) {
Expand All @@ -455,8 +496,6 @@ async function processTransaction(
);
}

await updateProgress(transactionId, 50);

if (!mobileMoneyResult.success) {
throw new Error(getProviderFailureMessage(mobileMoneyResult));
}
Expand Down
Loading
Loading