Files
nick-doc/02 - Data Models/Payment.md

202 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.
> [!warning] Runtime store
> The `Payment` document is still created, read, and updated through Mongoose on normal request paths. Backend `2.6.80` 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 `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-<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:
```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]].