Skip to main content
Version: v2

Error Strategy

Overview

The v2 payment service classifies errors into distinct categories, each with specific HTTP status codes, response structures, and handling behavior. Understanding this taxonomy is essential for implementing new endpoints and debugging production issues.


Error Classification

Category Details

CategoryHTTP StatusError TitleExamples
Authentication401UNAUTHORIZEDMissing or expired OAuth token
Authorization403FORBIDDENValid token but X-Merchant-Id not linked to clientId
Schema Validation400INVALID_REQUESTMalformed JSON, missing required fields, type mismatches, constraint violations
Business Rule422UNPROCESSABLE_ENTITYInvalid state transition, retry limit exceeded, customer not found
Vendor Error422PAYMENT_ERRORCard declined, insufficient funds, Stripe processing error
Resource Not Found404NOT_FOUNDpaymentId does not exist or belongs to another merchant
Infrastructure500INTERNAL_ERRORDatabase failure, unhandled exception, Stripe timeout

Error Response Structures

Simple Error (400, 401, 403, 404, 500)

{
"title": "INVALID_REQUEST",
"status": 400,
"detail": "Invalid request format. Please check JSON structure and field types"
}

Payment Error with Data (422)

When a payment is created but fails during processing, the error includes the full payment state with per-allocation error details:

{
"title": "PAYMENT_ERROR",
"status": 422,
"detail": "Payment processing failed for all records. Check individual records for error details",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"merchantTransactionId": "order-20260404-001",
"amount": 15000,
"status": "FAILED",
"paymentAllocations": [
{
"id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"amount": 15000,
"status": "FAILED",
"error": {
"code": "card_declined",
"message": "Your card was declined."
}
}
]
}
}

Business Rule Violation (422)

{
"title": "UNPROCESSABLE_ENTITY",
"status": 422,
"detail": "Payment cannot be cancelled in COMPLETED state"
}

Exception Hierarchy & When to Use Each

RuntimeException
├── BadRequestException → 400 — structural input error not caught by Bean Validation
├── PaymentNotFoundException → 404 — resource not found or merchant mismatch
├── TransactionCommandException → 400 — pre-auth command pipeline failure (CustomerCheck, MerchantCheck)
├── PaymentMethodCheckException → 422 — payment method invalid or not owned by customer
├── PaymentFailedException → 422 — business violation or processor decline (carries PaymentResponse)
├── RefundFailedException → 400/404/422 — refund processing failure (carries RefundResponse)
└── PaymentRollbackException → 500 — rollback of split-tender payment failed; needs manual intervention

PaymentFailedException — Usage Patterns

// Processor decline — include partial payment state for the 422 response body
throw new PaymentFailedException("Payment processing failed", paymentResponse);

// Business rule violation before allocations are created — no payment state
throw PaymentFailedException.forBusinessViolation(
"paymentAllocations[:].paymentMethodId must be unique");

// Explicit HTTP status override
throw new PaymentFailedException(
"Card not supported for this merchant", null, HttpStatus.UNPROCESSABLE_ENTITY);

TransactionCommandException — Usage in Command Pipeline

// In a command implementation
if (customer == null || !customer.isActive()) {
throw new TransactionCommandException("CustomerCheckCommand",
"Customer not found or inactive for customerId: "
+ LogSanitizationUtil.sanitizeForLog(customerId));
}

// ExceptionHandlersV2 maps it to 400
@ExceptionHandler(TransactionCommandException.class)
public ResponseEntity<Problem> transactionCommandExceptionHandler(TransactionCommandException ex) {
log.error("Transaction command failed - Command: {}, Error: {}",
ex.getCommandName(), ex.getErrorMessage(), ex);
String msg = switch (ex.getCommandName()) {
case "CustomerCheckCommand" -> "CustomerCheckCommand validation failed.";
case "MerchantCheckCommand" -> "MerchantCheckCommand validation failed.";
default -> "Please review API specs.";
};
return ResponseEntity.badRequest().body(Problem.buildBadRequest(msg));
}

Error Propagation Chain

Propagation Rules

LayerResponsibility
Stripe AdapterCatch Stripe exceptions → map to internal error model (code + message)
Service LayerPersist error on allocation → update payment state → trigger webhook
ControllerMap service result to HTTP status → build error response body
Global Exception HandlerCatch unhandled exceptions → return 500 with generic message
Never Expose Internal Details

Error responses must never include stack traces, internal class names, database details, or Stripe API keys. The detail field should contain a human-readable, merchant-safe message.


Error Handling Checklist (Code Review)

When reviewing payment code, verify:

  • All Stripe calls are wrapped in try/catch with proper error mapping
  • Allocation errors are persisted to the database before responding
  • Split-tender rollback is triggered when any allocation fails
  • Error codes are from the approved taxonomy (no ad-hoc error strings)
  • Sensitive data is excluded from error messages and logs
  • HTTP status codes match the error category (see table above)
  • Webhook event is emitted for terminal error states (PAYMENT_FAILED)
  • Idempotency is preserved — retries with same merchantTransactionId return consistent results

Async Error Surfacing — The V2 Contract

POST /v2/payments always returns 202 Accepted. Failures that occur during asynchronous post-processing are surfaced through GET /v2/payments polling, not as synchronous HTTP errors.

Pre-processing vs Post-processing Commands

Commands in the V2 pipeline are tagged with isPreProcessingCommand():

isPreProcessingCommand()RunsOn failure
trueBefore 202 is sentSynchronous HTTP error (400/422/504)
falseAfter 202 is sentTransaction → FAILED, error persisted; discoverable via GET polling

Most commands are post-processing. Only commands that must resolve before the caller receives a response (e.g. idempotency checks) should be pre-processing.

Never make customer/payment-method checks pre-processing

CC-12883 was caused by CustomerCheckCommand.isPreProcessingCommand() incorrectly returning true. This made customer-service 422s (e.g. invalid metadata keys) surface as synchronous HTTP 422 responses, violating the POST = 202 contract.

How Async Failures Become Visible to Callers

When a post-processing command fails, 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) — preserves the ErrorResponse from the exception if present, falls back to INTERNAL_ERROR/500 via buildGenericErrorResponse()
  3. Calls TransactionUtil.normalizeBusinessRuleViolationError() — normalizes any PAYMENT_METHOD_ERROR or INVALID_REQUEST errorGroup to UNPROCESSABLE_ENTITY/422 before publishability is checked
  4. Saves and calls publishFailedTransactionEvent() only if shouldPublishFailedEvent() returns true
// Only 404 and 422 trigger event publication (in TransactionService)
private static final Set<HttpStatus> PUBLISHABLE_FAILED_HTTP_STATUSES =
Set.of(HttpStatus.NOT_FOUND, HttpStatus.UNPROCESSABLE_ENTITY);

private boolean shouldPublishFailedEvent(Transaction transaction) {
return transaction.getError() != null
&& transaction.getError().getHttpStatus() != null
&& PUBLISHABLE_FAILED_HTTP_STATUSES.contains(transaction.getError().getHttpStatus());
}
// normalizeBusinessRuleViolationError() in TransactionUtil — called before shouldPublishFailedEvent()
// Normalizes PAYMENT_METHOD_ERROR or INVALID_REQUEST → UNPROCESSABLE_ENTITY/422
// so those errors are always publishable and always surface as 422 via GET polling
public static void normalizeBusinessRuleViolationError(Transaction transaction) {
if (isBusinessRuleViolation(transaction)) {
transaction.getError().setErrorGroup(ErrorGroup.UNPROCESSABLE_ENTITY);
transaction.getError().setHttpStatus(HttpStatus.UNPROCESSABLE_ENTITY);
}
}

Consequence: If a post-processing command throws a TransactionCommandException with no ErrorResponse, buildGenericErrorResponse() sets INTERNAL_ERROR/500. Since 500 ∉ PUBLISHABLE_FAILED_HTTP_STATUSES and INTERNAL_ERRORBUSINESS_RULE_VIOLATION_ERROR_GROUPS, no event is published and GET /v2/payments shows status=FAILED with no error detail. Always ensure the ErrorResponse is populated in handleError().

How GET /v2/payments Surfaces the Error

When the caller polls GET /v2/payments/{id} and the transaction is FAILED:

isBusinessRuleViolation() (TransactionUtil) checks transaction.error.errorGroup ∈ BUSINESS_RULE_VIOLATION_ERROR_GROUPS
→ BUSINESS_RULE_VIOLATION_ERROR_GROUPS = {PAYMENT_METHOD_ERROR, INVALID_REQUEST, UNPROCESSABLE_ENTITY}
→ If true: return 422 Unprocessable Entity with error detail
→ If false: return 200 OK with FAILED status (no 422 from GET)

This means the errorGroup persisted on the transaction (after normalizeBusinessRuleViolationError() runs) directly determines whether the GET endpoint returns a 422 or just a 200 with status=FAILED.

Async Error Classification Summary

Error originerrorGroup persistedhttpStatus persistedPublishable?GET returns
Customer service network timeoutINTERNAL_ERROR504 GATEWAY_TIMEOUTNo (504 ∉ set)200 OK (status=FAILED)
Customer service 422/4xxPreserved (e.g. UNPROCESSABLE_ENTITY)422Yes422 with detail
Pre-validation failure (null fields)UNPROCESSABLE_ENTITY422Yes422 with detail
Unhandled exception (generic fallback)INTERNAL_ERROR500No200 OK (status=FAILED, no detail)

Adding a New Error Code

  1. Add the error definition to docs/03-developers/5-convenient-checkout-api/4-error-codes/payment-api.json
  2. Include: id, title, httpStatus, message, scenario, resolution, scopeWithEndPoint, sampleResponse
  3. Map the new error in the service layer's error mapping logic
  4. Write a unit test that triggers the error and verifies the response structure
  5. Run yarn lint:docs to validate the docs site builds correctly