14 KiB
title, tags, aliases
| title | tags | aliases | |||||
|---|---|---|---|---|---|---|---|
| Payment |
|
|
Payment
Last updated: 2026-06-01 — documented the first payment-repo runtime seam for funds ledger appends/balance reads.
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 most normal request paths. Backend2.8.20routesFundsLedgerEntryappends and balance reads through the payment repository seam (REPO_PAYMENT=mongo|dual|pg), but the default remains Mongo and this does not make the whole payment domain PG-authoritative. Oracle quotes can persist to Postgrespayment_quoteswhenORACLE_QUOTING_ENABLED=trueand a PG parent payment row can be resolved. 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.
[!note]
confirmedvscompleted— stats parity Payment stats should count bothconfirmedandcompletedas successful. Backend2.8.20aligns the Mongo and Drizzle payment repository implementations with that behavior before broader payment-service wiring.
[!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 |
— | — | 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', 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.
Funds Ledger Repository Seam
Backend 2.8.20 routes appendFundsLedgerEntry, getFundsBalanceByPurchaseRequestId, and getFundsBalanceByPaymentId through getPaymentRepo(). In default mode this is still MongoPaymentRepo, preserving the existing FundsLedgerEntry collection behavior. REPO_PAYMENT=dual can mirror ledger writes to Postgres after backfill/verification; REPO_PAYMENT=pg should wait until the surrounding payment services, derived destinations, and webhook/update paths are also wired and soaked.
The Drizzle ledger balance path supports both UUID entity refs and external/string refs, which matters for template-checkout rows where purchaseRequestId or paymentId is not a normal Mongo ObjectId.
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.