Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ export. The pieces:
- Current sections: `account` (auth: email/phone/role, never the password
hash), `profile` (patients), `appointments` (appointments, with slot times
from `availability` and the doctor's name from `doctors`, both via their
services), `payments` (payments: amounts, status, on-chain tx hash /
escrow id / contract id; the platform's signing secrets never live in a
row and can't reach an export).
services), `payments` (version 2: amount in the asset's smallest unit,
asset code, status, on-chain tx hash / escrow id / contract id; the
platform's signing secrets never live in a row and can't reach an export).

**Adding a module with patient data (referrals, prescriptions, lab orders,
…):** export a `PatientExportSection` from that module's `services/` and add
Expand Down
6 changes: 4 additions & 2 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
- Auth: JWT access + refresh, roles = `patient | doctor | facility | admin`. The refresh cookie is `SameSite=None` cross-site, so `/auth/refresh` + `/auth/logout` require an `X-CSRF-Token` header (a per-session token bound into the refresh JWT) and an allowlisted `Origin` — see `backend/src/middleware/csrf.ts`. CORS is an explicit per-environment allowlist (`CORS_ORIGINS`).
- Frontend: React + TypeScript + Vite, React Query + Zustand.
- Video/voice, SMS/email, file storage: **third-party, behind an interface** — never build custom.
- Jobs: keep it simple (cron/interval) until a real job exists; don't pre-install a queue. Existing interval jobs: login-throttle prune (`src/server.ts`) and patient-export worker/cleanup (`src/jobs/patient-exports.ts`), both started only from `server.ts`.
- Jobs: keep it simple (cron/interval) until a real job exists; don't pre-install a queue. Existing interval jobs: login-throttle prune (`src/server.ts`), patient-export worker/cleanup (`src/jobs/patient-exports.ts`), and prepared-payment expiry (`src/jobs/payment-intents.ts`), all started only from `server.ts`.
- New module holding patient data? Register a patient-export section (ARCHITECTURE.md §3.1) — right of access must stay complete.

## Hard rules (violating these = request changes on the PR)
Expand Down Expand Up @@ -37,7 +37,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
- `WalletLink` is scoped to the **facility** (the on-chain payee), not the patient.
- 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.
- The actual Stellar contract calls go through `@docforum/escrow-sdk` (`backend/src/modules/payments/services/escrow-sdk-loader.ts` — dynamic `import()`, the SDK is ESM-only and this backend is CommonJS). Never reimplement contract-calling logic in this repo. The SDK used today (0.2.0) has no prepare call, so prepare returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED` until the 0.3.0 prepare/submit call (docforum-escrow #58) lands.

## Known unresolved design gaps (do not silently "fix" without flagging in PR description)
- No routing rule yet for a referral with no specific target doctor (which specialist gets it?). **v1 re-scope (2026-10-17): `target_doctor_id` is required, so unassigned referrals can't exist — but that makes PRD §8.2.2's "waiting for a specialist" state unreachable rather than implemented. ADR still owed (issue #6).**
Expand All @@ -48,6 +48,8 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
- `AuditEvent` foundation exists (Phase 8, issue #43 — ARCHITECTURE.md §4.10): append-only (DB triggers), written by `auditService.record(tx, …)` **inside the same transaction** as the change. Wired for booking (direct and referral-origin), doctor verification, payment fund/release/refund, referral accept/decline and referral admin actions (`entity_type: 'Referral'`) — **Phase 4 transitions must call it when they're built.** There is one audit table: referral actions go into it, not into a second one. Never put PHI in `metadata`.
- No email/SMS provider integrated yet (Phase 6, issue #17) — email verification (issue #34) therefore can't deliver its link on a hosted deployment; `backend/scripts/mark-email-verified.mjs` is the interim operator path.
- One doctor = one calendar assumed; multi-facility doctors not modeled.
- Non-custodial payments are only half-built: prepare exists (issue #80), submit/fee-bump does not, and the SEP-10 patient-wallet link flow that creates `PatientWalletLink` rows is not implemented. `PaymentIntent.refundAfter` uses the intent's `createdAt` as the order-expiry anchor because orders have no expiry field yet (Phase 4).
- `@docforum/escrow-sdk` 0.2.0 has no prepare call, so `/payments/intents/:id/prepare` returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED` against the real SDK until docforum-escrow publishes SDK 0.3.0 (docforum-core #32 / docforum-escrow #58). Integration tests exercise the endpoint with a mocked SDK.

## Overengineering guardrails (things NOT to build yet)
- No custom video/WebRTC.
Expand Down
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ roadmap. See each phase section below for status and issue links.
- [ ] Idempotent escrow funding + on-chain reconcile job (`fundPaymentIntent` is check-then-act — docforum-escrow threat model §5). Tracked as [issue #24](https://github.com/DocForum/docforum-core/issues/24) (High, 200 pts).
- [ ] ADR: releaser key custody — multisig, KMS, or per-period rotation (threat model T1/T2). Tracked as [issue #25](https://github.com/DocForum/docforum-core/issues/25) (Medium, 150 pts).
- [ ] Checksum-validate facility wallet keys, not just regex. Tracked as [issue #26](https://github.com/DocForum/docforum-core/issues/26) (Trivial, 100 pts).
- [ ] Non-custodial patient payments (v2): patients fund escrows from their own Stellar wallet, custodial kept as fallback. **Proposed** in `docs/adr/0005-non-custodial-patient-payments.md`; implementation issues to be opened once it's Accepted.
- [ ] Upgrade to `@docforum/escrow-sdk` 0.3.0 and the current testnet contract (`refund_after`, balance-delta check, `getEscrow`) — step 1 of ADR 0005's plan, but worth doing regardless. Tracked as [issue #32](https://github.com/DocForum/docforum-core/issues/32) (Medium, 150 pts); depends on `docforum-escrow` publishing `sdk-v0.3.0`.
- [ ] Non-custodial patient payments (v2): patients fund escrows from their own Stellar wallet, custodial kept as fallback. **Proposed** in `docs/adr/0005-non-custodial-patient-payments.md`; implementation has started (see the prepare item below).
- [x] Prepare endpoint — `POST /payments/intents/:id/prepare` builds + simulates the patient-as-payer `create_escrow` transaction, returns unsigned XDR + expiry ledger, and moves the intent to `awaiting_signature` via a conditional update. Adds `awaiting_signature`/`submitting` statuses and a prepared-transaction expiry sweep back to `created` (a normal case, not an error). Tracked as [issue #80](https://github.com/DocForum/docforum-core/issues/80). Patient ownership is service-enforced (someone else's intent is `404`); a missing verified payer wallet is `409 WALLET_NOT_LINKED`. The real SDK call is pending docforum-escrow #58 (SDK 0.3.0) — until then the endpoint returns `503 ESCROW_SDK_PREPARE_UNSUPPORTED`, and integration tests use a mocked SDK.
- [ ] Upgrade to `@docforum/escrow-sdk` 0.3.0 and the current testnet contract (`refund_after`, balance-delta check, `getEscrow`, plus the prepare/submit calls #80 needs) — step 1 of ADR 0005's plan, but worth doing regardless. Tracked as [issue #32](https://github.com/DocForum/docforum-core/issues/32) (Medium, 150 pts); depends on `docforum-escrow` publishing `sdk-v0.3.0` (#58). This is now the blocker for a real (non-mocked) prepare.
- [ ] Doc reconciliation: PRD §8.1.6 (facility fulfillment integrity) — not marked resolved; this phase proves the payment *mechanism* works, not the fulfillment-integrity question, which still needs real `FulfillmentRecord`/facility-matching (Phase 5) to actually address.

## Phase 6 — Notifications
Expand Down
2 changes: 2 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ PLATFORM_RELEASER_SECRET=
# — see that repo's docs/testnet-deployments.md for the current value.
ESCROW_CONTRACT_ID=CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM
# Native XLM SAC on testnet (stellar contract id asset --asset native --network testnet)
# Kept as the legacy fallback for PaymentIntent rows created before #79;
# new intents take their token from PAYMENT_ASSETS below.
ESCROW_TOKEN_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
# Contract event ingestion (issue #86). Use comma-separated id@deployment-ledger
# pairs to include current and historical redeployments. Each deployment needs
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
-- Issue #79: PaymentIntent is denominated in an allowlisted asset, not
-- implicitly native XLM. The amount column is renamed, not recreated, so
-- every existing row keeps its exact smallest-unit value (no data copy, no
-- precision loss). `assetCode` is backfilled to 'XLM' because that is the
-- only asset v1 could have created; `tokenContractId` stays NULL for those
-- rows and the service resolves it from ESCROW_TOKEN_ID (see schema comment).
ALTER TABLE "PaymentIntent" RENAME COLUMN "amountStroops" TO "amount";
ALTER TABLE "PaymentIntent" ADD COLUMN "assetCode" TEXT NOT NULL DEFAULT 'XLM';
ALTER TABLE "PaymentIntent" ADD COLUMN "tokenContractId" TEXT;
ALTER TABLE "PaymentIntent" ALTER COLUMN "assetCode" DROP DEFAULT;

-- Issue #80: prepare/expire state machine. `awaiting_signature` and
-- `submitting` are added to the status enum (submitting is reserved for the
-- submit half of ADR 0005 and not written yet).
ALTER TYPE "PaymentIntentStatus" ADD VALUE 'awaiting_signature';
ALTER TYPE "PaymentIntentStatus" ADD VALUE 'submitting';

ALTER TABLE "PaymentIntent" ADD COLUMN "preparedTxHash" TEXT;
ALTER TABLE "PaymentIntent" ADD COLUMN "preparedXdr" TEXT;
ALTER TABLE "PaymentIntent" ADD COLUMN "preparedExpiryLedger" INTEGER;
ALTER TABLE "PaymentIntent" ADD COLUMN "preparedExpiresAt" TIMESTAMP(3);
ALTER TABLE "PaymentIntent" ADD COLUMN "refundAfter" TIMESTAMP(3);

-- Expiry sweep: find live prepared transactions whose window has passed.
CREATE INDEX "PaymentIntent_status_preparedExpiresAt_idx" ON "PaymentIntent"("status", "preparedExpiresAt");

-- ADR 0005 step 1: patient payer wallet link (SEP-10 verified once that
-- flow exists). Separate from WalletLink, which stays the facility payee.
CREATE TABLE "PatientWalletLink" (
"id" TEXT NOT NULL,
"patientProfileId" TEXT NOT NULL,
"stellarPublicKey" TEXT NOT NULL,
"verifiedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "PatientWalletLink_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "PatientWalletLink_patientProfileId_key" ON "PatientWalletLink"("patientProfileId");

ALTER TABLE "PatientWalletLink" ADD CONSTRAINT "PatientWalletLink_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
67 changes: 59 additions & 8 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,12 @@ model Notification {

enum PaymentIntentStatus {
created
// ADR 0005 steps 2-4: a prepared (unsigned) create_escrow transaction
// exists, then is being submitted with the patient's signature. The
// submit half is not built yet (issue #80 only covers prepare), so
// `submitting` is reserved by the state machine but not written today.
awaiting_signature
submitting
escrowed
released
refunded
Expand All @@ -749,14 +755,38 @@ model PaymentIntent {
facilityId String
facility FacilityProfile @relation(fields: [facilityId], references: [id])

// Raw stroops (native XLM's smallest unit) — matches
// @docforum/escrow-sdk's `amount: bigint` exactly. v1 is native-XLM
// only, no decimal/currency-conversion layer: an earlier scaffold draft
// of this model used a Decimal amount + "USDC" currency default,
// dropped here in favor of what's actually proven end-to-end on
// testnet (see ADR 0004). Multi-asset support is a future addition,
// not assumed by this field.
amountStroops BigInt
// Amount in the smallest unit of `assetCode` (stroops for XLM, 7-decimal
// units for USDC) — matches @docforum/escrow-sdk's `amount: bigint`
// exactly. Renamed from `amountStroops` by issue #79; the migration is a
// plain column rename, so existing rows keep their value. This module
// never scales or converts amounts, so it never assumes an asset's
// decimals (the allowlist carries them for clients only).
amount BigInt
// The asset this intent is denominated in — `XLM` or `USDC` in the
// default testnet allowlist. Assets are identified on-chain by the SAC
// contract id, not by the code, so a new intent must name an allowlisted
// asset (see config.payments.assets and issue #79).
assetCode String
// The Stellar Asset Contract address the escrow pulls from. Nullable only
// for rows created before issue #79: those were funded against
// `ESCROW_TOKEN_ID` (native XLM), which the service falls back to when it
// sees a null here. New rows always store it.
tokenContractId String?

// ADR 0005 step 2 (issue #80). `preparedTxHash` is the hash of the
// unsigned create_escrow transaction the patient must sign;
// `preparedXdr` lets a retry return exactly the same transaction instead
// of building a second one. Both are cleared when the preparation
// expires back to `created`.
preparedTxHash String?
preparedXdr String?
preparedExpiryLedger Int?
preparedExpiresAt DateTime?
// ADR 0005: escrow refund deadline = order expiry + grace. Orders don't
// exist as real records yet (Phase 4), so the service uses the intent's
// createdAt as the order-expiry anchor — see
// config.payments.orderExpiryHours / refundGraceHours.
refundAfter DateTime?

// Set once funded (create_escrow succeeds) — needed to later call
// release/refund against the right on-chain escrow.
Expand All @@ -772,6 +802,27 @@ model PaymentIntent {

@@index([facilityId])
@@index([status])
@@index([status, preparedExpiresAt])
}

// ADR 0005 step 1: a patient's verified Stellar address, used as the payer
// of a non-custodial escrow. Verification is SEP-10 (a signed challenge),
// not the type-the-address form used for facilities — but the SEP-10
// endpoints are not built yet, so nothing in this repo creates these rows
// today. Prepare (issue #80) requires one and returns 409 WALLET_NOT_LINKED
// otherwise. Deliberately a separate model from WalletLink: that one is
// the facility payee (unique per facility), this one is the patient payer
// (unique per patient), and conflating the two would make a facility's
// payout address eligible as a patient payer.
model PatientWalletLink {
id String @id @default(uuid())
patientProfileId String @unique
stellarPublicKey String
verifiedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Cascade)
}

model WalletLink {
Expand Down
71 changes: 71 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,77 @@ function parseTrustProxy(raw: string | undefined, isProduction: boolean): boolea
return raw;
}

/** A payment asset the escrow may be denominated in (issue #79). The
* contract id is the Stellar Asset Contract address the escrow pulls from;
* `decimals` is informational for clients (every amount in this API is
* already in the asset's smallest unit, so the server never scales one). */
export interface PaymentAsset {
code: string;
tokenContractId: string;
decimals: number;
/** Set for issued assets (e.g. USDC). Native XLM has none and needs no
* trustline; every issued asset's payee must have one before funding. */
issuer?: string;
}

// Testnet defaults. Override per environment with PAYMENT_ASSETS; production
// must set it explicitly (mainnet contract ids differ). Only assets without
// clawback, transfer fees or rebasing belong in the allowlist — escrow
// threat model T5 — and the parser refuses entries that opt into clawback or
// rebasing. This is a curated policy list, not an on-chain capability probe.
const TESTNET_NATIVE_SAC = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC';
const TESTNET_USDC_SAC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA';
const TESTNET_USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';

const STELLAR_CONTRACT_ID = /^C[A-Z2-7]{55}$/;
const STELLAR_PUBLIC_KEY = /^G[A-Z2-7]{55}$/;

function parsePaymentAssets(raw: string | undefined): PaymentAsset[] {
const fallback: PaymentAsset[] = [
{ code: 'XLM', tokenContractId: process.env.ESCROW_TOKEN_ID ?? TESTNET_NATIVE_SAC, decimals: 7 },
{
code: 'USDC',
tokenContractId: TESTNET_USDC_SAC,
decimals: 7,
issuer: TESTNET_USDC_ISSUER,
},
];
if (!raw) return fallback;

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error('PAYMENT_ASSETS must be a JSON array of { code, tokenContractId, decimals, issuer? }.');
}
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error('PAYMENT_ASSETS must be a non-empty JSON array.');
}
const seen = new Set<string>();
return parsed.map((entry, index) => {
if (typeof entry !== 'object' || entry === null) throw new Error(`PAYMENT_ASSETS[${index}] must be an object.`);
const { code, tokenContractId, decimals, issuer, clawback, rebase } = entry as Record<string, unknown>;
if (clawback === true || rebase === true) {
throw new Error(`PAYMENT_ASSETS[${index}] opts into clawback/rebasing, which the escrow allowlist forbids (T5).`);
}
if (typeof code !== 'string' || !/^[A-Z0-9]{1,12}$/.test(code)) {
throw new Error(`PAYMENT_ASSETS[${index}].code must be 1-12 uppercase alphanumeric characters.`);
}
if (seen.has(code)) throw new Error(`PAYMENT_ASSETS contains duplicate asset code ${code}.`);
seen.add(code);
if (typeof tokenContractId !== 'string' || !STELLAR_CONTRACT_ID.test(tokenContractId)) {
throw new Error(`PAYMENT_ASSETS[${index}].tokenContractId must be a Stellar contract id.`);
}
if (typeof decimals !== 'number' || !Number.isInteger(decimals) || decimals < 0 || decimals > 18) {
throw new Error(`PAYMENT_ASSETS[${index}].decimals must be an integer between 0 and 18.`);
}
if (issuer !== undefined && (typeof issuer !== 'string' || !STELLAR_PUBLIC_KEY.test(issuer))) {
throw new Error(`PAYMENT_ASSETS[${index}].issuer must be a Stellar public key.`);
}
return { code, tokenContractId, decimals, ...(issuer ? { issuer } : {}) };
});
}

const PRODUCTION_CORS_ORIGINS = ['https://docforum.github.io'];
const LOCAL_DEV_CORS_ORIGINS = ['http://localhost:5173', 'http://127.0.0.1:5173'];

Expand Down
Loading
Loading