Files
nick-doc/04 - Flows/Payment Flow - SHKeeper.md
Siavash Sameni 7a616744f4 docs: complete code-reality alignment for remaining docs + reconcile issue set
Remaining docs updated to match code (the docs that the first pass had not covered):
- Flows: Chat, Referral, Rating, Registration, Google OAuth, Negotiation, Payout,
  Trezor Safekeeping — corrected endpoints, socket events, status enums, auth gaps
- API Reference: User API, Trezor API — admin route prefix/verb/status corrections,
  added undocumented endpoints (ton-proof challenge, profile email verify,
  GET /trezor/account, POST /trezor/verify-operation)
- Data Models: Chat, Notification, Payment, PointTransaction, User — corrected
  enums (PaymentProvider, escrowState, PointTransaction.type, User.status),
  90-day notification TTL, soft-delete semantics, wallet fields

Trezor "zero frontend" finding (audit C31/C32) corrected as STALE:
- Verified current code HAS a full frontend Trezor implementation (admin/trezor
  page, TrezorSettingsView, trezorConnector via @trezor/connect-web,
  TrezorSignDialog, actions/trezor.ts building the {message,signature} object)
- Fixed Trezor Safekeeping Flow doc (removed false "no frontend" warnings)
- Reclassified ISSUE-012 as invalid/superseded with explanation

Issue set reconciled to a single canonical numbering (ISSUE-001..054):
- Adopted the comprehensive 51-issue set (long-slug, fully indexed)
- Removed 35 superseded short-slug duplicates from the first pass
- Removed a duplicate ISSUE-046 file
- Added 3 issues the 51-set lacked: ISSUE-052 (completed-not-counted-in-stats),
  ISSUE-053 (axios 401-only interceptor), ISSUE-054 (rate limiter counts all attempts)
- Regenerated Issues Index: 53 open (14 critical, 39 major) + 1 invalid

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:15:02 +04:00

288 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Payment Flow - SHKeeper
tags: [flow, payment, shkeeper, crypto, escrow, webhook]
related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[SellerOffer]]"]
related_apis: ["POST /api/payment/shkeeper/intents", "POST /api/payment/shkeeper/webhook", "POST /api/payment/:id/release", "POST /api/payment/:id/refund"]
---
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
> [!caution] Audit — 2026-05-29
> This document was reviewed against the live codebase. **3 corrections applied**: (1) the non-existent HTTP polling endpoint has been removed (status updates arrive via socket only), (2) the release/refund/confirm paths have been corrected to remove the erroneous `/shkeeper/` segment, and (3) the intent-creation endpoint corrected from `/shkeeper/create` to `/shkeeper/intents` and parallel stats/export paths documented.
# Payment Flow — SHKeeper (Crypto Pay-In)
> [!warning] Historical migration document
> This page describes the older SHKeeper pay-in rail. It is retained for migration/reconciliation context only. The current primary pay-in path is [[PRD - Request Network In-House Checkout]], and the current escrow/custody model is [[Escrow Flow]] plus [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
End-to-end **crypto pay-in** via the self-hosted [SHKeeper](https://github.com/vsys-host/shkeeper.io) gateway at `pay.amn.gg`. The buyer pays in stablecoins/crypto; SHKeeper monitors the blockchain, sends webhooks back, and the backend marks the escrow funded.
## Supported assets
Pulled from env: `SHKEEPER_NETWORKS` and `SHKEEPER_ALLOWED_TOKENS` (`shkeeperService.ts:97-98`).
- **Networks**: `bsc`, `ethereum` (default in code).
- **Tokens**: `USDT`, `USDC` (default). The endpoint URL is built as `https://pay.amn.gg/api/v1/{NETWORK_PREFIX}-{TOKEN}/payment_request` (`shkeeperService.ts:413-417`).
- BSC → `BNB-USDT`, `BNB-USDC` (i.e. BEP-20).
- Ethereum → `ETH-USDT`, `ETH-USDC` (ERC-20).
- TRC-20 (`USDT-TRC20`) and native `BTC` are mentioned in the task brief but **not currently wired** in `shkeeperService.ts` — only BSC/ETH variants are produced from the code path. Verify SHKeeper-side configuration if those are required.
## Actors
- **Buyer** — pays.
- **Seller** — passive in this flow; gets notified on success.
- **Frontend** — checkout components under `frontend/src/sections/request/components/buyer-steps/step-3-components/` and `frontend/src/sections/payment/`.
- **Backend** — `shkeeperService.createPayInIntent` (`backend/src/services/payment/shkeeper/shkeeperService.ts:48-533`) and `shkeeperWebhook.handleShkeeperWebhook` (`backend/src/services/payment/shkeeper/shkeeperWebhook.ts`).
- **SHKeeper gateway** (`https://pay.amn.gg`) — issues per-payment deposit addresses, watches the chain, sends webhooks.
- **Blockchain** — BSC / Ethereum.
- **PaymentCoordinator** (`backend/src/services/payment/paymentCoordinator.ts`) — serialises concurrent payment-status updates from multiple sources (webhook, wallet monitor, manual confirm).
- **MongoDB** — `payments`, `purchaserequests`, `selleroffers`, `chats`, `notifications`.
- **Redis** — `paymentRedisService` (wallet-address cache, 2 h TTL).
- **Socket.IO** — `payment-created`, `payment-update`, `template-checkout-payment-confirmed`, `seller-offer-update`, `purchase-request-update`.
## Preconditions
- Buyer has selected an offer (or is using the template-checkout shortcut).
- Backend env: `SHKEEPER_API_URL`, `SHKEEPER_API_KEY`, `SHKEEPER_WEBHOOK_SECRET`, `API_URL` (for the callback URL).
- Redis is reachable (graceful degradation if not — see `app.ts:361-367`).
- SHKeeper has wallets provisioned for each `crypto_name`.
## Payment state machine
```mermaid
stateDiagram-v2
[*] --> pending: createPayInIntent\n(Payment.status="pending")
pending --> pending_partial: webhook PARTIAL\nescrowState="partial"
pending --> completed: webhook PAID/OVERPAID\nescrowState="funded"
pending --> failed: webhook EXPIRED/CANCELLED\nescrowState="cancelled"
pending_partial --> completed: top-up arrives, total ≥ amount
pending_partial --> failed: expires
completed --> released: admin release → seller payout\n[[Payout Flow]]
completed --> refunded: dispute resolution → buyer refund
refunded --> [*]
released --> [*]
failed --> [*]
```
## Step-by-step narrative
### Phase 1 — Create intent
1. Buyer clicks "Pay" on the chosen offer (`/dashboard/buyer/requests/{id}` → step-3-select-and-pay).
2. Frontend POSTs `POST /api/payment/shkeeper/intents` with `{ purchaseRequestId, sellerOfferId, amount, token?, network? }`.
3. Backend `createPayInIntent`:
- Validates ObjectIds (`shkeeperService.ts:55-71`). Special path for **template checkout** (string IDs starting with `template-checkout-`).
- **Duplicate-guard #1** (`:75-116`): if there is already an active `Payment` (`status ∈ {pending, processing}`) for the same `{purchaseRequestId, sellerOfferId, buyerId}`, the existing record is reused — same `paymentUrl`, no new wallet allocation.
- **Duplicate-guard #2 (template checkout)** (`:131-198`): if a recent `completed`/`confirmed` payment exists for the same buyer + template session, reuse it; otherwise dedupe pending records within the last 5 minutes.
- **Upsert** (`:218-249`): atomic `Payment.findOneAndUpdate(filter, {$setOnInsert: {...}}, {upsert: true, new: true})` keyed by `{buyerId, purchaseRequestId, provider, direction:'in', status:'pending'}` — prevents race-condition duplicates.
- Sets `Payment.providerPaymentId = externalId`. For template checkouts, `externalId = template-{ts}-{rand}`; otherwise it's the `Payment._id`.
- **Wallet cache lookup** (`:421-450`): `paymentRedisService.getCachedWallet(cacheKey)` — if a wallet was allocated for the same `(amount, token, network, requestId)` within the last 2 h, reuse it. Avoids hammering SHKeeper for the same checkout.
- **Call SHKeeper API** (`:453-475`): `POST https://pay.amn.gg/api/v1/{cryptoName}/payment_request` with header `X-Shkeeper-Api-Key`. Body: `{ external_id, fiat: 'USD', amount: '12.34', callback_url: ${API_URL}/api/payment/shkeeper/webhook }`. The HTTP call is wrapped by `shkeeperFetch` (`shkeeperHealthCheck.ts`) which trips the breaker on repeated failures.
- **Persist response** (`:484-503`): updates `Payment.metadata.{shkeeperInvoiceId, shkeeperData, cryptoName, walletAddress}`; caches the wallet for 2 h; calls `walletMonitor.addWallet(...)` so the on-chain watcher can confirm independently (belt-and-braces against missed webhooks).
4. Returns `{ paymentId, paymentUrl, shkeeperInvoiceId, walletAddress, amount, exchangeRate, displayName, cryptoName }`.
5. Emits **`payment-created`** globally via `emitGlobalEvent` (`shkeeperService.ts:277-287`) so the admin dashboard sees the new pending payment in real time.
### Phase 2 — Buyer pays
6. Frontend renders a **QR code** for `${walletAddress}?amount=${amount}&token=...` and shows the exchange-rate-locked USDT amount, recalculate-after timer (`recalculate_after` from SHKeeper, typically 15 min), and a copy-to-clipboard button.
7. Buyer scans with MetaMask / Trust Wallet / Binance App and sends the on-chain transfer.
8. SHKeeper polls the chain, detects the deposit. When confirmations reach the threshold it marks the invoice `PAID` (or `OVERPAID` if the buyer sent extra).
### Phase 3 — Webhook
9. SHKeeper POSTs to `${API_URL}/api/payment/shkeeper/webhook` with the `ShkeeperWebhookPayload` shape (`shkeeperWebhook.ts:14-37`):
```
{
external_id, crypto: "BNB-USDT", addr, fiat: "USD",
balance_fiat, balance_crypto, paid: true,
status: "PAID" | "PENDING" | "EXPIRED" | "CANCELLED" | "PARTIAL" | "OVERPAID",
transactions: [{ txid, date, amount_crypto, amount_fiat, trigger: true, ... }],
fee_percent, fee_fixed, fee_policy, overpaid_fiat
}
```
10. **Signature verification** (`shkeeperWebhook.ts:84-120`): HMAC-SHA256 of the raw body with `SHKEEPER_WEBHOOK_SECRET`, header `x-shkeeper-signature` (also accepts `x-signature`, `signature`, `x-hub-signature`, `x-hub-signature-256`). Mismatch → `401` in production, allowed in dev. Length-mismatched signature → `401` (avoids `timingSafeEqual` crash).
11. **Fallback auth** (`:122-141`): if no signature header but env requires it, the route accepts `X-Shkeeper-Api-Key` matching `SHKEEPER_API_KEY`. Otherwise returns `202` to **stop SHKeeper retries** even if rejected (idempotency principle: always 2xx unless the request itself is mangled).
12. **DB reconnect** (`:143-155`): if Mongoose is disconnected, attempt reconnection. On failure → `202 OK` to avoid retry loop, log for investigation.
13. **Payment lookup**: `Payment.findOne({ providerPaymentId: payload.external_id })`. If not found and the external_id looks like a template checkout, hand off to `handleTemplateCheckoutWebhook` (`templateCheckoutWebhook.ts`). Otherwise → `202 OK` with a rate-limited log.
14. **Duplicate-webhook detection** (`:249-296`): if `metadata.shkeeperStatus`, `balance_fiat`, `paid` are identical to the previous webhook **and** less than 10 seconds have passed → return `202` (idempotent). Logged once per minute per payment.
15. **Map SHKeeper status → internal status** (`:387-411`):
| SHKeeper | Internal `status` | `escrowState` |
|---|---|---|
| `PAID` | `completed` | `funded` |
| `OVERPAID` | `completed` | `funded` |
| `EXPIRED`, `CANCELLED` | `failed` | `cancelled` |
| `PARTIAL` | `pending` | `partial` |
| `PENDING` | `pending` | — |
16. **Extract `transactionHash`** (`:311-385`) — prefers the transaction with `trigger === true`, then falls back to the latest, then to fetching from SHKeeper's invoice endpoint if the webhook somehow arrived without transactions.
17. **PaymentCoordinator** (`:482-507`) — `coordinatePaymentUpdate` returns false if another worker already started processing this state change, otherwise `executePaymentUpdate` writes the new status/escrowState/txHash atomically with metadata.
18. **Cascade on PAID/OVERPAID** (`:543-714`):
- Load `PurchaseRequest` and `SellerOffer` for this payment.
- **Mark winning offer accepted**: `selectedOffer.status = 'accepted'; save()`.
- **Reject all other offers**: `SellerOffer.updateMany({ purchaseRequestId, _id: { $ne: sellerOfferId } }, { status: 'rejected' })`.
- **Promote request**: `status = 'payment'; selectedOfferId = sellerOfferId`.
- **Create direct chat** (`chatService.createChat`, see [[Chat Flow]]).
- **Notifications**: `notifyPaymentConfirmed` (to both parties), `notifyOfferAccepted` (winner), `notifyRequestStatusChanged` (`received_offers → payment`).
- **Socket fan-out**: `seller-offer-update` `'payment-completed'` to winner, `'offer-rejected'` to losers (each carries `offerId`, `reason`).
19. **Cleanup**: `simpleAutoWebhook.removePayment(external_id)` stops the simple polling fallback.
20. Always respond `202 Accepted` (SHKeeper retries on non-2xx). `200` would cause infinite retries because SHKeeper expects `202` per its convention.
### Phase 4 — Frontend reaction
21. The buyer's checkout page subscribes to socket events (`payment-update`, `template-checkout-payment-confirmed`). When the status flips to `completed`, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery).
> [!warning] No HTTP polling endpoint — socket events only
> `GET /api/payment/shkeeper/status/:paymentId` **does not exist** — there is no polling route in `shkeeperRoutes.ts`. Status transitions must be observed via Socket.IO events (`payment-update`, `template-checkout-payment-confirmed`). Any frontend code path that polls this URL will always receive 404. Remove HTTP polling and rely solely on the socket subscription.
22. The seller's dashboard receives `seller-offer-update` `payment-completed` and surfaces the green "Order paid — start preparing" banner.
## Sequence diagram
```mermaid
sequenceDiagram
autonumber
actor B as Buyer
participant FE as Frontend
participant BE as Backend
participant R as Redis
participant SK as SHKeeper (pay.amn.gg)
participant BC as Blockchain
participant DB as MongoDB
participant IO as Socket.IO
actor S as Seller
B->>FE: Choose offer, click "Pay"
FE->>BE: POST /api/payment/shkeeper/intents
BE->>DB: dedupe / upsert Payment(status:"pending")
BE->>R: getCachedWallet(amount, token, network, requestId)
alt cache hit
R-->>BE: cached wallet
else cache miss
BE->>SK: POST /api/v1/{cryptoName}/payment_request\nX-Shkeeper-Api-Key
SK-->>BE: { id, wallet, amount, exchange_rate, ... }
BE->>R: setCachedWallet (TTL 2h)
BE->>BE: walletMonitor.addWallet (chain watcher)
end
BE->>IO: emit 'payment-created' (admin)
BE-->>FE: { paymentId, walletAddress, amount, QR-ready data }
FE-->>B: Render QR + countdown + copy address
B->>BC: Send USDT/USDC to walletAddress
BC-->>SK: deposit confirmed
SK->>BE: POST /api/payment/shkeeper/webhook\nx-shkeeper-signature
BE->>BE: HMAC verify
BE->>DB: Payment.findOne({providerPaymentId})
BE->>BE: duplicate-webhook check
BE->>BE: PaymentCoordinator.coordinate + execute
BE->>DB: Payment.status="completed"\nescrowState="funded"\nblockchain.transactionHash=...
BE->>DB: SellerOffer.status="accepted" (others "rejected")
BE->>DB: PurchaseRequest.status="payment", selectedOfferId
BE->>DB: Chat.create (buyer + winning seller)
BE->>IO: emit seller-{winner} 'payment-completed'
BE->>IO: emit seller-{loser_i} 'offer-rejected'
BE-->>SK: 202 OK
IO-->>FE: payment-update / status updated
IO-->>S: dashboard updates
FE-->>B: "Payment received ✓"
```
## API calls
| Method | Endpoint | Purpose | Auth | Source |
|---|---|---|---|---|
| `POST` | `/api/payment/shkeeper/intents` | Create pay-in intent | Bearer JWT (buyer) | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | HMAC / API key | `shkeeperWebhook.handleShkeeperWebhook` |
| `POST` | `/api/payment/:id/release` | Release escrow to seller | Bearer JWT | `paymentRoutes.ts` |
| `POST` | `/api/payment/:id/release/confirm` | Confirm escrow release | Bearer JWT | `paymentRoutes.ts` |
| `POST` | `/api/payment/:id/refund` | Refund to buyer | Bearer JWT | `paymentRoutes.ts` |
| `POST` | `/api/payment/:id/refund/confirm` | Confirm buyer refund | Bearer JWT | `paymentRoutes.ts` |
| `POST` | `/api/payment/payments/:id/fetch-tx` | Manual transaction lookup | Bearer JWT | `paymentRoutes.ts` |
| `GET` | `/api/payment/payments/stats` | Payment statistics (admin-gated strict) | Bearer JWT + admin role | `paymentRoutes.ts` |
| `GET` | `/api/payment/stats` | Payment statistics (no admin guard) | Bearer JWT | `paymentRoutes.ts` |
| ~~`GET /api/payment/shkeeper/status/:paymentId`~~ | | **404 — does not exist.** Use socket events instead. | — | — |
> [!note] Two parallel stats paths
> Two separate stats endpoints exist with different auth levels:
> - `GET /api/payment/payments/stats` — admin-gated (strict role check); intended for admin dashboard.
> - `GET /api/payment/stats` — authenticated but no admin guard; accessible to any logged-in user.
> Similarly, export endpoints exist at two paths with different auth levels. Confirm which is appropriate for each consumer before wiring the frontend.
> [!warning] Release/refund path correction
> Previously documented paths included a `/shkeeper/` segment that does **not** exist in the router:
> - ~~`POST /api/payment/shkeeper/:id/release`~~ → correct: `POST /api/payment/:id/release`
> - ~~`POST /api/payment/shkeeper/:id/release/confirm`~~ → correct: `POST /api/payment/:id/release/confirm`
> - ~~`POST /api/payment/shkeeper/:id/refund`~~ → correct: `POST /api/payment/:id/refund`
> - ~~`POST /api/payment/shkeeper/:id/refund/confirm`~~ → correct: `POST /api/payment/:id/refund/confirm`
>
> The `/shkeeper/` infix never existed on release/refund routes. These are generic payment lifecycle endpoints shared across all providers.
## Database writes
- **`payments`**: insert on intent creation (`status: 'pending'`); update on each webhook (status, escrowState, blockchain.transactionHash, metadata).
- **`payments`** (unique index `uniq_pending_shkeeper_by_buyer_session`, see `Payment.ts:181-188`): partial unique on `{buyerId, purchaseRequestId, provider:'shkeeper', direction:'in', status:'pending'}` prevents duplicate pending pay-ins.
- **`selleroffers`**: `status` flipped (`accepted` / `rejected`) by the webhook cascade.
- **`purchaserequests`**: `status` → `payment`, `selectedOfferId` set.
- **`chats`**: a new `direct` chat (or reuse) — find-or-create via `ChatService.createChat`.
- **`notifications`**: 2N entries depending on parties.
## Socket events emitted
- **`payment-created`** (global) — broadcast on intent creation.
- **`payment-update`** — status change notifications to the buyer's checkout page.
- **`template-checkout-payment-confirmed`** — for template checkout flows.
- **`seller-offer-update`** with `eventType: 'payment-completed'` → winning seller.
- **`seller-offer-update`** with `eventType: 'offer-rejected'` → each losing seller.
- **`purchase-request-update`** with `eventType: 'status-changed'` (via `PurchaseRequestService`) → `request-{id}`.
- **`new-notification`** → both buyer and seller.
## Side effects
- **Redis**: `walletCache:{amount}_{token}_{network}_{requestId}` set for 2 hours.
- **Wallet monitor**: `walletMonitor.addWallet(addr, amount, paymentId, token, network)` — the on-chain watcher polls BSC/ETH RPCs directly; if SHKeeper's webhook is lost, the monitor still flips the payment to `completed`. This is the redundancy mechanism noted in the comments.
- **simpleAutoWebhook** (`shkeeperSimpleAuto.ts`): a poll-based fallback that asks SHKeeper for invoice status; cleaned up once a real webhook arrives.
- **Webhook stats**: `webhookStats.recordWebhook(...)` updates an in-memory ring buffer surfaced via `webhookStats.ts` admin endpoint.
## Error / edge cases
- **Duplicate intent submission** → reuse the existing pending payment (no new wallet). UX-safe.
- **SHKeeper API unreachable** → `shkeeperFetch` (with circuit breaker) throws; controller returns a **demo fallback** URL (`shkeeperService.ts:520-532`). In production this is observed as a Sentry error.
- **Webhook signature mismatch in prod** → `401`, SHKeeper retries — usually the secret has rotated; fix env and they catch up.
- **Webhook missing both signature and API key in prod** → `202 OK` (no-op) to prevent retry storm.
- **DB disconnected during webhook** → reconnect; on failure `202 OK` + log (consider DLQ).
- **`PARTIAL` payment** → state held as `pending/partial`; further deposits to the same address are aggregated by SHKeeper and a new webhook arrives.
- **`OVERPAID`** → treated as `completed/funded`; the overage stays with the platform unless an admin manually refunds (no automatic refund of overpayment today).
- **`EXPIRED`** → `failed/cancelled`. Buyer can re-initiate; the duplicate-guard will create a fresh intent because the old one is no longer `pending`.
- **External_id not found** → `202` with rate-limited log; common for orphaned webhooks from old tests.
- **Webhook arrives twice within 10 s with same data** → idempotency skip → `202`.
- **`PaymentCoordinator` deferral** → `202` with a "coordinator skipped update" log; the in-flight worker will finish the state change.
- **Wallet address reuse** — cached for 2 h means two parallel checkouts for the same `(amount, token, network, requestId)` share one address; whichever pays first wins (acceptable since the duplicate-guard reuses the same `Payment` doc anyway).
- **`crypto_name` mismatch** — only `BNB-*` and `ETH-*` are produced; for TRC-20, additional logic is needed in `shkeeperService.ts:415`.
> [!warning] Webhook returns 202 even on errors
> The handler always responds 2xx to avoid SHKeeper's retry storm — even for unknown payments, signature failures (in non-production paths), DB errors, and unexpected exceptions. Operationally this means failed-to-process webhooks are silently swallowed unless someone tails the logs. Hook the catch-all into Sentry severity = `error` and alert on `webhookStats.errorCount`.
> [!tip] Manual reconciliation
> Use `fix-transaction-hashes.js` at repo root to backfill `blockchain.transactionHash` for payments where the webhook arrived without transactions. See [[Payout Flow]] for the parallel payout-side script usage.
## Linked flows
- [[Purchase Request Flow]] — supplies the request being paid for.
- [[Seller Offer Flow]] — supplies the offer being accepted.
- [[Payment Flow - DePay & Web3]] — alternative direct-wallet route.
- [[Escrow Flow]] — what `escrowState=funded` means downstream.
- [[Chat Flow]] — auto-created on success.
- [[Notification Flow]] — both parties pinged.
- [[Payout Flow]] — pays the seller from the funded escrow.
- [[Dispute Flow]] — escape if the order goes wrong.
## Source files
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts` (intent creation, ~650 lines)
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts` (webhook handler, ~750 lines)
- Backend: `backend/src/services/payment/shkeeper/shkeeperHealthCheck.ts` (circuit breaker)
- Backend: `backend/src/services/payment/shkeeper/shkeeperRoutes.ts`
- Backend: `backend/src/services/payment/paymentCoordinator.ts`
- Backend: `backend/src/services/payment/cleanupPendingPayments.ts` (periodic GC)
- Backend: `backend/src/services/blockchain/walletMonitor.ts` (chain watcher)
- Backend: `backend/src/services/redis/paymentRedisService.ts` (wallet cache)
- Backend: `backend/src/models/Payment.ts`
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/step-3-payment.tsx`
- Frontend: `frontend/src/sections/payment/`