Repository navigation
Expand file tree
/
Copy pathpaymentLimits.js
More file actions
92 lines (82 loc) · 3.98 KB
/
Copy pathpaymentLimits.js
File metadata and controls
92 lines (82 loc) · 3.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
'use strict';
/**
* ROUNDING POLICY (Issue #751)
* ─────────────────────────────────────────────────────────────────────────────
* Monetary comparisons use Decimal to avoid IEEE-754 drift.
* XLM — 7 decimal places (Stellar on-chain precision)
* USDC — 7 decimal places
* Fiat — 2 decimal places
*
* Rule: never use raw JS Number arithmetic on monetary values. Use Decimal for
* all comparisons and arithmetic; convert to Number only at output boundaries.
*
* LIMIT SOURCE (Issue #1117)
* ─────────────────────────────────────────────────────────────────────────────
* Limits used to be read once from MIN_PAYMENT_AMOUNT / MAX_PAYMENT_AMOUNT at
* module load, so changing them meant a redeploy. They now resolve per call via
* paymentLimitsService, which reads database-backed values and falls back to
* those same env vars — a deployment that never uses the admin API keeps its
* existing behaviour exactly.
*
* This made validatePaymentAmount async. Every caller was already inside an
* async function, so the change is an added `await` at each site.
*/
const Decimal = require('decimal.js');
const {
resolveLimits,
compareAgainstLimits,
} = require('../services/paymentLimitsService');
const { paymentLimitTriggeredTotal } = require('../metrics');
const logger = require('./logger').child('PaymentLimits');
/**
* Validate an amount against the effective limits for a school and asset.
*
* @param {number} amount
* @param {object} [context]
* @param {string} [context.schoolId] - Scopes to a school's override
* @param {string} [context.asset] - Asset code, e.g. 'XLM'
* @returns {Promise<{valid: boolean, error?: string, code?: string}>}
*/
async function validatePaymentAmount(amount, { schoolId, asset } = {}) {
const limits = await resolveLimits({ schoolId, asset });
const result = compareAgainstLimits(amount, limits);
if (!result.valid) {
try {
paymentLimitTriggeredTotal.inc({
school_id: schoolId || 'unknown',
asset: (asset || 'XLM').toUpperCase(),
code: result.code,
});
} catch (err) {
// Never let a metrics failure reject a payment that the limits allow, or
// vice versa — observability is not in the correctness path.
logger.warn('Failed to record payment limit metric', { err: err.message });
}
}
return result;
}
function validatePaymentAmountAgainstFee(paymentAmount, feeAmount, maxPaymentMultiplier = 3.0) {
const dPayment = new Decimal(typeof paymentAmount === 'number' && isFinite(paymentAmount) ? paymentAmount : NaN);
const dFee = new Decimal(typeof feeAmount === 'number' && isFinite(feeAmount) ? feeAmount : NaN);
if (!dPayment.isFinite() || dPayment.lte(0))
return { valid: false, error: 'Payment amount must be a valid positive number', code: 'INVALID_AMOUNT' };
if (!dFee.isFinite() || dFee.lte(0))
return { valid: false, error: 'Fee amount must be a valid positive number', code: 'INVALID_FEE' };
const maxAllowed = dFee.mul(new Decimal(maxPaymentMultiplier));
if (dPayment.gt(maxAllowed))
return { valid: false, error: `Payment amount ${paymentAmount} exceeds the maximum of ${maxAllowed.toNumber()} (${maxPaymentMultiplier}× the fee)`, code: 'AMOUNT_TOO_HIGH' };
return { valid: true };
}
/**
* The effective limits, for display in payment instructions.
*
* @param {object} [context]
* @param {string} [context.schoolId]
* @param {string} [context.asset]
* @returns {Promise<{min: number, max: number}>}
*/
async function getPaymentLimits({ schoolId, asset } = {}) {
const { min, max } = await resolveLimits({ schoolId, asset });
return { min, max };
}
module.exports = { validatePaymentAmount, validatePaymentAmountAgainstFee, getPaymentLimits };