Skip to content

feat: payout execution, money correctness and payout API security (#1183 #1184 #1185 #1186) - #1290

Merged
github-actions[bot] merged 1 commit into
Smartdevs17:mainfrom
SagaciousBoss:fix/payments-payout-reliability-1183-1186
Oct 17, 2026
Merged

github-actions[bot] merged 1 commit into
Smartdevs17:mainfrom
SagaciousBoss:fix/payments-payout-reliability-1183-1186

Conversation

@SagaciousBoss

@SagaciousBoss SagaciousBoss commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Payout reliability, payment correctness and payment security (iterations 10-11)

Closes #1183
Closes #1184
Closes #1185
Closes #1186

These four tickets are templated "deliver the next increment" issues with no fixed scope.
Following the precedent of #1286 and #1287, this PR takes one concrete, real problem per
theme instead of a broad sweep, and keeps each change independently reviewable.

Scope

#1185 — Improve payout reliability

PayoutEngine had no execution path. Auto-approved and manually approved batches were
stamped completed without anything being sent, executing was unreachable dead state,
there was no failure or retry path, and calendar schedules fired on every evaluation pass.

  • pending -> executing -> completed | failed is now a guarded transition table.
    completed is only reached when the rail returns a transaction hash.
  • An injected executor actually settles the batch and receives a stable
    idempotencyKey on every attempt.
  • Failures schedule an exponential backoff with jitter, capped by maxAttempts.
  • daily / weekly / monthly respect their period and key their batches by period
    bucket, so a replayed pass cannot pay the same period twice.
  • thresholdAmount of 0 no longer falls through to the "pay anything" branch.
  • payout.created / pending_review / executing / completed / failed / delayed
    events via a pluggable sink. A throwing subscriber cannot abort a payout decision.
  • Reservations are released on rejection, and on exhaustion only after an approver
    explicitly calls releaseReservedFunds — returning them automatically would mint a
    fresh batch and spin against a rail that is already down.
  • One merchant's misconfiguration is isolated from the rest of the evaluation pass.
  • New REST surface at /api/v1/payouts, mounted in backend/src/index.ts.

#1184 — Fix payment correctness bugs

  • Unsupported FX pairs were priced at 1.0. getCurrentRate returned par for any
    pair it did not know, so GBP -> BTC would quote one Bitcoin per pound and
    confirmConversion would record it. Unquoted pairs are now an error.
  • Money was derived with binary floating point. 33.33 * 1.08 is
    35.99640000000001, so the crypto amount stopped agreeing with the fiat amount charged.
    Added backend/src/services/money.ts (bigint minor units, half-away-from-zero
    rounding) and routed conversions through it.
  • lockRate accepted NaN, negative and infinite amounts, producing locks whose
    conversion amounts were "NaN".
  • processFiatRefund recorded 'failed' — indistinguishable from a real failure —
    returned true without refunding anything when no payment intent was present, and was
    not idempotent, so a retry issued a second Stripe refund.
  • Revenue pool recipients were validated individually but not in aggregate, so a pool
    could be configured to distribute more than 100% of what it received.
  • recordDistribution had no idempotency on txHash, so a reorg or retried indexer
    double-counted revenue.
  • getConversion / listConversions returned live internal references, letting a
    caller mutate a financial record.

#1186 — Strengthen payment security

  • Every /api/v1/payouts route, including reads, requires an authenticated approver
    principal. The check runs before any lookup, so an unauthenticated caller cannot probe
    batch existence from the status code.
  • Idempotency-Key on every money-moving route. A key reused with a different body
    is rejected with 409 idempotency_key_reuse_mismatch instead of silently replaying the
    earlier response — previously a caller could believe it created a new resource while
    holding the old one.
  • Strict allowlists (asset, schedule cadence) and bounded amounts and identifiers.
  • The asset allowlist and amount ceiling are enforced in the engine as well as the
    route, so a caller reaching the engine directly is not less protected.
  • Route-scoped rate limits: 120/min for the router, 30/min for approval decisions.
  • Engine errors are mapped to sanitised HTTP responses; rail text never reaches clients.

#1183 — Improve documentation

  • docs/PAYOUT_RELIABILITY.md — lifecycle, retry policy, reservations, events, API, ops runbook.
  • docs/PAYMENT_CORRECTNESS.md — the money, FX, idempotency and allocation invariants.
  • docs/security/payment-security.md — authentication, authorization, allowlists, rate limits, audit.
  • docs/README.md index entries, and PAYOUT_APPROVALS.md cross-links.

Files

File Change
backend/src/services/money.ts new — decimal-safe money arithmetic
backend/src/services/payouts/payout-engine.ts execution, retry, cadence, events, limits
backend/src/services/fiat-crypto.ts pair validation, amount validation, exact conversion, refund semantics
backend/src/services/revenue-pool/index.ts allocation ceiling, distribution idempotency
backend/src/middleware/idempotency.ts payload fingerprinting, bounded cache
backend/src/routes/payouts.ts new — /api/v1/payouts
backend/src/routes/stripe.ts unsupported-pair and settlement guards
backend/src/index.ts mount the payouts router
backend/src/**/__tests__/* new and updated tests
docs/PAYOUT_RELIABILITY.md, docs/PAYMENT_CORRECTNESS.md, docs/security/payment-security.md new

Tests

  • services/__tests__/money.test.ts (new) — parsing, rounding, exact rate application.
  • services/payouts/__tests__/payout-engine.test.ts — execution, backoff growth,
    attempt exhaustion, reservation release, cadence, cycle keys, event emission,
    per-merchant isolation, concurrency collapse.
  • services/__tests__/fiat-crypto.test.ts — unquoted pairs, amount validation, exact
    conversion, refund state and idempotency, deterministic settlement references, snapshot
    isolation.
  • services/revenue-pool/__tests__/revenue-pool.test.ts (new) — allocation ceiling on
    add and update, distribution replay.
  • middleware/__tests__/idempotency.test.ts — fingerprint mismatch, key ordering,
    nested payloads, cache eviction.
  • routes/__tests__/payouts.test.ts (new) — auth on every route, validation, idempotency
    replay and mismatch, approve/reject lifecycle, rate limiting.

Verification

npm run test --workspace backend, npm run lint --workspace backend and
npm run test:docs are the relevant commands. Note: this change set was authored in an
environment without a Node runtime, so the suites were not executed locally — CI is the
first run. The changes were reviewed by hand against the existing suites, and the two
pre-existing payout tests that assert the old "no execution" behaviour were updated to the
new contract.

…artdevs17#1183 Smartdevs17#1184 Smartdevs17#1185 Smartdevs17#1186)

Payout reliability (Smartdevs17#1185)
- PayoutEngine had no execution path: auto-approved and approved batches were
  stamped completed without anything being sent, executing was unreachable, and
  there was no failure or retry path.
- Add a guarded pending -> executing -> completed|failed transition table; a batch
  is only completed when the rail returns a transaction hash.
- Add an injected executor that receives a stable idempotencyKey on every attempt.
- Add exponential backoff with jitter, capped by maxAttempts. Exhausted batches keep
  their reservation and are released only by an approver, so an unattended retry
  cannot spin against a failing rail.
- Enforce daily/weekly/monthly cadence and key calendar batches by period bucket.
- Reject thresholdAmount of 0 instead of falling through to pay anything.
- Emit payout.created/pending_review/executing/completed/failed/delayed events.
- Isolate one merchant's misconfiguration from the rest of the evaluation pass.
- Add /api/v1/payouts, mounted in index.ts.

Payment correctness (Smartdevs17#1184)
- getCurrentRate returned 1.0 for unquoted pairs, pricing GBP->BTC at par. Unquoted
  pairs are now an error.
- Money was derived with binary floating point; add services/money.ts (bigint minor
  units, half-away-from-zero) and route conversions through it.
- lockRate accepted NaN/negative/infinite amounts.
- processFiatRefund recorded failed, reported success without refunding when no
  payment intent existed, and was not idempotent.
- Revenue pool recipient ratios were validated individually but not in aggregate, so a
  pool could distribute more than 100 percent; recordDistribution was not idempotent
  on txHash; conversion reads returned live internal references.

Payment security (Smartdevs17#1186)
- Every /api/v1/payouts route, including reads, requires an authenticated approver
  principal, checked before any lookup so batch existence cannot be probed.
- Reject reuse of an Idempotency-Key with a different payload (409
  idempotency_key_reuse_mismatch) instead of replaying the earlier response, and
  bound the response cache.
- Enforce asset and amount allowlists in the engine as well as the route, and add
  route-scoped rate limits (120/min, 30/min for approval decisions).

Documentation (Smartdevs17#1183)
- Add docs/PAYOUT_RELIABILITY.md, docs/PAYMENT_CORRECTNESS.md and
  docs/security/payment-security.md, index them, and cross-link PAYOUT_APPROVALS.md.
@vercel

vercel Bot commented Oct 17, 2026

Copy link
Copy Markdown

@SagaciousBoss is attempting to deploy a commit to the smartdevs17's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@SagaciousBoss Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@github-actions
github-actions Bot merged commit 8119f72 into Smartdevs17:main Oct 17, 2026
16 of 25 checks passed
SagaciousBoss added a commit to SagaciousBoss/agenticpay that referenced this pull request Oct 17, 2026
…rtdevs17#1290

Four defects introduced in Smartdevs17#1290 that the backend lint+test jobs do not catch
because they only run eslint and vitest, neither of which type-checks:

- fiat-crypto.ts: const conversions = Map<string, ConversionRecord>() was missing

ew, which also made Array.from(conversions.values()) an unknown[] and broke
  the element types in listConversions.
- payout-engine.ts: evaluatePayouts called the async materialiseBatch without
  awaiting it, so it pushed a Promise into the returned PayoutBatch[] and the
  try/catch could not observe a per-schedule failure.
- routes/payouts.ts: 
eq.params.batchId is typed string | string[] under this
  Express version. The handlers now resolve the id once through requireId and reuse
  the validated string, which also removes the repeated param lookup.
- Also replaced the === undefined rate/scale lookups with hasOwnProperty checks
  and moved the settlement amount parse into a helper, so neither can trip the
  type checker or trip over a definite-assignment analysis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Oct 17, 2026
Follow-up to #1290, which merged with four type errors that only the Docker `build` job (tsc) caught — the backend CI jobs run eslint and vitest, neither of which type-checks. #1290 passed `Backend (Node.js 20)` and `Backend (Node.js 22)`.

## What was wrong

- `backend/src/services/fiat-crypto.ts` — `const conversions = Map<string, ConversionRecord>()` was missing `new`. That also made `Array.from(conversions.values())` an `unknown[]`, which broke the element types in `listConversions`.
- `backend/src/services/payouts/payout-engine.ts` — `evaluatePayouts` called the async `materialiseBatch` without awaiting it, so it pushed a `Promise` into a `PayoutBatch[]`, and the per-schedule `try/catch` could not observe a failure.
- `backend/src/routes/payouts.ts` — `req.params.batchId` is typed `string | string[]` under this Express version. The handlers now resolve the id once through `requireId` and reuse the validated string, which also removes the repeated param lookup.

Two smaller robustness changes while in the same code:

- The FX rate lookup and the payout asset-scale lookup use `Object.prototype.hasOwnProperty.call` rather than comparing the result to `undefined`, which keeps them honest if a rate is ever legitimately falsy.
- The settlement amount parse moved into `parseSettlementAmount`, which collapses every failure mode onto the single `INVALID_SETTLEMENT_AMOUNT` sentinel instead of relying on definite-assignment analysis across a `try/catch`.

## Scope

Three files, no behaviour change beyond fixing the above. `Backend (Node.js 20/22)` on #1290 passed, so the test suite already covered this code; this commit makes `tsc` agree.

## Verification

`npm run test --workspace backend` and `npm run lint --workspace backend`. The repository-wide `tsc` build has a large number of pre-existing errors unrelated to this change (missing modules, ungenerated Prisma client, `websocket.ts`, `tax-engine.ts`, `featureFlagRegistry.ts` and others); this commit removes the only ones introduced by #1290.

Closes #1184
Closes #1185
Closes #1186
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant