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: 6 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
14 changes: 13 additions & 1 deletion backend/src/modules/payments/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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));
}
49 changes: 49 additions & 0 deletions backend/src/modules/payments/repositories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/modules/payments/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
87 changes: 86 additions & 1 deletion backend/src/modules/payments/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
// Types are inferred from the prisma instance; use 'as' assertions where needed
Expand Down Expand Up @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions backend/src/modules/payments/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.',
});
159 changes: 159 additions & 0 deletions backend/tests/integration/payment-history.test.mts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../../src/app.js').createApp>;
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);
});
});
Loading
Loading