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
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,8 @@ PLATFORM_RELEASER_SECRET=
ESCROW_CONTRACT_ID=CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM
# Native XLM SAC on testnet (stellar contract id asset --asset native --network testnet)
ESCROW_TOKEN_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
# 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=
7 changes: 7 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import 'dotenv/config';

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 @@ -146,5 +151,7 @@ export const config = {
platformReleaserSecret: process.env.PLATFORM_RELEASER_SECRET,
escrowContractId: process.env.ESCROW_CONTRACT_ID,
escrowTokenId: process.env.ESCROW_TOKEN_ID,
payoutPrivacyMode,
payoutPrivacyPoolAddress: process.env.PAYOUT_PRIVACY_POOL_ADDRESS,
},
} as const;
9 changes: 8 additions & 1 deletion backend/src/modules/payments/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { HttpError } from '../../../http-error';
import { config } from '../../../config';
import * as paymentsRepo from '../repositories';
import { loadEscrowSdk } from './escrow-sdk-loader';
import { resolveEscrowPayee } from './payout-privacy';
import { paginate, type PatientExportSection } from '../../patients/services';
import { prisma } from '../../../db/prisma-client';
import * as auditService from '../../audit/services';
Expand Down Expand Up @@ -125,11 +126,17 @@ export async function fundPaymentIntent(actor: auditService.AuditActor, id: stri
if (!walletLink) throw new HttpError(409, 'The facility for this payment has no linked wallet.');

const { platformPayer, platformReleaser, escrowContractId, escrowTokenId } = await requirePaymentsConfig();
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,
payee: walletLink.stellarPublicKey,
payee,
token: escrowTokenId,
amount: intent.amountStroops,
// The PaymentIntent's own id is the opaque condition_ref — the
Expand Down
17 changes: 17 additions & 0 deletions backend/src/modules/payments/services/payout-privacy.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions backend/tests/testnet-integration/payments.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,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);
Expand Down
22 changes: 22 additions & 0 deletions backend/tests/unit/payment-payout-privacy.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading