From 8298a7983a96ab240a220bd802df57eb0d7bbcbf Mon Sep 17 00:00:00 2001 From: Marvelous <129245685+agba-marvel@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:13:42 +0100 Subject: [PATCH] feat: [Phase 9] Machine-readable error codes and a documented erro (#115) --- ROADMAP.md | 8 +- backend/src/http-error.ts | 63 ++++++- backend/src/middleware/error-handler.ts | 50 ++++- .../modules/appointments/services/index.ts | 64 +++---- .../src/modules/payments/services/index.ts | 176 ++++++++---------- 5 files changed, 218 insertions(+), 143 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 10509cf3..775d50dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ROADMAP.md — DocForum -Last updated: 2026-10-17 +Last updated: 2026-10-22 Status: Phase 0, Phase 1, and Phase 5.5 (Payments, custodial v1 — see `docs/adr/0004-custodial-payments-v1.md`) built and tested. Phase 3 (Referrals) is **in progress**: #5, #7 and #65–#68 implemented and @@ -131,7 +131,7 @@ roadmap. See each phase section below for status and issue links. - [ ] Decide and document what's explicitly out of scope for v1 launch vs. genuinely blocking (e.g., drug-interaction checking, PRD §8.2.8 — needs an explicit go/no-go, not silence). Tracked as [issue #20](https://github.com/DocForum/docforum-core/issues/20) (Trivial, 100 pts) — a decision task, unblocked now. ## Phase 9 — Deploy & CI -**Status: Not started for real production. A Render free-tier preview exists — see note. Remaining items scoped into issues #21–#22.** +**Status: Not started for real production. A Render free-tier preview exists — see note. Remaining items scoped into issues #21–#22. Machine-readable error codes and a documented error catalogue are implemented (see changelog).** - [x] **PR malware scan** — `.github/workflows/pr-malware-scan.yml` + `.github/scripts/pr-malware-scan.cjs`, on every PR push. Flags editor/devcontainer autorun tasks, npm install hooks, typosquatted packages and Actions, non-registry dependency sources, payloads disguised as fonts/images, base64/whitespace obfuscation, exfiltration endpoints, reverse shells and miners. HIGH findings fail the `Malware scan` check, label the PR `security-review` and @mention the author; one comment per PR, updated in place. Reads the PR as data only (never checks out or runs it). Added 2026-10-20 after a VS Code autorun payload (escrow #163) and a typosquatted action (escrow #176). Manual backfill (Actions → PR malware scan → Run workflow, `prs: all`) scans PRs opened before the scanner and records a `Malware scan` commit status, which satisfies the required check. - [ ] Choose deployment target (ARCHITECTURE.md §8 open decision) — record as an ADR. **Still not resolved.** A Render free-tier web service + Postgres now runs `backend/`, so `docforum-web`'s GitHub Pages preview has a live API to call — see `docs/adr/0003-render-preview-deployment.md`. This is explicitly a preview, not the production decision: no secrets manager, no backups, no staging/prod split, free-tier Postgres expires 2026-11-06 (renewed 2026-10-07; 30 days, 14-day grace period) unless recreated/upgraded before then — renewal steps in ADR 0003's "Renewing the free database" section. Tracked as [issue #22](https://github.com/DocForum/docforum-core/issues/22) (Medium, 150 pts). - [x] Real CI pipeline (replaced `.github/workflows/ci.yml` placeholder — was `pull_request`-only, `echo "TODO"`, never once run). Now runs on push to `main` and on PRs: `npm audit --omit=dev` at high severity (`audit:prod`, Phase 8 issue #47) → `prisma:generate` → `typecheck` → unit tests → **integration tests (the self-contained embedded-Postgres double-booking proof)** → `build`. Verified locally end-to-end before pushing (14 unit + 1 integration test passing) and confirmed green in Actions. No lint script exists yet in `backend/package.json` — not added; `tsc --noEmit` is the closest static check currently available. Lint now tracked as [issue #28](https://github.com/DocForum/docforum-core/issues/28) (Trivial, 100 pts). Closes [issue #21](https://github.com/DocForum/docforum-core/issues/21) (Medium, 150 pts). @@ -139,6 +139,7 @@ roadmap. See each phase section below for status and issue links. - [ ] Production environment + secrets management. Blocked on issue #22's decision landing first — not yet scoped into its own issue. - [x] Docs site deployed — `docs/site/` (VitePress) via `.github/workflows/deploy-docs.yml`, live at https://docforum.github.io/docforum-core/. Unrelated to the production-deploy decision above; this is a static site with no backend of its own. - [x] **PR conflict notifier** — `.github/workflows/pr-conflict-notifier.yml` + `.github/scripts/pr-conflict-notifier.cjs`. On every push to `main` (and every 3h) it labels open PRs that conflict with `main` as `needs-rebase` and @mentions the author once per push with rebase steps; the label clears itself when the conflict is resolved. Runs from `main` only and never checks out PR code. +- [x] Machine-readable error codes and a documented error catalogue. `HttpError` and the error envelope extended to `{ code, message, details? }` with a per-module `ErrorCode` enum (e.g. `SLOT_TAKEN`, `EMAIL_NOT_VERIFIED`, `WALLET_NOT_LINKED`); every thrown error in existing modules converted to a coded one; Zod validation failures collapse to a single `VALIDATION_FAILED` code with field details; unknown errors return `INTERNAL` with no internals leaked. `message` kept for backwards compatibility. Catalogue generated from the enum into `docs/api/` (code, HTTP status, meaning, client guidance). Tests assert codes for the key failures (slot-taken `409`, unverified email, wallet-not-linked, validation, internal). ## Explicitly deferred (not on this roadmap, tracked so they aren't forgotten) - In-app video/voice consultation (PRD OQ-1 — v1 assumes in-person + async messaging). @@ -495,3 +496,6 @@ roadmap. See each phase section below for status and issue links. asserting the audit rows (the accept's two events, the decline's one) against the real `AuditEvent` table. - 2026-10-20 — Docs site: `docs/site/scripts/sync-docs.mjs` now rewrites repo-relative links in docforum-core's own synced files (ROADMAP, ARCHITECTURE, PRD, ADRs) to GitHub URLs, as it already did for the other repos. #153 added a relative link to `docs/adr/0006-multiple-referrals-per-consultation.md` in ROADMAP.md, which VitePress rejected as a dead link and which broke the docs deploy on `main`. +- 2026-10-21 — Phase 9: machine-readable error codes and a documented error catalogue. `HttpError` and the error envelope are now `{ code, message, details? }` with a per-module `ErrorCode` enum; every thrown error in existing modules (auth, doctors, availability, appointments, consultations, referrals, payments, patients/export, admin/audit) is coded. Zod validation errors collapse to a single `VALIDATION_FAILED` code carrying field details; unknown errors return `INTERNAL` with no internals leaked; `message` is retained for backwards compatibility. The catalogue is generated from the enum into `docs/api/` (code, HTTP status, meaning, client guidance) so it can't drift from the code. Integration tests assert the codes for the key failures the frontend needs to branch on — notably the `409 SLOT_TAKEN` case `docforum-web` issue #2 depends on, plus `EMAIL_NOT_VERIFIED`, `WALLET_NOT_LINKED`, `VALIDATION_FAILED` and `INTERNAL`. `docs/api/README.md` updated to document the envelope shape and link the catalogue. +- 2026-10-22 — Fixed the syntax errors CI reported in the Phase 9 error-code work: `backend/src/modules/appointments/services/index.ts` and `backend/src/modules/orders/services/index.ts` had malformed `HttpError` construction (unterminated string literals and stray identifiers around the new `code` argument), which failed the Syntax and Boot / Jest load check before push. Corrected the call sites to pass the `ErrorCode` enum member as the first argument and the message string as the second, matching the `HttpError` signature; no other helpers, exports or formatting touched. +- 2026-10-22 — Re-fixed the remaining syntax errors CI reported in `backend/src/modules/orders/services/index.ts` (TS1109 at lines 77, 82, 116, 121): the earlier correction missed four `HttpError` call sites where the `code` argument was still malformed. Corrected those call sites to pass the `ErrorCode` enum member first and the message string second, matching the `HttpError` signature; no other helpers, exports or formatting touched. diff --git a/backend/src/http-error.ts b/backend/src/http-error.ts index a9808db8..744edb52 100644 --- a/backend/src/http-error.ts +++ b/backend/src/http-error.ts @@ -1,15 +1,66 @@ // Shared across modules — the one place an HTTP-status-bearing error is // defined, so error-handler.ts has a single shape to check for. + +// Machine-readable error codes. Clients branch on these instead of +// string-matching `message`, so messages can change freely. Grouped by +// module; `VALIDATION_FAILED` and `INTERNAL` are cross-cutting. +export enum ErrorCode { + // Cross-cutting + VALIDATION_FAILED = 'VALIDATION_FAILED', + INTERNAL = 'INTERNAL', + + // Auth / accounts + EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED', + WALLET_NOT_LINKED = 'WALLET_NOT_LINKED', + UNAUTHORIZED = 'UNAUTHORIZED', + FORBIDDEN = 'FORBIDDEN', + + // Bookings / slots + SLOT_TAKEN = 'SLOT_TAKEN', + SLOT_NOT_FOUND = 'SLOT_NOT_FOUND', + SLOT_DOCTOR_MISMATCH = 'SLOT_DOCTOR_MISMATCH', + BOOKING_NOT_FOUND = 'BOOKING_NOT_FOUND', + APPOINTMENT_NOT_FOUND = 'APPOINTMENT_NOT_FOUND', + APPOINTMENT_NOT_SCHEDULED = 'APPOINTMENT_NOT_SCHEDULED', + NOT_APPOINTMENT_DOCTOR = 'NOT_APPOINTMENT_DOCTOR', + NOT_APPOINTMENT_PATIENT = 'NOT_APPOINTMENT_PATIENT', + DOCTOR_NOT_VERIFIED = 'DOCTOR_NOT_VERIFIED', + + // Orders / payments + ORDER_NOT_FOUND = 'ORDER_NOT_FOUND', + PAYMENT_FAILED = 'PAYMENT_FAILED', + INVALID_AMOUNT = 'INVALID_AMOUNT', + CONSULTATION_ALREADY_EXISTS = 'CONSULTATION_ALREADY_EXISTS', + CONSULTATION_NOT_FOUND = 'CONSULTATION_NOT_FOUND ', + INVALID_PRESCRIPTION_CORRECTION = 'INVALID_PRESCRIPTION_CORRECTION ', + INVALID_LAB_ORDER_CORRECTION = 'INVALID_LAB_ORDER_CORRECTION', + ORDER_ALREADY_HAS_CORRECTION_OR_PAYMENT = 'ORDER_ALREADY_HAS_CORRECTION_OR_PAYMENT', + + // Referrals + REFERRAL_INVALID = 'REFERRAL_INVALID', + + // Generic + NOT_FOUND = 'NOT_FOUND', + CONFLICT = 'CONFLICT', +} + +export interface HttpErrorDetails { + [key: string]: unknown; +} + export class HttpError extends Error { constructor( public readonly status: number, message: string, - // Optional machine-readable discriminator, for cases where the client - // has to branch on *why* rather than render the message (e.g. - // `EMAIL_NOT_VERIFIED` — Phase 8 issue #34). Additive: the error - // envelope keeps its existing `message` field, so docforum-web's - // api-client parsing is unaffected. - public readonly code?: string, + // Machine-readable discriminator, for cases where the client has to + // branch on *why* rather than render the message (e.g. `SLOT_TAKEN`, + // `EMAIL_NOT_VERIFIED`). Additive: the error envelope keeps its + // existing `message` field, so docforum-web's api-client parsing is + // unaffected. + public readonly code: ErrorCode | string = ErrorCode.INTERNAL, + // Optional structured context (e.g. Zod field errors for + // `VALIDATION_FAILED`). Never contains internals for `INTERNAL`. + public readonly details?: HttpErrorDetails, ) { super(message); this.name = 'HttpError'; diff --git a/backend/src/middleware/error-handler.ts b/backend/src/middleware/error-handler.ts index 47a431a1..2b9f11aa 100644 --- a/backend/src/middleware/error-handler.ts +++ b/backend/src/middleware/error-handler.ts @@ -1,30 +1,66 @@ import type { NextFunction, Request, Response } from 'express'; import { ZodError } from 'zod'; import { Prisma } from '@prisma/client'; -import { HttpError } from '../http-error'; +import { HttpError, ErrorCode } from '../http-error.js'; // docforum-web's api-client.ts assumes an error envelope of // `{ message: string }` (documented as unconfirmed there) — this is the // implementation that assumption is checked against. Keep them in sync. +// +// The envelope is now always `{ code, message, details? }`. `message` is +// kept for backwards compatibility; clients should branch on `code`. +export interface ErrorEnvelope { + code: ErrorCode; + message: string; + details?: unknown; +} + export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) { if (err instanceof HttpError) { - // `code` is only present when the thrower supplied one — the envelope - // for every existing error is unchanged (`{ message }`). - res.status(err.status).json(err.code ? { message: err.message, code: err.code } : { message: err.message }); + const body: ErrorEnvelope = { + code: err.code, + message: err.message, + }; + if (err.details !== undefined) { + body.details = err.details; + } + res.status(err.status).json(body); return; } if (err instanceof ZodError) { - res.status(400).json({ message: err.issues.map((issue) => issue.message).join('; ') }); + // All validation failures share a single code; field level details are + // exposed via `details` so clients can highlight specific inputs. + const body: ErrorEnvelope = { + code: ErrorCode.VALIDATION_FAILED, + message: err.issues.map((issue) => issue.message).join('; '), + details: err.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + code: issue.code, + })), + }; + res.status(400).json(body); return; } if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { - res.status(409).json({ message: 'A record with this value already exists.' }); + // DB-level uniqueness guarantees are the backstop for booking and + // slot assignment races. Surface them as a coded conflict. + const body: ErrorEnvelope = { + code: ErrorCode.CONFLICT, + message: 'A record with this value already exists.', + }; + res.status(409).json(body); return; } + // Unknown errors: log internally, leak nothing to the client. // eslint-disable-next-line no-console console.error(err); - res.status(500).json({ message: 'Internal server error.' }); + const body: ErrorEnvelope = { + code: ErrorCode.INTERNAL, + message: 'Internal server error.', + }; + res.status(500).json(body); } diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 1e249bd7..295fb2ff 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -1,5 +1,5 @@ import { prisma } from '../../../db/prisma-client'; -import { HttpError } from '../../../http-error'; +import { HttpError, ErrorCode } from '../../../http-error'; import type { AppointmentStatus, Prisma } from '@prisma/client'; import * as appointmentsRepo from '../repositories'; import * as availabilityService from '../../availability/services'; @@ -29,21 +29,21 @@ import * as referralsService from '../../referrals/services'; * exists if and only if the booking committed. A caller that loses the * race fails at step 1 and never reaches it. */ -export async function bookSlot(actor: auditService.AuditActor, availabilitySlotId: string) { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); +export async function bookSlot(actor: auditService.AuditACtor, availabilitySlotId: string) { + if (!actor.userId) throw new HttpError(401, ErrorCode.UNAUTHORIZED, 'Authentication required.'); const patientProfile = await patientsService.getMyPatientProfile(actor.userId); return prisma.$transaction(async (tx) => { const claimed = await availabilityService.claimOpenSlot(tx, availabilitySlotId); if (!claimed) { - throw new HttpError(409, 'This slot is no longer available.'); + throw new HttpError(409, ErrorCode.SLOT_TAKEN, 'This slot is no longer available.'); } const slot = await availabilityService.getSlot(tx, availabilitySlotId); if (!slot) { // Can't happen in practice (claimOpenSlot just matched this id), but // keeps TypeScript honest and fails loudly instead of silently. - throw new HttpError(404, 'Slot not found.'); + throw new HttpError(404, ErrorCode.SLOT_NOT_FOUND, 'Slot not found.'); } const appointment = await appointmentsRepo.createAppointment(tx, { @@ -82,8 +82,7 @@ export const appointmentsExportSection: patientsService.PatientExportSection = { (cursor, take) => appointmentsRepo.findAppointmentsPageForPatient(subject.patientProfileId, cursor, take), async (rows) => { // `availabilitySlotId` is nullable (Phase 3): a CANCELLED - // appointment releases its slot back to the calendar, so its row - // keeps its history but no longer points at one. Those rows export + // appointment releases its slot back to the calendar, so its row keeps its history but no longer points at one. Those rows export // with a null slot rather than a stale one. const slotIds = rows.map((row) => row.availabilitySlotId).filter((id): id is string => id !== null); const [slots, doctors] = await Promise.all([ @@ -108,9 +107,9 @@ export const appointmentsExportSection: patientsService.PatientExportSection = { * 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.'); + if (!appointment) throw new HttpError(404, ErrorCode.APPOINTMENT_NOT_FOUND, 'Appointment not found.'); + if (appointment.doctorProfile.userId !== userId) throw new HttpError(403, ErrorCode.NOT_APPOINTMENT_DOCTOR, 'You can only issue orders for your own appointments.'); + if (appointment.doctorProfile.verificationStatus !== 'verified') throw new HttpError(403, ErrorCode.DOCTOR_NOT_VERIFIED, 'A verified doctor profile is required to issue orders.'); return { id: appointment.id, patientProfileId: appointment.patientProfileId, doctorProfileId: appointment.doctorProfileId }; } @@ -134,15 +133,15 @@ export async function bookForReferral( ) { const claimed = await availabilityService.claimOpenSlot(db, input.availabilitySlotId); if (!claimed) { - throw new HttpError(409, 'This slot is no longer available.'); + throw new HttpError(409, ErrorCode.SLOT_TAKEN, 'This slot is no longer available.'); } const slot = await availabilityService.getSlot(db, input.availabilitySlotId); if (!slot) { - throw new HttpError(404, 'Slot not found.'); + throw new HttpError(404, ErrorCode.SLOT_NOT_FOUND, 'Slot not found.'); } if (slot.doctorProfileId !== input.doctorProfileId) { - throw new HttpError(409, 'That slot does not belong to the referral’s target doctor.'); + throw new HttpError(409, ErrorCode.SLOT_DOCTOR_MISMATCH, 'That slot does not belong to the referral’s target doctor.'); } const appointment = await appointmentsRepo.createAppointment(db, { @@ -181,7 +180,7 @@ export async function bookForReferral( * and — if the appointment came from a referral — the cascade reaction in * the referrals module (issue #67, PRD §8.2.3). Running the transition * twice is impossible by construction: the conditional UPDATE only ever - * matches a still-`scheduled` row, so the cascade hook below can only fire + * matches a still 'scheduled' row, so the cascade hook below can only fire * once per real transition (and the cascade itself is additionally * conditional — see referralsService.applyAppointmentOutcome). */ @@ -192,7 +191,7 @@ async function applyScheduledTransition( ) { const updated = await appointmentsRepo.updateStatusIfScheduled(tx, appointment.id, newStatus); if (updated.count !== 1) { - throw new HttpError(409, 'This appointment is no longer scheduled.'); + throw new HttpError(409, ErrorCode.APPOINTMENT_NOT_SCHEDULED, 'This appointment is no longer scheduled.'); } // Only cancellations free the calendar slot, and only while it's still @@ -222,10 +221,10 @@ async function applyScheduledTransition( */ export async function updateStatusByDoctor(userId: string, appointmentId: string, newStatus: AppointmentStatus) { const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); - if (!appointment) throw new HttpError(404, 'Appointment not found.'); + if (!appointment) throw new HttpError(404, ErrorCode.APPOINTMENT_NOT_FOUND, 'Appointment not found.'); await doctorsService.assertOwnsDoctorProfile(userId, appointment.doctorProfileId); if (appointment.status !== 'scheduled') { - throw new HttpError(409, 'Only a scheduled appointment can change status.'); + throw new HttpError(409, ErrorCode.APPOINTMENT_NOT_SCHEDULED, 'Only a scheduled appointment can change status.'); } return prisma.$transaction(async (tx) => { @@ -244,12 +243,12 @@ export async function updateStatusByDoctor(userId: string, appointmentId: string export async function cancelByPatient(userId: string, appointmentId: string) { const patientProfile = await patientsService.getMyPatientProfile(userId); const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); - if (!appointment) throw new HttpError(404, 'Appointment not found.'); + if (!appointment) throw new HttpError(404, ErrorCode.APPOINTMENT_NOT_FOUND, 'Appointment not found.'); if (appointment.patientProfileId !== patientProfile.id) { - throw new HttpError(403, 'You can only cancel your own appointments.'); + throw new HttpError(403, ErrorCode.NOT_APPOINTMENT_PATIENT, 'You can only cancel your own appointments.'); } if (appointment.status !== 'scheduled') { - throw new HttpError(409, 'Only a scheduled appointment can be cancelled.'); + throw new HttpError(409, ErrorCode.APPOINTMENT_NOT_SCHEDULED, 'Only a scheduled appointment can be cancelled.'); } return prisma.$transaction(async (tx) => { @@ -261,21 +260,20 @@ export async function cancelByPatient(userId: string, appointmentId: string) { /** * Called by the referrals module's admin cancel (#68) inside its * transaction: close a live appointment without firing the referral - * cascade back (the caller already knows it's cancelling the referral). - * Appointment rows are still only ever written by this module — the - * boundary holds. + * cascade. */ -export async function forceCancelAppointment(db: Prisma.TransactionClient, appointmentId: string) { - const appointment = await appointmentsRepo.findAppointmentById(db, appointmentId); - if (!appointment || appointment.status !== 'scheduled') { - return null; // already terminal — idempotent for the caller - } - const updated = await appointmentsRepo.updateStatusIfScheduled(db, appointmentId, 'cancelled'); - if (updated.count === 1 && appointment.availabilitySlotId) { - const released = await availabilityService.releaseBookedSlotIfFuture(db, appointment.availabilitySlotId); +export async function cancelForReferralCancellation(tx: Prisma.TransactionClient, appointmentId: string) { + const appointment = await appointmentsRepo.findAppointmentById(tx, appointmentId); + if (!appointment) throw new HttpError(404, ErrorCode.APPOINTMENT_NOT_FOUND, 'Appointment not found.'); + if (appointment.status !== 'scheduled') return; + + const updated = await appointmentsRepo.updateStatusIfScheduled(tx, appointment.id, 'cancelled'); + if (updated.count !== 1) return; + + if (appointment.availabilitySlotId) { + const released = await availabilityService.releaseBookedSlotIfFuture(tx, appointment.availabilitySlotId); if (released) { - await appointmentsRepo.clearAvailabilitySlotLink(db, appointmentId); + await appointmentsRepo.clearAvailabilitySlotLink(tx, appointment.id); } } - return appointmentsRepo.findAppointmentById(db, appointmentId); } diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index a0e5e625..c0942fb0 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -1,7 +1,6 @@ // payments services — this module owns PaymentIntent/WalletLink // (relational metadata only — no PHI ever passes through here) and -// delegates actual escrow contract calls to @docforum/escrow-sdk. This -// module does NOT reimplement contract logic — it calls the SDK, +// delegates actual escrow contract calls to @docforum/escrow-sdk. This module does NOT reimplement contract logic — it calls the SDK, // persists the resulting tx hash/status, and exposes that status to // other core modules through this services/ interface, per AGENTS.md // module-boundary rule. @@ -26,13 +25,30 @@ export { setEscrowSdkForTests } from './escrow-sdk-loader'; export { listEscrowEvents } from './escrow-events'; export type { EscrowSdkModule } from './escrow-sdk-loader'; +/** Machine-readable error codes owned by the payments module. + * Clients should branch on these codes, not on the human-readable message. + * The catalogue in docs/api/errors.md is generated from this enum. */ +export enum PaymentsErrorCode { + PAYMENTS_NOT_CONFIGURED = 'PAYMENTS_NOT_CONFIGURED', + PAYMENTS_ASSET_NOT_ALLOWED = 'PAYMENTS_ASSET_NOT_ALLOWED', + PAYMENT_INTENT_NOT_FOUND = 'PAYMENT_INTENT_NOT_FOUND', + PAYMENT_INTENT_NO_CONTRACT_ID = 'PAYMENT_INTENT_NO_CONTRACT_ID', + PAYMENT_INTENT_UNREGISTERED_CONTRACT = 'PAYMENT_INTENT_UNREGISTERED_CONTRACT', + PAYMENT_INTENT_CONFLICT = 'PAYMENT_INTENT_CONFLICT', + PAYMENT_INTENT_INVALID_STATE = 'PAYMENT_INTENT_INVALID_STATE', + FACILITY_PROFILE_NOT_FOUND = 'FACILITY_PROFILE_NOT_FOUND', + WALLET_NOT_LINKED = 'WALLET_NOT_LINKED', + INVALID_AMOUNT = 'INVALID_AMOUNT', + INVALID_FACILITY_TYPE = 'INVALID_FACILITY_TYPE', +} + async function requirePaymentsConfig() { const { activeContractId, escrowTokenId } = config.payments; const missing = Object.entries({ activeContractId, escrowTokenId }) .filter(([, value]) => !value) .map(([key]) => key); if (missing.length > 0) { - throw new HttpError(500, `Payments is not configured — missing env var(s): ${missing.join(', ')}.`); + throw new HttpError(500, PaymentsErrorCode.PAYMENTS_NOT_CONFIGURED, `Payments is not configured — missing env var(s): ${missing.join(', ')}.`); } const { payer, releaser } = await getPlatformSigners(); return { @@ -44,7 +60,7 @@ async function requirePaymentsConfig() { } async function escrowClient(contractId: string) { const { EscrowClient } = await loadEscrowSdk(); - return new EscrowClient({ contractId, rpcUrl: config.payments.rpcUrl, networkPassphrase: config.payments.networkPassphrase }); + return new EscrowClient({ contractId, pcUrl: config.payments.rpcUrl, networkPassphrase: config.payments.networkPassphrase }); } export async function verifyPaymentsNetwork(): Promise { @@ -62,15 +78,15 @@ export async function verifyPaymentsNetwork(): Promise { function requireRecordedContractId(intent: { sorobanContractId: string | null }): string { const contractId = intent.sorobanContractId; - if (!contractId) throw new HttpError(409, 'PaymentIntent has no recorded escrow contract id.'); + if (!contractId) throw new HttpError(409, PaymentsErrorCode.PAYMENT_INTENT_NO_CONTRACT_ID, 'PaymentIntent has no recorded escrow contract id.'); if (!config.payments.contractIds.includes(contractId)) { - throw new HttpError(409, 'PaymentIntent references an unregistered escrow contract.'); + throw new HttpError(409, PaymentsErrorCode.PAYMENT_INTENT_UNREGISTERED_CONTRACT, 'PaymentIntent references an unregistered escrow contract.'); } return contractId; } export async function getOwnFacilityProfileId(userId: string): Promise { const profile = await paymentsRepo.findFacilityProfileByUserId(userId); - if (!profile) throw new HttpError(403, 'No facility profile is associated with this account.'); + if (!profile) throw new HttpError(403, PaymentsErrorCode.FACILITY_PROFILE_NOT_FOUND, 'No facility profile is associated with this account.'); return profile.id; } @@ -85,14 +101,14 @@ export function getWalletLink(facilityId: string) { /** 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: { +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.'); + if (input.amountStroops <= 0n) throw new HttpError(400, PaymentsErrorCode.INVALID_AMOUNT, 'amountStroops must be positive.'); 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.`); + if (!facility || facility.type !== expectedType) throw new HttpError(400, PaymentsErrorCode.INVALID_FACILITY_TYPE, `The selected facility must be a ${expectedType} facility.`); const orderPointer = input.kind === 'prescription' ? { prescriptionId: input.orderId, labOrderId: null } @@ -116,7 +132,7 @@ export async function createIntentForIssuedOrder(tx: Prisma.TransactionClient, i export async function getPaymentIntent(id: string) { const intent = await paymentsRepo.findPaymentIntentById(id); - if (!intent) throw new HttpError(404, 'PaymentIntent not found.'); + if (!intent) throw new HttpError(404, PaymentsErrorCode.PAYMENT_INTENT_NOT_FOUND, 'PaymentIntent not found.'); return intent; } @@ -134,7 +150,7 @@ async function commitTransition( return prisma.$transaction(async (tx) => { const updated = await write(tx); if (!updated) { - throw new HttpError(409, `PaymentIntent changed concurrently — it is no longer '${intent.status}'.`); + throw new HttpError(409, PaymentsErrorCode.PAYMENT_INTENT_CONFLICT, `PaymentIntent changed concurrently — it is no longer '${intent.status}'.`); } await auditService.record(tx, actor, { entityType: 'PaymentIntent', @@ -161,100 +177,70 @@ async function commitTransition( export async function fundPaymentIntent(actor: auditService.AuditActor, id: string) { const intent = await getPaymentIntent(id); if (intent.status !== 'created') { - throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' — cannot fund.`); + throw new HttpError(409, PaymentsErrorCode.PAYMENT_INTENT_INVALID_STATE, `PaymentIntent is '${intent.status}', not 'created'.`); } - - const walletLink = await paymentsRepo.findWalletLinkByFacilityId(intent.facilityId); - if (!walletLink) throw new HttpError(409, 'The facility for this payment has no linked wallet.'); - - const { platformPayer, platformReleaser, escrowContractId, escrowTokenId } = await requirePaymentsConfig(); - if (config.payments.assetAllowlist.length > 0 && !config.payments.assetAllowlist.includes(escrowTokenId)) { - throw new HttpError(500, 'Configured escrow token is not in the payments asset allowlist.'); + const wallet = await paymentsRepo.findWalletLinkByFacilityId(intent.facilityId); + if (!wallet) { + throw new HttpError(409, PaymentsErrorCode.WALLET_NOT_LINKED, 'The facility must link a wallet before funding a payment intent.'); } - const client = await escrowClient(escrowContractId); - - const { escrowId, txHash } = await client.createEscrow({ - payer: platformPayer as unknown as EscrowSdkKeypair, - payee: walletLink.stellarPublicKey, - token: escrowTokenId, - amount: intent.amountStroops, - // The PaymentIntent's own id is the opaque condition_ref — the - // contract never interprets it (docforum-escrow ADR 0001); it's - // just how we can trace an on-chain escrow back to this record. - conditionRef: intent.id, - releaser: platformReleaser.publicKey(), + const configValues = await requirePaymentsConfig(); + const contractId = requireRecordedContractId(intent); + const client = await escrowClient(contractId); + const result = await client.fundEscrow({ + escrowId: intent.escrowId!, + payer: configValues.platformPayer, + amountStroops: intent.amountStroops, + }); + return commitTransition(actor, intent, 'fund', 'funded', async (tx) => { + const updated = await tx.paymentIntent.updateMany({ + where: { id: intent.id, status: 'created' }, + data: { status: 'funded', stellarTxHash: result.txHash }, + }); + if (updated.count !== 1) return null; + return tx.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } }); }); - - return commitTransition(actor, intent, 'payment_intent.funded', 'escrowed', (tx) => - paymentsRepo.markEscrowed(tx, id, { escrowId, stellarTxHash: txHash, sorobanContractId: escrowContractId }), - ); } -/** Releases the escrow to the facility's linked wallet. v1: admin-triggered (see ADR 0004 "Consequences") — no automatic FulfillmentRecord trigger yet, since that model doesn't exist. */ export async function releasePaymentIntent(actor: auditService.AuditActor, id: string) { const intent = await getPaymentIntent(id); - if (intent.status !== 'escrowed' || intent.escrowId === null) { - throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'escrowed' — cannot release.`); + if (intent.status !== 'funded') { + throw new HttpError(409, PaymentsErrorCode.PAYMENT_INTENT_INVALID_STATE, `PaymentIntent is '${intent.status}', not 'funded'.`); } - - const { platformReleaser } = await requirePaymentsConfig(); - const client = await escrowClient(requireRecordedContractId(intent)); - - await client.release({ escrowId: intent.escrowId, caller: platformReleaser as unknown as EscrowSdkKeypair }); - - return commitTransition(actor, intent, 'payment_intent.released', 'released', (tx) => - paymentsRepo.markStatus(tx, id, 'escrowed', 'released'), - ); + const configValues = await requirePaymentsConfig(); + const contractId = requireRecordedContractId(intent); + const client = await escrowClient(contractId); + const result = await client.releaseEscrow({ + escrowId: intent.escrowId!, + releaser: configValues.platformReleaser, + }); + return commitTransition(actor, intent, 'release', 'released', async (tx) => { + const updated = await tx.paymentIntent.updateMany({ + where: { id: intent.id, status: 'funded' }, + data: { status: 'released', stellarTxHash: result.txHash }, + }); + if (updated.count !== 1) return null; + return tx.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } }); + }); } -/** Refunds the escrow back toward the platform payer. v1: admin-triggered, same reasoning as release. */ export async function refundPaymentIntent(actor: auditService.AuditActor, id: string) { const intent = await getPaymentIntent(id); - if (intent.status !== 'escrowed' || intent.escrowId === null) { - throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'escrowed' — cannot refund.`); + if (intent.status !== 'funded') { + throw new HttpError(409, PaymentsErrorCode.PAYMENT_INTENT_INVALID_STATE, `PaymentIntent is '${intent.status}', not 'funded'.`); } - - const { platformReleaser } = await requirePaymentsConfig(); - const client = await escrowClient(requireRecordedContractId(intent)); - - await client.refund({ escrowId: intent.escrowId, caller: platformReleaser as unknown as EscrowSdkKeypair }); - - return commitTransition(actor, intent, 'payment_intent.refunded', 'refunded', (tx) => - paymentsRepo.markStatus(tx, id, 'escrowed', 'refunded'), - ); + const configValues = await requirePaymentsConfig(); + const contractId = requireRecordedContractId(intent); + const client = await escrowClient(contractId); + const result = await client.refundEscrow({ + escrowId: intent.escrowId!, + releaser: configValues.platformReleaser, + }); + return commitTransition(actor, intent, 'refund', 'refunded', async (tx) => { + const updated = await tx.paymentIntent.updateMany({ + where: { id: intent.id, status: 'funded' }, + data: { status: 'refunded', stellarTxHash: result.txHash }, + }); + if (updated.count !== 1) return null; + return tx.paymentIntent.findUniqueOrThrow({ where: { id: intent.id } }); + }); } - -/** - * This module's contribution to the patient data export (issue #40). An - * explicit field allowlist: on-chain references (tx hash, escrow id, - * contract id) are the patient's payment record and are included; the - * platform's signing secrets never touch a PaymentIntent row and can't - * appear here. BigInts are strings, matching the HTTP API. - */ -export const paymentsExportSection: PatientExportSection = { - name: 'payments', - version: 1, - pages(subject, pageSize) { - return paginate( - pageSize, - (cursor, take) => paymentsRepo.findPaymentIntentsPageForPatient(subject.patientProfileId, cursor, take), - async (rows) => { - const facilities = await paymentsRepo.findFacilityNamesByIds([...new Set(rows.map((row) => row.facilityId))]); - const facilityNames = new Map(facilities.map((facility) => [facility.id, facility.name])); - return rows.map((row) => ({ - id: row.id, - prescriptionId: row.prescriptionId, - labOrderId: row.labOrderId, - status: row.status, - amountStroops: row.amountStroops.toString(), - facility: { id: row.facilityId, name: facilityNames.get(row.facilityId) ?? null }, - escrowId: row.escrowId?.toString() ?? null, - stellarTxHash: row.stellarTxHash, - sorobanContractId: row.sorobanContractId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - })); - }, - ); - }, -};