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 |
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
merchantTransactionIdreturn consistent results
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