The Payment Limits feature provides configurable minimum and maximum thresholds for payment amounts as a security measure. This helps prevent:
- Accidental overpayments
- Fraudulent transactions
- System abuse
- Processing errors
Limits resolve at request time from the database, with the environment variables as the final fallback. A deployment that never calls the admin API behaves exactly as it did before this was introduced.
Resolution order, most specific first:
| # | Source | Scope |
|---|---|---|
| 1 | School.settings.paymentLimits.assets[CODE] |
One school, one asset |
| 2 | School.settings.paymentLimits.default |
One school |
| 3 | SystemConfig key paymentLimits → assets[CODE] |
Global, one asset |
| 4 | SystemConfig key paymentLimits → default |
Global |
| 5 | MIN_PAYMENT_AMOUNT / MAX_PAYMENT_AMOUNT |
Env fallback |
Per-asset matters because XLM and USDC occupy very different value ranges — a ceiling that is sensible for one is meaningless for the other. A deployment accepts a single asset at a time today, so the asset key is largely future-proofing, but storing limits without it would rebuild the same conflation in the config model.
# Minimum payment amount (default: 0.01)
MIN_PAYMENT_AMOUNT=0.01
# Maximum payment amount (default: 100000)
MAX_PAYMENT_AMOUNT=100000
# How long resolved limits are cached in process (default: 30000)
PAYMENT_LIMITS_CACHE_TTL_MS=30000Both the env values at boot and every admin write are checked against the same rules:
minmust be a finite, non-negative numbermaxmust be a finite number greater thanmin- A bad env value prevents startup; a bad admin write is rejected with
400andINVALID_PAYMENT_LIMITS
A stored value that somehow fails validation is ignored at resolution time and the next layer down applies — a corrupt record degrades to a safe limit rather than to no limit.
Limits are a fraud-prevention control, so they need to be adjustable while an incident is happening rather than on the next deploy.
# Read effective limits (add ?schoolId= to scope to a school)
GET /api/admin/payment-limits
# Set global limits
PUT /api/admin/payment-limits
{ "default": { "min": 1, "max": 5000 },
"assets": { "USDC": { "min": 1, "max": 2000 } } }
# Set one school's limits
PUT /api/admin/payment-limits
{ "schoolId": "SCH001", "default": { "min": 2, "max": 800 } }
# Remove a school override, falling back to global
DELETE /api/admin/payment-limits/SCH001All three require admin authentication. The GET response includes a source
field naming the layer that supplied the effective value — without it, a school
override silently masking a global change looks identical to the change not
having applied.
Every mutation is audit-logged at high severity with the before and after
values (PAYMENT_LIMITS_UPDATED, PAYMENT_LIMITS_CLEARED). Rejected writes are
logged too, as PAYMENT_LIMITS_UPDATE_REJECTED — a run of malformed writes
against a security control is itself worth seeing.
Resolved limits are cached in process for PAYMENT_LIMITS_CACHE_TTL_MS
(default 30s) because resolution sits in the payment hot path. A write
invalidates the cache immediately on the instance that served it; other
instances converge within the TTL. That bounded staleness is the trade for not
doing a database read per payment, and is still a different order of magnitude
from requiring a redeployment.
payment_limit_triggered_total{school_id,asset,code} counts payments rejected
by the limits. Before it existed, the limits were a control with no feedback
loop — nothing told an operator the configured values had stopped matching real
payment behaviour.
Two alerts ship in monitoring/alerts/payment_limits.yml:
- PaymentLimitTriggeredFrequently (warning) — a sustained rejection rate for
a school/asset/code. A sustained
AMOUNT_TOO_HIGHis either a fee structure the ceiling no longer fits, or someone probing with large amounts. - PaymentLimitBlockingMostPayments (critical) — more than half of received payments rejected, which in practice means a misconfiguration (a limit set in the wrong asset's value range does exactly this) and parents cannot pay.
When a payment transaction is verified via the /api/payments/verify endpoint:
- The transaction is fetched from the Stellar network
- The payment amount is extracted and normalized
- Payment limit validation is performed
- If the amount is outside the configured limits, the transaction is rejected with an appropriate error code
When creating a payment intent via the /api/payments/intent endpoint:
- The student's fee amount is retrieved
- The fee amount is validated against payment limits
- If the fee amount is outside limits, the intent creation is rejected
During automatic payment synchronization:
- Recent transactions are fetched from the Stellar network
- Each payment amount is validated against limits
- Payments outside limits are skipped and not recorded
Retrieve the current payment limit configuration.
Endpoint: GET /api/payments/limits
Response:
{
"min": 0.01,
"max": 100000,
"message": "Payment amounts must be between 0.01 and 100000"
}The payment instructions endpoint now includes payment limits information.
Endpoint: GET /api/payments/instructions/:studentId
Response:
{
"walletAddress": "GXXX...",
"memo": "STUDENT123",
"acceptedAssets": [...],
"paymentLimits": {
"min": 0.01,
"max": 100000
},
"note": "Include the payment intent memo exactly when sending payment to ensure your fees are credited."
}When a payment is rejected due to limit violations, the following error codes are returned:
| Code | Description | HTTP Status |
|---|---|---|
AMOUNT_TOO_LOW |
Payment amount is below the minimum allowed | 400 |
AMOUNT_TOO_HIGH |
Payment amount exceeds the maximum allowed | 400 |
INVALID_AMOUNT |
Payment amount is not a valid number or is zero/negative | 400 |
{
"error": "Payment amount 0.005 is below the minimum allowed amount of 0.01",
"code": "AMOUNT_TOO_LOW"
}The core validation logic is implemented in backend/src/utils/paymentLimits.js:
function validatePaymentAmount(amount) {
// Validates that amount is:
// 1. A valid number
// 2. Greater than zero
// 3. Within configured min/max limits
// Returns: { valid: boolean, error?: string, code?: string }
}Payment limit validation is integrated at three key points:
stellarService.verifyTransaction()- Validates amounts during transaction verificationpaymentController.createPaymentIntent()- Validates fee amounts during intent creationstellarService.syncPayments()- Validates amounts during automatic synchronization
- Fraud Prevention: Limits help detect and prevent fraudulent transactions that may attempt to exploit the system
- Error Detection: Catches accidental overpayments or data entry errors
- Resource Protection: Prevents system abuse through extremely large or small transactions
- Compliance: Helps meet regulatory requirements for transaction monitoring
- Set Realistic Limits: Configure limits based on your actual fee structure
- Monitor Rejections: Track rejected payments to identify potential issues
- Regular Review: Periodically review and adjust limits as needed
- Document Changes: Keep a record of limit changes for audit purposes
Comprehensive tests are available in tests/payment-limits.test.js.
Run tests with:
npm test tests/payment-limits.test.js- Valid amounts within limits
- Amounts below minimum
- Amounts above maximum
- Edge cases (zero, negative, NaN, non-numeric)
- Boundary values (exactly at min/max)
Payments rejected due to limit violations are recorded in the database with:
- Status:
failed - Student ID:
unknown(if not identifiable) - Amount:
0
This provides an audit trail for security analysis.
- Rejection Rate: Track the percentage of payments rejected due to limits
- Rejection Reasons: Monitor which limit (min/max) is triggered most often
- Temporal Patterns: Identify if rejections cluster at certain times
- Student Impact: Track if specific students are repeatedly affected
If you're adding payment limits to an existing deployment:
- Review Existing Data: Analyze current payment amounts to set appropriate limits
- Set Conservative Limits: Start with wider limits and tighten gradually
- Communicate Changes: Notify users about the new limits
- Monitor Impact: Watch for increased rejections after deployment
- Adjust as Needed: Fine-tune limits based on real-world usage
Issue: Application won't start after adding payment limits
- Cause: Invalid configuration (e.g., max < min)
- Solution: Check
.envfile and ensureMAX_PAYMENT_AMOUNT > MIN_PAYMENT_AMOUNT
Issue: Valid payments are being rejected
- Cause: Limits set too restrictively
- Solution: Review and adjust
MIN_PAYMENT_AMOUNTandMAX_PAYMENT_AMOUNT
Issue: Payment intent creation fails for existing students
- Cause: Student fee amounts exceed new limits
- Solution: Either adjust limits or update student fee amounts
Potential improvements to the payment limits feature:
- Dynamic Limits: Adjust limits based on student grade level or program
- Rate Limiting: Limit number of payments per time period
- Admin UI: A front-end for the limits API, which is currently HTTP-only