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
| Category | HTTP Status | Error Title | Examples |
|---|---|---|---|
| Authentication | 401 | UNAUTHORIZED | Missing or expired OAuth token |
| Authorization | 403 | FORBIDDEN | Valid token but X-Merchant-Id not linked to clientId |
| Schema Validation | 400 | INVALID_REQUEST | Malformed JSON, missing required fields, type mismatches, constraint violations |
| Business Rule | 422 | UNPROCESSABLE_ENTITY | Invalid state transition, retry limit exceeded, customer not found |
| Vendor Error | 422 | PAYMENT_ERROR | Card declined, insufficient funds, Stripe processing error |
| Resource Not Found | 404 | NOT_FOUND | paymentId does not exist or belongs to another merchant |
| Infrastructure | 500 | INTERNAL_ERROR | Database 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
| Layer | Responsibility |
|---|---|
| Stripe Adapter | Catch Stripe exceptions → map to internal error model (code + message) |
| Service Layer | Persist error on allocation → update payment state → trigger webhook |
| Controller | Map service result to HTTP status → build error response body |
| Global Exception Handler | Catch unhandled exceptions → return 500 with generic message |
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
merchantTransactionIdreturn 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() | Runs | On failure |
|---|---|---|
true | Before 202 is sent | Synchronous HTTP error (400/422/504) |
false | After 202 is sent | Transaction → 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.
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:
- Fetches the transaction from DB (falls back to the in-memory object if not found)
- Sets
status=FAILED,error=TransactionErrorMapper.toErrorResponse(exception)— preserves theErrorResponsefrom the exception if present, falls back toINTERNAL_ERROR/500 viabuildGenericErrorResponse() - Calls
TransactionUtil.normalizeBusinessRuleViolationError()— normalizes anyPAYMENT_METHOD_ERRORorINVALID_REQUESTerrorGroup toUNPROCESSABLE_ENTITY/422 before publishability is checked - Saves and calls
publishFailedTransactionEvent()only ifshouldPublishFailedEvent()returnstrue
// 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_ERROR ∉ BUSINESS_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 origin | errorGroup persisted | httpStatus persisted | Publishable? | GET returns |
|---|---|---|---|---|
| Customer service network timeout | INTERNAL_ERROR | 504 GATEWAY_TIMEOUT | No (504 ∉ set) | 200 OK (status=FAILED) |
| Customer service 422/4xx | Preserved (e.g. UNPROCESSABLE_ENTITY) | 422 | Yes | 422 with detail |
| Pre-validation failure (null fields) | UNPROCESSABLE_ENTITY | 422 | Yes | 422 with detail |
| Unhandled exception (generic fallback) | INTERNAL_ERROR | 500 | No | 200 OK (status=FAILED, no detail) |
Adding a New Error Code
- Add the error definition to
docs/03-developers/5-convenient-checkout-api/4-error-codes/payment-api.json - Include:
id,title,httpStatus,message,scenario,resolution,scopeWithEndPoint,sampleResponse - Map the new error in the service layer's error mapping logic
- Write a unit test that triggers the error and verifies the response structure
- Run
yarn lint:docsto validate the docs site builds correctly