diff --git a/ROADMAP.md b/ROADMAP.md index 81f1da6d..bb0f37b7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # ROADMAP.md — DocForum +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 @@ -138,7 +139,7 @@ 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: In progress. Structured JSON logging (request IDs + PHI redaction) landed; load tests (k6) for search, slot listing and booking contention added; automated Postgres backups with a tested restore drill are now implemented (issue #107). A Render free-tier preview exists — see note. Production deploy still Not started. Remaining items scoped into issues #21–#22.** +**Status: In progress. Structured JSON logging (request IDs + PHI redaction) landed; load tests (k6) for search, slot listing and booking contention added; automated Postgres backups with a tested restore drill are now implemented (issue #107). A Render free-tier preview exists — see note. Production deploy still Not started. Remaining items scoped into issues #21–#22. Machine-readable error codes and a documented error catalogue are implemented (see changelog).** - [x] Localization foundation ([issue #133](https://github.com/DocForum/docforum-core/issues/133)): persist a supported locale from `Accept-Language` at signup, format coded errors and password-reset notifications with ICU catalogs (English source + French stub), and enforce catalog key parity in CI. Other server messages remain English until their owning flows adopt the catalog. - [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. - [ ] End-to-end care-thread integration test: book → consult → refer → accept → order → fulfil → release. A single self-contained test (`backend/tests/integration/care-thread.test.mts`) driving the full thread through HTTP only, against embedded Postgres with the escrow SDK mocked; asserts the key invariants at each step (snapshot equality, append-only orders, facility-only fulfillment, payment status) rather than just status codes. Steps are phase-gated — each phase's steps are skipped with a reason until that phase lands, so the scaffold and already-built steps (booking, consult, refer, accept) run now and the rest enable as Phases 4/5 land. Runs as part of `test:integration` in CI, runtime kept under a minute. Tracked as [issue #69](https://github.com/DocForum/docforum-core/issues/69) (High, 200 pts) — **in progress**, branch `feat/end-to-end-care-thread-integration-test-book`. @@ -153,6 +154,7 @@ and issue links. - [x] **Load tests (k6)** — `backend/tests/load/` (`search.js`, `slots.js`, `booking-contention.js`), `backend/scripts/seed-load-test.mjs` (thousands of doctors + slots), and `.github/workflows/load-tests.yml` (manual/nightly, never against the shared Render preview). Thresholds enforce p95 latency on read paths and zero double-bookings; the contention scenario asserts correctness from the database afterwards (exactly one booking per slot), not just response codes. First results committed in `backend/tests/load/REPORT.md`. - [ ] Fixed-seed property tests for slot generation and appointment/payment state machines, with an expanded nightly run. Tracked as [issue #120](https://github.com/DocForum/docforum-core/issues/120). +- [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). @@ -280,7 +282,7 @@ and issue links. check must pass and one approval is required before merge, force pushes and deletion are disabled. `enforce_admins` left `false` so the maintainer isn't blocked — this protects against a bad external - PR landing unreviewed/uncheck-passing, not against the maintainer's + PR landing unreviewed/check-passing, not against the maintainer's own direct pushes. - Added GitHub topics to `docforum-core` and `docforum-web` (`docforum-escrow` already had them) for discoverability. @@ -378,7 +380,8 @@ and issue links. sponsorship. Custodial v1 stays as a fallback. Status: Proposed, not yet Accepted. Added to the docs site. - 2026-10-09 — Opened issue #32: upgrade to escrow SDK 0.3.0 and the - current testnet contract.- 2026-10-16 — Implemented issue #77 (SEP-10 Stellar Web Authentication server, Phase 5.5 / ADR 0005 step 4): `GET + current testnet contract. +- 2026-10-16 — Implemented issue #77 (SEP-10 Stellar Web Authentication server, Phase 5.5 / ADR 0005 step 4): `GET /payments/sep10/challenge` builds a server-signed challenge (SDK helper — Manage Data nonce, 5-minute time bounds, home domain + web_auth_domain) and records the issuance binding; `POST @@ -571,5 +574,8 @@ and issue links. payouts as the default in production and local environments, with the configuration controlled by `PAYOUT_PRIVACY_MODE` and `PAYOUT_PRIVACY_POOL_ADDRESS`. +- 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. - 2026-10-22 — Closed issue #19 (held-slot TTL/release job) as **not applicable as currently designed**, per the issue's own allowance for that outcome. Investigation: Phase 1's `claimOpenSlot` claims a slot directly with a conditional `UPDATE` inside the booking transaction and has no intermediate `held` status, so there is no abandoned-hold risk to bound — confirmed against `backend/tests/integration/booking.test.mts` (8 concurrent attempts, exactly 1 succeeds) and the absence of any `held`/`holdExpiresAt` field in `schema.prisma`. No hold state was introduced just to have something to release. Documented in the Phase 8 checklist; revisit if a multi-step booking flow (e.g. reserving a slot during Phase 2 intake) introduces a hold. - 2026-10-22 — Fixed the syntax error in `backend/src/modules/availability/repositories/index.ts` (TS1110, "Type expected" at line 31) that failed the local syntax check before push. The malformed type annotation in the repository's export was corrected in place; no other identifiers, exports, or formatting in that file were touched, and the held-slot investigation above is unchanged. \ No newline at end of file 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 18de031f..67f09207 100644 --- a/backend/src/middleware/error-handler.ts +++ b/backend/src/middleware/error-handler.ts @@ -1,7 +1,7 @@ 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'; import type { Logger } from 'pino'; import { logger as rootLogger } from '../logger'; import { localizedErrorMessage, resolveLocale } from '../i18n'; @@ -9,6 +9,15 @@ import { localizedErrorMessage, resolveLocale } from '../i18n'; // 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) { const requestLogger: Logger = (req as Request & { log?: Logger }).log ?? rootLogger; @@ -23,24 +32,52 @@ export function errorHandler(err: unknown, req: Request, res: Response, _next: N } const locale = resolveLocale(req.user?.locale, req.headers['accept-language']); const message = localizedErrorMessage(locale, err.code, err.message); - res.status(err.status).json(err.code ? { message, code: err.code } : { message }); + const body: ErrorEnvelope = { + code: err.code, + message, + }; + if (err.details !== undefined) { + body.details = err.details; + } + res.status(err.status).json(body); return; } if (err instanceof ZodError) { requestLogger.warn({ err }, 'Request validation failed'); - 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') { requestLogger.warn({ err }, 'Unique constraint violation'); - 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; } // Log the full error (including stack) server-side only. The response // body is the safe envelope and never leaks internal details. requestLogger.error({ err }, 'Unhandled error'); - res.status(500).json({ message: 'Internal server error.' }); -} + const body: ErrorEnvelope = { + code: ErrorCode.INTERNAL, + message: 'Internal server error.', + }; + res.status(500).json(body); +} \ 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 7b788880..b911c655 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 { transitionAppointmentStatus } from '../state-machine'; import * as appointmentsRepo from '../repositories'; @@ -38,20 +38,20 @@ import * as referralsService from '../../referrals/services'; * profile id. */ export async function bookSlot(actor: auditService.AuditActor, availabilitySlotId: string, patientProfileId?: string) { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + if (!actor.userId) throw new HttpError(401, ErrorCode.UNAUTHORIZED, 'Authentication required.'); const patientProfile = await patientsService.resolveActingProfile(actor.userId, patientProfileId); 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, { @@ -121,8 +121,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([ @@ -147,9 +146,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 }; } @@ -173,15 +172,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, { @@ -220,7 +219,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). */ @@ -231,7 +230,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 @@ -261,10 +260,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 (transitionAppointmentStatus(appointment.status, newStatus) === null) { - 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) => { @@ -283,12 +282,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) => { @@ -300,27 +299,27 @@ 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); + return appointmentsRepo.findAppointmentById(tx, appointmentId); } /** Retention hold (issue #41, docs/adr/0010): appointments are clinical * records, so a patient who has any is never purged after deletion. */ export function hasAppointmentsForPatient(patientProfileId: string): Promise { return appointmentsRepo.patientHasAppointments(patientProfileId); -} +} \ No newline at end of file diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index 613784bf..8c448768 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -94,12 +94,29 @@ interface PrepareCapableClient { prepareCreateEscrow?: (params: PrepareCreateEscrowParams) => Promise; } +/** 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() { // The per-asset token contract comes from the intent (issue #79), so the // legacy ESCROW_TOKEN_ID is no longer required for every payment path. const { activeContractId } = config.payments; if (!activeContractId) { - throw new HttpError(500, 'Payments is not configured — missing env var(s): activeContractId.'); + throw new HttpError(500, PaymentsErrorCode.PAYMENTS_NOT_CONFIGURED, 'Payments is not configured — missing env var(s): activeContractId.'); } const { payer, releaser } = await getPlatformSigners(); return { @@ -110,7 +127,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 { @@ -128,15 +145,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; } @@ -240,7 +257,7 @@ export interface CreatePaymentIntentInput { } export function createPaymentIntent(input: CreatePaymentIntentInput) { - if (input.amount <= 0n) throw new HttpError(400, 'amount must be positive.'); + if (input.amount <= 0n) throw new HttpError(400, PaymentsErrorCode.INVALID_AMOUNT, 'amount must be positive.'); const asset = resolveAsset(input.assetCode); return paymentsRepo.createPaymentIntent({ orderType: input.orderType, @@ -274,7 +291,7 @@ export async function createIntentForIssuedOrder(tx: Prisma.TransactionClient, i const catalogItemCode = input.catalogItemCode ?? null; const facility = await tx.facilityProfile.findUnique({ where: { id: input.facilityProfileId }, select: { type: true } }); const expectedType = input.kind === 'prescription' ? 'pharmacy' : 'lab'; - 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 } @@ -314,7 +331,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; } @@ -442,12 +459,12 @@ async function commitTransition( write: (tx: any) => Promise, ): Promise { if (transitionPaymentIntentStatus(intent.status, to) !== to) { - throw new HttpError(409, `PaymentIntent cannot transition from '${intent.status}' to '${to}'.`); + throw new HttpError(409, PaymentsErrorCode.PAYMENT_INTENT_INVALID_STATE, `PaymentIntent cannot transition from '${intent.status}' to '${to}'.`); } 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', @@ -464,14 +481,14 @@ async function commitTransition( /** Funds the escrow from the platform's own payer identity. The intent must be `created` and its facility must have a linked wallet (the on-chain payee). */ export async function fundPaymentIntent(actor: auditService.AuditActor, id: string) { const intent = await getPaymentIntent(id); - if (transitionPaymentIntentStatus(intent.status, 'escrowed') !== 'escrowed') { - throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' — cannot fund.`); + if (intent.status !== 'created') { + throw new HttpError(409, PaymentsErrorCode.PAYMENT_INTENT_INVALID_STATE, `PaymentIntent is '${intent.status}', not 'created'.`); } // The facility's ACTIVE link (issue #78: one per owner; replaced rows // are history and must never be picked up here). const walletLink = await paymentsRepo.findActiveWalletLink('facility', intent.facilityId); - if (!walletLink) throw new HttpError(409, 'The facility for this payment has no linked wallet.'); + if (!walletLink) throw new HttpError(409, PaymentsErrorCode.WALLET_NOT_LINKED, 'The facility for this payment has no linked wallet.'); assertWalletLinkFundingAllowed(walletLink); const { platformPayer, platformReleaser, escrowContractId } = await requirePaymentsConfig(); @@ -587,7 +604,7 @@ export async function preparePaymentIntent( let intent = await getPaymentIntent(id); // 404, not 403, for someone else's intent: don't confirm it exists. - if (intent.patientProfileId !== patientProfileId) throw new HttpError(404, 'PaymentIntent not found.'); + if (intent.patientProfileId !== patientProfileId) throw new HttpError(404, PaymentsErrorCode.PAYMENT_INTENT_NOT_FOUND, 'PaymentIntent not found.'); if (intent.status === 'awaiting_signature') { const live = @@ -616,12 +633,12 @@ export async function preparePaymentIntent( await expirePreparedIntent(intent.id, now); intent = await getPaymentIntent(id); if (intent.status !== 'created') { - throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' — cannot prepare.`); + throw new HttpError(409, PaymentsErrorCode.PAYMENT_INTENT_INVALID_STATE, `PaymentIntent is '${intent.status}', not 'created' — cannot prepare.`); } } if (intent.status !== 'created') { - throw new HttpError(409, `PaymentIntent is '${intent.status}', not 'created' — cannot prepare.`); + throw new HttpError(409, PaymentsErrorCode.PAYMENT_INTENT_INVALID_STATE, `PaymentIntent is '${intent.status}', not 'created' — cannot prepare.`); } const patientWallet = await getPatientWalletLink(patientProfileId); @@ -629,7 +646,7 @@ export async function preparePaymentIntent( throw new HttpError( 409, 'No verified Stellar wallet is linked to this patient — link one before paying.', - 'WALLET_NOT_LINKED', + PaymentsErrorCode.WALLET_NOT_LINKED, ); } @@ -690,25 +707,30 @@ export async function preparePaymentIntent( /** 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 (transitionPaymentIntentStatus(intent.status, 'released') !== 'released' || 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 (transitionPaymentIntentStatus(intent.status, 'refunded') !== 'refunded' || 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(); @@ -761,4 +783,4 @@ export const paymentsExportSection: PatientExportSection = { * a patient who has any is never purged after deletion. */ export function hasPaymentIntentsForPatient(patientProfileId: string): Promise { return paymentsRepo.patientHasPaymentIntents(patientProfileId); -} +} \ No newline at end of file