Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Store exchange rates as decimal strings so quote arithmetic and API responses
-- never round through JavaScript floats. PostgreSQL's text cast can emit
-- scientific notation for existing DOUBLE PRECISION values; to_char reconciles
-- existing rows into a plain decimal representation before the column becomes
-- TEXT.
ALTER TABLE "Quote"
ALTER COLUMN "rate" TYPE TEXT
USING CASE
WHEN "rate" IS NULL THEN NULL
ELSE trim(trailing '.' FROM trim(trailing '0' FROM to_char("rate", 'FM999999999999999999999999999999990.999999999999999999999999999999')))
END;
8 changes: 7 additions & 1 deletion apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ model Transaction {
id String @id @default(cuid())
userId String
type String
/// Canonical decimal string validated by src/utils/money.js; never serialize monetary values as floats.
amount String
asset String @default("USDC")
fiatCurrency String?
/// Canonical decimal string for configured fiat currencies.
fiatAmount String?
rail String @default("unknown")
routeType String @default("unknown")
Expand Down Expand Up @@ -219,9 +221,13 @@ model Quote {
userId String?
sourceCurrency String
targetCurrency String
/// Canonical decimal string in sourceCurrency precision.
sourceAmount String
/// Canonical decimal string in targetCurrency precision.
targetAmount String?
rate Float?
/// Decimal string exchange rate; do not use Float for quote math.
rate String?
/// Canonical decimal string in sourceCurrency precision.
fee String?
provider String?
route String?
Expand Down
30 changes: 17 additions & 13 deletions apps/api/src/compliance/compliance.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ const config = require('../config/env');
const prisma = require('../common/prisma');
const logger = require('../utils/logger');
const smileId = require('./smileId.provider');
const { assertValidAmount, add, compare, formatUnits, getAssetRule } = require('../utils/money');

const tierLimits = {
0: { daily: 0, single: 0 },
1: { daily: Number(process.env.TIER_1_DAILY_LIMIT || 50000), single: Number(process.env.TIER_1_SINGLE_LIMIT || 20000) },
2: { daily: Number(process.env.TIER_2_DAILY_LIMIT || 500000), single: Number(process.env.TIER_2_SINGLE_LIMIT || 200000) },
3: { daily: Number(process.env.TIER_3_DAILY_LIMIT || 5000000), single: Number(process.env.TIER_3_SINGLE_LIMIT || 1000000) },
1: { daily: process.env.TIER_1_DAILY_LIMIT || '50000.00', single: process.env.TIER_1_SINGLE_LIMIT || '20000.00' },
2: { daily: process.env.TIER_2_DAILY_LIMIT || '500000.00', single: process.env.TIER_2_SINGLE_LIMIT || '200000.00' },
3: { daily: process.env.TIER_3_DAILY_LIMIT || '5000000.00', single: process.env.TIER_3_SINGLE_LIMIT || '1000000.00' },
};

const SANCTIONS_BLOCKED_COUNTRIES = new Set(['KP', 'IR', 'SY', 'CU', 'SD', 'SDN']);
Expand Down Expand Up @@ -185,10 +186,10 @@ const processSmileIdCallback = async (payload) => {

const cryptoHash = (value) => require('crypto').createHash('sha256').update(value).digest('hex');

const calculateRiskScore = ({ amount, routeType, destinationCountry, profileRiskScore = 0 }) => {
const calculateRiskScore = ({ amount, asset = 'NGN', routeType, destinationCountry, profileRiskScore = 0 }) => {
let score = 10;
if (Number(amount) > 100000) score += 30;
if (Number(amount) > 50000) score += 10;
if (compare(assertValidAmount(amount, asset), '100000.00', asset) > 0) score += 30;
if (compare(assertValidAmount(amount, asset), '50000.00', asset) > 0) score += 10;
if (routeType === 'cross_border') score += 25;
if (destinationCountry && destinationCountry !== 'NG') score += 15;
score += Math.min(Math.max(Number(profileRiskScore) || 0, 0), 30);
Expand Down Expand Up @@ -223,10 +224,11 @@ const screenSanctions = ({ destinationCountry, routeType }) => {
};
};

const enforceTransactionPolicy = async ({ user, amount, routeType, destinationCountry, tx = prisma }) => {
const enforceTransactionPolicy = async ({ user, amount, asset = 'NGN', routeType, destinationCountry, tx = prisma }) => {
const profile = await getOrCreateKycProfile(user);
const limits = tierLimits[profile.tier] || tierLimits[0];
const parsedAmount = Number(amount);
const policyAsset = asset;
const normalizedAmount = assertValidAmount(amount, policyAsset);

if (profile.status !== 'approved') {
throw new Error('KYC approval is required before sending money.');
Expand All @@ -244,21 +246,23 @@ const enforceTransactionPolicy = async ({ user, amount, routeType, destinationCo
throw new Error('This account is under sanctions review and cannot send funds until cleared.');
}

if (parsedAmount > limits.single) {
if (compare(normalizedAmount, limits.single, policyAsset) > 0) {
throw new Error(`This payment exceeds your tier ${profile.tier} single transaction limit.`);
}

const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recent = await tx.transaction.findMany({
where: {
userId: user.id,
asset: policyAsset,
status: { in: ['success', 'processing', 'pending'] },
createdAt: { gte: since },
},
select: { amount: true },
select: { amount: true, asset: true },
});
const dailyTotal = recent.reduce((sum, t) => sum + Number(t.amount || 0), 0);
if (dailyTotal + parsedAmount > limits.daily) {
const zeroAmount = formatUnits(0n, getAssetRule(policyAsset).precision);
const dailyTotal = recent.reduce((sum, t) => add(sum, t.amount || zeroAmount, policyAsset), zeroAmount);
if (compare(add(dailyTotal, normalizedAmount, policyAsset), limits.daily, policyAsset) > 0) {
throw new Error(`This payment exceeds your tier ${profile.tier} daily limit.`);
}

Expand All @@ -282,7 +286,7 @@ const enforceTransactionPolicy = async ({ user, amount, routeType, destinationCo
throw new Error(`This payment requires manual compliance review: ${sanctionsResult.reason}`);
}

const riskScore = calculateRiskScore({ amount, routeType, destinationCountry, profileRiskScore: updatedProfile.riskScore });
const riskScore = calculateRiskScore({ amount: normalizedAmount, asset: policyAsset, routeType, destinationCountry, profileRiskScore: updatedProfile.riskScore });
if (riskScore >= 80) {
throw new Error('This payment requires manual compliance review.');
}
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ module.exports = {
coinGeckoBaseUrl: process.env.COINGECKO_BASE_URL || 'https://api.coingecko.com/api/v3',
coinGeckoApiKey: process.env.COINGECKO_API_KEY,
exchangeRateApiKey: process.env.EXCHANGERATE_API_KEY,
supportedFiatCurrencies: (process.env.SUPPORTED_FIAT_CURRENCIES || 'NGN')
.split(',')
.map((currency) => currency.trim().toUpperCase())
.filter(Boolean),
},
compliance: {
provider: process.env.KYC_PROVIDER || 'smileid',
Expand Down
28 changes: 14 additions & 14 deletions apps/api/src/payment/payment.orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,9 @@ const { enforceTransactionPolicy } = require('../compliance/compliance.service')
const { markTransactionFailed } = require('./markFailed');
const prisma = require('../common/prisma');
const { withIdAlias } = require('../common/records');
const { assertValidAmount, percentage } = require('../utils/money');

const calculateFee = (amount) => {
const parsed = Number(amount);
if (!Number.isFinite(parsed)) return '0';
return (parsed * 0.01).toFixed(2);
};
const calculateFee = (amount, asset = 'XLM') => percentage(assertValidAmount(amount, asset), asset, 100);

const buildReceipt = ({ transaction }) => {
return {
Expand Down Expand Up @@ -52,11 +49,13 @@ const executePayment = async ({
const effectiveAsset = asset || NATIVE_ASSET;
const effectiveRouteType = routeType
|| (sourceCountry && destinationCountry && sourceCountry !== destinationCountry ? 'cross_border' : 'domestic');
const normalizedAmount = assertValidAmount(amount, effectiveAsset);

const { quote, transaction } = await (prisma.$transaction ? prisma.$transaction(async (tx) => {
const comp = await enforceTransactionPolicy({
user: senderUser,
amount,
amount: normalizedAmount,
asset: effectiveAsset,
routeType: effectiveRouteType,
destinationCountry,
tx,
Expand All @@ -65,15 +64,15 @@ const executePayment = async ({
userId: senderUser.id,
sourceCurrency: effectiveAsset,
targetCurrency: effectiveAsset,
sourceAmount: amount,
sourceAmount: normalizedAmount,
route: rail,
provider: rail,
});
const t = await tx.transaction.create({
data: {
userId: senderUser.id,
type: 'send',
amount: String(amount),
amount: normalizedAmount,
asset: effectiveAsset,
recipientPhoneNumber,
destination,
Expand All @@ -82,7 +81,7 @@ const executePayment = async ({
quoteId: q.id,
status: 'processing',
metadata: {
fee: calculateFee(amount),
fee: calculateFee(normalizedAmount, effectiveAsset),
userHiddenRail: true,
riskScore: comp.riskScore,
},
Expand All @@ -92,7 +91,8 @@ const executePayment = async ({
}) : (async () => {
const comp = await enforceTransactionPolicy({
user: senderUser,
amount,
amount: normalizedAmount,
asset: effectiveAsset,
routeType: effectiveRouteType,
destinationCountry,
tx: prisma,
Expand All @@ -101,15 +101,15 @@ const executePayment = async ({
userId: senderUser.id,
sourceCurrency: effectiveAsset,
targetCurrency: effectiveAsset,
sourceAmount: amount,
sourceAmount: normalizedAmount,
route: rail,
provider: rail,
});
const t = await prisma.transaction.create({
data: {
userId: senderUser.id,
type: 'send',
amount: String(amount),
amount: normalizedAmount,
asset: effectiveAsset,
recipientPhoneNumber,
destination,
Expand All @@ -118,7 +118,7 @@ const executePayment = async ({
quoteId: q.id,
status: 'processing',
metadata: {
fee: calculateFee(amount),
fee: calculateFee(normalizedAmount, effectiveAsset),
userHiddenRail: true,
riskScore: comp.riskScore,
},
Expand All @@ -131,7 +131,7 @@ const executePayment = async ({

try {
const wallet = await walletService.createOrGetWallet({ user: senderUser });
const result = await walletService.submitPayment({ wallet, destination, amount, asset: effectiveAsset });
const result = await walletService.submitPayment({ wallet, destination, amount: normalizedAmount, asset: effectiveAsset });
activeTransaction = await prisma.transaction.update({
where: { id: activeTransaction.id },
data: {
Expand Down
37 changes: 28 additions & 9 deletions apps/api/src/pricing/pricing.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,53 @@ const axios = require('axios');
const config = require('../config/env');
const prisma = require('../common/prisma');
const { withIdAlias } = require('../common/records');
const { assertValidAmount, percentage, convert, getAssetRule, subtract, decimalToRatio } = require('../utils/money');

const normalizeCurrency = (currency) => String(currency || '').trim().toUpperCase();

const assertConfiguredCurrency = (currency) => {
const code = normalizeCurrency(currency);
getAssetRule(code);
if (!['XLM', 'USDC'].includes(code) && !(config.pricing?.supportedFiatCurrencies || ['NGN']).includes(code)) {
throw new Error(`Unsupported fiat currency: ${code}. Configure SUPPORTED_FIAT_CURRENCIES to enable it.`);
}
return code;
};

const getExchangeRate = async ({ sourceCurrency = 'NGN', targetCurrency = 'USDC' }) => {
if (sourceCurrency === targetCurrency) return 1;
sourceCurrency = assertConfiguredCurrency(sourceCurrency);
targetCurrency = assertConfiguredCurrency(targetCurrency);
if (sourceCurrency === targetCurrency) return '1';

if (!config.pricing.exchangeRateApiKey) {
if (!config.pricing?.exchangeRateApiKey) {
return null;
}

const response = await axios.get(`https://v6.exchangerate-api.com/v6/${config.pricing.exchangeRateApiKey}/pair/${sourceCurrency}/${targetCurrency}`, {
const response = await axios.get(`https://v6.exchangerate-api.com/v6/${config.pricing?.exchangeRateApiKey}/pair/${sourceCurrency}/${targetCurrency}`, {
timeout: 15000,
});
return response.data?.conversion_rate || null;
if (response.data?.conversion_rate == null) return null;
return decimalToRatio(response.data.conversion_rate).decimal;
};

const createQuote = async ({ userId, sourceCurrency = 'NGN', targetCurrency = 'USDC', sourceAmount, route, provider }) => {
sourceCurrency = assertConfiguredCurrency(sourceCurrency);
targetCurrency = assertConfiguredCurrency(targetCurrency);
const normalizedSourceAmount = assertValidAmount(sourceAmount, sourceCurrency);
const rate = await getExchangeRate({ sourceCurrency, targetCurrency });
const numericAmount = Number(sourceAmount);
const feeAmount = Number.isFinite(numericAmount) ? numericAmount * 0.01 : 0;
const targetAmount = rate && Number.isFinite(numericAmount) ? ((numericAmount - feeAmount) * rate).toFixed(6) : undefined;
const fee = percentage(normalizedSourceAmount, sourceCurrency, 100);
const netSourceAmount = subtract(normalizedSourceAmount, fee, sourceCurrency);
const targetAmount = rate ? convert({ amount: netSourceAmount, sourceAsset: sourceCurrency, targetAsset: targetCurrency, rate }) : undefined;

const quote = await prisma.quote.create({
data: {
userId,
sourceCurrency,
targetCurrency,
sourceAmount: String(sourceAmount),
sourceAmount: normalizedSourceAmount,
targetAmount,
rate,
fee: feeAmount.toFixed(2),
fee,
provider,
route,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
Expand All @@ -42,4 +60,5 @@ const createQuote = async ({ userId, sourceCurrency = 'NGN', targetCurrency = 'U
module.exports = {
createQuote,
getExchangeRate,
assertConfiguredCurrency,
};
Loading