diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 172c95e..d862b8e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -72,9 +72,9 @@ export. The pieces: - Current sections: `account` (auth: email/phone/role, never the password hash), `profile` (patients), `appointments` (appointments, with slot times from `availability` and the doctor's name from `doctors`, both via their - services), `payments` (payments: amounts, status, on-chain tx hash / - escrow id / contract id; the platform's signing secrets never live in a - row and can't reach an export). + services), `payments` (version 2: amount in the asset's smallest unit, + asset code, status, on-chain tx hash / escrow id / contract id; the + platform's signing secrets never live in a row and can't reach an export). **Adding a module with patient data (referrals, prescriptions, lab orders, …):** export a `PatientExportSection` from that module's `services/` and add diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index b5ec372..564c438 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -8,7 +8,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc - Auth: JWT access + refresh, roles = `patient | doctor | facility | admin`. The refresh cookie is `SameSite=None` cross-site, so `/auth/refresh` + `/auth/logout` require an `X-CSRF-Token` header (a per-session token bound into the refresh JWT) and an allowlisted `Origin` — see `backend/src/middleware/csrf.ts`. CORS is an explicit per-environment allowlist (`CORS_ORIGINS`). - Frontend: React + TypeScript + Vite, React Query + Zustand. - Video/voice, SMS/email, file storage: **third-party, behind an interface** — never build custom. -- Jobs: keep it simple (cron/interval) until a real job exists; don't pre-install a queue. Existing interval jobs: login-throttle prune (`src/server.ts`) and patient-export worker/cleanup (`src/jobs/patient-exports.ts`), both started only from `server.ts`. +- Jobs: keep it simple (cron/interval) until a real job exists; don't pre-install a queue. Existing interval jobs: login-throttle prune (`src/server.ts`), patient-export worker/cleanup (`src/jobs/patient-exports.ts`), and prepared-payment expiry (`src/jobs/payment-intents.ts`), all started only from `server.ts`. - New module holding patient data? Register a patient-export section (ARCHITECTURE.md §3.1) — right of access must stay complete. ## Hard rules (violating these = request changes on the PR) @@ -37,7 +37,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc - `WalletLink` is scoped to the **facility** (the on-chain payee), not the patient. - Each issued order stores its selected facility and clinician-entered total in XLM stroops; a database check requires exactly one PaymentIntent order FK. - `release`/`refund` are admin-triggered HTTP actions in v1 (`POST /payments/intents/:id/{release,refund}`) — there's no `FulfillmentRecord.status == fulfilled` to trigger off of yet. Don't treat this as the final design. -- The actual Stellar contract calls go through `@docforum/escrow-sdk` (`backend/src/modules/payments/services/escrow-sdk-loader.ts` — dynamic `import()`, the SDK is ESM-only and this backend is CommonJS). Never reimplement contract-calling logic in this repo. +- The actual Stellar contract calls go through `@docforum/escrow-sdk` (`backend/src/modules/payments/services/escrow-sdk-loader.ts` — dynamic `import()`, the SDK is ESM-only and this backend is CommonJS). Never reimplement contract-calling logic in this repo. The SDK used today (0.2.0) has no prepare call, so prepare returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED` until the 0.3.0 prepare/submit call (docforum-escrow #58) lands. ## Known unresolved design gaps (do not silently "fix" without flagging in PR description) - No routing rule yet for a referral with no specific target doctor (which specialist gets it?). **v1 re-scope (2026-10-17): `target_doctor_id` is required, so unassigned referrals can't exist — but that makes PRD §8.2.2's "waiting for a specialist" state unreachable rather than implemented. ADR still owed (issue #6).** @@ -48,6 +48,8 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc - `AuditEvent` foundation exists (Phase 8, issue #43 — ARCHITECTURE.md §4.10): append-only (DB triggers), written by `auditService.record(tx, …)` **inside the same transaction** as the change. Wired for booking (direct and referral-origin), doctor verification, payment fund/release/refund, referral accept/decline and referral admin actions (`entity_type: 'Referral'`) — **Phase 4 transitions must call it when they're built.** There is one audit table: referral actions go into it, not into a second one. Never put PHI in `metadata`. - No email/SMS provider integrated yet (Phase 6, issue #17) — email verification (issue #34) therefore can't deliver its link on a hosted deployment; `backend/scripts/mark-email-verified.mjs` is the interim operator path. - One doctor = one calendar assumed; multi-facility doctors not modeled. +- Non-custodial payments are only half-built: prepare exists (issue #80), submit/fee-bump does not, and the SEP-10 patient-wallet link flow that creates `PatientWalletLink` rows is not implemented. `PaymentIntent.refundAfter` uses the intent's `createdAt` as the order-expiry anchor because orders have no expiry field yet (Phase 4). +- `@docforum/escrow-sdk` 0.2.0 has no prepare call, so `/payments/intents/:id/prepare` returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED` against the real SDK until docforum-escrow publishes SDK 0.3.0 (docforum-core #32 / docforum-escrow #58). Integration tests exercise the endpoint with a mocked SDK. ## Overengineering guardrails (things NOT to build yet) - No custom video/WebRTC. diff --git a/ROADMAP.md b/ROADMAP.md index 2c645a0..526b047 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,8 +96,9 @@ roadmap. See each phase section below for status and issue links. - [ ] Idempotent escrow funding + on-chain reconcile job (`fundPaymentIntent` is check-then-act — docforum-escrow threat model §5). Tracked as [issue #24](https://github.com/DocForum/docforum-core/issues/24) (High, 200 pts). - [ ] ADR: releaser key custody — multisig, KMS, or per-period rotation (threat model T1/T2). Tracked as [issue #25](https://github.com/DocForum/docforum-core/issues/25) (Medium, 150 pts). - [ ] Checksum-validate facility wallet keys, not just regex. Tracked as [issue #26](https://github.com/DocForum/docforum-core/issues/26) (Trivial, 100 pts). -- [ ] Non-custodial patient payments (v2): patients fund escrows from their own Stellar wallet, custodial kept as fallback. **Proposed** in `docs/adr/0005-non-custodial-patient-payments.md`; implementation issues to be opened once it's Accepted. -- [ ] Upgrade to `@docforum/escrow-sdk` 0.3.0 and the current testnet contract (`refund_after`, balance-delta check, `getEscrow`) — step 1 of ADR 0005's plan, but worth doing regardless. Tracked as [issue #32](https://github.com/DocForum/docforum-core/issues/32) (Medium, 150 pts); depends on `docforum-escrow` publishing `sdk-v0.3.0`. +- [ ] Non-custodial patient payments (v2): patients fund escrows from their own Stellar wallet, custodial kept as fallback. **Proposed** in `docs/adr/0005-non-custodial-patient-payments.md`; implementation has started (see the prepare item below). +- [x] Prepare endpoint — `POST /payments/intents/:id/prepare` builds + simulates the patient-as-payer `create_escrow` transaction, returns unsigned XDR + expiry ledger, and moves the intent to `awaiting_signature` via a conditional update. Adds `awaiting_signature`/`submitting` statuses and a prepared-transaction expiry sweep back to `created` (a normal case, not an error). Tracked as [issue #80](https://github.com/DocForum/docforum-core/issues/80). Patient ownership is service-enforced (someone else's intent is `404`); a missing verified payer wallet is `409 WALLET_NOT_LINKED`. The real SDK call is pending docforum-escrow #58 (SDK 0.3.0) — until then the endpoint returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED`, and integration tests use a mocked SDK. +- [ ] Upgrade to `@docforum/escrow-sdk` 0.3.0 and the current testnet contract (`refund_after`, balance-delta check, `getEscrow`, plus the prepare/submit calls #80 needs) — step 1 of ADR 0005's plan, but worth doing regardless. Tracked as [issue #32](https://github.com/DocForum/docforum-core/issues/32) (Medium, 150 pts); depends on `docforum-escrow` publishing `sdk-v0.3.0` (#58). This is now the blocker for a real (non-mocked) prepare. - [ ] Doc reconciliation: PRD §8.1.6 (facility fulfillment integrity) — not marked resolved; this phase proves the payment *mechanism* works, not the fulfillment-integrity question, which still needs real `FulfillmentRecord`/facility-matching (Phase 5) to actually address. ## Phase 6 — Notifications diff --git a/backend/.env.example b/backend/.env.example index 7696748..f718001 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -60,6 +60,8 @@ PLATFORM_RELEASER_SECRET= # — see that repo's docs/testnet-deployments.md for the current value. ESCROW_CONTRACT_ID=CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM # Native XLM SAC on testnet (stellar contract id asset --asset native --network testnet) +# Kept as the legacy fallback for PaymentIntent rows created before #79; +# new intents take their token from PAYMENT_ASSETS below. ESCROW_TOKEN_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC # Contract event ingestion (issue #86). Use comma-separated id@deployment-ledger # pairs to include current and historical redeployments. Each deployment needs diff --git a/backend/prisma/migrations/20261019100000_payment_assets_and_prepare/migration.sql b/backend/prisma/migrations/20261019100000_payment_assets_and_prepare/migration.sql new file mode 100644 index 0000000..f6cd3f7 --- /dev/null +++ b/backend/prisma/migrations/20261019100000_payment_assets_and_prepare/migration.sql @@ -0,0 +1,42 @@ +-- Issue #79: PaymentIntent is denominated in an allowlisted asset, not +-- implicitly native XLM. The amount column is renamed, not recreated, so +-- every existing row keeps its exact smallest-unit value (no data copy, no +-- precision loss). `assetCode` is backfilled to 'XLM' because that is the +-- only asset v1 could have created; `tokenContractId` stays NULL for those +-- rows and the service resolves it from ESCROW_TOKEN_ID (see schema comment). +ALTER TABLE "PaymentIntent" RENAME COLUMN "amountStroops" TO "amount"; +ALTER TABLE "PaymentIntent" ADD COLUMN "assetCode" TEXT NOT NULL DEFAULT 'XLM'; +ALTER TABLE "PaymentIntent" ADD COLUMN "tokenContractId" TEXT; +ALTER TABLE "PaymentIntent" ALTER COLUMN "assetCode" DROP DEFAULT; + +-- Issue #80: prepare/expire state machine. `awaiting_signature` and +-- `submitting` are added to the status enum (submitting is reserved for the +-- submit half of ADR 0005 and not written yet). +ALTER TYPE "PaymentIntentStatus" ADD VALUE 'awaiting_signature'; +ALTER TYPE "PaymentIntentStatus" ADD VALUE 'submitting'; + +ALTER TABLE "PaymentIntent" ADD COLUMN "preparedTxHash" TEXT; +ALTER TABLE "PaymentIntent" ADD COLUMN "preparedXdr" TEXT; +ALTER TABLE "PaymentIntent" ADD COLUMN "preparedExpiryLedger" INTEGER; +ALTER TABLE "PaymentIntent" ADD COLUMN "preparedExpiresAt" TIMESTAMP(3); +ALTER TABLE "PaymentIntent" ADD COLUMN "refundAfter" TIMESTAMP(3); + +-- Expiry sweep: find live prepared transactions whose window has passed. +CREATE INDEX "PaymentIntent_status_preparedExpiresAt_idx" ON "PaymentIntent"("status", "preparedExpiresAt"); + +-- ADR 0005 step 1: patient payer wallet link (SEP-10 verified once that +-- flow exists). Separate from WalletLink, which stays the facility payee. +CREATE TABLE "PatientWalletLink" ( + "id" TEXT NOT NULL, + "patientProfileId" TEXT NOT NULL, + "stellarPublicKey" TEXT NOT NULL, + "verifiedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PatientWalletLink_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PatientWalletLink_patientProfileId_key" ON "PatientWalletLink"("patientProfileId"); + +ALTER TABLE "PatientWalletLink" ADD CONSTRAINT "PatientWalletLink_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 94fba03..57494b2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -728,6 +728,12 @@ model Notification { enum PaymentIntentStatus { created + // ADR 0005 steps 2-4: a prepared (unsigned) create_escrow transaction + // exists, then is being submitted with the patient's signature. The + // submit half is not built yet (issue #80 only covers prepare), so + // `submitting` is reserved by the state machine but not written today. + awaiting_signature + submitting escrowed released refunded @@ -749,14 +755,38 @@ model PaymentIntent { facilityId String facility FacilityProfile @relation(fields: [facilityId], references: [id]) - // Raw stroops (native XLM's smallest unit) — matches - // @docforum/escrow-sdk's `amount: bigint` exactly. v1 is native-XLM - // only, no decimal/currency-conversion layer: an earlier scaffold draft - // of this model used a Decimal amount + "USDC" currency default, - // dropped here in favor of what's actually proven end-to-end on - // testnet (see ADR 0004). Multi-asset support is a future addition, - // not assumed by this field. - amountStroops BigInt + // Amount in the smallest unit of `assetCode` (stroops for XLM, 7-decimal + // units for USDC) — matches @docforum/escrow-sdk's `amount: bigint` + // exactly. Renamed from `amountStroops` by issue #79; the migration is a + // plain column rename, so existing rows keep their value. This module + // never scales or converts amounts, so it never assumes an asset's + // decimals (the allowlist carries them for clients only). + amount BigInt + // The asset this intent is denominated in — `XLM` or `USDC` in the + // default testnet allowlist. Assets are identified on-chain by the SAC + // contract id, not by the code, so a new intent must name an allowlisted + // asset (see config.payments.assets and issue #79). + assetCode String + // The Stellar Asset Contract address the escrow pulls from. Nullable only + // for rows created before issue #79: those were funded against + // `ESCROW_TOKEN_ID` (native XLM), which the service falls back to when it + // sees a null here. New rows always store it. + tokenContractId String? + + // ADR 0005 step 2 (issue #80). `preparedTxHash` is the hash of the + // unsigned create_escrow transaction the patient must sign; + // `preparedXdr` lets a retry return exactly the same transaction instead + // of building a second one. Both are cleared when the preparation + // expires back to `created`. + preparedTxHash String? + preparedXdr String? + preparedExpiryLedger Int? + preparedExpiresAt DateTime? + // ADR 0005: escrow refund deadline = order expiry + grace. Orders don't + // exist as real records yet (Phase 4), so the service uses the intent's + // createdAt as the order-expiry anchor — see + // config.payments.orderExpiryHours / refundGraceHours. + refundAfter DateTime? // Set once funded (create_escrow succeeds) — needed to later call // release/refund against the right on-chain escrow. @@ -772,6 +802,27 @@ model PaymentIntent { @@index([facilityId]) @@index([status]) + @@index([status, preparedExpiresAt]) +} + +// ADR 0005 step 1: a patient's verified Stellar address, used as the payer +// of a non-custodial escrow. Verification is SEP-10 (a signed challenge), +// not the type-the-address form used for facilities — but the SEP-10 +// endpoints are not built yet, so nothing in this repo creates these rows +// today. Prepare (issue #80) requires one and returns 409 WALLET_NOT_LINKED +// otherwise. Deliberately a separate model from WalletLink: that one is +// the facility payee (unique per facility), this one is the patient payer +// (unique per patient), and conflating the two would make a facility's +// payout address eligible as a patient payer. +model PatientWalletLink { + id String @id @default(uuid()) + patientProfileId String @unique + stellarPublicKey String + verifiedAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Cascade) } model WalletLink { diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index f059127..22cc44c 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -18,6 +18,77 @@ function parseTrustProxy(raw: string | undefined, isProduction: boolean): boolea return raw; } +/** A payment asset the escrow may be denominated in (issue #79). The + * contract id is the Stellar Asset Contract address the escrow pulls from; + * `decimals` is informational for clients (every amount in this API is + * already in the asset's smallest unit, so the server never scales one). */ +export interface PaymentAsset { + code: string; + tokenContractId: string; + decimals: number; + /** Set for issued assets (e.g. USDC). Native XLM has none and needs no + * trustline; every issued asset's payee must have one before funding. */ + issuer?: string; +} + +// Testnet defaults. Override per environment with PAYMENT_ASSETS; production +// must set it explicitly (mainnet contract ids differ). Only assets without +// clawback, transfer fees or rebasing belong in the allowlist — escrow +// threat model T5 — and the parser refuses entries that opt into clawback or +// rebasing. This is a curated policy list, not an on-chain capability probe. +const TESTNET_NATIVE_SAC = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; +const TESTNET_USDC_SAC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; +const TESTNET_USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + +const STELLAR_CONTRACT_ID = /^C[A-Z2-7]{55}$/; +const STELLAR_PUBLIC_KEY = /^G[A-Z2-7]{55}$/; + +function parsePaymentAssets(raw: string | undefined): PaymentAsset[] { + const fallback: PaymentAsset[] = [ + { code: 'XLM', tokenContractId: process.env.ESCROW_TOKEN_ID ?? TESTNET_NATIVE_SAC, decimals: 7 }, + { + code: 'USDC', + tokenContractId: TESTNET_USDC_SAC, + decimals: 7, + issuer: TESTNET_USDC_ISSUER, + }, + ]; + if (!raw) return fallback; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('PAYMENT_ASSETS must be a JSON array of { code, tokenContractId, decimals, issuer? }.'); + } + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error('PAYMENT_ASSETS must be a non-empty JSON array.'); + } + const seen = new Set(); + return parsed.map((entry, index) => { + if (typeof entry !== 'object' || entry === null) throw new Error(`PAYMENT_ASSETS[${index}] must be an object.`); + const { code, tokenContractId, decimals, issuer, clawback, rebase } = entry as Record; + if (clawback === true || rebase === true) { + throw new Error(`PAYMENT_ASSETS[${index}] opts into clawback/rebasing, which the escrow allowlist forbids (T5).`); + } + if (typeof code !== 'string' || !/^[A-Z0-9]{1,12}$/.test(code)) { + throw new Error(`PAYMENT_ASSETS[${index}].code must be 1-12 uppercase alphanumeric characters.`); + } + if (seen.has(code)) throw new Error(`PAYMENT_ASSETS contains duplicate asset code ${code}.`); + seen.add(code); + if (typeof tokenContractId !== 'string' || !STELLAR_CONTRACT_ID.test(tokenContractId)) { + throw new Error(`PAYMENT_ASSETS[${index}].tokenContractId must be a Stellar contract id.`); + } + if (typeof decimals !== 'number' || !Number.isInteger(decimals) || decimals < 0 || decimals > 18) { + throw new Error(`PAYMENT_ASSETS[${index}].decimals must be an integer between 0 and 18.`); + } + if (issuer !== undefined && (typeof issuer !== 'string' || !STELLAR_PUBLIC_KEY.test(issuer))) { + throw new Error(`PAYMENT_ASSETS[${index}].issuer must be a Stellar public key.`); + } + return { code, tokenContractId, decimals, ...(issuer ? { issuer } : {}) }; + }); +} + const PRODUCTION_CORS_ORIGINS = ['https://docforum.github.io']; const LOCAL_DEV_CORS_ORIGINS = ['http://localhost:5173', 'http://127.0.0.1:5173']; diff --git a/backend/src/jobs/README.md b/backend/src/jobs/README.md index f75c7e0..474ad28 100644 --- a/backend/src/jobs/README.md +++ b/backend/src/jobs/README.md @@ -17,6 +17,7 @@ safe. | Login-throttle prune (issue #35) | `src/server.ts` | Deletes stale `LoginAttempt` buckets. | | Patient-export worker (issue #40) | `patient-exports.ts` | Generates pending exports one at a time; woken immediately by a new request, plus every `PATIENT_EXPORT_WORKER_INTERVAL_SECONDS`. Requeues claims older than `PATIENT_EXPORT_STALE_AFTER_SECONDS` (up to `PATIENT_EXPORT_MAX_ATTEMPTS`). | | Patient-export cleanup (issue #40) | `patient-exports.ts` | Every `PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS`: expires `ready` exports past `expiresAt` and deletes their chunks, plus any chunk that can never be served. | +| Prepared-payment expiry (issue #80) | `payment-intents.ts` | Every `PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS` (default 60): moves `PaymentIntent`s stuck in `awaiting_signature` past their `preparedExpiresAt` back to `created`, clearing the prepared transaction. Expiry is a normal case — the patient prepares again. | Job logs carry error *types* only, never messages — a driver error can echo the patient data it was handling. diff --git a/backend/src/jobs/payment-intents.ts b/backend/src/jobs/payment-intents.ts new file mode 100644 index 0000000..227cab5 --- /dev/null +++ b/backend/src/jobs/payment-intents.ts @@ -0,0 +1,42 @@ +// Payment-intent jobs (Phase 5.5, issue #80). Plain interval, per +// ARCHITECTURE_ESSENTIALS.md ("keep jobs simple until a real job exists — +// don't pre-install a queue"). Started from src/server.ts only, so test +// suites that build the app never start a timer; tests drive the service +// function directly. +// +// The transition itself is a conditional UPDATE in the payments module +// (`awaiting_signature -> created` only where the prepared window has +// passed), so overlapping runs and multiple processes are safe. Expiry is a +// normal case: the patient simply prepares again. +import { config } from '../config'; +import { expireStalePreparedIntents } from '../modules/payments/services'; + +export function startPaymentIntentJobs(): () => void { + let running = false; + const sweep = () => { + if (running) return; + running = true; + void expireStalePreparedIntents() + .then(({ expired }) => { + if (expired > 0) { + // eslint-disable-next-line no-console + console.log(`[jobs] expired ${expired} stale prepared payment intent(s)`); + } + }) + .catch((err) => { + // Error type only: a database error message can echo row data. + // eslint-disable-next-line no-console + console.error(`[jobs] payment-intent expiry sweep failed: ${err instanceof Error ? err.name : typeof err}`); + }) + .finally(() => { + running = false; + }); + }; + + const timer = setInterval(sweep, config.payments.prepareExpirySweepIntervalSeconds * 1000); + timer.unref(); + // Pick up anything already past its window on startup. + sweep(); + + return () => clearInterval(timer); +} diff --git a/backend/src/modules/payments/controllers/index.ts b/backend/src/modules/payments/controllers/index.ts index 67d47c7..859d9d3 100644 --- a/backend/src/modules/payments/controllers/index.ts +++ b/backend/src/modules/payments/controllers/index.ts @@ -6,7 +6,9 @@ import { getParam } from '../../../request-params'; import { auditActor } from '../../../middleware/request-id'; // BigInt doesn't serialize via JSON.stringify by default — every response -// here that carries one converts to a string explicitly. +// here that carries one converts to a string explicitly. The prepared XDR +// is deliberately not part of the general intent shape: it's returned by +// the prepare call itself and can be large. function serializeIntent(intent: { id: string; prescriptionId: string | null; @@ -14,16 +16,22 @@ function serializeIntent(intent: { status: string; patientProfileId: string; facilityId: string; - amountStroops: bigint; + amount: bigint; + assetCode: string; + tokenContractId: string | null; escrowId: bigint | null; stellarTxHash: string | null; sorobanContractId: string | null; + preparedTxHash: string | null; + preparedExpiryLedger: number | null; + preparedExpiresAt: Date | null; + refundAfter: Date | null; createdAt: Date; updatedAt: Date; }) { return { ...intent, - amountStroops: intent.amountStroops.toString(), + amount: intent.amount.toString(), escrowId: intent.escrowId?.toString() ?? null, }; } @@ -50,6 +58,19 @@ export async function getIntent(req: Request, res: Response) { res.status(200).json(serializeIntent(intent)); } +export async function prepareIntent(req: Request, res: Response) { + const id = getParam(req, 'id'); + const prepared = await paymentsService.preparePaymentIntent(auditActor(req), id); + res.status(200).json({ + ...serializeIntent(prepared.intent), + // ADR 0005 step 2: unsigned XDR + the ledger after which the + // transaction/auth entries are no longer valid. + xdr: prepared.xdr, + txHash: prepared.txHash, + expiryLedger: prepared.expiryLedger, + }); +} + export async function fundIntent(req: Request, res: Response) { const id = getParam(req, 'id'); const intent = await paymentsService.fundPaymentIntent(auditActor(req), id); diff --git a/backend/src/modules/payments/repositories/index.ts b/backend/src/modules/payments/repositories/index.ts index affe04d..a99ea0a 100644 --- a/backend/src/modules/payments/repositories/index.ts +++ b/backend/src/modules/payments/repositories/index.ts @@ -1,8 +1,8 @@ -// payments repositories — PaymentIntent, WalletLink data access only. -// Never store PHI here. Never call docforum-escrow directly from here — -// go through services/. +// payments repositories — PaymentIntent, WalletLink and PatientWalletLink +// data access only. Never store PHI here. Never call docforum-escrow +// directly from here — go through services/. import { prisma } from '../../../db/prisma-client'; -import type { PaymentIntentStatus, Prisma } from '@prisma/client'; +import type { PaymentIntent, PaymentIntentStatus, Prisma } from '@prisma/client'; /** Reads FacilityProfile directly — a Phase 1 core identity model, not * something the (still-placeholder) facilities module exclusively owns. */ @@ -10,6 +10,13 @@ export function findFacilityProfileByUserId(userId: string) { return prisma.facilityProfile.findUnique({ where: { userId } }); } +/** Same reasoning as findFacilityProfileByUserId — PatientProfile is a + * Phase 1 identity model, and the payments module needs the caller's + * patient-scoped id to own-check an intent (issue #80). */ +export function findPatientProfileByUserId(userId: string) { + return prisma.patientProfile.findUnique({ where: { userId } }); +} + export function upsertWalletLink(facilityId: string, stellarPublicKey: string) { return prisma.walletLink.upsert({ where: { facilityId }, @@ -45,16 +52,17 @@ export function findFacilityNamesByIds(ids: string[]) { /** * Status writes are conditional on the state the caller observed (issue - * #43): `created -> escrowed`, `escrowed -> released|refunded`. They run in - * the caller's transaction alongside the AuditEvent, and return null if the - * row was no longer in the expected state (a concurrent action won), so an - * audit event can never claim a transition that didn't happen. The - * check-then-act gap before the on-chain call is issue #24's to close. + * #43): `created -> escrowed`, `created -> awaiting_signature`, + * `awaiting_signature -> created` (expiry), `escrowed -> released|refunded`. + * They run in the caller's transaction alongside the AuditEvent, and return + * null if the row was no longer in the expected state (a concurrent action + * won), so an audit event can never claim a transition that didn't happen. + * The check-then-act gap before the on-chain call is issue #24's to close. */ export async function markEscrowed( tx: Prisma.TransactionClient, id: string, - input: { escrowId: bigint; stellarTxHash: string; sorobanContractId: string }, + input: { escrowId: bigint; stellarTxHash: string; sorobanContractId: string; tokenContractId?: string }, ) { const result = await tx.paymentIntent.updateMany({ where: { id, status: 'created' }, @@ -63,6 +71,60 @@ export async function markEscrowed( return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null; } +/** `created -> awaiting_signature` (issue #80). Conditional, so two + * concurrent prepare requests can never both produce a live transaction. */ +export async function markAwaitingSignature( + tx: Prisma.TransactionClient, + id: string, + input: { + preparedTxHash: string; + preparedXdr: string; + preparedExpiryLedger: number; + preparedExpiresAt: Date; + refundAfter: Date; + }, +): Promise { + const result = await tx.paymentIntent.updateMany({ + where: { id, status: 'created' }, + data: { status: 'awaiting_signature', ...input }, + }); + return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null; +} + +/** `awaiting_signature -> created`, only if the prepared transaction is + * actually past its window (issue #80). A prepare that raced this update + * sees zero rows and re-reads instead of clobbering it. */ +export async function expirePrepared( + tx: Prisma.TransactionClient, + id: string, + now: Date, +): Promise { + const result = await tx.paymentIntent.updateMany({ + where: { id, status: 'awaiting_signature', preparedExpiresAt: { lte: now } }, + data: { + status: 'created', + preparedTxHash: null, + preparedXdr: null, + preparedExpiryLedger: null, + preparedExpiresAt: null, + refundAfter: null, + }, + }); + return result.count === 1 ? tx.paymentIntent.findUniqueOrThrow({ where: { id } }) : null; +} + +/** Ids for one expiry-sweep page, oldest deadline first. */ +export function findExpiredPreparedIntentIds(now: Date, take: number): Promise { + return prisma.paymentIntent + .findMany({ + where: { status: 'awaiting_signature', preparedExpiresAt: { lte: now } }, + orderBy: [{ preparedExpiresAt: 'asc' }, { id: 'asc' }], + take, + select: { id: true }, + }) + .then((rows) => rows.map((row) => row.id)); +} + export async function markStatus( tx: Prisma.TransactionClient, id: string, diff --git a/backend/src/modules/payments/routes/index.ts b/backend/src/modules/payments/routes/index.ts index f49e167..7571878 100644 --- a/backend/src/modules/payments/routes/index.ts +++ b/backend/src/modules/payments/routes/index.ts @@ -15,6 +15,19 @@ paymentsRouter.post( paymentsRouter.get('/wallet-link', authenticate, requireRole('facility'), paymentsController.getOwnWallet); paymentsRouter.get('/intents/:id', authenticate, requireRole('admin'), paymentsController.getIntent); + +// ADR 0005 step 2 (issue #80) — the patient prepares their own intent for +// signing. Ownership is enforced in the service (not merely by the role), +// and someone else's intent is a 404 rather than a 403. Money does not move +// in this call, but it is the first half of a payment, so it sits behind +// the same verified-email gate as fund. +paymentsRouter.post( + '/intents/:id/prepare', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + paymentsController.prepareIntent, +); paymentsRouter.post( '/intents/:id/fund', authenticate, diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index a0e5e62..cbdfed2 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -1,16 +1,18 @@ -// payments services — this module owns PaymentIntent/WalletLink -// (relational metadata only — no PHI ever passes through here) and -// delegates actual escrow contract calls to @docforum/escrow-sdk. This -// module does NOT reimplement contract logic — it calls the SDK, -// persists the resulting tx hash/status, and exposes that status to -// other core modules through this services/ interface, per AGENTS.md -// module-boundary rule. +// payments services — this module owns PaymentIntent/WalletLink/ +// PatientWalletLink (relational metadata only — no PHI ever passes through +// here) and delegates actual escrow contract calls to +// @docforum/escrow-sdk. This module does NOT reimplement contract logic — it +// calls the SDK, persists the resulting tx hash/status, and exposes that +// status to other core modules through this services/ interface, per +// AGENTS.md module-boundary rule. // -// Custodial v1 (see docs/adr/0004-custodial-payments-v1.md): -// docforum-core holds its own Stellar payer + releaser identities. -// Patients never hold or sign with a wallet in this design. +// Custodial v1 (see docs/adr/0004-custodial-payments-v1.md) still funds +// escrows from the platform's own payer identity. ADR 0005 adds the +// non-custodial path: issue #79 made the asset explicit per intent, and +// issue #80 adds prepare(), which builds the patient-signed transaction. +// Release/refund are unchanged. import { HttpError } from '../../../http-error'; -import { config } from '../../../config'; +import { config, type PaymentAsset } from '../../../config'; import * as paymentsRepo from '../repositories'; import { loadEscrowSdk, type EscrowSdkKeypair } from './escrow-sdk-loader'; import { getPlatformSigners } from './platform-signers'; @@ -20,11 +22,43 @@ import * as auditService from '../../audit/services'; import type { Prisma } from '@prisma/client'; // Types are inferred from the prisma instance; use 'as' assertions where needed -// Test seam re-exported through the service surface (same convention as the -// auth module's setEmailSender) — see escrow-sdk-loader.ts. +// Test seams re-exported through the service surface (same convention as the +// auth module's setEmailSender) — see escrow-sdk-loader.ts and trustline.ts. export { setEscrowSdkForTests } from './escrow-sdk-loader'; export { listEscrowEvents } from './escrow-events'; export type { EscrowSdkModule } from './escrow-sdk-loader'; +export { setTrustlineCheckerForTests } from './trustline'; +export type { TrustlineChecker } from './trustline'; + +/** + * The prepare half of ADR 0005 step 2. `@docforum/escrow-sdk` 0.2.0 (the + * version this repo currently depends on) has no prepare call — that is + * docforum-escrow issue #58 / SDK 0.3.0, and issue #80 depends on it. The + * types below are the contract this module codes against; when the SDK + * publishes it, only `prepareCreateEscrow` below needs to be typed more + * strictly. Until then the call is detected at runtime and the endpoint + * returns a clear 503 instead of pretending to prepare something it can't. + */ +export interface PrepareCreateEscrowParams { + payer: string; + payee: string; + token: string; + amount: bigint; + conditionRef: string; + releaser: string; + /** Unix seconds, matching the contract's `refund_after` (ADR 0005). */ + refundAfter: bigint; +} + +export interface PreparedCreateEscrow { + xdr: string; + txHash: string; + expiryLedger: number; +} + +interface PrepareCapableClient { + prepareCreateEscrow?: (params: PrepareCreateEscrowParams) => Promise; +} async function requirePaymentsConfig() { const { activeContractId, escrowTokenId } = config.payments; @@ -74,6 +108,14 @@ export async function getOwnFacilityProfileId(userId: string): Promise { return profile.id; } +/** The caller's patient profile id — every patient-scoped payment read is + * owned through this, never through a client-supplied profile id. */ +export async function getOwnPatientProfileId(userId: string): Promise { + const profile = await paymentsRepo.findPatientProfileByUserId(userId); + if (!profile) throw new HttpError(403, 'No patient profile is associated with this account.'); + return profile.id; +} + export function linkWallet(facilityId: string, stellarPublicKey: string) { return paymentsRepo.upsertWalletLink(facilityId, stellarPublicKey); } @@ -120,6 +162,20 @@ export async function getPaymentIntent(id: string) { return intent; } +/** Public, non-identifying references only: the escrow is on a public + * ledger anyway. No patient data, no secrets. */ +function intentAuditMetadata(intent: PaymentIntent) { + return { + orderType: intent.orderType, + amount: intent.amount.toString(), + assetCode: intent.assetCode, + escrowId: intent.escrowId?.toString() ?? null, + stellarTxHash: intent.stellarTxHash, + sorobanContractId: intent.sorobanContractId, + preparedTxHash: intent.preparedTxHash, + }; +} + /** Commits a status change and its AuditEvent together (issue #43). If the * row moved on concurrently, nothing is written and the caller gets 409. */ async function commitTransition( @@ -176,8 +232,8 @@ export async function fundPaymentIntent(actor: auditService.AuditActor, id: stri const { escrowId, txHash } = await client.createEscrow({ payer: platformPayer as unknown as EscrowSdkKeypair, payee: walletLink.stellarPublicKey, - token: escrowTokenId, - amount: intent.amountStroops, + token: asset.tokenContractId, + amount: intent.amount, // The PaymentIntent's own id is the opaque condition_ref — the // contract never interprets it (docforum-escrow ADR 0001); it's // just how we can trace an on-chain escrow back to this record. @@ -186,8 +242,188 @@ export async function fundPaymentIntent(actor: auditService.AuditActor, id: stri }); return commitTransition(actor, intent, 'payment_intent.funded', 'escrowed', (tx) => - paymentsRepo.markEscrowed(tx, id, { escrowId, stellarTxHash: txHash, sorobanContractId: escrowContractId }), + paymentsRepo.markEscrowed(tx, id, { + escrowId, + stellarTxHash: txHash, + sorobanContractId: escrowContractId, + // Legacy rows start with a null token id; record what was actually used. + tokenContractId: asset.tokenContractId, + }), + ); +} + +export interface PreparePaymentIntentResult { + intent: PaymentIntent; + xdr: string; + txHash: string; + expiryLedger: number; + expiresAt: Date; + refundAfter: Date; +} + +/** ADR 0005's refund deadline: order expiry + grace. Orders have no expiry + * field yet (Phase 4 placeholders), so the anchor is the later of the + * intent's creation and now — preparing a week-old intent must not hand the + * patient a deadline that has already passed. */ +function computeRefundAfter(intent: PaymentIntent, now: Date): Date { + const anchorMs = Math.max(intent.createdAt.getTime(), now.getTime()); + const windowMs = (config.payments.orderExpiryHours + config.payments.refundGraceHours) * 60 * 60 * 1000; + return new Date(anchorMs + windowMs); +} + +/** `awaiting_signature -> created` for one intent. Returns false when it was + * no longer in that state (a concurrent expiry or re-prepare won). */ +async function expirePreparedIntent(id: string, now: Date): Promise { + return prisma.$transaction(async (tx) => { + const updated = await paymentsRepo.expirePrepared(tx, id, now); + if (!updated) return false; + await auditService.record(tx, auditService.SYSTEM_ACTOR, { + entityType: 'PaymentIntent', + entityId: id, + action: 'payment_intent.prepare_expired', + fromState: 'awaiting_signature', + toState: 'created', + metadata: intentAuditMetadata(updated), + }); + return true; + }); +} + +/** Sweep used by src/jobs/payment-intents.ts. Expiry is a normal case, not + * an error: the intent goes back to `created` so the patient can prepare + * again. Conditional updates mean overlapping sweeps are safe. */ +export async function expireStalePreparedIntents(now = new Date(), max = 100): Promise<{ expired: number }> { + let expired = 0; + // Drain the backlog in bounded pages; stop early if a page made no + // progress (every row was claimed by a concurrent worker). + for (;;) { + const ids = await paymentsRepo.findExpiredPreparedIntentIds(now, max); + if (ids.length === 0) break; + let progressed = 0; + for (const id of ids) { + if (await expirePreparedIntent(id, now)) progressed += 1; + } + expired += progressed; + if (ids.length < max || progressed === 0) break; + } + return { expired }; +} + +/** + * Prepares a patient-signed create_escrow transaction (ADR 0005 step 2, + * issue #80). Builds + simulates via the SDK, stores the transaction hash + * and moves the intent to `awaiting_signature` with a conditional update. + * The XDR is returned unsigned; the browser only hands it to the patient's + * wallet and returns the signed bytes to the (not-yet-built) submit route. + */ +export async function preparePaymentIntent( + actor: auditService.AuditActor, + id: string, + now = new Date(), +): Promise { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + const patientProfileId = await getOwnPatientProfileId(actor.userId); + + let intent = await getPaymentIntent(id); + // 404, not 403, for someone else's intent: don't confirm it exists. + if (intent.patientProfileId !== patientProfileId) throw new HttpError(404, 'PaymentIntent not found.'); + + if (intent.status === 'awaiting_signature') { + const live = + intent.preparedXdr !== null && + intent.preparedTxHash !== null && + intent.preparedExpiryLedger !== null && + intent.preparedExpiresAt !== null && + intent.refundAfter !== null; + if (!live) { + throw new HttpError(409, 'This payment already has a prepared transaction.', 'PREPARE_ALREADY_PENDING'); + } + // Idempotent re-prepare while the transaction is still valid: return the + // exact same transaction instead of building a second live one. + if (intent.preparedExpiresAt!.getTime() > now.getTime()) { + return { + intent, + xdr: intent.preparedXdr!, + txHash: intent.preparedTxHash!, + expiryLedger: intent.preparedExpiryLedger!, + expiresAt: intent.preparedExpiresAt!, + refundAfter: intent.refundAfter!, + }; + } + // Past its window but the sweep hasn't run yet — expiry is a normal + // case, so roll it back and prepare a fresh transaction now. + await expirePreparedIntent(intent.id, now); + intent = await getPaymentIntent(id); + if (intent.status !== 'created') { + throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' — cannot prepare.`); + } + } + + if (intent.status !== 'created') { + throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' — cannot prepare.`); + } + + const patientWallet = await getPatientWalletLink(patientProfileId); + if (!patientWallet) { + throw new HttpError( + 409, + 'No verified Stellar wallet is linked to this patient — link one before paying.', + 'WALLET_NOT_LINKED', + ); + } + + const payeeWallet = await getWalletLink(intent.facilityId); + if (!payeeWallet) { + throw new HttpError( + 409, + 'The facility for this payment has no linked wallet.', + 'FACILITY_WALLET_NOT_LINKED', + ); + } + + const asset = resolveAssetForIntent(intent); + await requirePayeeTrustline(payeeWallet.stellarPublicKey, asset); + + const { platformReleaser, escrowContractId } = await requirePaymentsConfig(); + const client = (await escrowClient(escrowContractId)) as unknown as PrepareCapableClient; + if (typeof client.prepareCreateEscrow !== 'function') { + throw new HttpError( + 503, + 'The installed escrow SDK cannot prepare unsigned transactions yet (requires docforum-escrow SDK 0.3.0).', + 'ESCROW_SDK_PREPARE_UNSUPPORTED', + ); + } + + const refundAfter = computeRefundAfter(intent, now); + const prepared = await client.prepareCreateEscrow({ + payer: patientWallet.stellarPublicKey, + payee: payeeWallet.stellarPublicKey, + token: asset.tokenContractId, + amount: intent.amount, + conditionRef: intent.id, + releaser: platformReleaser.publicKey(), + refundAfter: BigInt(Math.floor(refundAfter.getTime() / 1000)), + }); + + const preparedExpiresAt = new Date(now.getTime() + config.payments.prepareTtlSeconds * 1000); + const updated = await commitTransition(actor, intent, 'payment_intent.prepared', 'awaiting_signature', (tx) => + paymentsRepo.markAwaitingSignature(tx, id, { + preparedTxHash: prepared.txHash, + preparedXdr: prepared.xdr, + preparedExpiryLedger: prepared.expiryLedger, + preparedExpiresAt, + refundAfter, + }), ); + + return { + intent: updated, + xdr: prepared.xdr, + txHash: prepared.txHash, + expiryLedger: prepared.expiryLedger, + expiresAt: preparedExpiresAt, + refundAfter, + }; } /** Releases the escrow to the facility's linked wallet. v1: admin-triggered (see ADR 0004 "Consequences") — no automatic FulfillmentRecord trigger yet, since that model doesn't exist. */ @@ -229,11 +465,12 @@ export async function refundPaymentIntent(actor: auditService.AuditActor, id: st * explicit field allowlist: on-chain references (tx hash, escrow id, * contract id) are the patient's payment record and are included; the * platform's signing secrets never touch a PaymentIntent row and can't - * appear here. BigInts are strings, matching the HTTP API. + * appear here. BigInts are strings, matching the HTTP API. Version 2 + * (issue #79) renames `amountStroops` to `amount` and adds `assetCode`. */ export const paymentsExportSection: PatientExportSection = { name: 'payments', - version: 1, + version: 2, pages(subject, pageSize) { return paginate( pageSize, @@ -246,7 +483,8 @@ export const paymentsExportSection: PatientExportSection = { prescriptionId: row.prescriptionId, labOrderId: row.labOrderId, status: row.status, - amountStroops: row.amountStroops.toString(), + amount: row.amount.toString(), + assetCode: row.assetCode, facility: { id: row.facilityId, name: facilityNames.get(row.facilityId) ?? null }, escrowId: row.escrowId?.toString() ?? null, stellarTxHash: row.stellarTxHash, diff --git a/backend/src/modules/payments/services/trustline.ts b/backend/src/modules/payments/services/trustline.ts new file mode 100644 index 0000000..95c7481 --- /dev/null +++ b/backend/src/modules/payments/services/trustline.ts @@ -0,0 +1,57 @@ +// Payee trustline check (issue #79). A facility's linked Stellar address +// must be able to hold the asset an escrow pays out in; for an issued asset +// (USDC) that means a trustline to the issuer. Native XLM needs none. +// +// Horizon is read here and nowhere else in this module — contract calls go +// through @docforum/escrow-sdk. The lookup is deliberately narrow: account +// existence + a matching, authorised balance line. +import { HttpError } from '../../../http-error'; +import { config, type PaymentAsset } from '../../../config'; + +export type TrustlineChecker = (account: string, asset: PaymentAsset) => Promise; + +async function horizonTrustline(account: string, asset: PaymentAsset): Promise { + // Native asset: no trustline exists or is needed. + if (!asset.issuer) return true; + + let response: Response; + try { + response = await fetch(`${config.payments.horizonUrl}/accounts/${encodeURIComponent(account)}`, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }); + } catch { + // Network/timeout — a transport failure, not "no trustline". Failing + // closed with a distinct code keeps the two distinguishable. + throw new HttpError(502, 'Could not reach Horizon to check the payee trustline.', 'HORIZON_UNAVAILABLE'); + } + // A Stellar account that doesn't exist (or was merged) can't hold the + // asset. That's a real "no trustline" answer, not an outage. + if (response.status === 404) return false; + if (!response.ok) { + throw new HttpError(502, `Horizon trustline check failed (${response.status}).`, 'HORIZON_UNAVAILABLE'); + } + + const body = (await response.json()) as { + balances?: Array<{ asset_type?: string; asset_code?: string; asset_issuer?: string; is_authorized?: boolean }>; + }; + return (body.balances ?? []).some( + (balance) => + balance.asset_code === asset.code && + balance.asset_issuer === asset.issuer && + // An explicitly frozen/unauthorised line can't receive the payout. + balance.is_authorized !== false, + ); +} + +let checker: TrustlineChecker = horizonTrustline; + +export function hasTrustline(account: string, asset: PaymentAsset): Promise { + return checker(account, asset); +} + +/** Test seam, same convention as setEscrowSdkForTests. `undefined` restores + * the Horizon-backed checker. Never called by application code. */ +export function setTrustlineCheckerForTests(fn: TrustlineChecker | undefined): void { + checker = fn ?? horizonTrustline; +} diff --git a/backend/tests/integration/patient-export.test.mts b/backend/tests/integration/patient-export.test.mts index 300e6fa..8b7df4c 100644 --- a/backend/tests/integration/patient-export.test.mts +++ b/backend/tests/integration/patient-export.test.mts @@ -301,7 +301,8 @@ describe('patient data export', () => { expect(payments[0]).toMatchObject({ labOrderId: aliceLabOrder.id, status: 'escrowed', - amountStroops: '12345678901', + amount: '12345678901', + assetCode: 'XLM', escrowId: '42', stellarTxHash: 'a'.repeat(64), sorobanContractId: 'CCONTRACT', diff --git a/backend/tests/integration/payments-assets.test.mts b/backend/tests/integration/payments-assets.test.mts new file mode 100644 index 0000000..8633c47 --- /dev/null +++ b/backend/tests/integration/payments-assets.test.mts @@ -0,0 +1,237 @@ +// Issue #79 integration coverage: PaymentIntent is denominated in an +// allowlisted asset (USDC + native XLM), amounts are stored in that asset's +// smallest unit, an unknown asset is rejected, and funding checks the +// payee's trustline before any on-chain call. +// +// Real Postgres (embedded, port 5439) and the module's own services; the +// SDK and Horizon are substituted through the module's existing test seams, +// so no network is involved. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { Keypair } from '@stellar/stellar-sdk'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5439; +const runId = Date.now(); + +const NATIVE_SAC = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; +const USDC_SAC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; +const USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + +// Must be set before src/config is imported (dynamic import in beforeAll). +process.env.PAYMENT_ASSETS = JSON.stringify([ + { code: 'XLM', tokenContractId: NATIVE_SAC, decimals: 7 }, + { code: 'USDC', tokenContractId: USDC_SAC, decimals: 7, issuer: USDC_ISSUER }, +]); +process.env.ESCROW_TOKEN_ID = NATIVE_SAC; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-assets-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_assets_test`; +const TEST_ACTOR = { userId: null, role: null, requestId: `assets-${runId}` }; + +/** Vitest's rejects matchers don't reliably see custom Error fields, so grab + * the rejection and assert the fields directly. */ +async function captureError(thunk: () => unknown): Promise<{ status?: number; code?: string }> { + try { + await thunk(); + } catch (err) { + return err as { status?: number; code?: string }; + } + throw new Error('Expected the promise to reject, but it resolved.'); +} + +describe('payment assets (issue #79)', () => { + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let paymentsService: typeof import('../../src/modules/payments/services/index.js'); + let facilityId: string; + let patientProfileId: string; + let payoutWallet: string; + + const sdkCalls: Array<{ token: string; amount: bigint }> = []; + const trustlineCalls: Array<{ account: string; code: string }> = []; + let trustlineResult = true; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_assets_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + process.env.PLATFORM_PAYER_SECRET = Keypair.random().secret(); + process.env.PLATFORM_RELEASER_SECRET = Keypair.random().secret(); + process.env.ESCROW_CONTRACT_ID = 'CTESTCONTRACT'; + process.env.ESCROW_TOKEN_ID = NATIVE_SAC; + + const dbModule = await import('../../src/db/prisma-client.js'); + prisma = dbModule.prisma; + paymentsService = await import('../../src/modules/payments/services/index.js'); + + paymentsService.setEscrowSdkForTests({ + Keypair, + EscrowClient: class { + async createEscrow(params: { token: string; amount: bigint }) { + sdkCalls.push({ token: params.token, amount: params.amount }); + return { escrowId: 7n, txHash: 'funded-hash' }; + } + }, + } as never); + paymentsService.setTrustlineCheckerForTests(async (account, asset) => { + trustlineCalls.push({ account, code: asset.code }); + return trustlineResult; + }); + + const facilityUser = await prisma.user.create({ + data: { email: `facility-assets-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + facilityId = ( + await prisma.facilityProfile.create({ + data: { userId: facilityUser.id, name: 'Asset Facility', type: 'pharmacy' }, + }) + ).id; + + const patientUser = await prisma.user.create({ + data: { email: `patient-assets-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, + }); + patientProfileId = ( + await prisma.patientProfile.create({ data: { userId: patientUser.id, fullName: 'Asset Patient' } }) + ).id; + + payoutWallet = Keypair.random().publicKey(); + await paymentsService.linkWallet(facilityId, payoutWallet); + }, 180_000); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('stores the asset and its token contract on the intent', async () => { + const intent = await paymentsService.createPaymentIntent({ + orderType: 'prescription', + orderId: `usdc-${runId}`, + patientProfileId, + facilityId, + assetCode: 'USDC', + amount: 25_000_000n, + }); + + expect(intent.assetCode).toBe('USDC'); + expect(intent.tokenContractId).toBe(USDC_SAC); + expect(intent.amount).toBe(25_000_000n); + }); + + it('rejects an asset that is not on the environment allowlist', async () => { + const error = await captureError(() => + paymentsService.createPaymentIntent({ + orderType: 'prescription', + orderId: `eur-${runId}`, + patientProfileId, + facilityId, + assetCode: 'EUR', + amount: 100n, + }), + ); + expect(error.status).toBe(400); + expect(error.code).toBe('UNSUPPORTED_PAYMENT_ASSET'); + }); + + it('checks the payee trustline and funds in USDC', async () => { + trustlineResult = true; + trustlineCalls.length = 0; + sdkCalls.length = 0; + + const intent = await paymentsService.createPaymentIntent({ + orderType: 'lab_order', + orderId: `usdc-fund-${runId}`, + patientProfileId, + facilityId, + assetCode: 'USDC', + amount: 12_500_000n, + }); + const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); + + expect(funded.status).toBe('escrowed'); + expect(trustlineCalls).toEqual([{ account: payoutWallet, code: 'USDC' }]); + expect(sdkCalls).toEqual([{ token: USDC_SAC, amount: 12_500_000n }]); + }); + + it('refuses to fund when the payee has no trustline, before calling the SDK', async () => { + trustlineResult = false; + sdkCalls.length = 0; + + const intent = await paymentsService.createPaymentIntent({ + orderType: 'lab_order', + orderId: `no-trustline-${runId}`, + patientProfileId, + facilityId, + assetCode: 'USDC', + amount: 100n, + }); + + const error = await captureError(() => paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id)); + expect(error.status).toBe(409); + expect(error.code).toBe('PAYEE_TRUSTLINE_MISSING'); + expect(sdkCalls).toHaveLength(0); + expect((await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status).toBe('created'); + }); + + it('skips the trustline check for native XLM', async () => { + trustlineCalls.length = 0; + sdkCalls.length = 0; + + const intent = await paymentsService.createPaymentIntent({ + orderType: 'prescription', + orderId: `xlm-${runId}`, + patientProfileId, + facilityId, + assetCode: 'XLM', + amount: 500_000n, + }); + const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); + + expect(funded.status).toBe('escrowed'); + expect(trustlineCalls).toHaveLength(0); + expect(sdkCalls).toEqual([{ token: NATIVE_SAC, amount: 500_000n }]); + }); + + it('resolves the legacy null token contract to ESCROW_TOKEN_ID', async () => { + sdkCalls.length = 0; + // A row created before issue #79: no tokenContractId, native XLM only. + const legacy = await prisma.paymentIntent.create({ + data: { + orderType: 'prescription', + orderId: `legacy-${runId}`, + patientProfileId, + facilityId, + assetCode: 'XLM', + tokenContractId: null, + amount: 1_000_000n, + }, + }); + + const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, legacy.id); + + expect(funded.status).toBe('escrowed'); + expect(funded.tokenContractId).toBe(NATIVE_SAC); + expect(sdkCalls).toEqual([{ token: NATIVE_SAC, amount: 1_000_000n }]); + }); +}); diff --git a/backend/tests/integration/payments-bigint.test.mts b/backend/tests/integration/payments-bigint.test.mts index a3fc4d6..78e4463 100644 --- a/backend/tests/integration/payments-bigint.test.mts +++ b/backend/tests/integration/payments-bigint.test.mts @@ -1,7 +1,7 @@ // Hermetic BigInt round-trip through Prisma 7's node-postgres driver adapter // (Phase 8, issue #47). // -// Why this file exists: the upgrade issue calls out `PaymentIntent.amountStroops` +// Why this file exists: the upgrade issue calls out `PaymentIntent.amount` // (a Prisma `BigInt`, i.e. Postgres `bigint`, i.e. stroop count) as the field // most likely to break when the Rust query engine is replaced by a driver // adapter, and names the testnet payment test as its coverage. That test @@ -97,7 +97,7 @@ describe('PaymentIntent BigInt round-trip (Prisma 7 driver adapter)', () => { await pg.stop(); }); - async function roundTrip(amountStroops: bigint, orderId: string) { + async function roundTrip(amount: bigint, orderId: string) { // Goes through the module's own service, so this exercises the real write // path rather than a hand-built query. const order = await createOrderFixture(prisma, { @@ -119,17 +119,17 @@ describe('PaymentIntent BigInt round-trip (Prisma 7 driver adapter)', () => { it('round-trips an amount above Number.MAX_SAFE_INTEGER without precision loss', async () => { const reloaded = await roundTrip(UNSAFE_AMOUNT, `bigint-unsafe-${runId}`); - expect(typeof reloaded.amountStroops).toBe('bigint'); - expect(reloaded.amountStroops).toBe(UNSAFE_AMOUNT); + expect(typeof reloaded.amount).toBe('bigint'); + expect(reloaded.amount).toBe(UNSAFE_AMOUNT); // Guards against a future refactor that "helpfully" converts to Number. - expect(Number(reloaded.amountStroops)).not.toBe(UNSAFE_AMOUNT); + expect(Number(reloaded.amount)).not.toBe(UNSAFE_AMOUNT); }); it('round-trips a realistic stroop amount', async () => { const reloaded = await roundTrip(XLM_AMOUNT, `bigint-normal-${runId}`); - expect(typeof reloaded.amountStroops).toBe('bigint'); - expect(reloaded.amountStroops).toBe(XLM_AMOUNT); + expect(typeof reloaded.amount).toBe('bigint'); + expect(reloaded.amount).toBe(XLM_AMOUNT); }); it('round-trips a nullable BigInt (escrowId) as null, then as a value', async () => { diff --git a/backend/tests/integration/payments-prepare.test.mts b/backend/tests/integration/payments-prepare.test.mts new file mode 100644 index 0000000..c007862 --- /dev/null +++ b/backend/tests/integration/payments-prepare.test.mts @@ -0,0 +1,282 @@ +// Issue #80 integration coverage: POST /payments/intents/:id/prepare's +// service layer against real Postgres (embedded, port 5440), with the SDK +// mocked through setEscrowSdkForTests — the definition of done asks for +// mock-RPC tests, not live testnet. Covers prepare, only-one-live-transaction +// (idempotent re-prepare), expiry back to `created` plus re-prepare, the +// patient-only ownership check, and the wallet-link preconditions. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { Keypair } from '@stellar/stellar-sdk'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5440; +const runId = Date.now(); + +const NATIVE_SAC = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; + +// Before src/config is imported (dynamic import in beforeAll). +process.env.PAYMENT_ASSETS = JSON.stringify([{ code: 'XLM', tokenContractId: NATIVE_SAC, decimals: 7 }]); +process.env.PAYMENT_ORDER_EXPIRY_HOURS = '72'; +process.env.PAYMENT_REFUND_GRACE_HOURS = '24'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-prepare-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_prepare_test`; + +/** Vitest's rejects matchers don't reliably see custom Error fields, so grab + * the rejection and assert the fields directly. */ +async function captureError(promise: Promise): Promise<{ status?: number; code?: string }> { + try { + await promise; + } catch (err) { + return err as { status?: number; code?: string }; + } + throw new Error('Expected the promise to reject, but it resolved.'); +} + +describe('prepare endpoint (issue #80)', () => { + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let paymentsService: typeof import('../../src/modules/payments/services/index.js'); + let patientUserId: string; + let patientProfileId: string; + let patientPublicKey: string; + let otherPatientUserId: string; + let otherPatientProfileId: string; + let facilityId: string; + let unlinkedFacilityId: string; + + const prepareCalls: Array<{ + payer: string; + payee: string; + token: string; + amount: bigint; + conditionRef: string; + releaser: string; + refundAfter: bigint; + }> = []; + let preparedCount = 0; + + const actor = (userId: string) => ({ userId, role: 'patient' as const, requestId: `prepare-${runId}` }); + + async function createIntent(facility = facilityId) { + return paymentsService.createPaymentIntent({ + orderType: 'prescription', + orderId: `prepare-${runId}-${Math.random()}`, + patientProfileId, + facilityId: facility, + assetCode: 'XLM', + amount: 500_000n, + }); + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_prepare_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + process.env.PLATFORM_PAYER_SECRET = Keypair.random().secret(); + process.env.PLATFORM_RELEASER_SECRET = Keypair.random().secret(); + process.env.ESCROW_CONTRACT_ID = 'CTESTCONTRACT'; + + const dbModule = await import('../../src/db/prisma-client.js'); + prisma = dbModule.prisma; + paymentsService = await import('../../src/modules/payments/services/index.js'); + + paymentsService.setEscrowSdkForTests({ + Keypair, + EscrowClient: class { + async prepareCreateEscrow(params: (typeof prepareCalls)[number]) { + prepareCalls.push(params); + preparedCount += 1; + return { xdr: `unsigned-xdr-${preparedCount}`, txHash: `prepared-hash-${preparedCount}`, expiryLedger: 1_000 + preparedCount }; + } + }, + } as never); + + const patientUser = await prisma.user.create({ + data: { + email: `patient-prepare-${runId}@example.com`, + passwordHash: 'unused', + role: 'patient', + emailVerifiedAt: new Date(), + }, + }); + patientUserId = patientUser.id; + patientProfileId = ( + await prisma.patientProfile.create({ data: { userId: patientUser.id, fullName: 'Prepare Patient' } }) + ).id; + patientPublicKey = Keypair.random().publicKey(); + await prisma.patientWalletLink.create({ + data: { patientProfileId, stellarPublicKey: patientPublicKey, verifiedAt: new Date() }, + }); + + const otherUser = await prisma.user.create({ + data: { email: `patient-other-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, + }); + otherPatientUserId = otherUser.id; + otherPatientProfileId = ( + await prisma.patientProfile.create({ data: { userId: otherUser.id, fullName: 'Other Patient' } }) + ).id; + + const facilityUser = await prisma.user.create({ + data: { email: `facility-prepare-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + facilityId = ( + await prisma.facilityProfile.create({ + data: { userId: facilityUser.id, name: 'Prepare Facility', type: 'pharmacy' }, + }) + ).id; + await prisma.walletLink.create({ + data: { facilityId, stellarPublicKey: Keypair.random().publicKey() }, + }); + + const unlinkedUser = await prisma.user.create({ + data: { email: `facility-nolink-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + unlinkedFacilityId = ( + await prisma.facilityProfile.create({ + data: { userId: unlinkedUser.id, name: 'Unlinked Facility', type: 'lab' }, + }) + ).id; + }, 180_000); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('prepares an unsigned transaction and moves the intent to awaiting_signature', async () => { + const intent = await createIntent(); + const before = Date.now(); + + const result = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + + expect(result.intent.status).toBe('awaiting_signature'); + expect(result.txHash).toBe('prepared-hash-1'); + expect(result.xdr).toBe('unsigned-xdr-1'); + expect(result.expiryLedger).toBe(1001); + expect(result.intent.preparedTxHash).toBe('prepared-hash-1'); + expect(result.intent.preparedXdr).toBe('unsigned-xdr-1'); + expect(result.refundAfter.getTime()).toBeGreaterThan(before + 95 * 60 * 60 * 1000); + + expect(prepareCalls.at(-1)).toMatchObject({ + payer: patientPublicKey, + token: NATIVE_SAC, + amount: 500_000n, + conditionRef: intent.id, + refundAfter: BigInt(Math.floor(result.refundAfter.getTime() / 1000)), + }); + + const event = await prisma.auditEvent.findFirstOrThrow({ + where: { entityId: intent.id, action: 'payment_intent.prepared' }, + }); + expect([event.entityType, event.fromState, event.toState]).toEqual(['PaymentIntent', 'created', 'awaiting_signature']); + expect(event.actorUserId).toBe(patientUserId); + expect(event.metadata).toMatchObject({ preparedTxHash: 'prepared-hash-1', assetCode: 'XLM', amount: '500000' }); + }); + + it('is idempotent while the prepared transaction is still live', async () => { + const intent = await createIntent(); + const first = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + const callsAfterFirst = prepareCalls.length; + + const second = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + + expect(second.txHash).toBe(first.txHash); + expect(second.xdr).toBe(first.xdr); + expect(prepareCalls).toHaveLength(callsAfterFirst); + }); + + it('expires a stale prepared transaction back to created and allows a fresh prepare', async () => { + const intent = await createIntent(); + const first = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + + // Simulate the TTL passing while the sweep hasn't run. + await prisma.paymentIntent.update({ + where: { id: intent.id }, + data: { preparedExpiresAt: new Date(Date.now() - 1_000) }, + }); + const now = new Date(); + const { expired } = await paymentsService.expireStalePreparedIntents(now); + expect(expired).toBeGreaterThanOrEqual(1); + + const expiredIntent = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } }); + expect(expiredIntent.status).toBe('created'); + expect(expiredIntent.preparedTxHash).toBeNull(); + expect(expiredIntent.preparedXdr).toBeNull(); + expect(expiredIntent.preparedExpiresAt).toBeNull(); + expect(expiredIntent.refundAfter).toBeNull(); + + const event = await prisma.auditEvent.findFirstOrThrow({ + where: { entityId: intent.id, action: 'payment_intent.prepare_expired' }, + }); + expect([event.fromState, event.toState]).toEqual(['awaiting_signature', 'created']); + + const again = await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + expect(again.intent.status).toBe('awaiting_signature'); + expect(again.txHash).not.toBe(first.txHash); + }); + + it('returns 404 for another patient’s intent', async () => { + const intent = await createIntent(); + + const error = await captureError(paymentsService.preparePaymentIntent(actor(otherPatientUserId), intent.id)); + expect(error.status).toBe(404); + expect((await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } })).status).toBe('created'); + }); + + it('returns 409 WALLET_NOT_LINKED when the patient has no verified wallet', async () => { + // Put an intent on the other patient, who has a profile but no wallet link. + const intent = await prisma.paymentIntent.create({ + data: { + orderType: 'lab_order', + orderId: `no-wallet-${runId}`, + patientProfileId: otherPatientProfileId, + facilityId, + assetCode: 'XLM', + tokenContractId: NATIVE_SAC, + amount: 100n, + }, + }); + + const error = await captureError(paymentsService.preparePaymentIntent(actor(otherPatientUserId), intent.id)); + expect(error.status).toBe(409); + expect(error.code).toBe('WALLET_NOT_LINKED'); + }); + + it('returns 409 FACILITY_WALLET_NOT_LINKED when the payee has no wallet', async () => { + const intent = await createIntent(unlinkedFacilityId); + + const error = await captureError(paymentsService.preparePaymentIntent(actor(patientUserId), intent.id)); + expect(error.status).toBe(409); + expect(error.code).toBe('FACILITY_WALLET_NOT_LINKED'); + }); + + it('does not prepare a non-created intent', async () => { + const intent = await createIntent(); + await paymentsService.preparePaymentIntent(actor(patientUserId), intent.id); + await prisma.paymentIntent.update({ where: { id: intent.id }, data: { status: 'escrowed' } }); + + const error = await captureError(paymentsService.preparePaymentIntent(actor(patientUserId), intent.id)); + expect(error.status).toBe(409); + }); +}); diff --git a/backend/tests/testnet-integration/payments.test.mts b/backend/tests/testnet-integration/payments.test.mts index 0a6fa22..8f98419 100644 --- a/backend/tests/testnet-integration/payments.test.mts +++ b/backend/tests/testnet-integration/payments.test.mts @@ -34,6 +34,14 @@ const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), const PORT = 5435; // distinct from booking.test.mts's 5433 and gen-migration.mjs's 5434 const runId = Date.now(); +const USDC_SAC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; +// Opt-in: funding a real USDC escrow needs the platform payer to hold both +// testnet USDC and a trustline to its issuer. There is no automated faucet +// for testnet USDC, so the positive case only runs when an operator has +// prepared the payer and sets this flag. The trustline-rejection case below +// always runs against live Horizon and needs no USDC. +const USDC_FUNDING_ENABLED = process.env.PAYMENT_TESTNET_USDC === 'true'; + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-payments-test-pg-')); const pg = new EmbeddedPostgres({ databaseDir: dataDir, @@ -138,6 +146,38 @@ describe('payments module against live testnet', () => { expect(refunded.status).toBe('refunded'); }, 60_000); + it('rejects a USDC escrow when the payee has no trustline, before any on-chain call', async () => { + const intent = await paymentsService.createPaymentIntent({ + orderType: 'lab_order', + orderId: 'test-order-usdc-no-trustline', + patientProfileId, + facilityId, + assetCode: 'USDC', + amount: 100_000n, + }); + + // The friendbot-funded payout wallet exists but has no USDC trustline. + await expect(paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id)).rejects.toThrow(/trustline for USDC/); + }, 30_000); + + it.skipIf(!USDC_FUNDING_ENABLED)('funds and releases a USDC escrow end-to-end on testnet', async () => { + const intent = await paymentsService.createPaymentIntent({ + orderType: 'lab_order', + orderId: 'test-order-usdc-release', + patientProfileId, + facilityId, + assetCode: 'USDC', + amount: 500_000n, // 0.05 USDC (both testnet SACs use 7 decimals) + }); + expect(intent.assetCode).toBe('USDC'); + expect(intent.tokenContractId).toBe(USDC_SAC); + + const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); + expect(funded.status).toBe('escrowed'); + const released = await paymentsService.releasePaymentIntent(TEST_ACTOR, intent.id); + expect(released.status).toBe('released'); + }, 60_000); + it('rejects funding a payment intent for a facility with no linked wallet', async () => { const unlinkedFacilityUser = await prisma.user.create({ data: { email: `facility-unlinked-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, diff --git a/backend/tests/unit/audit.test.ts b/backend/tests/unit/audit.test.ts index bd73c51..257b662 100644 --- a/backend/tests/unit/audit.test.ts +++ b/backend/tests/unit/audit.test.ts @@ -17,7 +17,7 @@ import { requestId } from '../../src/middleware/request-id'; describe('validateAuditMetadata', () => { it('accepts a small flat map of ids and codes', () => { - const metadata = { availabilitySlotId: 's1', doctorProfileId: 'd1', origin: 'direct_booking', amountStroops: '100', escrowId: null, retried: false, attempt: 2 }; + const metadata = { availabilitySlotId: 's1', doctorProfileId: 'd1', origin: 'direct_booking', amount: '100', escrowId: null, retried: false, attempt: 2 }; expect(validateAuditMetadata(metadata)).toEqual(metadata); expect(validateAuditMetadata(undefined)).toBeUndefined(); }); diff --git a/docs/adr/0005-non-custodial-patient-payments.md b/docs/adr/0005-non-custodial-patient-payments.md index 20a6867..2838127 100644 --- a/docs/adr/0005-non-custodial-patient-payments.md +++ b/docs/adr/0005-non-custodial-patient-payments.md @@ -1,6 +1,7 @@ # ADR 0005: Non-custodial payments v2 — patients fund escrows from their own Stellar wallet -Status: Proposed +Status: Accepted (2026-10-19; implementation started — steps 5 and the +prepare half of 6 have landed, see Implementation notes) Date: 2026-10-08 Builds on ADR 0004 (custodial v1), which stays in place as a fallback. @@ -103,7 +104,8 @@ v2 pays in **USDC** through its Stellar Asset Contract, so prices stay stable for medical bills. Native XLM stays supported for testnet development. `PaymentIntent` gains a token contract id and stores the amount in that token's smallest unit. The current `amountStroops` field -is renamed, with a migration. +is renamed, with a migration. *(Implemented by issue #79 — see +Implementation notes below.)* ### Rule change in `docforum-web` @@ -188,6 +190,34 @@ Done this way, every network-facing Stellar call still originates in - **Release triggering doesn't change.** Admin-triggered release remains the interim, as in ADR 0004, until Phase 5 exists. +## Implementation notes (2026-10-19) + +- **Step 5 — assets (issue #79):** `PaymentIntent` now stores + `assetCode` + `tokenContractId` and the amount in that asset's smallest + unit (`amountStroops` renamed to `amount` by a lossless column rename). + `PAYMENT_ASSETS` is the per-environment allowlist (testnet defaults: + native XLM SAC + testnet USDC SAC); intents in any other asset are + rejected. Issued assets require an authorised payee trustline before + funding (`PAYEE_TRUSTLINE_MISSING`), checked against Horizon; native XLM + needs none. The server never scales amounts, so it never assumes an + asset's decimals. +- **Step 6, first half — prepare (issue #80):** + `POST /payments/intents/:id/prepare` is patient-only, verifies the + intent belongs to the caller (`404` otherwise), requires a verified + `PatientWalletLink` (`409 WALLET_NOT_LINKED`), builds + simulates the + patient-as-payer `create_escrow` call, and moves the intent + `created → awaiting_signature` with the prepared hash/XDR/expiry ledger + stored via a conditional update. Re-prepare while the transaction is + live returns the same transaction; expiry (`awaiting_signature → + created`) is a normal case handled by a sweep job. `refund_after` is set + to the order expiry + grace; orders have no expiry field yet, so the + intent's `createdAt` is the documented anchor. +- **Still open:** `@docforum/escrow-sdk` 0.3.0 / docforum-escrow #58 (the + real prepare/submit calls), submit + fee-bump + hash check (step 6's + second half), SEP-10 wallet linking (step 4), and the web flow (steps + 7-8). Against SDK 0.2.0 the prepare endpoint returns `503 + ESCROW_SDK_PREPARE_UNSUPPORTED` rather than faking a transaction. + ## Implementation outline (to become issues once Accepted) In order: diff --git a/docs/api/README.md b/docs/api/README.md index 4d59220..e989034 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -284,9 +284,10 @@ Bundle (`schemaVersion` 1): ``` Sections are sorted by name; new modules add sections (ARCHITECTURE.md §3.1) -without changing the envelope. `amountStroops`/`escrowId` are strings -(bigint). No password hash, tokens, throttle state or platform secrets are -ever included. +without changing the envelope. `amount`/`escrowId` are strings (bigint), +and `amount` is in the smallest unit of `assetCode` (payments section +version 2 — issue #79; version 1 used `amountStroops`). No password hash, +tokens, throttle state or platform secrets are ever included. Exports stay downloadable for `PATIENT_EXPORT_TTL_HOURS` (default 24), then the cleanup job deletes the stored bundle. Other knobs: `PATIENT_EXPORT_PAGE_SIZE`, @@ -295,6 +296,56 @@ the cleanup job deletes the stored bundle. Other knobs: `PATIENT_EXPORT_PAGE_SIZ `PATIENT_EXPORT_MAX_ATTEMPTS` — see `backend/src/config/index.ts` and `backend/src/jobs/README.md`. +## Payments (`/payments`) — `backend/src/modules/payments` + +Phase 5.5: custodial funding v1 (`docs/adr/0004`) plus the prepare half of +the non-custodial flow (`docs/adr/0005`, issue #80). Every Stellar call is +made by this backend through `@docforum/escrow-sdk`; the browser only ever +receives an unsigned XDR to hand to the patient's wallet. + +**Amounts are JSON strings in the smallest unit of the intent's asset** +(stroops for XLM, 7-decimal units for USDC). The server never scales or +converts them, so it never assumes an asset's decimals. + +| Method & path | Auth | Body / notes | +|---|---|---| +| `POST /payments/wallet-link` | facility | `{ stellarPublicKey }`. Sets the facility's payout (payee) address. | +| `GET /payments/wallet-link` | facility | The caller's own link; `404` if none. | +| `POST /payments/intents` | admin | `{ orderType: 'prescription'\|'lab_order', orderId, patientProfileId, facilityId, assetCode?, amount }`. `assetCode` defaults to `XLM`; it must be on the environment allowlist (`PAYMENT_ASSETS`) or the response is `400 UNSUPPORTED_PAYMENT_ASSET`. Returns the intent with `amount` as a string. | +| `GET /payments/intents/:id` | admin | Intent status, asset, prepared hash/expiry, `refundAfter`. | +| `POST /payments/intents/:id/prepare` | patient (owner) | Builds and simulates the patient-signed `create_escrow`, returns `{ ...intent, status: 'awaiting_signature', xdr, txHash, expiryLedger }`. `404` for an intent that isn't the caller's (owned in the service, not just role-gated); `409 WALLET_NOT_LINKED` without a verified patient wallet; `409 FACILITY_WALLET_NOT_LINKED` if the payee hasn't linked one; `409 PAYEE_TRUSTLINE_MISSING` if the payee can't hold the asset. Calling it again while the prepared transaction is live returns the **same** transaction; expiry is a normal case (`awaiting_signature → created`, swept by a job) and a later prepare builds a fresh one. | +| `POST /payments/intents/:id/fund` | admin | Custodial funding from the platform payer. Requires the facility wallet, and (for issued assets) an authorised payee trustline — checked against Horizon before any on-chain call. | +| `POST /payments/intents/:id/release` | admin | `escrowed → released`. | +| `POST /payments/intents/:id/refund` | admin | `escrowed → refunded`. | + +State machine: `created → awaiting_signature → (expiry) created` and +`created → escrowed → released | refunded`. `submitting` is reserved for +the submit half of ADR 0005 and is not written yet. All transitions are +conditional UPDATEs in the same transaction as their `AuditEvent`, so a +racing second action gets `409` instead of overwriting. + +**Assets (issue #79).** The allowlist is `PAYMENT_ASSETS`, a JSON array of +`{ code, tokenContractId, decimals, issuer? }`. Testnet defaults are native +XLM and USDC +(`CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC` / +`CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA`, issuer +`GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5`). Production +must set it explicitly to mainnet contract ids. Only assets without +clawback, transfer fees or rebasing belong on the list (escrow threat model +T5); entries that opt into clawback/rebasing are rejected at startup. + +**Prepare uses SDK 0.3.0.** The repo currently pins escrow SDK 0.2.0, which +has no prepare call (docforum-escrow #58). Until that ships, prepare returns +`503 ESCROW_SDK_PREPARE_UNSUPPORTED` rather than fabricating a transaction; +the integration tests substitute a mocked SDK. `PatientWalletLink` rows +(the payer addresses prepare requires) are written by the SEP-10 flow, +which is not built yet, so there is no HTTP route to create one. + +Env knobs: `PAYMENT_ASSETS`, `HORIZON_URL` (trustline lookups only), +`PAYMENT_PREPARE_TTL_SECONDS` (default 300), `PAYMENT_ORDER_EXPIRY_HOURS` +(default 72) + `PAYMENT_REFUND_GRACE_HOURS` (default 24) for `refundAfter`, +and `PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS` (default 60). + ## Not yet implemented `patients`, `facilities`, `prescriptions`, `lab-orders`,