13 KiB
title, tags, aliases
| title | tags | aliases | |||||
|---|---|---|---|---|---|---|---|
| Payment |
|
|
Payment
Last updated: 2026-05-31 — added AMN scanner provider, oracle quote mirror, Postgres
payment_quoteslinkage, and webhook confirmation persistence.
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
Paymentdocument is still created, read, and updated through Mongoose on normal request paths. Backend2.6.81can persist oracle quotes to Postgrespayment_quotes, but that is conditional onORACLE_QUOTING_ENABLED=trueand does not make the whole payment domain PG-authoritative. See Postgres Runtime Cutover Status.
[!note] Source
backend/src/models/Payment.ts:3— schema definitionbackend/src/models/Payment.ts:257— model export (default export)
[!warning] Mixed types
purchaseRequestId,sellerOfferId, andsellerIduseSchema.Types.Mixed. They are usuallyObjectIds, but the template-checkout flow passes string ids that do not yet exist in the database, so the schema accepts both.
[!note]
providervalues The current backend schema acceptsrequest.network,amn.scanner,shkeeper, andother.amn.scanneris the in-house scanner provider used for direct on-chain monitoring. Older docs and some frontend types may still mention historical values such astestordecentralized; treat those as legacy until their active routes are audited again.
[!warning]
confirmedvscompleted— stats undercount Payment stats (paymentService.getPaymentStats) only incrementsuccessfulPaymentsfor statusconfirmed: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 reachedcompleted.
[!warning]
SIM_payment-hash bypass — security concern In bothpayment/paymentRoutes.tsandmarketplace/routes.ts, apaymentHashthat starts withSIM_(or a short0x...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. noNODE_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 |
— | — | Confirmation count persisted from transaction-safety verifier evidence, provider payload confirmations, or the configured per-chain threshold fallback when a webhook already reports confirmed/completed. |
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', nameuniq_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, sometimessellerId), 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.