Customer Creation During Payment
Overview
When a v2 payment is submitted and the customer lookup or creation fails, the system must never return a synchronous error from POST /v2/payments. All customer-related failures are post-processing — the API always returns 202 Accepted immediately, and the result is surfaced asynchronously through GET /v2/payments polling.
There are two distinct async failure paths:
USER_INITIATED_PAYMENT— customer not found: The transaction is parked inPENDING_FOR_CUSTOMER_CREATIONand resumes when the customer service provisions the customer asynchronously.- Any flow — customer service rejects the request (e.g. invalid metadata, bad identifier): The transaction transitions to
FAILEDwith the customer-service error preserved, and aPAYMENT_UPDATEDevent is published. The caller discovers this viaGET /v2/paymentspolling.
Prior to this fix, CustomerCheckCommand.isPreProcessingCommand() returned true, causing any customer-service error (e.g. invalid metadata key, 422 from customer service) to be returned synchronously as an HTTP 422. The contract requires these failures to be asynchronous. This was fixed by moving the command to post-processing (isPreProcessingCommand() = false) and adding correct PaymentException handling in handleError().
This mechanism mirrors the equivalent V1 behaviour on the cc_payment table but is implemented entirely within the V2 domain model (Transaction aggregate / CC_TRANSACTION table).
Flow Type Decision Matrix
PaymentFlowType | Customer not found | Customer service rejects request (4xx/5xx) | Customer found |
|---|---|---|---|
USER_INITIATED_PAYMENT | Park → PENDING_FOR_CUSTOMER_CREATION | FAILED (async, event published) | Enrich transaction and continue |
MERCHANT_INITIATED_PAYMENT | Synchronous find-or-create via CustomerClient.createCustomer(). If create also fails → FAILED (async) | FAILED (async, event published) | Enrich and continue |
GUEST_PAYMENT | Customer lookup skipped entirely | N/A | N/A |
Pre-validation failures (missing required fields already in the transaction) are also FAILED with UNPROCESSABLE_ENTITY:
| Scenario | PaymentFlowType | Missing field | Result |
|---|---|---|---|
customerId null | USER_INITIATED_PAYMENT | transaction.customerId | FAILED, UNPROCESSABLE_ENTITY 422 via polling |
customer object null | MERCHANT_INITIATED_PAYMENT | txn_details.customer | FAILED, UNPROCESSABLE_ENTITY 422 via polling |
End-to-End Sequences
Path A — USER_INITIATED_PAYMENT: Customer Not Found (Park & Resume)
Path B — Any Flow: Customer Service Rejects Request (Async FAILED)
Component Breakdown
1. CustomerCheckCommand — Post-Processing Command
CustomerCheckCommand is a post-processing command (isPreProcessingCommand() = false). This is the central design principle: the transaction is persisted and 202 Accepted is returned to the caller before the customer check runs. All customer-related failures are surfaced asynchronously.
Making this post-processing ensures the V2 contract is preserved: POST /v2/payments is always 202 Accepted, and errors are discovered through GET /v2/payments polling or the PAYMENT_UPDATED webhook. A pre-processing command would surface customer failures synchronously (HTTP 422), which violates this contract.
This was the root cause of CC-12883: isPreProcessingCommand() was incorrectly returning true, causing customer-service 422s (e.g. invalid metadata keys) to be returned synchronously.
@Override
public boolean isPreProcessingCommand() {
return false; // Post-processing: runs after 202 is returned to the caller
}
@Override
public Mono<Map<String, Object>> execute(Map<String, Object> context, Transaction transaction) {
PaymentFlowType flowType = transaction.getTxn_details().getPaymentFlowType();
// Guest payments skip customer lookup entirely
if (PaymentFlowType.GUEST_PAYMENT.equals(flowType)) {
context.put(CUSTOMER_EXISTS_KEY, false);
return Mono.just(context);
}
// Non-guest flows require a customer object in the request
if (transaction.getTxn_details().getCustomer() == null) {
return Mono.error(new TransactionCommandException(CUSTOMER_COMMAND_NAME,
"Customer object is required for non-guest payment flows"));
}
return findCustomerBasedOnPaymentFlow(transaction)
.map(customerResponse -> {
transaction.enrichWithCustomerDetails(customerResponse);
context.put(CUSTOMER_RESPONSE_KEY, customerResponse);
context.put(CUSTOMER_EXISTS_KEY, true);
return context;
})
.onErrorResume(throwable -> handleError(throwable, transaction, context));
}
handleError() — Error Handling Decision Tree
All failures in execute() flow through handleError(). The branch order is critical:
private Mono<Map<String, Object>> handleError(Throwable throwable, Transaction transaction,
Map<String, Object> context) {
// 1. USER_INITIATED + CustomerNotFoundException → park in PENDING_FOR_CUSTOMER_CREATION
if (throwable instanceof CustomerNotFoundException
&& PaymentFlowType.USER_INITIATED_PAYMENT.equals(
transaction.getTxn_details().getPaymentFlowType())) {
transaction.setStatus(PaymentStatus.PENDING_FOR_CUSTOMER_CREATION);
return transactionRepository.save(transaction)
.doOnSuccess(this::publishPendingEvent)
.map(saved -> {
context.put(CUSTOMER_EXISTS_KEY, false);
context.put(CUSTOMER_PENDING_CREATION_KEY, true);
return context;
});
}
// 2. Network failure → errorGroup=INTERNAL_ERROR, httpStatus=GATEWAY_TIMEOUT
// Note: httpStatus=504 ∉ PUBLISHABLE_FAILED_HTTP_STATUSES → no event published
// INTERNAL_ERROR ∉ BUSINESS_RULE_VIOLATION_ERROR_GROUPS → GET returns 200 (FAILED, no 422)
if (throwable instanceof WebClientRequestException webClientRequestException) {
ErrorResponse error = ErrorResponse.builder()
.errorGroup(ErrorGroup.INTERNAL_ERROR)
.httpStatus(HttpStatus.GATEWAY_TIMEOUT)
.message(String.format("'%s' failed", CUSTOMER_COMMAND_NAME))
.build();
return Mono.error(new TransactionCommandException(CUSTOMER_COMMAND_NAME,
"Customer validation failed: " + webClientRequestException.getMessage(), error, throwable));
}
// 3. PaymentException with ErrorResponse → preserve the downstream error (e.g. 422 from customer service)
// PaymentException(ErrorResponse) constructor sets NO string message, so throwable.getMessage() == null.
// Use Optional to safely fall back.
if (throwable instanceof PaymentException paymentException
&& paymentException.getErrorResponse() != null) {
String customerServiceMessage = Optional.ofNullable(paymentException.getErrorResponse().getMessage())
.orElseGet(throwable::getMessage);
return Mono.error(new TransactionCommandException(CUSTOMER_COMMAND_NAME,
"Customer validation failed: " + customerServiceMessage,
paymentException.getErrorResponse(), throwable));
}
// 4. TransactionCommandException with no ErrorResponse → wrap as UNPROCESSABLE_ENTITY/422
// This catches pre-validation failures (null customerId, null customer object)
if (throwable instanceof TransactionCommandException tce && tce.getErrorResponse() == null) {
ErrorResponse error = ErrorResponse.builder()
.errorGroup(ErrorGroup.UNPROCESSABLE_ENTITY)
.httpStatus(HttpStatus.UNPROCESSABLE_ENTITY)
.message(tce.getErrorMessage())
.build();
return Mono.error(new TransactionCommandException(CUSTOMER_COMMAND_NAME,
"Customer validation failed: " + tce.getErrorMessage(), error, throwable));
}
// 5. Generic fallback — no ErrorResponse; TransactionAttemptOrchestrator treats this as INTERNAL_ERROR/500
return Mono.error(new TransactionCommandException(CUSTOMER_COMMAND_NAME,
throwable.getMessage(), throwable));
}
Why branch 3 must come before branch 5:
CustomerClientImpl.parseErrorResponse() always throws new PaymentException(ErrorResponse) — using the constructor that takes only an ErrorResponse and sets no string message. If the generic TransactionCommandException fallback (branch 5) caught this first, the error response would be lost, the transaction would end up with no ErrorResponse, and shouldPublishFailedEvent() would return false — making the failure invisible to the caller via polling.
What happens after handleError() throws:
handlePostProcessingCommandFailure() in TransactionService:
- Fetches the transaction from DB (falls back to the in-memory object if not found)
- Sets
status=FAILED,error=TransactionErrorMapper.toErrorResponse(exception)— which preserves theErrorResponsefrom the exception if present, or builds a genericINTERNAL_ERROR/500 fallback - Calls
TransactionUtil.normalizeBusinessRuleViolationError()— normalizes anyPAYMENT_METHOD_ERRORorINVALID_REQUESTerrorGroup toUNPROCESSABLE_ENTITY/422 before checking publishability - Saves and calls
publishFailedTransactionEvent()→shouldPublishFailedEvent()
Error classification after handleError():
| Branch | ErrorResponse.errorGroup | ErrorResponse.httpStatus | PUBLISHABLE_FAILED_HTTP_STATUSES? | GET returns |
|---|---|---|---|---|
| CustomerNotFoundException + USER_INITIATED | — (parked, not failed) | — | N/A | Via PENDING_FOR_CUSTOMER_CREATION state |
| WebClientRequestException | INTERNAL_ERROR | 504 GATEWAY_TIMEOUT | No (504 ∉ set) | 200 OK (status=FAILED, no 422) |
| PaymentException (e.g. 422 from customer service) | Preserved (e.g. UNPROCESSABLE_ENTITY) | 422 | Yes | 422 with detail |
| TransactionCommandException (null ErrorResponse) wrapped to 422 | UNPROCESSABLE_ENTITY | 422 | Yes | 422 with detail |
| Generic fallback | INTERNAL_ERROR (via buildGenericErrorResponse()) | 500 | No | 200 OK (status=FAILED, no detail) |
PUBLISHABLE_FAILED_HTTP_STATUSES = Set.of(HttpStatus.NOT_FOUND, HttpStatus.UNPROCESSABLE_ENTITY)
2. Context Flag — CUSTOMER_PENDING_CREATION_KEY
The constant CommonConstants.CUSTOMER_PENDING_CREATION_KEY = "customerPendingCreation" is placed in the shared execution context by CustomerCheckCommand when the transaction is parked.
TransactionService reads this flag after pre-processing commands complete to decide whether to trigger TransactionAttemptOrchestrator:
// TransactionService — orchestration guard
private boolean isCustomerPendingCreation(Map<String, Object> context) {
return Boolean.TRUE.equals(context.get(CUSTOMER_PENDING_CREATION_KEY));
}
When true, orchestration (attempt creation → vendor calls) is skipped entirely for the current request. Resumption is exclusively triggered by the CustomerConsumer.
3. Transaction Domain Model — Lifecycle Methods
Three methods added to the Transaction aggregate manage the pending/resumption lifecycle:
// Predicate
public boolean isPendingForCustomerCreation() {
return PaymentStatus.PENDING_FOR_CUSTOMER_CREATION.equals(status);
}
// Called by CustomerConsumer when customer CREATED event arrives
public Transaction resumeOnCustomerFound(CustomerEvent customerEvent) {
if (CustomerStatus.FAILED.equals(customerEvent.getStatus())) {
return failOnCustomerCreationFailure();
}
this.customerId = customerEvent.getCustomerId();
if (this.txn_details != null) {
this.txn_details.setCustomerId(customerEvent.getCustomerId());
this.txn_details.setVendorMerchantId(customerEvent.getVendorMerchantId());
}
this.status = PaymentStatus.PROCESSING;
return this;
}
// Terminal failure path
public Transaction failOnCustomerCreationFailure() {
this.status = PaymentStatus.FAILED;
this.error = ErrorResponse.builder()
.message("Customer creation failed — payment cannot proceed")
.build();
return this;
}
4. CustomerConsumer — V2 Resumption Path
CustomerConsumer.handle() now runs three sequential flows using Flux.concat, ensuring V1 payments, V2 transactions, and refunds are all processed from a single customer event, with independent onErrorResume guards on each:
private Mono<Void> handle(CustomerPayload customerPayload) {
return Flux.concat(
handleV1Payments(customerPayload), // resumes cc_payment rows (V1)
handleV2Transactions(customerPayload), // resumes cc_transaction rows (V2)
handleRefunds(customerPayload) // resumes refunds (V1/shared)
).then();
}
V2 path:
private Flux<Transaction> handleV2Transactions(CustomerPayload customerPayload) {
return transactionRepository
.findByCustomerIdAndStatus(
customerPayload.getCustomerId(),
PaymentStatus.PENDING_FOR_CUSTOMER_CREATION)
.flatMap(transaction -> resumeTransactionOnCustomerFound(transaction, customerPayload))
.onErrorResume(throwable -> {
log.error("Unable to process V2 transaction on customer creation", throwable);
return Mono.empty(); // independent failure — does not affect V1 or refund paths
});
}
private Mono<Transaction> resumeTransactionOnCustomerFound(Transaction transaction,
CustomerPayload payload) {
CustomerEvent customerEvent = payload.toCustomerEvent();
if (CustomerStatus.FAILED.equals(customerEvent.getStatus())) {
transaction.failOnCustomerCreationFailure();
return transactionRepository.save(transaction)
.doOnSuccess(this::publishFailedTransactionEvent);
}
transaction.resumeOnCustomerFound(customerEvent);
return transactionRepository.save(transaction)
.flatMap(this::triggerTransactionAttemptCreation);
}
Failure resilience in attempt creation:
private Mono<Transaction> triggerTransactionAttemptCreation(Transaction transaction) {
TransactionAttemptCommandRequest attemptRequest =
transactionAttemptMapper.toTransactionAttemptCommandRequestWithLinkedPayments(transaction);
return transactionAttemptOrchestrator
.createTransactionAttempt(attemptRequest, new HashMap<>())
.collectList()
.thenReturn(transaction)
.onErrorResume(throwable -> {
// If attempt creation fails, transition to FAILED and publish event
transaction.failOnCustomerCreationFailure();
return transactionRepository.save(transaction)
.doOnSuccess(this::publishFailedTransactionEvent);
});
}
5. Database — Repository and Index
TransactionRepository.findByCustomerIdAndStatus() queries CC_TRANSACTION rows in PENDING_FOR_CUSTOMER_CREATION during event resumption.
// JpaTransactionRepository
List<Transaction> findAllByCustomerIdAndStatus(UUID customerId, PaymentStatus status);
// TransactionRepositoryImpl (reactive wrapper)
public Flux<Transaction> findByCustomerIdAndStatus(UUID customerId, PaymentStatus status) {
return Mono.fromCallable(() ->
jpaTransactionRepository.findAllByCustomerIdAndStatus(customerId, status))
.flatMapMany(Flux::fromIterable)
.subscribeOn(Schedulers.boundedElastic());
}
A Flyway migration (V1_1_62) adds a composite index on (customer_id, status) to ensure this query remains performant as the table grows:
-- V1_1_62__add_customer_id_status_index.sql
CREATE INDEX IF NOT EXISTS idx_cc_transaction_customer_id_status
ON cc_transaction (customer_id, status);
State Transition: PENDING_FOR_CUSTOMER_CREATION
| Trigger | From | To | Action |
|---|---|---|---|
CustomerCheckCommand — CustomerNotFoundException on USER_INITIATED_PAYMENT | INITIATED | PENDING_FOR_CUSTOMER_CREATION | Persist transaction, publish event, set context flag |
CustomerConsumer — Customer CREATED event received | PENDING_FOR_CUSTOMER_CREATION | PROCESSING | Enrich customer data, trigger attempt creation |
CustomerConsumer — Customer FAILED event received | PENDING_FOR_CUSTOMER_CREATION | FAILED | Set error message, publish failed event |
CustomerConsumer — Attempt creation throws | PROCESSING | FAILED | failOnCustomerCreationFailure() + publish failed event |
Event Publishing
Two events are published during this lifecycle:
| Event | When | Status in Payload |
|---|---|---|
PAYMENT_UPDATED | Transaction parked in PENDING_FOR_CUSTOMER_CREATION | PENDING_FOR_CUSTOMER_CREATION |
PAYMENT_UPDATED | Transaction resumes to PROCESSING (via CustomerConsumer) | PROCESSING → subsequent status changes follow normal flow |
PAYMENT_UPDATED | Customer creation failed | FAILED |
The event ID format is consistent across all publishing points:
String eventId = String.format("%s|%s|%s",
transaction.getId(),
transactionUpdateEvent, // e.g. "wallet.payment.updated"
transaction.getStatus());
PaymentRequestValidator — Null Customer Allowed
Prior to this feature, PaymentRequestValidator rejected a null customer object at the DTO layer. This was intentionally relaxed to allow GUEST_PAYMENT flows, where X-Customer-Id is not provided and no customer object is needed.
The null-customer guard was moved into CustomerCheckCommand, where the PaymentFlowType is already resolved:
// PaymentRequestValidator — customer is no longer @NotNull at DTO level
// Enforcement for non-guest flows is delegated to CustomerCheckCommand
// CustomerCheckCommand — enforces customer presence for non-guest flows
if (transaction.getTxn_details().getCustomer() == null) {
return Mono.error(new TransactionCommandException(CUSTOMER_COMMAND_NAME,
"Customer object is required for non-guest payment flows"));
}
Why this matters: Bean Validation (@Valid) runs before the payment flow type can be determined from the HTTP header. Deferring the check to CustomerCheckCommand allows the same validation logic to be applied correctly for all flow types.
Test Coverage
CustomerCheckCommand Tests (37 tests)
| Test | What it verifies |
|---|---|
isPreProcessingCommand_shouldReturnFalse | Command is post-processing — runs after 202 is returned |
execute_guestPaymentFlow_skipsCustomerLookupAndReturnsContext | Guest flows short-circuit before any customer call |
execute_userInitiated_customerNotFound_transitionsToPendingForCustomerCreation | PENDING_FOR_CUSTOMER_CREATION status set, event published, context flags set |
execute_userInitiated_customerNotFound_preservesExistingContextEntries | CUSTOMER_EXISTS_KEY=false, CUSTOMER_PENDING_CREATION_KEY=true in context |
execute_ShouldFindOrCreateCustomer_WhenMerchantInitiatedPaymentAndCustomerNotFound | MERCHANT_INITIATED_PAYMENT triggers find-or-create |
execute_merchantInitiated_missingCustomerObject_wrapsAs422UnprocessableEntity | Null customer object → TransactionCommandException with UNPROCESSABLE_ENTITY/422 |
execute_userInitiated_nullCustomerId_wrapsAs422UnprocessableEntity | Null customerId → TransactionCommandException with UNPROCESSABLE_ENTITY/422 |
execute_merchantInitiated_customerCreationFailsWithUnprocessableEntity_throwsTransactionCommandExceptionWith422 | Customer service 422 → TransactionCommandException with preserved 422 ErrorResponse |
execute_merchantInitiated_customerCreationFailsWithPaymentException_preservesHttpStatusFromDownstream | PaymentException → ErrorResponse httpStatus preserved (not lost) |
execute_merchantInitiated_searchFailsWithPaymentException_preservesHttpStatus | Search 422 → preserved through handleError() |
execute_merchantInitiated_paymentException_errorMessageUsesErrorResponseMessage_notNull | throwable.getMessage()=null → uses errorResponse.getMessage() as fallback |
execute_userInitiated_paymentExceptionOnSearch_preservesHttpStatusAndMessage | USER_INITIATED non-404 failure from search → FAILED with correct status |
execute_merchantInitiated_createCustomerFailsWithWebClientRequestException_returnsGatewayTimeout | Network timeout → TransactionCommandException with errorGroup=INTERNAL_ERROR, httpStatus=504 GATEWAY_TIMEOUT |
execute_merchantInitiated_searchFailsWithWebClientRequestException_returnsGatewayTimeout | Network timeout on search → same: INTERNAL_ERROR errorGroup, 504 httpStatus |
execute_merchantInitiated_createCustomerFailsWithGenericException_wrapsAsTransactionCommandException | Unknown error → generic fallback |
execute_paymentExceptionWithNullErrorResponse_fallsToGenericHandler | PaymentException with null errorResponse → falls through to generic fallback |
execute_userInitiated_customerNotFound_saveFailure_propagatesDbError | DB save fails during park → error propagated upstream |
CustomerConsumer Tests
| Test | What it verifies |
|---|---|
handleV2Transactions_customerCreated_resumesTransaction | resumeOnCustomerFound() called, PROCESSING state saved, attempt creation triggered |
handleV2Transactions_customerFailed_transitionsToFailed | failOnCustomerCreationFailure() called, FAILED event published |
handleV2Transactions_attemptCreationFails_transitionToFailedAndPublishesEvent | Attempt creation error → transaction transitions to FAILED, event published |
TransactionService Tests
| Test | What it verifies |
|---|---|
testProcessTransaction_PendingForCustomerCreationStatus | Orchestrator is never invoked when CUSTOMER_PENDING_CREATION_KEY=true |
processTransaction_cc12883_customerCheckPostProcessing_invalidMetadata_returns202AndEventuallyFails | CC-12883 regression test — invalid metadata POST returns 202; async post-processing transitions transaction to FAILED with 422 |
Transaction Domain Tests
| Test | What it verifies |
|---|---|
resumeOnCustomerFound_customerCreated_transitionsToProcessing | customerId, vendorMerchantId enriched, status = PROCESSING |
resumeOnCustomerFound_customerFailed_transitionsToFailed | Delegates to failOnCustomerCreationFailure() |
failOnCustomerCreationFailure_setsFailedStatus | Status = FAILED, error message set |
Logging Reference
Key log messages to trace this flow in Splunk / Application Insights:
| Message | Level | Location |
|---|---|---|
"Guest payment — skipping customer lookup for transaction: {merchantTransactionId}" | INFO | CustomerCheckCommand |
"Customer not found for USER_INITIATED_PAYMENT transaction: {} — transitioning to PENDING_FOR_CUSTOMER_CREATION" | INFO | CustomerCheckCommand |
"Failed to publish PENDING_FOR_CUSTOMER_CREATION event for transaction: {} — {}" | WARN | CustomerCheckCommand |
"Resuming V2 transaction {} after customer creation" | INFO | CustomerConsumer |
"Customer creation failed for transaction: {} — transitioning to FAILED" | WARN | CustomerConsumer |
"Re-triggered TransactionAttempt with {} result(s) for transaction: {}" | INFO | CustomerConsumer |
"Attempt creation failed for transaction: {} — transitioning to FAILED" | ERROR | CustomerConsumer |
"Unable to process V2 transaction on customer creation" | ERROR | CustomerConsumer |
Search query example (Splunk):
index=wallet_payment "PENDING_FOR_CUSTOMER_CREATION" OR "customer creation"
| eval flow=if(searchmatch("Resuming V2"), "resumed", if(searchmatch("FAILED"), "failed", "parked"))
| stats count by flow, merchantTransactionId
Common Failure Scenarios
| Scenario | Observed State | Recovery |
|---|---|---|
| Customer service unavailable (network timeout) | handleError() branch 2: errorGroup=INTERNAL_ERROR, httpStatus=504 GATEWAY_TIMEOUT; transaction transitions to FAILED via handlePostProcessingCommandFailure(); not publishable (504 ∉ PUBLISHABLE_FAILED_HTTP_STATUSES); INTERNAL_ERROR ∉ BUSINESS_RULE_VIOLATION_ERROR_GROUPS; GET returns 200 (FAILED) | Caller retries POST /v2/payments |
| Customer service returns 422 (e.g. invalid metadata key) | Post-processing wraps as UNPROCESSABLE_ENTITY/422; transaction → FAILED; event published | Caller polls GET /v2/payments — receives 422 with customer-service error detail |
| Customer service returns 4xx that is not 404 on USER_INITIATED | Same as above — preserved as 422 via PaymentException branch | Caller polls GET, discovers failure |
| Customer creation event never arrives | Transaction stays in PENDING_FOR_CUSTOMER_CREATION indefinitely | Operational alert on stale PENDING_FOR_CUSTOMER_CREATION rows; manual requeue or support escalation |
| Customer event arrives but attempt creation fails | Transaction transitions to FAILED, event published | Caller investigates via PAYMENT_UPDATED webhook |
| Duplicate customer event delivered (at-least-once delivery) | TransactionAttemptOrchestrator idempotency check returns empty Flux | No duplicate payments created |
Missing customer object (MERCHANT_INITIATED) | TransactionCommandException with no ErrorResponse → wrapped as UNPROCESSABLE_ENTITY/422; transaction → FAILED | Caller polls GET, discovers validation error |
Null customerId (USER_INITIATED) | Same as above — UNPROCESSABLE_ENTITY/422 via polling | Caller polls GET, discovers validation error |
CC-12883 — Root Cause & Resolution
Ticket: CC-12883
Symptom: POST /v2/payments returned 422 Unprocessable Entity synchronously when the customer object contained an invalid metadata key (e.g. a key not recognised by the customer service).
Expected: 202 Accepted always; failure surfaced via GET /v2/payments polling.
Root Causes
Three defects combined to produce the bug:
| # | Defect | Impact |
|---|---|---|
| 1 | isPreProcessingCommand() returned true | Customer check ran synchronously — any error became a synchronous HTTP response |
| 2 | No PaymentException branch in handleError() | Customer-service errors (delivered as PaymentException(ErrorResponse)) fell into the generic fallback, producing TransactionCommandException with no ErrorResponse → shouldPublishFailedEvent() returned false → failure invisible via polling |
| 3 | PaymentException(ErrorResponse) constructor sets no string message | throwable.getMessage() returns null → "Customer validation failed: null" in error messages without Optional guard |
Fixes Applied
| File | Change |
|---|---|
CustomerCheckCommand.isPreProcessingCommand() | true → false |
CustomerCheckCommand.handleError() | Added PaymentException branch (branch 3) preserving downstream ErrorResponse with null-safe message extraction |
CustomerCheckCommand.handleError() | Added TransactionCommandException (null ErrorResponse) branch (branch 4) wrapping pre-validation failures as UNPROCESSABLE_ENTITY/422 |
End-to-End After Fix
POST /v2/payments (invalid metadata)
→ 202 Accepted (transaction saved as INITIATED)
↓ async on boundedElastic
CustomerCheckCommand.execute()
→ customer service returns 422
→ handleError() → PaymentException branch → TransactionCommandException(errorResponse={422, UNPROCESSABLE_ENTITY})
→ handlePostProcessingCommandFailure()
→ transaction.status = FAILED, error = {httpStatus=422, group=UNPROCESSABLE_ENTITY}
→ publishFailedTransactionEvent() [422 ∈ PUBLISHABLE_FAILED_HTTP_STATUSES]
GET /v2/payments → FAILED + isBusinessRuleViolation() → 422 with detail