Files
nick-doc/04 - Flows/Payout Flow.md
2026-05-23 20:35:34 +03:30

7.9 KiB

title, tags, related_models, related_apis
title tags related_models related_apis
Payout Flow
flow
payment
payout
shkeeper
seller
Payment
POST /api/payment/shkeeper/payout
GET /api/payment/shkeeper/payout/:taskId

Payout Flow

How the seller receives the escrowed crypto once the order is complete. Two variants are implemented:

  1. SHKeeper Payouts API (shkeeperPayoutService.ts) — the gateway signs and broadcasts on behalf of the platform.
  2. Manual admin wallet payout (admin-wallet-payout.tsx) — an admin connects their own wallet and signs the transfer; the tx hash is reported back to the backend.

Both result in Payment.escrowState = 'released' and an outgoing Payment record with direction: 'out'.

Actors

  • Admin (or scheduled system trigger) — initiates the payout.
  • Seller — recipient, has saved their wallet address under User.profile.walletAddress.
  • BackendshkeeperPayoutService.createPayoutTask and the manual confirmation routes.
  • SHKeeper Payouts APIPOST https://pay.amn.gg/api/v1/payout (per SHKeeper docs).
  • Blockchain (BSC) — final on-chain settlement.
  • MongoDB — separate Payment document with direction: 'out'.

Preconditions

  • The original pay-in Payment has escrowState = 'funded' (or releasable).
  • The seller has set profile.walletAddress (validated ^0x... format).
  • The corresponding PurchaseRequest is in a status that allows payout (delivered, confirming, seller_paid, or completed).

Step-by-step narrative

SHKeeper-mediated payout

  1. Admin (or the auto-release scheduler — not yet implemented) hits POST /api/payment/shkeeper/payout with { purchaseRequestId, sellerOfferId, buyerId, sellerId, amount, recipientAddress, token?, network? }.
  2. Backend shkeeperPayoutService.createPayoutTask (shkeeperPayoutService.ts:40-150):
    • Validates ObjectIds and the recipientAddress (startsWith('0x')).
    • Idempotency: Payment.findOne({ purchaseRequestId, sellerOfferId, sellerId, provider:'shkeeper', direction:'out', status: { $in:['pending','processing','completed'] } }) — if found, reuses it.
    • Creates a new Payment document with direction: 'out', escrowState: 'releasing', blockchain.receiver = recipientAddress.
    • Calls SHKeeper Payouts API (POST /api/v1/payout) with the body documented at https://shkeeper.io/api/#tag/Payouts. SHKeeper returns a task_id.
    • Stores Payment.providerPaymentId = task_id, metadata.shkeeperTaskId = task_id, metadata.payoutType = 'seller-payment'.
  3. Polling or webhook: when SHKeeper completes the payout, it pushes a webhook (or the backend polls GET /api/v1/payout/{task_id}) and the system flips Payment.status = 'completed', escrowState = 'released', populates blockchain.transactionHash.
  4. The original pay-in Payment is updated in tandem: escrowState = 'released', PurchaseRequest.status = 'seller_paid'completed.
  5. Notifications: notifyPayoutSent to the seller, internal admin log.

Manual admin payout

  1. Admin opens the request detail in the admin view; the admin-step component admin-wallet-payout.tsx shows the recipient and amount.
  2. Admin connects their wallet (useWeb3 / web3Service.connect()).
  3. Admin clicks "Send payout"; wagmi triggers transfer(recipient, amount) on the USDT contract.
  4. After confirmation, the admin clicks "Confirm in system", which POSTs POST /api/payment/admin/confirm-tx/:paymentId with { txHash, kind: 'release' }.
  5. Backend confirmAdminTx(paymentId, txHash, 'release') (shkeeperService.ts:628-647) sets status: 'completed', escrowState: 'released', blockchain.transactionHash = txHash.

Sequence diagram (SHKeeper payout)

sequenceDiagram
    autonumber
    actor A as Admin/System
    participant BE as Backend
    participant DB as MongoDB
    participant SK as SHKeeper Payout API
    participant BC as BSC
    actor S as Seller

    A->>BE: POST /api/payment/shkeeper/payout
    BE->>DB: Payment.create({direction:"out", escrowState:"releasing"})
    BE->>SK: POST /api/v1/payout {to, amount, crypto}
    SK-->>BE: { task_id, status:"pending" }
    BE->>DB: Payment.providerPaymentId=task_id
    SK->>BC: signed payout tx (managed wallet)
    BC-->>SK: confirmed
    SK->>BE: webhook payout-completed (or BE polls)
    BE->>DB: Payment.status="completed"\nescrowState="released"\ntxHash
    BE->>DB: pay-in Payment.escrowState="released"\nPurchaseRequest.status="seller_paid"
    BE->>S: notifyPayoutSent

API calls

Method Endpoint Source
POST /api/payment/shkeeper/payout shkeeperPayoutRoutes.tscreatePayoutTask
GET /api/payment/shkeeper/payout/:taskId Polls SHKeeper task status
POST /api/payment/admin/confirm-tx/:paymentId Manual admin confirmation
GET /api/payment/admin/payouts List payouts (admin dashboard)

Database writes

  • payments — new outgoing document; updates to status, escrowState, blockchain.transactionHash as the task progresses.
  • payments (pay-in counterpart) — escrowState = 'released'.
  • purchaserequestsstatus advances to seller_paidcompleted.
  • notifications — seller payout receipt.

Socket events emitted

  • payment-status (admin) on each transition.
  • purchase-request-update status-changed.

Side effects

  • fix-transaction-hashes.js at repo root (backend/fix-transaction-hashes.js) — script used to backfill missing blockchain.transactionHash on payouts where the SHKeeper webhook arrived without the txid (e.g. signature length mismatch in dev). Run locally with the same Mongo URI to repair stale documents. Use it as the reference for the data-fix pattern — pull recent payouts, query SHKeeper for invoice/task details, write back the hash.
  • Hash repair — periodic reconciliation against SHKeeper invoice GET endpoints ensures bookkeeping accuracy.

Error / edge cases

  • Invalid recipient address → throws synchronously, no DB record created.
  • SHKeeper insufficient hot-wallet balance → SHKeeper returns an error; payout task stays pending, backend logs.
  • Duplicate payout request → idempotency: existing payment returned with no extra SHKeeper call.
  • Payout reverted on chain → SHKeeper marks the task failed; backend sets Payment.status = 'failed', escrowState = 'failed'. Admin retries.
  • Missing transactionHash after success → use fix-transaction-hashes.js to backfill.
  • Manual payout signed but never confirmed in system → on-chain transfer happened, but Payment.escrowState stays releasing. Admin can run a reconciliation script that scans the escrow wallet's outgoing txs and matches by amount/timestamp.
  • Seller changes wallet address mid-flight → the saved recipientAddress is the snapshot taken at payout creation; subsequent profile changes do not affect in-flight payouts.

[!warning] Auto-release is not yet implemented Today, payouts are admin-initiated. The flow is ready for an automatic trigger when Delivery Confirmation Flow completes — implement a cron job or queue worker that scans for PurchaseRequest.status='delivered' and auto-creates payouts after a configurable grace period.

Linked flows

Source files

  • Backend: backend/src/services/payment/shkeeper/shkeeperPayoutService.ts
  • Backend: backend/src/services/payment/shkeeper/shkeeperPayoutRoutes.ts
  • Backend: backend/src/services/payment/shkeeper/shkeeperService.ts:614-647 (build & confirm admin tx payload)
  • Backend: backend/fix-transaction-hashes.js (reconciliation script)
  • Frontend: frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx
  • Frontend: frontend/src/web3/web3Service.ts