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
1 change: 1 addition & 0 deletions ENV_VARS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
| VAPID_PRIVATE_KEY | VAPID private key for Web Push API | auto-generated | No |
| WS_ENABLED | Enable/disable WebSocket support | true | No |
| WS_PORT | WebSocket port | 3001 | No |
| PAYOUT_APPROVAL_THRESHOLD | Payouts at or above this amount require approval | 10000 | No |

## Frontend

Expand Down
46 changes: 45 additions & 1 deletion backend/src/services/__tests__/fiat-payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,48 @@ describe('fiatPaymentsService', () => {
expect(report.totals.fees).toBeGreaterThan(0);
expect(report.byMethod.ach.count).toBe(1);
});
});
});
describe('fiatPaymentsService payment correctness', () => {
beforeEach(() => {
fiatPaymentsService.resetForTests();
});

const createVerifiedAccount = () =>
fiatPaymentsService.createBankAccount({
accountHolderName: 'Correctness Co',
bankName: 'Chase',
accountNumberMasked: '****2222',
routingNumber: '021000021',
verificationMethod: 'plaid',
countryCode: 'US',
});

const paymentInput = (bankAccountId: string, amount: number) => ({
method: 'ach' as const,
bankAccountId,
recipient: {
name: 'Vendor LLC',
accountNumberMasked: '****4444',
routingNumber: '021000021',
bankName: 'Wells Fargo',
countryCode: 'US',
},
amount,
currency: 'USD',
isInternational: false,
description: 'Invoice payout',
});

it.each([0, -50, Number.NaN, Number.POSITIVE_INFINITY])('rejects invalid amount %s', (amount) => {
const account = createVerifiedAccount();
expect(() => fiatPaymentsService.createPayment(paymentInput(account.id, amount))).toThrow('INVALID_PAYMENT_AMOUNT');
});

it('keeps no payments from a batch when one of its payments is invalid', () => {
const account = createVerifiedAccount();
const inputs = [paymentInput(account.id, 100), paymentInput(account.id, 0)];

expect(() => fiatPaymentsService.createBatch('batch-ref', inputs)).toThrow('INVALID_PAYMENT_AMOUNT');
expect(fiatPaymentsService.listPayments()).toHaveLength(0);
});
});
2 changes: 1 addition & 1 deletion backend/src/services/__tests__/issues-456-459.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('Payout Engine (Issue 457)', () => {
expect(batches).toHaveLength(1);
expect(batches[0].status).toBe('pending');

const approved = await engine.approveManualPayout(batches[0].id);
const approved = await engine.approveManualPayout(batches[0].id, { id: 'fin_1', role: 'finance_approver' });
expect(approved).toBe(true);
expect(batches[0].status).toBe('completed');
});
Expand Down
71 changes: 71 additions & 0 deletions backend/src/services/__tests__/stripe-connect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const stripeMock = vi.hoisted(() => ({
accounts: { create: vi.fn() },
accountLinks: { create: vi.fn() },
paymentIntents: { create: vi.fn() },
transfers: { create: vi.fn() },
}));

vi.mock('../stripe.js', () => ({ getStripe: () => stripeMock }));
vi.mock('../auditService.js', () => ({ auditService: { logAction: vi.fn().mockResolvedValue(undefined) } }));
vi.mock('../../config/env.js', () => ({ config: () => ({ APP_URL: 'https://app.example.com' }) }));

import {
connectedAccounts,
createConnectedAccount,
createPaymentIntentWithConnect,
createTransfer,
handleConnectWebhook,
} from '../stripe-connect.js';

describe('stripe-connect', () => {
beforeEach(() => {
vi.clearAllMocks();
connectedAccounts.clear();
});

it('does not track a merchant when the onboarding link fails', async () => {
stripeMock.accounts.create.mockResolvedValue({ id: 'acct_1' });
stripeMock.accountLinks.create.mockRejectedValue(new Error('stripe down'));

await expect(createConnectedAccount('merch_1', 'owner@example.com')).rejects.toThrow('stripe down');
expect(connectedAccounts.has('merch_1')).toBe(false);
});

it('tracks the merchant once onboarding succeeds', async () => {
stripeMock.accounts.create.mockResolvedValue({ id: 'acct_2' });
stripeMock.accountLinks.create.mockResolvedValue({ url: 'https://connect.stripe.com/setup' });

const result = await createConnectedAccount('merch_2', 'owner@example.com');
expect(result).toEqual({ accountId: 'acct_2', onboardingUrl: 'https://connect.stripe.com/setup' });
expect(connectedAccounts.get('merch_2')?.onboardingStatus).toBe('onboarding');
});

it('ignores account.updated webhooks for unknown accounts', async () => {
await expect(
handleConnectWebhook({ type: 'account.updated', data: { object: { id: 'acct_unknown' } } } as never)
).resolves.toBeUndefined();
});

it.each([0, -100, 10.5, Number.NaN])('rejects non-minor-unit amount %s for payment intents', async (amount) => {
await expect(createPaymentIntentWithConnect(amount, 'usd', 'acct_1')).rejects.toThrow(
'Amount must be a positive integer in minor units'
);
expect(stripeMock.paymentIntents.create).not.toHaveBeenCalled();
});

it('rejects non-minor-unit amounts for transfers', async () => {
await expect(createTransfer(0, 'usd', 'acct_1')).rejects.toThrow('Amount must be a positive integer in minor units');
expect(stripeMock.transfers.create).not.toHaveBeenCalled();
});

it('creates a payment intent with a 0.5% application fee for valid amounts', async () => {
stripeMock.paymentIntents.create.mockResolvedValue({ id: 'pi_1' });

await createPaymentIntentWithConnect(10_000, 'USD', 'acct_1');
expect(stripeMock.paymentIntents.create).toHaveBeenCalledWith(
expect.objectContaining({ amount: 10_000, currency: 'usd', application_fee_amount: 50 })
);
});
});
17 changes: 16 additions & 1 deletion backend/src/services/fiat-payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ class FiatPaymentsService {
}

createPayment(input: CreatePaymentInput): FiatPaymentRecord {
if (!Number.isFinite(input.amount) || input.amount <= 0) {
throw new Error('INVALID_PAYMENT_AMOUNT');
}
const bankAccount = this.bankAccounts.get(input.bankAccountId);
if (!bankAccount) {
throw new Error('BANK_ACCOUNT_NOT_FOUND');
Expand Down Expand Up @@ -283,7 +286,19 @@ class FiatPaymentsService {
}

createBatch(reference: string, inputs: CreatePaymentInput[]): { batchId: string; reference: string; payments: FiatPaymentRecord[] } {
const payments = inputs.map((input) => this.createPayment(input));
// All-or-nothing: if any payment in the batch is rejected, none of the batch is kept.
const paymentIdsBefore = new Set(this.payments.keys());
const eventCountBefore = this.events.length;
let payments: FiatPaymentRecord[];
try {
payments = inputs.map((input) => this.createPayment(input));
} catch (error) {
for (const id of [...this.payments.keys()]) {
if (!paymentIdsBefore.has(id)) this.payments.delete(id);
}
this.events.length = eventCountBefore;
throw error;
}
return {
batchId: randomUUID(),
reference,
Expand Down
96 changes: 96 additions & 0 deletions backend/src/services/payouts/__tests__/payout-engine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it } from 'vitest';
import { DEFAULT_PAYOUT_APPROVAL_THRESHOLD, PayoutApprover, PayoutEngine } from '../payout-engine.js';

const finance: PayoutApprover = { id: 'fin_1', role: 'finance_approver' };
const outsider = { id: 'usr_9', role: 'merchant' } as unknown as PayoutApprover;

function engineWithSchedule(options?: { approvalThreshold?: number; autoApproveLimit?: number }) {
const engine = new PayoutEngine({ approvalThreshold: options?.approvalThreshold });
engine.configureSchedule({
id: 'sch_1',
merchantId: 'merch_1',
scheduleType: 'threshold',
thresholdAmount: 100,
preferredAsset: 'USDC',
autoApproveLimit: options?.autoApproveLimit ?? 500,
});
return engine;
}

describe('PayoutEngine approval workflow', () => {
afterEach(() => {
delete process.env.PAYOUT_APPROVAL_THRESHOLD;
});

it('uses the default approval threshold when none is configured', () => {
expect(new PayoutEngine().approvalThreshold).toBe(DEFAULT_PAYOUT_APPROVAL_THRESHOLD);
});

it('reads the approval threshold from PAYOUT_APPROVAL_THRESHOLD', () => {
process.env.PAYOUT_APPROVAL_THRESHOLD = '2500';
expect(new PayoutEngine().approvalThreshold).toBe(2500);
});

it('rejects an invalid approval threshold', () => {
expect(() => new PayoutEngine({ approvalThreshold: 0 })).toThrow('INVALID_PAYOUT_APPROVAL_THRESHOLD');
});

it('holds payouts at or above the configured threshold for review', async () => {
const engine = engineWithSchedule({ approvalThreshold: 1000, autoApproveLimit: 5000 });
engine.setPendingAmount('merch_1', 1000);

const [batch] = await engine.evaluatePayouts();
expect(batch.status).toBe('pending');
});

it('does not create duplicate batches while a payout awaits approval', async () => {
const engine = engineWithSchedule({ autoApproveLimit: 500 });
engine.setPendingAmount('merch_1', 600);

await engine.evaluatePayouts();
const second = await engine.evaluatePayouts();

expect(second).toHaveLength(0);
expect(engine.getPayoutHistory()).toHaveLength(1);
});

it('rejects approval from callers without an approver role', async () => {
const engine = engineWithSchedule({ autoApproveLimit: 500 });
engine.setPendingAmount('merch_1', 600);
const [batch] = await engine.evaluatePayouts();

await expect(engine.approveManualPayout(batch.id, outsider)).rejects.toThrow('PAYOUT_APPROVER_FORBIDDEN');
expect(batch.status).toBe('pending');
});

it('returns reserved funds to the merchant when a payout is rejected', async () => {
const engine = engineWithSchedule({ autoApproveLimit: 500 });
engine.setPendingAmount('merch_1', 600);
const [batch] = await engine.evaluatePayouts();

expect(await engine.rejectManualPayout(batch.id, finance)).toBe(true);
expect(engine.getPendingAmount('merch_1')).toBe(600);
});

it('does not allow a rejected payout to be retried around review', async () => {
const engine = engineWithSchedule({ autoApproveLimit: 500 });
engine.setPendingAmount('merch_1', 600);
const [batch] = await engine.evaluatePayouts();
await engine.rejectManualPayout(batch.id, finance);

expect(await engine.retryFailedBatch(batch.id, finance)).toBe(false);
expect(batch.status).toBe('failed');
});

it('records an audit entry for every approval decision', async () => {
const engine = engineWithSchedule({ autoApproveLimit: 500 });
engine.setPendingAmount('merch_1', 600);
const [batch] = await engine.evaluatePayouts();

await engine.approveManualPayout(batch.id, finance);

const trail = engine.getAuditTrail(batch.id);
expect(trail.map((entry) => entry.action)).toEqual(['created', 'approved']);
expect(trail[1].actorId).toBe('fin_1');
});
});
Loading
Loading