From 107ff0ec2847c6b4a9a233463eb58fa6165b8af3 Mon Sep 17 00:00:00 2001 From: Hikmah Oladele Date: Mon, 19 Oct 2026 14:21:43 -0700 Subject: [PATCH 01/10] feat: link issued orders to payment intents --- ARCHITECTURE.md | 26 +-- ARCHITECTURE_ESSENTIALS.md | 6 +- ROADMAP.md | 6 +- .../migration.sql | 90 ++++++++++ backend/prisma/schema.prisma | 134 +++++++++----- backend/scripts/rotate-field-keys.mjs | 1 + backend/src/app.ts | 2 + backend/src/export-sections.ts | 3 +- .../appointments/repositories/index.ts | 5 + .../modules/appointments/services/index.ts | 10 ++ backend/src/modules/audit/services/index.ts | 2 +- .../src/modules/orders/controllers/index.ts | 26 +++ .../src/modules/orders/repositories/index.ts | 50 ++++++ backend/src/modules/orders/routes/index.ts | 7 + backend/src/modules/orders/services/index.ts | 164 ++++++++++++++++++ backend/src/modules/orders/validation.ts | 6 + .../src/modules/payments/controllers/index.ts | 12 +- .../modules/payments/repositories/index.ts | 10 -- backend/src/modules/payments/routes/index.ts | 10 -- .../src/modules/payments/services/index.ts | 45 +++-- backend/src/modules/payments/validation.ts | 17 -- backend/tests/integration/audit.test.mts | 9 +- backend/tests/integration/order-fixtures.mts | 39 +++++ .../tests/integration/patient-export.test.mts | 44 ++--- .../integration/payments-bigint.test.mts | 13 +- .../testnet-integration/payments.test.mts | 35 ++-- docs/adr/0004-custodial-payments-v1.md | 24 ++- docs/api/README.md | 7 +- 28 files changed, 610 insertions(+), 193 deletions(-) create mode 100644 backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql create mode 100644 backend/src/modules/orders/controllers/index.ts create mode 100644 backend/src/modules/orders/repositories/index.ts create mode 100644 backend/src/modules/orders/routes/index.ts create mode 100644 backend/src/modules/orders/services/index.ts create mode 100644 backend/src/modules/orders/validation.ts create mode 100644 backend/tests/integration/order-fixtures.mts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d7592346..496237ce 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 bdbb1d42..ceb1d433 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -24,17 +24,17 @@ 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). `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 dd2c6462..9a4a04ba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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,10 +77,10 @@ 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, 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 and automatic intent issuance implemented alongside the Phase 4 order foundation; 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). -- [ ] 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] 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. 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 00000000..b4c10b30 --- /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" pi +WHERE pi."status" = 'created' + AND ((pi."orderType" = 'prescription' AND NOT EXISTS (SELECT 1 FROM "Prescription" p WHERE p."id" = pi."orderId')) + OR (pi."orderType" = 'lab_order' AND NOT EXISTS (SELECT 1 FROM "LabOrder" l WHERE l."id" = pi."orderId'))); +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM "PaymentIntent" pi + WHERE (pi."orderType" = 'prescription' AND NOT EXISTS (SELECT 1 FROM "Prescription" p WHERE p."id" = pi."orderId')) + OR (pi."orderType" = 'lab_order' AND NOT EXISTS (SELECT 1 FROM "LabOrder" l WHERE l."id" = pi."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 7cfb28b6..eb65150d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -183,6 +183,8 @@ model PatientProfile { user User @relation(fields: [userId], references: [id], onDelete: Cascade) appointments Appointment[] paymentIntents PaymentIntent[] + prescriptions Prescription[] + labOrders LabOrder[] dataExports PatientExport[] } @@ -265,6 +267,8 @@ model DoctorProfile { specialties DoctorSpecialty[] availabilitySlots AvailabilitySlot[] appointments Appointment[] + prescriptions Prescription[] + labOrders LabOrder[] @@index([verificationStatus]) } @@ -298,6 +302,9 @@ model FacilityProfile { user User @relation(fields: [userId], references: [id], onDelete: Cascade) capabilities FacilityCapability[] + prescriptions Prescription[] + labOrders LabOrder[] + labResults LabResult[] paymentIntents PaymentIntent[] walletLink WalletLink? } @@ -350,6 +357,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 @@ -370,11 +378,15 @@ 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 { @@ -390,36 +402,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 { @@ -453,12 +510,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 @@ -468,18 +521,15 @@ 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]) diff --git a/backend/scripts/rotate-field-keys.mjs b/backend/scripts/rotate-field-keys.mjs index a5c32a39..8d8e13e5 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 0397740f..c13f9d0f 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 { registerPatientExportSections } from './export-sections'; @@ -40,6 +41,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); app.use('/admin', auditRouter); diff --git a/backend/src/export-sections.ts b/backend/src/export-sections.ts index f4f7b1f8..5b257d7f 100644 --- a/backend/src/export-sections.ts +++ b/backend/src/export-sections.ts @@ -12,12 +12,13 @@ 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, + paymentsExportSection,`r`n prescriptionsExportSection,`r`n labOrdersExportSection, ]; export function registerPatientExportSections(): void { diff --git a/backend/src/modules/appointments/repositories/index.ts b/backend/src/modules/appointments/repositories/index.ts index 4f2be7f8..d5c327cb 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 1b59c5ba..2a0ba1b5 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 daea0592..e2081f87 100644 --- a/backend/src/modules/audit/services/index.ts +++ b/backend/src/modules/audit/services/index.ts @@ -24,7 +24,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 00000000..29135523 --- /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 00000000..9861fcdb --- /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 00000000..e68d7ea0 --- /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 00000000..63ba541f --- /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 00000000..f400a106 --- /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 14e1a7f2..67d47c79 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 bfc8c327..affe04d2 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 7f80503b..f49e1670 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 ac64c261..7cc2d3d6 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -59,17 +59,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) { @@ -101,7 +120,7 @@ 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, + order: updated.prescriptionId ? { type: 'prescription', id: updated.prescriptionId } : { type: 'lab_order', id: updated.labOrderId }, amountStroops: updated.amountStroops.toString(), escrowId: updated.escrowId?.toString() ?? null, stellarTxHash: updated.stellarTxHash, @@ -195,8 +214,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 3513c8df..5f6e4ce5 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/audit.test.mts b/backend/tests/integration/audit.test.mts index 159f39df..b316a389 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; @@ -82,9 +83,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 () => { @@ -296,7 +297,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: { order: { type: 'lab_order', id: 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/order-fixtures.mts b/backend/tests/integration/order-fixtures.mts new file mode 100644 index 00000000..42cf62d7 --- /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 abe49540..ea8e9975 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; @@ -170,33 +171,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), - }, - }); + const 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' } }); + const 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 +275,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' }), @@ -312,7 +296,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 +304,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 7ef3eea5..3af3a406 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 = 5438; @@ -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 3ae83ae2..5edbaadf 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)), '../..'); @@ -59,7 +60,7 @@ describe('payments module against live testnet', () => { 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 patientProfileId: string;`r`n let pharmacyFacilityId: string; beforeAll(async () => { await pg.initialise(); @@ -91,6 +92,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 +115,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 +129,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 +145,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 fde218d8..52328b15 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 5e67678a..d3faa844 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" }] } } } From e5621e86e4359ab608325a6991130fc318d9ea5d Mon Sep 17 00:00:00 2001 From: Hikmah Oladele Date: Mon, 19 Oct 2026 23:17:23 -0700 Subject: [PATCH 02/10] fix TypeScript syntax in export sections --- backend/src/export-sections.ts | 4 +++- backend/tests/testnet-integration/payments.test.mts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/export-sections.ts b/backend/src/export-sections.ts index 5b257d7f..afff61d4 100644 --- a/backend/src/export-sections.ts +++ b/backend/src/export-sections.ts @@ -18,7 +18,9 @@ export const PATIENT_EXPORT_SECTIONS = [ accountExportSection, profileExportSection, appointmentsExportSection, - paymentsExportSection,`r`n prescriptionsExportSection,`r`n labOrdersExportSection, + paymentsExportSection, + prescriptionsExportSection, + labOrdersExportSection, ]; export function registerPatientExportSections(): void { diff --git a/backend/tests/testnet-integration/payments.test.mts b/backend/tests/testnet-integration/payments.test.mts index 5edbaadf..0a6fa22e 100644 --- a/backend/tests/testnet-integration/payments.test.mts +++ b/backend/tests/testnet-integration/payments.test.mts @@ -60,7 +60,8 @@ describe('payments module against live testnet', () => { 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;`r`n let pharmacyFacilityId: string; + let patientProfileId: string; + let pharmacyFacilityId: string; beforeAll(async () => { await pg.initialise(); From 20c8a0a15008f6623ed8101d70247c344c92fd7f Mon Sep 17 00:00:00 2001 From: Hikmah Oladele Date: Mon, 19 Oct 2026 23:18:55 -0700 Subject: [PATCH 03/10] restore merged Prisma schema models --- backend/prisma/schema.prisma | 68 +++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index eb65150d..4c082086 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -98,17 +98,28 @@ enum Urgency { // ========================================================================= model User { - id String @id @default(uuid()) - email String @unique - phone String? - passwordHash String - role Role + id String @id @default(uuid()) + email String @unique + phone String? + passwordHash String + role Role // Phase 8 (issue #34): null = never verified. Self-serve signup leaves // this null; existing rows were backfilled to `createdAt` in the // add_email_verification migration. Distinct from // DoctorProfile.verificationStatus, which is the separate, manual // admin verification of a doctor's credentials — do not conflate. emailVerifiedAt DateTime? + // Phase 8 (issue #39): null = active. Deactivating never deletes anything + // — no clinical or payment record is keyed to this flag, and none of the + // deactivation paths remove rows. Login, refresh and access tokens are all + // refused while this is set. + deactivatedAt DateTime? + // Phase 8 (issue #39): bumped when an account is deactivated/reactivated, + // so access tokens minted before that moment stop working immediately + // instead of living out their 15-minute TTL. There is still no general + // refresh-token revocation list (issue #18) — this is the narrow "revoke + // everything for one account" case that deactivation needs. + sessionsInvalidatedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -117,6 +128,10 @@ model User { facilityProfile FacilityProfile? emailVerificationTokens EmailVerificationToken[] + adminInvitesSent AdminInvite[] + // Phase 8 (issue #45): PHI read-access log rows this user wrote (as viewer). + // Opposite of AccessLog.accessedByUser. + accessLogs AccessLog[] @@index([role]) } @@ -138,6 +153,20 @@ model EmailVerificationToken { @@index([userId]) } +model AdminInvite { + id String @id @default(uuid()) + email String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + invitedByUserId String + createdAt DateTime @default(now()) + + invitedBy User @relation(fields: [invitedByUserId], references: [id], onDelete: Cascade) + + @@index([email]) +} + // Phase 8 (issue #35): login-throttle state. Deliberately in Postgres and // not in process memory — the backend runs as a single Render instance // today but must not assume that forever, and a restart must not hand an @@ -186,6 +215,9 @@ model PatientProfile { prescriptions Prescription[] labOrders LabOrder[] dataExports PatientExport[] + + // Phase 8 (issue #45): who viewed this patient's records. + accessLogs AccessLog[] } // Phase 8 (issue #40): right-of-access export jobs. One row per request; @@ -367,6 +399,32 @@ model Appointment { } // ========================================================================= +model AccessLog { + id String @id @default(uuid()) + // Who viewed. SetNull on user delete keeps the log row (timestamp + + // entity remain) while removing the PII pointer. + accessedBy String? + // Which patient's records were viewed (the record owner, not the viewer). + patientProfileId String? + // Entity that was read: 'IntakeForm' | 'Consultation' | 'Prescription' | + // 'LabOrder' | 'LabResult' | 'Referral' | 'PaymentIntent' | 'PatientProfile' + entityType String + entityId String + // The HTTP route that returned the payload (for correlating with access logs). + route String? + // Free-text detail — no PHI here, just context like "GET /appointments/mine". + detail String? + accessedAt DateTime @default(now()) + + accessedByUser User? @relation(fields: [accessedBy], references: [id], onDelete: SetNull) + patientProfile PatientProfile? @relation(fields: [patientProfileId], references: [id], onDelete: Cascade) + + @@index([accessedAt]) + @@index([accessedBy]) + @@index([patientProfileId, accessedAt]) + @@index([entityType, entityId]) +} + // Below this line: Phase 0 structural placeholders, not yet implemented. // See ROADMAP.md for phase order. // ========================================================================= From 894394ec5dd757746224df642391e7f9ba7776c2 Mon Sep 17 00:00:00 2001 From: Hikmah Oladele Date: Mon, 19 Oct 2026 23:20:26 -0700 Subject: [PATCH 04/10] fix remaining payment CI type errors --- backend/src/modules/payments/services/index.ts | 4 +++- backend/tests/integration/patient-export.test.mts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index 18a4cb55..3f5543a8 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -16,6 +16,7 @@ import { loadEscrowSdk } from './escrow-sdk-loader'; 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 @@ -143,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: { - order: updated.prescriptionId ? { type: 'prescription', id: updated.prescriptionId } : { type: 'lab_order', id: updated.labOrderId }, + orderType: updated.prescriptionId ? 'prescription' : 'lab_order', + orderId: updated.prescriptionId ?? updated.labOrderId!, amountStroops: updated.amountStroops.toString(), escrowId: updated.escrowId?.toString() ?? null, stellarTxHash: updated.stellarTxHash, diff --git a/backend/tests/integration/patient-export.test.mts b/backend/tests/integration/patient-export.test.mts index ea8e9975..5f53ed86 100644 --- a/backend/tests/integration/patient-export.test.mts +++ b/backend/tests/integration/patient-export.test.mts @@ -55,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`; @@ -171,10 +173,10 @@ describe('patient data export', () => { facilityId = ( await prisma.facilityProfile.create({ data: { userId: facilityUser.id, name: 'Lagos Lab', type: 'lab' } }) ).id; - const aliceLabOrder = await createOrderFixture(prisma, { patientProfileId: alice.profileId, facilityProfileId: facilityId, kind: 'lab_order', amountStroops: 12_345_678_901n, tag: `alice-lab-${runId}` }); + 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' } }); - const alicePrescription = await createOrderFixture(prisma, { patientProfileId: alice.profileId, facilityProfileId: facilityId, kind: 'prescription', amountStroops: 5n, tag: `alice-rx-${runId}` }); + 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: { From ef62d261b76133df4e1956a184c751b2755b58de Mon Sep 17 00:00:00 2001 From: Hikmah Oladele Date: Mon, 19 Oct 2026 23:23:07 -0700 Subject: [PATCH 05/10] fix payment order migration SQL aliases --- .../20261020000000_order_payment_intents/migration.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql b/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql index b4c10b30..d4e54381 100644 --- a/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql +++ b/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql @@ -65,13 +65,13 @@ ALTER TABLE "LabResult" ADD CONSTRAINT "LabResult_fulfillingFacilityId_fkey" FOR -- 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" pi +DELETE FROM "PaymentIntent" AS pi WHERE pi."status" = 'created' AND ((pi."orderType" = 'prescription' AND NOT EXISTS (SELECT 1 FROM "Prescription" p WHERE p."id" = pi."orderId')) OR (pi."orderType" = 'lab_order' AND NOT EXISTS (SELECT 1 FROM "LabOrder" l WHERE l."id" = pi."orderId'))); DO $$ BEGIN IF EXISTS ( - SELECT 1 FROM "PaymentIntent" pi + SELECT 1 FROM "PaymentIntent" AS pi WHERE (pi."orderType" = 'prescription' AND NOT EXISTS (SELECT 1 FROM "Prescription" p WHERE p."id" = pi."orderId')) OR (pi."orderType" = 'lab_order' AND NOT EXISTS (SELECT 1 FROM "LabOrder" l WHERE l."id" = pi."orderId')) ) THEN RAISE EXCEPTION 'Orphaned non-created PaymentIntent rows need manual reconciliation'; END IF; From 526ffa32a80bc2e8b3fce36443fc2561baeedd5f Mon Sep 17 00:00:00 2001 From: Hikmah Oladele Date: Mon, 19 Oct 2026 23:25:24 -0700 Subject: [PATCH 06/10] rewrite payment migration cleanup query --- .../migration.sql | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql b/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql index d4e54381..f3e08d09 100644 --- a/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql +++ b/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql @@ -65,15 +65,15 @@ ALTER TABLE "LabResult" ADD CONSTRAINT "LabResult_fulfillingFacilityId_fkey" FOR -- 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" AS pi -WHERE pi."status" = 'created' - AND ((pi."orderType" = 'prescription' AND NOT EXISTS (SELECT 1 FROM "Prescription" p WHERE p."id" = pi."orderId')) - OR (pi."orderType" = 'lab_order' AND NOT EXISTS (SELECT 1 FROM "LabOrder" l WHERE l."id" = pi."orderId'))); +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" AS pi - WHERE (pi."orderType" = 'prescription' AND NOT EXISTS (SELECT 1 FROM "Prescription" p WHERE p."id" = pi."orderId')) - OR (pi."orderType" = 'lab_order' AND NOT EXISTS (SELECT 1 FROM "LabOrder" l WHERE l."id" = pi."orderId')) + 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 $$; From 4c8254f49e1110b869077061f2e75ed7127f144c Mon Sep 17 00:00:00 2001 From: Hikmah Oladele Date: Mon, 19 Oct 2026 23:27:48 -0700 Subject: [PATCH 07/10] fix quoting in order cleanup migration --- .../20261020000000_order_payment_intents/migration.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql b/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql index f3e08d09..3f399c41 100644 --- a/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql +++ b/backend/prisma/migrations/20261020000000_order_payment_intents/migration.sql @@ -67,13 +67,13 @@ ALTER TABLE "LabResult" ADD CONSTRAINT "LabResult_fulfillingFacilityId_fkey" FOR -- 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'))); + 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')) + 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 $$; From a615c689ffcaa56e2258e8403d4da9c2b688221f Mon Sep 17 00:00:00 2001 From: Hikmah Oladele Date: Mon, 19 Oct 2026 23:33:40 -0700 Subject: [PATCH 08/10] update integration expectations for order issuance --- backend/tests/integration/access-control.test.mts | 10 ++++++++-- backend/tests/integration/audit.test.mts | 2 +- backend/tests/integration/email-verification.test.mts | 8 ++++---- backend/tests/integration/patient-export.test.mts | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/tests/integration/access-control.test.mts b/backend/tests/integration/access-control.test.mts index bd46c8c7..b89cf3b4 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 b35d7843..62731c85 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: { order: { type: 'lab_order', id: intent.labOrderId }, 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 0ba43e1e..d3db5e87 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/patient-export.test.mts b/backend/tests/integration/patient-export.test.mts index 5f53ed86..48bc463f 100644 --- a/backend/tests/integration/patient-export.test.mts +++ b/backend/tests/integration/patient-export.test.mts @@ -287,7 +287,7 @@ describe('patient data export', () => { ]); const appointments = bundle.sections.appointments.items; - expect(appointments).toHaveLength(21); + expect(appointments).toHaveLength(23); expect(new Set(appointments.map((a: { id: string }) => a.id)).size).toBe(21); for (const appointment of appointments) { expect(appointment.doctor).toEqual({ id: doctorProfileId, fullName: 'export-doctor Fullname' }); From a3ddd91c976655fa123f3725fbff73508f15e41d Mon Sep 17 00:00:00 2001 From: Hikmah Oladele Date: Tue, 20 Oct 2026 00:11:08 -0700 Subject: [PATCH 09/10] fix export appointment count assertion --- backend/tests/integration/patient-export.test.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/integration/patient-export.test.mts b/backend/tests/integration/patient-export.test.mts index 48bc463f..c5cc547a 100644 --- a/backend/tests/integration/patient-export.test.mts +++ b/backend/tests/integration/patient-export.test.mts @@ -288,7 +288,7 @@ describe('patient data export', () => { const appointments = bundle.sections.appointments.items; expect(appointments).toHaveLength(23); - expect(new Set(appointments.map((a: { id: string }) => a.id)).size).toBe(21); + expect(new Set(appointments.map((a: { id: string }) => a.id)).size).toBe(23); for (const appointment of appointments) { expect(appointment.doctor).toEqual({ id: doctorProfileId, fullName: 'export-doctor Fullname' }); expect(appointment.slot.startTime).toBeTruthy(); From 33f6217af8ae5261e608204399217d77d3c394df Mon Sep 17 00:00:00 2001 From: Hikmah Oladele Date: Tue, 20 Oct 2026 00:16:30 -0700 Subject: [PATCH 10/10] assert fixture doctors in patient export --- backend/tests/integration/patient-export.test.mts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/tests/integration/patient-export.test.mts b/backend/tests/integration/patient-export.test.mts index c5cc547a..300e6fa3 100644 --- a/backend/tests/integration/patient-export.test.mts +++ b/backend/tests/integration/patient-export.test.mts @@ -289,8 +289,9 @@ describe('patient data export', () => { const appointments = bundle.sections.appointments.items; 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'); }