From 1ae2e6028ab29446a3b3d549c911c048358e4feb Mon Sep 17 00:00:00 2001 From: habeebti <144542166+habeebti@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:16:54 +0000 Subject: [PATCH 1/3] feat(payments): multi-asset intents and patient-signed prepare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-environment asset allowlist (USDC + native XLM) with a token-agnostic amount, payee trustline checks, and the prepare half of the non-custodial flow: an unsigned create_escrow XDR, new awaiting_signature state, and an expiry sweep back to created. Closes #79 Closes #80 πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- ARCHITECTURE.md | 48 ++- ARCHITECTURE_ESSENTIALS.md | 16 +- ROADMAP.md | 27 +- backend/.env.example | 20 + .../migration.sql | 42 ++ backend/prisma/schema.prisma | 68 +++- backend/src/config/index.ts | 89 +++++ backend/src/jobs/README.md | 1 + backend/src/jobs/payment-intents.ts | 42 ++ .../src/modules/payments/controllers/index.ts | 27 +- .../modules/payments/repositories/index.ts | 92 ++++- backend/src/modules/payments/routes/index.ts | 13 + .../src/modules/payments/services/index.ts | 378 ++++++++++++++++-- .../modules/payments/services/trustline.ts | 57 +++ backend/src/modules/payments/validation.ts | 20 +- backend/src/server.ts | 6 + backend/tests/integration/audit.test.mts | 4 +- .../tests/integration/patient-export.test.mts | 11 +- .../integration/payments-assets.test.mts | 237 +++++++++++ .../integration/payments-bigint.test.mts | 19 +- .../integration/payments-prepare.test.mts | 282 +++++++++++++ .../testnet-integration/payments.test.mts | 49 ++- backend/tests/unit/audit.test.ts | 2 +- docs/adr/0004-custodial-payments-v1.md | 12 + .../0005-non-custodial-patient-payments.md | 34 +- docs/api/README.md | 65 ++- 26 files changed, 1556 insertions(+), 105 deletions(-) create mode 100644 backend/prisma/migrations/20261019100000_payment_assets_and_prepare/migration.sql create mode 100644 backend/src/jobs/payment-intents.ts create mode 100644 backend/src/modules/payments/services/trustline.ts create mode 100644 backend/tests/integration/payments-assets.test.mts create mode 100644 backend/tests/integration/payments-prepare.test.mts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d7592346..55d0b2cc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -54,6 +54,7 @@ Each module under `backend/src/modules//` owns its own `routes/`, `control - `lab-orders` β€” lab order issuance and versioning. - `pharmacy-orders` β€” fulfillment-side record for prescriptions at a specific pharmacy (kept distinct from `prescriptions` itself β€” see data model note in Β§4.7). - `notifications` β€” outbound notification dispatch, provider-agnostic. +- `payments` β€” `PaymentIntent`, the facility payee `WalletLink`, and the patient payer `PatientWalletLink` (Β§4.12). Relational metadata only; every Stellar call goes through `@docforum/escrow-sdk` via `services/escrow-sdk-loader.ts`. Assets are allowlisted per environment (issue #79), and the prepare half of ADR 0005 (issue #80) builds the unsigned patient transaction without moving funds. - `audit` β€” the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking, doctor verification, payment fund/release/refund. **Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must call `record()` in the same transaction when they're built** β€” that wiring is not done here. ### 3.1 Patient data export registry (Phase 8, issue #40) @@ -72,9 +73,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 @@ -264,7 +265,8 @@ AuditEvent Transitions recorded (action: from β†’ to): - `appointment.booked`: `null β†’ scheduled` (metadata: slot id, doctor profile id, origin) - `doctor.verification_status_changed`: previous β†’ new verification status (read under `FOR UPDATE`; a no-op set records nothing) -- `payment_intent.funded` `created β†’ escrowed`, `payment_intent.released` `escrowed β†’ released`, `payment_intent.refunded` `escrowed β†’ refunded` (metadata: order type, amount, escrow id, tx hash, contract id β€” public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets `409` and records nothing. +- `payment_intent.funded` `created β†’ escrowed`, `payment_intent.released` `escrowed β†’ released`, `payment_intent.refunded` `escrowed β†’ refunded` (metadata: order type, amount, asset code, escrow id, tx hash, contract id β€” public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets `409` and records nothing. +- `payment_intent.prepared` `created β†’ awaiting_signature`, `payment_intent.prepare_expired` `awaiting_signature β†’ created` (issue #80). Actor is the patient for prepare and the system for expiry; metadata adds the prepared transaction hash. ### 4.11 Patient data export (Phase 8, issue #40) ``` PatientExport @@ -292,6 +294,44 @@ Bundle format (schemaVersion 1): `{ format: "docforum.patient-export", schemaVersion, exportId, generatedAt, sections: { : { version, items } } }` β€” see Β§3.1 for where sections come from. +### 4.12 Payments (Phase 5.5 β€” ADR 0004 custodial v1, ADR 0005 non-custodial) +``` +PaymentIntent + id, order_type/order_id (opaque, unvalidated until Phase 4 exists) + status (enum: created|awaiting_signature|submitting|escrowed|released|refunded|failed) + patient_profile_id (FK), facility_id (FK) + amount (bigint β€” smallest unit of asset_code; never scaled in this repo) + asset_code, token_contract_id (SAC address; token_contract_id is nullable + only for pre-#79 rows, which resolve ESCROW_TOKEN_ID as native XLM) + escrow_id, stellar_tx_hash, soroban_contract_id (nullable β€” set on fund) + prepared_tx_hash, prepared_xdr, prepared_expiry_ledger, prepared_expires_at, + refund_after (nullable β€” live only in awaiting_signature; cleared on expiry) + created_at, updated_at + # Every status write is a conditional UPDATE on the observed state, in the + same transaction as its AuditEvent (Β§4.10). Prepare is + created -> awaiting_signature; expiry is awaiting_signature -> created. + `submitting` is reserved for ADR 0005's submit half (not built). + +PatientWalletLink + patient_profile_id (unique FK -> PatientProfile, cascade) + stellar_public_key, verified_at + # The PAYER wallet (ADR 0005). Only prepared rows are written by the + SEP-10 flow, which is not built yet; prepare requires one and returns + 409 WALLET_NOT_LINKED otherwise. + +WalletLink + facility_id (unique FK -> FacilityProfile, cascade) + stellar_public_key + # The PAYEE/payout wallet, facility-managed (ADR 0004). Unchanged by #79. +``` +Assets are an environment allowlist (`PAYMENT_ASSETS`; testnet defaults XLM + +USDC SACs) rather than a fixed enum: an intent in any other asset is +rejected at creation. Before funding (and preparing) an issued asset, the +payee's Horizon account is checked for an authorised trustline +(`PAYEE_TRUSTLINE_MISSING`); native XLM needs none. Only assets without +clawback/transfer fees/rebasing belong on the list β€” escrow threat model +T5, enforced as a curated config policy. + ## 5. Key flows (sequence-level, not code) ### 5.1 Booking (concurrency-safe) diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index bdbb1d42..d421bab7 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) @@ -29,14 +29,16 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc `User` β†’ `EmailVerificationToken` (Phase 8, issue #34 β€” hashed/expiring, single-use; `User.emailVerifiedAt` gates booking + payment mutations, never login/browsing). `LoginAttempt` (Phase 8, issue #35 β€” Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert). `PatientProfile` β†’ `PatientExport` β†’ `PatientExportChunk` (Phase 8, issue #40 β€” right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` β€” see ARCHITECTURE.md Β§3.1). -`FacilityProfile` β†’ `WalletLink` (payout address) ; `PatientProfile`/`FacilityProfile` β†’ `PaymentIntent` (opaque `orderType`/`orderId` β€” see docs/adr/0004) +`FacilityProfile` β†’ `WalletLink` (payout address) ; `PatientProfile` β†’ `PatientWalletLink` (patient payer address, ADR 0005) ; `PatientProfile`/`FacilityProfile` β†’ `PaymentIntent` (opaque `orderType`/`orderId`, allowlisted `assetCode` + `tokenContractId`, prepare fields β€” see docs/adr/0004/0005) -## Payments (Phase 5.5 β€” see docs/adr/0004-custodial-payments-v1.md) -- **Custodial v1**: `docforum-core` holds its own Stellar payer + releaser identities (`PLATFORM_PAYER_SECRET`/`PLATFORM_RELEASER_SECRET` env vars). Patients never hold or sign with a wallet β€” the only reason this is possible without violating `docforum-web`'s "no direct Stellar calls" hard rule. -- `WalletLink` is scoped to the **facility** (the on-chain payee), not the patient. +## Payments (Phase 5.5 β€” see docs/adr/0004 and docs/adr/0005) +- **Custodial v1**: `docforum-core` holds its own Stellar payer + releaser identities (`PLATFORM_PAYER_SECRET`/`PLATFORM_RELEASER_SECRET` env vars). The patient-wallet path (ADR 0005) is being added alongside it; custodial stays as the fallback. +- `WalletLink` is the **facility payee**; `PatientWalletLink` (ADR 0005) is the **patient payer**. Don't conflate them β€” a facility payout address must never be usable as a patient payer. +- Asset is per intent, not per deployment (issue #79): `PaymentIntent.amount` is the smallest unit of `assetCode` (XLM or USDC by default), `tokenContractId` is the SAC address, and only `PAYMENT_ASSETS` allowlist entries may be used. Amounts are never scaled/converted in this repo, so don't assume 7 decimals. Issued assets need an authorised payee trustline before fund/prepare (`PAYEE_TRUSTLINE_MISSING`); XLM needs none. - `PaymentIntent.orderType`/`orderId` are opaque, unvalidated β€” `Prescription`/`LabOrder` aren't real records yet (Phase 4). Don't build on top of these assuming they're FK-validated. +- `POST /payments/intents/:id/prepare` (issue #80) is patient-only and ownership-checked in the service (someone else's id is a 404). It builds+simulates via the SDK, stores the transaction hash/XDR, and moves `created β†’ awaiting_signature`; re-prepare while live is idempotent, and expiry (`awaiting_signature β†’ created`) is a normal case handled by a sweep, not an error. Only one prepared transaction may be live per intent β€” enforce with the conditional UPDATE, never a read-then-write. - `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?). @@ -45,6 +47,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, doctor verification and payment fund/release/refund only β€” **Phase 3/4 transitions must call it when they're built.** 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 dd2c6462..7953ce2b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ROADMAP.md β€” DocForum -Last updated: 2026-10-17 +Last updated: 2026-10-19 Status: Phase 0, Phase 1, and Phase 5.5 (Payments, custodial v1 β€” see `docs/adr/0004-custodial-payments-v1.md`) built and tested. Phases 2-6 and 8-9 scoped into GitHub issues (#1-#22, #21 closed); Phase 7 is @@ -78,7 +78,8 @@ for status and issue links. ## Phase 5.5 β€” Payments (consumer side) **Status: Built, decoupled from Phase 4/5 rather than blocked by them β€” see docs/adr/0004-custodial-payments-v1.md.** `docforum-escrow` issues #4/#5 (SDK + distribution decision) closed, so the original blocker cleared; rather than wait further on Phase 4/5 (Orders/Facilities, still not built), `PaymentIntent`/`WalletLink` were designed with opaque, unvalidated `orderType`/`orderId` fields instead of hard FKs to `Prescription`/`LabOrder`, so this phase didn't have to wait on two more unbuilt phases. Verified against **live testnet**, not mocked β€” `npm run test:integration:payments-testnet` (3/3 passing): fundβ†’release, fundβ†’refund, and reject-when-no-wallet-linked. -- [x] `PaymentIntent` + `WalletLink` models finalized (relational metadata only β€” no PHI). `WalletLink` scoped to the **facility** (payee), not the patient β€” see ADR 0004. Amount is raw stroops (`BigInt`), not the originally-scaffolded `Decimal`+`"USDC"` β€” matches what's actually proven end-to-end on testnet; multi-asset support is future work, not assumed. +- [x] `PaymentIntent` + `WalletLink` models finalized (relational metadata only β€” no PHI). `WalletLink` scoped to the **facility** (payee), not the patient β€” see ADR 0004. Amount is a token-agnostic `BigInt` in the asset's smallest unit (renamed from `amountStroops` by #79). +- [x] Payment asset support β€” USDC + native XLM per `PaymentIntent`, with a per-environment allowlist, payee trustline check, and a lossless `amountStroops β†’ amount` migration. Tracked as [issue #79](https://github.com/DocForum/docforum-core/issues/79). Adds `assetCode`/`tokenContractId` to `PaymentIntent`; `PAYMENT_ASSETS` is the allowlist (testnet defaults: XLM + USDC SACs). Funding a non-native asset now fails with `409 PAYEE_TRUSTLINE_MISSING` before any on-chain call if the facility's linked wallet can't hold it. - [x] Added `@docforum/escrow-sdk` as a real dependency (via the GitHub Release tarball URL β€” see `docforum-escrow`'s ADR 0003); wired `payments/services` to call it through a dynamic `import()` (`escrow-sdk-loader.ts` β€” the SDK is ESM-only, this backend is CommonJS). - [ ] On order issuance, create a `PaymentIntent` (`created` status) β€” still manual/admin-triggered (`POST /payments/intents`) since Phase 4 order issuance doesn't exist yet to trigger it automatically. - [x] On patient funding action, call SDK to move funds into escrow (`escrowed` status), store `stellarTxHash` β€” except it's not a *patient* action in this design (custodial v1, ADR 0004): `POST /payments/intents/:id/fund`, admin-triggered, funds from `docforum-core`'s own platform payer identity. @@ -87,8 +88,9 @@ 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 @@ -405,3 +407,20 @@ for status and issue links. observed state, so a racing second action gets 409 instead of silently overwriting). Rollback coupling proven with a real injected DB failure, not a mock. Phase 3/4 wiring explicitly left pending. +- 2026-10-19 β€” Issues #79 and #80 opened as one PR: + PaymentIntent now carries `assetCode` + `tokenContractId` and stores its + amount as a token-agnostic `amount` (migration is a plain column rename, + so no row loses its value; pre-#79 rows resolve native XLM from + `ESCROW_TOKEN_ID`). `PAYMENT_ASSETS` is the per-environment allowlist + (testnet defaults XLM + USDC SACs); unknown assets are `400 + UNSUPPORTED_PAYMENT_ASSET`, and fund/prepare check the payee's Horizon + trustline first (`409 PAYEE_TRUSTLINE_MISSING`). The prepare half of ADR + 0005 adds `POST /payments/intents/:id/prepare` (patient-only, service + ownership check, `404` for someone else's intent), `awaiting_signature`/ + `submitting` statuses, a stored prepared hash/XDR/expiry, `refundAfter` + from the order-expiry proxy + grace, and an expiry sweep back to + `created`. `PatientWalletLink` is the new patient payer address model + (SEP-10 flow still not built). Real prepare still waits on + `@docforum/escrow-sdk` 0.3.0 (docforum-escrow #58) β€” the endpoint returns + `503 ESCROW_SDK_PREPARE_UNSUPPORTED` against 0.2.0, and the new + integration tests mock the SDK/RPC. diff --git a/backend/.env.example b/backend/.env.example index e815beaa..40c98883 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -51,4 +51,24 @@ 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 + +# Per-environment payment asset allowlist (issue #79). JSON array of +# { code, tokenContractId, decimals, issuer? }. Defaults (testnet XLM + USDC) +# live in src/config/index.ts; production MUST set this explicitly with +# mainnet contract ids. Only assets without clawback, transfer fees or +# rebasing belong here (escrow threat model T5). +# PAYMENT_ASSETS=[{"code":"XLM","tokenContractId":"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC","decimals":7},{"code":"USDC","tokenContractId":"CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA","decimals":7,"issuer":"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"}] +# Horizon is read only for payee trustline checks before funding/prepare. +HORIZON_URL=https://horizon-testnet.stellar.org + +# ADR 0005 prepare (issue #80). Defaults are in src/config/index.ts. +# prepared transaction TTL, and the expiry sweep cadence. +PAYMENT_PREPARE_TTL_SECONDS=300 +PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS=60 +# refund_after = order expiry + grace; orders have no expiry field yet +# (Phase 4), so the intent's createdAt is the anchor. +PAYMENT_ORDER_EXPIRY_HOURS=72 +PAYMENT_REFUND_GRACE_HOURS=24 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 00000000..f6cd3f70 --- /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 7cfb28b6..57e092e1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -184,6 +184,7 @@ model PatientProfile { appointments Appointment[] paymentIntents PaymentIntent[] dataExports PatientExport[] + walletLink PatientWalletLink? } // Phase 8 (issue #40): right-of-access export jobs. One row per request; @@ -462,6 +463,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 @@ -486,14 +493,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. @@ -509,6 +540,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 9a01e58f..5229233f 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -17,6 +17,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']; @@ -139,6 +210,24 @@ export const config = { platformPayerSecret: process.env.PLATFORM_PAYER_SECRET, platformReleaserSecret: process.env.PLATFORM_RELEASER_SECRET, escrowContractId: process.env.ESCROW_CONTRACT_ID, + // Legacy native-XLM token id (ADR 0004). Still used as the fallback for + // PaymentIntent rows created before issue #79, whose tokenContractId is + // null, and as the default XLM entry in the allowlist. escrowTokenId: process.env.ESCROW_TOKEN_ID, + // Per-environment asset allowlist (issue #79). See parsePaymentAssets. + assets: parsePaymentAssets(process.env.PAYMENT_ASSETS), + // Horizon is only read for payee trustlines β€” never for contract calls, + // which go through the SDK. + horizonUrl: process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org', + // Issue #80: how long a prepared, unsigned create_escrow transaction is + // considered live before the expiry job returns the intent to `created`. + prepareTtlSeconds: Number(process.env.PAYMENT_PREPARE_TTL_SECONDS ?? 5 * 60), + // ADR 0005: refund_after = order expiry + grace. Orders are still + // placeholders without an expiry field (Phase 4), so the intent's own + // createdAt is the order-expiry anchor today. + orderExpiryHours: Number(process.env.PAYMENT_ORDER_EXPIRY_HOURS ?? 72), + refundGraceHours: Number(process.env.PAYMENT_REFUND_GRACE_HOURS ?? 24), + // Expiry sweep cadence (src/jobs/payment-intents.ts). + prepareExpirySweepIntervalSeconds: Number(process.env.PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS ?? 60), }, } as const; diff --git a/backend/src/jobs/README.md b/backend/src/jobs/README.md index f75c7e05..474ad282 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 00000000..227cab59 --- /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 14e1a7f2..378d9172 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; orderType: string; @@ -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, }; } @@ -56,6 +64,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 bfc8c327..eb4572e1 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 }, @@ -22,12 +29,20 @@ export function findWalletLinkByFacilityId(facilityId: string) { return prisma.walletLink.findUnique({ where: { facilityId } }); } +/** The patient's payer wallet (ADR 0005). Rows are created by the SEP-10 + * linking flow once it exists β€” not by this module's HTTP surface yet. */ +export function findPatientWalletLinkByPatientProfileId(patientProfileId: string) { + return prisma.patientWalletLink.findUnique({ where: { patientProfileId } }); +} + export function createPaymentIntent(input: { orderType: 'prescription' | 'lab_order'; orderId: string; patientProfileId: string; facilityId: string; - amountStroops: bigint; + assetCode: string; + tokenContractId: string; + amount: bigint; }) { return prisma.paymentIntent.create({ data: input }); } @@ -55,16 +70,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' }, @@ -73,6 +89,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 7f80503b..8497f246 100644 --- a/backend/src/modules/payments/routes/index.ts +++ b/backend/src/modules/payments/routes/index.ts @@ -25,6 +25,19 @@ paymentsRouter.get('/wallet-link', authenticate, requireRole('facility'), paymen // notices) this phase adds either. paymentsRouter.post('/intents', authenticate, requireRole('admin'), requireVerifiedEmail, paymentsController.createIntent); 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 ac64c261..743e407f 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -1,31 +1,66 @@ -// 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 } from './escrow-sdk-loader'; +import { hasTrustline } from './trustline'; import { paginate, type PatientExportSection } from '../../patients/services'; import { prisma } from '../../../db/prisma-client'; import * as auditService from '../../audit/services'; import type { PaymentIntent, PaymentIntentStatus, Prisma } from '@prisma/client'; -// 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 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 { platformPayerSecret, platformReleaserSecret, escrowContractId, escrowTokenId } = config.payments; - const missing = Object.entries({ platformPayerSecret, platformReleaserSecret, escrowContractId, escrowTokenId }) + const { platformPayerSecret, platformReleaserSecret, escrowContractId } = config.payments; + const missing = Object.entries({ platformPayerSecret, platformReleaserSecret, escrowContractId }) .filter(([, value]) => !value) .map(([key]) => key); if (missing.length > 0) { @@ -36,7 +71,6 @@ async function requirePaymentsConfig() { platformPayer: Keypair.fromSecret(platformPayerSecret!), platformReleaser: Keypair.fromSecret(platformReleaserSecret!), escrowContractId: escrowContractId!, - escrowTokenId: escrowTokenId!, }; } @@ -45,12 +79,80 @@ async function escrowClient(contractId: string) { return new EscrowClient({ contractId }); } +/** The per-environment allowlist (issue #79). An intent may only be created + * in one of these assets. */ +export function listPaymentAssets(): PaymentAsset[] { + return config.payments.assets; +} + +function resolveAsset(code: string): PaymentAsset { + const asset = config.payments.assets.find((candidate) => candidate.code === code); + if (!asset) { + throw new HttpError( + 400, + `Unsupported payment asset '${code}'. Allowed: ${config.payments.assets.map((a) => a.code).join(', ')}.`, + 'UNSUPPORTED_PAYMENT_ASSET', + ); + } + return asset; +} + +/** + * Resolves the asset recorded on an intent. Rows created before issue #79 + * have no `tokenContractId` and were native XLM funded against + * `ESCROW_TOKEN_ID` β€” the documented legacy fallback. The allowlist only + * supplies `decimals`/`issuer` for the response; the stored contract id + * wins so an intent keeps paying the asset it was denominated in even if + * the allowlist entry is later rotated. + */ +function resolveAssetForIntent(intent: PaymentIntent): PaymentAsset { + const configured = config.payments.assets.find((asset) => asset.code === intent.assetCode); + const tokenContractId = + intent.tokenContractId ?? + (intent.assetCode === 'XLM' ? config.payments.escrowTokenId : undefined) ?? + configured?.tokenContractId; + if (!tokenContractId) { + throw new HttpError( + 409, + `PaymentIntent uses asset '${intent.assetCode}', whose token contract is not configured.`, + 'PAYMENT_ASSET_NOT_CONFIGURED', + ); + } + return { + code: intent.assetCode, + tokenContractId, + decimals: configured?.decimals ?? 0, + ...(configured?.issuer ? { issuer: configured.issuer } : {}), + }; +} + +/** Checks the payee can hold the asset before any on-chain call. Native XLM + * passes unconditionally; an issued asset needs an authorised trustline. */ +async function requirePayeeTrustline(payee: string, asset: PaymentAsset): Promise { + if (!asset.issuer) return; + if (!(await hasTrustline(payee, asset))) { + throw new HttpError( + 409, + `The facility's linked wallet has no trustline for ${asset.code}. Ask the facility to link a wallet that can hold ${asset.code}.`, + 'PAYEE_TRUSTLINE_MISSING', + ); + } +} + export async function getOwnFacilityProfileId(userId: string): Promise { const profile = await paymentsRepo.findFacilityProfileByUserId(userId); if (!profile) throw new HttpError(403, 'No facility profile is associated with this account.'); 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); } @@ -59,17 +161,32 @@ export function getWalletLink(facilityId: string) { return paymentsRepo.findWalletLinkByFacilityId(facilityId); } +export function getPatientWalletLink(patientProfileId: string) { + return paymentsRepo.findPatientWalletLinkByPatientProfileId(patientProfileId); +} + export interface CreatePaymentIntentInput { orderType: 'prescription' | 'lab_order'; orderId: string; patientProfileId: string; facilityId: string; - amountStroops: bigint; + assetCode: string; + /** Smallest unit of `assetCode`. */ + amount: bigint; } export function createPaymentIntent(input: CreatePaymentIntentInput) { - if (input.amountStroops <= 0n) throw new HttpError(400, 'amountStroops must be positive.'); - return paymentsRepo.createPaymentIntent(input); + if (input.amount <= 0n) throw new HttpError(400, 'amount must be positive.'); + const asset = resolveAsset(input.assetCode); + return paymentsRepo.createPaymentIntent({ + orderType: input.orderType, + orderId: input.orderId, + patientProfileId: input.patientProfileId, + facilityId: input.facilityId, + assetCode: asset.code, + tokenContractId: asset.tokenContractId, + amount: input.amount, + }); } export async function getPaymentIntent(id: string) { @@ -78,6 +195,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( @@ -98,15 +229,7 @@ async function commitTransition( action, fromState: intent.status, toState: to, - // Public, non-identifying references only: the escrow is on a public - // ledger anyway. No patient data, no secrets. - metadata: { - orderType: updated.orderType, - amountStroops: updated.amountStroops.toString(), - escrowId: updated.escrowId?.toString() ?? null, - stellarTxHash: updated.stellarTxHash, - sorobanContractId: updated.sorobanContractId, - }, + metadata: intentAuditMetadata(updated), }); return updated; }); @@ -122,14 +245,17 @@ export async function fundPaymentIntent(actor: auditService.AuditActor, id: stri const walletLink = await paymentsRepo.findWalletLinkByFacilityId(intent.facilityId); if (!walletLink) throw new HttpError(409, 'The facility for this payment has no linked wallet.'); - const { platformPayer, platformReleaser, escrowContractId, escrowTokenId } = await requirePaymentsConfig(); + const asset = resolveAssetForIntent(intent); + await requirePayeeTrustline(walletLink.stellarPublicKey, asset); + + const { platformPayer, platformReleaser, escrowContractId } = await requirePaymentsConfig(); const client = await escrowClient(escrowContractId); const { escrowId, txHash } = await client.createEscrow({ payer: platformPayer, 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. @@ -138,8 +264,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. */ @@ -181,11 +487,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, @@ -198,7 +505,8 @@ export const paymentsExportSection: PatientExportSection = { orderType: row.orderType, orderId: row.orderId, 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 00000000..95c74817 --- /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/src/modules/payments/validation.ts b/backend/src/modules/payments/validation.ts index 3513c8df..5f27d348 100644 --- a/backend/src/modules/payments/validation.ts +++ b/backend/src/modules/payments/validation.ts @@ -16,12 +16,20 @@ export const createPaymentIntentSchema = z.object({ orderId: z.string().min(1), patientProfileId: z.string().min(1), facilityId: z.string().min(1), - // Stroops (native XLM's smallest unit), matching - // @docforum/escrow-sdk's bigint amount exactly. Sent as a string over - // JSON (a raw number can't safely carry bigint precision) and parsed - // here. - amountStroops: z + // Asset code, matched against the environment's allowlist in the + // service (issue #79). Defaults to XLM so pre-#79 clients that only + // rename `amountStroops` keep working. + assetCode: z .string() - .regex(/^[1-9][0-9]*$/, 'amountStroops must be a positive integer string.') + .regex(/^[A-Z0-9]{1,12}$/, 'assetCode must be 1-12 uppercase alphanumeric characters.') + .default('XLM'), + // Smallest unit of `assetCode` (stroops for XLM, 7-decimal units for + // USDC), matching @docforum/escrow-sdk's bigint amount exactly. Sent as a + // string over JSON (a raw number can't safely carry bigint precision) + // and parsed here. The server never scales it, so it never assumes a + // particular asset's decimals. + amount: z + .string() + .regex(/^[1-9][0-9]*$/, 'amount must be a positive integer string.') .transform((value) => BigInt(value)), }); diff --git a/backend/src/server.ts b/backend/src/server.ts index 7030fd12..1b0cf490 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,6 +2,7 @@ import { createApp } from './app'; import { config } from './config'; import { pruneLoginAttempts } from './modules/auth/services'; import { startPatientExportJobs } from './jobs/patient-exports'; +import { startPaymentIntentJobs } from './jobs/payment-intents'; const app = createApp(); @@ -32,3 +33,8 @@ pruneTimer.unref(); // Patient data export worker + expiry cleanup (Phase 8, issue #40). startPatientExportJobs(); + +// Prepared-transaction expiry (Phase 5.5, issue #80): an unsigned +// create_escrow transaction that outlives its window moves the intent back +// to `created` so the patient can prepare again. +startPaymentIntentJobs(); diff --git a/backend/tests/integration/audit.test.mts b/backend/tests/integration/audit.test.mts index 159f39df..a31729aa 100644 --- a/backend/tests/integration/audit.test.mts +++ b/backend/tests/integration/audit.test.mts @@ -83,7 +83,7 @@ describe('AuditEvent foundation', () => { async function createIntent() { return prisma.paymentIntent.create({ - data: { orderType: 'lab_order', orderId: `order-${Math.random()}`, patientProfileId: (await prisma.patientProfile.findFirstOrThrow()).id, facilityId, amountStroops: 5000n }, + data: { orderType: 'lab_order', orderId: `order-${Math.random()}`, patientProfileId: (await prisma.patientProfile.findFirstOrThrow()).id, facilityId, assetCode: 'XLM', amount: 5000n }, }); } @@ -296,7 +296,7 @@ describe('AuditEvent foundation', () => { actorUserId: admin.id, actorRole: 'admin', requestId: `pay-fund-${runId}`, - metadata: { orderType: 'lab_order', amountStroops: '5000', stellarTxHash: TX_HASH, sorobanContractId: 'CTESTCONTRACT' }, + metadata: { orderType: 'lab_order', amount: '5000', assetCode: 'XLM', stellarTxHash: TX_HASH, sorobanContractId: 'CTESTCONTRACT' }, }); expect((events[0]!.metadata as Record).escrowId).toMatch(/^\d+$/); const serialized = JSON.stringify(events); diff --git a/backend/tests/integration/patient-export.test.mts b/backend/tests/integration/patient-export.test.mts index abe49540..a460b304 100644 --- a/backend/tests/integration/patient-export.test.mts +++ b/backend/tests/integration/patient-export.test.mts @@ -176,7 +176,8 @@ describe('patient data export', () => { orderId: 'order-1', patientProfileId: alice.profileId, facilityId, - amountStroops: 12_345_678_901n, + assetCode: 'XLM', + amount: 12_345_678_901n, status: 'escrowed', escrowId: 42n, stellarTxHash: 'a'.repeat(64), @@ -184,7 +185,7 @@ describe('patient data export', () => { }, }); await prisma.paymentIntent.create({ - data: { orderType: 'prescription', orderId: 'order-2', patientProfileId: alice.profileId, facilityId, amountStroops: 5n }, + data: { orderType: 'prescription', orderId: 'order-2', patientProfileId: alice.profileId, facilityId, assetCode: 'XLM', amount: 5n }, }); // Bob's payment must never show up in Alice's export. await prisma.paymentIntent.create({ @@ -193,7 +194,8 @@ describe('patient data export', () => { orderId: 'bob-order', patientProfileId: bob.profileId, facilityId, - amountStroops: 9n, + assetCode: 'XLM', + amount: 9n, stellarTxHash: 'b'.repeat(64), }, }); @@ -314,7 +316,8 @@ describe('patient data export', () => { expect(payments[0]).toMatchObject({ orderType: 'lab_order', 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 00000000..8633c475 --- /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 7ef3eea5..221f04eb 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 @@ -96,7 +96,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 created = await paymentsService.createPaymentIntent({ @@ -104,9 +104,10 @@ describe('PaymentIntent BigInt round-trip (Prisma 7 driver adapter)', () => { orderId, patientProfileId, facilityId, - amountStroops, + assetCode: 'XLM', + amount, }); - expect(created.amountStroops).toBe(amountStroops); + expect(created.amount).toBe(amount); // Re-read from the database, bypassing the in-memory object the create // returned β€” this is the assertion that actually proves the adapter @@ -118,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 00000000..c0078623 --- /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 3ae83ae2..f2aab3a1 100644 --- a/backend/tests/testnet-integration/payments.test.mts +++ b/backend/tests/testnet-integration/payments.test.mts @@ -33,6 +33,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, @@ -114,7 +122,8 @@ describe('payments module against live testnet', () => { orderId: 'test-order-release', patientProfileId, facilityId, - amountStroops: 500_000n, // 0.05 XLM + assetCode: 'XLM', + amount: 500_000n, // 0.05 XLM }); expect(intent.status).toBe('created'); @@ -133,7 +142,8 @@ describe('payments module against live testnet', () => { orderId: 'test-order-refund', patientProfileId, facilityId, - amountStroops: 500_000n, + assetCode: 'XLM', + amount: 500_000n, }); await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); @@ -141,6 +151,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' }, @@ -154,7 +196,8 @@ describe('payments module against live testnet', () => { orderId: 'test-order-unlinked', patientProfileId, facilityId: unlinkedFacility.id, - amountStroops: 500_000n, + assetCode: 'XLM', + amount: 500_000n, }); await expect(paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id)).rejects.toThrow(/no linked wallet/); diff --git a/backend/tests/unit/audit.test.ts b/backend/tests/unit/audit.test.ts index bd73c513..257b662f 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/0004-custodial-payments-v1.md b/docs/adr/0004-custodial-payments-v1.md index fde218d8..f6a68489 100644 --- a/docs/adr/0004-custodial-payments-v1.md +++ b/docs/adr/0004-custodial-payments-v1.md @@ -93,3 +93,15 @@ assigned to `facilityId`) as a follow-up, not assumed done by this ADR. the roadmap already flagged: "don't let it drift into a manual/admin- triggered release" is the target once Phase 4/5 exist; v1's admin-only gate is the honest interim, not the destination. + +## Update β€” 2026-10-19 (issues #79 and #80) + +`PaymentIntent` is no longer native-XLM-only: issue #79 added an +allowlisted `assetCode` + `tokenContractId` per intent and renamed +`amountStroops` to the token-agnostic `amount` (a plain column rename, so +existing values are preserved). The custodial funding path described here +is unchanged β€” the platform payer still funds the escrow β€” but the token it +pulls from is now the intent's asset, defaulting to the legacy +`ESCROW_TOKEN_ID` for pre-#79 rows. ADR 0005's non-custodial path is being +added alongside this one (issue #80 added prepare; submit is still pending), +and release/refund remain admin-triggered exactly as described above. diff --git a/docs/adr/0005-non-custodial-patient-payments.md b/docs/adr/0005-non-custodial-patient-payments.md index 20a6867e..28381274 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 5e67678a..160b4e75 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -29,7 +29,7 @@ depend on a trustworthy contact address: - `POST /appointments` (booking) - `POST /payments/wallet-link`, `POST /payments/intents`, - `POST /payments/intents/:id/{fund,release,refund}` + `POST /payments/intents/:id/{prepare,fund,release,refund}` That guard reads the database rather than trusting a claim in the access token, so verifying in one tab unblocks the caller immediately instead of @@ -220,16 +220,17 @@ Bundle (`schemaVersion` 1): "sections": { "account": { "version": 1, "items": [{ "id", "email", "phone", "role", "emailVerifiedAt", "createdAt", "updatedAt" }] }, "appointments": { "version": 1, "items": [{ "id", "status", "origin", "slot": { "startTime", "endTime" }, "doctor": { "id", "fullName" }, "createdAt", "updatedAt" }] }, - "payments": { "version": 1, "items": [{ "id", "orderType", "orderId", "status", "amountStroops", "facility": { "id", "name" }, "escrowId", "stellarTxHash", "sorobanContractId", "createdAt", "updatedAt" }] }, + "payments": { "version": 2, "items": [{ "id", "orderType", "orderId", "status", "amount", "assetCode", "facility": { "id", "name" }, "escrowId", "stellarTxHash", "sorobanContractId", "createdAt", "updatedAt" }] }, "profile": { "version": 1, "items": [{ "id", "fullName", "dateOfBirth", "createdAt", "updatedAt" }] } } } ``` 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`, @@ -238,9 +239,59 @@ 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 Everything else in `ARCHITECTURE.md` Β§3's module list (`facilities`, `consultations`, `referrals`, `prescriptions`, `lab-orders`, -`pharmacy-orders`, `payments`, `notifications`) has no routes yet β€” Phase -2 onward, see `ROADMAP.md`. +`pharmacy-orders`, `notifications`) has no routes yet β€” Phase 2 onward, see +`ROADMAP.md`. From 84795cad3915b95ebdb29e2a544cd3a11327a519 Mon Sep 17 00:00:00 2001 From: habeebti <144542166+habeebti@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:12:28 +0000 Subject: [PATCH 2/3] fix(payments): restore multi-asset prepare wiring lost in merge Repeated merges with main dropped the PatientWalletLink opposite relation, the payment asset config fields, and the prepare helper functions, so prisma:generate and typecheck failed on every push. Restore those pieces, keep the newer payment-history and payout-privacy behaviour, and move tests to the token-agnostic amount/assetCode shape. --- ROADMAP.md | 3 +- backend/.env.example | 19 ++ backend/prisma/schema.prisma | 4 + backend/src/config/index.ts | 17 ++ backend/src/jobs/README.md | 1 + .../modules/payments/repositories/index.ts | 37 +++- .../src/modules/payments/services/index.ts | 207 ++++++++++++++---- .../payments/services/state-machine.ts | 9 +- backend/src/server.ts | 5 + .../integration/account-deletion.test.mts | 3 +- backend/tests/integration/audit.test.mts | 2 +- .../integration/facility-prices.test.mts | 6 +- backend/tests/integration/order-fixtures.mts | 2 +- .../tests/integration/patient-export.test.mts | 2 +- .../integration/payment-history.test.mts | 11 +- .../integration/payments-assets.test.mts | 74 +++---- .../integration/payments-bigint.test.mts | 6 +- .../integration/payments-prepare.test.mts | 38 ++-- .../testnet-integration/payments.test.mts | 6 +- changelog.d/79-80-payment-assets-prepare.md | 16 ++ docs/adr/0004-custodial-payments-v1.md | 12 + 21 files changed, 363 insertions(+), 117 deletions(-) create mode 100644 changelog.d/79-80-payment-assets-prepare.md diff --git a/ROADMAP.md b/ROADMAP.md index c9a4d946..b873f840 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -84,7 +84,7 @@ roadmap. See each phase section below for status and issue links. ## Phase 5.5 β€” Payments (consumer side) **Status: Payment order linkage, automatic intent issuance, configuration hardening, and platform signer abstraction are implemented; fulfillment-triggered release remains pending.** See docs/adr/0004-custodial-payments-v1.md. Verified against **live testnet**, not mocked β€” `npm run test:integration:payments-testnet` (3/3 passing): fundβ†’release, fundβ†’refund, and reject-when-no-wallet-linked. -- [x] `PaymentIntent` + `WalletLink` models finalized (relational metadata only β€” no PHI). `WalletLink` scoped to the **facility** (payee), not the patient β€” see ADR 0004. Amount is raw stroops (`BigInt`), not the originally-scaffolded `Decimal`+`"USDC"` β€” matches what's actually proven end-to-end on testnet; multi-asset support is future work, not assumed. +- [x] `PaymentIntent` + `WalletLink` models finalized (relational metadata only β€” no PHI). `WalletLink` scoped to the **facility** (payee), not the patient β€” see ADR 0004. Amount is the asset's smallest unit (`BigInt`), with `assetCode` + `tokenContractId` per intent after #79 (native XLM plus an allowlisted USDC), not the originally-scaffolded `Decimal`+implicit `"USDC"`. - [x] Added `@docforum/escrow-sdk` as a real dependency (via the GitHub Release tarball URL β€” see `docforum-escrow`'s ADR 0003); wired `payments/services` to call it through a dynamic `import()` (`escrow-sdk-loader.ts` β€” the SDK is ESM-only, this backend is CommonJS). - [x] On order issuance, create a `PaymentIntent` (`created` status) automatically with a typed order FK. Manual intent creation was removed. Corrections move created intents; funded intents remain attached to the immutable original. - [x] [Issue #88] Harden payments configuration: typed network/RPC/contract/asset settings, mainnet opt-in guard, startup reachability check, and intent-recorded contract ids for release/refund. @@ -99,6 +99,7 @@ roadmap. See each phase section below for status and issue links. - [x] Nightly/manual testnet payment verification with fresh Friendbot-funded identities and deduplicated failure tracking. Tracked as [issue #91](https://github.com/DocForum/docforum-core/issues/91). - [x] Payout privacy prototype: testnet-only pooled payout support in the escrow funding path, with direct facility payouts as the default in production/local environments. Tracked as [issue #92](https://github.com/DocForum/docforum-core/issues/92). - [ ] 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. +- [x] Multi-asset intents (USDC + native XLM per-environment allowlist, payee trustline checks) and the patient-signed prepare half of ADR 0005. Tracked as [issue #79](https://github.com/DocForum/docforum-core/issues/79) and [issue #80](https://github.com/DocForum/docforum-core/issues/80). Prepare returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED` against SDK 0.2.0 until #32 lands; submit/fee-bump is out of scope for #80. - [ ] 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`. - [ ] 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. diff --git a/backend/.env.example b/backend/.env.example index 19600161..7765ecea 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -68,6 +68,25 @@ ESCROW_CONTRACT_ID=CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM # Kept as the legacy fallback for PaymentIntent rows created before #79; # new intents take their token from PAYMENT_ASSETS below. ESCROW_TOKEN_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC + +# Per-environment payment asset allowlist (issue #79). JSON array of +# { code, tokenContractId, decimals, issuer? }. Defaults (testnet XLM + USDC) +# live in src/config/index.ts; production MUST set this explicitly with +# mainnet contract ids. Only assets without clawback, transfer fees or +# rebasing belong here (escrow threat model T5). +# PAYMENT_ASSETS=[{"code":"XLM","tokenContractId":"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC","decimals":7},{"code":"USDC","tokenContractId":"CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA","decimals":7,"issuer":"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"}] +# Horizon is read only for payee trustline checks before funding/prepare. +HORIZON_URL=https://horizon-testnet.stellar.org + +# ADR 0005 prepare (issue #80). Defaults are in src/config/index.ts. +# prepared transaction TTL, and the expiry sweep cadence. +PAYMENT_PREPARE_TTL_SECONDS=300 +PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS=60 +# refund_after = order expiry + grace; orders have no expiry field yet +# (Phase 4), so the intent's createdAt is the anchor. +PAYMENT_ORDER_EXPIRY_HOURS=72 +PAYMENT_REFUND_GRACE_HOURS=24 + # Contract event ingestion (issue #86). Use comma-separated id@deployment-ledger # pairs to include current and historical redeployments. Each deployment needs # its own start ledger so RPC retention loss is detected rather than skipped. diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0b7cbac0..ec90624a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -298,6 +298,10 @@ model PatientProfile { // Phase 3: the referrals this patient is the subject of (the opposite // side of Referral.patientProfileId). referrals Referral[] + // ADR 0005 step 1 (issue #80): the patient's verified Stellar payer + // address, when one has been linked. Optional because linking is a + // separate flow that nothing creates rows for yet. + walletLink PatientWalletLink? // Phase 8 (issue #45): who viewed this patient's records. accessLogs AccessLog[] diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 683f8356..8ee3acac 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -245,6 +245,23 @@ export const config = { }, payments: { ...parsePaymentsConfig(process.env), + // Issue #79: per-environment asset allowlist (USDC + native XLM by + // default on testnet). The legacy `escrowTokenId`/`assetAllowlist` + // fields above remain for the pre-#79 native-XLM path. + assets: parsePaymentAssets(process.env.PAYMENT_ASSETS), + // Horizon is read only for payee trustline checks β€” never for contract + // calls, which go through the SDK. + horizonUrl: process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org', + // Issue #80: how long a prepared, unsigned create_escrow transaction is + // considered live before the expiry job returns the intent to `created`. + prepareTtlSeconds: Number(process.env.PAYMENT_PREPARE_TTL_SECONDS ?? 5 * 60), + // ADR 0005: refund_after = order expiry + grace. Orders are still + // placeholders without an expiry field (Phase 4), so the intent's own + // createdAt is the order-expiry anchor today. + orderExpiryHours: Number(process.env.PAYMENT_ORDER_EXPIRY_HOURS ?? 72), + refundGraceHours: Number(process.env.PAYMENT_REFUND_GRACE_HOURS ?? 24), + // Expiry sweep cadence (src/jobs/payment-intents.ts). + prepareExpirySweepIntervalSeconds: Number(process.env.PAYMENT_PREPARE_EXPIRY_SWEEP_INTERVAL_SECONDS ?? 60), payoutPrivacyMode, payoutPrivacyPoolAddress: process.env.PAYOUT_PRIVACY_POOL_ADDRESS, }, diff --git a/backend/src/jobs/README.md b/backend/src/jobs/README.md index b6eff9c0..373717a3 100644 --- a/backend/src/jobs/README.md +++ b/backend/src/jobs/README.md @@ -18,6 +18,7 @@ safe. | 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. | | Deleted-account purge (issue #41, ADR 0010) | `account-purge.ts` | Every `ACCOUNT_PURGE_INTERVAL_SECONDS`: hard-deletes anonymised patients that no module holds records for (`src/retention-holds.ts`), once `ACCOUNT_PURGE_GRACE_HOURS` has passed. Never deletes clinical, order or payment rows. Logs counts and error types only. | +| 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/modules/payments/repositories/index.ts b/backend/src/modules/payments/repositories/index.ts index 5f386eab..2260ee05 100644 --- a/backend/src/modules/payments/repositories/index.ts +++ b/backend/src/modules/payments/repositories/index.ts @@ -29,6 +29,36 @@ export function findWalletLinkByFacilityId(facilityId: string) { return prisma.walletLink.findUnique({ where: { facilityId } }); } +/** The patient's payer wallet (ADR 0005). Rows are created by the SEP-10 + * linking flow once it exists β€” not by this module's HTTP surface yet. */ +export function findPatientWalletLinkByPatientProfileId(patientProfileId: string) { + return prisma.patientWalletLink.findUnique({ where: { patientProfileId } }); +} + +export function createPaymentIntent(input: { + orderType: 'prescription' | 'lab_order'; + orderId: string; + patientProfileId: string; + facilityId: string; + assetCode: string; + tokenContractId: string; + amount: bigint; +}) { + const orderPointer = input.orderType === 'prescription' + ? { prescriptionId: input.orderId, labOrderId: null } + : { prescriptionId: null, labOrderId: input.orderId }; + return prisma.paymentIntent.create({ + data: { + ...orderPointer, + patientProfileId: input.patientProfileId, + facilityId: input.facilityId, + assetCode: input.assetCode, + tokenContractId: input.tokenContractId, + amount: input.amount, + }, + }); +} + export function findPaymentIntentById(id: string) { return prisma.paymentIntent.findUnique({ where: { id } }); } @@ -80,16 +110,17 @@ export function findPaymentHistoryPage( }); } -/** Aggregate each lifecycle bucket in PostgreSQL; amountStroops stays BigInt end to end. */ +/** Aggregate each lifecycle bucket, per asset, in PostgreSQL. The amount + * stays BigInt end to end; mixing assets in one total would be meaningless. */ export function aggregateFacilityPayments(facilityId: string, from?: Date, to?: Date) { return prisma.paymentIntent.groupBy({ - by: ['status'], + by: ['status', 'assetCode'], where: { facilityId, status: { in: ['escrowed', 'released', 'refunded'] }, ...(from || to ? { createdAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {}), }, - _sum: { amountStroops: true }, + _sum: { amount: true }, }); } diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index a9f341ff..81182ef5 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -12,10 +12,11 @@ // 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 type { PaymentIntent, PaymentIntentStatus } from '@prisma/client'; import * as paymentsRepo from '../repositories'; import { loadEscrowSdk, type EscrowSdkKeypair } from './escrow-sdk-loader'; +import { hasTrustline } from './trustline'; import { getPlatformSigners } from './platform-signers'; import { resolveEscrowPayee } from './payout-privacy'; import { getMyPatientProfile, paginate, type PatientExportSection } from '../../patients/services'; @@ -65,19 +66,17 @@ interface PrepareCapableClient { } async function requirePaymentsConfig() { - const { activeContractId, escrowTokenId } = config.payments; - const missing = Object.entries({ activeContractId, escrowTokenId }) - .filter(([, value]) => !value) - .map(([key]) => key); - if (missing.length > 0) { - throw new HttpError(500, `Payments is not configured β€” missing env var(s): ${missing.join(', ')}.`); + // The per-asset token contract comes from the intent (issue #79), so the + // legacy ESCROW_TOKEN_ID is no longer required for every payment path. + const { activeContractId } = config.payments; + if (!activeContractId) { + throw new HttpError(500, 'Payments is not configured β€” missing env var(s): activeContractId.'); } const { payer, releaser } = await getPlatformSigners(); return { platformPayer: payer, platformReleaser: releaser, - escrowContractId: activeContractId!, - escrowTokenId: escrowTokenId!, + escrowContractId: activeContractId, }; } async function escrowClient(contractId: string) { @@ -128,6 +127,94 @@ export function getWalletLink(facilityId: string) { return paymentsRepo.findWalletLinkByFacilityId(facilityId); } +export function getPatientWalletLink(patientProfileId: string) { + return paymentsRepo.findPatientWalletLinkByPatientProfileId(patientProfileId); +} + +/** The per-environment allowlist (issue #79). An intent may only be created + * in one of these assets. */ +export function listPaymentAssets(): PaymentAsset[] { + return config.payments.assets; +} + +function resolveAsset(code: string): PaymentAsset { + const asset = config.payments.assets.find((candidate) => candidate.code === code); + if (!asset) { + throw new HttpError( + 400, + `Unsupported payment asset '${code}'. Allowed: ${config.payments.assets.map((a) => a.code).join(', ')}.`, + 'UNSUPPORTED_PAYMENT_ASSET', + ); + } + return asset; +} + +/** + * Resolves the asset recorded on an intent. Rows created before issue #79 + * have no `tokenContractId` and were native XLM funded against + * `ESCROW_TOKEN_ID` β€” the documented legacy fallback. The allowlist only + * supplies `decimals`/`issuer` for the response; the stored contract id + * wins so an intent keeps paying the asset it was denominated in even if + * the allowlist entry is later rotated. + */ +function resolveAssetForIntent(intent: PaymentIntent): PaymentAsset { + const configured = config.payments.assets.find((asset) => asset.code === intent.assetCode); + const tokenContractId = + intent.tokenContractId ?? + (intent.assetCode === 'XLM' ? config.payments.escrowTokenId : undefined) ?? + configured?.tokenContractId; + if (!tokenContractId) { + throw new HttpError( + 409, + `PaymentIntent uses asset '${intent.assetCode}', whose token contract is not configured.`, + 'PAYMENT_ASSET_NOT_CONFIGURED', + ); + } + return { + code: intent.assetCode, + tokenContractId, + decimals: configured?.decimals ?? 0, + ...(configured?.issuer ? { issuer: configured.issuer } : {}), + }; +} + +/** Checks the payee can hold the asset before any on-chain call. Native XLM + * passes unconditionally; an issued asset needs an authorised trustline. */ +async function requirePayeeTrustline(payee: string, asset: PaymentAsset): Promise { + if (!asset.issuer) return; + if (!(await hasTrustline(payee, asset))) { + throw new HttpError( + 409, + `The facility's linked wallet has no trustline for ${asset.code}. Ask the facility to link a wallet that can hold ${asset.code}.`, + 'PAYEE_TRUSTLINE_MISSING', + ); + } +} + +export interface CreatePaymentIntentInput { + orderType: 'prescription' | 'lab_order'; + orderId: string; + patientProfileId: string; + facilityId: string; + assetCode: string; + /** Smallest unit of `assetCode`. */ + amount: bigint; +} + +export function createPaymentIntent(input: CreatePaymentIntentInput) { + if (input.amount <= 0n) throw new HttpError(400, 'amount must be positive.'); + const asset = resolveAsset(input.assetCode); + return paymentsRepo.createPaymentIntent({ + orderType: input.orderType, + orderId: input.orderId, + patientProfileId: input.patientProfileId, + facilityId: input.facilityId, + assetCode: asset.code, + tokenContractId: asset.tokenContractId, + amount: input.amount, + }); +} + /** Called by the orders module inside the order-issuance transaction. A * created intent moves to a corrected order; any funded/terminal intent * stays attached to the immutable original order. @@ -141,8 +228,11 @@ export async function createIntentForIssuedOrder(tx: Prisma.TransactionClient, i catalogItemCode?: string | null; }) { // Same guard the facility price list uses: positive integer stroops that - // fit the asset's precision and the contract's i128 amount. + // fit the asset's precision and the contract's i128 amount. Orders are + // still priced in native XLM; issue #79 made the intent's asset explicit + // so this write path has to name it rather than imply it. const amountStroops = parseStroopsAmount(input.amountStroops, 'amountStroops'); + const asset = resolveAsset('XLM'); const catalogItemCode = input.catalogItemCode ?? null; const facility = await tx.facilityProfile.findUnique({ where: { id: input.facilityProfileId }, select: { type: true } }); const expectedType = input.kind === 'prescription' ? 'pharmacy' : 'lab'; @@ -158,13 +248,29 @@ export async function createIntentForIssuedOrder(tx: Prisma.TransactionClient, i if (previous?.status === 'created') { const moved = await tx.paymentIntent.updateMany({ where: { id: previous.id, status: 'created' }, - data: { ...orderPointer, patientProfileId: input.patientProfileId, facilityId: input.facilityProfileId, amountStroops, catalogItemCode }, + data: { + ...orderPointer, + patientProfileId: input.patientProfileId, + facilityId: input.facilityProfileId, + amount: amountStroops, + assetCode: asset.code, + tokenContractId: asset.tokenContractId, + catalogItemCode, + }, }); if (moved.count === 1) return tx.paymentIntent.findUniqueOrThrow({ where: { id: previous.id } }); } } return tx.paymentIntent.create({ - data: { ...orderPointer, patientProfileId: input.patientProfileId, facilityId: input.facilityProfileId, amountStroops, catalogItemCode }, + data: { + ...orderPointer, + patientProfileId: input.patientProfileId, + facilityId: input.facilityProfileId, + amount: amountStroops, + assetCode: asset.code, + tokenContractId: asset.tokenContractId, + catalogItemCode, + }, }); } @@ -207,18 +313,23 @@ function paymentPage(filters: PaymentHistoryFilters, owner: { patientProfileId: }); } +function serializeAsset(code: string) { + return { type: code === 'XLM' ? 'native' : 'issued', code }; +} + function serializePayment(payment: PaymentIntent) { return { id: payment.id, orderType: payment.prescriptionId ? 'prescription' : 'lab_order', orderId: payment.prescriptionId ?? payment.labOrderId, status: payment.status, - amountStroops: payment.amountStroops.toString(), - asset: { type: 'native', code: 'XLM' }, + amount: payment.amount.toString(), + assetCode: payment.assetCode, + asset: serializeAsset(payment.assetCode), escrowId: payment.escrowId?.toString() ?? null, stellarTxHash: payment.stellarTxHash, sorobanContractId: payment.sorobanContractId, - refundAfter: null, + refundAfter: payment.refundAfter, createdAt: payment.createdAt, updatedAt: payment.updatedAt, }; @@ -245,17 +356,39 @@ export async function listFacilityPaymentLedger(userId: string, filters: Payment listPayments(filters, { facilityId }), paymentsRepo.aggregateFacilityPayments(facilityId, filters.from, filters.to), ]); - const totals = new Map(aggregates.map((row) => [row.status, row._sum.amountStroops ?? 0n])); + // Totals are per asset: summing XLM and USDC smallest units would be + // meaningless, so each allowlisted asset gets its own row. + const totalsByAsset = new Map>(); + for (const row of aggregates) { + const byStatus = totalsByAsset.get(row.assetCode) ?? new Map(); + byStatus.set(row.status, (byStatus.get(row.status) ?? 0n) + (row._sum.amount ?? 0n)); + totalsByAsset.set(row.assetCode, byStatus); + } return { ...page, - totals: [ - { - asset: { type: 'native', code: 'XLM' }, - escrowed: (totals.get('escrowed') ?? 0n).toString(), - released: (totals.get('released') ?? 0n).toString(), - refunded: (totals.get('refunded') ?? 0n).toString(), - }, - ], + totals: [...totalsByAsset.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([code, byStatus]) => ({ + asset: serializeAsset(code), + escrowed: (byStatus.get('escrowed') ?? 0n).toString(), + released: (byStatus.get('released') ?? 0n).toString(), + refunded: (byStatus.get('refunded') ?? 0n).toString(), + })), + }; +} + +/** 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.prescriptionId ? 'prescription' : 'lab_order', + orderId: intent.prescriptionId ?? intent.labOrderId!, + amount: intent.amount.toString(), + assetCode: intent.assetCode, + escrowId: intent.escrowId?.toString() ?? null, + stellarTxHash: intent.stellarTxHash, + sorobanContractId: intent.sorobanContractId, + preparedTxHash: intent.preparedTxHash, }; } @@ -284,16 +417,7 @@ async function commitTransition( action, fromState: intent.status, toState: to, - // Public, non-identifying references only: the escrow is on a public - // ledger anyway. No patient data, no secrets. - metadata: { - orderType: updated.prescriptionId ? 'prescription' : 'lab_order', - orderId: updated.prescriptionId ?? updated.labOrderId!, - amountStroops: updated.amountStroops.toString(), - escrowId: updated.escrowId?.toString() ?? null, - stellarTxHash: updated.stellarTxHash, - sorobanContractId: updated.sorobanContractId, - }, + metadata: intentAuditMetadata(updated), }); return updated; }); @@ -309,23 +433,26 @@ export async function fundPaymentIntent(actor: auditService.AuditActor, id: stri const walletLink = await paymentsRepo.findWalletLinkByFacilityId(intent.facilityId); if (!walletLink) throw new HttpError(409, 'The facility for this payment has no linked wallet.'); - const { platformPayer, platformReleaser, escrowContractId, escrowTokenId } = await requirePaymentsConfig(); - if (config.payments.assetAllowlist.length > 0 && !config.payments.assetAllowlist.includes(escrowTokenId)) { - throw new HttpError(500, 'Configured escrow token is not in the payments asset allowlist.'); - } + const { platformPayer, platformReleaser, escrowContractId } = await requirePaymentsConfig(); const payee = resolveEscrowPayee({ facilityAddress: walletLink.stellarPublicKey, mode: config.payments.payoutPrivacyMode, poolAddress: config.payments.payoutPrivacyPoolAddress, production: process.env.NODE_ENV === 'production', }); + + const asset = resolveAssetForIntent(intent); + // The trustline belongs to the address that actually receives the payout, + // which in pooled mode is the pool rather than the facility wallet. + await requirePayeeTrustline(payee, asset); + const client = await escrowClient(escrowContractId); const { escrowId, txHash } = await client.createEscrow({ payer: platformPayer as unknown as EscrowSdkKeypair, payee, - 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. diff --git a/backend/src/modules/payments/services/state-machine.ts b/backend/src/modules/payments/services/state-machine.ts index 614b301b..2c8dd704 100644 --- a/backend/src/modules/payments/services/state-machine.ts +++ b/backend/src/modules/payments/services/state-machine.ts @@ -1,7 +1,14 @@ import type { PaymentIntentStatus } from '@prisma/client'; const transitions: Record = { - created: ['escrowed'], + // ADR 0005 steps 2-4: a created intent can either be funded from the + // platform payer (custodial v1) or prepared for the patient's signature. + // Expiry rolls a prepared transaction back to `created`. + created: ['awaiting_signature', 'escrowed'], + awaiting_signature: ['created', 'submitting'], + // The submit half of ADR 0005 is not built yet (issue #80 covers prepare), + // so `submitting` is reserved but never written by this service today. + submitting: ['escrowed'], escrowed: ['released', 'refunded'], released: [], refunded: [], diff --git a/backend/src/server.ts b/backend/src/server.ts index 7475c678..b7620431 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,6 +7,7 @@ import { startEscrowEventPoller } from './jobs/escrow-events'; import { validatePlatformSigners } from './modules/payments/services/platform-signers'; import { verifyPaymentsNetwork } from './modules/payments/services'; import { startAccountPurgeJob } from './jobs/account-purge'; +import { startPaymentIntentJobs } from './jobs/payment-intents'; const app = createApp(); @@ -65,3 +66,7 @@ startPatientExportJobs(); startEscrowEventPoller(); // Deleted-account tombstone purge (Phase 8, issue #41, docs/adr/0010). startAccountPurgeJob(); +// Prepared-transaction expiry (Phase 5.5, issue #80): an unsigned +// create_escrow transaction that outlives its window moves the intent back +// to `created` so the patient can prepare again. +startPaymentIntentJobs(); diff --git a/backend/tests/integration/account-deletion.test.mts b/backend/tests/integration/account-deletion.test.mts index 6512d86b..eef8e2dc 100644 --- a/backend/tests/integration/account-deletion.test.mts +++ b/backend/tests/integration/account-deletion.test.mts @@ -186,7 +186,8 @@ describe('account deletion with clinical-record retention', () => { labOrderId: labOrder.id, patientProfileId: p.profileId, facilityId, - amountStroops: 1000n, + amount: 1000n, + assetCode: 'XLM', status: 'escrowed', escrowId: 7n, stellarTxHash: 'f'.repeat(64), diff --git a/backend/tests/integration/audit.test.mts b/backend/tests/integration/audit.test.mts index 62731c85..004d3167 100644 --- a/backend/tests/integration/audit.test.mts +++ b/backend/tests/integration/audit.test.mts @@ -301,7 +301,7 @@ describe('AuditEvent foundation', () => { actorUserId: admin.id, actorRole: 'admin', requestId: `pay-fund-${runId}`, - metadata: { orderType: 'lab_order', orderId: intent.labOrderId, amountStroops: '5000', stellarTxHash: TX_HASH, sorobanContractId: 'CTESTCONTRACT' }, + metadata: { orderType: 'lab_order', orderId: intent.labOrderId, amount: '5000', assetCode: 'XLM', stellarTxHash: TX_HASH, sorobanContractId: 'CTESTCONTRACT' }, }); expect((events[0]!.metadata as Record).escrowId).toMatch(/^\d+$/); const serialized = JSON.stringify(events); diff --git a/backend/tests/integration/facility-prices.test.mts b/backend/tests/integration/facility-prices.test.mts index ceda76ed..5e2f1f18 100644 --- a/backend/tests/integration/facility-prices.test.mts +++ b/backend/tests/integration/facility-prices.test.mts @@ -211,14 +211,14 @@ describe('facility price lists and intent pricing (issue #127)', () => { expect(order.amountStroops).toBe(30_000_000n); const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: order.paymentIntent.id } }); - expect(intent.amountStroops).toBe(30_000_000n); + expect(intent.amount).toBe(30_000_000n); expect(intent.catalogItemCode).toBe('cbc-panel'); expect(intent.facilityId).toBe(facilityId); // A later price change must not re-price an existing intent. await publish({ catalogItemCode: 'cbc-panel', amount: '99000000' }); const afterChange = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } }); - expect(afterChange.amountStroops).toBe(30_000_000n); + expect(afterChange.amount).toBe(30_000_000n); expect(afterChange.catalogItemCode).toBe('cbc-panel'); }); @@ -246,7 +246,7 @@ describe('facility price lists and intent pricing (issue #127)', () => { }); expect(order.amountStroops).toBe(4_200_000n); const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { id: order.paymentIntent.id } }); - expect(intent.amountStroops).toBe(4_200_000n); + expect(intent.amount).toBe(4_200_000n); expect(intent.catalogItemCode).toBeNull(); }); }); diff --git a/backend/tests/integration/order-fixtures.mts b/backend/tests/integration/order-fixtures.mts index 42cf62d7..44e7d8f1 100644 --- a/backend/tests/integration/order-fixtures.mts +++ b/backend/tests/integration/order-fixtures.mts @@ -34,6 +34,6 @@ export function createPaymentIntentForFixture(prisma: PrismaClient, order: Await prescriptionId: order.kind === 'prescription' ? order.id : null, labOrderId: order.kind === 'lab_order' ? order.id : null, patientProfileId: order.patientProfileId, facilityId: order.facilityProfileId, - amountStroops: order.amountStroops, status, + amount: order.amountStroops, assetCode: 'XLM', status, } }); } \ No newline at end of file diff --git a/backend/tests/integration/patient-export.test.mts b/backend/tests/integration/patient-export.test.mts index 8b7df4c6..dedfa07e 100644 --- a/backend/tests/integration/patient-export.test.mts +++ b/backend/tests/integration/patient-export.test.mts @@ -181,7 +181,7 @@ describe('patient data export', () => { const bobPrescription = await createOrderFixture(prisma, { patientProfileId: bob.profileId, facilityProfileId: facilityId, kind: 'prescription', amountStroops: 9n, tag: `bob-rx-${runId}` }); await prisma.paymentIntent.create({ data: { prescriptionId: bobPrescription.id, patientProfileId: bob.profileId, facilityId, - amountStroops: 9n, stellarTxHash: 'b'.repeat(64), + amount: 9n, assetCode: 'XLM', stellarTxHash: 'b'.repeat(64), } }); }, 180_000); diff --git a/backend/tests/integration/payment-history.test.mts b/backend/tests/integration/payment-history.test.mts index eba107d0..6aa41474 100644 --- a/backend/tests/integration/payment-history.test.mts +++ b/backend/tests/integration/payment-history.test.mts @@ -48,7 +48,8 @@ describe('payment history and facility ledger APIs', () => { patientProfileId: input.patientProfileId, facilityId: input.facilityId, status: input.status, - amountStroops: input.amountStroops, + amount: input.amountStroops, + assetCode: 'XLM', createdAt: input.createdAt, ...(input.status === 'escrowed' || input.status === 'released' || input.status === 'refunded' ? { escrowId: input.amountStroops + 1n, stellarTxHash: 'a'.repeat(64), sorobanContractId: 'C'.repeat(56) } @@ -115,7 +116,7 @@ describe('payment history and facility ledger APIs', () => { expect(first.status).toBe(200); expect(first.body.items).toHaveLength(2); expect(first.body.nextCursor).toEqual(expect.any(String)); - expect(first.body.items[0].amountStroops).toBe('30'); + expect(first.body.items[0].amount).toBe('30'); expect(first.body.items[0].refundAfter).toBeNull(); const second = await request(app) @@ -124,12 +125,12 @@ describe('payment history and facility ledger APIs', () => { .set('Authorization', `Bearer ${patientA.token}`); expect(second.status).toBe(200); expect(second.body.items).toHaveLength(1); - expect(second.body.items[0].amountStroops).toBe('9007199254740993'); + expect(second.body.items[0].amount).toBe('9007199254740993'); expect(second.body.nextCursor).toBeNull(); const otherPatient = await request(app).get('/payments/mine').set('Authorization', `Bearer ${patientB.token}`); expect(otherPatient.body.items).toHaveLength(1); - expect(otherPatient.body.items[0].amountStroops).toBe('900'); + expect(otherPatient.body.items[0].amount).toBe('900'); }); it('includes the entire final day when filtering by a date-only to value', async () => { @@ -157,7 +158,7 @@ describe('payment history and facility ledger APIs', () => { .set('Authorization', `Bearer ${facilityA.token}`); expect(response.status).toBe(200); expect(response.body.items).toHaveLength(1); - expect(response.body.items[0].amountStroops).toBe('9007199254740993'); + expect(response.body.items[0].amount).toBe('9007199254740993'); expect(response.body.items[0].stellarTxHash).toBe('a'.repeat(64)); expect(response.body.items[0].asset).toEqual({ type: 'native', code: 'XLM' }); expect(response.body.totals).toEqual([ diff --git a/backend/tests/integration/payments-assets.test.mts b/backend/tests/integration/payments-assets.test.mts index 8633c475..13c48132 100644 --- a/backend/tests/integration/payments-assets.test.mts +++ b/backend/tests/integration/payments-assets.test.mts @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import EmbeddedPostgres from 'embedded-postgres'; import { Keypair } from '@stellar/stellar-sdk'; +import { createOrderFixture } from './order-fixtures.mjs'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const PORT = 5439; @@ -63,6 +64,29 @@ describe('payment assets (issue #79)', () => { const trustlineCalls: Array<{ account: string; code: string }> = []; let trustlineResult = true; + async function createIntent(input: { + kind: 'prescription' | 'lab_order'; + assetCode: string; + amount: bigint; + facilityId?: string; + }) { + const order = await createOrderFixture(prisma, { + patientProfileId, + facilityProfileId: input.facilityId ?? facilityId, + kind: input.kind, + amountStroops: input.amount, + tag: `assets-${runId}-${Math.random().toString(36).slice(2)}`, + }); + return paymentsService.createPaymentIntent({ + orderType: input.kind, + orderId: order.id, + patientProfileId, + facilityId: input.facilityId ?? facilityId, + assetCode: input.assetCode, + amount: input.amount, + }); + } + beforeAll(async () => { await pg.initialise(); await pg.start(); @@ -125,14 +149,7 @@ describe('payment assets (issue #79)', () => { }); 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, - }); + const intent = await createIntent({ kind: 'prescription', assetCode: 'USDC', amount: 25_000_000n }); expect(intent.assetCode).toBe('USDC'); expect(intent.tokenContractId).toBe(USDC_SAC); @@ -159,14 +176,7 @@ describe('payment assets (issue #79)', () => { 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 intent = await createIntent({ kind: 'lab_order', assetCode: 'USDC', amount: 12_500_000n }); const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); expect(funded.status).toBe('escrowed'); @@ -178,14 +188,7 @@ describe('payment assets (issue #79)', () => { trustlineResult = false; sdkCalls.length = 0; - const intent = await paymentsService.createPaymentIntent({ - orderType: 'lab_order', - orderId: `no-trustline-${runId}`, - patientProfileId, - facilityId, - assetCode: 'USDC', - amount: 100n, - }); + const intent = await createIntent({ kind: 'lab_order', assetCode: 'USDC', amount: 100n }); const error = await captureError(() => paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id)); expect(error.status).toBe(409); @@ -198,14 +201,7 @@ describe('payment assets (issue #79)', () => { trustlineCalls.length = 0; sdkCalls.length = 0; - const intent = await paymentsService.createPaymentIntent({ - orderType: 'prescription', - orderId: `xlm-${runId}`, - patientProfileId, - facilityId, - assetCode: 'XLM', - amount: 500_000n, - }); + const intent = await createIntent({ kind: 'prescription', assetCode: 'XLM', amount: 500_000n }); const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); expect(funded.status).toBe('escrowed'); @@ -216,16 +212,10 @@ describe('payment assets (issue #79)', () => { 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 created = await createIntent({ kind: 'prescription', assetCode: 'XLM', amount: 1_000_000n }); + const legacy = await prisma.paymentIntent.update({ + where: { id: created.id }, + data: { tokenContractId: null }, }); const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, legacy.id); diff --git a/backend/tests/integration/payments-bigint.test.mts b/backend/tests/integration/payments-bigint.test.mts index 78e44632..f0f76929 100644 --- a/backend/tests/integration/payments-bigint.test.mts +++ b/backend/tests/integration/payments-bigint.test.mts @@ -101,13 +101,13 @@ describe('PaymentIntent BigInt round-trip (Prisma 7 driver adapter)', () => { // 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, { - patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops, tag: orderId, + patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: amount, tag: orderId, }); const created = await prisma.$transaction((tx) => paymentsService.createIntentForIssuedOrder(tx, { kind: 'lab_order', orderId: order.id, patientProfileId, doctorProfileId: order.doctorProfileId, - facilityProfileId: facilityId, amountStroops, supersedesId: null, + facilityProfileId: facilityId, amountStroops: amount, supersedesId: null, })); - expect(created.amountStroops).toBe(amountStroops); + expect(created.amount).toBe(amount); // Re-read from the database, bypassing the in-memory object the create // returned β€” this is the assertion that actually proves the adapter diff --git a/backend/tests/integration/payments-prepare.test.mts b/backend/tests/integration/payments-prepare.test.mts index c0078623..00fce92a 100644 --- a/backend/tests/integration/payments-prepare.test.mts +++ b/backend/tests/integration/payments-prepare.test.mts @@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import EmbeddedPostgres from 'embedded-postgres'; import { Keypair } from '@stellar/stellar-sdk'; +import { createOrderFixture } from './order-fixtures.mjs'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const PORT = 5440; @@ -69,11 +70,18 @@ describe('prepare endpoint (issue #80)', () => { const actor = (userId: string) => ({ userId, role: 'patient' as const, requestId: `prepare-${runId}` }); - async function createIntent(facility = facilityId) { + async function createIntent(facility = facilityId, profileId = patientProfileId) { + const order = await createOrderFixture(prisma, { + patientProfileId: profileId, + facilityProfileId: facility, + kind: 'prescription', + amountStroops: 500_000n, + tag: `prepare-${runId}-${Math.random().toString(36).slice(2)}`, + }); return paymentsService.createPaymentIntent({ orderType: 'prescription', - orderId: `prepare-${runId}-${Math.random()}`, - patientProfileId, + orderId: order.id, + patientProfileId: profileId, facilityId: facility, assetCode: 'XLM', amount: 500_000n, @@ -246,16 +254,20 @@ describe('prepare endpoint (issue #80)', () => { 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 order = await createOrderFixture(prisma, { + patientProfileId: otherPatientProfileId, + facilityProfileId: facilityId, + kind: 'lab_order', + amountStroops: 100n, + tag: `no-wallet-${runId}`, + }); + const intent = await paymentsService.createPaymentIntent({ + orderType: 'lab_order', + orderId: order.id, + patientProfileId: otherPatientProfileId, + facilityId, + assetCode: 'XLM', + amount: 100n, }); const error = await captureError(paymentsService.preparePaymentIntent(actor(otherPatientUserId), intent.id)); diff --git a/backend/tests/testnet-integration/payments.test.mts b/backend/tests/testnet-integration/payments.test.mts index ca24f990..f2474479 100644 --- a/backend/tests/testnet-integration/payments.test.mts +++ b/backend/tests/testnet-integration/payments.test.mts @@ -171,9 +171,10 @@ describe('payments module against live testnet', () => { }, 60_000); it('rejects a USDC escrow when the payee has no trustline, before any on-chain call', async () => { + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 100_000n, tag: `usdc-no-trustline-${runId}` }); const intent = await paymentsService.createPaymentIntent({ orderType: 'lab_order', - orderId: 'test-order-usdc-no-trustline', + orderId: order.id, patientProfileId, facilityId, assetCode: 'USDC', @@ -185,9 +186,10 @@ describe('payments module against live testnet', () => { }, 30_000); it.skipIf(!USDC_FUNDING_ENABLED)('funds and releases a USDC escrow end-to-end on testnet', async () => { + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 500_000n, tag: `usdc-release-${runId}` }); const intent = await paymentsService.createPaymentIntent({ orderType: 'lab_order', - orderId: 'test-order-usdc-release', + orderId: order.id, patientProfileId, facilityId, assetCode: 'USDC', diff --git a/changelog.d/79-80-payment-assets-prepare.md b/changelog.d/79-80-payment-assets-prepare.md new file mode 100644 index 00000000..a5107453 --- /dev/null +++ b/changelog.d/79-80-payment-assets-prepare.md @@ -0,0 +1,16 @@ +Phase 5.5: multi-asset payment intents and the patient-signed prepare half +of ADR 0005 ([issue #79](https://github.com/DocForum/docforum-core/issues/79), +[issue #80](https://github.com/DocForum/docforum-core/issues/80)). +`PaymentIntent.amountStroops` is renamed to the token-agnostic `amount` +(a column rename in the migration, so existing values are preserved), and +each intent now records an allowlisted `assetCode` + `tokenContractId` +(native XLM + USDC on testnet, configured per environment via +`PAYMENT_ASSETS`). Funding and prepare check the payee's trustline before +any SDK call, and facility totals are grouped per asset rather than summed +across them. `POST /payments/intents/:id/prepare` builds and simulates the +patient-as-payer `create_escrow`, stores the unsigned XDR/hash and moves the +intent `created -> awaiting_signature`; an interval job expires stale +prepared transactions back to `created`. The real SDK call still returns +`503 ESCROW_SDK_PREPARE_UNSUPPORTED` until `@docforum/escrow-sdk` 0.3.0 +([issue #32](https://github.com/DocForum/docforum-core/issues/32)) ships; +submit/fee-bump is not part of #80. diff --git a/docs/adr/0004-custodial-payments-v1.md b/docs/adr/0004-custodial-payments-v1.md index 11d869d6..7e07cf6e 100644 --- a/docs/adr/0004-custodial-payments-v1.md +++ b/docs/adr/0004-custodial-payments-v1.md @@ -100,6 +100,18 @@ terminal intent has no matching order. triggered release" is the target once Phase 4/5 exist; v1's admin-only gate is the honest interim, not the destination. +## Update β€” 2026-10-19 (issues #79 and #80) + +`PaymentIntent` is no longer native-XLM-only: issue #79 added an +allowlisted `assetCode` + `tokenContractId` per intent and renamed +`amountStroops` to the token-agnostic `amount` (a plain column rename, so +existing values are preserved). The custodial funding path described here +is unchanged β€” the platform payer still funds the escrow β€” but the token it +pulls from is now the intent's asset, defaulting to the legacy +`ESCROW_TOKEN_ID` for pre-#79 rows. ADR 0005's non-custodial path is being +added alongside this one (issue #80 added prepare; submit is still pending), +and release/refund remain admin-triggered exactly as described above. + ## Event monitoring and releaser-key alerts (issue #86) From 52df727f0d00230db10ee24e6098c95af77e955d Mon Sep 17 00:00:00 2001 From: habeebti <144542166+habeebti@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:17:17 +0000 Subject: [PATCH 3/3] docs(payments): restore multi-asset and prepare docs lost in merge The merge also dropped the architecture model notes, the API contract for prepare, and the audit-state entries. Document the current schema (typed order FKs, per-asset amounts, awaiting_signature) and the export's payments section v2 so the docs match the code. --- ARCHITECTURE.md | 43 +++++++++++++++++++++++++++++++++++++- ARCHITECTURE_ESSENTIALS.md | 2 +- docs/api/README.md | 27 +++++++++++++++++------- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 883ff180..ec8c7ea0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -54,6 +54,7 @@ Each module under `backend/src/modules//` owns its own `routes/`, `control - `lab-orders` β€” lab order issuance and versioning. - `pharmacy-orders` β€” fulfillment-side record for prescriptions at a specific pharmacy (kept distinct from `prescriptions` itself β€” see data model note in Β§4.7). - `notifications` β€” outbound notification dispatch, provider-agnostic. +- `payments` β€” `PaymentIntent`, the facility payee `WalletLink`, and the patient payer `PatientWalletLink` (Β§4.13). Relational metadata only; every Stellar call goes through `@docforum/escrow-sdk` via `services/escrow-sdk-loader.ts`. Assets are allowlisted per environment (issue #79), and the prepare half of ADR 0005 (issue #80) builds the unsigned patient transaction without moving funds. - `audit` β€” the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking (both the direct and the referral-origin path), consultation closing (Phase 2, issue #4), doctor verification, payment fund/release/refund, the patient's referral accept/decline, and the referral admin actions (Phase 3, issue #68 β€” `referral.reassign` / `referral.cancel`, recorded as `entityType: 'Referral'` rows; there is no second, referral-specific audit table). **Phase 4 transitions (fulfillment) must call `record()` in the same transaction when they're built** β€” prescriptions and lab orders already do. Also owns the PHI read-access log (Phase 8, issue #45, Β§4.11): `auditService.recordAccess()` (fire-and-forget off the read path, fail-open but counted), admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log`, whose response strips all viewer PII to a role. ### 3.1 Patient data export registry (Phase 8, issue #40) @@ -377,7 +378,8 @@ Transitions recorded (action: from β†’ to): - `appointment.booked`: `null β†’ scheduled` (metadata: slot id, doctor profile id, origin). Written by the appointments module for **both** booking paths β€” a direct booking and a referral-origin booking (the accept path), which is what the `origin` metadata distinguishes; there is no way to create an appointment that leaves no trail. - `consultation.closed`: `null β†’ outcome` (metadata: appointment id; never the notes β€” that's PHI). Written by the consultations module for every consultation write, whichever route addressed it (`POST /consultations`, `POST /appointments/:id/consultation`, or the orders flow). - `doctor.verification_status_changed`: previous β†’ new verification status (read under `FOR UPDATE`; a no-op set records nothing) -- `payment_intent.funded` `created β†’ escrowed`, `payment_intent.released` `escrowed β†’ released`, `payment_intent.refunded` `escrowed β†’ refunded` (metadata: order type, amount, escrow id, tx hash, contract id β€” public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets `409` and records nothing. +- `payment_intent.funded` `created β†’ escrowed`, `payment_intent.released` `escrowed β†’ released`, `payment_intent.refunded` `escrowed β†’ refunded` (metadata: order type, amount, asset code, escrow id, tx hash, contract id β€” public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets `409` and records nothing. +- `payment_intent.prepared` `created β†’ awaiting_signature`, `payment_intent.prepare_expired` `awaiting_signature β†’ created` (issue #80). Actor is the patient for prepare and the system for expiry; metadata adds the prepared transaction hash. - `referral.accepted`: `pending β†’ accepted` (metadata: the appointment id the accept booked). Written by the referrals module in the same transaction as the accept, so the patient's decision and the appointment it produced are both recorded or neither is. - `referral.declined`: `pending β†’ declined`. - `referral.reassign`: `null β†’ null` β€” the referral is retargeted, not @@ -459,6 +461,45 @@ to the caller's own profile (resolved from the authenticated user, never from the request) and strips all viewer PII to a role β€” a deleted viewer surfaces as `deleted_user`. +### 4.13 Payments (Phase 5.5 β€” ADR 0004 custodial v1, ADR 0005 non-custodial) +``` +PaymentIntent + id, prescription_id / lab_order_id (exactly one typed FK β€” issue #85) + status (enum: created|awaiting_signature|submitting|escrowed|released|refunded|failed) + patient_profile_id (FK, RESTRICT), facility_id (FK) + amount (bigint β€” smallest unit of asset_code; never scaled in this repo) + asset_code, token_contract_id (SAC address; token_contract_id is nullable + only for pre-#79 rows, which resolve ESCROW_TOKEN_ID as native XLM) + escrow_id, stellar_tx_hash, soroban_contract_id (nullable β€” set on fund) + prepared_tx_hash, prepared_xdr, prepared_expiry_ledger, prepared_expires_at, + refund_after (nullable β€” live only in awaiting_signature; cleared on expiry) + created_at, updated_at + # Every status write is a conditional UPDATE on the observed state, in the + same transaction as its AuditEvent (Β§4.10). Prepare is + created -> awaiting_signature; expiry is awaiting_signature -> created. + `submitting` is reserved for ADR 0005's submit half (not built). Orders + are priced in XLM today, so issuance writes asset_code='XLM'. + +PatientWalletLink + patient_profile_id (unique FK -> PatientProfile, cascade) + stellar_public_key, verified_at + # The PAYER wallet (ADR 0005). Rows are written by the SEP-10 flow, which + is not built yet; prepare requires one and returns 409 + WALLET_NOT_LINKED otherwise. + +WalletLink + facility_id (unique FK -> FacilityProfile, cascade) + stellar_public_key + # The PAYEE/payout wallet, facility-managed (ADR 0004). Unchanged by #79. +``` +Assets are an environment allowlist (`PAYMENT_ASSETS`; testnet defaults XLM + +USDC SACs) rather than a fixed enum: an intent in any other asset is +rejected at creation. Before funding (and preparing) an issued asset, the +payee's Horizon account is checked for an authorised trustline +(`PAYEE_TRUSTLINE_MISSING`); native XLM needs none. Only assets without +clawback/transfer fees/rebasing belong on the list β€” escrow threat model +T5, enforced as a curated config policy. + ## 5. Key flows (sequence-level, not code) ### 5.1 Booking (concurrency-safe) diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 812f3422..bfc3128d 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -31,7 +31,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc `LoginAttempt` (Phase 8, issue #35 β€” Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert). `PatientProfile` β†’ `PatientExport` β†’ `PatientExportChunk` (Phase 8, issue #40 β€” right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` β€” see ARCHITECTURE.md Β§3.1). Account deletion (Phase 8, issue #41 β€” docs/adr/0010): anonymise in place (`User.deletedAt`, `PatientProfile.anonymizedAt`), never cascade. `PatientProfile.userId` is ON DELETE RESTRICT; new patient-linked clinical tables must be RESTRICT too **and** register a retention hold in `src/retention-holds.ts`. -`FacilityProfile` β†’ `WalletLink` (payout address) ; `Prescription` or `LabOrder` β†’ exactly one typed `PaymentIntent` FK (issue #85) +`FacilityProfile` β†’ `WalletLink` (payout address) ; `PatientProfile` β†’ `PatientWalletLink` (payer address, ADR 0005) ; `Prescription` or `LabOrder` β†’ exactly one typed `PaymentIntent` FK (issue #85), whose `amount` is in the smallest unit of a per-environment allowlisted `assetCode` (issue #79) ## Payments (Phase 5.5 β€” see docs/adr/0004-custodial-payments-v1.md) - **Custodial v1**: `docforum-core` holds its own Stellar payer + releaser identities (`PLATFORM_PAYER_SECRET`/`PLATFORM_RELEASER_SECRET` env vars). Patients never hold or sign with a wallet β€” the only reason this is possible without violating `docforum-web`'s "no direct Stellar calls" hard rule. diff --git a/docs/api/README.md b/docs/api/README.md index 31ad4f04..d8cad392 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -31,7 +31,7 @@ depend on a trustworthy contact address: - `POST /consultations`, `POST /consultations/:id/referrals` - `POST /referrals`, `POST /referrals/from-consultation/:id`, `POST /referrals/:id/{accept,decline}` -- `POST /payments/wallet-link`, `POST /payments/intents`, +- `POST /payments/wallet-link`, `POST /payments/intents/:id/prepare`, `POST /payments/intents/:id/{fund,release,refund}` (`GET`-style routes β€” lists, availability, the admin referral queue β€” @@ -255,11 +255,24 @@ issuance with `422 { code: 'PRICE_ON_REQUEST' }` and writes no order. Phase 5.5 (custodial v1 β€” `docs/adr/0004-custodial-payments-v1.md`): `POST /payments/wallet-link` (facility, sets its Stellar payout address), -and admin-gated `/payments/intents` (`POST`, `GET /:id`, plus -`POST /intents/:id/{fund,release,refund}`). `PaymentIntent.orderType` / -`orderId` are opaque and **not** FK-validated β€” `Prescription`/`LabOrder` -don't exist yet (Phase 4). Admin-triggered release/refund is the interim -design, not the final one. +and admin-gated `GET /payments/intents/:id` plus +`POST /intents/:id/{fund,release,refund}`. Intents are created only as part +of order issuance and carry a typed `Prescription`/`LabOrder` FK. Amounts +are in the smallest unit of `assetCode` (issue #79); the allowed assets are +an environment allowlist (`PAYMENT_ASSETS`, testnet XLM + USDC by default). +Admin-triggered release/refund is the interim design, not the final one. + +`POST /payments/intents/:id/prepare` (patient, verified email; issue #80) +owns the caller's intent in the service β€” someone else's intent is `404`. +It requires a verified `PatientWalletLink` (`409 WALLET_NOT_LINKED`) and a +linked facility payee (`409 FACILITY_WALLET_NOT_LINKED`), checks an issued +asset's payee trustline (`409 PAYEE_TRUSTLINE_MISSING`), then builds and +simulates the patient-as-payer `create_escrow`. It returns the intent plus +`{ xdr, txHash, expiryLedger }` and moves the intent to `awaiting_signature`; +re-preparing while live returns the same transaction, and an expiry sweep +moves stale prepared intents back to `created`. Against `@docforum/escrow-sdk` +0.2.0 the real prepare call does not exist, so this returns +`503 ESCROW_SDK_PREPARE_UNSUPPORTED` until issue #32 lands. ## Orders (`/orders`) β€” `backend/src/modules/orders` @@ -362,7 +375,7 @@ Bundle (`schemaVersion` 1): "sections": { "account": { "version": 1, "items": [{ "id", "email", "phone", "role", "emailVerifiedAt", "createdAt", "updatedAt" }] }, "appointments": { "version": 1, "items": [{ "id", "status", "origin", "slot": { "startTime", "endTime" }, "doctor": { "id", "fullName" }, "createdAt", "updatedAt" }] }, - "payments": { "version": 1, "items": [{ "id", "prescriptionId", "labOrderId", "status", "amountStroops", "facility": { "id", "name" }, "escrowId", "stellarTxHash", "sorobanContractId", "createdAt", "updatedAt" }] }, + "payments": { "version": 2, "items": [{ "id", "prescriptionId", "labOrderId", "status", "amount", "assetCode", "facility": { "id", "name" }, "escrowId", "stellarTxHash", "sorobanContractId", "createdAt", "updatedAt" }] }, "profile": { "version": 1, "items": [{ "id", "fullName", "dateOfBirth", "createdAt", "updatedAt" }] } } }