Send pending payouts to the bank every hour, retry on timeouts

Node · Node · advanced · modification

Finance has been uploading pending payouts to the bank portal by hand every morning, from the count `PayoutRun` logs each hour. This sends them automatically. A new `BankPayoutClient` posts a payout over the bank's REST API with `axios-retry` set up to retry a timeout or a refused or reset connection (3 attempts, 1 s and 2 s pauses, the 5 s timeout reset per attempt). Error statuses come back as they are, so a rejected IBAN is not retried in a loop. `PayoutService.send` skips payouts that are already `SENT` and records the bank's payout id; `PayoutRun` now calls it for each pending payout from the last 24 hours and logs failures without stopping the run. Finance stops the manual uploads once this ships. Tried it against the bank's sandbox with a few payouts.

NestJS 10 service (TypeScript, strict), TypeORM 0.3 on PostgreSQL, axios 1.x, axios-retry 4.x. The `Payout` entity exists (unchanged): `id` is a UUID string, `iban` is validated when the payout is created, `amountCents` is an integer from 1 to 100,000,000 in the currency's minor unit (what the bank's `amount` takes), `currency` is an uppercase ISO code the bank accepts, `status` is `'PENDING' | 'SENT'`, `bankRef` is the bank's payout id or `null`, `createdAt` is set on insert. Payouts are marked `SENT` only by this code. `AppModule` (unchanged) imports `PayoutsModule`, `TypeOrmModule.forRoot`, `ScheduleModule.forRoot()` and `ConfigModule.forRoot({ isGlobal: true })`; `BANK_BASE_URL` and `BANK_API_KEY` are set in the environment. The bank authenticates with `Authorization: Bearer <api key>`, and a successful payout response is a 201 whose JSON body always carries a non-empty `id`. The service runs as a single instance, and at most 100 payouts are created a day, so an hourly run ends well within the hour.

Requirements

Files touched

--- src/payouts/bank-payout.client.ts
+import { Injectable } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import axios, { AxiosError, AxiosInstance } from "axios";
+import axiosRetry, { isNetworkError } from "axios-retry";
+
+export interface PayoutRequest {
+  iban: string;
+  amountCents: number;
+  currency: string;
+  reference: string;
+}
+
+interface BankPayoutResponse {
+  id: string;
+}
+
+@Injectable()
+export class BankPayoutClient {
+  private readonly http: AxiosInstance;
+
+  constructor(config: ConfigService) {
+    this.http = axios.create({
+      baseURL: config.getOrThrow<string>("BANK_BASE_URL"),
+      timeout: 5_000,
+      headers: { Authorization: `Bearer ${config.getOrThrow<string>("BANK_API_KEY")}` },
+    });
+    axiosRetry(this.http, {
+      retries: 2,
+      retryDelay: (retryCount) => retryCount * 1_000,

Review this PR

Node practice