Spring Data JPA Hibernate Envers Batch Charging and Stripe Integration: How AuditReader.getRevisions() Returns Empty After Transaction Rollback While ch_A Stays Committed on Stripe, @PrePersist UUID Generation on an @Audited Entity Produces a New Idempotency Key After Rollback Because the Persisted Entity Never Committed, and OPTIMISTIC_FORCE_INCREMENT Fires OptimisticLockException at Flush Time After ch_A Is Already on Stripe Creating ch_B When Recovery Retries with UUID.randomUUID()

The prior Hibernate Envers post in this series covered three foundational failure modes: the @Audited entity rollback misread as “Stripe never called,” the AuditQuery cross-revision window miss that lets re-queued charges slip past the deduplication scan, and the DefaultRevisionEntity timestamp used as a charge-receipt gate that fails silently after a crash. This post covers three additional failure modes that surface specifically in batch billing contexts: using AuditReader.getRevisions() as a per-customer idempotency guard, generating the Stripe idempotency key inside a @PrePersist callback on the @Audited entity, and combining a OPTIMISTIC_FORCE_INCREMENT JPA lock with a Stripe call inside the same transaction where the version check fires at flush time. In all three modes the outcome is the same: Stripe records two Charge or PaymentIntent objects for the same billing event with distinct Idempotency-Key header values, and the customer is billed twice.

Hibernate Envers sits in the same JDBC transaction as the business tables. When a transaction commits, Envers appends revision rows to the audit schema atomically. When a transaction rolls back, the Envers rows are rolled back too. This is Envers’ core guarantee: the audit trail is exactly as durable as the data it audits. The failure modes in this post all exploit a corollary that developers find counterintuitive: Stripe commitments are not in that JDBC transaction. A Stripe HTTP call can succeed — committing ch_A on Stripe’s servers — while the surrounding JDBC transaction later rolls back and takes every Envers revision with it. After the rollback, the Envers audit trail shows no evidence that the Stripe call was ever attempted. If recovery logic interprets an empty Envers audit trail as “this charge never happened,” it will generate a new idempotency key and create ch_B alongside ch_A.

The three failure modes in this post are distinguishable by mechanism. The first arises from using Envers’ revision list API directly as an idempotency gate. The second arises from where in the entity lifecycle the idempotency key is generated — in a @PrePersist callback — and what happens to that key when the enclosing transaction rolls back. The third arises from the timing of JPA’s optimistic lock check relative to the Stripe HTTP call: OPTIMISTIC_FORCE_INCREMENT acquires its read lock when the entity is loaded, but it does not check the version before the Stripe call — it checks and increments the version at flush time, after the Stripe call has already returned.

For the tests in this post the stack is: Spring Boot 3.x, Spring Data JPA, Hibernate 6.x with hibernate-envers, PostgreSQL 15 via Testcontainers, WireMock for Stripe API stubs, and JUnit 5. The StripeClient is the official Stripe Java SDK v24+.

Background: Hibernate Envers and Stripe as separate consistency domains

Hibernate Envers appends to audit tables inside the same JDBC transaction that modifies the base tables. The mechanism is a post-insert, post-update, post-delete event listener registered in the Hibernate event system. When Session.flush() runs, Hibernate processes the entity action queue and fires the Envers listener for each dirty entity. The listener writes to the *_AUD tables within the same java.sql.Connection that holds the business table changes. Commit atomically lands both. Rollback atomically erases both.

Stripe operates entirely outside this JDBC transaction. When the Stripe Java SDK calls charges().create(params, options), it opens a fresh HTTPS connection to api.stripe.com, sends the request, and blocks until the response arrives. If the response arrives with HTTP 200 and a Charge object, the charge is durable on Stripe’s side — it is in Stripe’s PostgreSQL cluster, acknowledged by its replication layer, visible in the Stripe Dashboard. There is no rollback signal that the Java application can send to Stripe to undo a committed charge. The subsequent behavior of the JDBC transaction — commit, rollback, timeout, JVM crash — is invisible to Stripe.

Developers building recovery logic against Envers must keep this asymmetry in mind. Envers can tell you what your local database contained at each revision. It cannot tell you what Stripe committed. Treating the Envers audit trail as a proxy for Stripe’s state is the error shared by all three failure modes in this post.

Mode 1: AuditReader.getRevisions(Payment.class, paymentId) returns empty after rollback — developer interprets empty revision list as “Stripe never called” — UUID_B generated — ch_B committed alongside committed ch_A

The developer builds a batch billing job that processes a list of subscription payments each month. To prevent duplicate charges, the job queries the Hibernate Envers audit trail before each Stripe call: if any revision exists for the Payment entity corresponding to the current billing period, the job skips that customer. The reasoning is: “if a Stripe charge was made for this payment, then the payment entity was updated to COMPLETED status and Envers recorded a revision. If there is no revision, no charge was made.”

// Payment.java — @Audited entity
@Entity
@Audited
@Table(name = "payments")
public class Payment {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String subscriptionId;
    private String billingPeriod;         // e.g. "2026-10"
    private int amountCents;
    private String customerId;

    @Enumerated(EnumType.STRING)
    private PaymentStatus status;         // PENDING, COMPLETED, FAILED

    private String stripeChargeId;
    private String stripeIdempotencyKey;

    // getters/setters omitted
}

// BatchBillingJob.java — uses AuditReader as idempotency guard
@Service
@Transactional
public class BatchBillingJob {

    @PersistenceContext
    private EntityManager entityManager;

    @Autowired
    private StripeClient stripeClient;

    @Autowired
    private PaymentRepository paymentRepository;

    public void processBillingCycle(List<Subscription> subscriptions,
                                    String billingPeriod) {
        AuditReader auditReader = AuditReaderFactory.get(entityManager);

        for (Subscription sub : subscriptions) {
            // Find existing payment row for this subscription + billing period
            Payment payment = paymentRepository
                .findBySubscriptionIdAndBillingPeriod(sub.getId(), billingPeriod)
                .orElseGet(() -> {
                    Payment p = new Payment();
                    p.setSubscriptionId(sub.getId());
                    p.setBillingPeriod(billingPeriod);
                    p.setAmountCents(sub.getAmountCents());
                    p.setCustomerId(sub.getStripeCustomerId());
                    p.setStatus(PaymentStatus.PENDING);
                    return paymentRepository.save(p);
                });

            // Idempotency guard: check Envers revision list
            List<Number> revisions = auditReader.getRevisions(
                Payment.class, payment.getId());

            if (revisions.size() > 1) {
                // "More than the initial ADD revision exists — a subsequent MOD
                // revision means the payment was updated, which means chargePayment()
                // ran to completion and set status to COMPLETED."
                log.info("Payment {} has {} revisions — already charged, skipping",
                    payment.getId(), revisions.size());
                continue;
            }

            // No MOD revision found — attempt the charge
            chargePayment(payment);
        }
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void chargePayment(Payment payment) {
        String idempotencyKey = UUID.randomUUID().toString();
        payment.setStripeIdempotencyKey(idempotencyKey);

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setCustomer(payment.getCustomerId())
            .setAmount((long) payment.getAmountCents())
            .setCurrency("usd")
            .putMetadata("subscription_id", payment.getSubscriptionId())
            .putMetadata("billing_period", payment.getBillingPeriod())
            .build();

        // Stripe commits ch_A here. HTTP 200 arrives.
        Charge charge = stripeClient.charges().create(params, options);

        payment.setStripeChargeId(charge.getId());
        payment.setStatus(PaymentStatus.COMPLETED);
        // paymentRepository.save() triggers Envers to queue an audit entry.
        // The audit entry is flushed to *_AUD tables when this REQUIRES_NEW
        // transaction commits.
        paymentRepository.save(payment);

        // Suppose the database is under heavy load: connection pool timeout
        // fires here before the transaction commits. Spring's transaction
        // infrastructure catches the exception, rolls back the REQUIRES_NEW
        // transaction. Both the payment_records UPDATE and the
        // payments_aud INSERT are rolled back atomically.
        //
        // ch_A remains committed on Stripe.
        // payment.status is still PENDING in the database.
        // AuditReader.getRevisions(Payment.class, payment.getId()) will
        // still return exactly [rev_1] (the initial ADD at row creation).
    }
}

The failure sequence:

  1. Batch job starts. payment.id = 42, status = PENDING, one Envers revision (the ADD at row creation).
  2. Outer transaction queries AuditReader.getRevisions(Payment.class, 42L) → returns [rev_1] (one revision). The guard condition is revisions.size() > 1 which is false, so the charge proceeds.
  3. chargePayment(payment) runs in a REQUIRES_NEW transaction. Stripe receives the request, commits ch_A, returns HTTP 200.
  4. Inside chargePayment: paymentRepository.save(payment) marks the entity dirty. Before the REQUIRES_NEW transaction commits, a database-level exception fires (connection pool timeout, deadlock, constraint violation from a concurrent insert). Spring rolls back the REQUIRES_NEW transaction.
  5. After the rollback: payments table still has status = PENDING. payments_aud table still has only rev_1 (the ADD). Envers has no record of the COMPLETED state update.
  6. The batch job’s outer transaction catches the exception from chargePayment and logs it. The outer transaction advances to the next subscription.
  7. The batch job includes a retry pass: for any subscription that threw an exception, it retries once. On retry: AuditReader.getRevisions(Payment.class, 42L) returns [rev_1] again — same as before. Guard condition false. chargePayment(payment) called again. New UUID.randomUUID() → UUID_B. Stripe receives request with Idempotency-Key: UUID_B. This is a different key than UUID_A used in the first attempt. Stripe creates ch_B. Customer billed twice.

The developer misconception is: “if a Stripe charge was committed, the REQUIRES_NEW transaction that called Stripe and saved the COMPLETED status would have committed too, and Envers would have a MOD revision. No MOD revision ⇒ no committed charge.” This reasoning fails because the Stripe commit and the JDBC transaction commit are independent events. Stripe committed ch_A when the HTTP 200 arrived on the SDK thread. The JDBC transaction’s commit happened later, and it failed. The two outcomes are not coupled.

What the Envers revision list tells you

AuditReader.getRevisions(Payment.class, id) returns the list of Hibernate revision numbers at which the entity changed in the audited database. Each revision corresponds to a committed JDBC transaction that modified the entity. A revision exists if and only if its surrounding JDBC transaction committed. A revision does not exist if its JDBC transaction rolled back — even if the Stripe call inside that transaction succeeded.

The revision list is therefore a reliable record of what your database committed. It is not a record of what Stripe committed. The two can diverge whenever a JDBC transaction rolls back after a successful Stripe HTTP call.

The fix: content-hash idempotency key derived from stable business inputs

The root cause is that UUID.randomUUID() is called inside a transaction that can roll back, discarding the key. The fix is to derive the idempotency key from stable business inputs that exist before the transaction starts — inputs that do not change between attempts and that uniquely identify the billing event:

// Fixed chargePayment: derive key from stable inputs, not UUID.randomUUID()
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void chargePayment(Payment payment) {
    // Derive idempotency key from inputs that are stable across retries.
    // subscriptionId + billingPeriod uniquely identifies this billing event.
    // amountCents is included to prevent key reuse across amount changes.
    String idempotencyKey = toIdempotencyKey(
        payment.getSubscriptionId(),
        payment.getBillingPeriod(),
        payment.getAmountCents()
    );

    RequestOptions options = RequestOptions.builder()
        .setIdempotencyKey(idempotencyKey)
        .build();

    ChargeCreateParams params = ChargeCreateParams.builder()
        .setCustomer(payment.getCustomerId())
        .setAmount((long) payment.getAmountCents())
        .setCurrency("usd")
        .putMetadata("subscription_id", payment.getSubscriptionId())
        .putMetadata("billing_period", payment.getBillingPeriod())
        .build();

    Charge charge = stripeClient.charges().create(params, options);

    payment.setStripeChargeId(charge.getId());
    payment.setStripeIdempotencyKey(idempotencyKey);
    payment.setStatus(PaymentStatus.COMPLETED);
    paymentRepository.save(payment);
    // If this transaction rolls back: the next retry calls Stripe with the
    // same idempotencyKey. Stripe's 24-hour idempotency cache returns ch_A.
    // No ch_B is created regardless of how many times the JDBC transaction fails.
}

private String toIdempotencyKey(String subscriptionId,
                                String billingPeriod,
                                int amountCents) {
    String input = subscriptionId + ":" + billingPeriod + ":" + amountCents;
    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
        return "ik_" + HexFormat.of().formatHex(hash).substring(0, 40);
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException(e);
    }
}

With this fix, the idempotency key is identical on every retry for the same subscription + billing period + amount, regardless of how many JDBC transactions roll back between attempts. Stripe’s 24-hour idempotency window guarantees that repeat requests with the same key return the original ch_A response without creating a new charge. The Envers audit trail can remain empty for rolled-back attempts without triggering a double charge.

Integration test for Mode 1

@SpringBootTest
@Testcontainers
class BatchBillingIdempotencyTest {

    @Container
    static PostgreSQLContainer<?> pg =
        new PostgreSQLContainer<>("postgres:15");

    @RegisterExtension
    static WireMockExtension wm = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort())
        .build();

    @Autowired BatchBillingJob batchBillingJob;
    @Autowired PaymentRepository paymentRepository;
    @PersistenceContext EntityManager em;

    @Test
    void auditReaderRevisionEmptyAfterRollback_doesNotCreateChB_withContentHashKey() {
        // Arrange: create a subscription
        Subscription sub = createSubscription("sub_001", 2999, "cus_test1");
        String billingPeriod = "2026-10";

        // Wire Stripe to succeed on first call
        wm.stubFor(post(urlEqualTo("/v1/charges"))
            .willReturn(aResponse()
                .setStatus(200)
                .setBody(stripeChargeJson("ch_A001"))));

        // Simulate: first chargePayment call → Stripe succeeds → DB rollback
        // (we simulate this by making paymentRepository.save() throw on first call,
        //  AFTER the Stripe SDK call. In practice this is a connection timeout or
        //  deadlock that fires between the Stripe call and the transaction commit.)
        doThrow(new DataAccessException("simulated connection pool timeout") {})
            .doCallRealMethod()
            .when(paymentRepository).save(any());

        // Act: run the batch cycle (first pass fails, retry pass succeeds)
        batchBillingJob.processBillingCycle(List.of(sub), billingPeriod);

        // Assert: Stripe was called exactly once (not twice)
        // Both calls should carry the same Idempotency-Key header
        List<LoggedRequest> stripeRequests = wm.findAll(postRequestedFor(
            urlEqualTo("/v1/charges")));
        assertThat(stripeRequests).hasSize(2); // first attempt + retry pass
        String key1 = stripeRequests.get(0).getHeader("Idempotency-Key");
        String key2 = stripeRequests.get(1).getHeader("Idempotency-Key");
        assertThat(key1).isEqualTo(key2); // same key both times — no ch_B
        assertThat(key1).startsWith("ik_");

        // Assert: payment is now COMPLETED in the database
        Payment payment = paymentRepository
            .findBySubscriptionIdAndBillingPeriod("sub_001", billingPeriod)
            .orElseThrow();
        assertThat(payment.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
        assertThat(payment.getStripeChargeId()).isEqualTo("ch_A001");

        // Assert: Envers has TWO revisions (ADD + MOD to COMPLETED)
        AuditReader reader = AuditReaderFactory.get(em);
        List<Number> revisions = reader.getRevisions(Payment.class, payment.getId());
        assertThat(revisions).hasSize(2);
    }
}

Mode 2: @PrePersist UUID generation on @Audited entity — key set at persist time — entity rolls back with its containing transaction — next @PrePersist generates UUID_B — ch_B committed alongside committed ch_A

The developer wants the Stripe idempotency key to be generated exactly once per Payment entity and stored durably. To achieve this they put the UUID generation in a JPA @PrePersist lifecycle callback: the key is generated when the entity is first persisted, stored in the entity, and from that point on any code that loads the entity can read the key and send it to Stripe. The reasoning is: “the key is persisted with the entity — it will always be available after the entity is saved — and I’ll never call Stripe with a different key for the same entity because I always read payment.getStripeIdempotencyKey().”

// Payment.java — @PrePersist generates the idempotency key
@Entity
@Audited
@Table(name = "payments")
public class Payment {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String subscriptionId;
    private String billingPeriod;
    private int amountCents;
    private String customerId;

    @Enumerated(EnumType.STRING)
    private PaymentStatus status;

    private String stripeChargeId;
    private String stripeIdempotencyKey;

    @PrePersist
    void prePersist() {
        // Called by Hibernate when this entity is about to be inserted.
        // Developer's intent: generate the key once, store it durably.
        if (this.stripeIdempotencyKey == null) {
            this.stripeIdempotencyKey = UUID.randomUUID().toString();
        }
    }
    // getters/setters omitted
}

// PaymentService.java — reads the key from the persisted entity
@Service
public class PaymentService {

    @Autowired
    private PaymentRepository paymentRepository;

    @Autowired
    private StripeClient stripeClient;

    @Transactional
    public ChargeResult chargeSubscription(String subscriptionId,
                                           String billingPeriod,
                                           int amountCents,
                                           String customerId) {
        // Create and save the Payment entity first.
        // @PrePersist fires here: UUID_A is generated and set on the entity.
        // Envers records an ADD revision for this entity when this transaction
        // commits.
        Payment payment = new Payment();
        payment.setSubscriptionId(subscriptionId);
        payment.setBillingPeriod(billingPeriod);
        payment.setAmountCents(amountCents);
        payment.setCustomerId(customerId);
        payment.setStatus(PaymentStatus.PENDING);
        Payment saved = paymentRepository.save(payment);

        // saved.getStripeIdempotencyKey() returns UUID_A at this point.
        // The save() does NOT immediately flush to the database — Hibernate
        // batches the INSERT until flush time (end of transaction or explicit
        // flush). The entity is in the first-level cache with UUID_A set.

        String idempotencyKey = saved.getStripeIdempotencyKey();
        // idempotencyKey == UUID_A

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setCustomer(customerId)
            .setAmount((long) amountCents)
            .setCurrency("usd")
            .putMetadata("subscription_id", subscriptionId)
            .putMetadata("billing_period", billingPeriod)
            .build();

        // Stripe commits ch_A with Idempotency-Key: UUID_A.
        Charge charge = stripeClient.charges().create(params, options);

        saved.setStripeChargeId(charge.getId());
        saved.setStatus(PaymentStatus.COMPLETED);
        paymentRepository.save(saved);

        // Flush occurs at transaction commit. Hibernate issues:
        //   INSERT INTO payments (..., stripe_idempotency_key) VALUES (...)
        //   INSERT INTO payments_aud (...) VALUES (...)   ← Envers ADD revision
        //   UPDATE payments SET status='COMPLETED', stripe_charge_id='ch_A001' WHERE id=?
        //   INSERT INTO payments_aud (...) VALUES (...)   ← Envers MOD revision
        //
        // If flush fails (e.g., unique constraint on subscriptionId+billingPeriod
        // because a concurrent transaction already inserted a row for the same
        // billing event), the ENTIRE transaction rolls back:
        //   - The payments INSERT is rolled back.
        //   - Both Envers INSERT statements are rolled back.
        //   - UUID_A is discarded. It no longer exists anywhere on disk.
        //   - ch_A remains committed on Stripe with Idempotency-Key: UUID_A.
        //
        // The constraint violation exception propagates to the caller.

        return new ChargeResult(charge.getId(), ChargeStatus.SUCCEEDED);
    }
}

// SubscriptionBillingOrchestrator.java — retries on exception
@Component
public class SubscriptionBillingOrchestrator {

    @Autowired
    private PaymentService paymentService;

    public void runBillingCycle(List<Subscription> subscriptions,
                                String billingPeriod) {
        for (Subscription sub : subscriptions) {
            try {
                paymentService.chargeSubscription(
                    sub.getId(), billingPeriod,
                    sub.getAmountCents(), sub.getCustomerId());
            } catch (Exception e) {
                log.warn("Charge failed for sub {}: {} — retrying",
                    sub.getId(), e.getMessage());
                // Retry: calls chargeSubscription() again.
                // A new Payment entity is constructed.
                // @PrePersist fires again: UUID.randomUUID() → UUID_B.
                // UUID_B != UUID_A.
                // Stripe receives Idempotency-Key: UUID_B.
                // No 24-hour cache hit (different key).
                // Stripe creates ch_B alongside already-committed ch_A.
                paymentService.chargeSubscription(
                    sub.getId(), billingPeriod,
                    sub.getAmountCents(), sub.getCustomerId());
            }
        }
    }
}

The failure sequence:

  1. First call to chargeSubscription("sub_001", "2026-10", 2999, "cus_test1").
  2. New Payment entity constructed. paymentRepository.save(payment) triggers @PrePersist: UUID_A = UUID.randomUUID() is set on payment.stripeIdempotencyKey.
  3. Stripe called with Idempotency-Key: UUID_A. HTTP 200 returns. ch_A committed on Stripe.
  4. Transaction flushes at commit. Hibernate issues the payments INSERT. A unique constraint on (subscription_id, billing_period) fires because a concurrent transaction already inserted a row for "sub_001" / "2026-10". DataIntegrityViolationException thrown. Transaction rolls back.
  5. After rollback: payments table has the row inserted by the concurrent transaction (status may be PENDING or COMPLETED depending on that transaction). UUID_A does not appear anywhere in the database — it was generated in memory, set on the JPA entity, and the entity’s INSERT was rolled back before it reached disk.
  6. Orchestrator catches the exception and retries immediately.
  7. Second call to chargeSubscription("sub_001", "2026-10", 2999, "cus_test1"). A fresh Payment entity is constructed. paymentRepository.save(payment) triggers @PrePersist again: UUID_B = UUID.randomUUID(). UUID_B != UUID_A.
  8. Stripe called with Idempotency-Key: UUID_B. Stripe has no record of UUID_B. Creates ch_B. Customer billed twice.

The developer misconception is: “the idempotency key is stored in the database with the entity — I can always query the payment by subscription ID and billing period to find the key I used, and I’ll reuse it on retry.” The mistake is that the INSERT containing the key was rolled back. The key exists in the JVM’s memory during the transaction but is never committed to disk. After the rollback, there is no database record of UUID_A, and the code has no way to recover it without redesigning the key derivation.

Why @PrePersist fires before the Stripe call in this design

Hibernate calls @PrePersist when EntityManager.persist() is invoked (or when a Spring Data save() call results in a new entity being persisted via EntityManager.persist()). In Hibernate 6 with batch inserts enabled, the actual SQL INSERT statement is deferred until flush time. @PrePersist fires at persist() time, not at flush time. So when the developer writes:

Payment saved = paymentRepository.save(payment);
String idempotencyKey = saved.getStripeIdempotencyKey(); // UUID_A — set by @PrePersist
Charge charge = stripeClient.charges().create(params,
    RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());

the sequence is: (1) @PrePersist fires, sets UUID_A on the in-memory entity; (2) Hibernate queues the INSERT in the action queue but does not flush to the database yet; (3) Stripe is called with UUID_A; (4) Stripe commits ch_A; (5) the transaction eventually flushes and commits or rolls back. If the flush fails (constraint violation, connection timeout), UUID_A is lost. On retry, a new Payment entity is constructed and @PrePersist fires again with UUID_B = UUID.randomUUID().

This is a particularly subtle bug because it only manifests when both of the following are true: the Stripe call succeeds AND the JDBC transaction fails at flush/commit time. During development and testing, these two events rarely coincide — the database is typically healthy and constraints are rarely violated — so the bug can go undetected for months.

The fix: derive the key before constructing the entity, from inputs that survive rollback

@Transactional
public ChargeResult chargeSubscription(String subscriptionId,
                                       String billingPeriod,
                                       int amountCents,
                                       String customerId) {
    // Key is derived from stable business inputs before the entity is created.
    // It does not depend on the entity being saved to the database.
    String idempotencyKey = toIdempotencyKey(subscriptionId, billingPeriod, amountCents);

    // Check if a payment already exists for this event (concurrent-safe)
    Optional<Payment> existing = paymentRepository
        .findBySubscriptionIdAndBillingPeriod(subscriptionId, billingPeriod);
    if (existing.isPresent() &&
        existing.get().getStatus() == PaymentStatus.COMPLETED) {
        return new ChargeResult(
            existing.get().getStripeChargeId(), ChargeStatus.SUCCEEDED);
    }

    // Create or retrieve the PENDING payment row
    Payment payment = existing.orElseGet(() -> {
        Payment p = new Payment();
        p.setSubscriptionId(subscriptionId);
        p.setBillingPeriod(billingPeriod);
        p.setAmountCents(amountCents);
        p.setCustomerId(customerId);
        p.setStatus(PaymentStatus.PENDING);
        p.setStripeIdempotencyKey(idempotencyKey); // set explicitly, not via @PrePersist
        return paymentRepository.save(p);
    });

    RequestOptions options = RequestOptions.builder()
        .setIdempotencyKey(idempotencyKey)
        .build();

    ChargeCreateParams params = ChargeCreateParams.builder()
        .setCustomer(customerId)
        .setAmount((long) amountCents)
        .setCurrency("usd")
        .putMetadata("subscription_id", subscriptionId)
        .putMetadata("billing_period", billingPeriod)
        .build();

    Charge charge = stripeClient.charges().create(params, options);

    payment.setStripeChargeId(charge.getId());
    payment.setStatus(PaymentStatus.COMPLETED);
    paymentRepository.save(payment);

    return new ChargeResult(charge.getId(), ChargeStatus.SUCCEEDED);
}

With this fix, the idempotency key is derived from subscriptionId + billingPeriod + amountCents before the entity is constructed. If the JDBC transaction rolls back, the key can be re-derived identically on the next attempt without reading from the database. The @PrePersist callback is removed or left empty to prevent the old randomization logic from interfering. Stripe’s 24-hour idempotency window returns ch_A on every subsequent retry with the same key, preventing ch_B regardless of how many times the database transaction fails.

Integration test for Mode 2

@Test
void prePersistUuidLostOnRollback_doesNotCreateChB_withContentHashKey() {
    // Arrange
    String subscriptionId = "sub_002";
    String billingPeriod = "2026-10";
    int amountCents = 2999;
    String customerId = "cus_test2";

    // Wire Stripe: first call returns ch_A, any subsequent call returns
    // ch_A again (idempotency cache hit — same key)
    String expectedKey = computeExpectedKey(subscriptionId, billingPeriod, amountCents);
    wm.stubFor(post(urlEqualTo("/v1/charges"))
        .withHeader("Idempotency-Key", equalTo(expectedKey))
        .willReturn(aResponse()
            .setStatus(200)
            .setHeader("Idempotency-Key", expectedKey)
            .setHeader("Idempotency-Replayed", "false")
            .setBody(stripeChargeJson("ch_A002"))));

    // Simulate: first transaction rolls back after Stripe succeeds
    // (inject concurrent insert to trigger unique constraint on second flush)
    insertConflictingPaymentRow(subscriptionId, billingPeriod); // concurrent row

    // Act: service handles the exception and retries
    assertThatCode(() ->
        paymentService.chargeSubscription(
            subscriptionId, billingPeriod, amountCents, customerId))
        .doesNotThrowAnyException();

    // Assert: Stripe was called. Both calls had the same Idempotency-Key.
    List<LoggedRequest> stripeRequests = wm.findAll(
        postRequestedFor(urlEqualTo("/v1/charges")));
    assertThat(stripeRequests).isNotEmpty();
    Set<String> keysUsed = stripeRequests.stream()
        .map(r -> r.getHeader("Idempotency-Key"))
        .collect(Collectors.toSet());
    assertThat(keysUsed).hasSize(1); // exactly one distinct key across all attempts
    assertThat(keysUsed.iterator().next()).isEqualTo(expectedKey);
}

Mode 3: @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) acquires read lock at load time but fires OptimisticLockException at flush time after Stripe commits ch_A — Envers records no COMPLETED revision — recovery queries PENDING status — generates UUID_B — ch_B created alongside ch_A

The developer uses JPA optimistic locking to prevent two concurrent billing threads from charging the same customer simultaneously. The entity has a @Version column. The billing method loads the Payment entity with LockModeType.OPTIMISTIC_FORCE_INCREMENT to detect any concurrent modification and fail fast with an OptimisticLockException. The developer’s reasoning is: “if I load the entity with OPTIMISTIC_FORCE_INCREMENT, I hold a logical lock that prevents any other transaction from also billing this customer — if a concurrent transaction touches the payment entity, my version check will fail and I’ll know not to proceed.”

The critical misunderstanding is when the version check fires. OPTIMISTIC_FORCE_INCREMENT does not hold a database-level row lock. It does not perform the version check when the entity is loaded. It performs the version check at flush time, just before committing: Hibernate issues UPDATE payments SET version = version + 1 WHERE id = ? AND version = ?. If the row’s current version does not match the version loaded at the start of the transaction, the UPDATE affects zero rows and Hibernate throws OptimisticLockException. By this time, any code that ran between the entity load and the flush — including the Stripe HTTP call — has already executed.

// Payment.java — with @Version for optimistic locking
@Entity
@Audited
@Table(name = "payments")
public class Payment {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version
    private Long version;

    private String subscriptionId;
    private String billingPeriod;
    private int amountCents;
    private String customerId;

    @Enumerated(EnumType.STRING)
    private PaymentStatus status;

    private String stripeChargeId;
    private String stripeIdempotencyKey;
    // getters/setters omitted
}

// BillingService.java — OPTIMISTIC_FORCE_INCREMENT used as concurrency guard
@Service
public class BillingService {

    @PersistenceContext
    private EntityManager entityManager;

    @Autowired
    private PaymentRepository paymentRepository;

    @Autowired
    private StripeClient stripeClient;

    @Transactional
    public ChargeResult chargePayment(Long paymentId) {
        // Load with OPTIMISTIC_FORCE_INCREMENT.
        // At this point: NO version check has occurred.
        // No database lock is acquired on the row.
        // The entity is in the first-level cache with version = N.
        Payment payment = entityManager.find(
            Payment.class, paymentId,
            LockModeType.OPTIMISTIC_FORCE_INCREMENT);

        if (payment == null) {
            throw new IllegalArgumentException("Payment " + paymentId + " not found");
        }

        if (payment.getStatus() != PaymentStatus.PENDING) {
            // Already charged — return early
            return new ChargeResult(
                payment.getStripeChargeId(), ChargeStatus.SUCCEEDED);
        }

        // Developer's belief: "I hold the OPTIMISTIC_FORCE_INCREMENT lock —
        // any concurrent modification will be detected before I proceed."
        // Reality: the detection happens at flush time, not here.
        // A concurrent transaction can already be mid-flight, past the Stripe
        // call, and about to commit — and this transaction will not discover
        // the conflict until its own flush.

        String idempotencyKey = UUID.randomUUID().toString(); // UUID_A

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setCustomer(payment.getCustomerId())
            .setAmount((long) payment.getAmountCents())
            .setCurrency("usd")
            .putMetadata("payment_id", paymentId.toString())
            .putMetadata("billing_period", payment.getBillingPeriod())
            .build();

        // Stripe call executes BEFORE the version check.
        // If a concurrent transaction has already incremented the version
        // (by modifying and committing a change to this payment row),
        // THIS transaction does not know that yet.
        //
        // Stripe commits ch_A with Idempotency-Key: UUID_A.
        Charge charge = stripeClient.charges().create(params, options);

        payment.setStripeChargeId(charge.getId());
        payment.setStripeIdempotencyKey(idempotencyKey);
        payment.setStatus(PaymentStatus.COMPLETED);

        // Hibernate flush occurs here (or at transaction end).
        // Hibernate executes:
        //   UPDATE payments
        //   SET version = N+1, status='COMPLETED', stripe_charge_id='ch_A001',
        //       stripe_idempotency_key='UUID_A'
        //   WHERE id = ? AND version = N
        //
        // If a concurrent transaction already incremented the version to N+1
        // (e.g., a metadata update, a status change, any @Version-tracked change),
        // this UPDATE affects 0 rows.
        // Hibernate throws OptimisticLockException.
        // Spring @Transactional rolls back the entire transaction:
        //   - The UPDATE is rolled back (no rows changed anyway).
        //   - The Envers audit entry for the COMPLETED status update is rolled back.
        //   - UUID_A is discarded.
        //   - ch_A remains on Stripe.
        paymentRepository.saveAndFlush(payment);

        return new ChargeResult(charge.getId(), ChargeStatus.SUCCEEDED);
    }
}

// BillingRetryService.java — retries OptimisticLockException
@Service
public class BillingRetryService {

    @Autowired
    private BillingService billingService;

    @Autowired
    private PaymentRepository paymentRepository;

    public ChargeResult chargeWithRetry(Long paymentId) {
        int attempts = 0;
        while (attempts < 3) {
            try {
                return billingService.chargePayment(paymentId);
            } catch (OptimisticLockingFailureException e) {
                attempts++;
                // Developer's reasoning:
                // "OptimisticLockException means a concurrent transaction modified
                // the payment entity — my transaction was rejected WITHOUT reaching
                // Stripe, because the lock detection fires before any side effects.
                // It is safe to retry — no charge was committed on my attempt."
                //
                // Reality:
                // The Stripe call was made INSIDE the transaction.
                // The version check fires at FLUSH time, AFTER the Stripe call.
                // ch_A was committed on Stripe before OptimisticLockException was thrown.
                //
                // On retry: billingService.chargePayment() is called again.
                // Payment status is still PENDING (the UPDATE was rolled back).
                // UUID_B = UUID.randomUUID() in the new transaction.
                // Stripe receives Idempotency-Key: UUID_B.
                // ch_B created alongside ch_A.
                log.warn("Optimistic lock on payment {} attempt {} — retrying",
                    paymentId, attempts);
                // Reload entity state — version is now N+1 from the concurrent txn
            }
        }
        throw new IllegalStateException(
            "Failed to charge payment " + paymentId + " after 3 attempts");
    }
}

The failure sequence:

  1. Payment id=42, version=0, status=PENDING.
  2. Billing thread loads Payment(42) with OPTIMISTIC_FORCE_INCREMENT. Version snapshot: 0. No database lock acquired.
  3. Concurrently: a monitoring process updates the payment’s lastUpdatedAt metadata field (any write that touches the @Version column). Commits with version incremented to 1.
  4. Billing thread calls Stripe with UUID_A. Stripe commits ch_A. HTTP 200 returns.
  5. Billing thread sets payment.status = COMPLETED, calls paymentRepository.saveAndFlush(payment).
  6. Hibernate issues UPDATE payments SET version=1, status='COMPLETED', ... WHERE id=42 AND version=0. Version is now 1 (set by the concurrent monitoring process). The WHERE version=0 clause matches zero rows. Hibernate throws OptimisticLockException.
  7. Spring rolls back the transaction. payments row: version=1, status=PENDING (the concurrent update’s status change). payments_aud: no COMPLETED revision. UUID_A: gone from memory.
  8. Retry service reloads payment: version=1, status=PENDING (the concurrent update left it PENDING). Calls billingService.chargePayment(42) again. New transaction: UUID_B = UUID.randomUUID(). Stripe receives UUID_B. Creates ch_B. Customer billed twice.

The developer misconception is: “OPTIMISTIC_FORCE_INCREMENT is a guard that fires before any side effects — if the lock fails, the transaction was aborted before reaching Stripe.” The word “optimistic” in JPA’s optimistic locking means the opposite of what the developer assumes. Optimistic locking does not acquire an exclusive row lock to prevent concurrent modifications; it detects concurrent modifications at commit time. By the time the detection fires, all business logic inside the transaction — including Stripe HTTP calls — has already executed.

When does the version check actually fire?

For OPTIMISTIC (read lock): Hibernate adds a version check to the flush SQL without modifying the version. At flush: SELECT version FROM payments WHERE id=?; if the current database version differs from the loaded version, throw OptimisticLockException.

For OPTIMISTIC_FORCE_INCREMENT (write lock): Hibernate issues an UPDATE payments SET version=N+1 WHERE id=? AND version=N at flush time. Zero rows updated → throw OptimisticLockException. In both cases, the check happens during flush, which in a @Transactional Spring method happens either at an explicit flush() / saveAndFlush() call or at the boundary of the transaction (when Spring calls EntityManager.flush() before commit()). Everything before the flush — including the Stripe call — has already executed.

This is documented behavior, but the documentation does not highlight the interaction with out-of-band operations like Stripe HTTP calls. Developers who understand optimistic locking in a pure-database context often assume it provides a pre-check that gates Stripe calls; it does not.

The fix: content-hash key + idempotency check before the Stripe call

@Transactional
public ChargeResult chargePayment(Long paymentId) {
    Payment payment = entityManager.find(
        Payment.class, paymentId,
        LockModeType.OPTIMISTIC_FORCE_INCREMENT);

    if (payment == null) {
        throw new IllegalArgumentException("Payment " + paymentId + " not found");
    }

    // Status check: if already COMPLETED, return without calling Stripe.
    // This handles both the "concurrent transaction already charged" and the
    // "previous attempt committed ch_A and also committed the DB update" cases.
    if (payment.getStatus() == PaymentStatus.COMPLETED) {
        return new ChargeResult(
            payment.getStripeChargeId(), ChargeStatus.SUCCEEDED);
    }

    // Content-hash key derived from stable inputs.
    // If this transaction rolls back and the retry reloads the entity
    // (still PENDING because the UPDATE was rolled back), the key
    // will be identical to the key used in the failed attempt.
    String idempotencyKey = toIdempotencyKey(
        payment.getSubscriptionId(),
        payment.getBillingPeriod(),
        payment.getAmountCents()
    );

    RequestOptions options = RequestOptions.builder()
        .setIdempotencyKey(idempotencyKey)
        .build();

    ChargeCreateParams params = ChargeCreateParams.builder()
        .setCustomer(payment.getCustomerId())
        .setAmount((long) payment.getAmountCents())
        .setCurrency("usd")
        .putMetadata("payment_id", paymentId.toString())
        .build();

    Charge charge = stripeClient.charges().create(params, options);
    // If ch_A was committed on a prior attempt with the same key, Stripe
    // returns ch_A from the 24-hour idempotency cache. No ch_B.
    // If this is a fresh call, Stripe commits ch_A now.

    payment.setStripeChargeId(charge.getId());
    payment.setStripeIdempotencyKey(idempotencyKey);
    payment.setStatus(PaymentStatus.COMPLETED);
    paymentRepository.saveAndFlush(payment);
    // If OptimisticLockException fires here: content-hash key ensures
    // the next retry sends the same key to Stripe. Stripe returns ch_A.
    // No ch_B even if the JDBC transaction fails repeatedly.

    return new ChargeResult(charge.getId(), ChargeStatus.SUCCEEDED);
}

Integration test for Mode 3

@Test
void optimisticForceIncrementFiresAtFlushAfterStripeCommits_doesNotCreateChB() {
    // Arrange: payment exists in PENDING state, version=0
    Payment payment = createPendingPayment("sub_003", "2026-10", 4999, "cus_test3");
    Long paymentId = payment.getId();

    String expectedKey = computeExpectedKey(
        payment.getSubscriptionId(),
        payment.getBillingPeriod(),
        payment.getAmountCents()
    );

    // Wire Stripe: always returns ch_A for the content-hash key
    wm.stubFor(post(urlEqualTo("/v1/charges"))
        .withHeader("Idempotency-Key", equalTo(expectedKey))
        .willReturn(aResponse()
            .setStatus(200)
            .setHeader("Idempotency-Key", expectedKey)
            .setBody(stripeChargeJson("ch_A003"))));

    // Simulate concurrent version increment (fires between entity load and flush)
    // We increment the version directly in the database to simulate a concurrent
    // transaction modifying the payment row.
    jdbcTemplate.update(
        "UPDATE payments SET version = version + 1 WHERE id = ?", paymentId);
    // version is now 1; billing transaction will load version=0 and then fail
    // the flush UPDATE (WHERE version=0 matches nothing)

    // First billing attempt: OptimisticLockException at flush
    assertThatExceptionOfType(OptimisticLockingFailureException.class)
        .isThrownBy(() -> billingService.chargePayment(paymentId));

    // Verify: Stripe was called exactly once with the content-hash key
    List<LoggedRequest> firstPassRequests = wm.findAll(
        postRequestedFor(urlEqualTo("/v1/charges")));
    assertThat(firstPassRequests).hasSize(1);
    assertThat(firstPassRequests.get(0).getHeader("Idempotency-Key"))
        .isEqualTo(expectedKey);

    // Reset the version to a state where the next attempt will succeed
    // (simulate: the concurrent transaction's update is now stable)
    // The version in DB is 1; next billing load reads 1; flush UPDATE
    // WHERE version=1 will succeed if no further concurrent changes.

    // Second attempt (retry): should succeed with same key — Stripe returns ch_A
    ChargeResult result = billingService.chargePayment(paymentId);
    assertThat(result.getChargeId()).isEqualTo("ch_A003");
    assertThat(result.getStatus()).isEqualTo(ChargeStatus.SUCCEEDED);

    // Verify: Stripe received two requests total, both with the same key
    List<LoggedRequest> allStripeRequests = wm.findAll(
        postRequestedFor(urlEqualTo("/v1/charges")));
    assertThat(allStripeRequests).hasSize(2);
    Set<String> keysUsed = allStripeRequests.stream()
        .map(r -> r.getHeader("Idempotency-Key"))
        .collect(Collectors.toSet());
    assertThat(keysUsed).hasSize(1); // same key both times — no ch_B
}

Why Envers creates a false sense of completeness for Stripe audit trails

The three failure modes share a deeper cause: Hibernate Envers is an excellent audit tool for your database, and developers working in both JPA and Stripe contexts naturally look for a single audit trail that covers both. Envers’ completeness for database changes — it captures every committed transaction and its revision number, timestamp, and changed values — creates an expectation that the Envers audit trail is a reliable proxy for Stripe’s state. This expectation is correct for any operation that is fully inside the JDBC transaction. It fails for operations that reach outside the JDBC transaction — which is every Stripe HTTP call.

Stripe maintains its own idempotency log, keyed on the Idempotency-Key header and scoped to your API key and request path. Stripe’s log is durable and separate from your database. The correct way to build a Stripe audit trail in a JPA application is not to use Envers to track Stripe commitments, but rather to:

  1. Use content-hash idempotency keys derived from stable business inputs, so that the same logical billing event always produces the same Stripe idempotency key regardless of how many JDBC transactions roll back.
  2. Store the Charge or PaymentIntent ID from Stripe’s response in the database when the local transaction commits, so that after a successful commit the local record and the Stripe record are in sync.
  3. Use Stripe’s GET /v1/charges?metadata[subscription_id]=sub_001&metadata[billing_period]=2026-10 endpoint or GET /v1/charges/{charge_id} to query Stripe’s state directly during recovery, rather than inferring Stripe’s state from the local audit trail.

Point 3 is particularly important for the OPTIMISTIC_FORCE_INCREMENT failure mode: after an OptimisticLockException, the local Envers audit trail has no COMPLETED revision (it was rolled back). But Stripe’s API will return ch_A for a GET /v1/charges/{id} query if the charge was committed. Recovery code can query Stripe directly to check for a committed charge before generating a new idempotency key, and then store the found charge ID in the local payment record without calling Stripe again.

Summary table

Mode Mechanism Trigger Developer misconception Detection signal Fix
1 AuditReader.getRevisions() as idempotency gate JDBC transaction rolls back after Stripe commits ch_A; Envers revision rolled back; recovery queries revisions, finds none “No Envers revision ⇒ Stripe was never called” Duplicate Charge IDs in Stripe Dashboard; Stripe idempotency-replayed header absent on second call Content-hash idempotency key; do not use Envers revision list as a proxy for Stripe commitments
2 @PrePersist UUID generation on @Audited entity Entity INSERT rolls back (constraint violation, connection timeout); UUID_A never reaches the database; retry generates UUID_B “The key is stored in the database with the entity — I can always look it up” Two Stripe charges sharing no idempotency key for the same subscription + billing period; ch_B never appears in local DB records Derive key from stable inputs before constructing the entity; remove UUID logic from @PrePersist
3 OPTIMISTIC_FORCE_INCREMENT fires at flush, after Stripe call Concurrent version increment between entity load and flush; OptimisticLockException at flush; Envers COMPLETED revision rolled back; retry generates UUID_B “OPTIMISTIC_FORCE_INCREMENT aborts the transaction before side effects if a conflict is detected” Double charge for same payment ID; status remains PENDING in DB despite ch_A on Stripe Content-hash key; verify Stripe charge status directly via API before re-charging; understand that optimistic lock check fires at flush time

Related posts in this series

Stop guessing whether your agent double-charged.

Keybrake proxies every Stripe call your agent makes — capping spend per-vendor, logging every request with its idempotency key, and giving you a one-click kill switch. The audit log shows you exactly which Stripe charges were committed, which keys were replayed, and which calls never reached Stripe.