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
- The hourly `PayoutRun` sends every `PENDING` payout created in the last 24 hours through the bank's `POST /v1/payouts`, one payout at a time, and marks it `SENT` with the bank's payout id. A payout that is already `SENT` is skipped. A payout whose send fails is logged and stays `PENDING` for the next hourly run; one failure does not stop the others. Payouts older than 24 hours are left to finance's existing manual review.
- A request that times out (the 5 second axios `timeout`) or whose connection is refused or dropped is retried, up to 3 attempts in all, with a pause of 1 s and then 2 s. A response with an error status is not retried and fails that payout for this run.
- The bank creates one payout per `POST /v1/payouts` unless the request carries an `Idempotency-Key` header (any string up to 64 characters). A key the bank has seen in the 7 days since its first request returns that first response unchanged and creates nothing; a key whose first request was answered with an error status is not remembered. A response with an error status means the bank created no payout. The body's `reference` is free text printed on the beneficiary's statement (up to 140 characters) and is not used to deduplicate. The bank may create a payout and still answer after our timeout.
- Each payout reaches the beneficiary at most once, however many attempts the retry or later hourly runs make for it.
Files touched
- src/payouts/bank-payout.client.ts
- src/payouts/payout.service.ts
- src/payouts/payout-run.ts
- src/payouts/payouts.module.ts
--- 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,