--- title: Payment tags: [data-model, mongoose] aliases: [Payment Record, Escrow, IPayment] --- # Payment > **Last updated:** 2026-05-31 — added AMN scanner provider, oracle quote mirror, and Postgres `payment_quotes` linkage. 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. > [!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 `ObjectId`s, 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`**: > ```ts > 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` | — | — | Confirmation count. | | `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-` | `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 stores 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. ## 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: ```mermaid 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'`): ```mermaid 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 ```ts // 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]].