diff --git a/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java b/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java index bff758a..31f4eac 100644 --- a/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java +++ b/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java @@ -20,14 +20,21 @@ class StripeApi { static final class Result { private final String id; private final String status; + private final boolean idempotentReplayed; Result(String id, String status) { + this(id, status, false); + } + + Result(String id, String status, boolean idempotentReplayed) { this.id = id; this.status = status; + this.idempotentReplayed = idempotentReplayed; } String id() { return id; } String status() { return status; } + boolean idempotentReplayed() { return idempotentReplayed; } @Override public boolean equals(Object o) { @@ -35,17 +42,19 @@ public boolean equals(Object o) { if (!(o instanceof Result)) return false; Result result = (Result) o; return Objects.equals(id, result.id) - && Objects.equals(status, result.status); + && Objects.equals(status, result.status) + && idempotentReplayed == result.idempotentReplayed; } @Override public int hashCode() { - return Objects.hash(id, status); + return Objects.hash(id, status, idempotentReplayed); } @Override public String toString() { - return "Result[id=" + id + ", status=" + status + "]"; + return "Result[id=" + id + ", status=" + status + + ", idempotentReplayed=" + idempotentReplayed + "]"; } } @@ -77,7 +86,16 @@ Result createAndConfirm( .build(); PaymentIntent pi = client.paymentIntents().create(builder.build(), options); - return new Result(pi.getId(), pi.getStatus()); + + boolean idempotentReplayed = false; + if (pi.getLastResponse() != null) { + idempotentReplayed = pi.getLastResponse().headers() + .firstValue("Idempotent-Replayed") + .map(Boolean::parseBoolean) + .orElse(false); + } + + return new Result(pi.getId(), pi.getStatus(), idempotentReplayed); } catch (StripeException e) { throw new VerificationFailedException(e.getMessage()); diff --git a/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java b/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java index d51f00a..0c73dd0 100644 --- a/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java +++ b/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java @@ -103,6 +103,10 @@ public Receipt verify(Credential credential, Map request) { StripeApi.Result result = stripeApi.createAndConfirm( secretKey, amountMinorUnits, currency, spt, paymentMethodTypes, metadata, challengeId); + if (result.idempotentReplayed()) { + throw new VerificationFailedException( + "PaymentIntent " + result.id() + " was an idempotent replay, not a fresh charge"); + } if ("requires_action".equals(result.status())) { throw new com.stripe.mpp.error.PaymentActionRequiredException( "PaymentIntent " + result.id() + " requires action"); diff --git a/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java b/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java index 29f6a00..cf4bce8 100644 --- a/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java +++ b/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java @@ -97,6 +97,21 @@ void successfulChargeReturnsReceipt() { assertThat(receipt.externalId()).isNull(); } + @Test + void idempotentReplayedSucceededChargeIsRejected() { + // Regression test for AGR-2026-035: Stripe reporting a succeeded + // PaymentIntent whose creation was an idempotent replay (i.e. the + // charge already happened for an earlier challenge/SPT pair) must + // not be accepted as a fresh successful verification. + StubStripeApi api = new StubStripeApi( + new StripeApi.Result("pi_replay123", "succeeded", true)); + + assertThatThrownBy(() -> intent(api).verify(credential("spt_xxx"), REQUEST)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("pi_replay123") + .hasMessageContaining("replay"); + } + @Test void externalIdIncludedInReceipt() { StubStripeApi api = new StubStripeApi(new StripeApi.Result("pi_abc123", "succeeded"));