Skip to content

feat: Payment API hardening — error normalization, idempotency, balance checks, rate limiting (#173-176) - #273

Merged
heymariam merged 10 commits into
Dorisio:mainfrom
TheHalalHunter:feat/payment-security-173-176
Oct 16, 2026
Merged

heymariam merged 10 commits into
Dorisio:mainfrom
TheHalalHunter:feat/payment-security-173-176

Conversation

@TheHalalHunter

@TheHalalHunter TheHalalHunter commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Closes #173, Closes #174, Closes #175, Closes #176


Summary

Addresses four issues in the payment domain:

Issue Description
#173 Standardize payment API error responses
#174 Add missing idempotency key validation for charge endpoints
#175 Enforce wallet balance checks before accepting payments
#176 Add rate limiting per-user for wallet operations

#173 — Standardize payment API error responses

Problem: Payment and charge endpoints returned inconsistent error formats — raw Prisma errors, mixed \code/\message\ shapes, and internal details leaking to clients.

Changes:

  • \src/utils/errors.ts: Added 5 payment-specific \AppError\ subclasses (\InsufficientFundsError, \InvalidCurrencyError, \PaymentDeclinedError, \DuplicatePaymentError, \PaymentNotFoundError) with correct status codes (422/422/422/409/404)
  • \src/domains/payments/payment.service.ts: All throw sites replaced with typed errors; structured \logger.warn/error({ userId, failureReason, cause })\ on every error path
  • \src/domains/payments/charge.service.ts: Raw
    ew AppError(409/403)\ replaced with \DuplicatePaymentError/\ForbiddenError; structured logs added
  • \src/middleware/tests/error-handler.test.ts: 10 new tests — per-class shape assertions, status code mapping, no-leak assertions

All payment/charge endpoints now return { code, message, statusCode, timestamp }. No Prisma errors or stack traces reach clients.


#174 — Idempotency key validation for charge endpoints

Problem: Charge endpoints accepted malformed idempotency keys and had no true idempotency guarantee — duplicate requests could create duplicate charges.

Changes:

  • \src/domains/payments/charge.types.ts: \idempotencyKey\ schema tightened to \z.string().uuid()\
  • \src/domains/payments/charge.routes.ts: \idempotencyKeyFrom()\ now validates the \Idempotency-Key\ header with \z.string().uuid().safeParse(), throws \ValidationError\ (400) for any non-UUID v4 or empty value
  • \src/domains/payments/charge.service.ts: \createPayment()\ checks for an existing charge with the same key before processing — matching amount + creatorId returns cached result; differing amount or creatorId throws \DuplicatePaymentError\ (409)
  • New tests in \charge.routes.test.ts\ and \charge.service.test.ts\ covering all deduplication and validation scenarios

#175 — Enforce wallet balance checks before accepting payments

Problem: Tips could be created against wallets with zero balance, resulting in failed on-chain confirmations and orphaned DB records.

Changes:

  • \src/utils/errors.ts: Added \PaymentRequiredError\ (402) with typed \PaymentRequiredDetails\ interface enforcing { requiredAmount, currentBalance, currency }\
  • \src/domains/payments/payment.service.ts: Balance pre-flight added in \createTip()\ — idempotency replay runs first (no unnecessary Stellar calls on retries), then \getWalletBalance()\ is called; insufficient balance → 402 with balance details; Stellar unreachable → 503
  • No DB record is created if the balance check fails
  • 4 new tests covering: insufficient balance (402), missing trustline (402), Stellar unavailable (503), exact-balance boundary (passes through)

Note: \ChargeService\ was intentionally excluded — it processes tokenized card payments via an external provider with no Stellar wallet concept. Documented with a #175\ comment in source.


#176 — Per-user rate limiting for wallet operations

Problem: Wallet auth endpoints were rate-limited globally (per-IP), allowing attackers to rotate user IDs to bypass limits or exhaust the global limit for all users.

Changes:

  • \src/config/rate-limit.ts: Added \walletNonce\ (5 req/hr) and \walletVerify\ (10 req/hr) named policies; fixed stale URL rules pointing at non-existent paths
  • \src/plugins/rateLimit.ts: Extended \�uthOverride\ chain with wallet route arms; existing
    esolveClientKey\ emits \user:\ for authenticated requests — no new dependency needed
  • \src/config/schema.ts, \docs/CONFIGURATION.md, .env.example: 4 new env keys for configurable window/max values (ceiling raised to 24 hr)
  • 26 rate limit tests pass, including per-user isolation and IP-fallback for unauthenticated requests

Testing

Non-blocking follow-ups (separate tickets recommended)

@drips-wave

drips-wave Bot commented Oct 16, 2026

Copy link
Copy Markdown

@TheHalalHunter Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Per review finding: idempotency replay must short-circuit before the
getWalletBalance() network call so retries with a cached key bypass
Stellar. Also types PaymentRequiredDetails interface (extends ErrorDetails)
to enforce requiredAmount/currentBalance/currency at the call site.
- Add walletNonce (5/hr) and walletVerify (10/hr) named policies to RATE_LIMIT_POLICIES
- Extend RateLimitClass type with 'walletNonce' | 'walletVerify'
- Fix stale URL rules: /api/v1/wallet/nonce -> /api/v1/auth/wallet/challenge,
  /api/v1/wallet/verify -> /api/v1/auth/wallet/verify
- Extend authOverride chain in rateLimit.ts to assign wallet policies by route URL
- Fix policy lookup to use authOverride (not routeClass) so named overrides apply
- Add env schema entries for RATE_LIMIT_WALLET_NONCE_* and RATE_LIMIT_WALLET_VERIFY_*
  with defaults matching issue spec (5/hr nonce, 10/hr verify)
- Document new env keys in docs/CONFIGURATION.md and .env.example
- Add 5 per-user wallet rate-limit tests covering: per-user isolation for nonce
  and verify, correct header values, 1-hour window, cross-IP user tracking
@TheHalalHunter
TheHalalHunter force-pushed the feat/payment-security-173-176 branch from 60934b2 to 8eb7293 Compare October 16, 2026 14:11
@heymariam
heymariam merged commit d6eb87c into Dorisio:main Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants