Build a Tap-to-Pay Authorization System

Build a local payment simulator that proves idempotency under failure.

Introduction

30 Second Summary

A dropped reply or hurried second tap can make the same purchase look like two separate requests. The systems behind the checkout still need to produce one trustworthy result.

In this project, you will build a local tap-to-pay authorization simulator using synthetic payment data. You will test durable idempotency under duplicate delivery plus a lost response.

What You'll Build

You will send a synthetic tap from terminal to issuer while the lab preserves one durable authorization through simultaneous duplicates plus a response lost after commit.

By the end of this project, you'll have:

  • An authorization flow where you send a synthetic token plus demo cryptogram through Spring Boot to receive an AUTHORIZED or DECLINED result.
  • A concurrency proof that exposes multiple flawed issuer effects before the fix. PostgreSQL preserves one issuer effect while Redis remains a disposable completed-result hint.
  • A GitHub-ready reliability report showing UNKNOWN returning to AUTHORIZED without a second issuer effect. Automated tests plus Grafana k6 latency results provide the evidence.
  • Secret Mission: An automated integration test for duplicate concurrent calls plus a post-commit response loss. Its assertions prove that reconciliation preserves one durable issuer authorization.

Are there any prerequisites?

Start with an Intel-based Mac running VS Code plus Docker Desktop.

The setup step provides install guidance for Java 25 plus Apache Maven 3.10.0 if either check fails.

Before We Start

First, commit to the correctness problem behind this local simulator. You are modeling duplicate network delivery with synthetic data while protecting the durable issuer authorization from duplicate effects.

Set Up the Local Payment Lab

A correctness experiment needs a repeatable foundation. Local services keep payment-network access out of the lab while preserving the database behavior you need to test.

You’ll prepare Java, Apache Maven, Spring Boot, PostgreSQL, and Redis for the simulator. Docker Desktop supplies the real local services used by the application plus its integration tests.

In this step, get ready to:
  • Verify Java 25 plus Apache Maven 3.10.0.
  • Create the Spring Boot project plus its local service configuration.
  • Start the PostgreSQL plus Redis containers before running the integration test.
Verify Java and Maven

Java compiles and runs the simulator. Maven resolves the project’s managed dependencies before executing its tests.

  • Check the active Java version by running this command:
java -version

What does this command check?

The command reports the Java runtime selected by your current terminal. This project needs version 25.

✔️ I see Java 25

Your Java runtime is ready. The simulator can use the language level configured in its Maven build.

ⓧ I see an older Java version

Your terminal is selecting an older runtime. Install the Intel-compatible Temurin 25 cask before selecting Java 25 for this shell.

  • Install Temurin 25 through Homebrew by running this command:
brew install --cask temurin@25

What does this command install?

Homebrew installs the Temurin 25 JDK package for macOS. The published cask includes an Intel x64 package.

  • Select Java 25 for the current terminal by running these commands:
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
export PATH="$JAVA_HOME/bin:$PATH"

How does the shell select Java?

The first line locates the installed Java 25 home. The second line places its executables first on the current shell’s path.

  • Confirm the selected runtime by running the version check again:
java -version

What should you see?

The output now identifies Java version 25.

Still seeing the older runtime?

Confirm that both export commands ran in the same terminal where you checked the version.

Use Help me select Java 25 on my Intel Mac if the older runtime remains active.

ⓧ Command not found

Java is missing from the current shell. Install the Intel-compatible Temurin 25 cask before selecting its runtime.

  • Install Temurin 25 through Homebrew by running this command:
brew install --cask temurin@25

What does this command install?

The cask installs the Java 25 development kit used to compile plus run the simulator.

  • Select the new Java installation by running these commands:
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
export PATH="$JAVA_HOME/bin:$PATH"

How is Java activated?

These environment values make the Java 25 executables available to the current terminal.

  • Verify the installation by running this command:
java -version

What should you see?

The output identifies Java version 25.

Java still unavailable?

Restart your terminal after the installation. Run both export commands again in the new terminal.

Use Help me finish the Temurin setup if the command remains unavailable.

  • Check the active Maven version by running this command:
mvn -v

What does this command check?

The output reports Maven’s version plus the Java runtime it uses. Look for Maven 3.10.0 plus Java 25.

✔️ I see Maven 3.10.0

Your build tool is ready. Maven now has the expected runtime for compiling the project.

ⓧ I see an older Maven version

Your shell is selecting an older Maven installation. Install the current Homebrew formula for this project.

  • Install the Maven formula by running this command:
brew install maven

What does this command install?

Homebrew installs Maven 3.10.0 from its current formula.

  • Confirm the active Maven version by running the check again:
mvn -v

What should you see?

The output identifies Apache Maven 3.10.0 plus Java 25.

Still seeing an older Maven version?

Restart the terminal so its command path includes the Homebrew installation. Check the version again from the new terminal.

Use Help me find the active Maven installation if the older version remains active.

ⓧ Command not found

Maven is missing from the current command path. Install the Homebrew formula before checking it again.

  • Install Maven by running this command:
brew install maven

What does this command install?

The formula installs Maven 3.10.0 for the local build.

  • Verify the Maven installation by running this command:
mvn -v

What should you see?

The first output line identifies Apache Maven 3.10.0.

Maven still unavailable?

Restart your terminal after Homebrew finishes. Check the version from the new terminal.

Use Help me finish the Maven setup if the command remains unavailable.

Create the local project

The project keeps its terminal, processor, network, plus issuer boundaries inside one application process. This preserves the correctness model without adding distributed deployment plumbing.

Why one Spring Boot process?

Explicit components can still model each payment boundary. One process keeps this session focused on concurrency plus durable state.

Separate services would add network setup before you can reproduce the race. The database remains real where correctness depends on it.

  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The command places the project in a predictable location on your Mac.

  • Create the project directories by running these commands:
mkdir tap-to-pay-authorization
cd tap-to-pay-authorization
mkdir -p src/main/java/ai/nextwork/taptopay src/main/resources src/test/java/ai/nextwork/taptopay k6 results

What do these commands create?

  • The src/main tree holds application code plus configuration.
  • The src/test tree holds the integration-test scaffold.
  • The k6 plus results directories reserve locations for later reliability evidence.
  • Confirm the top-level directories by running this command:
ls

What should you see?

The output lists k6, results, plus src.

  • Press Cmd+Space to open Spotlight on macOS.
  • Type VS Code into Spotlight.
  • Press Enter to open VS Code.
  • Select Open Folder from the start screen.
  • Choose the tap-to-pay-authorization folder on your Desktop.

You’ll see the k6, results, plus src directories in the Explorer sidebar. That confirms VS Code opened the intended project.

  • Create pom.xml inside the tap-to-pay-authorization folder with this content:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <!-- Spring Boot manages compatible dependency versions. -->
  <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.2</version><relativePath/></parent>
  <groupId>ai.nextwork</groupId>
  <artifactId>tap-to-pay-authorization</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>tap-to-pay-authorization</name>
  <properties><java.version>25</java.version></properties>

  <!-- These modules provide HTTP, durable storage, caching, and real integration-test services. -->
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
    <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-testcontainers</artifactId><scope>test</scope></dependency>
    <dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-junit-jupiter</artifactId><scope>test</scope></dependency>
    <dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-postgresql</artifactId><scope>test</scope></dependency>
  </dependencies>

  <!-- The plugin compiles, tests, and runs the application. -->
  <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>

What does this build file do?

  • The Spring Boot parent manages JUnit 6.0.3 plus Testcontainers 2.0.5 with compatible dependency versions.
  • The Java property tells Maven to compile against version 25.
  • The test dependencies let JUnit start real PostgreSQL plus Redis containers through Docker.
  • Create TapToPayApplication.java inside src/main/java/ai/nextwork/taptopay with this content:
package ai.nextwork.taptopay;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// Start the local authorization simulator and discover its components.
@SpringBootApplication
public class TapToPayApplication {

    public static void main(String[] args) {
        SpringApplication.run(TapToPayApplication.class, args);
    }
}

What does this class do?

The annotation marks the application’s component-scanning entry point. The main() method starts the Spring application context.

  • Save pom.xml.
  • Save TapToPayApplication.java.
  • Open VS Code’s integrated terminal with Ctrl+`.
  • Compile the initial project by running this command:
mvn test

What does this first build prove?

Maven resolves the managed dependencies before compiling the application. A successful build confirms the Java source plus project model are valid.

The first Maven run may take a few minutes while dependencies download. Quiet stretches during this first build are expected.

You’ll see BUILD SUCCESS near the end of the output. Your Java project can now compile.

Build failed before the tests?

Check that the terminal is inside the tap-to-pay-authorization folder. Confirm that pom.xml is saved there.

Use Help me diagnose this Maven build failure if Maven reports a dependency or compilation problem.

  • Create application.yaml inside src/main/resources with this content:
spring:
  # Connect the local application to the Compose database.
  datasource:
    url: jdbc:postgresql://127.0.0.1:5432/tap_to_pay
    username: tap_user
    password: tap_password
  # Keep Redis local and disposable.
  data:
    redis:
      host: 127.0.0.1
      port: 6379
  # Apply the schema whenever the local database starts empty.
  sql:
    init:
      mode: always

What does this configuration control?

The data source points Spring JDBC at the local PostgreSQL port. The Redis properties point the cache client at its local container.

SQL initialization applies schema.sql to the external database. The fixed password belongs only to this isolated local lab.

  • Create schema.sql inside src/main/resources with this content:
-- Store one processor-side identity for each idempotency key.
CREATE TABLE IF NOT EXISTS payment_authorizations (
    idempotency_key VARCHAR(128) PRIMARY KEY,
    request_fingerprint VARCHAR(128) NOT NULL,
    network_request_id UUID NOT NULL UNIQUE,
    status VARCHAR(32) NOT NULL CHECK (status IN ('RECEIVED', 'AUTHORIZED', 'DECLINED', 'UNKNOWN')),
    response_code VARCHAR(32),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Enforce one durable issuer effect for each network request identity.
CREATE TABLE IF NOT EXISTS issuer_authorizations (
    network_request_id UUID PRIMARY KEY,
    payment_token VARCHAR(128) NOT NULL,
    amount_minor BIGINT NOT NULL CHECK (amount_minor >= 0),
    currency CHAR(3) NOT NULL,
    status VARCHAR(32) NOT NULL CHECK (status IN ('AUTHORIZED', 'DECLINED')),
    response_code VARCHAR(32) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

What does the schema protect?

The first primary key gives each merchant idempotency key one durable payment identity. Its unique network request ID remains stable across later retries.

The issuer table uses the network request ID as its primary key. PostgreSQL can therefore reject a second durable issuer effect for the same authorization identity.

  • Save application.yaml.
  • Save schema.sql.
  • Confirm the project still compiles by running this command:
mvn test

What does this build confirm?

Maven packages both resource files with the application. You’ll see BUILD SUCCESS when their paths plus syntax are accepted.

Seeing a YAML or SQL problem?

Use spaces for YAML indentation. Confirm that both files sit directly inside src/main/resources.

Use Help me compare these resource files if the build remains unsuccessful.

  • Create compose.yaml inside the tap-to-pay-authorization folder with this content:
services:
  postgres:
    # Bind the durable database only to this Mac.
    image: postgres:18.6-alpine
    environment:
      POSTGRES_DB: tap_to_pay
      POSTGRES_USER: tap_user
      POSTGRES_PASSWORD: tap_password
    ports:
      - "127.0.0.1:5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d tap_to_pay -U tap_user -h localhost -p 5432 -q"]
      interval: 2s
      timeout: 2s
      retries: 20

  redis:
    # Bind the disposable completed-result cache only to this Mac.
    image: redis:8.10.3-alpine
    ports:
      - "127.0.0.1:6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 2s
      retries: 20

What does Compose provide?

The two services run PostgreSQL 18.6 plus Redis 8.10.3 in local containers. Their host bindings limit access to 127.0.0.1.

The health checks test whether each service can accept work. A running container still needs to become healthy before the application relies on it.

  • Create PaymentReliabilityIntegrationTest.java inside src/test/java/ai/nextwork/taptopay with this content:
package ai.nextwork.taptopay;

import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

// Start real service containers around one sequential JUnit test.
@Testcontainers
@SpringBootTest
class PaymentReliabilityIntegrationTest {
    @Container @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18.6-alpine");

    @Container @ServiceConnection(name = "redis")
    static GenericContainer<?> redis = new GenericContainer<>("redis:8.10.3-alpine")
            .withExposedPorts(6379);

    @Test
    void contextLoads() {
        assertTrue(postgres.isRunning());
        assertTrue(redis.isRunning());
    }
}

What does this test prove?

Testcontainers starts isolated PostgreSQL plus Redis instances for the Spring application context. Service connections replace the fixed local addresses with each test container’s runtime connection details.

The assertions confirm both containers are running. Loading the application context also applies the SQL schema to the temporary PostgreSQL database.

  • Save compose.yaml.
  • Save PaymentReliabilityIntegrationTest.java.

✔️ Awesome, I've got everything!

Great. Double-check that every project file is saved before starting the local services.

ⓧ I'd like to double check the full code

Check pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <!-- Spring Boot manages compatible dependency versions. -->
  <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.2</version><relativePath/></parent>
  <groupId>ai.nextwork</groupId>
  <artifactId>tap-to-pay-authorization</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>tap-to-pay-authorization</name>
  <properties><java.version>25</java.version></properties>

  <!-- These modules provide HTTP, durable storage, caching, and real integration-test services. -->
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
    <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-testcontainers</artifactId><scope>test</scope></dependency>
    <dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-junit-jupiter</artifactId><scope>test</scope></dependency>
    <dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-postgresql</artifactId><scope>test</scope></dependency>
  </dependencies>

  <!-- The plugin compiles, tests, and runs the application. -->
  <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
Check TapToPayApplication.java
package ai.nextwork.taptopay;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// Start the local authorization simulator and discover its components.
@SpringBootApplication
public class TapToPayApplication {

    public static void main(String[] args) {
        SpringApplication.run(TapToPayApplication.class, args);
    }
}
Check application.yaml
spring:
  # Connect the local application to the Compose database.
  datasource:
    url: jdbc:postgresql://127.0.0.1:5432/tap_to_pay
    username: tap_user
    password: tap_password
  # Keep Redis local and disposable.
  data:
    redis:
      host: 127.0.0.1
      port: 6379
  # Apply the schema whenever the local database starts empty.
  sql:
    init:
      mode: always
Check schema.sql
-- Store one processor-side identity for each idempotency key.
CREATE TABLE IF NOT EXISTS payment_authorizations (
    idempotency_key VARCHAR(128) PRIMARY KEY,
    request_fingerprint VARCHAR(128) NOT NULL,
    network_request_id UUID NOT NULL UNIQUE,
    status VARCHAR(32) NOT NULL CHECK (status IN ('RECEIVED', 'AUTHORIZED', 'DECLINED', 'UNKNOWN')),
    response_code VARCHAR(32),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Enforce one durable issuer effect for each network request identity.
CREATE TABLE IF NOT EXISTS issuer_authorizations (
    network_request_id UUID PRIMARY KEY,
    payment_token VARCHAR(128) NOT NULL,
    amount_minor BIGINT NOT NULL CHECK (amount_minor >= 0),
    currency CHAR(3) NOT NULL,
    status VARCHAR(32) NOT NULL CHECK (status IN ('AUTHORIZED', 'DECLINED')),
    response_code VARCHAR(32) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Check compose.yaml
services:
  postgres:
    # Bind the durable database only to this Mac.
    image: postgres:18.6-alpine
    environment:
      POSTGRES_DB: tap_to_pay
      POSTGRES_USER: tap_user
      POSTGRES_PASSWORD: tap_password
    ports:
      - "127.0.0.1:5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d tap_to_pay -U tap_user -h localhost -p 5432 -q"]
      interval: 2s
      timeout: 2s
      retries: 20

  redis:
    # Bind the disposable completed-result cache only to this Mac.
    image: redis:8.10.3-alpine
    ports:
      - "127.0.0.1:6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 2s
      retries: 20
Check PaymentReliabilityIntegrationTest.java
package ai.nextwork.taptopay;

import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

// Start real service containers around one sequential JUnit test.
@Testcontainers
@SpringBootTest
class PaymentReliabilityIntegrationTest {
    @Container @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18.6-alpine");

    @Container @ServiceConnection(name = "redis")
    static GenericContainer<?> redis = new GenericContainer<>("redis:8.10.3-alpine")
            .withExposedPorts(6379);

    @Test
    void contextLoads() {
        assertTrue(postgres.isRunning());
        assertTrue(redis.isRunning());
    }
}
Start the services and run the test

Compose supplies persistent local services for later manual runs. Testcontainers supplies isolated services for automated tests so each test run controls its own database state.

  • Press Cmd+Space to open Spotlight on macOS.
  • Type Docker Desktop into Spotlight.
  • Press Enter to start Docker Desktop.

Docker Desktop can take a moment to start its engine. Wait until it reports that the engine is ready before continuing.

  • Start PostgreSQL plus Redis in the background by running this command:
docker compose up -d

What does this command start?

Compose creates the local network before starting both service containers. The detached flag returns control of the terminal while the services continue running.

The first run may spend a few minutes downloading both images. Docker reuses those images on later runs.

Compose could not start?

Confirm that Docker Desktop finished starting. Check that the terminal remains inside tap-to-pay-authorization.

Use Help me diagnose this Compose startup failure if the services still fail to start.

Before you check the services, do you expect both containers to be merely running or fully healthy?

  • Inspect both service states by running this command:
docker compose ps

What should you see?

You’ll see PostgreSQL plus Redis listed as running. Each service reaches a healthy state after its health check succeeds.

A service is still starting or unhealthy?

Wait briefly before running the status command again. PostgreSQL can need extra startup time while it initializes the empty database.

Use Help me inspect the unhealthy container if either health check remains unsuccessful.

Before you run the integration suite, do you expect its containers to reuse the Compose ports or receive isolated runtime connections?

  • Run the Testcontainers-backed integration suite by running this command:
mvn test

What should you see?

Maven starts temporary PostgreSQL plus Redis containers for the test. Spring Boot connects to them through the generated service connections.

You’ll see the context test pass before Maven ends with BUILD SUCCESS.

Integration test failed?

Confirm that Docker Desktop remains running. Compare the two container image names in the test with the image names in compose.yaml.

Use Help me debug the Testcontainers failure if Maven cannot start or connect to a test container.

That’s the foundation working: the local services are healthy while the automated suite can create isolated equivalents on demand.

Your local payment lab is reproducible plus isolated from real payment systems. Next up, you’ll send a synthetic authorization before reproducing the duplicate-tap race.

Reproduce the Duplicate-Tap Failure

Your local Spring Boot lab can reach healthy PostgreSQL and Redis services. That foundation lets you test payment correctness against real infrastructure.

A cache lookup followed by a separate write creates a check-then-act race. Two simultaneous taps can both observe the same missing idempotency key before either request records its result.

In this step, get ready to:
  • Define the synthetic authorization request and response contract.
  • Expose an HTTP endpoint backed by a deliberately flawed Redis authorization path.
  • Coordinate two duplicate calls and measure the resulting issuer effects.
Define the synthetic payment contract

The simulator uses synthetic payment data so the experiment stays local. The contract also names each authorization state that later steps persist.

  • In the VS Code Explorer sidebar, select the src/main/java/ai/nextwork/taptopay folder.
  • Click the New File icon in the Explorer toolbar.
  • Enter PaymentModels.java as the file name.
  • Create the synthetic request states in PaymentModels.java by pasting this code:
package ai.nextwork.taptopay;

public final class PaymentModels {
    private PaymentModels() {
    }

    // These states describe the simulator's authorization lifecycle.
    public enum PaymentStatus {
        RECEIVED, AUTHORIZED, DECLINED, UNKNOWN
    }

    // Tests use this switch to inject a failure after issuer work.
    public enum FailureMode {
        NONE, TIMEOUT_AFTER_COMMIT
    }

    // This payload contains synthetic payment data only.
    public record AuthorizationRequest(
            String token, long amount, String currency, String cryptogram) {
        public boolean isValidDemoTap() {
            return token.equals("tok_demo_device_a")
                    && amount == 1299
                    && currency.equals("USD")
                    && cryptogram.equals("cg_demo_valid");
        }
    }
}

What does this code do?

  • PaymentStatus separates a successful authorization from a decline or an ambiguous outcome.
  • FailureMode gives later tests a controlled way to simulate response loss.
  • AuthorizationRequest accepts only synthetic token data for this lab.
  • isValidDemoTap() recognizes the approved demo payload without contacting a payment network.
  • In PaymentModels.java, place the response records immediately before the final closing brace:
    // Responses expose the state and stable network identity.
    public record PaymentResponse(
            PaymentStatus status, String networkRequestId, String authorizationCode) {
    }

    // Later steps persist this record as the durable payment state.
    public record PaymentRecord(
            String idempotencyKey, String networkRequestId, PaymentStatus status) {
    }

Why these records matter

  • PaymentResponse carries the authorization state returned to the terminal.
  • networkRequestId identifies one issuer authorization attempt.
  • PaymentRecord defines the durable state that the corrected path uses later.
  • Save PaymentModels.java.
  • Confirm the new contract compiles by running this command in the VS Code terminal:
mvn test

What should you see?

Maven compiles the new records before running the existing context test. The command ends with BUILD SUCCESS.

Seeing a Java compilation failure?

Check that every record remains inside the PaymentModels class. Confirm that the final file has one closing brace for each record plus one for the outer class.

Make sure the file path matches the declared ai.nextwork.taptopay package.

Help me fix the PaymentModels compilation failure

✔️ Awesome, I've got everything!

Your synthetic authorization contract compiles. Confirm that PaymentModels.java is saved before continuing.

ⓧ I'd like to double check the full code

package ai.nextwork.taptopay;

public final class PaymentModels {
    private PaymentModels() {
    }

    // These states describe the simulator's authorization lifecycle.
    public enum PaymentStatus {
        RECEIVED, AUTHORIZED, DECLINED, UNKNOWN
    }

    // Tests use this switch to inject a failure after issuer work.
    public enum FailureMode {
        NONE, TIMEOUT_AFTER_COMMIT
    }

    // This payload contains synthetic payment data only.
    public record AuthorizationRequest(
            String token, long amount, String currency, String cryptogram) {
        public boolean isValidDemoTap() {
            return token.equals("tok_demo_device_a")
                    && amount == 1299
                    && currency.equals("USD")
                    && cryptogram.equals("cg_demo_valid");
        }
    }

    // Responses expose the state and stable network identity.
    public record PaymentResponse(
            PaymentStatus status, String networkRequestId, String authorizationCode) {
    }

    // Later steps persist this record as the durable payment state.
    public record PaymentRecord(
            String idempotencyKey, String networkRequestId, PaymentStatus status) {
    }
}
Expose the flawed authorization path

An idempotency check should stop repeated delivery from creating repeated financial effects. This first implementation separates the Redis lookup from the cache write, which leaves a window where another request can enter.

  • In the VS Code Explorer sidebar, select the src/main/java/ai/nextwork/taptopay folder.
  • Click the New File icon in the Explorer toolbar.
  • Enter FlawedPaymentAuthorizationService.java as the file name.
  • Set up the Redis dependency and issuer-effect counter by pasting this code:
package ai.nextwork.taptopay;

import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import ai.nextwork.taptopay.PaymentModels.PaymentResponse;
import ai.nextwork.taptopay.PaymentModels.PaymentStatus;

@Service
public class FlawedPaymentAuthorizationService {
    private final StringRedisTemplate redis;
    private final AtomicLong flawedIssuerEffects = new AtomicLong();

    public FlawedPaymentAuthorizationService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public long flawedIssuerEffectCount() {
        return flawedIssuerEffects.get();
    }
}

What does this code do?

  • StringRedisTemplate provides the cache operations used by the flawed path.
  • AtomicLong counts every simulated issuer effect safely across test threads.
  • flawedIssuerEffectCount() exposes the measurement without changing the counter.
  • In FlawedPaymentAuthorizationService.java, place the flawed authorization method above flawedIssuerEffectCount():
    // This check and the later write are separate operations on purpose.
    public PaymentResponse authorize(
            PaymentModels.AuthorizationRequest request, String idempotencyKey) {
        String cacheKey = "flawed:" + idempotencyKey;
        if (Boolean.TRUE.equals(redis.hasKey(cacheKey))) {
            return new PaymentResponse(PaymentStatus.AUTHORIZED,
                    redis.opsForValue().get(cacheKey), "AUTH-DEMO-001");
        }

        // The delay widens the race so both callers can observe a cache miss.
        try {
            Thread.sleep(100);
        } catch (InterruptedException error) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(error);
        }

        String networkRequestId = UUID.randomUUID().toString();
        PaymentStatus status = request.isValidDemoTap()
                ? PaymentStatus.AUTHORIZED : PaymentStatus.DECLINED;
        flawedIssuerEffects.incrementAndGet();
        redis.opsForValue().set(cacheKey, networkRequestId, Duration.ofMinutes(5));
        return new PaymentResponse(status, networkRequestId,
                status == PaymentStatus.AUTHORIZED ? "AUTH-DEMO-001" : null);
    }

Where the race comes from

  • hasKey() asks whether a completed result already exists.
  • Thread.sleep(100) creates a deterministic overlap after a cache miss.
  • flawedIssuerEffects.incrementAndGet() represents the financial effect that must happen once.
  • set() writes a completed-result hint after the effect has already occurred.
  • Save FlawedPaymentAuthorizationService.java.
  • Confirm the flawed service compiles by running this command:
mvn test

What does this check prove?

Maven now constructs the service with the Testcontainers-backed Redis connection. A BUILD SUCCESS result confirms the new component loads inside the application context.

Does the application context fail now?

Confirm that Docker Desktop is running. Check that the existing Testcontainers scaffold still declares its Redis service connection.

Check that FlawedPaymentAuthorizationService.java imports StringRedisTemplate from the Spring Data Redis package.

Help me diagnose this Spring context failure

  • In the VS Code Explorer sidebar, select the src/main/java/ai/nextwork/taptopay folder.
  • Click the New File icon in the Explorer toolbar.
  • Enter MerchantTerminalController.java as the file name.
  • Connect the terminal endpoint to the flawed service by pasting this code:
package ai.nextwork.taptopay;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

import ai.nextwork.taptopay.PaymentModels.AuthorizationRequest;
import ai.nextwork.taptopay.PaymentModels.PaymentResponse;

@RestController
public class MerchantTerminalController {
    private final FlawedPaymentAuthorizationService flawed;

    public MerchantTerminalController(FlawedPaymentAuthorizationService flawed) {
        this.flawed = flawed;
    }

    // The terminal supplies one key for every delivery of the same payment.
    @PostMapping("/terminal/authorizations")
    public PaymentResponse authorize(
            @RequestBody AuthorizationRequest request,
            @RequestHeader("Idempotency-Key") String idempotencyKey) {
        return flawed.authorize(request, idempotencyKey);
    }
}

How the terminal boundary works

  • PostMapping exposes the merchant-terminal authorization route.
  • RequestBody converts the synthetic JSON payload into an AuthorizationRequest.
  • Idempotency-Key identifies repeated deliveries of the same logical payment.
  • authorize() returns the flawed service result as the HTTP response.
  • Save MerchantTerminalController.java.
  • Start the local application by running this command in the current terminal:
mvn spring-boot:run

What does this command do?

Maven starts the Spring Boot application against the healthy Compose services. Keep this process running while you send the synthetic request.

  • Click the Split Terminal icon in the VS Code terminal panel.
  • Send the synthetic tap from the new terminal by running this command:
curl --json '{"token":"tok_demo_device_a","amount":1299,"currency":"USD","cryptogram":"cg_demo_valid"}' -H "Idempotency-Key: happy-path-001" http://localhost:8080/terminal/authorizations

What should you see?

The JSON response contains an AUTHORIZED status. It also contains a generated network request ID plus the synthetic authorization code.

Your terminal-to-processor slice now returns an authorization result. The next check puts the unsafe idempotency logic under simultaneous delivery.

Did the request fail to connect?

Confirm that the first terminal still shows the running Spring Boot process. Check that the request URL ends with /terminal/authorizations.

If the response is declined, compare all four synthetic payload values with the request contract. The amount must remain a number.

Help me debug this local authorization request

✔️ Awesome, I've got everything!

The endpoint returns an authorized response for the approved synthetic tap. Keep both Java files saved before adding the concurrency test.

ⓧ I'd like to double check the full code

package ai.nextwork.taptopay;

import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import ai.nextwork.taptopay.PaymentModels.PaymentResponse;
import ai.nextwork.taptopay.PaymentModels.PaymentStatus;

@Service
public class FlawedPaymentAuthorizationService {
    private final StringRedisTemplate redis;
    private final AtomicLong flawedIssuerEffects = new AtomicLong();

    public FlawedPaymentAuthorizationService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    // This check and the later write are separate operations on purpose.
    public PaymentResponse authorize(
            PaymentModels.AuthorizationRequest request, String idempotencyKey) {
        String cacheKey = "flawed:" + idempotencyKey;
        if (Boolean.TRUE.equals(redis.hasKey(cacheKey))) {
            return new PaymentResponse(PaymentStatus.AUTHORIZED,
                    redis.opsForValue().get(cacheKey), "AUTH-DEMO-001");
        }

        // The delay widens the race so both callers can observe a cache miss.
        try {
            Thread.sleep(100);
        } catch (InterruptedException error) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(error);
        }

        String networkRequestId = UUID.randomUUID().toString();
        PaymentStatus status = request.isValidDemoTap()
                ? PaymentStatus.AUTHORIZED : PaymentStatus.DECLINED;
        flawedIssuerEffects.incrementAndGet();
        redis.opsForValue().set(cacheKey, networkRequestId, Duration.ofMinutes(5));
        return new PaymentResponse(status, networkRequestId,
                status == PaymentStatus.AUTHORIZED ? "AUTH-DEMO-001" : null);
    }

    public long flawedIssuerEffectCount() {
        return flawedIssuerEffects.get();
    }
}
package ai.nextwork.taptopay;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

import ai.nextwork.taptopay.PaymentModels.AuthorizationRequest;
import ai.nextwork.taptopay.PaymentModels.PaymentResponse;

@RestController
public class MerchantTerminalController {
    private final FlawedPaymentAuthorizationService flawed;

    public MerchantTerminalController(FlawedPaymentAuthorizationService flawed) {
        this.flawed = flawed;
    }

    // The terminal supplies one key for every delivery of the same payment.
    @PostMapping("/terminal/authorizations")
    public PaymentResponse authorize(
            @RequestBody AuthorizationRequest request,
            @RequestHeader("Idempotency-Key") String idempotencyKey) {
        return flawed.authorize(request, idempotencyKey);
    }
}
Force two duplicate taps to race

A reliable concurrency test controls when workers begin. A shared CountDownLatch holds both calls until they are ready, which makes the cache-miss window reproducible.

  • Return to the terminal running Spring Boot.
  • Press Ctrl+C to stop the application.
  • Switch back to PaymentReliabilityIntegrationTest.java in the VS Code editor.
  • Replace its import section with these concurrency and assertion imports:
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

import ai.nextwork.taptopay.PaymentModels.AuthorizationRequest;
import ai.nextwork.taptopay.PaymentModels.PaymentResponse;
import ai.nextwork.taptopay.PaymentModels.PaymentStatus;

Why these imports are needed

The executor creates two worker threads. The latches coordinate their start while the futures collect both responses.

The assertions compare delivery count, authorization state, and issuer-effect count after the race.

  • In PaymentReliabilityIntegrationTest.java, place this helper immediately before the class's final closing brace:
    // This helper releases both calls from the same starting line.
    private List<PaymentResponse> runTogether(Supplier<PaymentResponse> action)
            throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2);
        CountDownLatch ready = new CountDownLatch(2);
        CountDownLatch start = new CountDownLatch(1);
        Callable<PaymentResponse> call = () -> {
            ready.countDown();
            start.await();
            return action.get();
        };

        Future<PaymentResponse> first = pool.submit(call);
        Future<PaymentResponse> second = pool.submit(call);
        try {
            assertTrue(ready.await(2, TimeUnit.SECONDS));
            start.countDown();
            return List.of(
                    first.get(5, TimeUnit.SECONDS),
                    second.get(5, TimeUnit.SECONDS));
        } finally {
            pool.shutdownNow();
        }
    }

How the concurrency harness works

  • ready confirms that both workers reached the starting line.
  • start releases both workers from one shared signal.
  • Future.get() waits for each response with a bounded timeout.
  • shutdownNow() closes the thread pool even when an assertion fails.
  • Save PaymentReliabilityIntegrationTest.java.
  • Confirm the concurrency helper compiles by running this command:
mvn test

What does this check prove?

The existing context test still passes with the concurrency harness present. Maven ends with BUILD SUCCESS before the race assertion is added.

  • In PaymentReliabilityIntegrationTest.java, add this field below the existing container declarations:
    // The test invokes the deliberately unsafe authorization path.
    @Autowired
    FlawedPaymentAuthorizationService flawed;

Why inject the service directly?

Direct service calls remove HTTP timing from the experiment. The test isolates the Redis check-then-act race that creates repeated issuer effects.

  • In the same test class, place this test above runTogether():
    @Test
    void flawedCheckThenActCreatesDuplicateEffects() throws Exception {
        String key = "flawed-" + UUID.randomUUID();
        AuthorizationRequest request = new AuthorizationRequest(
                "tok_demo_device_a", 1299, "USD", "cg_demo_valid");
        long effectsBefore = flawed.flawedIssuerEffectCount();

        // Both deliveries reuse one logical payment identity.
        List<PaymentResponse> delivered = runTogether(
                () -> flawed.authorize(request, key));

        long effectsCreated = flawed.flawedIssuerEffectCount() - effectsBefore;
        assertEquals(2, delivered.size(),
                "The simulator must deliver both duplicate calls");
        assertTrue(delivered.stream().allMatch(response ->
                response.status() == PaymentStatus.AUTHORIZED),
                "Both callers should observe an authorized response");
        assertTrue(effectsCreated >= 2,
                "The flawed check-then-act path must duplicate issuer effects");
    }

What does this test measure?

  • key gives both calls one shared idempotency identity.
  • effectsBefore captures the counter before the experiment.
  • runTogether() delivers both calls into the cache-miss window.
  • effectsCreated measures the repeated issuer work caused by that delivery.

Before you run the race, do you expect one idempotency key to guarantee one issuer effect?

  • Save PaymentReliabilityIntegrationTest.java.
  • Run the completed concurrency experiment with this command:
mvn test

What should you see?

The flawedCheckThenActCreatesDuplicateEffects test passes because it observes at least two issuer effects. Maven ends with BUILD SUCCESS.

This passing test captures a reliability failure as a repeatable baseline. Duplicate delivery produced duplicate financial effects despite one shared idempotency key.

You have reproduced the hard part: the simulator now turns a timing window into measurable duplicate issuer work.

Did the race test fail unexpectedly?

Confirm that both calls use the same key value. Check that the delay remains after hasKey() and before the counter increment.

If the test cannot start its containers, confirm that Docker Desktop is running. Keep the Testcontainers annotations and service connections from the existing scaffold.

Help me debug the duplicate-effect concurrency test

✔️ Awesome, I've got everything!

Your integration suite now records the duplicate issuer-effect baseline. Save the passing Maven output as evidence of the flaw.

ⓧ I'd like to double check the full code

package ai.nextwork.taptopay;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

import ai.nextwork.taptopay.PaymentModels.AuthorizationRequest;
import ai.nextwork.taptopay.PaymentModels.PaymentResponse;
import ai.nextwork.taptopay.PaymentModels.PaymentStatus;

@Testcontainers
@SpringBootTest
class PaymentReliabilityIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres =
            new PostgreSQLContainer("postgres:18.6-alpine");

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis = new GenericContainer<>("redis:8.10.3-alpine")
            .withExposedPorts(6379);

    // The test invokes the deliberately unsafe authorization path.
    @Autowired
    FlawedPaymentAuthorizationService flawed;

    @Test
    void contextLoads() {
    }

    @Test
    void flawedCheckThenActCreatesDuplicateEffects() throws Exception {
        String key = "flawed-" + UUID.randomUUID();
        AuthorizationRequest request = new AuthorizationRequest(
                "tok_demo_device_a", 1299, "USD", "cg_demo_valid");
        long effectsBefore = flawed.flawedIssuerEffectCount();

        // Both deliveries reuse one logical payment identity.
        List<PaymentResponse> delivered = runTogether(
                () -> flawed.authorize(request, key));

        long effectsCreated = flawed.flawedIssuerEffectCount() - effectsBefore;
        assertEquals(2, delivered.size(),
                "The simulator must deliver both duplicate calls");
        assertTrue(delivered.stream().allMatch(response ->
                response.status() == PaymentStatus.AUTHORIZED),
                "Both callers should observe an authorized response");
        assertTrue(effectsCreated >= 2,
                "The flawed check-then-act path must duplicate issuer effects");
    }

    // This helper releases both calls from the same starting line.
    private List<PaymentResponse> runTogether(Supplier<PaymentResponse> action)
            throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2);
        CountDownLatch ready = new CountDownLatch(2);
        CountDownLatch start = new CountDownLatch(1);
        Callable<PaymentResponse> call = () -> {
            ready.countDown();
            start.await();
            return action.get();
        };

        Future<PaymentResponse> first = pool.submit(call);
        Future<PaymentResponse> second = pool.submit(call);
        try {
            assertTrue(ready.await(2, TimeUnit.SECONDS));
            start.countDown();
            return List.of(
                    first.get(5, TimeUnit.SECONDS),
                    second.get(5, TimeUnit.SECONDS));
        } finally {
            pool.shutdownNow();
        }
    }
}

The unsafe path now fails in a controlled way. Next, you will move idempotency authority into PostgreSQL so concurrent duplicates can share one durable payment identity.

Make Authorization Idempotent and Durable

Your local Spring Boot simulator proved that duplicate delivery can create duplicate issuer effects. The corrected path needs one durable authority that chooses the winning payment identity.

PostgreSQL will arbitrate each claim through idempotency constraints. One stable network request ID will extend that guarantee across the simulated issuer boundary.

In this step, get ready to:
  • Persist the payment state machine behind an atomic PostgreSQL claim.
  • Enforce issuer uniqueness through one stable network request ID.
  • Prove that concurrent duplicate deliveries create one durable issuer effect.
Persist the payment state machine

A transaction state machine records where a payment sits in its lifecycle. The durable record can hold RECEIVED, AUTHORIZED, DECLINED, or UNKNOWN.

Why PostgreSQL decides the winner

The earlier Redis hasKey check happened before the issuer effect. Both threads could pass that check before either thread wrote the result.

A unique PostgreSQL constraint places the decision inside the database write. INSERT ... ON CONFLICT DO NOTHING gives one caller the claim while every conflicting caller observes the stored winner.

  • In the VS Code Explorer sidebar, select src/main/resources/schema.sql.
  • Update the schema to persist one payment identity for each idempotency key.
  • Store the request payload beside its stable networkRequestId so reused keys can be validated.
  • Restrict the stored payment status to the four states defined in PaymentStatus.
  • Save src/main/resources/schema.sql.
  • Check that the updated schema preserves the existing baseline by running:
mvn test

What does this check prove?

Maven rebuilds the application context against the updated schema. The existing flawed test stays available as your before-fix reliability baseline.

You should see the existing tests complete with BUILD SUCCESS. Your durable schema now supports the corrected path without erasing the reproduced race.

Does the schema test fail?

Check the SQL around each constraint for unmatched parentheses or missing commas. Confirm that the status values match PaymentStatus exactly.

If PostgreSQL reports an existing object conflict, make the schema initialization safe for repeated test runs.

Help me diagnose my schema test failure

The schema can store the truth. PaymentStore now needs to make that truth available through atomic operations.

  • Create src/main/java/ai/nextwork/taptopay/PaymentStore.java inside the existing Java package with the VS Code Explorer's New File control.

You should see PaymentStore.java beside the existing payment classes in the Explorer sidebar.

  • Implement PaymentStore as the PostgreSQL-authoritative payment store.
  • Use INSERT ... ON CONFLICT DO NOTHING to claim an idempotency key atomically.
  • Read the persisted PaymentRecord when another caller already owns the claim.
  • Reject a reused idempotency key when its token, amount, currency, or cryptogram differs from the stored request.
  • Provide operations that store each supported payment status.
  • Save src/main/java/ai/nextwork/taptopay/PaymentStore.java.

What makes the claim atomic?

The insert attempt contains the existence check plus the write in one database operation. PostgreSQL serializes conflicting unique-index decisions before either caller can become a second winner.

A duplicate caller reads the persisted row after losing the claim. This keeps the winner's payment identity stable across every delivery.

  • Verify that the new store compiles against the existing models by running:
mvn test

What does this check prove?

This test run checks that PaymentStore compiles with the existing PaymentRecord contract. It also confirms that the baseline test still runs against the updated persistence layer.

You should see BUILD SUCCESS again. The simulator now has a durable place to resolve competing payment claims.

Does PaymentStore fail to compile?

Check that every constructor argument follows the existing PaymentRecord order. Confirm that each database column maps to the corresponding record field.

Help me fix my PaymentStore compilation error

Enforce one durable issuer effect

The processor-side payment claim protects one payment identity. The simulated issuer needs its own durable idempotency record because retries can still cross that boundary more than once.

Every attempt will reuse one networkRequestId. The issuer table will use that value as its primary key.

  • Return to src/main/resources/schema.sql in the editor tab from earlier.
  • Define issuer_authorizations.network_request_id as the durable issuer idempotency primary key.
  • Save src/main/resources/schema.sql.
  • Create src/main/java/ai/nextwork/taptopay/IssuerEffectStore.java inside the existing Java package with the Explorer's New File control.

You should see IssuerEffectStore.java in the same package as PaymentStore.java.

  • Implement IssuerEffectStore.authorize with ON CONFLICT (network_request_id) DO NOTHING.
  • Run the issuer insert in a REQUIRES_NEW transaction.
  • Implement effectCount to count durable issuer effects by network request ID.
  • Save src/main/java/ai/nextwork/taptopay/IssuerEffectStore.java.

Why use an independent issuer transaction?

The issuer effect represents a separate system boundary. REQUIRES_NEW commits that effect independently from the processor's surrounding work.

The primary key makes repeated calls with the same network request ID harmless. Every repeated delivery resolves to the same durable effect.

  • Confirm that the issuer store compiles by running:
mvn test

What does this check prove?

Maven now compiles the issuer persistence boundary with the payment store. The current tests also confirm that the new table does not disrupt the flawed baseline.

You should see BUILD SUCCESS. The issuer can now reject duplicate effects through durable database state.

Does the issuer store fail?

Check that the SQL conflict target is exactly network_request_id. Confirm that the same column is the primary key in schema.sql.

Help me debug IssuerEffectStore

  • Create src/main/java/ai/nextwork/taptopay/SimulatedIssuer.java inside the existing Java package with the Explorer's New File control.

You should see SimulatedIssuer.java beside IssuerEffectStore.java in the Explorer sidebar.

  • Implement SimulatedIssuer as the synthetic issuer boundary.
  • Validate the existing demo cryptogram before authorization.
  • Delegate durable authorization effects to IssuerEffectStore.authorize.
  • Return the matching authorization outcome from the synthetic issuer.
  • Save src/main/java/ai/nextwork/taptopay/SimulatedIssuer.java.

Why keep the issuer boundary explicit?

The separate issuer component models a remote payment-system boundary inside one local process. This keeps the correctness experiment focused on identity, persistence, and failure behavior.

The project still uses synthetic payment data. It does not perform real card cryptography or move money.

  • Verify the complete issuer boundary by running:
mvn test

What does this check prove?

The build now connects synthetic issuer validation to durable issuer persistence. A successful run proves that the new boundary loads inside the existing application context.

You should see BUILD SUCCESS once more. The simulator now has an issuer-side idempotency boundary.

Does SimulatedIssuer fail to load?

Check that SimulatedIssuer receives the existing IssuerEffectStore component. Confirm that the cryptogram comparison uses the existing request contract.

Help me connect SimulatedIssuer

Route duplicates through the durable winner

The processor now has durable payment storage plus an idempotent issuer. PaymentAuthorizationService will compose those guarantees around one stable network request ID.

Why Redis remains a hint

Redis can return a completed result quickly when it is available. Its contents can expire or disappear without changing the durable payment outcome.

The corrected service catches Redis availability failures. PostgreSQL always decides whether a payment identity or issuer effect exists.

  • Create src/main/java/ai/nextwork/taptopay/PaymentAuthorizationService.java inside the existing Java package with the Explorer's New File control.

You should see PaymentAuthorizationService.java beside the existing flawed service. Keep both implementations because the baseline test still needs the flawed path.

  • Implement PaymentAuthorizationService around the atomic claim from PaymentStore.
  • Generate one networkRequestId only when a caller wins a new payment claim.
  • Return the winner's stored outcome when a duplicate caller loses the claim.
  • Reject mismatched payload reuse before calling the issuer.
  • Send every issuer attempt through the stable network request ID.
  • Write only completed-result hints to Redis.
  • Catch Redis availability failures without changing the PostgreSQL outcome.
  • Save src/main/java/ai/nextwork/taptopay/PaymentAuthorizationService.java.

How the two idempotency layers compose

The incoming idempotency key identifies one merchant payment. The stable network request ID identifies that same payment at the issuer boundary.

PostgreSQL enforces uniqueness at both layers. Duplicate network delivery remains possible while duplicate financial effects are blocked.

  • Return to src/main/java/ai/nextwork/taptopay/MerchantTerminalController.java in the editor tab from earlier.
  • Route the merchant-terminal authorization endpoint through PaymentAuthorizationService.
  • Save src/main/java/ai/nextwork/taptopay/MerchantTerminalController.java.
  • Check that the corrected service is connected by running:
mvn test

What does this check prove?

The application context now wires the merchant-terminal endpoint to the corrected service. The build also preserves the separate flawed service for the baseline race.

You should see BUILD SUCCESS. The endpoint now reaches the durable authorization path.

Does the corrected service fail to load?

Check that the controller depends on PaymentAuthorizationService for the corrected endpoint. Keep FlawedPaymentAuthorizationService available for its dedicated baseline test.

Help me fix the corrected service wiring

The final integration test will deliver the same payment through two synchronized threads. Durable database state matters more than which caller returns first.

  • Return to src/test/java/ai/nextwork/taptopay/PaymentReliabilityIntegrationTest.java in the editor tab from earlier.
  • Add correctedPathCreatesOneIssuerEffect with the existing two-thread latch harness.
  • Assert that both deliveries resolve to one persisted payment identity.
  • Assert that both deliveries reuse one stable network request ID.
  • Assert that IssuerEffectStore.effectCount returns exactly one issuer row.
  • Save src/test/java/ai/nextwork/taptopay/PaymentReliabilityIntegrationTest.java.

✔️ Awesome, I've got everything!

Your corrected path now has durable payment claims, a stable issuer identity, and a concurrency test. Make sure every changed file is saved before the final run.

ⓧ I'd like to double check the full code

Compare your project against this cumulative artifact map before running the final test.

  • In compose.yaml, keep the local Docker Compose configuration for PostgreSQL 18.6 and Redis 8.10.3, with host ports bound to 127.0.0.1 and health checks configured.
  • In pom.xml, keep the Maven project configuration for Java 25, Spring Boot 4.1.2, PostgreSQL, Redis, JUnit 6.0.3, Testcontainers for Java 2.0.5, and test dependencies.
  • In FlawedPaymentAuthorizationService.java, retain the intentionally flawed Redis hasKey check-then-act authorization path for the duplicate-effect baseline test.
  • In IssuerEffectStore.java, make authorize use INSERT ... ON CONFLICT (network_request_id) DO NOTHING in a REQUIRES_NEW transaction. Make effectCount count durable issuer effects by network request ID.
  • In MerchantTerminalController.java, retain the merchant-terminal HTTP endpoint that accepts a synthetic authorization request plus an idempotency key. Return a PaymentResponse through the corrected service.
  • In PaymentAuthorizationService.java, create or reuse one durable payment identity plus one stable networkRequestId. Return stored winner outcomes for duplicates. Treat PostgreSQL as authoritative. Use Redis only as a non-authoritative completed-result hint.
  • In PaymentModels.java, retain AuthorizationRequest, PaymentResponse, PaymentStatus, FailureMode, and PaymentRecord for the existing synthetic request.
  • In PaymentStore.java, atomically claim an idempotency key. Validate matching payload reuse. Persist RECEIVED, AUTHORIZED, DECLINED, and UNKNOWN. Expose stored PaymentRecord outcomes.
  • In SimulatedIssuer.java, validate demo cryptograms at the synthetic issuer boundary. Delegate durable authorization effects to IssuerEffectStore.
  • In application.yaml, keep the local Spring Boot configuration for PostgreSQL and Redis connections.
  • In schema.sql, persist durable payment state with a unique idempotency-key constraint. Use issuer_authorizations.network_request_id as the issuer idempotency primary key.
  • In PaymentReliabilityIntegrationTest.java, retain flawedCheckThenActCreatesDuplicateEffects. Add correctedPathCreatesOneIssuerEffect to prove that two concurrent duplicate deliveries share one payment identity and create one issuer row.
  • Before you run the suite, predict whether both synchronized callers can still create separate issuer rows.
  • Run the complete integration suite to test your prediction:
mvn test

What does the final test prove?

JUnit releases both callers through the shared latch. PostgreSQL arbitrates the payment claim before either caller can create a second payment identity.

The issuer receives one stable network request ID. Its primary key limits the durable issuer effect to one row even when the request is delivered twice.

You should see correctedPathCreatesOneIssuerEffect pass before Maven ends with BUILD SUCCESS.

That concurrency boundary is now enforced: two delivered calls share one persisted payment identity plus exactly one durable issuer effect.

Does the corrected concurrency test fail?

If the issuer count exceeds one, confirm that every duplicate reuses the stored networkRequestId. Check that issuer_authorizations.network_request_id remains the primary key.

If the payment identities differ, confirm that the service reads the persisted winner after INSERT ... ON CONFLICT DO NOTHING loses the claim.

Help me debug the corrected concurrency test

Your simulator now accepts duplicate delivery without duplicating the durable issuer effect. Next up, you will recover an authorization whose issuer commit succeeds but whose response disappears.

Recover Unknown Outcomes and Measure Reliability

Your corrected path now gives PostgreSQL authority over concurrent payment claims. An issuer can still commit an authorization before its response disappears.

This step models that ambiguity with bounded same-ID retries plus reconciliation. Grafana k6 turns the finished behavior into latency evidence you can keep.

In this step, get ready to:
  • Model a lost post-commit response with bounded retries.
  • Reconcile an UNKNOWN payment without creating another issuer effect.
  • Capture reliability evidence from the local simulator.
Prove the post-commit ambiguity

A missing response does not reveal whether the issuer committed its effect. The safe state is UNKNOWN until durable evidence resolves the outcome.

Why commit before the timeout?

The timeout must occur after IssuerEffectStore.authorize returns. Throwing inside its independent transaction would roll back the effect.

A post-commit timeout creates the uncertainty found in distributed systems. The issuer has an authorization even though the processor received no answer.

  • In src/test/java/ai/nextwork/taptopay/PaymentReliabilityIntegrationTest.java, add the timeout integration case from the approved project implementation.
  • Save PaymentReliabilityIntegrationTest.java.

Before you run the suite, do you think the current authorization path can preserve a committed issuer effect after losing its response?

  • Expose the missing behavior by running the test suite:
mvn test

Why this failure is expected

The new timeout case should not pass yet. The current path has no post-commit failure injection or bounded retry behavior.

This failure is the designed gap. It proves the simulator still treats a delivered response as part of successful authorization.

  • In src/main/java/ai/nextwork/taptopay/SimulatedIssuer.java, update authorize so IssuerEffectStore.authorize completes before FailureMode.TIMEOUT_AFTER_COMMIT injects the lost response.
  • Save SimulatedIssuer.java.

Before you rerun the suite, do you think moving the failure after the commit is enough to recover the final payment state?

  • Check the partial implementation by running the test suite again:
mvn test

What does the remaining failure prove?

The issuer can now retain its durable effect before losing the response. The processor still needs a bounded policy for handling that communication failure.

A fresh authorization identity would risk a second financial effect. Every retry must reuse the existing networkRequestId.

  • In src/main/java/ai/nextwork/taptopay/PaymentAuthorizationService.java, add at most two retries after issuer communication failure.
  • Reuse the original networkRequestId for every retry.
  • Persist UNKNOWN after the retry limit is exhausted.
  • Save PaymentAuthorizationService.java.

Before you run the corrected path, do you expect repeated delivery to create another issuer row?

  • Verify the bounded retry behavior by running:
mvn test

What does this test prove?

  • The issuer commits its durable effect before the simulated response disappears.
  • Every retry uses the same networkRequestId.
  • The retry limit prevents an unbounded request loop.
  • The processor stores UNKNOWN instead of inventing a decline.

Is the timeout test still failing?

Check that FailureMode.TIMEOUT_AFTER_COMMIT is evaluated only after the independent issuer transaction returns. Confirm that the retry loop keeps one networkRequestId.

Confirm that retry exhaustion persists UNKNOWN through PaymentStore.

Help me debug the bounded timeout retry test.

Reconcile the durable issuer outcome

Reconciliation resolves uncertainty by reading the issuer's existing idempotency record. It never submits a new authorization.

  • In src/main/java/ai/nextwork/taptopay/SimulatedIssuer.java, add inquire using the approved project implementation.
  • Save SimulatedIssuer.java.
  • Check that issuer inquiry preserves the existing suite by running:
mvn test

What does inquiry change?

The inquiry path reads the record identified by the existing networkRequestId. It does not call the issuer authorization operation.

The existing tests should still pass. Inquiry adds a read path without weakening the issuer uniqueness constraint.

  • In src/main/java/ai/nextwork/taptopay/PaymentAuthorizationService.java, add reconcile using the original idempotency key.
  • Resolve the payment through inquire using its stored networkRequestId.
  • Persist AUTHORIZED when the existing issuer record confirms the committed effect.
  • Save PaymentAuthorizationService.java.
  • Check the reconciliation path for regressions by running:
mvn test

Why is reconciliation safe?

The service begins with the stored payment identity. It asks the issuer about the same durable authorization identity.

The state can move from UNKNOWN to AUTHORIZED without another call to IssuerEffectStore.authorize.

  • In src/test/java/ai/nextwork/taptopay/PaymentReliabilityIntegrationTest.java, complete the approved reconciliation assertions.
  • Assert one issuer effect before reconciliation.
  • Assert AUTHORIZED after reconciliation.
  • Assert one issuer effect after reconciliation.
  • Save PaymentReliabilityIntegrationTest.java.

Before you run the completed test, do you expect reconciliation to change the payment state or the issuer row count?

  • Verify the complete timeout recovery path by running:
mvn test

What should the suite confirm?

Maven should finish with BUILD SUCCESS. The timeout test should reconcile UNKNOWN to AUTHORIZED.

The durable issuer effect count should remain 1 before reconciliation. It should remain 1 afterward.

Does reconciliation create another effect?

Confirm that reconcile calls inquire with the stored networkRequestId. It must not call the authorization path.

Check that the test reads the issuer effect count both before the inquiry and after the state update.

Help me find why reconciliation creates another issuer effect.

That is the uncertainty boundary handled. A lost response now becomes a recoverable state without duplicating the issuer effect.

Measure and document reliability

Tests prove the invariant under controlled conditions. Load evidence shows how the running Spring Boot simulator behaves across repeated requests.

What belongs in the evidence pack?

  • An architecture diagram showing the terminal boundary through the issuer boundary.
  • A sequence diagram showing the durable commit before the lost response.
  • Reliability measurements from the corrected concurrency path.
  • Latency evidence saved as local JSON result files.

The documentation must distinguish exactly-once simulator effects from duplicate or lost network delivery. That boundary keeps the engineering claim precise.

  • Use the VS Code file sidebar to create a k6 folder inside the local project.

You should see the new k6 folder beside src.

  • Create k6/authorization-load.js with the approved authorization load script.
  • Save k6/authorization-load.js.

You should see authorization-load.js inside the k6 folder. This script collects latency evidence from synthetic authorization requests.

  • Create k6/duplicate-delivery-load.js with the approved shared-idempotency-key load script.
  • Save k6/duplicate-delivery-load.js.

You should now see both load scripts in the k6 folder. The second script exercises duplicate request delivery against one payment identity.

  • Create README.md inside the local project using the approved documentation.
  • Save README.md.

Your README.md should now contain the architecture diagram plus the timeout sequence. It should also contain the test commands plus both Dockerized load commands.

Keep the claim precise

State that the simulator enforces one durable financial effect for a stable issuer identity. Network requests can still be duplicated or lost.

This is an exactly-once effect guarantee within the lab. It is not an exactly-once network delivery guarantee.

  • Start the local application by running:
mvn spring-boot:run

What does this command do?

Maven starts the local Spring Boot application against the healthy PostgreSQL and Redis containers. Keep this terminal session running during the load tests.

The process keeps control of this terminal. Press Ctrl-C when you are ready to stop it.

  • Create another terminal session in VS Code for the load-test commands.

Before you run the load scripts, do you expect duplicate delivery to increase the durable issuer-effect count?

  • Run the Dockerized authorization-load command from README.md.

You should see a load-test summary with completed checks. You should also see the first generated result file inside results/.

  • Run the Dockerized duplicate-delivery command from README.md.

You should see another load-test summary with completed checks. A second result file should appear inside results/.

Can the load container reach the app?

Confirm that the Spring Boot process is still running in its original terminal. Confirm that the PostgreSQL and Redis containers remain healthy.

Run the commands exactly as documented in README.md. The Dockerized scripts use the host address configured for the local environment.

Help me debug the Dockerized load-test connection.

✔️ My evidence is complete

Your tests pass. Your running simulator has produced two local load-test result files.

Your documentation now explains how one durable issuer effect survives duplicate delivery plus a lost response.

ⓧ I'd like to double check the project state

Compare your project against these final guarantees before continuing.

  • PaymentAuthorizationService performs at most two retries using one stable networkRequestId.
  • SimulatedIssuer injects FailureMode.TIMEOUT_AFTER_COMMIT after the durable issuer transaction succeeds.
  • PaymentAuthorizationService persists UNKNOWN after retry exhaustion.
  • The reconcile method resolves the existing issuer record through inquire.
  • PaymentReliabilityIntegrationTest verifies one issuer effect before reconciliation.
  • PaymentReliabilityIntegrationTest verifies one issuer effect after reconciliation.
  • README.md contains setup commands plus diagrams plus measurements plus both load-test commands.
  • The k6 folder contains both approved load scripts.

You have evidence for the full recovery path. Duplicate delivery is allowed while the simulator preserves exactly one durable issuer effect.

Secret mission

Secret Mission, Step 5: Combine Concurrency with an Issuer Timeout (10 min)

Two concurrent taps hit the same payment while the issuer loses every post-commit response. Build one integration test around that collision. Prove the stored outcome recovers from UNKNOWN to AUTHORIZED while the issuer effect count stays at one.

Clean Up Your Resources

Clean Up Your Resources

Your payment lab runs entirely on your Mac at an ongoing cost of $0. The options below let you keep its resources, pause them, or remove them.

Resources you used:

  • Local Spring Boot process when the application is running.
  • Local Docker Compose service for PostgreSQL, including its tables.
  • Local Docker Compose service for Redis, including its ephemeral keys.
  • Local Docker Compose network for the two services.
  • Local Maven build artifacts in target/.
  • Generated Grafana k6 result files in JSON format under results/.

Keep everything running

Keeping the lab requires no cleanup. This option fits continued testing or local demonstrations.

  • The Spring Boot process can continue serving the merchant-terminal endpoint.
  • The PostgreSQL container keeps the durable authorization records available.
  • The Redis container keeps the disposable completed-result hints available.
  • The generated k6 evidence remains ready for future reliability comparisons.

Pause - I'll come back to this later

Pausing releases local memory while preserving the project files. Your containers can resume from their stopped state later.

  • Stop the Spring Boot process by pressing Ctrl-C in its terminal.
  • Stop the PostgreSQL and Redis containers by running this command in the project terminal from earlier:
docker compose stop

What Does This Command Do?

The command stops both containers without deleting them. Their project configuration remains available for the next session.

  • Restart the stopped containers later by running this command from the same project terminal:
docker compose start

How Resume Works

The command starts the existing PostgreSQL and Redis containers. You can then run the Spring Boot application again for another demo.

Delete - I don't want to use this again

Deletion clears the local services plus generated artifacts. This cleanup preserves the complete source project for future reference.

  • Stop the Spring Boot process by pressing Ctrl-C in its terminal.
  • Remove the local containers plus their Compose network by running this command in the project terminal from earlier:
docker compose down

What Gets Removed?

The command removes the PostgreSQL container. It also removes the Redis container.

The Compose network is removed too.

PostgreSQL tables disappear with its container. Redis keys disappear with its container.

  • Confirm that no project services remain by running this command:
docker compose ps

What Should You See?

The command should show no running services for this project. That confirms the payment lab is no longer consuming container resources.

  • Delete the Maven build artifacts plus generated k6 result files by running this command from the project terminal:
rm -rf target results/*.json

What Stays?

This command removes target/. It also removes generated results/*.json evidence.

Your k6/ scripts remain.

Your source code remains. Your README.md remains.

Nice Work!

Nice Work!

You did it! Your local Spring Boot authorization simulator now preserves one durable issuer effect through duplicate delivery. It also recovers a committed authorization after its response disappears.

What you learned:

  • Built a terminal-to-issuer authorization flow using synthetic payment data. The simulator produces AUTHORIZED or DECLINED without moving money.
  • Reproduced a duplicate-effect race with concurrent taps. Made PostgreSQL authoritative through unique durable claims. Kept Redis as a disposable completed-result hint.
  • Modeled a lost post-commit response as UNKNOWN. Reused one stable networkRequestId for bounded retries. Reconciled the stored authorization to AUTHORIZED without another issuer effect. Distinguished exactly-once financial effects from duplicate or lost network delivery.
  • Secret Mission: Added a Testcontainers integration test that combines two concurrent calls with TIMEOUT_AFTER_COMMIT. Its assertions prove one durable issuer effect remains before and after reconciliation.

Ready to quiz yourself?