Split-Payment Internal Event Flow — Auth Service
🔒 InternalInternal information — not visible in the public (merchant) site.
Overview
This page documents how wallet-payment-service publishes v2 split-payment lifecycle events to Azure Event Grid, and how wallet-auth-service consumes them to update checkout session statuses. It exists to prevent regressions (see CC-24848) and to serve as a reference for anyone adding new payment statuses or event handlers.
Architecture
Event Publishing — Payment Service
Publisher
TransactionEventPublisher.publishEvent() wraps a SplitPayment payload in a com.optum.wallet.common.events.Event envelope and emits it as an EventGridEvent with dataVersion = "2.0.0" (constant AZURE_EVENTGRID_DATA_VERSION_2).
Event Types Published
All v2 split-payment events use a single event type string resolved from config:
| Config property | Value |
|---|---|
${wallet.payment.updated} | PAYMENT_UPDATED_EVENT |
Note:
PAYMENT_CREATED_EVENTandPAYMENT_CAPTURE_INITIALIZED/PAYMENT_CANCEL_INITIALIZEDare also routed toPaymentConsumerV2in auth-service, but the primary split-payment lifecycle events originate fromTransactionService.publishTransactionUpdateEvent()and always usePAYMENT_UPDATED_EVENT.
Publishing Trigger — PUBLISHABLE_SPLIT_PAYMENT_STATUSES
TransactionService only publishes an event when all payment allocations share the same status AND that status is in:
// Package-visible for exhaustive classification tests
static final Set<PaymentStatus> PUBLISHABLE_SPLIT_PAYMENT_STATUSES = Set.of(
PaymentStatus.COMPLETED,
PaymentStatus.ACCEPTED,
PaymentStatus.AUTHORIZED,
PaymentStatus.AUTH_REQUIRED,
PaymentStatus.PAYMENT_METHOD_FAILED,
PaymentStatus.CANCELED,
PaymentStatus.CANCEL_FAILED, // CC-23416: was missing; caused events to be permanently suppressed
PaymentStatus.FAILED,
PaymentStatus.ROLLED_BACK
);
Additional publishing paths:
publishFailedTransactionEvent()— fires whentransaction.error.httpStatusisNOT_FOUNDorUNPROCESSABLE_ENTITYisRollBack || hasAllFailedStatus— bypass the uniform-status rule to publish rollback and all-failed scenariosallAllocationsHavePublishableStatus()— bypass for any mixed-status snapshot where every allocation is already in a publishable state (e.g.[AUTHORIZED, AUTH_REQUIRED],[AUTHORIZED, ACCEPTED]). This generalises the previous per-scenario bypass. See 3DS Mixed-State Publishing below.
Duplicate-Event Guard — Terminal State Fence
CC-23416: publishTransactionUpdateEvent() includes an early-return guard to prevent duplicate PAYMENT_FAILED events in split-tender rollback scenarios. When one allocation has already failed but its sibling is still AUTHORIZED (mid-rollback), the guard suppresses the event until all allocations reach a terminal state.
// Evaluated before any mapper work
if (hasAnyAllocationInFailedStatus(txnDetails)
&& !allAllocationsInTerminalStatus(txnDetails)) {
log.info("Suppressing event — failed allocation detected but not all allocations are terminal yet");
return;
}
The terminal statuses used by this guard are:
// Package-visible; scoped exclusively to this guard
static final Set<PaymentStatus> TERMINAL_PAYMENT_STATUSES = Set.of(
PaymentStatus.COMPLETED,
PaymentStatus.FAILED,
PaymentStatus.CANCELED,
PaymentStatus.CANCEL_FAILED, // terminal in V2 split-tender — no retry path exists
PaymentStatus.ROLLED_BACK,
PaymentStatus.PAYMENT_METHOD_FAILED
);
Why
CANCEL_FAILEDis terminal here: In V2 split-tender,CancelPaymentServicerejects cancel API calls on multi-allocation transactions andCancelPaymentCommand.CANCELLABLE_STATUSESdoes not includeCANCEL_FAILED, so no internal retry path exists. Omitting it would cause the guard to suppress events indefinitely for[FAILED, CANCEL_FAILED]allocation combinations.
3DS Mixed-State Publishing
CC-23416: When a split-payment transaction has one allocation already AUTHORIZED and a second awaiting 3DS customer authentication (AUTH_REQUIRED), the uniform-status rule in shouldPublishEvent() returns false (allocations have different statuses). Without a bypass, no event would be published and the auth-service checkout session would remain stuck at INITIATED.
The fix uses allAllocationsHavePublishableStatus() — a generalised bypass that returns true when every allocation is in PUBLISHABLE_SPLIT_PAYMENT_STATUSES. For the [AUTHORIZED, AUTH_REQUIRED] case both statuses are publishable, so the bypass fires and the event is published. This replaces the original narrower hasAuthRequiredWithAuthorizedAllocation() function and handles any mixed-publishable snapshot (e.g. [AUTHORIZED, ACCEPTED]) automatically.
When the bypass fires, the transaction-level status is AUTH_REQUIRED (from TransactionStateMachine.getParentPaymentStatus — AUTH_REQUIRED has a lower progress rank than AUTHORIZED), so auth-service maps it to CheckoutSessionStatus.PENDING.
The bypass is event-order safe: it only fires once all allocations are simultaneously in publishable states, regardless of which Stripe webhook arrives first.
NextPaymentCommandResolveris 3DS-aware: For[AUTHORIZED, AUTH_REQUIRED],isAuthenticationPending()returnstrueandisCaptureScenario()returnsfalse, so the resolver returns a no-op (capture is not triggered while a 3DS challenge is in progress). Once both allocations areAUTHORIZED,isCaptureScenario()becomestrueandCapturePaymentCommandfires automatically.
SplitPayment Payload Fields
The SplitPayment event payload (com.optum.wallet.common.events.v2.dto.payment.SplitPayment, wallet-commons 2.1.1+) contains:
| Field | Type | Notes |
|---|---|---|
id | UUID | Transaction ID (stored as transactionId in auth checkout session) |
status | PaymentStatus | Top-level transaction status. Payment-service maps PAYMENT_METHOD_FAILED → FAILED before publishing; retryAllowed carries the soft-failure semantic. |
retryAllowed | boolean | True when further retry attempts remain. Auth-service reads this directly to decide whether to clear or set the session error and whether to treat FAILED as retryable (PENDING) or terminal. |
paymentDateUtc | String | ISO-8601 payment date; mapped to TransactionDetails. Added in wallet-commons 2.1.1. |
modifiedTs | LocalDateTime | Last-modified timestamp |
paymentAllocations | List<PaymentAllocation> | Per-allocation breakdown (status, error, amounts) |
error | ErrorResponse | Top-level error; used by V2 consumer for FAILED events with no allocation error |
metadata | Map<String,String> | Must contain checkoutId key — used by auth-service to look up the checkout session |
paymentDateUtcwas added in wallet-commons 2.1.1. Services must declarewallet-commons.version=2.1.1or newer. Using2.1.0will result in compile errors inTransactionDetailMapper.
Event Routing — Auth Service
VersionedPaymentConsumer routes incoming events by dataVersion:
dataVersion | Consumer |
|---|---|
| (blank) | PaymentConsumerV1 |
0.1 | PaymentConsumerV1 |
1.0.0 | PaymentConsumerV1 |
2.0.0 | PaymentConsumerV2 ← split-payment events |
| anything else | IllegalArgumentException |
Status Mapping — Auth Service
PaymentConsumerUtil.mapSplitPaymentStatus() (v2 only)
Called by PaymentConsumerV2 to convert the incoming PaymentStatus to a CheckoutSessionStatus. The table covers all 18 values of the PaymentStatus enum. The Published in V2? column indicates whether that status can appear as the top-level SplitPayment.status in a published event (i.e. whether it is in PUBLISHABLE_SPLIT_PAYMENT_STATUSES or can be emitted via the rollback/failed paths).
PaymentStatus | Published in V2? | CheckoutSessionStatus | Code path | Notes |
|---|---|---|---|---|
FAILED | ✅ Yes | FAILED | mapPaymentStatus | Terminal — no retry. Also see retry-logic section: if retryAllowed=true, consumer overrides this to PAYMENT_METHOD_FAILED before calling mapSplitPaymentStatus, so session becomes PENDING. |
PAYMENT_METHOD_FAILED | ✅ Yes (sent by older PS) | PENDING | mapSplitPaymentStatus | Retryable failure. Payment-service now publishes FAILED + retryAllowed=true instead, but auth-service handles this status defensively for backward compat. |
AUTH_REQUIRED | ✅ Yes | PENDING | mapSplitPaymentStatus | 3DS challenge pending. |
CANCELED | ✅ Yes | CANCELED | mapSplitPaymentStatus | All allocations canceled. |
ROLLED_BACK | ✅ Yes | FAILED | mapSplitPaymentStatus | System-initiated reversal; terminal failure from session perspective. |
COMPLETED | ✅ Yes | COMPLETED | mapPaymentStatus | All allocations settled. |
ACCEPTED | ✅ Yes | COMPLETED | mapPaymentStatus | ACH accepted; awaiting settlement. |
AUTHORIZED | ✅ Yes | COMPLETED | mapPaymentStatus | PRE-AUTH capture hold placed. Note: session becomes COMPLETED even though the payment isn't yet captured — the checkout widget flow is complete from the session perspective. |
CANCEL_FAILED | ✅ Yes | (original unchanged) | mapPaymentStatus default | Falls through the default branch; session status is left unchanged and logged at INFO. CANCEL_FAILED rarely arrives as the sole transaction-level status; it is typically accompanied by a sibling FAILED allocation which drives the terminal event. |
PENDING | ✅ Yes (SALE, post-capture) | PENDING | mapPaymentStatus | Transient; e.g. SALE flow after all allocations AUTHORIZED while capture is queued. |
INITIATED | Rarely | INITIATED | mapPaymentStatus | Early lifecycle state; unlikely to arrive in a published event. |
PROCESSING | ❌ Not publishable | (original unchanged) | mapPaymentStatus default | Not in PUBLISHABLE_SPLIT_PAYMENT_STATUSES; event suppressed by payment-service. |
CAPTURE_INITIALIZED | ❌ Not publishable | (original unchanged) | mapPaymentStatus default | Not publishable; transient capture state. |
CANCEL_INITIALIZED | ❌ Not publishable | (original unchanged) | mapPaymentStatus default | Not publishable; transient cancel state. |
CONFIRMATION_REQUIRED | ❌ Not publishable | (original unchanged) | mapPaymentStatus default | Not publishable; allocation-level 3DS state only. |
CONFIRMATION_INITIALIZED | ❌ Not publishable | (original unchanged) | mapPaymentStatus default | Not publishable; in-flight confirm step. |
PENDING_FOR_CUSTOMER_CREATION | ❌ Not publishable | (original unchanged) | mapPaymentStatus default | Internal pause state during customer provisioning. |
PARTIAL_SUCCESS / NOT_INITIATED / PENDING_FOR_PAYMENT_METHOD_CREATION / PROCESSING_DEDUP_CHECK | ❌ Not publishable | (original unchanged) | mapPaymentStatus default | Internal operational states; never expected as V2 split-payment event payload. |
CC-24848: Before this fix,
PAYMENT_METHOD_FAILED,AUTH_REQUIRED,CANCELED, andROLLED_BACKall fell through to thedefaultbranch and silently left the session status unchanged. The fix extracted these intomapSplitPaymentStatus(), keepingmapPaymentStatus()(used by V1) unchanged.
PaymentConsumerUtil.mapPaymentStatus() (v1 only)
PaymentConsumerV1 calls mapPaymentStatus() directly. CANCELED is intentionally a no-op in V1 — the V1 consumer is not expected to handle canceled split-payment events (those arrive as v2 events).
Retry logic in PaymentConsumerV2
PaymentConsumerV2 reads retryAllowed directly from the SplitPayment event payload — payment-service computes the flag using maxRetries and stamps it on the event before publishing (fixed in CC-23416; previously the @Context maxRetries parameter was missing from the mapper, causing retryAllowed to always be false).
boolean retryAllowed = paymentPayload.isRetryAllowed();
// Error handling: set or clear based on retry eligibility
if (PaymentStatus.FAILED.equals(paymentPayload.getStatus())
|| PaymentStatus.PAYMENT_METHOD_FAILED.equals(paymentPayload.getStatus())) {
if (retryAllowed) {
// CC-23416: clear any stale error so GET /v2/checkout-sessions/{id} returns 200
// with status=PENDING, keeping the checkout widget open for the next attempt.
checkoutSession.setError(null);
} else {
// Terminal failure: set the error so the user sees the decline reason.
updateCheckoutSessionError(checkoutSession, paymentPayload);
}
}
// Status override: treat retryable FAILED as PAYMENT_METHOD_FAILED so the session maps to PENDING
PaymentStatus effectiveStatus = retryAllowed && PaymentStatus.FAILED.equals(paymentPayload.getStatus())
? PaymentStatus.PAYMENT_METHOD_FAILED // PENDING — widget stays open for retry
: paymentPayload.getStatus();
checkoutSession.setCheckoutSessionStatus(
PaymentConsumerUtil.mapSplitPaymentStatus(effectiveStatus, originalStatus)
);
V2 event contract: Payment-service never publishes
PAYMENT_METHOD_FAILEDin the top-levelSplitPayment.statusfield since CC-23416. It always maps PMF →FAILEDand setsretryAllowed=trueto carry the "soft/retryable failure" semantic. Auth-service still handlesPAYMENT_METHOD_FAILEDdefensively (e.g. for backfill of older events or if an older payment-service version is deployed alongside a newer auth-service).
Parent Session Propagation — updateParentSession()
When a split-payment is tied to a child checkout session, auth-service also updates the parent checkout session after saving the child:
child saved → findByChildSessionId(child.id) → updateParentSession(parent, child) → parent saved
Child CheckoutSessionStatus | Effect on parent |
|---|---|
COMPLETED | Embedded ChildSession.status = COMPLETED; parent status set to COMPLETED if in payment mode |
FAILED | Error details propagated to embedded ChildSession (unless PAYMENT_METHOD_ERROR) |
CANCELED | Embedded ChildSession.status = CANCELED; parent status set to CANCELED |
Null-safety: Both the COMPLETED and CANCELED branches use Optional.ofNullable(parentSession.getChildSession()).ifPresent(...) — the parent status is still updated even when the embedded ChildSession object is null.
Event Consumer Registration
PaymentConsumerV2 is registered with the correct TypeReference<Event<SplitPayment>> so the Azure Event Grid event is deserialized into the right type. VersionedPaymentConsumer matches the event based on dataVersion before dispatching.
Configuration Reference
| Service | Property | Value |
|---|---|---|
wallet-payment-service | ccg.azure.eventgrid.payment.endpoint | Azure Event Grid topic endpoint |
wallet-payment-service | wallet.payment.updated | PAYMENT_UPDATED_EVENT |
wallet-payment-service | retry.payment.maxRetries | Max retry attempts before treating FAILED as terminal |
wallet-auth-service | retry.payment.maxRetries | Must match payment-service value |
| Both services | wallet-commons.version | Must be 2.1.1 or newer |
Common Failure Modes
| Symptom | Root Cause | Fix |
|---|---|---|
Checkout session stuck in INITIATED/PENDING after payment canceled | CANCELED status missing from mapSplitPaymentStatus() (CC-24848) | Upgrade to fix commit; ensure mapSplitPaymentStatus() is called in V2 consumer |
Checkout session stuck in INITIATED when one allocation is AUTHORIZED and another is AUTH_REQUIRED | Uniform-status gate in shouldPublishEvent() suppressed the event for mixed allocations (CC-23416) | Upgrade wallet-payment-service to include allAllocationsHavePublishableStatus() bypass |
Duplicate PAYMENT_FAILED webhooks in split-tender rollback | Event was published as soon as first allocation failed, then again after sibling settled (CC-23416) | Upgrade wallet-payment-service to include the terminal-state fence: event is suppressed until all allocations are in TERMINAL_PAYMENT_STATUSES |
Split-tender CANCEL_FAILED events permanently suppressed | CANCEL_FAILED was absent from PUBLISHABLE_SPLIT_PAYMENT_STATUSES and PaymentStatusSummary.anyCanceled (CC-23416) | Upgrade wallet-payment-service; CANCEL_FAILED must be in both sets |
| UI shows dead-end screen on 3DS retry (session stuck on 422) | retryAllowed=true was not clearing the stale session error; next GET returns 422 (CC-23416) | Upgrade wallet-auth-service so the V2 consumer calls checkoutSession.setError(null) when retryAllowed=true |
retryAllowed always false even on first payment attempt | @Context maxRetries missing from TransactionMapper call sites in payment-service (CC-23416) | Upgrade wallet-payment-service so toSplitPayment, toRollBackPaymentResponses, and mapToFailedSplitPayment all receive maxRetries |
TransactionDetailMapper compile error: paymentDateUtc / attemptCount not found | Service declaring wallet-commons 2.1.0 but local commons is at 2.1.1 | Bump wallet-commons.version to 2.1.1 and rebuild commons from source |
| Parent session not reflecting child cancellation | CANCELED branch missing in updateParentSession() (CC-24848) | Both child and parent statuses must be set in the CANCELED branch |
NPE in updateParentSession() on COMPLETED child | Direct dereference of parentSession.getChildSession() without null-check | Use Optional.ofNullable(...).ifPresent(...) |