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.
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
| Aspect | V1 | V2 |
|---|---|---|
| Payment model | Single flat Payment entity per transaction | Payment → PaymentAllocation hierarchy (one payment, N allocations) |
| Split-tender | Not supported | Up to 2 allocations per payment, each processed independently |
| API versioning | No version prefix (e.g. /payments) | Explicit /v2/ prefix on every endpoint |
| Response on creation | 200 OK with final state | 202 Accepted — async; state polled or received via webhook |
| Error contract | ErrorResponse DTO (ad-hoc per controller) | Problem DTO (RFC 7807-aligned) with ErrorGroup taxonomy |
| Idempotency | Partial / inconsistent | Explicit: merchantTransactionId as composite key, 5-attempt limit |
| Validation layers | Single-layer (Bean Validation) | Multi-layer: Bean Validation → structural validator → business rule validator → command pipeline |
| Event model | V1 DTOs in event/ package | V2 DTOs in events/v2/ package of wallet-event-commons |
| Vendor integration | Event Grid used; adapter logic co-located within the payment service | wallet-stripe-adapter-service is a fully separate microservice; all vendor events flow exclusively through Event Grid |
| Refund types | Linked (against a payment) and Unlinked (against a payment method) — both supported | Linked and Unlinked both supported; V2 adds refundAllocations array for per-allocation partial and split-tender refund control |
| State machine | Simple: created → completed / failed | Rich: 15+ states with explicit transient and terminal classifications |
| Shared library | wallet-event-commons with mixed v1/v2 packages | wallet-event-commons v2 packages only (v1 in active deprecation) |
| Observability | Unstructured / partial logging | Structured 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.
| Operation | V1 Path | V2 Path |
|---|---|---|
| Create payment | POST /payments | POST /v2/payments |
| Get payment | GET /payments/{id} | GET /v2/payments/{id} |
| Cancel payment | PATCH /payments/{id}/cancel | PATCH /v2/payments/{paymentId}/cancel |
| Capture payment | PATCH /payments/{id}/capture | PATCH /v2/payments/{paymentId}/capture |
| Create refund | POST /refunds | POST /v2/refunds |
| Get refund | GET /refunds/{id} | GET /v2/refunds/{id} |
| Token payment | POST /token/payments | POST /v2/token/payments |
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
| Field | V1 | V2 | Notes |
|---|---|---|---|
paymentAllocations | Not present — single PM per request | Required array (1–2 items) | Core structural change |
paymentMethodId | Top-level field | Moved inside paymentAllocations[n] | Each allocation has its own method |
amount | Top-level | Still top-level but must equal sum of allocation amounts | Cross-field validation enforced |
customer.id | UUID reference | Replaced by customer.hsid / customer.enterpriseIdentifier / customer.metadata | Richer identity resolution |
authorizeCard | Top-level boolean | Top-level boolean (unchanged semantics) | — |
partialAuthorization | Top-level boolean | Top-level boolean (unchanged semantics) | — |
consent | Not present | Optional object (required for ACH) | Adds consent collection tracking |
agent | Not present | Optional object (for telephonic flows) | Captures agent-assisted payments |
statementDescriptorSuffix | Not present | Optional 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
| V1 | V2 | Why |
|---|---|---|
200 OK | 202 Accepted | V2 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/elseblocks) - 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
| Aspect | V1 | V2 |
|---|---|---|
| Linked refund | ✅ Supported — refund against a specific paymentId | ✅ Supported (unchanged semantics) |
| Unlinked refund | ✅ Supported — refund against a paymentMethodId directly | ✅ Supported (unchanged semantics) |
| Amount field | Single top-level amount (full or partial) | Moved into refundAllocations[n].amount for per-allocation control |
refundAllocations | Not present — single flat amount | New in V2 — enables partial and split-tender refunds with per-allocation control |
| Split-tender refund | Not applicable (no split-tender payments in V1) | Supported — up to 2 refundAllocations for linked split-tender payments |
| Unlinked refund allocations | Single paymentMethodId + amount flat | refundAllocations[0].paymentMethodId + amount; max 1 allocation |
| Refund reason | Optional enum: REQUESTED_BY_CUSTOMER, DUPLICATE, FRAUDULENT | Same enum values, same optionality |
merchantTransactionId | Top-level, required | Top-level, required (unchanged) |
agent | Supported | Supported (unchanged) |
metadata | Supported | Supported (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 States | V2 States (additions) |
|---|---|
CREATED | INITIATED → PENDING → PROCESSING → PROCESSING_DEDUP_CHECK |
AUTHORIZED | AUTH_REQUIRED → CONFIRMATION_INITIALIZED → AUTHORIZED |
CAPTURED | CAPTURE_INITIALIZED → COMPLETED |
FAILED | FAILED (unchanged terminal) |
CANCELLED | CANCEL_INITIALIZED → CANCELLED / 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
| Event | V1 | V2 |
|---|---|---|
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)
| Concern | V1 | V2 |
|---|---|---|
| DTO location | com.optum.wallet.common.event.* | com.optum.wallet.common.events.v2.* |
| Error DTO | ErrorResponse | Problem (RFC 7807) |
| Enums | Some duplicated across packages | Canonical in com.optum.wallet.common.enums.* |
| Refund error DTO | Custom per-service | RefundApiErrorResponse (shared) |
| Webhooks | No shared webhook DTO | Shared 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 -
POSTcreation endpoints return202 Accepted, not200 OK - Response is wrapped in
Resource<T>envelope withurlfield - All error responses use
ProblemDTO (orPaymentApiErrorResponse/RefundApiErrorResponsefor domain-specific 422s)
Validation
- Bean Validation on all DTO fields with explicit
messagestrings - Cross-field rules extracted into a
@ConstraintValidator(not inlineif/elsein 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) -
Problemor domain-specific error DTO used — notErrorResponse
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+merchantIdused 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
-
@WebFluxTestcontroller test for every HTTP status the endpoint can return - Integration test for idempotency (duplicate
merchantTransactionIdreturns existing record)