Skip to content

Commit 9c259c1

Browse files
fix(s3): handle charge.succeeded webhook event type (STA-194) (#184)
* fix(s3): handle charge.succeeded webhook event type (STA-194) WebhookCommandHandler only handled payment_intent.succeeded but not charge.succeeded. When Stripe sends a charge.succeeded webhook it now routes to the same handlePaymentSucceeded flow and the idempotency check covers both event types. Also corrects the E2E StripeWebhookSender payload to use charge.succeeded. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(s3): address PR review — add charge.succeeded JSON fixture and pspTransaction assertion (STA-194) - Add dedicated aChargeSucceededEventJson() with correct type field - Add pspTransactionRepository.shouldHaveNoInteractions() to duplicate test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0bb1429 commit 9c259c1

4 files changed

Lines changed: 94 additions & 3 deletions

File tree

fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/WebhookCommandHandler.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
public class WebhookCommandHandler {
3131

3232
private static final String EVENT_PAYMENT_SUCCEEDED = "payment_intent.succeeded";
33+
private static final String EVENT_CHARGE_SUCCEEDED = "charge.succeeded";
3334
private static final String EVENT_PAYMENT_FAILED = "payment_intent.payment_failed";
3435

3536
private final CollectionOrderRepository orderRepository;
@@ -61,7 +62,7 @@ public CollectionOrder handleWebhook(WebhookCommand command) {
6162
recordPspTransaction(order, command);
6263

6364
return switch (command.eventType()) {
64-
case EVENT_PAYMENT_SUCCEEDED -> handlePaymentSucceeded(order, command);
65+
case EVENT_PAYMENT_SUCCEEDED, EVENT_CHARGE_SUCCEEDED -> handlePaymentSucceeded(order, command);
6566
case EVENT_PAYMENT_FAILED -> handlePaymentFailed(order, command);
6667
default -> {
6768
log.warn("Ignoring unrecognised webhook event type={}", command.eventType());
@@ -138,7 +139,7 @@ private CollectionOrder handlePaymentFailed(CollectionOrder order, WebhookComman
138139

139140
private boolean isAlreadyProcessed(CollectionOrder order, String eventType) {
140141
return switch (eventType) {
141-
case EVENT_PAYMENT_SUCCEEDED ->
142+
case EVENT_PAYMENT_SUCCEEDED, EVENT_CHARGE_SUCCEEDED ->
142143
order.status() == CollectionStatus.COLLECTED
143144
|| order.status() == CollectionStatus.REFUND_INITIATED
144145
|| order.status() == CollectionStatus.REFUND_PROCESSING

fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/WebhookCommandHandlerTest.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.anAwaitingConfirmationOrder;
2626
import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoring;
2727
import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoringTimestamps;
28+
import static com.stablecoin.payments.onramp.fixtures.WebhookFixtures.aChargeSucceededCommand;
2829
import static com.stablecoin.payments.onramp.fixtures.WebhookFixtures.aFailedCommand;
2930
import static com.stablecoin.payments.onramp.fixtures.WebhookFixtures.aMismatchCommand;
3031
import static com.stablecoin.payments.onramp.fixtures.WebhookFixtures.aSucceededCommand;
@@ -108,6 +109,58 @@ void shouldDetectAmountMismatch() {
108109
}
109110
}
110111

112+
@Nested
113+
@DisplayName("charge.succeeded")
114+
class ChargeSucceeded {
115+
116+
@Test
117+
@DisplayName("should transition AWAITING_CONFIRMATION to COLLECTED and publish CollectionCompletedEvent")
118+
void shouldTransitionToCollectedAndPublishEventForChargeSucceeded() {
119+
// given
120+
var order = anAwaitingConfirmationOrder();
121+
var command = aChargeSucceededCommand();
122+
var collected = order.confirmCollection(command.amount());
123+
124+
given(orderRepository.findByPspReference(PSP_REFERENCE)).willReturn(Optional.of(order));
125+
given(orderRepository.save(eqIgnoringTimestamps(collected))).willReturn(collected);
126+
127+
// when
128+
handler.handleWebhook(command);
129+
130+
// then
131+
then(orderRepository).should().save(eqIgnoringTimestamps(collected));
132+
then(eventPublisher).should().publish(eqIgnoringTimestamps(
133+
new CollectionCompletedEvent(
134+
collected.collectionId(),
135+
collected.paymentId(),
136+
collected.correlationId(),
137+
collected.collectedAmount().amount(),
138+
collected.collectedAmount().currency(),
139+
collected.paymentRail().rail().name(),
140+
collected.psp().pspName(),
141+
collected.pspReference(),
142+
collected.pspSettledAt())));
143+
}
144+
145+
@Test
146+
@DisplayName("should skip duplicate charge.succeeded webhook for already COLLECTED order")
147+
void shouldSkipDuplicateChargeSucceededWebhookForAlreadyCollectedOrder() {
148+
// given
149+
var order = aCollectedOrder();
150+
var command = aChargeSucceededCommand();
151+
152+
given(orderRepository.findByPspReference(PSP_REFERENCE)).willReturn(Optional.of(order));
153+
154+
// when
155+
handler.handleWebhook(command);
156+
157+
// then — no save, event publish, or transaction recording should occur
158+
then(orderRepository).should(never()).save(eqIgnoringTimestamps(order));
159+
then(eventPublisher).shouldHaveNoInteractions();
160+
then(pspTransactionRepository).shouldHaveNoInteractions();
161+
}
162+
}
163+
111164
@Nested
112165
@DisplayName("payment_intent.payment_failed")
113166
class PaymentFailed {

fiat-on-ramp/fiat-on-ramp/src/testFixtures/java/com/stablecoin/payments/onramp/fixtures/WebhookFixtures.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ private WebhookFixtures() {}
2222
public static final String WEBHOOK_SECRET = "whsec_test_secret_12345";
2323
public static final String EVENT_ID = "evt_test_001";
2424
public static final String EVENT_TYPE_SUCCEEDED = "payment_intent.succeeded";
25+
public static final String EVENT_TYPE_CHARGE_SUCCEEDED = "charge.succeeded";
2526
public static final String EVENT_TYPE_FAILED = "payment_intent.payment_failed";
2627
public static final UUID COLLECTION_ID = UUID.fromString("c3d4e5f6-a7b8-9012-cdef-123456789012");
2728

@@ -107,6 +108,42 @@ public static WebhookCommand aFailedCommand() {
107108
aFailedEventJson());
108109
}
109110

111+
public static WebhookCommand aChargeSucceededCommand() {
112+
return new WebhookCommand(
113+
EVENT_ID,
114+
EVENT_TYPE_CHARGE_SUCCEEDED,
115+
PSP_REFERENCE,
116+
COLLECTION_ID,
117+
new Money(new BigDecimal("1000.00"), "USD"),
118+
"succeeded",
119+
aChargeSucceededEventJson());
120+
}
121+
122+
public static String aChargeSucceededEventJson() {
123+
return aChargeSucceededEventJson(PSP_REFERENCE, 100000L, "usd", COLLECTION_ID);
124+
}
125+
126+
public static String aChargeSucceededEventJson(String pspRef, long amountCents,
127+
String currency, UUID collectionId) {
128+
return """
129+
{
130+
"id": "%s",
131+
"type": "charge.succeeded",
132+
"data": {
133+
"object": {
134+
"id": "%s",
135+
"amount": %d,
136+
"currency": "%s",
137+
"status": "succeeded",
138+
"metadata": {
139+
"collection_id": "%s"
140+
}
141+
}
142+
}
143+
}
144+
""".formatted(EVENT_ID, pspRef, amountCents, currency, collectionId);
145+
}
146+
110147
public static WebhookCommand aMismatchCommand() {
111148
return new WebhookCommand(
112149
EVENT_ID,

phase3-integration-tests/src/test/java/com/stablecoin/payments/phase3/support/StripeWebhookSender.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ private String buildChargeSucceededPayload(String pspReference, long amount, Str
7474
{
7575
"id": "evt_%s",
7676
"object": "event",
77-
"type": "payment_intent.succeeded",
77+
"type": "charge.succeeded",
7878
"data": {
7979
"object": {
8080
"id": "%s",

0 commit comments

Comments
 (0)