diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c592fd5..5410e7d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -180,10 +180,10 @@ Design note: `snapshot_notes` and `snapshot_intake_id` deliberately duplicate da ### 4.5 Prescriptions (versioned, append-only) ``` Prescription - id, consultation_id (FK), patient_profile_id, prescribing_doctor_id - status (enum: issued|fulfilled|expired|superseded) - supersedes_prescription_id (nullable, self-FK — corrections create a new row) - created_at + id, consultation_id (FK), patient_profile_id (FK), prescribing_doctor_id (FK) + facility_profile_id (FK to pharmacy), amount_stroops (clinician-entered XLM total) + supersedes_prescription_id (nullable, unique self-FK), created_at + # Corrections create a child row and never update the issued row. PrescriptionItem id, prescription_id (FK), drug_name, dosage, frequency, duration @@ -192,16 +192,20 @@ PrescriptionItem ### 4.6 Lab orders (versioned, append-only) ``` LabOrder - id, consultation_id (FK), patient_profile_id, ordering_doctor_id - test_type, urgency (enum: routine|urgent), notes - status (enum: issued|fulfilled|expired|superseded) - supersedes_lab_order_id (nullable, self-FK) - created_at + id, consultation_id (FK), patient_profile_id (FK), ordering_doctor_id (FK) + facility_profile_id (FK to lab), amount_stroops (clinician-entered XLM total) + test_type, urgency (enum: routine|urgent), notes [ENCRYPTED] + supersedes_lab_order_id (nullable, unique self-FK), created_at LabResult - id, lab_order_id (FK, unique), fulfilling_facility_id, result_file_url, summary + id, lab_order_id (FK, unique), fulfilling_facility_id (FK), result_file_url, summary created_at ``` +The facility and total are captured at issuance. The issuing clinician supplies +`amount_stroops` in native XLM stroops because v1 has no facility catalog or +pricing service. Corrections create a new immutable row. A `created` payment +intent moves to the correction; an already escrowed intent remains linked to +the original and is refunded through the existing admin refund action. ### 4.7 Fulfillment (facility-side) ``` @@ -240,7 +244,7 @@ LoginAttempt (issue #35) ``` AuditEvent id, actor_user_id (nullable — null = system), actor_role (nullable Role) - entity_type ('Appointment' | 'DoctorProfile' | 'PaymentIntent' so far), entity_id + entity_type ('Appointment' | 'DoctorProfile' | 'PaymentIntent' | 'Consultation' | 'Prescription' | 'LabOrder'), entity_id action (dotted, e.g. 'appointment.booked'), from_state, to_state (nullable) request_id (X-Request-Id of the causing request), metadata (jsonb, nullable) created_at diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index ee0db1e..434e2f0 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -24,18 +24,18 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc `DoctorProfile` —< `DoctorSpecialty` >— `Specialty` `AvailabilitySlot` (doctor's calendar) → `Appointment` → `IntakeForm`, `Consultation` `Consultation` → (outcome=referred) → `Referral` (snapshot!) → new `Appointment` -`Consultation` → `Prescription`(+`PrescriptionItem`) / `LabOrder`(+`LabResult`) +`Appointment` → `Consultation` → immutable `Prescription`(+`PrescriptionItem`) / `LabOrder`(+`LabResult`) `FulfillmentRecord` (facility-owned) references an order but doesn't mutate it. `User` → `EmailVerificationToken` (Phase 8, issue #34 — hashed/expiring, single-use; `User.emailVerifiedAt` gates booking + payment mutations, never login/browsing). `User` → `AccessLog` (Phase 8, issue #45 — PHI read-access log: who viewed which clinical record, when; fire-and-forget writes, fail-open). `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) ; `Prescription` or `LabOrder` → exactly one typed `PaymentIntent` FK (issue #85) ## 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. -- `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. +- Each issued order stores its selected facility and clinician-entered total in XLM stroops; a database check requires exactly one PaymentIntent order FK. - `release`/`refund` are admin-triggered HTTP actions in v1 (`POST /payments/intents/:id/{release,refund}`) — there's no `FulfillmentRecord.status == fulfilled` to trigger off of yet. Don't treat this as the final design. - The actual Stellar contract calls go through `@docforum/escrow-sdk` (`backend/src/modules/payments/services/escrow-sdk-loader.ts` — dynamic `import()`, the SDK is ESM-only and this backend is CommonJS). Never reimplement contract-calling logic in this repo. diff --git a/ROADMAP.md b/ROADMAP.md index e733a21..94fec48 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ROADMAP.md — DocForum -Last updated: 2026-10-19 +Last updated: 2026-10-17 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 @@ -61,7 +61,7 @@ for status and issue links. - [ ] Doc reconciliation: update ARCHITECTURE_ESSENTIALS.md "Known unresolved design gaps" once routing rule + expiry exist — part of issue #6's DoD. ## Phase 4 — Orders: prescriptions & lab orders -**Status: Not started (blocked by Phase 2, informed by Phase 3). Scoped into issues #9–#12.** +**Status: Partially implemented by issues #9 and #85; remaining issue work is tracked in #10–#12.** - [ ] `Prescription` + `PrescriptionItem` models, **append-only** (issuance creates a row; corrections use `supersedes_prescription_id` — ARCHITECTURE.md §4.5, hard rule in ARCHITECTURE_ESSENTIALS.md). Tracked as [issue #9](https://github.com/DocForum/docforum-core/issues/9) (High, 200 pts), which also covers `LabOrder`/`LabResult` (same append-only rule). - [ ] `LabOrder` + `LabResult` models, same append-only rule — folded into issue #9 above. - [ ] Specialist UI: issue prescription/lab order from a consultation. Tracked as [issue #10](https://github.com/DocForum/docforum-core/issues/10) (Medium, 150 pts) — depends on #9. @@ -77,16 +77,15 @@ for status and issue links. - [ ] Address PRD §8.1.6 (facility fulfillment integrity) — at minimum, restrict which facilities can be selected to the curated/admin-invited list; document what's still unresolved if full anti-fraud isn't in scope for v1 — addressed by issue #14's admin-only gating; document any remaining gap in that issue's PR. ## Phase 5.5 — Payments (consumer side) -**Status: Built; signer abstraction (#87) and payment config hardening (#88) in progress, 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. +**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] 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). -- [ ] [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. In progress on `feat/payments-config-hardening-network-guard-contract`. -- [ ] 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 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. - [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. - [x] On `FulfillmentRecord` reaching `fulfilled`, trigger SDK release call (`released` status) — except there's no `FulfillmentRecord` yet (Phase 5), so v1 exposes `POST /payments/intents/:id/release` as an admin-triggered action instead. ADR 0002's "don't let it drift into a manual/admin-triggered release" goal is **not yet met** — this is the honest interim, not the destination; revisit once Phase 5 exists. - [x] Refund path for rejected/expired orders — `POST /payments/intents/:id/refund`, same admin-triggered caveat as release. - [ ] 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). -- [ ] [Issue #87](https://github.com/DocForum/docforum-core/issues/87): platform signer interface with env and remote adapters, expected-address validation, and payment service integration. In progress on `feat/payments-platform-signer-abstraction`. - [ ] 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. @@ -121,7 +120,6 @@ for status and issue links. - [ ] Held-slot TTL/release job (ARCHITECTURE.md §6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) — unblocked now, operates on Phase 1's `AvailabilitySlot`. - [x] Access-control test suite: who may read intake, consultation and payment data. Integration tests (`tests/integration/access-control.test.mts`) establishing the test harness and access-control patterns for the three PHI data categories; intake/consultation test cases are scaffolded as placeholders until Phase 2/3 routes exist, payment-intent access cases exercise the live admin-gated routes. Tracked as [issue #44](https://github.com/DocForum/docforum-core/issues/44) — **Done**. - [ ] Privacy access-control audit: confirm intake/consultation notes are visible only per PRD §7 (patient, treating doctor(s) in-thread, fulfilling facility for that specific order only) — write tests, not just code review. Still blocked — meaningfully auditable only once Phase 3/4/5 access paths exist. Not yet scoped into an issue. -- [ ] PHI read-access log: record who viewed which clinical record. `AccessLog` table (viewer, record owner, entity, route, retention-managed), fire-and-forget fail-open writes off the read path, admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log` with all viewer PII stripped to a role. Tracked as [issue #45](https://github.com/DocForum/docforum-core/issues/45) (High) — **in review**, PR #149. Read-path wiring into the Phase 2/3/4/5 controllers (intake, consultation, prescriptions, lab orders) happens as those routes are built. - [ ] Decide and document what's explicitly out of scope for v1 launch vs. genuinely blocking (e.g., drug-interaction checking, PRD §8.2.8 — needs an explicit go/no-go, not silence). Tracked as [issue #20](https://github.com/DocForum/docforum-core/issues/20) (Trivial, 100 pts) — a decision task, unblocked now. ## Phase 9 — Deploy & CI @@ -408,5 +406,3 @@ 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 - Started issue #87 signer abstraction work: payment service signing now goes through an env/remote-capable PlatformSigner seam; production custody choice remains tracked by issue #25. diff --git a/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql b/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql new file mode 100644 index 0000000..3f399c4 --- /dev/null +++ b/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql @@ -0,0 +1,90 @@ +-- Issue #9: turn the order placeholders into immutable, relational records. +-- Issue #85: replace polymorphic PaymentIntent order references with real FKs. + +-- Consultation existed as a placeholder; attach it to exactly one appointment. +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM "Consultation") THEN + RAISE EXCEPTION 'Issue #9 migration requires placeholder Consultation rows to be reviewed before migration'; + END IF; +END $$; +ALTER TABLE "Consultation" ADD COLUMN "appointmentId" TEXT NOT NULL; +CREATE UNIQUE INDEX "Consultation_appointmentId_key" ON "Consultation"("appointmentId"); +ALTER TABLE "Consultation" ADD CONSTRAINT "Consultation_appointmentId_fkey" + FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- These order tables were Phase 0 placeholders. Refuse to reinterpret any +-- pre-existing clinical rows as valid issued records without their missing +-- patient, clinician and consultation provenance. +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM "Prescription") OR EXISTS (SELECT 1 FROM "PrescriptionItem") OR EXISTS (SELECT 1 FROM "LabOrder") OR EXISTS (SELECT 1 FROM "LabResult") THEN + RAISE EXCEPTION 'Issue #9 migration requires placeholder Prescription/LabOrder rows to be reviewed before migration'; + END IF; +END $$; + +ALTER TABLE "Prescription" + ADD COLUMN "consultationId" TEXT NOT NULL, + ADD COLUMN "patientProfileId" TEXT NOT NULL, + ADD COLUMN "prescribingDoctorId" TEXT NOT NULL, + ADD COLUMN "facilityProfileId" TEXT NOT NULL, + ADD COLUMN "amountStroops" BIGINT NOT NULL, + ADD COLUMN "supersedesPrescriptionId" TEXT; +ALTER TABLE "PrescriptionItem" ADD COLUMN "prescriptionId" TEXT NOT NULL; +ALTER TABLE "LabOrder" + ADD COLUMN "consultationId" TEXT NOT NULL, + ADD COLUMN "patientProfileId" TEXT NOT NULL, + ADD COLUMN "orderingDoctorId" TEXT NOT NULL, + ADD COLUMN "facilityProfileId" TEXT NOT NULL, + ADD COLUMN "amountStroops" BIGINT NOT NULL, + ADD COLUMN "notes" TEXT, + ADD COLUMN "supersedesLabOrderId" TEXT; +ALTER TABLE "LabResult" + ADD COLUMN "labOrderId" TEXT NOT NULL, + ADD COLUMN "fulfillingFacilityId" TEXT NOT NULL; + +CREATE UNIQUE INDEX "Prescription_supersedesPrescriptionId_key" ON "Prescription"("supersedesPrescriptionId"); +CREATE INDEX "Prescription_patientProfileId_createdAt_idx" ON "Prescription"("patientProfileId", "createdAt"); +CREATE INDEX "Prescription_consultationId_idx" ON "Prescription"("consultationId"); +CREATE INDEX "PrescriptionItem_prescriptionId_idx" ON "PrescriptionItem"("prescriptionId"); +CREATE UNIQUE INDEX "LabOrder_supersedesLabOrderId_key" ON "LabOrder"("supersedesLabOrderId"); +CREATE INDEX "LabOrder_patientProfileId_createdAt_idx" ON "LabOrder"("patientProfileId", "createdAt"); +CREATE INDEX "LabOrder_consultationId_idx" ON "LabOrder"("consultationId"); +CREATE UNIQUE INDEX "LabResult_labOrderId_key" ON "LabResult"("labOrderId"); + +ALTER TABLE "Prescription" ADD CONSTRAINT "Prescription_consultationId_fkey" FOREIGN KEY ("consultationId") REFERENCES "Consultation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Prescription" ADD CONSTRAINT "Prescription_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Prescription" ADD CONSTRAINT "Prescription_prescribingDoctorId_fkey" FOREIGN KEY ("prescribingDoctorId") REFERENCES "DoctorProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Prescription" ADD CONSTRAINT "Prescription_facilityProfileId_fkey" FOREIGN KEY ("facilityProfileId") REFERENCES "FacilityProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Prescription" ADD CONSTRAINT "Prescription_supersedesPrescriptionId_fkey" FOREIGN KEY ("supersedesPrescriptionId") REFERENCES "Prescription"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "PrescriptionItem" ADD CONSTRAINT "PrescriptionItem_prescriptionId_fkey" FOREIGN KEY ("prescriptionId") REFERENCES "Prescription"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "LabOrder" ADD CONSTRAINT "LabOrder_consultationId_fkey" FOREIGN KEY ("consultationId") REFERENCES "Consultation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "LabOrder" ADD CONSTRAINT "LabOrder_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "LabOrder" ADD CONSTRAINT "LabOrder_orderingDoctorId_fkey" FOREIGN KEY ("orderingDoctorId") REFERENCES "DoctorProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "LabOrder" ADD CONSTRAINT "LabOrder_facilityProfileId_fkey" FOREIGN KEY ("facilityProfileId") REFERENCES "FacilityProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "LabOrder" ADD CONSTRAINT "LabOrder_supersedesLabOrderId_fkey" FOREIGN KEY ("supersedesLabOrderId") REFERENCES "LabOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "LabResult" ADD CONSTRAINT "LabResult_labOrderId_fkey" FOREIGN KEY ("labOrderId") REFERENCES "LabOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "LabResult" ADD CONSTRAINT "LabResult_fulfillingFacilityId_fkey" FOREIGN KEY ("fulfillingFacilityId") REFERENCES "FacilityProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Remove preview/manual intents without a resolvable order. Never discard a +-- funded or terminal payment record; abort and require an operator review. +DELETE FROM "PaymentIntent" +WHERE "status" = 'created' + AND (("orderType" = 'prescription' AND NOT EXISTS (SELECT 1 FROM "Prescription" WHERE "Prescription"."id" = "PaymentIntent"."orderId")) + OR ("orderType" = 'lab_order' AND NOT EXISTS (SELECT 1 FROM "LabOrder" WHERE "LabOrder"."id" = "PaymentIntent"."orderId"))); +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM "PaymentIntent" + WHERE ("PaymentIntent"."orderType" = 'prescription' AND NOT EXISTS (SELECT 1 FROM "Prescription" WHERE "Prescription"."id" = "PaymentIntent"."orderId")) + OR ("PaymentIntent"."orderType" = 'lab_order' AND NOT EXISTS (SELECT 1 FROM "LabOrder" WHERE "LabOrder"."id" = "PaymentIntent"."orderId")) + ) THEN RAISE EXCEPTION 'Orphaned non-created PaymentIntent rows need manual reconciliation'; END IF; +END $$; + +ALTER TABLE "PaymentIntent" ADD COLUMN "prescriptionId" TEXT, ADD COLUMN "labOrderId" TEXT; +UPDATE "PaymentIntent" SET "prescriptionId" = "orderId" WHERE "orderType" = 'prescription'; +UPDATE "PaymentIntent" SET "labOrderId" = "orderId" WHERE "orderType" = 'lab_order'; +ALTER TABLE "PaymentIntent" DROP COLUMN "orderType", DROP COLUMN "orderId"; +DROP TYPE "OrderTypeForPayment"; +CREATE UNIQUE INDEX "PaymentIntent_prescriptionId_key" ON "PaymentIntent"("prescriptionId"); +CREATE UNIQUE INDEX "PaymentIntent_labOrderId_key" ON "PaymentIntent"("labOrderId"); +ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_exactly_one_order_check" + CHECK (("prescriptionId" IS NOT NULL) <> ("labOrderId" IS NOT NULL)); +ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_prescriptionId_fkey" FOREIGN KEY ("prescriptionId") REFERENCES "Prescription"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_labOrderId_fkey" FOREIGN KEY ("labOrderId") REFERENCES "LabOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2822286..4c08208 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -153,13 +153,6 @@ model EmailVerificationToken { @@index([userId]) } -// Phase 8 (issue #39): admin invites. Admin accounts are "seeded or -// invited" (ROADMAP Phase 8) and there was no supported path for either. -// Only the SHA-256 hash of the token is stored — a database leak must not -// hand out working invite links — and every invite is single-use and -// expiring. Admin-only scope for now: PRD OQ-2's facility invites will need -// their own profile fields (name/type) at redemption, so they are -// deliberately not folded in here. model AdminInvite { id String @id @default(uuid()) email String @@ -204,21 +197,23 @@ model LoginAttempt { } model PatientProfile { - id String @id @default(uuid()) - userId String @unique - fullName String - dateOfBirth DateTime? + id String @id @default(uuid()) + userId String @unique + fullName String + dateOfBirth DateTime? // Nullable, no FK relation on purpose — the minor/dependent consent and // account-ownership model is an explicit open PRD question (§8.2.5), not // resolved here. Don't build on top of this field assuming it means // anything enforced yet. guardianUserId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) appointments Appointment[] paymentIntents PaymentIntent[] + prescriptions Prescription[] + labOrders LabOrder[] dataExports PatientExport[] // Phase 8 (issue #45): who viewed this patient's records. @@ -304,6 +299,8 @@ model DoctorProfile { specialties DoctorSpecialty[] availabilitySlots AvailabilitySlot[] appointments Appointment[] + prescriptions Prescription[] + labOrders LabOrder[] @@index([verificationStatus]) } @@ -337,6 +334,9 @@ model FacilityProfile { user User @relation(fields: [userId], references: [id], onDelete: Cascade) capabilities FacilityCapability[] + prescriptions Prescription[] + labOrders LabOrder[] + labResults LabResult[] paymentIntents PaymentIntent[] walletLink WalletLink? } @@ -347,7 +347,7 @@ model FacilityCapability { capabilityType CapabilityType // e.g. a lab test category, or a medication class — free text on purpose, // no controlled vocabulary decided yet (out of Phase 1 scope). - detail String? + detail String? facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id], onDelete: Cascade) @@ -389,6 +389,7 @@ model Appointment { patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id]) availabilitySlot AvailabilitySlot @relation(fields: [availabilitySlotId], references: [id]) + consultation Consultation? // TODO Phase 2: intakeId (FK -> IntakeForm). PRD FR-5 requires intake // before confirmation, not enforced yet — booking works without it in @@ -398,17 +399,6 @@ model Appointment { } // ========================================================================= -// Phase 8 (issue #45): PHI read-access logging — AccessLog -// -// Answers "who looked at this" (distinct from AuditEvent's "who changed -// this"). Written asynchronously off the read path so it never adds to -// p95 latency. Fail-open: a logging failure never fails the read, but is -// counted and surfaced for alerting. -// -// patientProfileId is the record owner (whose data was viewed) — used by -// GET /patients/me/access-log. accessedBy is the viewer. -// ========================================================================= - model AccessLog { id String @id @default(uuid()) // Who viewed. SetNull on user delete keeps the log row (timestamp + @@ -435,7 +425,6 @@ model AccessLog { @@index([entityType, entityId]) } -// ========================================================================= // Below this line: Phase 0 structural placeholders, not yet implemented. // See ROADMAP.md for phase order. // ========================================================================= @@ -447,19 +436,23 @@ model IntakeForm { } model Consultation { - id String @id @default(uuid()) - outcome ConsultationOutcome? - notes String? - createdAt DateTime @default(now()) - // TODO: appointmentId (unique) + id String @id @default(uuid()) + appointmentId String @unique + outcome ConsultationOutcome? + notes String? + createdAt DateTime @default(now()) + + appointment Appointment @relation(fields: [appointmentId], references: [id]) + prescriptions Prescription[] + labOrders LabOrder[] } model Referral { - id String @id @default(uuid()) - status ReferralStatus @default(pending) - snapshotNotes String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + status ReferralStatus @default(pending) + snapshotNotes String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // TODO: sourceConsultationId, referringDoctorId, targetSpecialtyId, // targetDoctorId (nullable), snapshotIntakeId, resultingAppointmentId (nullable) // IMPORTANT: snapshotNotes / snapshotIntakeId must be copies, not live @@ -467,36 +460,81 @@ model Referral { } model Prescription { - id String @id @default(uuid()) - status OrderStatus @default(issued) - createdAt DateTime @default(now()) - // TODO: consultationId, patientProfileId, prescribingDoctorId, supersedesPrescriptionId (nullable, self-relation) + id String @id @default(uuid()) + consultationId String + patientProfileId String + prescribingDoctorId String + facilityProfileId String + amountStroops BigInt + status OrderStatus @default(issued) + supersedesPrescriptionId String? + createdAt DateTime @default(now()) + + consultation Consultation @relation(fields: [consultationId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + prescribingDoctor DoctorProfile @relation(fields: [prescribingDoctorId], references: [id]) + facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) + supersedes Prescription? @relation("PrescriptionCorrections", fields: [supersedesPrescriptionId], references: [id]) + correctedBy Prescription? @relation("PrescriptionCorrections") + items PrescriptionItem[] + paymentIntent PaymentIntent? + + @@unique([supersedesPrescriptionId]) + @@index([patientProfileId, createdAt]) + @@index([consultationId]) } model PrescriptionItem { - id String @id @default(uuid()) - drugName String - dosage String - frequency String - duration String - // TODO: prescriptionId + id String @id @default(uuid()) + prescriptionId String + drugName String + dosage String + frequency String + duration String + + prescription Prescription @relation(fields: [prescriptionId], references: [id]) + + @@index([prescriptionId]) } model LabOrder { - id String @id @default(uuid()) - testType String - urgency Urgency @default(routine) - status OrderStatus @default(issued) - createdAt DateTime @default(now()) - // TODO: consultationId, patientProfileId, orderingDoctorId, supersedesLabOrderId (nullable, self-relation) + id String @id @default(uuid()) + consultationId String + patientProfileId String + orderingDoctorId String + facilityProfileId String + amountStroops BigInt + testType String + urgency Urgency @default(routine) + notes String? + status OrderStatus @default(issued) + supersedesLabOrderId String? + createdAt DateTime @default(now()) + + consultation Consultation @relation(fields: [consultationId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + orderingDoctor DoctorProfile @relation(fields: [orderingDoctorId], references: [id]) + facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) + supersedes LabOrder? @relation("LabOrderCorrections", fields: [supersedesLabOrderId], references: [id]) + correctedBy LabOrder? @relation("LabOrderCorrections") + result LabResult? + paymentIntent PaymentIntent? + + @@unique([supersedesLabOrderId]) + @@index([patientProfileId, createdAt]) + @@index([consultationId]) } model LabResult { - id String @id @default(uuid()) - resultFileUrl String? - summary String? - createdAt DateTime @default(now()) - // TODO: labOrderId (unique), fulfillingFacilityId + id String @id @default(uuid()) + labOrderId String @unique + fulfillingFacilityId String + resultFileUrl String? + summary String? + createdAt DateTime @default(now()) + + labOrder LabOrder @relation(fields: [labOrderId], references: [id]) + fulfillingFacility FacilityProfile @relation(fields: [fulfillingFacilityId], references: [id]) } model FulfillmentRecord { @@ -513,11 +551,11 @@ model FulfillmentRecord { } model Notification { - id String @id @default(uuid()) + id String @id @default(uuid()) type String payload Json readAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) // TODO: userId } @@ -530,12 +568,8 @@ model Notification { // in this schema. See docs/adr/0002-stellar-escrow-for-fulfillment-payout.md // and docs/adr/0004-custodial-payments-v1.md. // -// Deliberately decoupled from Prescription/LabOrder/FulfillmentRecord -// below — those are still Phase 0 placeholders (Phase 4/5, not built). -// `orderType`/`orderId` stay opaque, unvalidated references rather than -// real foreign keys, so this phase is buildable independently rather than -// blocked behind two unbuilt phases. Revisit once Phase 4/5 are real — -// see ADR 0004's "Known gap." +// One of prescriptionId/labOrderId is required by a PostgreSQL check +// constraint. Amount and recipient are snapshotted from the issued order. enum PaymentIntentStatus { created @@ -545,21 +579,18 @@ enum PaymentIntentStatus { failed } -enum OrderTypeForPayment { - prescription - lab_order -} model PaymentIntent { - id String @id @default(uuid()) - orderType OrderTypeForPayment - // Opaque — Prescription/LabOrder don't exist as real records yet - // (Phase 4). Not validated against anything until then. - orderId String - status PaymentIntentStatus @default(created) + id String @id @default(uuid()) + prescriptionId String? @unique + labOrderId String? @unique + status PaymentIntentStatus @default(created) + + prescription Prescription? @relation(fields: [prescriptionId], references: [id]) + labOrder LabOrder? @relation(fields: [labOrderId], references: [id]) patientProfileId String - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) facilityId String facility FacilityProfile @relation(fields: [facilityId], references: [id]) diff --git a/backend/scripts/rotate-field-keys.mjs b/backend/scripts/rotate-field-keys.mjs index a5c32a3..8d8e13e 100644 --- a/backend/scripts/rotate-field-keys.mjs +++ b/backend/scripts/rotate-field-keys.mjs @@ -38,6 +38,7 @@ const ENCRYPTED_COLUMNS = [ // today (nullable/TODO FKs), but the columns exist in the schema — the // script handles them as soon as rows exist. { table: 'Consultation', column: 'notes' }, + { table: 'LabOrder', column: 'notes' }, { table: 'Referral', column: 'snapshotNotes' }, ]; diff --git a/backend/src/app.ts b/backend/src/app.ts index 337d4aa..b941ba5 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,6 +7,7 @@ import { authRouter } from './modules/auth/routes'; import { doctorsRouter } from './modules/doctors/routes'; import { availabilityRouter } from './modules/availability/routes'; import { appointmentsRouter } from './modules/appointments/routes'; +import { ordersRouter } from './modules/orders/routes'; import { paymentsRouter } from './modules/payments/routes'; import { patientsRouter } from './modules/patients/routes'; import { adminRouter } from './modules/users/routes'; @@ -41,6 +42,7 @@ export function createApp() { app.use('/doctors', doctorsRouter); app.use('/availability', availabilityRouter); app.use('/appointments', appointmentsRouter); + app.use('/orders', ordersRouter); app.use('/payments', paymentsRouter); app.use('/patients', patientsRouter); // Phase 8, issue #39 — admin account lifecycle (invites, deactivate/ diff --git a/backend/src/export-sections.ts b/backend/src/export-sections.ts index f4f7b1f..afff61d 100644 --- a/backend/src/export-sections.ts +++ b/backend/src/export-sections.ts @@ -12,12 +12,15 @@ import { accountExportSection } from './modules/auth/services'; import { appointmentsExportSection } from './modules/appointments/services'; import { paymentsExportSection } from './modules/payments/services'; import { profileExportSection, registerExportSection } from './modules/patients/services'; +import { prescriptionsExportSection, labOrdersExportSection } from './modules/orders/services'; export const PATIENT_EXPORT_SECTIONS = [ accountExportSection, profileExportSection, appointmentsExportSection, paymentsExportSection, + prescriptionsExportSection, + labOrdersExportSection, ]; export function registerPatientExportSections(): void { diff --git a/backend/src/modules/appointments/repositories/index.ts b/backend/src/modules/appointments/repositories/index.ts index 4f2be7f..d5c327c 100644 --- a/backend/src/modules/appointments/repositories/index.ts +++ b/backend/src/modules/appointments/repositories/index.ts @@ -29,3 +29,8 @@ export function findAppointmentsForPatient(patientProfileId: string) { orderBy: { createdAt: 'desc' }, }); } + +/** Minimal appointment context for a doctor-owned consultation. */ +export function findAppointmentDoctorContext(id: string) { + return prisma.appointment.findUnique({ where: { id }, include: { doctorProfile: { select: { id: true, userId: true, verificationStatus: true } } } }); +} \ No newline at end of file diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 1b59c5b..2a0ba1b 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -96,3 +96,13 @@ export const appointmentsExportSection: patientsService.PatientExportSection = { ); }, }; + +/** Resolves an appointment for a doctor-owned consultation without exposing + * the appointments repository across the module boundary. */ +export async function getDoctorAppointmentContext(userId: string, appointmentId: string) { + const appointment = await appointmentsRepo.findAppointmentDoctorContext(appointmentId); + if (!appointment) throw new HttpError(404, 'Appointment not found.'); + if (appointment.doctorProfile.userId !== userId) throw new HttpError(403, 'You can only issue orders for your own appointments.'); + if (appointment.doctorProfile.verificationStatus !== 'verified') throw new HttpError(403, 'A verified doctor profile is required to issue orders.'); + return { id: appointment.id, patientProfileId: appointment.patientProfileId, doctorProfileId: appointment.doctorProfileId }; +} \ No newline at end of file diff --git a/backend/src/modules/audit/services/index.ts b/backend/src/modules/audit/services/index.ts index cf89fbe..8f83200 100644 --- a/backend/src/modules/audit/services/index.ts +++ b/backend/src/modules/audit/services/index.ts @@ -25,7 +25,7 @@ export interface AuditActor { export const SYSTEM_ACTOR: AuditActor = { userId: null, role: null, requestId: null }; -export type AuditEntityType = 'Appointment' | 'DoctorProfile' | 'PaymentIntent'; +export type AuditEntityType = 'Appointment' | 'DoctorProfile' | 'PaymentIntent' | 'Consultation' | 'Prescription' | 'LabOrder'; export type AuditMetadataValue = string | number | boolean | null; export type AuditMetadata = Record; diff --git a/backend/src/modules/orders/controllers/index.ts b/backend/src/modules/orders/controllers/index.ts new file mode 100644 index 0000000..2913552 --- /dev/null +++ b/backend/src/modules/orders/controllers/index.ts @@ -0,0 +1,26 @@ +import type { Request, Response } from 'express'; +import * as ordersService from '../services'; +import { consultationSchema, labOrderSchema, prescriptionSchema } from '../validation'; +import { getParam } from '../../../request-params'; +import { auditActor } from '../../../middleware/request-id'; + +function serialize(value: unknown) { + return JSON.parse(JSON.stringify(value, (_key, field) => typeof field === 'bigint' ? field.toString() : field)); +} + +export async function createConsultation(req: Request, res: Response) { + const input = consultationSchema.parse(req.body); + res.status(201).json(serialize(await ordersService.createConsultation(auditActor(req), { + appointmentId: input.appointmentId, outcome: input.outcome ?? null, notes: input.notes ?? null, + }))); +} +export async function issuePrescription(req: Request, res: Response) { + const input = prescriptionSchema.parse(req.body); + const order = await ordersService.issuePrescription(auditActor(req), { ...input, consultationId: getParam(req, 'consultationId') }); + res.status(201).json(serialize(order)); +} +export async function issueLabOrder(req: Request, res: Response) { + const input = labOrderSchema.parse(req.body); + const order = await ordersService.issueLabOrder(auditActor(req), { ...input, consultationId: getParam(req, 'consultationId') }); + res.status(201).json(serialize(order)); +} \ No newline at end of file diff --git a/backend/src/modules/orders/repositories/index.ts b/backend/src/modules/orders/repositories/index.ts new file mode 100644 index 0000000..9861fcd --- /dev/null +++ b/backend/src/modules/orders/repositories/index.ts @@ -0,0 +1,50 @@ +import { prisma } from '../../../db/prisma-client'; +import type { Prisma } from '@prisma/client'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +export function createConsultation(db: DbClient, input: { appointmentId: string; outcome: 'resolved' | 'referred' | 'follow_up_needed' | null; notes: string | null }) { + return db.consultation.create({ data: input }); +} + +export function findConsultation(id: string, db: DbClient = prisma) { + return db.consultation.findUnique({ where: { id } }); +} + +export function findPrescriptionForCorrection(id: string, db: DbClient = prisma) { + return db.prescription.findUnique({ where: { id }, include: { correctedBy: true } }); +} + +export function createPrescription(db: DbClient, input: { + consultationId: string; patientProfileId: string; prescribingDoctorId: string; + facilityProfileId: string; amountStroops: bigint; supersedesPrescriptionId: string | null; + items: Array<{ drugName: string; dosage: string; frequency: string; duration: string }>; +}) { + const { items, ...data } = input; + return db.prescription.create({ data: { ...data, items: { create: items } }, include: { items: true } }); +} + +export function findLabOrderForCorrection(id: string, db: DbClient = prisma) { + return db.labOrder.findUnique({ where: { id }, include: { correctedBy: true } }); +} + +export function createLabOrder(db: DbClient, input: { + consultationId: string; patientProfileId: string; orderingDoctorId: string; + facilityProfileId: string; amountStroops: bigint; testType: string; urgency: 'routine' | 'urgent'; + notes: string | null; supersedesLabOrderId: string | null; +}) { + return db.labOrder.create({ data: input }); +} +export function findPrescriptionExportPage(patientProfileId: string, cursor: string | undefined, take: number) { + return prisma.prescription.findMany({ + where: { patientProfileId }, orderBy: [{ id: 'asc' }], take, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), include: { items: true }, + }); +} + +export function findLabOrderExportPage(patientProfileId: string, cursor: string | undefined, take: number) { + return prisma.labOrder.findMany({ + where: { patientProfileId }, orderBy: [{ id: 'asc' }], take, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), include: { result: true }, + }); +} \ No newline at end of file diff --git a/backend/src/modules/orders/routes/index.ts b/backend/src/modules/orders/routes/index.ts new file mode 100644 index 0000000..e68d7ea --- /dev/null +++ b/backend/src/modules/orders/routes/index.ts @@ -0,0 +1,7 @@ +import { Router } from 'express'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import * as ordersController from '../controllers'; +export const ordersRouter = Router(); +ordersRouter.post('/consultations', authenticate, requireRole('doctor'), requireVerifiedEmail, ordersController.createConsultation); +ordersRouter.post('/consultations/:consultationId/prescriptions', authenticate, requireRole('doctor'), requireVerifiedEmail, ordersController.issuePrescription); +ordersRouter.post('/consultations/:consultationId/lab-orders', authenticate, requireRole('doctor'), requireVerifiedEmail, ordersController.issueLabOrder); \ No newline at end of file diff --git a/backend/src/modules/orders/services/index.ts b/backend/src/modules/orders/services/index.ts new file mode 100644 index 0000000..63ba541 --- /dev/null +++ b/backend/src/modules/orders/services/index.ts @@ -0,0 +1,164 @@ +import { prisma } from '../../../db/prisma-client'; +import { HttpError } from '../../../http-error'; +import * as appointmentsService from '../../appointments/services'; +import * as paymentsService from '../../payments/services'; +import * as auditService from '../../audit/services'; +import { encryptField, decryptField } from '../../../utils/field-encryption'; +import * as ordersRepo from '../repositories'; +import type { Prisma } from '@prisma/client'; +import { paginate, type PatientExportSection } from '../../patients/services'; + +export async function createConsultation(actor: auditService.AuditActor, input: { + appointmentId: string; outcome: 'resolved' | 'referred' | 'follow_up_needed' | null; notes: string | null; +}) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + const appointment = await appointmentsService.getDoctorAppointmentContext(actor.userId, input.appointmentId); + try { + return await prisma.$transaction(async (tx) => { + const consultation = await ordersRepo.createConsultation(tx, { + ...input, + notes: encryptField(input.notes) ?? null, + }); + await auditService.record(tx, actor, { + entityType: 'Consultation', entityId: consultation.id, action: 'consultation.created', + fromState: null, toState: consultation.outcome, + metadata: { appointmentId: appointment.id }, + }); + return { ...consultation, notes: input.notes }; + }); + } catch (error) { + if (isUniqueViolation(error)) throw new HttpError(409, 'A consultation already exists for this appointment.'); + throw error; + } +} + +async function consultationContext(actor: auditService.AuditActor, id: string) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + const consultation = await ordersRepo.findConsultation(id); + if (!consultation) throw new HttpError(404, 'Consultation not found.'); + const appointment = await appointmentsService.getDoctorAppointmentContext(actor.userId, consultation.appointmentId); + return { consultation, appointment }; +} + +function isUniqueViolation(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && (error as { code: unknown }).code === 'P2002'; +} + +function requirePositiveAmount(amount: bigint) { + if (amount <= 0n) throw new HttpError(400, 'amountStroops must be positive.'); +} + +async function createOrderPayment(tx: Prisma.TransactionClient, input: { + kind: 'prescription' | 'lab_order'; orderId: string; patientProfileId: string; doctorProfileId: string; + facilityProfileId: string; amountStroops: bigint; supersedesId: string | null; +}) { + return paymentsService.createIntentForIssuedOrder(tx, input); +} + +export async function issuePrescription(actor: auditService.AuditActor, input: { + consultationId: string; facilityProfileId: string; amountStroops: bigint; + supersedesPrescriptionId?: string; + items: Array<{ drugName: string; dosage: string; frequency: string; duration: string }>; +}) { + requirePositiveAmount(input.amountStroops); + const { consultation, appointment } = await consultationContext(actor, input.consultationId); + let predecessor = null; + if (input.supersedesPrescriptionId) { + predecessor = await ordersRepo.findPrescriptionForCorrection(input.supersedesPrescriptionId); + if (!predecessor || predecessor.consultationId !== consultation.id || predecessor.correctedBy) { + throw new HttpError(409, 'The prescription to correct does not exist, belongs to another consultation, or already has a correction.'); + } + } + try { + return await prisma.$transaction(async (tx) => { + const order = await ordersRepo.createPrescription(tx, { + consultationId: consultation.id, patientProfileId: appointment.patientProfileId, + prescribingDoctorId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, + amountStroops: input.amountStroops, supersedesPrescriptionId: predecessor?.id ?? null, items: input.items, + }); + const paymentIntent = await createOrderPayment(tx, { + kind: 'prescription', orderId: order.id, patientProfileId: appointment.patientProfileId, + doctorProfileId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, + amountStroops: input.amountStroops, supersedesId: predecessor?.id ?? null, + }); + await auditService.record(tx, actor, { + entityType: 'Prescription', entityId: order.id, action: 'prescription.issued', + fromState: null, toState: order.status, + metadata: { consultationId: consultation.id, paymentIntentId: paymentIntent.id, amountStroops: input.amountStroops.toString() }, + }); + return { ...order, paymentIntent }; + }); + } catch (error) { + if (isUniqueViolation(error)) throw new HttpError(409, 'This order already has a correction or payment intent.'); + throw error; + } +} + +export async function issueLabOrder(actor: auditService.AuditActor, input: { + consultationId: string; facilityProfileId: string; amountStroops: bigint; testType: string; + urgency: 'routine' | 'urgent'; notes?: string; supersedesLabOrderId?: string; +}) { + requirePositiveAmount(input.amountStroops); + const { consultation, appointment } = await consultationContext(actor, input.consultationId); + let predecessor = null; + if (input.supersedesLabOrderId) { + predecessor = await ordersRepo.findLabOrderForCorrection(input.supersedesLabOrderId); + if (!predecessor || predecessor.consultationId !== consultation.id || predecessor.correctedBy) { + throw new HttpError(409, 'The lab order to correct does not exist, belongs to another consultation, or already has a correction.'); + } + } + try { + return await prisma.$transaction(async (tx) => { + const order = await ordersRepo.createLabOrder(tx, { + consultationId: consultation.id, patientProfileId: appointment.patientProfileId, + orderingDoctorId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, + amountStroops: input.amountStroops, testType: input.testType, urgency: input.urgency, + notes: encryptField(input.notes) ?? null, supersedesLabOrderId: predecessor?.id ?? null, + }); + const paymentIntent = await createOrderPayment(tx, { + kind: 'lab_order', orderId: order.id, patientProfileId: appointment.patientProfileId, + doctorProfileId: appointment.doctorProfileId, facilityProfileId: input.facilityProfileId, + amountStroops: input.amountStroops, supersedesId: predecessor?.id ?? null, + }); + await auditService.record(tx, actor, { + entityType: 'LabOrder', entityId: order.id, action: 'lab_order.issued', + fromState: null, toState: order.status, + metadata: { consultationId: consultation.id, paymentIntentId: paymentIntent.id, amountStroops: input.amountStroops.toString() }, + }); + return { ...order, notes: decryptField(order.notes), paymentIntent }; + }); + } catch (error) { + if (isUniqueViolation(error)) throw new HttpError(409, 'This order already has a correction or payment intent.'); + throw error; + } +} +export const prescriptionsExportSection: PatientExportSection = { + name: 'prescriptions', version: 1, + pages(subject, pageSize) { + return paginate(pageSize, + (cursor, take) => ordersRepo.findPrescriptionExportPage(subject.patientProfileId, cursor, take), + (rows) => rows.map((row) => ({ + id: row.id, consultationId: row.consultationId, prescribingDoctorId: row.prescribingDoctorId, + facilityProfileId: row.facilityProfileId, amountStroops: row.amountStroops.toString(), + supersedesPrescriptionId: row.supersedesPrescriptionId, createdAt: row.createdAt, + items: row.items.map(({ drugName, dosage, frequency, duration }) => ({ drugName, dosage, frequency, duration })), + })), + ); + }, +}; + +export const labOrdersExportSection: PatientExportSection = { + name: 'lab_orders', version: 1, + pages(subject, pageSize) { + return paginate(pageSize, + (cursor, take) => ordersRepo.findLabOrderExportPage(subject.patientProfileId, cursor, take), + (rows) => rows.map((row) => ({ + id: row.id, consultationId: row.consultationId, orderingDoctorId: row.orderingDoctorId, + facilityProfileId: row.facilityProfileId, amountStroops: row.amountStroops.toString(), + testType: row.testType, urgency: row.urgency, notes: decryptField(row.notes), + supersedesLabOrderId: row.supersedesLabOrderId, createdAt: row.createdAt, + result: row.result ? { resultFileUrl: row.result.resultFileUrl, summary: row.result.summary, createdAt: row.result.createdAt } : null, + })), + ); + }, +}; \ No newline at end of file diff --git a/backend/src/modules/orders/validation.ts b/backend/src/modules/orders/validation.ts new file mode 100644 index 0000000..f400a10 --- /dev/null +++ b/backend/src/modules/orders/validation.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +const amountStroops = z.string().regex(/^[1-9][0-9]*$/, 'amountStroops must be a positive integer string.').transform(BigInt); +const item = z.object({ drugName: z.string().trim().min(1).max(200), dosage: z.string().trim().min(1).max(200), frequency: z.string().trim().min(1).max(200), duration: z.string().trim().min(1).max(200) }); +export const consultationSchema = z.object({ appointmentId: z.string().uuid(), outcome: z.enum(['resolved', 'referred', 'follow_up_needed']).nullable().optional(), notes: z.string().max(20000).nullable().optional() }); +export const prescriptionSchema = z.object({ facilityProfileId: z.string().uuid(), amountStroops, supersedesPrescriptionId: z.string().uuid().optional(), items: z.array(item).min(1).max(100) }); +export const labOrderSchema = z.object({ facilityProfileId: z.string().uuid(), amountStroops, testType: z.string().trim().min(1).max(200), urgency: z.enum(['routine', 'urgent']).default('routine'), notes: z.string().max(20000).optional(), supersedesLabOrderId: z.string().uuid().optional() }); \ No newline at end of file diff --git a/backend/src/modules/payments/controllers/index.ts b/backend/src/modules/payments/controllers/index.ts index 14e1a7f..67d47c7 100644 --- a/backend/src/modules/payments/controllers/index.ts +++ b/backend/src/modules/payments/controllers/index.ts @@ -1,6 +1,6 @@ import type { Request, Response } from 'express'; import * as paymentsService from '../services'; -import { linkWalletSchema, createPaymentIntentSchema } from '../validation'; +import { linkWalletSchema } from '../validation'; import { HttpError } from '../../../http-error'; import { getParam } from '../../../request-params'; import { auditActor } from '../../../middleware/request-id'; @@ -9,8 +9,8 @@ import { auditActor } from '../../../middleware/request-id'; // here that carries one converts to a string explicitly. function serializeIntent(intent: { id: string; - orderType: string; - orderId: string; + prescriptionId: string | null; + labOrderId: string | null; status: string; patientProfileId: string; facilityId: string; @@ -44,12 +44,6 @@ export async function getOwnWallet(req: Request, res: Response) { res.status(200).json(link); } -export async function createIntent(req: Request, res: Response) { - const input = createPaymentIntentSchema.parse(req.body); - const intent = await paymentsService.createPaymentIntent(input); - res.status(201).json(serializeIntent(intent)); -} - export async function getIntent(req: Request, res: Response) { const id = getParam(req, 'id'); const intent = await paymentsService.getPaymentIntent(id); diff --git a/backend/src/modules/payments/repositories/index.ts b/backend/src/modules/payments/repositories/index.ts index bfc8c32..affe04d 100644 --- a/backend/src/modules/payments/repositories/index.ts +++ b/backend/src/modules/payments/repositories/index.ts @@ -22,16 +22,6 @@ export function findWalletLinkByFacilityId(facilityId: string) { return prisma.walletLink.findUnique({ where: { facilityId } }); } -export function createPaymentIntent(input: { - orderType: 'prescription' | 'lab_order'; - orderId: string; - patientProfileId: string; - facilityId: string; - amountStroops: bigint; -}) { - return prisma.paymentIntent.create({ data: input }); -} - export function findPaymentIntentById(id: string) { return prisma.paymentIntent.findUnique({ where: { id } }); } diff --git a/backend/src/modules/payments/routes/index.ts b/backend/src/modules/payments/routes/index.ts index 7f80503..f49e167 100644 --- a/backend/src/modules/payments/routes/index.ts +++ b/backend/src/modules/payments/routes/index.ts @@ -14,16 +14,6 @@ paymentsRouter.post( ); paymentsRouter.get('/wallet-link', authenticate, requireRole('facility'), paymentsController.getOwnWallet); -// v1 (custodial, admin-triggered — see docs/adr/0004-custodial-payments-v1.md -// "Consequences"): Phase 4/5 (Orders/Fulfillment) don't exist yet, so -// there's no automatic trigger to wire these to. Restricted to admin -// rather than exposed as an open action. -// -// Every money-moving route requires a verified address (Phase 8, issue -// #34) — including the admin-only ones, since an admin account that can't -// receive mail can't complete the account lifecycle (invites, deactivation -// notices) this phase adds either. -paymentsRouter.post('/intents', authenticate, requireRole('admin'), requireVerifiedEmail, paymentsController.createIntent); paymentsRouter.get('/intents/:id', authenticate, requireRole('admin'), paymentsController.getIntent); paymentsRouter.post( '/intents/:id/fund', diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index b555834..18c5705 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -17,6 +17,7 @@ import { getPlatformSigners } from './platform-signers'; import { paginate, type PatientExportSection } from '../../patients/services'; import { prisma } from '../../../db/prisma-client'; import * as auditService from '../../audit/services'; +import type { Prisma } from '@prisma/client'; // Types are inferred from the prisma instance; use 'as' assertions where needed // Test seam re-exported through the service surface (same convention as the @@ -80,17 +81,36 @@ export function getWalletLink(facilityId: string) { return paymentsRepo.findWalletLinkByFacilityId(facilityId); } -export interface CreatePaymentIntentInput { - orderType: 'prescription' | 'lab_order'; - orderId: string; - patientProfileId: string; - facilityId: string; - amountStroops: bigint; -} - -export function createPaymentIntent(input: CreatePaymentIntentInput) { +/** 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. */ +export async function createIntentForIssuedOrder(tx: Prisma.TransactionClient, input: { + kind: 'prescription' | 'lab_order'; orderId: string; patientProfileId: string; doctorProfileId: string; + facilityProfileId: string; amountStroops: bigint; supersedesId: string | null; +}) { if (input.amountStroops <= 0n) throw new HttpError(400, 'amountStroops must be positive.'); - return paymentsRepo.createPaymentIntent(input); + const facility = await tx.facilityProfile.findUnique({ where: { id: input.facilityProfileId }, select: { type: true } }); + const expectedType = input.kind === 'prescription' ? 'pharmacy' : 'lab'; + if (!facility || facility.type !== expectedType) throw new HttpError(400, `The selected facility must be a ${expectedType} facility.`); + + const orderPointer = input.kind === 'prescription' + ? { prescriptionId: input.orderId, labOrderId: null } + : { prescriptionId: null, labOrderId: input.orderId }; + if (input.supersedesId) { + const previous = await tx.paymentIntent.findFirst({ + where: input.kind === 'prescription' ? { prescriptionId: input.supersedesId } : { labOrderId: input.supersedesId }, + }); + 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: input.amountStroops }, + }); + 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: input.amountStroops }, + }); } export async function getPaymentIntent(id: string) { @@ -124,7 +144,8 @@ async function commitTransition( // Public, non-identifying references only: the escrow is on a public // ledger anyway. No patient data, no secrets. metadata: { - orderType: updated.orderType, + orderType: updated.prescriptionId ? 'prescription' : 'lab_order', + orderId: updated.prescriptionId ?? updated.labOrderId!, amountStroops: updated.amountStroops.toString(), escrowId: updated.escrowId?.toString() ?? null, stellarTxHash: updated.stellarTxHash, @@ -221,8 +242,8 @@ export const paymentsExportSection: PatientExportSection = { const facilityNames = new Map(facilities.map((facility) => [facility.id, facility.name])); return rows.map((row) => ({ id: row.id, - orderType: row.orderType, - orderId: row.orderId, + prescriptionId: row.prescriptionId, + labOrderId: row.labOrderId, status: row.status, amountStroops: row.amountStroops.toString(), facility: { id: row.facilityId, name: facilityNames.get(row.facilityId) ?? null }, diff --git a/backend/src/modules/payments/validation.ts b/backend/src/modules/payments/validation.ts index 3513c8d..5f6e4ce 100644 --- a/backend/src/modules/payments/validation.ts +++ b/backend/src/modules/payments/validation.ts @@ -8,20 +8,3 @@ export const linkWalletSchema = z.object({ .string() .regex(/^G[A-Z2-7]{55}$/, 'stellarPublicKey must be a valid Stellar public key (starts with G, 56 chars).'), }); - -export const createPaymentIntentSchema = z.object({ - orderType: z.enum(['prescription', 'lab_order']), - // Opaque — see schema.prisma comment. Not validated against a real - // Prescription/LabOrder record yet (Phase 4 doesn't exist). - 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 - .string() - .regex(/^[1-9][0-9]*$/, 'amountStroops must be a positive integer string.') - .transform((value) => BigInt(value)), -}); diff --git a/backend/tests/integration/access-control.test.mts b/backend/tests/integration/access-control.test.mts index bd46c8c..b89cf3b 100644 --- a/backend/tests/integration/access-control.test.mts +++ b/backend/tests/integration/access-control.test.mts @@ -57,7 +57,9 @@ const KNOWN_ROUTES = [ 'GET /appointments/mine', 'POST /payments/wallet-link', 'GET /payments/wallet-link', - 'POST /payments/intents', + 'POST /orders/consultations', + 'POST /orders/consultations/:consultationId/prescriptions', + 'POST /orders/consultations/:consultationId/lab-orders', 'GET /payments/intents/:id', 'POST /payments/intents/:id/fund', 'POST /payments/intents/:id/release', @@ -105,10 +107,14 @@ const ACCESS_MATRIX: MatrixRow[] = [ { method: 'POST', path: '/appointments', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, // patient unverified → 403 EMAIL_NOT_VERIFIED { method: 'GET', path: '/appointments/mine', expected: { anon: 401, patient: 200, doctor: 403, facility: 403, admin: 403 } }, + // Orders: doctor-only and verified-email gated. + { method: 'POST', path: '/orders/consultations', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + { method: 'POST', path: '/orders/consultations/:consultationId/prescriptions', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + { method: 'POST', path: '/orders/consultations/:consultationId/lab-orders', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + // Payments — admin-triggered in v1; wallet-link is facility-only { method: 'POST', path: '/payments/wallet-link', expected: { anon: 401, patient: 403, doctor: 403, facility: 400, admin: 403 } }, { method: 'GET', path: '/payments/wallet-link', expected: { anon: 401, patient: 403, doctor: 403, facility: 404, admin: 403 } }, - { method: 'POST', path: '/payments/intents', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 400 } }, { method: 'GET', path: '/payments/intents/:id', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, { method: 'POST', path: '/payments/intents/:id/fund', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, { method: 'POST', path: '/payments/intents/:id/release', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 404 } }, diff --git a/backend/tests/integration/audit.test.mts b/backend/tests/integration/audit.test.mts index 03bb722..62731c8 100644 --- a/backend/tests/integration/audit.test.mts +++ b/backend/tests/integration/audit.test.mts @@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; import EmbeddedPostgres from 'embedded-postgres'; +import { createOrderFixture, createPaymentIntentForFixture } from './order-fixtures.mjs'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const PORT = 5441; @@ -83,9 +84,9 @@ describe('AuditEvent foundation', () => { request(app).get('/admin/audit-events').query(query).set(adminAuth()); async function createIntent() { - return prisma.paymentIntent.create({ - data: { orderType: 'lab_order', orderId: `order-${Math.random()}`, patientProfileId: (await prisma.patientProfile.findFirstOrThrow()).id, facilityId, amountStroops: 5000n }, - }); + const patientProfileId = (await prisma.patientProfile.findFirstOrThrow()).id; + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 5000n, tag: `audit-${Math.random()}` }); + return createPaymentIntentForFixture(prisma, order); } beforeAll(async () => { @@ -300,7 +301,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', orderId: intent.labOrderId, amountStroops: '5000', 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/email-verification.test.mts b/backend/tests/integration/email-verification.test.mts index 0ba43e1..d3db5e8 100644 --- a/backend/tests/integration/email-verification.test.mts +++ b/backend/tests/integration/email-verification.test.mts @@ -256,16 +256,16 @@ describe('email verification', () => { const login = await request(app).post('/auth/login').send({ email, password: 'longenough123' }); const adminToken = login.body.accessToken as string; - const blocked = await request(app).post('/payments/intents').set('Authorization', `Bearer ${adminToken}`).send({}); + const blocked = await request(app).post('/payments/intents/00000000-0000-4000-8000-000000000000/fund').set('Authorization', `Bearer ${adminToken}`).send({}); expect(blocked.status).toBe(403); expect(blocked.body.code).toBe('EMAIL_NOT_VERIFIED'); await request(app).post('/auth/verify-email').send({ token: tokenFor(email) }); - // Past the guard it reaches body validation, which is the proof the + // Past the guard it reaches the intent lookup, which is the proof the // guard was what stopped it a moment ago. - const unblocked = await request(app).post('/payments/intents').set('Authorization', `Bearer ${adminToken}`).send({}); - expect(unblocked.status).toBe(400); + const unblocked = await request(app).post('/payments/intents/00000000-0000-4000-8000-000000000000/fund').set('Authorization', `Bearer ${adminToken}`).send({}); + expect(unblocked.status).toBe(404); expect(unblocked.body.code).toBeUndefined(); }); }); diff --git a/backend/tests/integration/order-fixtures.mts b/backend/tests/integration/order-fixtures.mts new file mode 100644 index 0000000..42cf62d --- /dev/null +++ b/backend/tests/integration/order-fixtures.mts @@ -0,0 +1,39 @@ +import type { PrismaClient } from '@prisma/client'; + +export async function createOrderFixture( + prisma: PrismaClient, + input: { patientProfileId: string; facilityProfileId: string; kind: 'prescription' | 'lab_order'; amountStroops: bigint; tag: string }, +) { + const doctorUser = await prisma.user.create({ data: { email: `order-doctor-${input.tag}@example.com`, passwordHash: 'unused', role: 'doctor' } }); + const doctor = await prisma.doctorProfile.create({ data: { userId: doctorUser.id, fullName: 'Order Test Doctor', verificationStatus: 'verified' } }); + const startTime = new Date(Date.now() + 60 * 60 * 1000); + const slot = await prisma.availabilitySlot.create({ data: { + doctorProfileId: doctor.id, startTime, endTime: new Date(startTime.getTime() + 30 * 60 * 1000), + } }); + const appointment = await prisma.appointment.create({ data: { + patientProfileId: input.patientProfileId, doctorProfileId: doctor.id, availabilitySlotId: slot.id, + } }); + const consultation = await prisma.consultation.create({ data: { appointmentId: appointment.id } }); + if (input.kind === 'prescription') { + const order = await prisma.prescription.create({ data: { + consultationId: consultation.id, patientProfileId: input.patientProfileId, prescribingDoctorId: doctor.id, + facilityProfileId: input.facilityProfileId, amountStroops: input.amountStroops, + items: { create: [{ drugName: 'Test medicine', dosage: '1 tablet', frequency: 'daily', duration: '5 days' }] }, + } }); + return { id: order.id, consultationId: consultation.id, doctorProfileId: doctor.id, patientProfileId: input.patientProfileId, facilityProfileId: input.facilityProfileId, kind: input.kind, amountStroops: input.amountStroops }; + } + const order = await prisma.labOrder.create({ data: { + consultationId: consultation.id, patientProfileId: input.patientProfileId, orderingDoctorId: doctor.id, + facilityProfileId: input.facilityProfileId, amountStroops: input.amountStroops, testType: 'Test panel', + } }); + return { id: order.id, consultationId: consultation.id, doctorProfileId: doctor.id, patientProfileId: input.patientProfileId, facilityProfileId: input.facilityProfileId, kind: input.kind, amountStroops: input.amountStroops }; +} + +export function createPaymentIntentForFixture(prisma: PrismaClient, order: Awaited>, status: 'created' | 'escrowed' = 'created') { + return prisma.paymentIntent.create({ data: { + 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, + } }); +} \ 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 abe4954..300e6fa 100644 --- a/backend/tests/integration/patient-export.test.mts +++ b/backend/tests/integration/patient-export.test.mts @@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; import EmbeddedPostgres from 'embedded-postgres'; +import { createOrderFixture, createPaymentIntentForFixture } from './order-fixtures.mjs'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const PORT = 5442; @@ -54,6 +55,8 @@ describe('patient data export', () => { let alice: { token: string; userId: string; profileId: string; email: string }; let bob: { token: string; userId: string; profileId: string; email: string }; let facilityId: string; + let aliceLabOrder: Awaited>; + let alicePrescription: Awaited>; async function signupVerified(role: 'patient' | 'doctor', label: string) { const email = `${label}-${runId}@example.com`; @@ -170,33 +173,16 @@ describe('patient data export', () => { facilityId = ( await prisma.facilityProfile.create({ data: { userId: facilityUser.id, name: 'Lagos Lab', type: 'lab' } }) ).id; - await prisma.paymentIntent.create({ - data: { - orderType: 'lab_order', - orderId: 'order-1', - patientProfileId: alice.profileId, - facilityId, - amountStroops: 12_345_678_901n, - status: 'escrowed', - escrowId: 42n, - stellarTxHash: 'a'.repeat(64), - sorobanContractId: 'CCONTRACT', - }, - }); - await prisma.paymentIntent.create({ - data: { orderType: 'prescription', orderId: 'order-2', patientProfileId: alice.profileId, facilityId, amountStroops: 5n }, - }); - // Bob's payment must never show up in Alice's export. - await prisma.paymentIntent.create({ - data: { - orderType: 'prescription', - orderId: 'bob-order', - patientProfileId: bob.profileId, - facilityId, - amountStroops: 9n, - stellarTxHash: 'b'.repeat(64), - }, - }); + aliceLabOrder = await createOrderFixture(prisma, { patientProfileId: alice.profileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 12_345_678_901n, tag: `alice-lab-${runId}` }); + const aliceLabPayment = await createPaymentIntentForFixture(prisma, aliceLabOrder, 'escrowed'); + await prisma.paymentIntent.update({ where: { id: aliceLabPayment.id }, data: { escrowId: 42n, stellarTxHash: 'a'.repeat(64), sorobanContractId: 'CCONTRACT' } }); + alicePrescription = await createOrderFixture(prisma, { patientProfileId: alice.profileId, facilityProfileId: facilityId, kind: 'prescription', amountStroops: 5n, tag: `alice-rx-${runId}` }); + await createPaymentIntentForFixture(prisma, alicePrescription); + 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), + } }); }, 180_000); afterAll(async () => { @@ -291,7 +277,7 @@ describe('patient data export', () => { expect(bundle.schemaVersion).toBe(1); expect(bundle.exportId).toBe(exportId); expect(new Date(bundle.generatedAt).toISOString()).toBe(bundle.generatedAt); - expect(Object.keys(bundle.sections)).toEqual(['account', 'appointments', 'payments', 'profile']); + expect(Object.keys(bundle.sections)).toEqual(['account', 'appointments', 'lab_orders', 'payments', 'prescriptions', 'profile']); expect(bundle.sections.account.items).toEqual([ expect.objectContaining({ id: alice.userId, email: alice.email, role: 'patient' }), @@ -301,10 +287,11 @@ describe('patient data export', () => { ]); const appointments = bundle.sections.appointments.items; - expect(appointments).toHaveLength(21); - expect(new Set(appointments.map((a: { id: string }) => a.id)).size).toBe(21); + expect(appointments).toHaveLength(23); + expect(new Set(appointments.map((a: { id: string }) => a.id)).size).toBe(23); + expect(appointments.filter((appointment: { doctor: { id: string; fullName: string } }) => appointment.doctor.id === doctorProfileId)).toHaveLength(21); + expect(appointments.filter((appointment: { doctor: { fullName: string } }) => appointment.doctor.fullName === 'Order Test Doctor')).toHaveLength(2); for (const appointment of appointments) { - expect(appointment.doctor).toEqual({ id: doctorProfileId, fullName: 'export-doctor Fullname' }); expect(appointment.slot.startTime).toBeTruthy(); expect(appointment.status).toBe('scheduled'); } @@ -312,7 +299,7 @@ describe('patient data export', () => { const payments = bundle.sections.payments.items; expect(payments).toHaveLength(2); expect(payments[0]).toMatchObject({ - orderType: 'lab_order', + labOrderId: aliceLabOrder.id, status: 'escrowed', amountStroops: '12345678901', escrowId: '42', @@ -320,7 +307,7 @@ describe('patient data export', () => { sorobanContractId: 'CCONTRACT', facility: { id: facilityId, name: 'Lagos Lab' }, }); - expect(payments[1]).toMatchObject({ orderType: 'prescription', stellarTxHash: null, escrowId: null }); + expect(payments[1]).toMatchObject({ prescriptionId: alicePrescription.id, stellarTxHash: null, escrowId: null }); // Nothing that isn't Alice's, and nothing operational or secret. const passwordHash = (await prisma.user.findUniqueOrThrow({ where: { id: alice.userId } })).passwordHash; diff --git a/backend/tests/integration/payments-bigint.test.mts b/backend/tests/integration/payments-bigint.test.mts index 1b4c0a3..a3fc4d6 100644 --- a/backend/tests/integration/payments-bigint.test.mts +++ b/backend/tests/integration/payments-bigint.test.mts @@ -26,6 +26,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import EmbeddedPostgres from 'embedded-postgres'; +import { createOrderFixture } from './order-fixtures.mjs'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const PORT = 5440; // unique per integration file — 5438 collides with access-control @@ -99,13 +100,13 @@ describe('PaymentIntent BigInt round-trip (Prisma 7 driver adapter)', () => { async function roundTrip(amountStroops: 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({ - orderType: 'lab_order', - orderId, - patientProfileId, - facilityId, - amountStroops, + const order = await createOrderFixture(prisma, { + patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops, 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, + })); expect(created.amountStroops).toBe(amountStroops); // Re-read from the database, bypassing the in-memory object the create diff --git a/backend/tests/testnet-integration/payments.test.mts b/backend/tests/testnet-integration/payments.test.mts index 3ae83ae..0a6fa22 100644 --- a/backend/tests/testnet-integration/payments.test.mts +++ b/backend/tests/testnet-integration/payments.test.mts @@ -27,6 +27,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import EmbeddedPostgres from 'embedded-postgres'; +import { createOrderFixture, createPaymentIntentForFixture } from '../integration/order-fixtures.mjs'; import { Keypair } from '@stellar/stellar-sdk'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -60,6 +61,7 @@ describe('payments module against live testnet', () => { let paymentsService: typeof import('../../src/modules/payments/services/index.js'); let facilityId: string; let patientProfileId: string; + let pharmacyFacilityId: string; beforeAll(async () => { await pg.initialise(); @@ -91,6 +93,11 @@ describe('payments module against live testnet', () => { }); facilityId = facility.id; + const pharmacyUser = await prisma.user.create({ data: { email: `pharmacy-${runId}@example.com`, passwordHash: 'unused', role: 'facility' } }); + const pharmacy = await prisma.facilityProfile.create({ data: { userId: pharmacyUser.id, name: 'Test Pharmacy', type: 'pharmacy' } }); + pharmacyFacilityId = pharmacy.id; + await paymentsService.linkWallet(pharmacyFacilityId, (await fundedKeypair()).publicKey()); + const patientUser = await prisma.user.create({ data: { email: `patient-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, }); @@ -109,13 +116,8 @@ describe('payments module against live testnet', () => { }); it('funds and releases a payment intent end-to-end on testnet', async () => { - const intent = await paymentsService.createPaymentIntent({ - orderType: 'lab_order', - orderId: 'test-order-release', - patientProfileId, - facilityId, - amountStroops: 500_000n, // 0.05 XLM - }); + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 500_000n, tag: `release-${runId}` }); + const intent = await createPaymentIntentForFixture(prisma, order); expect(intent.status).toBe('created'); const funded = await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); @@ -128,13 +130,8 @@ describe('payments module against live testnet', () => { }, 60_000); it('funds and refunds a payment intent end-to-end on testnet', async () => { - const intent = await paymentsService.createPaymentIntent({ - orderType: 'prescription', - orderId: 'test-order-refund', - patientProfileId, - facilityId, - amountStroops: 500_000n, - }); + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: pharmacyFacilityId, kind: 'prescription', amountStroops: 500_000n, tag: `refund-${runId}` }); + const intent = await createPaymentIntentForFixture(prisma, order); await paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id); const refunded = await paymentsService.refundPaymentIntent(TEST_ACTOR, intent.id); @@ -149,13 +146,8 @@ describe('payments module against live testnet', () => { data: { userId: unlinkedFacilityUser.id, name: 'Unlinked Facility', type: 'pharmacy' }, }); - const intent = await paymentsService.createPaymentIntent({ - orderType: 'prescription', - orderId: 'test-order-unlinked', - patientProfileId, - facilityId: unlinkedFacility.id, - amountStroops: 500_000n, - }); + const order = await createOrderFixture(prisma, { patientProfileId, facilityProfileId: unlinkedFacility.id, kind: 'prescription', amountStroops: 500_000n, tag: `unlinked-${runId}` }); + const intent = await createPaymentIntentForFixture(prisma, order); await expect(paymentsService.fundPaymentIntent(TEST_ACTOR, intent.id)).rejects.toThrow(/no linked wallet/); }, 30_000); diff --git a/docs/adr/0004-custodial-payments-v1.md b/docs/adr/0004-custodial-payments-v1.md index 9e36a71..5b779c6 100644 --- a/docs/adr/0004-custodial-payments-v1.md +++ b/docs/adr/0004-custodial-payments-v1.md @@ -68,15 +68,21 @@ not modeled or assumed by this ADR either. are answered. If DocForum ever needs genuine non-custodial patient wallets, that's a v2 architectural change, not an extension of this one. -## Known gap - -`PaymentIntent.orderType`/`orderId` are opaque, unvalidated fields (see -`schema.prisma` comment) — `Prescription`/`LabOrder`/`FulfillmentRecord` -are still Phase 0 placeholders (Phase 4/5 aren't built). This phase is -deliberately buildable independently of those; once they're real, add -proper validation (does `orderId` actually exist, is it actually -assigned to `facilityId`) as a follow-up, not assumed done by this ADR. - +## Order linkage (resolved by issue #85) + +`PaymentIntent` has nullable `prescriptionId` and `labOrderId` foreign keys; +a PostgreSQL check constraint requires exactly one. Each order snapshots its +patient, issuing doctor, selected pharmacy/lab, and total amount. V1 has no +facility catalog or price service, so the issuing clinician supplies the total +in native XLM stroops at issuance. This is an explicit manual pricing source, +not a currency conversion or estimate. + +Issuing a correction creates a new order row. An unpaid (`created`) intent is +moved to the new row in the same transaction. An escrowed intent remains with +the original row and uses the existing admin-triggered refund action; this +preserves the current custodial v1 refund policy. The migration removes only +orphaned preview intents in `created` state and aborts if any funded or +terminal intent has no matching order. ## Consequences - The platform payer identity needs real funding to do anything (testnet diff --git a/docs/api/README.md b/docs/api/README.md index 5e67678..d3faa84 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -28,7 +28,7 @@ can log in, refresh, and browse; they get depend on a trustworthy contact address: - `POST /appointments` (booking) -- `POST /payments/wallet-link`, `POST /payments/intents`, +- `POST /payments/wallet-link`, `POST /payments/intents/:id/{fund,release,refund}` That guard reads the database rather than trusting a claim in the access @@ -151,6 +151,9 @@ drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. | `POST /appointments` | patient | `{ availabilitySlotId }` | **The concurrency-critical endpoint** — see `ARCHITECTURE.md` §5.1 and `backend/tests/integration/booking.test.mts`. `409` if the slot is no longer open. | | `GET /appointments/mine` | patient | — | Caller's own appointments, most recent first. | +## Orders (`/orders`) — `backend/src/modules/orders` + +Verified doctors can create a consultation with `POST /orders/consultations` (`appointmentId`, optional `outcome` and `notes`). Issue a prescription with `POST /orders/consultations/:consultationId/prescriptions` (`facilityProfileId`, positive integer `amountStroops`, and `items`), or a lab order with `POST /orders/consultations/:consultationId/lab-orders` (`facilityProfileId`, `amountStroops`, `testType`, optional `urgency` and `notes`). Issuance creates its payment intent in the same transaction. Corrections create superseding immutable orders: a created intent moves to the correction; an escrowed intent remains linked to the original and is handled by the existing admin refund action. ## Audit trail (`/admin/audit-events`) — `backend/src/modules/audit` Phase 8, issue #43. Every response carries an `X-Request-Id` header: a @@ -220,7 +223,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", "orderType", "orderId", "status", "amountStroops", "facility": { "id", "name" }, "escrowId", "stellarTxHash", "sorobanContractId", "createdAt", "updatedAt" }] }, + "payments": { "version": 1, "items": [{ "id", "prescriptionId", "labOrderId", "status", "amountStroops", "facility": { "id", "name" }, "escrowId", "stellarTxHash", "sorobanContractId", "createdAt", "updatedAt" }] }, "profile": { "version": 1, "items": [{ "id", "fullName", "dateOfBirth", "createdAt", "updatedAt" }] } } }