Skip to content

Testcontainers Kafka tests for a payment event pipeline: redelivery and ledger state

Testcontainers with Kafka lets an integration test start a real Kafka broker, here confluentinc/cp-kafka:8.3.2 through @testcontainers/kafka 12.1.0 for Node.js, next to a Postgres ledger. The test for a payment event pipeline stops the consumer after the database commit and before the offset commit, then asserts one set of ledger postings per event after Kafka redelivers.

The versions this test pins, checked on 22 September 2026

A payment event pipeline carries payment.authorised and payment.captured events on a Kafka topic keyed by payment ID, and a ledger consumer turns each event into double-entry postings. The integration test below runs that consumer against a real broker and a real Postgres database, both started by Testcontainers for Node.js. Every version in the table was read from its registry or release page on 22 September 2026.

ComponentVersion or tagReleased or updatedSource
@testcontainers/kafka, @testcontainers/postgresql, testcontainers12.1.04 August 2026testcontainers-node v12.1.0
Kafka imageconfluentinc/cp-kafka:8.3.2Module test image bumped on 21 September 2026Kafka module Dockerfile
Apache Kafka inside the image4.3Confluent Platform 8.3 release notesConfluent release notes
Kafka client@confluentinc/kafka-javascript 1.10.1, librdkafka 2.15.110 September 2026MIGRATION.md
Postgres image and driverpostgres:18.6-alpine, pg 8.23.0Docker Hub and npm, read 22 September 2026Docker Hub, npm
Test runner and runtimevitest 5.0.1, Node.js 22.23testcontainers 12.1.0 declares node >= 22.22npm

The Node.js Kafka module page says Kafka 8.x images use the @confluentinc/kafka-javascript client and lists port 9093 as the plaintext broker port. In the 12.1.0 source, KafkaContainer calls withKraft() itself when the image tag is 8.0.0 or later, so the test needs no ZooKeeper container. Confluent states that "8.3 is a major release of Confluent Platform that provides you with Apache Kafka® 4.3".

Java teams get the same setup from org.testcontainers:testcontainers-kafka:2.0.5. The Java Kafka module page documents org.testcontainers.kafka.KafkaContainer for the apache/kafka and apache/kafka-native images and marks the older org.testcontainers.containers.KafkaContainer as deprecated.

Shell
npm install --save-dev @testcontainers/kafka@12.1.0 @testcontainers/postgresql@12.1.0 \
  @confluentinc/kafka-javascript@1.10.1 pg@8.23.0 @types/pg vitest@5.0.1 typescript

Why redelivery is the case a ledger consumer must survive

The Apache Kafka 4.3 design documentation describes the consumer that processes first and saves its offset second: "In this case there is a possibility that the consumer process crashes after processing messages but before saving its position. In this case when the new process takes over the first few messages it receives will already have been processed. This corresponds to the “at-least-once” semantics in the case of consumer failure."

For a ledger consumer, a second payment.authorised posting doubles the hold and understates the customer's available balance by the authorised amount. A second capture posting overstates the merchant settlement liability, and the ledger then disagrees with the processor settlement file. The idempotent producer does not remove this risk. The same page says it "guarantees that resending will not result in duplicate entries in the log", and the broker deduplicates by producer ID and sequence number. An event that an upstream service publishes twice, for example after its outbox relay restarts, reaches the topic as two messages at two offsets.

The receiving side of provider webhooks, with signatures and retry windows, is in the BaaS webhook reliability guide. This post starts where that guide stops: the event is already on a Kafka topic.

The consumer under test

The consumer disables auto-commit, applies the event and an inbox row in one Postgres transaction, and commits the Kafka offset only after the database commit. The commitOffsets flag exists so the test can reproduce the crash window.

TypeScript
// src/ledger-consumer.ts
import { KafkaJS } from "@confluentinc/kafka-javascript";
import type { Pool } from "pg";

export type PaymentEvent = {
  eventId: string;
  type: "payment.authorised" | "payment.captured";
  paymentId: string; // Kafka message key
  accountId: string;
  amountMinor: number;
  currency: string;
};

export const SCHEMA = `
  CREATE TABLE processed_events (event_id text PRIMARY KEY, kafka_offset text NOT NULL);
  CREATE TABLE ledger_entries (
    id bigserial PRIMARY KEY,
    event_id text NOT NULL REFERENCES processed_events (event_id),
    account text NOT NULL,
    amount_minor bigint NOT NULL,          -- credit positive, debit negative
    currency char(3) NOT NULL
  );`;

// Postings per event type. Each set nets to zero.
function postings(e: PaymentEvent): Array<[string, number]> {
  const available = `customer:${e.accountId}:available`;
  const held = `customer:${e.accountId}:held`;
  return e.type === "payment.authorised"
    ? [[available, -e.amountMinor], [held, e.amountMinor]]
    : [[held, -e.amountMinor], ["merchant:settlement", e.amountMinor]];
}

// Returns "applied" or "duplicate". The inbox row and the postings commit in one transaction.
export async function applyEvent(pool: Pool, e: PaymentEvent, offset: string): Promise<"applied" | "duplicate"> {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const inserted = await client.query(
      "INSERT INTO processed_events (event_id, kafka_offset) VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING RETURNING event_id",
      [e.eventId, offset],
    );
    if (inserted.rowCount === 0) {
      await client.query("ROLLBACK");
      return "duplicate";
    }
    for (const [account, amount] of postings(e)) {
      await client.query(
        "INSERT INTO ledger_entries (event_id, account, amount_minor, currency) VALUES ($1, $2, $3, $4)",
        [e.eventId, account, amount, e.currency],
      );
    }
    await client.query("COMMIT");
    return "applied";
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

export async function startLedgerConsumer(opts: {
  kafka: KafkaJS.Kafka;
  pool: Pool;
  topic: string;
  groupId: string;
  commitOffsets: boolean; // false simulates a crash between the database commit and the offset commit
  onResult: (r: "applied" | "duplicate") => void;
}): Promise<KafkaJS.Consumer> {
  const consumer = opts.kafka.consumer({
    kafkaJS: { groupId: opts.groupId, fromBeginning: true, autoCommit: false },
  });
  await consumer.connect();
  await consumer.subscribe({ topic: opts.topic });
  await consumer.run({
    eachMessage: async ({ topic, partition, message }) => {
      const event = JSON.parse(message.value!.toString()) as PaymentEvent;
      opts.onResult(await applyEvent(opts.pool, event, message.offset));
      if (opts.commitOffsets) {
        await consumer.commitOffsets([
          { topic, partition, offset: (BigInt(message.offset) + 1n).toString() },
        ]);
      }
    },
  });
  return consumer;
}

The integration test: forced redelivery and ledger assertions

The test publishes three messages with the payment ID as key: the authorisation, a resend of the same authorisation with the same eventId, and the capture. Run 1 processes all three and never commits an offset. Run 2 joins the same consumer group, receives all three again from offset 0, and must treat each one as a duplicate.

TypeScript
// src/payment-pipeline.test.ts
import { KafkaJS } from "@confluentinc/kafka-javascript";
import { KafkaContainer, StartedKafkaContainer } from "@testcontainers/kafka";
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { Pool } from "pg";
import { afterAll, beforeAll, expect, test } from "vitest";
import { PaymentEvent, SCHEMA, startLedgerConsumer } from "./ledger-consumer";

const TOPIC = "payments.events";
let kafkaContainer: StartedKafkaContainer;
let pgContainer: StartedPostgreSqlContainer;
let kafka: KafkaJS.Kafka;
let pool: Pool;

beforeAll(async () => {
  [kafkaContainer, pgContainer] = await Promise.all([
    new KafkaContainer("confluentinc/cp-kafka:8.3.2").start(), // tag >= 8.0.0 starts in KRaft mode
    new PostgreSqlContainer("postgres:18.6-alpine").start(),
  ]);
  kafka = new KafkaJS.Kafka({
    kafkaJS: {
      brokers: [`${kafkaContainer.getHost()}:${kafkaContainer.getMappedPort(9093)}`],
      logLevel: KafkaJS.logLevel.ERROR,
    },
  });
  const admin = kafka.admin();
  await admin.connect();
  await admin.createTopics({ topics: [{ topic: TOPIC, numPartitions: 6 }] });
  await admin.disconnect();
  pool = new Pool({ connectionString: pgContainer.getConnectionUri() });
  await pool.query(SCHEMA);
}, 240_000);

afterAll(async () => {
  await pool?.end();
  await kafkaContainer?.stop();
  await pgContainer?.stop();
});

async function waitFor(check: () => boolean | Promise<boolean>, timeoutMs = 30_000) {
  const deadline = Date.now() + timeoutMs;
  while (!(await check())) {
    if (Date.now() > deadline) throw new Error("condition not met in time");
    await new Promise((r) => setTimeout(r, 200));
  }
}

async function balances(accountId: string) {
  const { rows } = await pool.query(
    `SELECT account, SUM(amount_minor)::bigint AS balance
       FROM ledger_entries
      WHERE account LIKE $1 OR account = 'merchant:settlement'
      GROUP BY account ORDER BY account`,
    [`customer:${accountId}:%`],
  );
  return Object.fromEntries(rows.map((r) => [r.account, Number(r.balance)]));
}

test("redelivery after a crash before the offset commit posts once", async () => {
  const authorised: PaymentEvent = {
    eventId: "evt_0001", type: "payment.authorised", paymentId: "pay_0001",
    accountId: "acc_0001", amountMinor: 2599, currency: "EUR",
  };
  const captured: PaymentEvent = { ...authorised, eventId: "evt_0002", type: "payment.captured" };

  const producer = kafka.producer({ kafkaJS: { idempotent: true } });
  await producer.connect();
  const meta = await producer.send({
    topic: TOPIC,
    messages: [authorised, authorised, captured].map((e) => ({ key: e.paymentId, value: JSON.stringify(e) })),
  });
  await producer.disconnect();
  // The second authorised message is an upstream resend: same eventId, new offset.
  // Ordering per key holds only if all three messages share a partition.
  expect(new Set(meta.map((m) => m.partition)).size).toBe(1);

  // Run 1: processes all three messages, commits the database, never commits offsets, then "crashes".
  const run1: string[] = [];
  const crashed = await startLedgerConsumer({
    kafka, pool, topic: TOPIC, groupId: "ledger", commitOffsets: false, onResult: (r) => run1.push(r),
  });
  await waitFor(() => run1.length === 3);
  await crashed.disconnect();

  // Run 2: same group, no committed offset, so Kafka redelivers all three messages.
  const run2: string[] = [];
  const restarted = await startLedgerConsumer({
    kafka, pool, topic: TOPIC, groupId: "ledger", commitOffsets: true, onResult: (r) => run2.push(r),
  });
  await waitFor(() => run2.length === 3);
  await restarted.disconnect();

  expect(run1).toEqual(["applied", "duplicate", "applied"]);
  expect(run2).toEqual(["duplicate", "duplicate", "duplicate"]);

  // Ledger state: one hold opened and released, one capture, nothing doubled.
  expect(await balances("acc_0001")).toEqual({
    "customer:acc_0001:available": -2599,
    "customer:acc_0001:held": 0,
    "merchant:settlement": 2599,
  });
  const { rows } = await pool.query(
    "SELECT event_id, SUM(amount_minor)::bigint AS net, COUNT(*)::int AS entries FROM ledger_entries GROUP BY event_id ORDER BY event_id",
  );
  expect(rows).toEqual([
    { event_id: "evt_0001", net: "0", entries: 2 },
    { event_id: "evt_0002", net: "0", entries: 2 },
  ]);
}, 120_000);

The assertions cover three failure classes. The partition check fails when events for one payment can land on different partitions, which removes Kafka's per-partition ordering. The run1 and run2 arrays fail when the handler applies a resend or a redelivery. The balance and net-zero checks fail when a posting is doubled, lost, or unbalanced, even if the duplicate check reports correctly.

Run it with npx vitest run src/payment-pipeline.test.ts on a machine with a Docker-compatible runtime. The 240-second hook timeout sits above the 180-second startup timeout that KafkaContainer sets in 12.1.0.

Three pitfalls the module docs leave to you

Auto-commit hides the crash window. The client's MIGRATION.md states: "If autoCommit is set to true, messages are not committed per-message, but rather periodically at the interval specified by autoCommitInterval (default 5 seconds)." With auto-commit on, a crash test redelivers whatever the last 5-second tick left uncommitted, so the same test passes or fails depending on timing. Our practice is to set autoCommit: false in the consumer and commit after the database transaction, which makes the redelivery deterministic.

Two partitioners for one key. The librdkafka 2.15.1 configuration reference sets the default partitioner to consistent_random, a "CRC32 hash of key", while murmur2_random "is functionally equivalent to the default partitioner in the Java Producer." MIGRATION.md states that the KafkaJS-compatible API uses murmur2_random, "Java client compatible". A Node.js service that uses the non-KafkaJS producer API without setting partitioner can therefore send a capture for pay_0001 to a different partition than the Java service that published its authorisation. The capture can then be consumed first. The single-producer partition assertion above does not catch this; run it once per producer configuration that writes to the topic.

A one-partition topic proves nothing about ordering. On a single partition every key shares a partition, so the partition assertion passes for any partitioner. The test creates payments.events with six partitions through the admin client before any producer runs.

We also run the suite once against a build with the inbox insert removed. If the redelivery test still passes, the test is broken, and the run is recorded with the suite.

Where the test runs and what it hands over

The test belongs in the merge stage of a CI pipeline on a runner with Docker; how that stage gates a release is covered under test automation. Balance checks against processor and bank files are the next layer, in the ledger reconciliation testing guide. For programmes with a partner bank, the BaaS segment page lists the ledger and webhook evidence a sponsor bank reviews.

Scope your testing

Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.

Scope your testing