Skip to content
Merged
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
129 changes: 129 additions & 0 deletions .github/workflows/nightly-testnet-payments.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ roadmap. See each phase section below for status and issue links.
- [ ] Idempotent escrow funding + on-chain reconcile job (`fundPaymentIntent` is check-then-act — docforum-escrow threat model §5). Tracked as [issue #24](https://github.com/DocForum/docforum-core/issues/24) (High, 200 pts).
- [ ] ADR: releaser key custody — multisig, KMS, or per-period rotation (threat model T1/T2). Tracked as [issue #25](https://github.com/DocForum/docforum-core/issues/25) (Medium, 150 pts).
- [ ] Checksum-validate facility wallet keys, not just regex. Tracked as [issue #26](https://github.com/DocForum/docforum-core/issues/26) (Trivial, 100 pts).
- [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.
Expand Down Expand Up @@ -511,3 +514,18 @@ 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.
- 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.
- 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`.
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
55 changes: 38 additions & 17 deletions backend/scripts/gen-platform-identities.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand All @@ -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());
}
11 changes: 10 additions & 1 deletion backend/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -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}`);
Expand Down Expand Up @@ -158,5 +163,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;
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 } from '../validation';
import { linkWalletSchema, paymentHistoryQuerySchema } from '../validation';
import { HttpError } from '../../../http-error';
import { getParam } from '../../../request-params';
import { auditActor } from '../../../middleware/request-id';
Expand Down Expand Up @@ -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));
}
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 @@ -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[]) {
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
Loading
Loading