docs: sync from backend 8fc2309 — M43/M44 missing FKs + H37 dispute enums
This commit is contained in:
@@ -1,45 +1,45 @@
|
||||
---
|
||||
title: Payment
|
||||
tags: [data-model, mongoose]
|
||||
tags: [data-model, postgresql, drizzle]
|
||||
aliases: [Payment Record, Escrow, IPayment]
|
||||
---
|
||||
|
||||
# Payment
|
||||
|
||||
> **Last updated:** 2026-06-01 — documented the first payment-repo runtime seam for funds ledger appends/balance reads.
|
||||
> **Last updated:** 2026-06-06 — MongoDB fully removed; PostgreSQL + Drizzle ORM is the only database layer as of backend v2.9.12.
|
||||
|
||||
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.
|
||||
Records every monetary movement in the marketplace: buyer pay-ins, seller payouts, and refunds. The 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 table 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 most normal request paths. Backend `2.8.20` routes `FundsLedgerEntry` appends 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 Postgres `payment_quotes` when `ORACLE_QUOTING_ENABLED=true` and a PG parent payment row can be resolved. See [[Postgres Runtime Cutover Status]].
|
||||
> [!note] Runtime store
|
||||
> The `Payment` record is stored exclusively in PostgreSQL (`payments` table). Mongoose and MongoDB have been completely removed from the backend as of v2.9.12. The repository factory returns Drizzle repos only. `MONGO_URI` / `MONGODB_URI` / `MONGO_CONNECT_MODE` env vars are obsolete; `PG_URL` is required.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/Payment.ts:3` — schema definition
|
||||
> `backend/src/models/Payment.ts:257` — model export (default export)
|
||||
> `backend/src/repositories/drizzle/DrizzlePaymentRepo.ts` — Drizzle repository implementation
|
||||
> `backend/src/db/schema/` — Drizzle schema definitions
|
||||
|
||||
> [!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] IDs
|
||||
> All primary keys are PostgreSQL UUIDs (`.id` field, string). The legacy MongoDB ObjectId is preserved as `legacy_object_id` for historical lookups only. Marketplace FKs (e.g. `sellerId`) reference `user.pgId` (UUID), not the legacy `_id`.
|
||||
|
||||
> [!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.
|
||||
> The backend accepts `request.network`, `amn.scanner`, `shkeeper`, `escrow`, and `other`. `amn.scanner` is the in-house scanner provider used for direct on-chain monitoring. `escrow` is used for internal escrow-native flows. 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.
|
||||
|
||||
> [!note] `confirmed` vs `completed` — stats parity
|
||||
> Payment stats should count both **`confirmed`** and **`completed`** as successful. Backend `2.8.20` aligns the Mongo and Drizzle payment repository implementations with that behavior before broader payment-service wiring.
|
||||
> Payment stats count both **`confirmed`** and **`completed`** as successful.
|
||||
|
||||
> [!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
|
||||
## PostgreSQL schema (Drizzle)
|
||||
|
||||
| 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. |
|
||||
| `id` | UUID (string) | yes | gen_random_uuid() | — | yes (PK) | Primary key. |
|
||||
| `purchaseRequestId` | UUID or String | yes | — | — | yes (compound, partial) | Linked [[PurchaseRequest]] id (or template id). |
|
||||
| `sellerOfferId` | UUID or String | yes | — | — | — | Linked [[SellerOffer]] id (or template offer ref). |
|
||||
| `buyerId` | UUID → [[User]] | yes | — | — | yes (compound) | Buyer paying. |
|
||||
| `sellerId` | UUID or String | yes | — | — | yes (compound) | Seller receiving (or template seller). References `user.pgId`. |
|
||||
| `amount` | String (decimal) | yes | — | decimal string | — | Settlement amount as a decimal string (e.g. `"12.50"`). |
|
||||
| `provider` | String | no | `request.network` | enum: `request.network` / `amn.scanner` / `shkeeper` / `escrow` / `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. |
|
||||
@@ -48,8 +48,11 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
|
||||
| `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. |
|
||||
| `blockchain.confirmations` | Number | no | `0` | — | — | Accepted confirmation count. For settled webhooks this is capped at the effective per-chain threshold rather than an endlessly increasing live block count; payment screens render settled values with a `+` suffix. |
|
||||
| `blockchain.blockNumber` | Number | no | — | — | — | Block number of the confirmed transaction. |
|
||||
| `blockchain.gasUsed` | Number | no | — | — | — | Gas units consumed by the transaction. |
|
||||
| `blockchain.isSimulated` | Boolean | no | — | — | — | True when the payment was created via the `SIM_` hash bypass (no real on-chain tx). |
|
||||
| `status` | String | no | `pending` | enum: `pending` / `processing` / `confirmed` / `completed` / `failed` / `cancelled` / `refunded` | yes (compound) | Lifecycle status. Both `confirmed` and `completed` are counted as successful in payment stats. |
|
||||
| `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. |
|
||||
@@ -58,7 +61,7 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
|
||||
| `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.shkeeperData` | JSONB | no | — | — | — | Raw provider payload. |
|
||||
| `metadata.shkeeperStatus` | String | no | — | — | — | Provider status string. |
|
||||
| `metadata.balanceFiat` | String | no | — | — | — | Fiat-equivalent balance. |
|
||||
| `metadata.balanceCrypto` | String | no | — | — | — | Crypto balance. |
|
||||
@@ -68,16 +71,16 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
|
||||
| `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.requestNetworkData` | JSONB | no | — | — | — | Raw Request Network payload. |
|
||||
| `metadata.transactionSafety` | JSONB | no | — | — | — | Last Transaction Safety Provider decision, checks, evidence, and blocker reason. |
|
||||
| `metadata.derivedDestination` | JSONB | 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.webhookPayload` | JSONB | 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.quoteId` | String | no | — | — | — | `payment_quotes.id` (UUID) 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. |
|
||||
@@ -93,57 +96,38 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
|
||||
| `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. |
|
||||
| `createdAt` | Date | auto | now() | — | yes (compound) | Row creation timestamp. |
|
||||
| `processedAt` | Date | no | — | — | — | When processing started. |
|
||||
| `completedAt` | Date | no | — | — | — | When fully settled. |
|
||||
| `notes` | String | no | — | — | — | Free-form notes. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Last update timestamp. |
|
||||
| `legacy_object_id` | String | no | — | — | yes (sparse) | Original MongoDB ObjectId preserved for historical lookups during migration window. |
|
||||
|
||||
## Virtuals
|
||||
## Virtuals / Computed
|
||||
|
||||
| Virtual | Returns | Definition |
|
||||
| Field | Returns | Description |
|
||||
| --- | --- | --- |
|
||||
| `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.
|
||||
| `paymentRef` | `PAY-<LAST_8_OF_ID_UPPERCASE>` | Derived from UUID `id`. Included in API responses. |
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/Payment.ts:174-188`:
|
||||
PostgreSQL indexes on the `payments` table:
|
||||
|
||||
- `{ 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.
|
||||
- `{ status, createdAt DESC }` — admin queues.
|
||||
- `{ buyerId, status }` — buyer dashboard.
|
||||
- `{ sellerId, status }` — seller dashboard.
|
||||
- `{ blockchain.transactionHash }` (sparse) — webhook lookup by hash.
|
||||
- `{ providerPaymentId }` (sparse) — provider idempotency.
|
||||
- `{ buyerId, purchaseRequestId, provider, direction }` (unique partial: `provider = 'shkeeper' AND direction = 'in' AND 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.
|
||||
|
||||
## 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.
|
||||
Oracle quotes are stored in `payment_quotes`, a 1:1 child table keyed by `payment_id → payments.id`. Amount/rate columns use `numeric(38,18)`. The `payments.legacy_object_id` column supports lookups that originate from legacy references during the migration window.
|
||||
|
||||
## 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`.
|
||||
- **References**: [[User]] (`buyerId`, `sellerId` via `pgId`), [[PurchaseRequest]] (`purchaseRequestId`), [[SellerOffer]] (`sellerOfferId`).
|
||||
- **Referenced by**: Indirectly through [[PurchaseRequest]] status transitions and [[Dispute]] resolution amounts; no table holds a direct foreign key back to `payments`.
|
||||
|
||||
## State Transitions
|
||||
|
||||
@@ -182,22 +166,17 @@ stateDiagram-v2
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Buyer history
|
||||
Payment.find({ buyerId, direction: 'in' }).sort({ createdAt: -1 });
|
||||
// Buyer history (Drizzle)
|
||||
db.select().from(payments).where(and(eq(payments.buyerId, buyerId), eq(payments.direction, 'in'))).orderBy(desc(payments.createdAt));
|
||||
|
||||
// Seller payouts
|
||||
Payment.find({ sellerId, direction: 'out', status: 'completed' });
|
||||
db.select().from(payments).where(and(eq(payments.sellerId, sellerId), eq(payments.direction, 'out'), eq(payments.status, 'completed')));
|
||||
|
||||
// Webhook lookup
|
||||
Payment.findOne({ providerPaymentId });
|
||||
db.select().from(payments).where(eq(payments.providerPaymentId, 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', ...
|
||||
});
|
||||
db.select().from(payments).where(and(eq(payments.direction, 'in'), eq(payments.escrowState, 'releasable')));
|
||||
```
|
||||
|
||||
Related: [[PurchaseRequest]], [[SellerOffer]], [[User]], [[Dispute]].
|
||||
|
||||
Reference in New Issue
Block a user