Checking access…

Skip to main content
Version: v2

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 propertyValue
${wallet.payment.updated}PAYMENT_UPDATED_EVENT

Note: PAYMENT_CREATED_EVENT and PAYMENT_CAPTURE_INITIALIZED / PAYMENT_CANCEL_INITIALIZED are also routed to PaymentConsumerV2 in auth-service, but the primary split-payment lifecycle events originate from TransactionService.publishTransactionUpdateEvent() and always use PAYMENT_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 when transaction.error.httpStatus is NOT_FOUND or UNPROCESSABLE_ENTITY
  • isRollBack || hasAllFailedStatus — bypass the uniform-status rule to publish rollback and all-failed scenarios
  • allAllocationsHavePublishableStatus() — 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_FAILED is terminal here: In V2 split-tender, CancelPaymentService rejects cancel API calls on multi-allocation transactions and CancelPaymentCommand.CANCELLABLE_STATUSES does not include CANCEL_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.getParentPaymentStatusAUTH_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.

NextPaymentCommandResolver is 3DS-aware: For [AUTHORIZED, AUTH_REQUIRED], isAuthenticationPending() returns true and isCaptureScenario() returns false, so the resolver returns a no-op (capture is not triggered while a 3DS challenge is in progress). Once both allocations are AUTHORIZED, isCaptureScenario() becomes true and CapturePaymentCommand fires automatically.


SplitPayment Payload Fields

The SplitPayment event payload (com.optum.wallet.common.events.v2.dto.payment.SplitPayment, wallet-commons 2.1.1+) contains:

FieldTypeNotes
idUUIDTransaction ID (stored as transactionId in auth checkout session)
statusPaymentStatusTop-level transaction status. Payment-service maps PAYMENT_METHOD_FAILEDFAILED before publishing; retryAllowed carries the soft-failure semantic.
retryAllowedbooleanTrue 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.
paymentDateUtcStringISO-8601 payment date; mapped to TransactionDetails. Added in wallet-commons 2.1.1.
modifiedTsLocalDateTimeLast-modified timestamp
paymentAllocationsList<PaymentAllocation>Per-allocation breakdown (status, error, amounts)
errorErrorResponseTop-level error; used by V2 consumer for FAILED events with no allocation error
metadataMap<String,String>Must contain checkoutId key — used by auth-service to look up the checkout session

paymentDateUtc was added in wallet-commons 2.1.1. Services must declare wallet-commons.version=2.1.1 or newer. Using 2.1.0 will result in compile errors in TransactionDetailMapper.


Event Routing — Auth Service

VersionedPaymentConsumer routes incoming events by dataVersion:

dataVersionConsumer
(blank)PaymentConsumerV1
0.1PaymentConsumerV1
1.0.0PaymentConsumerV1
2.0.0PaymentConsumerV2 ← split-payment events
anything elseIllegalArgumentException

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).

PaymentStatusPublished in V2?CheckoutSessionStatusCode pathNotes
FAILED✅ YesFAILEDmapPaymentStatusTerminal — 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)PENDINGmapSplitPaymentStatusRetryable failure. Payment-service now publishes FAILED + retryAllowed=true instead, but auth-service handles this status defensively for backward compat.
AUTH_REQUIRED✅ YesPENDINGmapSplitPaymentStatus3DS challenge pending.
CANCELED✅ YesCANCELEDmapSplitPaymentStatusAll allocations canceled.
ROLLED_BACK✅ YesFAILEDmapSplitPaymentStatusSystem-initiated reversal; terminal failure from session perspective.
COMPLETED✅ YesCOMPLETEDmapPaymentStatusAll allocations settled.
ACCEPTED✅ YesCOMPLETEDmapPaymentStatusACH accepted; awaiting settlement.
AUTHORIZED✅ YesCOMPLETEDmapPaymentStatusPRE-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 defaultFalls 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)PENDINGmapPaymentStatusTransient; e.g. SALE flow after all allocations AUTHORIZED while capture is queued.
INITIATEDRarelyINITIATEDmapPaymentStatusEarly lifecycle state; unlikely to arrive in a published event.
PROCESSING❌ Not publishable(original unchanged)mapPaymentStatus defaultNot in PUBLISHABLE_SPLIT_PAYMENT_STATUSES; event suppressed by payment-service.
CAPTURE_INITIALIZED❌ Not publishable(original unchanged)mapPaymentStatus defaultNot publishable; transient capture state.
CANCEL_INITIALIZED❌ Not publishable(original unchanged)mapPaymentStatus defaultNot publishable; transient cancel state.
CONFIRMATION_REQUIRED❌ Not publishable(original unchanged)mapPaymentStatus defaultNot publishable; allocation-level 3DS state only.
CONFIRMATION_INITIALIZED❌ Not publishable(original unchanged)mapPaymentStatus defaultNot publishable; in-flight confirm step.
PENDING_FOR_CUSTOMER_CREATION❌ Not publishable(original unchanged)mapPaymentStatus defaultInternal pause state during customer provisioning.
PARTIAL_SUCCESS / NOT_INITIATED / PENDING_FOR_PAYMENT_METHOD_CREATION / PROCESSING_DEDUP_CHECK❌ Not publishable(original unchanged)mapPaymentStatus defaultInternal operational states; never expected as V2 split-payment event payload.

CC-24848: Before this fix, PAYMENT_METHOD_FAILED, AUTH_REQUIRED, CANCELED, and ROLLED_BACK all fell through to the default branch and silently left the session status unchanged. The fix extracted these into mapSplitPaymentStatus(), keeping mapPaymentStatus() (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_FAILED in the top-level SplitPayment.status field since CC-23416. It always maps PMF → FAILED and sets retryAllowed=true to carry the "soft/retryable failure" semantic. Auth-service still handles PAYMENT_METHOD_FAILED defensively (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 CheckoutSessionStatusEffect on parent
COMPLETEDEmbedded ChildSession.status = COMPLETED; parent status set to COMPLETED if in payment mode
FAILEDError details propagated to embedded ChildSession (unless PAYMENT_METHOD_ERROR)
CANCELEDEmbedded 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

ServicePropertyValue
wallet-payment-serviceccg.azure.eventgrid.payment.endpointAzure Event Grid topic endpoint
wallet-payment-servicewallet.payment.updatedPAYMENT_UPDATED_EVENT
wallet-payment-serviceretry.payment.maxRetriesMax retry attempts before treating FAILED as terminal
wallet-auth-serviceretry.payment.maxRetriesMust match payment-service value
Both serviceswallet-commons.versionMust be 2.1.1 or newer

Common Failure Modes

SymptomRoot CauseFix
Checkout session stuck in INITIATED/PENDING after payment canceledCANCELED 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_REQUIREDUniform-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 rollbackEvent 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 suppressedCANCEL_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 foundService declaring wallet-commons 2.1.0 but local commons is at 2.1.1Bump wallet-commons.version to 2.1.1 and rebuild commons from source
Parent session not reflecting child cancellationCANCELED branch missing in updateParentSession() (CC-24848)Both child and parent statuses must be set in the CANCELED branch
NPE in updateParentSession() on COMPLETED childDirect dereference of parentSession.getChildSession() without null-checkUse Optional.ofNullable(...).ifPresent(...)