From 7cafaf139fd0a073f1b3dd00b42545c0f59ce812 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] 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 | 20 +++ .../integration/payment-history.test.mts | 159 ++++++++++++++++++ docs/api/README.md | 24 +++ 8 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 backend/tests/integration/payment-history.test.mts diff --git a/ROADMAP.md b/ROADMAP.md index dd2c646..50a9c69 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,6 +87,7 @@ 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. @@ -405,3 +406,8 @@ for status and issue links. observed state, so a racing second action gets 409 instead of silently overwriting). Rollback coupling proven with a real injected DB failure, not a mock. Phase 3/4 wiring explicitly left pending. +- 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 14e1a7f..9cb009c 100644 --- a/backend/src/modules/payments/controllers/index.ts +++ b/backend/src/modules/payments/controllers/index.ts @@ -1,6 +1,6 @@ import type { Request, Response } from 'express'; import * as paymentsService from '../services'; -import { linkWalletSchema, createPaymentIntentSchema } from '../validation'; +import { linkWalletSchema, createPaymentIntentSchema, paymentHistoryQuerySchema } from '../validation'; import { HttpError } from '../../../http-error'; import { getParam } from '../../../request-params'; import { auditActor } from '../../../middleware/request-id'; @@ -73,3 +73,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 bfc8c32..9424410 100644 --- a/backend/src/modules/payments/repositories/index.ts +++ b/backend/src/modules/payments/repositories/index.ts @@ -47,6 +47,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 7f80503..a1b033b 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 ac64c26..8c38701 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -13,7 +13,7 @@ import { HttpError } from '../../../http-error'; import { config } from '../../../config'; import * as paymentsRepo from '../repositories'; import { loadEscrowSdk } from './escrow-sdk-loader'; -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 { PaymentIntent, PaymentIntentStatus, Prisma } from '@prisma/client'; @@ -78,6 +78,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 3513c8d..5fa6ba8 100644 --- a/backend/src/modules/payments/validation.ts +++ b/backend/src/modules/payments/validation.ts @@ -25,3 +25,23 @@ export const createPaymentIntentSchema = z.object({ .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 0000000..cb2ea38 --- /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 5e67678..bb35445 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -178,6 +178,30 @@ 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)