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

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