From 1cc7f984d9c62b142d8ec9f8a08266dd43d91492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=97=A9=F0=9D=97=9C=F0=9D=97=96=F0=9D=97=A7=E2=AD=95?= =?UTF-8?q?=EF=B8=8F=F0=9D=97=A5=20=E2=9B=93=EF=B8=8F=20=F0=9D=97=96?= =?UTF-8?q?=E2=AD=95=EF=B8=8F=F0=9D=97=A5=F0=9D=97=A1=F0=9D=97=98?= =?UTF-8?q?=F0=9D=97=A5=F0=9D=97=A6=F0=9D=97=A7=E2=AD=95=EF=B8=8F?= =?UTF-8?q?=F0=9D=97=A1=F0=9D=97=98?= <155533416+victor-134@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:59:26 +0000 Subject: [PATCH 1/6] feat(payments): add payment history and facility ledger APIs --- ROADMAP.md | 6 + .../src/modules/payments/controllers/index.ts | 14 +- .../modules/payments/repositories/index.ts | 49 ++++++ backend/src/modules/payments/routes/index.ts | 3 + .../src/modules/payments/services/index.ts | 87 +++++++++- backend/src/modules/payments/validation.ts | 37 ++++ .../integration/payment-history.test.mts | 159 ++++++++++++++++++ docs/api/README.md | 24 +++ 8 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 backend/tests/integration/payment-history.test.mts diff --git a/ROADMAP.md b/ROADMAP.md index 2c645a0d..e8acf771 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,6 +96,7 @@ 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). +- [x] Patient payment history and facility payout ledger APIs, with ownership-scoped pagination, on-chain references, and BigInt-safe SQL totals. Tracked as [issue #90](https://github.com/DocForum/docforum-core/issues/90) — **implemented; `refundAfter` remains null until a payment model/contract version supports deadlines**. - [ ] 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`. - [ ] 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. @@ -500,3 +501,8 @@ roadmap. See each phase section below for status and issue links. - 2026-10-20 — Fixed the backup drill shipped above, which had never run. `backup-restore-drill.yml` was invalid YAML, so it failed on every push to `main` (broken `BACKUP_ENCRYPTION_KEY` expression and service `--health-cmd` options, missing `POSTGRES_USER`). Both scripts had shell syntax errors (`psal`, `-symmetric`, malformed heredocs, a broken `find -printf`, mangled parameter expansions). Also: gpg now reads the passphrase non-interactively (`--pinentry-mode loopback`); the restore drops and recreates the scratch database through the `postgres` maintenance database and only accepts plain identifiers; the backup log no longer prints the connection string (it contains the password). Corrected the roadmap entry, which described files that don't exist (`postgres-backup.yml`, `restore-drill.mjs`, `age`, `docs/backups.md`). Separately, `tests/integration/sessions.test.mts` moved its embedded Postgres from port 5438 to 5451, because 5438 is already used by `access-control.test.mts` and vitest runs integration files in parallel. - 2026-10-21 — Phase 9: automated Postgres backups with a tested restore drill (issue #30). Encrypted logical backups (`pg_dump` piped through `age`, checksummed manifest, retention policy) scheduled in `.github/workflows/postgres-backup.yml`; a restore script (`scripts/restore-drill.mjs`) that restores the latest backup into a scratch database and runs integrity checks (row counts, migration version, a sample booking query), with hard safety guards — explicit confirmation flag, scratch-name pattern check, and refusal to target any live database by name or by matching `DATABASE_URL`/`STAGING_DATABASE_URL`/`PRODUCTION_DATABASE_URL`. The drill runs on a schedule in CI against staging backups and alerts on failure. RPO/RTO targets and the measured restore time are documented in `docs/backups.md`. Backups contain PHI, so encryption at rest and access control are mandatory and enforced in the workflow (artifact encryption key is a repo secret; artifacts are never uploaded unencrypted). No schema or endpoint changes, so `ARCHITECTURE.md` §4 and `docs/api/README.md` are untouched. - 2026-10-20 — Hardened the PR automation against transient GitHub API errors. Today a single 500/503 response aborted a whole conflict-notifier run, so no PR after it got labelled. Both `pr-conflict-notifier.yml` and `pr-malware-scan.yml` now set `retries: 3` on `actions/github-script`, so 5xx responses are retried. The notifier also isolates each PR: a PR whose API calls still fail is skipped with a warning, listed as "API error, retried next run" in the run summary, and the remaining PRs are processed normally. +- 2026-10-19 — Implemented issue #90's patient payment-history and facility + ledger APIs. Lists are owner-scoped and keyset-paginated; ledger totals + are aggregated in PostgreSQL and serialized as decimal strings. The + existing custodial v1 model has no `refund_after` field, so the API + returns `refundAfter: null` rather than implying a deadline exists. diff --git a/backend/src/modules/payments/controllers/index.ts b/backend/src/modules/payments/controllers/index.ts index 67d47c79..f9a15dd5 100644 --- a/backend/src/modules/payments/controllers/index.ts +++ b/backend/src/modules/payments/controllers/index.ts @@ -1,6 +1,6 @@ import type { Request, Response } from 'express'; import * as paymentsService from '../services'; -import { linkWalletSchema } from '../validation'; +import { linkWalletSchema, paymentHistoryQuerySchema } from '../validation'; import { HttpError } from '../../../http-error'; import { getParam } from '../../../request-params'; import { auditActor } from '../../../middleware/request-id'; @@ -67,3 +67,15 @@ export async function refundIntent(req: Request, res: Response) { const intent = await paymentsService.refundPaymentIntent(auditActor(req), id); res.status(200).json(serializeIntent(intent)); } + +export async function listMyPayments(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const filters = paymentHistoryQuerySchema.parse(req.query); + res.status(200).json(await paymentsService.listMyPayments(req.user.id, filters)); +} + +export async function listFacilityLedger(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const filters = paymentHistoryQuerySchema.parse(req.query); + res.status(200).json(await paymentsService.listFacilityPaymentLedger(req.user.id, filters)); +} diff --git a/backend/src/modules/payments/repositories/index.ts b/backend/src/modules/payments/repositories/index.ts index affe04d2..118e2cf1 100644 --- a/backend/src/modules/payments/repositories/index.ts +++ b/backend/src/modules/payments/repositories/index.ts @@ -37,6 +37,55 @@ export function findPaymentIntentsPageForPatient(patientProfileId: string, curso }); } +type PaymentHistoryFilter = { + status?: PaymentIntentStatus; + from?: Date; + to?: Date; + before?: { createdAt: Date; id: string }; + take: number; +}; + +function paymentHistoryWhere(filter: PaymentHistoryFilter) { + return { + ...(filter.status ? { status: filter.status } : {}), + ...(filter.from || filter.to + ? { createdAt: { ...(filter.from ? { gte: filter.from } : {}), ...(filter.to ? { lte: filter.to } : {}) } } + : {}), + ...(filter.before + ? { + OR: [ + { createdAt: { lt: filter.before.createdAt } }, + { createdAt: filter.before.createdAt, id: { lt: filter.before.id } }, + ], + } + : {}), + }; +} + +export function findPaymentHistoryPage( + owner: { patientProfileId: string } | { facilityId: string }, + filter: PaymentHistoryFilter, +) { + return prisma.paymentIntent.findMany({ + where: { ...paymentHistoryWhere(filter), ...owner }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: filter.take, + }); +} + +/** Aggregate each lifecycle bucket in PostgreSQL; amountStroops stays BigInt end to end. */ +export function aggregateFacilityPayments(facilityId: string, from?: Date, to?: Date) { + return prisma.paymentIntent.groupBy({ + by: ['status'], + where: { + facilityId, + status: { in: ['escrowed', 'released', 'refunded'] }, + ...(from || to ? { createdAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {}), + }, + _sum: { amountStroops: true }, + }); +} + /** Same FacilityProfile read this module already does for its own * payee lookups (see findFacilityProfileByUserId). */ export function findFacilityNamesByIds(ids: string[]) { diff --git a/backend/src/modules/payments/routes/index.ts b/backend/src/modules/payments/routes/index.ts index f49e1670..94aa9791 100644 --- a/backend/src/modules/payments/routes/index.ts +++ b/backend/src/modules/payments/routes/index.ts @@ -4,6 +4,9 @@ import * as paymentsController from '../controllers'; export const paymentsRouter = Router(); +paymentsRouter.get('/mine', authenticate, requireRole('patient'), paymentsController.listMyPayments); +paymentsRouter.get('/facility/ledger', authenticate, requireRole('facility'), paymentsController.listFacilityLedger); + // Facility manages its own payout wallet. paymentsRouter.post( '/wallet-link', diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index a0e5e625..a13f238c 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -14,7 +14,7 @@ import { config } from '../../../config'; import * as paymentsRepo from '../repositories'; import { loadEscrowSdk, type EscrowSdkKeypair } from './escrow-sdk-loader'; import { getPlatformSigners } from './platform-signers'; -import { paginate, type PatientExportSection } from '../../patients/services'; +import { getMyPatientProfile, paginate, type PatientExportSection } from '../../patients/services'; import { prisma } from '../../../db/prisma-client'; import * as auditService from '../../audit/services'; import type { Prisma } from '@prisma/client'; @@ -120,6 +120,91 @@ export async function getPaymentIntent(id: string) { return intent; } +export interface PaymentHistoryFilters { + status?: PaymentIntentStatus; + from?: Date; + to?: Date; + cursor?: string; + limit: number; +} + +function encodePaymentCursor(payment: { createdAt: Date; id: string }): string { + return Buffer.from(`${payment.createdAt.toISOString()}|${payment.id}`, 'utf8').toString('base64url'); +} + +function decodePaymentCursor(cursor: string): { createdAt: Date; id: string } { + const raw = Buffer.from(cursor, 'base64url').toString('utf8'); + const separator = raw.indexOf('|'); + const createdAt = new Date(raw.slice(0, separator)); + const id = raw.slice(separator + 1); + if (separator < 1 || Number.isNaN(createdAt.getTime()) || !id) { + throw new HttpError(400, 'Invalid cursor.', 'INVALID_CURSOR'); + } + return { createdAt, id }; +} + +function paymentPage(filters: PaymentHistoryFilters, owner: { patientProfileId: string } | { facilityId: string }) { + return paymentsRepo.findPaymentHistoryPage(owner, { + status: filters.status, + from: filters.from, + to: filters.to, + before: filters.cursor ? decodePaymentCursor(filters.cursor) : undefined, + take: filters.limit + 1, + }); +} + +function serializePayment(payment: PaymentIntent) { + return { + id: payment.id, + orderType: payment.orderType, + orderId: payment.orderId, + status: payment.status, + amountStroops: payment.amountStroops.toString(), + asset: { type: 'native', code: 'XLM' }, + escrowId: payment.escrowId?.toString() ?? null, + stellarTxHash: payment.stellarTxHash, + sorobanContractId: payment.sorobanContractId, + refundAfter: null, + createdAt: payment.createdAt, + updatedAt: payment.updatedAt, + }; +} + +async function listPayments(filters: PaymentHistoryFilters, owner: { patientProfileId: string } | { facilityId: string }) { + const rows = await paymentPage(filters, owner); + const page = rows.slice(0, filters.limit); + const last = page[page.length - 1]; + return { + items: page.map(serializePayment), + nextCursor: rows.length > filters.limit && last ? encodePaymentCursor(last) : null, + }; +} + +export async function listMyPayments(userId: string, filters: PaymentHistoryFilters) { + const profile = await getMyPatientProfile(userId); + return listPayments(filters, { patientProfileId: profile.id }); +} + +export async function listFacilityPaymentLedger(userId: string, filters: PaymentHistoryFilters) { + const facilityId = await getOwnFacilityProfileId(userId); + const [page, aggregates] = await Promise.all([ + listPayments(filters, { facilityId }), + paymentsRepo.aggregateFacilityPayments(facilityId, filters.from, filters.to), + ]); + const totals = new Map(aggregates.map((row) => [row.status, row._sum.amountStroops ?? 0n])); + return { + ...page, + totals: [ + { + asset: { type: 'native', code: 'XLM' }, + escrowed: (totals.get('escrowed') ?? 0n).toString(), + released: (totals.get('released') ?? 0n).toString(), + refunded: (totals.get('refunded') ?? 0n).toString(), + }, + ], + }; +} + /** Commits a status change and its AuditEvent together (issue #43). If the * row moved on concurrently, nothing is written and the caller gets 409. */ async function commitTransition( diff --git a/backend/src/modules/payments/validation.ts b/backend/src/modules/payments/validation.ts index 5f6e4ce5..5fa6ba8a 100644 --- a/backend/src/modules/payments/validation.ts +++ b/backend/src/modules/payments/validation.ts @@ -8,3 +8,40 @@ export const linkWalletSchema = z.object({ .string() .regex(/^G[A-Z2-7]{55}$/, 'stellarPublicKey must be a valid Stellar public key (starts with G, 56 chars).'), }); + +export const createPaymentIntentSchema = z.object({ + orderType: z.enum(['prescription', 'lab_order']), + // Opaque — see schema.prisma comment. Not validated against a real + // Prescription/LabOrder record yet (Phase 4 doesn't exist). + orderId: z.string().min(1), + patientProfileId: z.string().min(1), + facilityId: z.string().min(1), + // Stroops (native XLM's smallest unit), matching + // @docforum/escrow-sdk's bigint amount exactly. Sent as a string over + // JSON (a raw number can't safely carry bigint precision) and parsed + // here. + amountStroops: z + .string() + .regex(/^[1-9][0-9]*$/, 'amountStroops must be a positive integer string.') + .transform((value) => BigInt(value)), +}); + +const isoDate = z + .string() + .refine((value) => !Number.isNaN(new Date(value).getTime()) && /^\d{4}-\d{2}-\d{2}/.test(value), { + message: 'must be an ISO-8601 date or date-time', + }) + .transform((value) => new Date(value)); + +export const paymentHistoryQuerySchema = z + .object({ + status: z.enum(['created', 'escrowed', 'released', 'refunded', 'failed']).optional(), + from: isoDate.optional(), + to: isoDate.optional(), + cursor: z.string().min(1).max(512).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + }) + .strict() + .refine((query) => !query.from || !query.to || query.from <= query.to, { + message: '`from` must not be after `to`.', + }); diff --git a/backend/tests/integration/payment-history.test.mts b/backend/tests/integration/payment-history.test.mts new file mode 100644 index 00000000..cb2ea381 --- /dev/null +++ b/backend/tests/integration/payment-history.test.mts @@ -0,0 +1,159 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5442; +const runId = Date.now(); +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-payment-history-pg-')); +const pg = new EmbeddedPostgres({ databaseDir: dataDir, user: 'docforum', password: 'docforum', port: PORT, persistent: false }); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_payment_history_test`; + +describe('payment history and facility ledger APIs', () => { + let app: ReturnType; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let patientA: { id: string; token: string }; + let patientB: { id: string; token: string }; + let facilityA: { id: string; token: string }; + let facilityB: { id: string; token: string }; + + function token(id: string, role: 'patient' | 'facility') { + return jwt.sign({ sub: id, role }, 'test-access-secret', { expiresIn: '15m' }); + } + + async function createIntent(input: { + patientProfileId: string; + facilityId: string; + status: 'created' | 'escrowed' | 'released' | 'refunded' | 'failed'; + amountStroops: bigint; + createdAt: Date; + }) { + return prisma.paymentIntent.create({ + data: { + orderType: 'lab_order', + orderId: `order-${Math.random()}`, + patientProfileId: input.patientProfileId, + facilityId: input.facilityId, + status: input.status, + amountStroops: input.amountStroops, + createdAt: input.createdAt, + ...(input.status === 'escrowed' || input.status === 'released' || input.status === 'refunded' + ? { escrowId: input.amountStroops + 1n, stellarTxHash: 'a'.repeat(64), sorobanContractId: 'C'.repeat(56) } + : {}), + }, + }); + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_payment_history_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + process.env.DATABASE_URL = databaseUrl; + + const dbModule = await import('../../src/db/prisma-client.js'); + prisma = dbModule.prisma; + app = (await import('../../src/app.js')).createApp(); + + async function makePatient(label: string) { + const user = await prisma.user.create({ + data: { email: `${label}-${runId}@example.com`, passwordHash: 'unused', role: 'patient' }, + }); + const profile = await prisma.patientProfile.create({ data: { userId: user.id, fullName: label } }); + return { id: profile.id, token: token(user.id, 'patient') }; + } + + async function makeFacility(label: string) { + const user = await prisma.user.create({ + data: { email: `${label}-${runId}@example.com`, passwordHash: 'unused', role: 'facility' }, + }); + const profile = await prisma.facilityProfile.create({ data: { userId: user.id, name: label, type: 'lab' } }); + return { id: profile.id, token: token(user.id, 'facility') }; + } + + [patientA, patientB, facilityA, facilityB] = await Promise.all([ + makePatient('payment-history-a'), + makePatient('payment-history-b'), + makeFacility('payment-history-facility-a'), + makeFacility('payment-history-facility-b'), + ]); + + const now = Date.now(); + await Promise.all([ + createIntent({ patientProfileId: patientA.id, facilityId: facilityA.id, status: 'escrowed', amountStroops: 9_007_199_254_740_993n, createdAt: new Date(now - 3000) }), + createIntent({ patientProfileId: patientA.id, facilityId: facilityA.id, status: 'released', amountStroops: 20n, createdAt: new Date(now - 2000) }), + createIntent({ patientProfileId: patientA.id, facilityId: facilityA.id, status: 'refunded', amountStroops: 30n, createdAt: new Date(now - 1000) }), + createIntent({ patientProfileId: patientB.id, facilityId: facilityB.id, status: 'escrowed', amountStroops: 900n, createdAt: new Date(now) }), + ]); + }, 180_000); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('returns only the authenticated patient payments with stable cursor pagination', async () => { + const first = await request(app).get('/payments/mine?limit=2').set('Authorization', `Bearer ${patientA.token}`); + expect(first.status).toBe(200); + expect(first.body.items).toHaveLength(2); + expect(first.body.nextCursor).toEqual(expect.any(String)); + expect(first.body.items[0].amountStroops).toBe('30'); + expect(first.body.items[0].refundAfter).toBeNull(); + + const second = await request(app) + .get('/payments/mine') + .query({ limit: 2, cursor: first.body.nextCursor }) + .set('Authorization', `Bearer ${patientA.token}`); + expect(second.status).toBe(200); + expect(second.body.items).toHaveLength(1); + expect(second.body.items[0].amountStroops).toBe('9007199254740993'); + expect(second.body.nextCursor).toBeNull(); + + const otherPatient = await request(app).get('/payments/mine').set('Authorization', `Bearer ${patientB.token}`); + expect(otherPatient.body.items).toHaveLength(1); + expect(otherPatient.body.items[0].amountStroops).toBe('900'); + }); + + it('returns facility-owned rows and exact SQL totals across statuses', async () => { + const response = await request(app) + .get('/payments/facility/ledger?status=escrowed') + .set('Authorization', `Bearer ${facilityA.token}`); + expect(response.status).toBe(200); + expect(response.body.items).toHaveLength(1); + expect(response.body.items[0].amountStroops).toBe('9007199254740993'); + expect(response.body.items[0].stellarTxHash).toBe('a'.repeat(64)); + expect(response.body.items[0].asset).toEqual({ type: 'native', code: 'XLM' }); + expect(response.body.totals).toEqual([ + { + asset: { type: 'native', code: 'XLM' }, + escrowed: '9007199254740993', + released: '20', + refunded: '30', + }, + ]); + + const otherFacility = await request(app) + .get('/payments/facility/ledger') + .set('Authorization', `Bearer ${facilityB.token}`); + expect(otherFacility.body.items).toHaveLength(1); + expect(otherFacility.body.totals[0].escrowed).toBe('900'); + }); + + it('rejects cross-role access and invalid query filters', async () => { + expect((await request(app).get('/payments/mine').set('Authorization', `Bearer ${facilityA.token}`)).status).toBe(403); + expect((await request(app).get('/payments/facility/ledger').set('Authorization', `Bearer ${patientA.token}`)).status).toBe(403); + expect((await request(app).get('/payments/mine?unknown=true').set('Authorization', `Bearer ${patientA.token}`)).status).toBe(400); + expect((await request(app).get('/payments/mine?from=2026-10-20&to=2026-10-19').set('Authorization', `Bearer ${patientA.token}`)).status).toBe(400); + }); +}); \ No newline at end of file diff --git a/docs/api/README.md b/docs/api/README.md index 4df31f59..390190ae 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -227,6 +227,30 @@ endpoint, and the table rejects UPDATE/DELETE in the database. Payment actions (`POST /payments/intents/:id/{fund,release,refund}`) now return `409` when the intent changed state concurrently between the check and the write (previously the second write silently overwrote the first). + +### Payment history and facility ledger (Phase 5.5, issue #90) + +| Method & path | Auth | Query | Notes | +|---|---|---|---| +| `GET /payments/mine` | patient | `status`, `from`, `to`, `limit`, `cursor` | Lists only the caller's payment intents, newest first. | +| `GET /payments/facility/ledger` | facility | `status`, `from`, `to`, `limit`, `cursor` | Lists only the caller facility's intents and includes SQL-aggregated lifecycle totals. | + +`limit` defaults to 50 and is capped at 100. `from` and `to` are inclusive +ISO-8601 dates or date-times; `status` is one of `created`, `escrowed`, +`released`, `refunded`, or `failed`. The opaque `nextCursor` can be passed +as `cursor`; ordering is stable by `(createdAt DESC, id DESC)`. Facility +totals apply the date filters but intentionally remain split across all +three summary statuses even when the row list has a `status` filter. + +Rows include `orderType`, `orderId`, lifecycle status, amount, and the +contract/escrow/transaction references needed for an explorer link. Stroop +amounts and escrow ids are decimal strings. The current schema supports +native XLM only, so `asset` is `{ "type": "native", "code": "XLM" }`. +`refundAfter` is currently `null`: this custodial v1 contract path does not +set an on-chain refund deadline, and the schema has no such field. No +clinical order details are exposed. The facility totals object contains +`escrowed` (pending in escrow), `released`, and `refunded` sums, each as a +BigInt-safe decimal string. ## Patients (`/patients`) — `backend/src/modules/patients` ### Data export — right of access (Phase 8, issue #40) From b13409e05badbdb275b44ce2d15619b4825917b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=97=A9=F0=9D=97=9C=F0=9D=97=96=F0=9D=97=A7=E2=AD=95?= =?UTF-8?q?=EF=B8=8F=F0=9D=97=A5=20=E2=9B=93=EF=B8=8F=20=F0=9D=97=96?= =?UTF-8?q?=E2=AD=95=EF=B8=8F=F0=9D=97=A5=F0=9D=97=A1=F0=9D=97=98?= =?UTF-8?q?=F0=9D=97=A5=F0=9D=97=A6=F0=9D=97=A7=E2=AD=95=EF=B8=8F?= =?UTF-8?q?=F0=9D=97=A1=F0=9D=97=98?= <155533416+victor-134@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:44:28 +0000 Subject: [PATCH 2/6] ci: run nightly Stellar testnet payment checks --- .../workflows/nightly-testnet-payments.yml | 129 ++++++++++++++++++ ROADMAP.md | 7 + backend/scripts/gen-platform-identities.mjs | 55 +++++--- .../testnet-integration/payments.test.mts | 21 ++- 4 files changed, 192 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/nightly-testnet-payments.yml diff --git a/.github/workflows/nightly-testnet-payments.yml b/.github/workflows/nightly-testnet-payments.yml new file mode 100644 index 00000000..df76c580 --- /dev/null +++ b/.github/workflows/nightly-testnet-payments.yml @@ -0,0 +1,129 @@ +name: Nightly testnet payments + +on: + schedule: + - cron: '17 2 * * *' + workflow_dispatch: + inputs: + simulate_failure: + description: Exercise the failure-reporting path without running the test suite + type: boolean + default: false + required: false + +permissions: + contents: read + issues: write + +concurrency: + group: nightly-testnet-payments + cancel-in-progress: false + +jobs: + testnet-payments: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: backend/package-lock.json + + - run: npm ci + - run: npm run prisma:generate + + # These are public testnet identifiers from backend/.env.example, not + # preview deployment secrets. Repository variables can rotate them. + - name: Generate fresh testnet identities and fund with Friendbot + id: identities + continue-on-error: true + env: + ESCROW_CONTRACT_ID: ${{ vars.ESCROW_CONTRACT_ID || 'CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM' }} + ESCROW_TOKEN_ID: ${{ vars.ESCROW_TOKEN_ID || 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC' }} + run: node scripts/gen-platform-identities.mjs + + - name: Simulate a test failure + id: simulated_failure + if: github.event_name == 'workflow_dispatch' && inputs.simulate_failure + continue-on-error: true + run: | + echo 'Deliberate failure to verify the workflow failure reporter.' + exit 1 + + # The test suite starts and tears down its own isolated PostgreSQL + # cluster, so no shared service database or credentials are needed. + - name: Run payments against Stellar testnet + id: test_suite + if: steps.identities.outcome == 'success' && steps.simulated_failure.outcome != 'failure' + continue-on-error: true + env: + ESCROW_CONTRACT_ID: ${{ vars.ESCROW_CONTRACT_ID || 'CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM' }} + ESCROW_TOKEN_ID: ${{ vars.ESCROW_TOKEN_ID || 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC' }} + run: | + set +e + npm run test:integration:payments-testnet > "$RUNNER_TEMP/payments-testnet.log" 2>&1 + result=$? + cat "$RUNNER_TEMP/payments-testnet.log" + set -e + if [[ "$result" -eq 0 ]]; then + echo 'failure_class=none' >> "$GITHUB_OUTPUT" + elif grep -Eiq 'friendbot|ECONN|ETIMEDOUT|ENOTFOUND|fetch failed|MissingValue|NoSuchContract|contract.{0,40}(missing|not found|does not exist)|(missing|not found|does not exist).{0,40}contract' "$RUNNER_TEMP/payments-testnet.log"; then + echo 'failure_class=infrastructure_or_contract_reset' >> "$GITHUB_OUTPUT" + else + echo 'failure_class=test_failure' >> "$GITHUB_OUTPUT" + fi + + exit "$result" + + - name: Update failure summary and tracking issue + if: always() && (steps.identities.outcome == 'failure' || steps.simulated_failure.outcome == 'failure' || steps.test_suite.outcome == 'failure') + env: + GH_TOKEN: ${{ github.token }} + IDENTITY_OUTCOME: ${{ steps.identities.outcome }} + SIMULATED_OUTCOME: ${{ steps.simulated_failure.outcome }} + TEST_OUTCOME: ${{ steps.test_suite.outcome }} + TEST_FAILURE_CLASS: ${{ steps.test_suite.outputs.failure_class }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [[ "$IDENTITY_OUTCOME" == failure ]]; then + failure_class='Infrastructure failure: Friendbot funding or identity setup failed.' + elif [[ "$SIMULATED_OUTCOME" == failure ]]; then + failure_class='Simulated test failure: the manual failure-reporting check was requested.' + elif [[ "$TEST_FAILURE_CLASS" == infrastructure_or_contract_reset ]]; then + failure_class='Infrastructure failure: Friendbot/RPC outage or missing/reset testnet contract.' + else + failure_class='Payment test failure: the test suite failed without a recognized infrastructure signature.' + fi + + { + echo '### Nightly testnet payments failed' + echo + echo "**Classification:** $failure_class" + echo "**Run:** $RUN_URL" + } >> "$GITHUB_STEP_SUMMARY" + + body=$(printf 'The scheduled/manual Stellar testnet payment run failed.\n\n**Classification:** %s\n**Run:** %s\n\nThis issue is updated in place on subsequent failures to avoid notification spam.' "$failure_class" "$RUN_URL") + issue_number=$(gh issue list --state all --limit 100 --json number,title --jq '.[] | select(.title == "[Nightly Testnet Payments] Failure") | .number' | head -n 1) + if [[ -n "$issue_number" ]]; then + gh issue reopen "$issue_number" 2>/dev/null || true + gh issue edit "$issue_number" --body "$body" + else + gh issue create --title '[Nightly Testnet Payments] Failure' --body "$body" + fi + + - name: Fail the workflow when a required step failed + if: always() + env: + IDENTITY_OUTCOME: ${{ steps.identities.outcome }} + SIMULATED_OUTCOME: ${{ steps.simulated_failure.outcome }} + TEST_OUTCOME: ${{ steps.test_suite.outcome }} + run: | + if [[ "$IDENTITY_OUTCOME" == failure || "$SIMULATED_OUTCOME" == failure || "$TEST_OUTCOME" == failure ]]; then + exit 1 + fi \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index e8acf771..457c0d45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,6 +97,8 @@ roadmap. See each phase section below for status and issue links. - [ ] 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). - [x] Patient payment history and facility payout ledger APIs, with ownership-scoped pagination, on-chain references, and BigInt-safe SQL totals. Tracked as [issue #90](https://github.com/DocForum/docforum-core/issues/90) — **implemented; `refundAfter` remains null until a payment model/contract version supports deadlines**. +- [x] Nightly/manual testnet payment verification with fresh Friendbot-funded identities and deduplicated failure tracking. Tracked as [issue #91](https://github.com/DocForum/docforum-core/issues/91). +- [x] Payout privacy prototype: testnet-only pooled payout support in the escrow funding path, with direct facility payouts as the default in production/local environments. Tracked as [issue #92](https://github.com/DocForum/docforum-core/issues/92). - [ ] 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`. - [ ] 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. @@ -506,3 +508,8 @@ roadmap. See each phase section below for status and issue links. are aggregated in PostgreSQL and serialized as decimal strings. The existing custodial v1 model has no `refund_after` field, so the API returns `refundAfter: null` rather than implying a deadline exists. +- 2026-10-19 — Implemented issue #91's scheduled and manually dispatched + testnet payment workflow. Every run generates new test identities, + retries Friendbot with bounded jitter, reports infrastructure/contract + reset failures separately from assertion failures, and updates one open + tracking issue instead of posting repeated reports. diff --git a/backend/scripts/gen-platform-identities.mjs b/backend/scripts/gen-platform-identities.mjs index d5b45faf..5c102776 100644 --- a/backend/scripts/gen-platform-identities.mjs +++ b/backend/scripts/gen-platform-identities.mjs @@ -1,9 +1,6 @@ -// One-off local-dev helper: generates two fresh testnet Stellar keypairs -// (platform payer + releaser, see docs/adr/0004-custodial-payments-v1.md), -// funds them via Friendbot, and appends the secrets DIRECTLY to .env — -// never printed to stdout/logs. Only the public keys (safe to display) -// are printed. Delete this script's output and rerun for a fresh pair; -// never commit the resulting .env. +// Generates fresh testnet payer/releaser identities. Locally, credentials +// are appended directly to .env; in Actions, they go to GITHUB_ENV and are +// masked. Secret values are never printed. See ADR 0004. import { Keypair } from "@stellar/stellar-sdk"; import fs from "node:fs"; import path from "node:path"; @@ -12,9 +9,28 @@ import { fileURLToPath } from "node:url"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const ENV_PATH = path.join(ROOT, ".env"); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + async function fund(publicKey) { - const res = await fetch(`https://friendbot.stellar.org?addr=${encodeURIComponent(publicKey)}`); - if (!res.ok) throw new Error(`Friendbot funding failed for ${publicKey}: ${res.status} ${await res.text()}`); + const maxAttempts = 5; + let lastFailure; + let attempts = 0; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + attempts = attempt + 1; + try { + const res = await fetch(`https://friendbot.stellar.org?addr=${encodeURIComponent(publicKey)}`); + if (res.ok) return; + lastFailure = `HTTP ${res.status}`; + if (res.status !== 429 && res.status < 500) break; + } catch (error) { + lastFailure = error instanceof Error ? error.name : "network error"; + } + if (attempt < maxAttempts - 1) { + const backoff = Math.min(1000 * 2 ** attempt, 16_000); + await wait(backoff + Math.floor(Math.random() * 500)); + } + } + throw new Error(`Friendbot funding failed for ${publicKey} after ${attempts} attempt(s) (${lastFailure}).`); } const payer = Keypair.random(); @@ -23,18 +39,23 @@ const releaser = Keypair.random(); await fund(payer.publicKey()); await fund(releaser.publicKey()); -const lines = [ - "", - "# Generated by scripts/gen-platform-identities.mjs — testnet only, never commit.", +const envLines = [ `PLATFORM_PAYER_SECRET=${payer.secret()}`, `PLATFORM_PAYER_PUBLIC_KEY=${payer.publicKey()}`, `PLATFORM_RELEASER_SECRET=${releaser.secret()}`, `PLATFORM_RELEASER_PUBLIC_KEY=${releaser.publicKey()}`, - "", ].join("\n"); -fs.appendFileSync(ENV_PATH, lines); - -console.log("Funded and wrote secrets to .env (not printed here). Public keys:"); -console.log(" payer: ", payer.publicKey()); -console.log(" releaser:", releaser.publicKey()); +if (process.env.GITHUB_ENV) { + process.stdout.write(`::add-mask::${payer.secret()}\n::add-mask::${releaser.secret()}\n`); + fs.appendFileSync(process.env.GITHUB_ENV, `${envLines}\n`); + console.log("Funded fresh testnet identities for this workflow run."); +} else { + fs.appendFileSync( + ENV_PATH, + ["", "# Generated by scripts/gen-platform-identities.mjs — testnet only, never commit.", envLines, ""].join("\n"), + ); + console.log("Funded and wrote secrets to .env (not printed here). Public keys:"); + console.log(" payer: ", payer.publicKey()); + console.log(" releaser:", releaser.publicKey()); +} diff --git a/backend/tests/testnet-integration/payments.test.mts b/backend/tests/testnet-integration/payments.test.mts index 0a6fa22e..f532c096 100644 --- a/backend/tests/testnet-integration/payments.test.mts +++ b/backend/tests/testnet-integration/payments.test.mts @@ -42,6 +42,7 @@ const pg = new EmbeddedPostgres({ port: PORT, persistent: false, }); + const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_payments_test`; // Phase 8, issue #43: fund/release/refund now take the audit actor. This @@ -51,9 +52,22 @@ const TEST_ACTOR = { userId: null, role: null, requestId: `testnet-${runId}` }; async function fundedKeypair(): Promise { const kp = Keypair.random(); - const res = await fetch(`https://friendbot.stellar.org?addr=${encodeURIComponent(kp.publicKey())}`); - if (!res.ok) throw new Error(`Friendbot funding failed: ${res.status} ${await res.text()}`); - return kp; + let lastFailure: string | undefined; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const res = await fetch(`https://friendbot.stellar.org?addr=${encodeURIComponent(kp.publicKey())}`); + if (res.ok) return kp; + lastFailure = `HTTP ${res.status}`; + if (res.status !== 429 && res.status < 500) break; + } catch (error) { + lastFailure = error instanceof Error ? error.name : 'network error'; + } + if (attempt < 4) { + const backoff = Math.min(1000 * 2 ** attempt, 16_000); + await new Promise((resolve) => setTimeout(resolve, backoff + Math.floor(Math.random() * 500))); + } + } + throw new Error(`Friendbot funding failed after 5 attempts (${lastFailure}).`); } describe('payments module against live testnet', () => { @@ -113,6 +127,7 @@ describe('payments module against live testnet', () => { afterAll(async () => { await prisma?.$disconnect(); await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); }); it('funds and releases a payment intent end-to-end on testnet', async () => { From 0c17a66500cbfaa2ac38e7f35736f6390b9eb74e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=97=A9=F0=9D=97=9C=F0=9D=97=96=F0=9D=97=A7=E2=AD=95?= =?UTF-8?q?=EF=B8=8F=F0=9D=97=A5=20=E2=9B=93=EF=B8=8F=20=F0=9D=97=96?= =?UTF-8?q?=E2=AD=95=EF=B8=8F=F0=9D=97=A5=F0=9D=97=A1=F0=9D=97=98?= =?UTF-8?q?=F0=9D=97=A5=F0=9D=97=A6=F0=9D=97=A7=E2=AD=95=EF=B8=8F?= =?UTF-8?q?=F0=9D=97=A1=F0=9D=97=98?= <155533416+victor-134@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:28:29 +0000 Subject: [PATCH 3/6] feat(payments): add payout privacy prototype --- ROADMAP.md | 5 +++++ backend/.env.example | 5 +++++ backend/src/config/index.ts | 11 +++++++++- .../src/modules/payments/services/index.ts | 9 +++++++- .../payments/services/payout-privacy.ts | 17 ++++++++++++++ .../testnet-integration/payments.test.mts | 9 ++++++++ .../tests/unit/payment-payout-privacy.test.ts | 22 +++++++++++++++++++ 7 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 backend/src/modules/payments/services/payout-privacy.ts create mode 100644 backend/tests/unit/payment-payout-privacy.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 457c0d45..8477f98e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -513,3 +513,8 @@ roadmap. See each phase section below for status and issue links. retries Friendbot with bounded jitter, reports infrastructure/contract reset failures separately from assertion failures, and updates one open tracking issue instead of posting repeated reports. +- 2026-10-19 — Implemented issue #92's pooled payout prototype. The call path + applies a testnet-only pool address override while keeping direct facility + payouts as the default in production and local environments, with the + configuration controlled by `PAYOUT_PRIVACY_MODE` and + `PAYOUT_PRIVACY_POOL_ADDRESS`. diff --git a/backend/.env.example b/backend/.env.example index 76967487..8de44ab2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -85,3 +85,8 @@ ESCROW_LEGACY_CONTRACT_IDS= ESCROW_ASSET_ALLOWLIST= # Mainnet is refused unless explicitly enabled after security review. ALLOW_MAINNET=false +# Issue #92 testnet-only prototype: defaults to direct facility payouts. +# `pooled` routes escrow payouts to this shared G-address and is rejected in +# production; use only a testnet account during evaluation. +PAYOUT_PRIVACY_MODE=facility +PAYOUT_PRIVACY_POOL_ADDRESS= diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 911967f8..c57d755e 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,6 +1,11 @@ import 'dotenv/config'; import { parsePaymentsConfig } from './payments'; +const payoutPrivacyMode = process.env.PAYOUT_PRIVACY_MODE ?? 'facility'; +if (payoutPrivacyMode !== 'facility' && payoutPrivacyMode !== 'pooled') { + throw new Error('PAYOUT_PRIVACY_MODE must be either "facility" or "pooled".'); +} + function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`Missing required env var: ${name}`); @@ -143,5 +148,9 @@ export const config = { ttlHours: Number(process.env.ADMIN_INVITE_TTL_HOURS ?? 72), acceptUrl: process.env.ADMIN_INVITE_ACCEPT_URL ?? 'http://localhost:5173/accept-admin-invite', }, - payments: parsePaymentsConfig(process.env), + payments: { + ...parsePaymentsConfig(process.env), + payoutPrivacyMode, + payoutPrivacyPoolAddress: process.env.PAYOUT_PRIVACY_POOL_ADDRESS, + }, } as const; diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index a13f238c..590807c5 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -14,6 +14,7 @@ import { config } from '../../../config'; import * as paymentsRepo from '../repositories'; import { loadEscrowSdk, type EscrowSdkKeypair } from './escrow-sdk-loader'; import { getPlatformSigners } from './platform-signers'; +import { resolveEscrowPayee } from './payout-privacy'; import { getMyPatientProfile, paginate, type PatientExportSection } from '../../patients/services'; import { prisma } from '../../../db/prisma-client'; import * as auditService from '../../audit/services'; @@ -256,11 +257,17 @@ export async function fundPaymentIntent(actor: auditService.AuditActor, id: stri 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 payee = resolveEscrowPayee({ + facilityAddress: walletLink.stellarPublicKey, + mode: config.payments.payoutPrivacyMode, + poolAddress: config.payments.payoutPrivacyPoolAddress, + production: process.env.NODE_ENV === 'production', + }); const client = await escrowClient(escrowContractId); const { escrowId, txHash } = await client.createEscrow({ payer: platformPayer as unknown as EscrowSdkKeypair, - payee: walletLink.stellarPublicKey, + payee, token: escrowTokenId, amount: intent.amountStroops, // The PaymentIntent's own id is the opaque condition_ref — the diff --git a/backend/src/modules/payments/services/payout-privacy.ts b/backend/src/modules/payments/services/payout-privacy.ts new file mode 100644 index 00000000..a76ff9af --- /dev/null +++ b/backend/src/modules/payments/services/payout-privacy.ts @@ -0,0 +1,17 @@ +import { HttpError } from '../../../http-error'; + +export function resolveEscrowPayee(input: { + facilityAddress: string; + mode: 'facility' | 'pooled'; + poolAddress?: string; + production: boolean; +}): string { + if (input.mode === 'facility') return input.facilityAddress; + if (input.production) { + throw new HttpError(503, 'Pooled payout privacy is a testnet prototype and is disabled in production.'); + } + if (!input.poolAddress || !/^G[A-Z2-7]{55}$/.test(input.poolAddress)) { + throw new HttpError(503, 'Pooled payout privacy requires a valid PAYOUT_PRIVACY_POOL_ADDRESS.'); + } + return input.poolAddress; +} \ No newline at end of file diff --git a/backend/tests/testnet-integration/payments.test.mts b/backend/tests/testnet-integration/payments.test.mts index f532c096..7617e2b1 100644 --- a/backend/tests/testnet-integration/payments.test.mts +++ b/backend/tests/testnet-integration/payments.test.mts @@ -140,6 +140,15 @@ describe('payments module against live testnet', () => { expect(funded.escrowId).not.toBeNull(); expect(funded.stellarTxHash).toBeTruthy(); + const transactionResponse = await fetch(`https://horizon-testnet.stellar.org/transactions/${funded.stellarTxHash}`); + if (!transactionResponse.ok) { + throw new Error(`Unable to retrieve testnet fee measurement: HTTP ${transactionResponse.status}`); + } + const transaction = (await transactionResponse.json()) as { fee_charged: string }; + console.log( + `[payout privacy testnet] ${process.env.PAYOUT_PRIVACY_MODE ?? 'facility'} funding fee: ${transaction.fee_charged} stroops`, + ); + const released = await paymentsService.releasePaymentIntent(TEST_ACTOR, intent.id); expect(released.status).toBe('released'); }, 60_000); diff --git a/backend/tests/unit/payment-payout-privacy.test.ts b/backend/tests/unit/payment-payout-privacy.test.ts new file mode 100644 index 00000000..4285ec6e --- /dev/null +++ b/backend/tests/unit/payment-payout-privacy.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { HttpError } from '../../src/http-error'; +import { resolveEscrowPayee } from '../../src/modules/payments/services/payout-privacy'; + +const facilityAddress = `G${'A'.repeat(55)}`; +const poolAddress = `G${'B'.repeat(55)}`; + +describe('payout privacy prototype', () => { + it('keeps the facility wallet as the default payout destination', () => { + expect(resolveEscrowPayee({ facilityAddress, mode: 'facility', production: true })).toBe(facilityAddress); + }); + + it('uses the shared pool only when explicitly enabled outside production', () => { + expect(resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress, production: false })).toBe(poolAddress); + }); + + it('rejects pooled mode in production or without a valid pool address', () => { + expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress, production: true })).toThrow(HttpError); + expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', production: false })).toThrow(HttpError); + expect(() => resolveEscrowPayee({ facilityAddress, mode: 'pooled', poolAddress: `M${'A'.repeat(55)}`, production: false })).toThrow(HttpError); + }); +}); \ No newline at end of file From 84609aea12ca4d9b6833bd35e10829600f75e9f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=97=A9=F0=9D=97=9C=F0=9D=97=96=F0=9D=97=A7=E2=AD=95?= =?UTF-8?q?=EF=B8=8F=F0=9D=97=A5=20=E2=9B=93=EF=B8=8F=20=F0=9D=97=96?= =?UTF-8?q?=E2=AD=95=EF=B8=8F=F0=9D=97=A5=F0=9D=97=A1=F0=9D=97=98?= =?UTF-8?q?=F0=9D=97=A5=F0=9D=97=A6=F0=9D=97=A7=E2=AD=95=EF=B8=8F?= =?UTF-8?q?=F0=9D=97=A1=F0=9D=97=98?= <155533416+victor-134@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:10:55 +0000 Subject: [PATCH 4/6] fix(payments): import Prisma payment types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/src/modules/payments/services/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index 590807c5..d0e762f7 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -11,6 +11,7 @@ // Patients never hold or sign with a wallet in this design. import { HttpError } from '../../../http-error'; import { config } from '../../../config'; +import type { PaymentIntent, PaymentIntentStatus } from '@prisma/client'; import * as paymentsRepo from '../repositories'; import { loadEscrowSdk, type EscrowSdkKeypair } from './escrow-sdk-loader'; import { getPlatformSigners } from './platform-signers'; From 5dc0b11e58cd9c7359b8d774cd77b2e384d41c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=97=A9=F0=9D=97=9C=F0=9D=97=96=F0=9D=97=A7=E2=AD=95?= =?UTF-8?q?=EF=B8=8F=F0=9D=97=A5=20=E2=9B=93=EF=B8=8F=20=F0=9D=97=96?= =?UTF-8?q?=E2=AD=95=EF=B8=8F=F0=9D=97=A5=F0=9D=97=A1=F0=9D=97=98?= =?UTF-8?q?=F0=9D=97=A5=F0=9D=97=A6=F0=9D=97=A7=E2=AD=95=EF=B8=8F?= =?UTF-8?q?=F0=9D=97=A1=F0=9D=97=98?= <155533416+victor-134@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:19:24 +0000 Subject: [PATCH 5/6] fix(payments): align history with current order schema Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/src/modules/payments/services/index.ts | 4 ++-- backend/tests/integration/payment-history.test.mts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index d0e762f7..ba5c2fcb 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -158,8 +158,8 @@ function paymentPage(filters: PaymentHistoryFilters, owner: { patientProfileId: function serializePayment(payment: PaymentIntent) { return { id: payment.id, - orderType: payment.orderType, - orderId: payment.orderId, + orderType: payment.prescriptionId ? 'prescription' : 'lab_order', + orderId: payment.prescriptionId ?? payment.labOrderId, status: payment.status, amountStroops: payment.amountStroops.toString(), asset: { type: 'native', code: 'XLM' }, diff --git a/backend/tests/integration/payment-history.test.mts b/backend/tests/integration/payment-history.test.mts index cb2ea381..dbeded27 100644 --- a/backend/tests/integration/payment-history.test.mts +++ b/backend/tests/integration/payment-history.test.mts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; import EmbeddedPostgres from 'embedded-postgres'; +import { createOrderFixture } from './order-fixtures.mjs'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const PORT = 5442; @@ -30,14 +31,20 @@ describe('payment history and facility ledger APIs', () => { async function createIntent(input: { patientProfileId: string; facilityId: string; - status: 'created' | 'escrowed' | 'released' | 'refunded' | 'failed'; + status: 'created' | 'escrowed' | 'released' | 'refunded'; amountStroops: bigint; createdAt: Date; }) { + const order = await createOrderFixture(prisma, { + patientProfileId: input.patientProfileId, + facilityProfileId: input.facilityId, + kind: 'lab_order', + amountStroops: input.amountStroops, + tag: `payment-history-${runId}-${Math.random().toString(36).slice(2)}`, + }); return prisma.paymentIntent.create({ data: { - orderType: 'lab_order', - orderId: `order-${Math.random()}`, + labOrderId: order.id, patientProfileId: input.patientProfileId, facilityId: input.facilityId, status: input.status, From 12df9a3bd9e784dd6d9375bc6fb805160f2dbe17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=97=A9=F0=9D=97=9C=F0=9D=97=96=F0=9D=97=A7=E2=AD=95?= =?UTF-8?q?=EF=B8=8F=F0=9D=97=A5=20=E2=9B=93=EF=B8=8F=20=F0=9D=97=96?= =?UTF-8?q?=E2=AD=95=EF=B8=8F=F0=9D=97=A5=F0=9D=97=A1=F0=9D=97=98?= =?UTF-8?q?=F0=9D=97=A5=F0=9D=97=A6=F0=9D=97=A7=E2=AD=95=EF=B8=8F?= =?UTF-8?q?=F0=9D=97=A1=F0=9D=97=98?= <155533416+victor-134@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:18:22 +0000 Subject: [PATCH 6/6] fix(payments): address history review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/modules/payments/services/index.ts | 1 - backend/src/modules/payments/validation.ts | 15 +++++++++++---- .../integration/payment-history.test.mts | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index 5ccd6fbd..047039c8 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -20,7 +20,6 @@ import { getMyPatientProfile, paginate, type PatientExportSection } from '../../ import { prisma } from '../../../db/prisma-client'; import * as auditService from '../../audit/services'; import type { Prisma } from '@prisma/client'; -import type { PaymentIntentStatus } from '@prisma/client'; import { transitionPaymentIntentStatus } from './state-machine'; // Types are inferred from the prisma instance; use 'as' assertions where needed diff --git a/backend/src/modules/payments/validation.ts b/backend/src/modules/payments/validation.ts index 5fa6ba8a..c09918d7 100644 --- a/backend/src/modules/payments/validation.ts +++ b/backend/src/modules/payments/validation.ts @@ -26,18 +26,25 @@ export const createPaymentIntentSchema = z.object({ .transform((value) => BigInt(value)), }); -const isoDate = z +const isoDateString = z .string() .refine((value) => !Number.isNaN(new Date(value).getTime()) && /^\d{4}-\d{2}-\d{2}/.test(value), { message: 'must be an ISO-8601 date or date-time', - }) - .transform((value) => new Date(value)); + }); + +const isoDate = isoDateString.transform((value) => new Date(value)); + +const inclusiveEndDate = isoDateString.transform((value) => { + const date = new Date(value); + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) date.setUTCHours(23, 59, 59, 999); + return date; +}); export const paymentHistoryQuerySchema = z .object({ status: z.enum(['created', 'escrowed', 'released', 'refunded', 'failed']).optional(), from: isoDate.optional(), - to: isoDate.optional(), + to: inclusiveEndDate.optional(), cursor: z.string().min(1).max(512).optional(), limit: z.coerce.number().int().min(1).max(100).default(50), }) diff --git a/backend/tests/integration/payment-history.test.mts b/backend/tests/integration/payment-history.test.mts index dbeded27..eba107d0 100644 --- a/backend/tests/integration/payment-history.test.mts +++ b/backend/tests/integration/payment-history.test.mts @@ -132,6 +132,25 @@ describe('payment history and facility ledger APIs', () => { expect(otherPatient.body.items[0].amountStroops).toBe('900'); }); + it('includes the entire final day when filtering by a date-only to value', async () => { + const payment = await createIntent({ + patientProfileId: patientB.id, + facilityId: facilityB.id, + status: 'created', + amountStroops: 40n, + createdAt: new Date('2020-01-02T23:59:59.500Z'), + }); + + const response = await request(app) + .get('/payments/mine?from=2020-01-02&to=2020-01-02') + .set('Authorization', `Bearer ${patientB.token}`); + + expect(response.status).toBe(200); + expect(response.body.items).toHaveLength(1); + expect(response.body.items[0].id).toBe(payment.id); + await prisma.paymentIntent.delete({ where: { id: payment.id } }); + }); + it('returns facility-owned rows and exact SQL totals across statuses', async () => { const response = await request(app) .get('/payments/facility/ledger?status=escrowed')