Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,32 +20,41 @@ 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) {
if (this == o) return true;
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 + "]";
}
}

Expand DownExpand Up@@ -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());
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,6 +103,10 @@ public Receipt verify(Credential credential, Map<String, Object> 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");
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"));
Expand Down