Skip to main content
Version: v2

V1 vs V2 — What Changed and Why

Purpose

This document is the definitive reference for any engineer transitioning from V1 to V2 — whether implementing a new V2 feature, maintaining a V1 service that must interoperate with V2, or reviewing code. It explains what changed, why it changed, and what the practical consequences are for day-to-day development.

Start here if you are new to V2

Read this document before any domain-specific guide. Understanding the delta between V1 and V2 is the fastest way to become productive.


Summary: Core Philosophy Changes

AspectV1V2
Payment modelSingle flat Payment entity per transactionPaymentPaymentAllocation hierarchy (one payment, N allocations)
Split-tenderNot supportedUp to 2 allocations per payment, each processed independently
API versioningNo version prefix (e.g. /payments)Explicit /v2/ prefix on every endpoint
Response on creation200 OK with final state202 Accepted — async; state polled or received via webhook
Error contractErrorResponse DTO (ad-hoc per controller)Problem DTO (RFC 7807-aligned) with ErrorGroup taxonomy
IdempotencyPartial / inconsistentExplicit: merchantTransactionId as composite key, 5-attempt limit
Validation layersSingle-layer (Bean Validation)Multi-layer: Bean Validation → structural validator → business rule validator → command pipeline
Event modelV1 DTOs in event/ packageV2 DTOs in events/v2/ package of wallet-event-commons
Vendor integrationEvent Grid used; adapter logic co-located within the payment servicewallet-stripe-adapter-service is a fully separate microservice; all vendor events flow exclusively through Event Grid
Refund typesLinked (against a payment) and Unlinked (against a payment method) — both supportedLinked and Unlinked both supported; V2 adds refundAllocations array for per-allocation partial and split-tender refund control
State machineSimple: created → completed / failedRich: 15+ states with explicit transient and terminal classifications
Shared librarywallet-event-commons with mixed v1/v2 packageswallet-event-commons v2 packages only (v1 in active deprecation)
ObservabilityUnstructured / partial loggingStructured JSON logging with standard fields; distributed tracing

V1 vs V2 — API Endpoint Changes

Path Prefix

All V2 endpoints carry an explicit /v2/ prefix. V1 and V2 routes coexist in the same service.

OperationV1 PathV2 Path
Create paymentPOST /paymentsPOST /v2/payments
Get paymentGET /payments/{id}GET /v2/payments/{id}
Cancel paymentPATCH /payments/{id}/cancelPATCH /v2/payments/{paymentId}/cancel
Capture paymentPATCH /payments/{id}/capturePATCH /v2/payments/{paymentId}/capture
Create refundPOST /refundsPOST /v2/refunds
Get refundGET /refunds/{id}GET /v2/refunds/{id}
Token paymentPOST /token/paymentsPOST /v2/token/payments
Coexistence

V1 endpoints remain active. Do not remove or modify them — they serve live merchant traffic. New features must be built in V2 only.


V1 vs V2 — Request Payload Changes

Create Payment: Key Field Differences

FieldV1V2Notes
paymentAllocationsNot present — single PM per requestRequired array (1–2 items)Core structural change
paymentMethodIdTop-level fieldMoved inside paymentAllocations[n]Each allocation has its own method
amountTop-levelStill top-level but must equal sum of allocation amountsCross-field validation enforced
customer.idUUID referenceReplaced by customer.hsid / customer.enterpriseIdentifier / customer.metadataRicher identity resolution
authorizeCardTop-level booleanTop-level boolean (unchanged semantics)
partialAuthorizationTop-level booleanTop-level boolean (unchanged semantics)
consentNot presentOptional object (required for ACH)Adds consent collection tracking
agentNot presentOptional object (for telephonic flows)Captures agent-assisted payments
statementDescriptorSuffixNot presentOptional string (max 10 chars)New in V2

V1 Payment Request (simplified)

{
"merchantTransactionId": "order-123",
"amount": 15000,
"currencyCode": "USD",
"authorizeCard": false,
"customer": {
"id": "550e8400-e29b-41d4-a716-446655440000"
},
"paymentMethodId": "b0b3c48d-4cf6-404a-a554-e14640a51c5b"
}

V2 Payment Request (simplified)

{
"merchantTransactionId": "order-123",
"amount": 15000,
"currencyCode": "USD",
"authorizeCard": false,
"customer": {
"hsid": "b313c1d1-b5b6-4ec7-8b5a-3a9cf7755060"
},
"paymentAllocations": [
{
"amount": 15000,
"paymentMethodId": "b0b3c48d-4cf6-404a-a554-e14640a51c5b"
}
]
}

Split-Tender (V2 Only)

{
"merchantTransactionId": "split-order-456",
"amount": 20000,
"paymentAllocations": [
{ "amount": 12000, "paymentMethodId": "card-uuid-1" },
{ "amount": 8000, "paymentMethodId": "card-uuid-2" }
]
}

V1 vs V2 — Response Shape Changes

HTTP Status on Creation

V1V2Why
200 OK202 AcceptedV2 processing is asynchronous. The service returns immediately after accepting the request; Stripe processing happens after the response is sent via Event Grid.

Response Envelope

V1 returned the resource directly. V2 wraps it in a Resource<T> envelope:

// V1 — bare resource
{
"id": "550e8400-...",
"status": "COMPLETED",
...
}

// V2 — envelope with self-link
{
"url": "https://.../v2/payments/550e8400-...",
"data": {
"id": "550e8400-...",
"status": "PENDING",
...
}
}

paymentAllocations in Response

V2 responses always include allocation-level detail with per-allocation status, paymentMethod, vendor, and error objects. V1 had no allocation concept.


V1 vs V2 — Error Response Changes

V1 Error Shape

{
"code": "PAYMENT_ERROR",
"message": "Payment failed",
"declineCode": "insufficient_funds"
}

V2 Error Shape (simple — 400/404)

{
"title": "INVALID_REQUEST",
"status": 400,
"detail": "paymentAllocations[0].amount is required"
}

V2 Error Shape (422 with payment state)

V2 uniquely returns the partial payment state in the error body for processing failures, enabling clients to inspect per-allocation outcomes without a separate GET:

{
"title": "PAYMENT_ERROR",
"status": 422,
"detail": "Payment processing failed for all records.",
"data": {
"id": "550e8400-...",
"status": "FAILED",
"paymentAllocations": [
{
"id": "a1b2c3d4-...",
"status": "FAILED",
"error": {
"code": "card_declined",
"message": "Your card was declined."
}
}
]
}
}

V1 vs V2 — Validation Changes

V1 Validation

  • Single layer: Bean Validation (@NotNull, @Size, etc.) at the controller level
  • Business rule checks inlined inside service methods (if/else blocks)
  • No standard error message format — messages varied per endpoint

V2 Validation: 5 Layers

Each layer has a distinct responsibility and produces a deterministic HTTP response:

Layer 1  Bean Validation         → 400  @NotNull, @Size, @Range, @Pattern on DTO fields
Layer 2 Structural Validator → 400 @PaymentRequestConstraint — cross-field rules
(allocation uniqueness, amount sum, customer identity)
Layer 3 Business Rule Validator → 422 @PaymentBusinessRuleConstraint
(IIAS amounts, authorization flag combinations)
Layer 4 Command Pipeline → 400 CustomerCheckCommand, MerchantCheckCommand,
PaymentMethodsEnablementCheckCommand
Layer 5 Domain Service → 422 Idempotency guard, state transition guards

Key implication for engineers: In V1, a validation failure anywhere produced an inconsistent error shape. In V2, the layer that catches the failure determines both the HTTP status code and the error DTO used. Engineers must route validation logic to the correct layer.


V1 vs V2 — Refund Changes

AspectV1V2
Linked refund✅ Supported — refund against a specific paymentId✅ Supported (unchanged semantics)
Unlinked refund✅ Supported — refund against a paymentMethodId directly✅ Supported (unchanged semantics)
Amount fieldSingle top-level amount (full or partial)Moved into refundAllocations[n].amount for per-allocation control
refundAllocationsNot present — single flat amountNew in V2 — enables partial and split-tender refunds with per-allocation control
Split-tender refundNot applicable (no split-tender payments in V1)Supported — up to 2 refundAllocations for linked split-tender payments
Unlinked refund allocationsSingle paymentMethodId + amount flatrefundAllocations[0].paymentMethodId + amount; max 1 allocation
Refund reasonOptional enum: REQUESTED_BY_CUSTOMER, DUPLICATE, FRAUDULENTSame enum values, same optionality
merchantTransactionIdTop-level, requiredTop-level, required (unchanged)
agentSupportedSupported (unchanged)
metadataSupportedSupported (unchanged)

Key V2 Difference: refundAllocations vs Flat Amount

V1 refund is a flat structure:

{
"paymentId": "uuid-of-payment",
"merchantTransactionId": "refund-001",
"amount": 5000,
"reason": "REQUESTED_BY_CUSTOMER"
}

V2 distributes the refund amount across allocations — required for split-tender payments, optional for single-allocation payments:

{
"paymentId": "uuid-of-payment",
"merchantTransactionId": "refund-001",
"reason": "REQUESTED_BY_CUSTOMER",
"refundAllocations": [
{
"paymentAllocationId": "uuid-of-allocation",
"amount": 3000
}
]
}

For unlinked refunds, V1 used a top-level paymentMethodId. V2 moves this into the allocations array:

{
"merchantTransactionId": "refund-unlinked-001",
"reason": "DUPLICATE",
"customer": { "hsid": "user-hsid-abc123" },
"refundAllocations": [
{
"paymentMethodId": "uuid-of-payment-method",
"amount": 5000
}
]
}

V1 vs V2 — State Machine Changes

V1 had a flat status model. V2 adds 15+ states to precisely represent asynchronous processing:

V1 StatesV2 States (additions)
CREATEDINITIATEDPENDINGPROCESSINGPROCESSING_DEDUP_CHECK
AUTHORIZEDAUTH_REQUIREDCONFIRMATION_INITIALIZEDAUTHORIZED
CAPTUREDCAPTURE_INITIALIZEDCOMPLETED
FAILEDFAILED (unchanged terminal)
CANCELLEDCANCEL_INITIALIZEDCANCELLED / CANCEL_FAILED
(not present)ACCEPTED (ACH bank account accepted, settlement pending)
(not present)ROLLED_BACK (split-tender allocation reversed after sibling failure)
(not present)PENDING_FOR_CUSTOMER_CREATION
(not present)PENDING_FOR_PAYMENT_METHOD_CREATION

Key implication: V2 clients must treat non-terminal states as "in progress" and use webhooks or polling. The V1 pattern of treating the HTTP response status as the final answer does not apply in V2.


V1 vs V2 — Event / Webhook Changes

Webhook Event Catalog

EventV1V2
PAYMENT_SUCCEEDED✅ (unchanged name, richer payload)
PAYMENT_FAILED✅ (includes per-allocation error details)
PAYMENT_AUTHORIZED✅ New
PAYMENT_ACCEPTED✅ New (ACH)
PAYMENT_CANCELLED
REFUND_SUCCEEDED
REFUND_FAILED

Payload Shape

V2 webhook payloads always include paymentAllocations with per-allocation state. V1 payloads had a single flat paymentMethod block.


V1 vs V2 — Shared Library (wallet-event-commons)

ConcernV1V2
DTO locationcom.optum.wallet.common.event.*com.optum.wallet.common.events.v2.*
Error DTOErrorResponseProblem (RFC 7807)
EnumsSome duplicated across packagesCanonical in com.optum.wallet.common.enums.*
Refund error DTOCustom per-serviceRefundApiErrorResponse (shared)
WebhooksNo shared webhook DTOShared MerchantWebhookEvent envelope

Rule for V2 engineers: Always import from com.optum.wallet.common.events.v2.* and com.optum.wallet.common.enums.*. Never use com.optum.wallet.common.event.* (v1) in new code.


V1 vs V2 — Coding Patterns

Customer Identity Resolution

// V1 — customer referenced by internal UUID
{
"customer": { "id": "550e8400-..." }
}

// V2 — customer resolved by external identifier (priority order)
// 1. enterpriseIdentifier (highest priority)
// 2. hsid
// 3. metadata key-value
// At least one must be present.
{
"customer": { "hsid": "b313c1d1-..." }
}

Controller Base Path

// V1
@RequestMapping("/payments")
public class PaymentController { ... }

// V2 — explicit version in base path
@RequestMapping("/v2/payments")
public class PaymentControllerV2 { ... }

Exception Handling

// V1 — ad-hoc per controller
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handle(Exception ex) {
return ResponseEntity.status(500).body(new ErrorResponse(...));
}

// V2 — centralized advisor scoped to V2 controllers
@RestControllerAdvice(assignableTypes = {PaymentControllerV2.class, RefundControllerV2.class})
public class ExceptionHandlersV2 {

@ExceptionHandler(PaymentFailedException.class)
public ResponseEntity<PaymentApiErrorResponse> handlePaymentFailed(PaymentFailedException ex) {
// Returns 422 with partial payment state in body
}

@ExceptionHandler(TransactionCommandException.class)
public ResponseEntity<Problem> handleCommandFailure(TransactionCommandException ex) {
// Returns 400 with Problem DTO
}
}

Migration Checklist for Engineers

Use this checklist when implementing a new V2 endpoint or migrating a V1 endpoint:

API Contract

  • Route is under /v2/ prefix
  • POST creation endpoints return 202 Accepted, not 200 OK
  • Response is wrapped in Resource<T> envelope with url field
  • All error responses use Problem DTO (or PaymentApiErrorResponse / RefundApiErrorResponse for domain-specific 422s)

Validation

  • Bean Validation on all DTO fields with explicit message strings
  • Cross-field rules extracted into a @ConstraintValidator (not inline if/else in service)
  • Business rules that need a 422 (not 400) use PaymentFailedException.forBusinessViolation()
  • Command pipeline used for checks requiring external calls (customer, merchant)

Shared Library

  • All enums imported from com.optum.wallet.common.enums.*
  • All event DTOs imported from com.optum.wallet.common.events.v2.*
  • No imports from com.optum.wallet.common.event.* (v1)
  • Problem or domain-specific error DTO used — not ErrorResponse

Asynchronous Semantics

  • Service method returns Mono<T> — no .block() calls
  • Response returned before async processing completes (202 pattern)
  • Webhook event emitted on all terminal state transitions

Idempotency

  • merchantTransactionId + merchantId used as composite dedup key
  • Retry count checked and incremented atomically
  • Terminal payments (COMPLETED, FAILED, CANCELLED) returned as-is on retry

Security & Observability

  • All user-supplied strings passed through LogSanitizationUtil.sanitizeForLog()
  • Sensitive fields (PAN, CVV, account numbers) never logged
  • merchantId, requestId, and resource ID present in structured log fields
  • Merchant ownership verified before returning any resource (404 on mismatch)

Testing

  • Unit tests cover all validation layers including error paths
  • @WebFluxTest controller test for every HTTP status the endpoint can return
  • Integration test for idempotency (duplicate merchantTransactionId returns existing record)