Files
nick-doc/02 - Data Models/Payment.md
2026-05-31 15:21:28 +04:00

13 KiB
Raw Blame History

title, tags, aliases
title tags aliases
Payment
data-model
mongoose
Payment Record
Escrow
IPayment

Payment

Last updated: 2026-05-31 — added AMN scanner provider, oracle quote mirror, Postgres payment_quotes linkage, webhook confirmation persistence, and capped accepted confirmation counts.

Records every monetary movement in the marketplace: buyer pay-ins, seller payouts, and refunds. The current model is centered on Request Network pay-in, in-house checkout metadata, on-chain transaction verification, escrow state, and provider request IDs. The provider and direction discriminators let one collection hold incoming buyer payments, outgoing seller releases, refunds, and legacy/other provider records.

[!warning] Runtime store The Payment document is still created, read, and updated through Mongoose on normal request paths. Backend 2.6.82 can persist oracle quotes to Postgres payment_quotes, but that is conditional on ORACLE_QUOTING_ENABLED=true and does not make the whole payment domain PG-authoritative. See Postgres Runtime Cutover Status.

[!note] Source backend/src/models/Payment.ts:3 — schema definition backend/src/models/Payment.ts:257 — model export (default export)

[!warning] Mixed types purchaseRequestId, sellerOfferId, and sellerId use Schema.Types.Mixed. They are usually ObjectIds, but the template-checkout flow passes string ids that do not yet exist in the database, so the schema accepts both.

[!note] provider values The current backend schema accepts request.network, amn.scanner, shkeeper, and other. amn.scanner is the in-house scanner provider used for direct on-chain monitoring. Older docs and some frontend types may still mention historical values such as test or decentralized; treat those as legacy until their active routes are audited again.

[!warning] confirmed vs completed — stats undercount Payment stats (paymentService.getPaymentStats) only increment successfulPayments for status confirmed:

case "confirmed": stats.successfulPayments += stat.count; break;

The terminal SHKeeper / DePay state is completed, which has no case in the switch and is therefore not counted as a successful payment. ⚠️ This causes successful-payment stats to undercount any payment that reached completed.

[!warning] SIM_ payment-hash bypass — security concern In both payment/paymentRoutes.ts and marketplace/routes.ts, a paymentHash that starts with SIM_ (or a short 0x... hash under 64 chars) is treated as a simulated transaction and skips on-chain verification entirely (isVerified = true). There is no environment guard (e.g. no NODE_ENV !== 'production' check) around this branch, so the bypass is reachable in production. ⚠️ A caller can mark a payment verified without any real on-chain settlement.

Schema

Field Type Required Default Validation Index Description
purchaseRequestId Mixed (ObjectId or String) yes yes (compound, partial) Linked PurchaseRequest id (or template id).
sellerOfferId Mixed (ObjectId or String) yes Linked SellerOffer id (or template offer ref).
buyerId ObjectId → User yes yes (compound) Buyer paying.
sellerId Mixed (ObjectId or String) yes yes (compound) Seller receiving (or template seller).
amount.amount Number yes Numeric amount.
amount.currency String yes USDT Settlement currency.
provider String no request.network enum: request.network / amn.scanner / shkeeper / other yes (compound, partial) Payment processor. amn.scanner is the in-house scanner pay-in rail.
direction String no in enum: in / out / refund yes (compound, partial) Flow direction.
blockchain.network String no Network identifier.
blockchain.transactionHash String no yes (sparse) On-chain tx hash.
blockchain.blockchain String no enum: ethereum / polygon / bsc / avalanche / solana / optimism / arbitrum / base / gnosis Chain.
blockchain.token String no Token symbol.
blockchain.sender String no Source address.
blockchain.receiver String no Destination address.
blockchain.confirmedAt Date no When tx confirmed.
blockchain.confirmations Number no 0 Accepted confirmation count. For settled webhooks this is capped at the effective per-chain threshold (for example 50, 200, 300) rather than an endlessly increasing live block count; payment screens render settled values with a + suffix.
status String no pending enum: pending / processing / confirmed / completed / failed / cancelled / refunded yes (compound) Lifecycle status. ⚠️ confirmed vs completed: only confirmed is counted as a successful payment in stats. See status note below.
escrowState String no enum: funded / releasable / released / refunded / releasing / failed / cancelled / partial Escrow lifecycle. Note the intermediate states releasable (delivery confirmed, ready to pay out) and releasing (payout in flight) between funded and released.
providerPaymentId String no yes (sparse) External provider id for idempotency.
metadata.userAgent String no Browser UA.
metadata.ipAddress String no Client IP.
metadata.walletType String no Wallet category.
metadata.paymentMethod String no Payment method label.
metadata.shkeeperUrl String no Invoice URL.
metadata.shkeeperInvoiceId String no Invoice id.
metadata.shkeeperData Mixed no Raw provider payload.
metadata.shkeeperStatus String no Provider status string.
metadata.balanceFiat String no Fiat-equivalent balance.
metadata.balanceCrypto String no Crypto balance.
metadata.cryptoName String no Crypto label.
metadata.walletAddress String no Wallet address.
metadata.shkeeperTaskId String no Payout task id.
metadata.requestNetworkRequestId String no Request Network request id.
metadata.requestNetworkPaymentReference String no Request Network payment reference.
metadata.requestNetworkSecurePaymentUrl String no Request Network secure payment URL.
metadata.requestNetworkData Mixed no Raw Request Network payload.
metadata.transactionSafety Mixed no Last Transaction Safety Provider decision, checks, evidence, and blocker reason.
metadata.derivedDestination Object no Snapshot of per-payment derived destination address/path/index/chain.
metadata.lastWebhookAt Date no Last webhook timestamp.
metadata.webhookPayload Mixed no Last webhook body.
metadata.createdVia String no Origin marker.
metadata.payoutType String no Payout sub-type.
metadata.error String no Last error message.
metadata.failedAt Date no When it failed.
quote.quoteId String no PG payment_quotes.id when a Postgres quote row exists.
quote.pricingCurrency String no Seller offer currency used for the quote.
quote.offerAmount String no decimal string Seller obligation in pricingCurrency.
quote.invoiceUSD String no decimal string offerAmount × fxRate at quote time.
quote.fxRate String no decimal string Pricing currency to USD rate.
quote.fxSource String no FX provider id.
quote.tokenPriceUsd String no decimal string Settlement token USD price used for depeg protection.
quote.depegSource String no Depeg/token-price provider id.
quote.rawSettleAmount String no decimal string Exact invoiceUSD / tokenPriceUsd before rounding.
quote.settleAmount String no decimal string Final token amount after seller-protective rounding.
quote.roundingBps Number no integer bps Upward rounding applied.
quote.depegAdjustmentBps Number no integer bps Absolute deviation from USD peg.
quote.token String no Settlement token symbol.
quote.chainId Number no Settlement chain id.
quote.fetchedAt Date no Oracle rate timestamp.
quote.expiresAt Date no Quote expiry.
createdAt Date auto Date.now yes (compound) Mongoose timestamp.
processedAt Date no When processing started.
completedAt Date no When fully settled.
notes String no Free-form notes.
updatedAt Date auto Mongoose timestamp.

Virtuals

Virtual Returns Definition
paymentRef PAY-<LAST_8_OF_ID_UPPERCASE> backend/src/models/Payment.ts:191

The schema enables toJSON: { virtuals: true } and toObject: { virtuals: true } so the ref appears in API responses.

Indexes

Defined at backend/src/models/Payment.ts:174-188:

  • { status: 1, createdAt: -1 } — admin queues.
  • { buyerId: 1, status: 1 } — buyer dashboard.
  • { sellerId: 1, status: 1 } — seller dashboard.
  • { 'blockchain.transactionHash': 1 } (sparse) — webhook lookup by hash.
  • { providerPaymentId: 1 } (sparse) — provider idempotency.
  • { buyerId: 1, purchaseRequestId: 1, provider: 1, direction: 1 } (unique, partial: provider === 'shkeeper' && direction === 'in' && status === 'pending', name uniq_pending_shkeeper_by_buyer_session) — guards against duplicate pending invoices.

Postgres Quote Table

The Postgres money-core branch can store oracle quotes in payment_quotes, a 1:1 child table keyed by payment_id → payments.id. Amount/rate columns use numeric(38,18) and the route resolves the PG parent through payments.legacy_object_id or id_map during the Mongo/PG dual-write window. If the PG payment row is missing, the quote is mirrored to this Mongo quote subdocument and a pg_dualwrite_gaps row is recorded for reconciliation. This table is quote/audit storage only until the payment service itself is wired through the PG repository path.

Pre/Post Hooks

None declared.

Instance Methods

None defined.

Static Methods

None defined.

Relationships

  • References: User (buyerId, sometimes sellerId), PurchaseRequest (purchaseRequestId), SellerOffer (sellerOfferId).
  • Referenced by: Indirectly through PurchaseRequest status transitions and Dispute resolution amounts; no model holds a direct foreign key back to Payment.

State Transitions

Payment status:

stateDiagram-v2
    [*] --> pending
    pending --> processing : webhook received
    processing --> confirmed : tx confirmed
    confirmed --> completed : escrow released / payout done
    pending --> cancelled : buyer aborts
    processing --> failed : provider error
    completed --> refunded : dispute resolved
    failed --> [*]
    cancelled --> [*]
    completed --> [*]
    refunded --> [*]

Escrow state (for direction: 'in'):

stateDiagram-v2
    [*] --> funded : buyer pays
    funded --> releasable : delivery confirmed
    releasable --> releasing : payout initiated
    releasing --> released : payout completed
    funded --> refunded : dispute refund
    releasing --> failed : payout error
    released --> [*]
    refunded --> [*]
    failed --> [*]

Common Queries

// Buyer history
Payment.find({ buyerId, direction: 'in' }).sort({ createdAt: -1 });

// Seller payouts
Payment.find({ sellerId, direction: 'out', status: 'completed' });

// Webhook lookup
Payment.findOne({ providerPaymentId });

// Pending escrows ready for release
Payment.find({ direction: 'in', escrowState: 'releasable' });

// Idempotent invoice creation (will fail by unique index if a pending one exists)
Payment.create({
  buyerId, purchaseRequestId, provider: 'shkeeper', direction: 'in', status: 'pending', ...
});

Related: PurchaseRequest, SellerOffer, User, Dispute.