Skip to main content
Version: v2

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:

  1. USER_INITIATED_PAYMENT — customer not found: The transaction is parked in PENDING_FOR_CUSTOMER_CREATION and resumes when the customer service provisions the customer asynchronously.
  2. Any flow — customer service rejects the request (e.g. invalid metadata, bad identifier): The transaction transitions to FAILED with the customer-service error preserved, and a PAYMENT_UPDATED event is published. The caller discovers this via GET /v2/payments polling.
CC-12883 — Bug Fix (2026-05-06)

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

PaymentFlowTypeCustomer not foundCustomer service rejects request (4xx/5xx)Customer found
USER_INITIATED_PAYMENTPark → PENDING_FOR_CUSTOMER_CREATIONFAILED (async, event published)Enrich transaction and continue
MERCHANT_INITIATED_PAYMENTSynchronous find-or-create via CustomerClient.createCustomer(). If create also fails → FAILED (async)FAILED (async, event published)Enrich and continue
GUEST_PAYMENTCustomer lookup skipped entirelyN/AN/A

Pre-validation failures (missing required fields already in the transaction) are also FAILED with UNPROCESSABLE_ENTITY:

ScenarioPaymentFlowTypeMissing fieldResult
customerId nullUSER_INITIATED_PAYMENTtransaction.customerIdFAILED, UNPROCESSABLE_ENTITY 422 via polling
customer object nullMERCHANT_INITIATED_PAYMENTtxn_details.customerFAILED, 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.

Design rationale

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:

  1. Fetches the transaction from DB (falls back to the in-memory object if not found)
  2. Sets status=FAILED, error=TransactionErrorMapper.toErrorResponse(exception) — which preserves the ErrorResponse from the exception if present, or builds a generic INTERNAL_ERROR/500 fallback
  3. Calls TransactionUtil.normalizeBusinessRuleViolationError() — normalizes any PAYMENT_METHOD_ERROR or INVALID_REQUEST errorGroup to UNPROCESSABLE_ENTITY/422 before checking publishability
  4. Saves and calls publishFailedTransactionEvent()shouldPublishFailedEvent()

Error classification after handleError():

BranchErrorResponse.errorGroupErrorResponse.httpStatusPUBLISHABLE_FAILED_HTTP_STATUSES?GET returns
CustomerNotFoundException + USER_INITIATED— (parked, not failed)N/AVia PENDING_FOR_CUSTOMER_CREATION state
WebClientRequestExceptionINTERNAL_ERROR504 GATEWAY_TIMEOUTNo (504 ∉ set)200 OK (status=FAILED, no 422)
PaymentException (e.g. 422 from customer service)Preserved (e.g. UNPROCESSABLE_ENTITY)422Yes422 with detail
TransactionCommandException (null ErrorResponse) wrapped to 422UNPROCESSABLE_ENTITY422Yes422 with detail
Generic fallbackINTERNAL_ERROR (via buildGenericErrorResponse())500No200 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

TriggerFromToAction
CustomerCheckCommandCustomerNotFoundException on USER_INITIATED_PAYMENTINITIATEDPENDING_FOR_CUSTOMER_CREATIONPersist transaction, publish event, set context flag
CustomerConsumer — Customer CREATED event receivedPENDING_FOR_CUSTOMER_CREATIONPROCESSINGEnrich customer data, trigger attempt creation
CustomerConsumer — Customer FAILED event receivedPENDING_FOR_CUSTOMER_CREATIONFAILEDSet error message, publish failed event
CustomerConsumer — Attempt creation throwsPROCESSINGFAILEDfailOnCustomerCreationFailure() + publish failed event

Event Publishing

Two events are published during this lifecycle:

EventWhenStatus in Payload
PAYMENT_UPDATEDTransaction parked in PENDING_FOR_CUSTOMER_CREATIONPENDING_FOR_CUSTOMER_CREATION
PAYMENT_UPDATEDTransaction resumes to PROCESSING (via CustomerConsumer)PROCESSING → subsequent status changes follow normal flow
PAYMENT_UPDATEDCustomer creation failedFAILED

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)

TestWhat it verifies
isPreProcessingCommand_shouldReturnFalseCommand is post-processing — runs after 202 is returned
execute_guestPaymentFlow_skipsCustomerLookupAndReturnsContextGuest flows short-circuit before any customer call
execute_userInitiated_customerNotFound_transitionsToPendingForCustomerCreationPENDING_FOR_CUSTOMER_CREATION status set, event published, context flags set
execute_userInitiated_customerNotFound_preservesExistingContextEntriesCUSTOMER_EXISTS_KEY=false, CUSTOMER_PENDING_CREATION_KEY=true in context
execute_ShouldFindOrCreateCustomer_WhenMerchantInitiatedPaymentAndCustomerNotFoundMERCHANT_INITIATED_PAYMENT triggers find-or-create
execute_merchantInitiated_missingCustomerObject_wrapsAs422UnprocessableEntityNull customer object → TransactionCommandException with UNPROCESSABLE_ENTITY/422
execute_userInitiated_nullCustomerId_wrapsAs422UnprocessableEntityNull customerId → TransactionCommandException with UNPROCESSABLE_ENTITY/422
execute_merchantInitiated_customerCreationFailsWithUnprocessableEntity_throwsTransactionCommandExceptionWith422Customer service 422 → TransactionCommandException with preserved 422 ErrorResponse
execute_merchantInitiated_customerCreationFailsWithPaymentException_preservesHttpStatusFromDownstreamPaymentException → ErrorResponse httpStatus preserved (not lost)
execute_merchantInitiated_searchFailsWithPaymentException_preservesHttpStatusSearch 422 → preserved through handleError()
execute_merchantInitiated_paymentException_errorMessageUsesErrorResponseMessage_notNullthrowable.getMessage()=null → uses errorResponse.getMessage() as fallback
execute_userInitiated_paymentExceptionOnSearch_preservesHttpStatusAndMessageUSER_INITIATED non-404 failure from search → FAILED with correct status
execute_merchantInitiated_createCustomerFailsWithWebClientRequestException_returnsGatewayTimeoutNetwork timeout → TransactionCommandException with errorGroup=INTERNAL_ERROR, httpStatus=504 GATEWAY_TIMEOUT
execute_merchantInitiated_searchFailsWithWebClientRequestException_returnsGatewayTimeoutNetwork timeout on search → same: INTERNAL_ERROR errorGroup, 504 httpStatus
execute_merchantInitiated_createCustomerFailsWithGenericException_wrapsAsTransactionCommandExceptionUnknown error → generic fallback
execute_paymentExceptionWithNullErrorResponse_fallsToGenericHandlerPaymentException with null errorResponse → falls through to generic fallback
execute_userInitiated_customerNotFound_saveFailure_propagatesDbErrorDB save fails during park → error propagated upstream

CustomerConsumer Tests

TestWhat it verifies
handleV2Transactions_customerCreated_resumesTransactionresumeOnCustomerFound() called, PROCESSING state saved, attempt creation triggered
handleV2Transactions_customerFailed_transitionsToFailedfailOnCustomerCreationFailure() called, FAILED event published
handleV2Transactions_attemptCreationFails_transitionToFailedAndPublishesEventAttempt creation error → transaction transitions to FAILED, event published

TransactionService Tests

TestWhat it verifies
testProcessTransaction_PendingForCustomerCreationStatusOrchestrator is never invoked when CUSTOMER_PENDING_CREATION_KEY=true
processTransaction_cc12883_customerCheckPostProcessing_invalidMetadata_returns202AndEventuallyFailsCC-12883 regression test — invalid metadata POST returns 202; async post-processing transitions transaction to FAILED with 422

Transaction Domain Tests

TestWhat it verifies
resumeOnCustomerFound_customerCreated_transitionsToProcessingcustomerId, vendorMerchantId enriched, status = PROCESSING
resumeOnCustomerFound_customerFailed_transitionsToFailedDelegates to failOnCustomerCreationFailure()
failOnCustomerCreationFailure_setsFailedStatusStatus = FAILED, error message set

Logging Reference

Key log messages to trace this flow in Splunk / Application Insights:

MessageLevelLocation
"Guest payment — skipping customer lookup for transaction: {merchantTransactionId}"INFOCustomerCheckCommand
"Customer not found for USER_INITIATED_PAYMENT transaction: {} — transitioning to PENDING_FOR_CUSTOMER_CREATION"INFOCustomerCheckCommand
"Failed to publish PENDING_FOR_CUSTOMER_CREATION event for transaction: {} — {}"WARNCustomerCheckCommand
"Resuming V2 transaction {} after customer creation"INFOCustomerConsumer
"Customer creation failed for transaction: {} — transitioning to FAILED"WARNCustomerConsumer
"Re-triggered TransactionAttempt with {} result(s) for transaction: {}"INFOCustomerConsumer
"Attempt creation failed for transaction: {} — transitioning to FAILED"ERRORCustomerConsumer
"Unable to process V2 transaction on customer creation"ERRORCustomerConsumer

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

ScenarioObserved StateRecovery
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_ERRORBUSINESS_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 publishedCaller polls GET /v2/payments — receives 422 with customer-service error detail
Customer service returns 4xx that is not 404 on USER_INITIATEDSame as above — preserved as 422 via PaymentException branchCaller polls GET, discovers failure
Customer creation event never arrivesTransaction stays in PENDING_FOR_CUSTOMER_CREATION indefinitelyOperational alert on stale PENDING_FOR_CUSTOMER_CREATION rows; manual requeue or support escalation
Customer event arrives but attempt creation failsTransaction transitions to FAILED, event publishedCaller investigates via PAYMENT_UPDATED webhook
Duplicate customer event delivered (at-least-once delivery)TransactionAttemptOrchestrator idempotency check returns empty FluxNo duplicate payments created
Missing customer object (MERCHANT_INITIATED)TransactionCommandException with no ErrorResponse → wrapped as UNPROCESSABLE_ENTITY/422; transaction → FAILEDCaller polls GET, discovers validation error
Null customerId (USER_INITIATED)Same as above — UNPROCESSABLE_ENTITY/422 via pollingCaller 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:

#DefectImpact
1isPreProcessingCommand() returned trueCustomer check ran synchronously — any error became a synchronous HTTP response
2No PaymentException branch in handleError()Customer-service errors (delivered as PaymentException(ErrorResponse)) fell into the generic fallback, producing TransactionCommandException with no ErrorResponseshouldPublishFailedEvent() returned false → failure invisible via polling
3PaymentException(ErrorResponse) constructor sets no string messagethrowable.getMessage() returns null → "Customer validation failed: null" in error messages without Optional guard

Fixes Applied

FileChange
CustomerCheckCommand.isPreProcessingCommand()truefalse
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