← Files paytechARCHIVED FILE
skills/psp-payments/references/code-examples-java.md
36 KB · Oct 4, 2026 · 12:34 UTC
# Java / Spring Boot — PSP integration code
Rules and the failures they prevent: `references/integration-patterns.md`. When the project outgrows
the baseline (several instances, real concurrency on one order, crash-during-POST, sweep jobs, and
why the transactional work must live in separate beans): `references/hardening-concurrency.md`.
The code below is the **baseline** level.
**Adapt, don't transplant:** reuse the project's HTTP client, ORM, logger, config and test
framework. Field names, endpoints and states are fixed by the API; everything else is yours.
## 1. PSP client
```java
@JsonInclude(JsonInclude.Include.NON_NULL) // nulls omitted from requests
record CreatePaymentRequest(String paymentType, BigDecimal amount, String currency, String referenceId,
String returnUrl, String webhookUrl, String parentPaymentId,
Map<String, String> customer, Map<String, String> billingAddress) {}
record PaymentResult(String id, String referenceId, String paymentType, String state, BigDecimal amount,
String currency, String redirectUrl, String errorCode, String errorMessage) {}
record Envelope<T>(String timestamp, int status, T result) {} // every response is wrapped
@Component
public class PspClient {
private static final Logger log = LoggerFactory.getLogger(PspClient.class);
private static final ParameterizedTypeReference<Envelope<PaymentResult>> ONE = new ParameterizedTypeReference<>() {};
private static final ParameterizedTypeReference<Envelope<List<PaymentResult>>> MANY = new ParameterizedTypeReference<>() {};
private final RestClient http;
PspClient(@Value("${psp.api-url}") String baseUrl, @Value("${psp.api-key}") String apiKey) {
var f = new SimpleClientHttpRequestFactory();
f.setConnectTimeout(Duration.ofSeconds(5));
f.setReadTimeout(Duration.ofSeconds(30)); // checkout creation can be slow
this.http = RestClient.builder().baseUrl(baseUrl).requestFactory(f)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) // never logged or echoed
.defaultHeader(HttpHeaders.USER_AGENT, "psp-integration/1.0") // some WL hosts sit behind a WAF that 403s the default client UA
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).build();
}
public PaymentResult createDeposit(CreatePaymentRequest body) { return post(body); }
/** REFUND = a new payment linked by parentPaymentId; there is no /refund endpoint. */
public PaymentResult createRefund(String parentId, BigDecimal amount, String ccy, String ref) {
return post(new CreatePaymentRequest("REFUND", amount, ccy, ref, null, null, parentId, null, null));
}
public PaymentResult getPayment(String id) {
return call(() -> http.get().uri("/api/v1/payments/{id}", id).retrieve().body(ONE)).result();
}
public List<PaymentResult> findByReferenceId(String ref) { // reconciliation lookup
return call(() -> http.get().uri(b -> b.path("/api/v1/payments")
.queryParam("referenceId.eq", ref).build()).retrieve().body(MANY)).result();
}
private PaymentResult post(CreatePaymentRequest body) {
var r = call(() -> http.post().uri("/api/v1/payments").body(body).retrieve().body(ONE)).result();
log.info("psp payment id={} ref={} state={}", r.id(), r.referenceId(), r.state()); // ids/state only
return r; // a decline is 200 + DECLINED, not an exception
}
// Two tiny RuntimeExceptions of your own: PspTimeoutException = outcome UNKNOWN;
// PspApiException exposes status() — >= 500 is ambiguous, a 4xx is a CONFIRMED refusal.
//
// Classify FAIL-SAFE: only a real HTTP status tells you what happened. Everything
// else means "the PSP may or may not have processed it", so it must become UNKNOWN
// and reach the reconcile path. Catching just ResourceAccessException here (the
// RestTemplate idiom) silently misses the most important case: RestClient extracts
// the body lazily, so a read timeout surfaces as a plain RestClientException whose
// cause is SocketTimeoutException. It also misses UnknownContentTypeException, which
// is what you get when a proxy answers 200 text/html. Either one escaping this method
// leaves the attempt IN_FLIGHT with nothing to resolve it — a permanently wedged order.
private <T> Envelope<T> call(Supplier<Envelope<T>> op) {
try { return op.get(); }
catch (RestClientResponseException e) { throw new PspApiException( // a real status + body
e.getStatusCode().value(), e.getResponseBodyAsString(), e); }
catch (RestClientException e) { // transport, timeout (incl. lazy body read), undecodable body
throw new PspTimeoutException("outcome unknown", e);
}
}
}
```
## 2. Webhook controller — the raw-body recipe
```java
@RestController
public class PspWebhookController {
private static final Logger log = LoggerFactory.getLogger(PspWebhookController.class);
private final WebhookSignatureVerifier verifier;
private final ObjectMapper mapper;
private final OrderPaymentService orders;
// CRITICAL: byte[] (ByteArrayHttpMessageConverter) yields the UNMODIFIED request bytes.
// `@RequestBody Map<String,Object>` or a DTO makes Jackson parse the body; re-serialising that to
// compute the HMAC reorders keys and drops whitespace, so the signature NEVER matches.
// Equivalent: HttpServletRequest req -> req.getInputStream().readAllBytes().
@PostMapping(path = "/webhooks/psp", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, String>> handle(@RequestBody byte[] rawBody,
@RequestHeader(name = "Signature", required = false) String signature) throws IOException {
if (!verifier.verify(rawBody, signature)) {
log.warn("psp webhook signature mismatch, {} bytes", rawBody.length); // never log the key
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "invalid signature"));
}
var event = mapper.readValue(rawBody, PaymentResult.class); // parse only after verifying
var outcome = orders.applyWebhook(event);
log.info("psp webhook id={} state={} outcome={}", event.id(), event.state(), outcome);
return ResponseEntity.ok(Map.of("status", outcome.name().toLowerCase(Locale.ROOT))); // 2xx, fast
}
}
@Component
class WebhookSignatureVerifier {
private final byte[] key;
WebhookSignatureVerifier(@Value("${psp.signing-key}") String signingKey) { // PSP_SIGNING_KEY
if (signingKey == null || signingKey.isBlank())
throw new IllegalStateException("PSP_SIGNING_KEY is not configured"); // fail fast at startup
this.key = signingKey.getBytes(StandardCharsets.UTF_8);
}
boolean verify(byte[] rawBody, String header) {
if (header == null || header.isBlank()) return false;
byte[] mac;
try {
var hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(key, "HmacSHA256"));
mac = hmac.doFinal(rawBody);
} catch (GeneralSecurityException e) { throw new IllegalStateException(e); }
String presented = header.trim();
// Encoding (hex vs base64) is NOT documented: accept both, then pin the one your sandbox sends
// and delete the other branch (authentication.md §3). isEqual = constant time.
return eq(HexFormat.of().formatHex(mac), presented.toLowerCase(Locale.ROOT))
|| eq(Base64.getEncoder().encodeToString(mac), presented);
}
private static boolean eq(String a, String b) {
return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}
}
```
## 3. Order state transition (idempotent, DB-guarded)
```java
enum OrderStatus { AWAITING_PAYMENT, PROCESSING, AUTHORIZED, PAID, PAYMENT_FAILED }
enum WebhookOutcome { APPLIED, DUPLICATE, IGNORED, UNKNOWN_PAYMENT }
@Service
public class OrderPaymentService {
private static final Map<String, OrderStatus> MAPPING = Map.of( // whitelist: integration-patterns.md
"COMPLETED", OrderStatus.PAID, "AUTHORIZED", OrderStatus.AUTHORIZED,
"DECLINED", OrderStatus.PAYMENT_FAILED, "CANCELLED", OrderStatus.PAYMENT_FAILED);
private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED_FROM = Map.of(
OrderStatus.PAID, EnumSet.of(AWAITING_PAYMENT, PROCESSING, AUTHORIZED),
OrderStatus.AUTHORIZED, EnumSet.of(AWAITING_PAYMENT, PROCESSING),
OrderStatus.PAYMENT_FAILED, EnumSet.of(AWAITING_PAYMENT, PROCESSING, AUTHORIZED));
private final OrderRepository orders;
private final WebhookEventRepository events;
private final FulfilmentQueue queue;
@Transactional
public WebhookOutcome applyWebhook(PaymentResult e) {
OrderStatus next = MAPPING.get(e.state());
if (next == null) return WebhookOutcome.IGNORED; // non-final / unknown: no change
Long inboxId = events.claim(e.id(), e.state()).orElse(null); // INBOX claim, not a tombstone
if (inboxId == null) return WebhookOutcome.DUPLICATE; // already applied
// Conditional UPDATE: re-application and any downgrade of a final status match 0 rows. Book
// amount/currency FROM THE PAYLOAD — the final amount may differ from the requested one.
int updated = orders.applyTransition(e.id(), e.referenceId(), next, e.amount(), e.currency(),
e.errorCode(), e.errorMessage(), ALLOWED_FROM.get(next));
if (updated == 0) {
// No order yet: the webhook can beat the create-payment response. COMMIT the receipt but leave
// processed_at NULL, or the redelivery is dismissed as a duplicate and the event is lost.
if (!orders.existsForPayment(e.id(), e.referenceId())) return WebhookOutcome.UNKNOWN_PAYMENT;
events.markProcessed(inboxId, "duplicate"); // order already past this transition
return WebhookOutcome.DUPLICATE; // both ack with 200
}
events.markProcessed(inboxId, "applied");
if (next == OrderStatus.PAID) queue.enqueueFulfilment(e.id()); // heavy work async
return WebhookOutcome.APPLIED;
}
}
interface WebhookEventRepository extends JpaRepository<WebhookEventEntity, Long> {
/** Inbox claim: insert, or take over a row that was received but never processed.
* `@Transactional` (read-write) is required: Spring Data marks plain query methods
* `@Transactional(readOnly = true)`, and this one is an INSERT ... RETURNING, so a
* call outside an existing read-write transaction fails. Inside `applyWebhook` the
* caller's transaction already covers it; this annotation is what makes a direct
* call (a test, a replay job) work too. No `@Modifying` — that would discard the
* RETURNING value. */
@Transactional
@Query(value = """
insert into psp_webhook_event (payment_id, state, received_at) values (:id, :state, now())
on conflict (payment_id, state) do update set received_at = now()
where psp_webhook_event.processed_at is null
returning id""", nativeQuery = true)
Optional<Long> claim(String id, String state);
@Modifying(clearAutomatically = true)
@Query(value = "update psp_webhook_event set processed_at = now(), processed_reason = :reason"
+ " where id = :id", nativeQuery = true)
void markProcessed(long id, String reason);
Optional<WebhookEventEntity> findByPaymentIdAndState(String paymentId, String state);
}
interface OrderRepository extends JpaRepository<OrderEntity, Long> {
Optional<OrderEntity> findByOrderRef(String orderRef); // derived query: must be declared
@Modifying(clearAutomatically = true)
@Query("""
update OrderEntity o set o.status = :next, o.pspPaymentId = :paymentId,
o.paidAmount = coalesce(:amount, o.paidAmount),
o.paidCurrency = coalesce(:currency, o.paidCurrency),
o.errorCode = :errorCode, o.errorMessage = :errorMessage
where (o.pspPaymentId = :paymentId or o.orderRef = :referenceId) and o.status in :allowedFrom""")
int applyTransition(String paymentId, String referenceId, OrderStatus next, BigDecimal amount,
String currency, String errorCode, String errorMessage, Set<OrderStatus> allowedFrom);
@Modifying // refund only the remainder; self-serialising under READ COMMITTED
@Query("update OrderEntity o set o.refundedAmount = o.refundedAmount + :amount where o.id = :orderId"
+ " and o.status = 'PAID' and o.refundedAmount + :amount <= o.paidAmount")
int reserveRefund(long orderId, BigDecimal amount);
@Modifying // only a CONFIRMED failure gives the amount back; a timeout must not
@Query("update OrderEntity o set o.refundedAmount = o.refundedAmount - :amount where o.id = :orderId")
int releaseRefund(long orderId, BigDecimal amount);
@Modifying // stores the id the webhook matches on; may land AFTER the first webhook
@Query("update OrderEntity o set o.pspPaymentId = :paymentId where o.orderRef = :ref")
int linkPayment(String ref, String paymentId);
@Modifying // AWAITING_PAYMENT only: a webhook may already have moved the order to PAID
@Query("update OrderEntity o set o.status = 'PROCESSING', o.pspPaymentId = :paymentId"
+ " where o.id = :orderId and o.status = 'AWAITING_PAYMENT'")
int linkProcessing(long orderId, String paymentId);
@Query("select count(o) > 0 from OrderEntity o where o.pspPaymentId = :id or o.orderRef = :ref")
boolean existsForPayment(String id, String ref);
}
```
## 4. Creation and refund idempotency
```java
// All @ResponseStatus(HttpStatus.CONFLICT) + Retry-After, except CheckoutFailedException.
class PaymentOutcomeUnknownException extends RuntimeException { // reconciled, STILL inconclusive:
PaymentOutcomeUnknownException(String ref) { super(ref); } } // the next call reconciles again
class CheckoutFailedException extends RuntimeException { // attempt FAILED, order free again
CheckoutFailedException(String code) { super(code); } }
class CheckoutInProgressException extends RuntimeException {} // attempt state is churning
class RefundOutcomeUnknownException extends RuntimeException {} // committed refund unresolved
class RefundFailedException extends RuntimeException { // key was refused; reservation
RefundFailedException(String refundKey) { super(refundKey); } } // released. A corrected refund
// needs a NEW refund key.
// The transactional work lives in the two *Store beans, NOT here: Spring's proxy does not intercept
// self-invocation, so `this.claim(...)` would not commit before the PSP call —
// hardening-concurrency.md §3.
@Service // deliberately NOT @Transactional: these methods do network I/O
public class CheckoutService {
private final PspAttemptStore attempts;
private final RefundAttemptStore refunds;
private final OrderRepository orders;
private final PspClient psp;
/** Double-click safe: only the request that OWNS the freshly inserted attempt may POST. */
public String startCheckout(long orderId, BigDecimal amount, String currency) {
var claimed = attempts.claim(orderId); // committed before any PSP call
var attempt = claimed.attempt();
if (!claimed.owner()) return join(attempt); // a non-owner never POSTs, never mints a ref
try {
return attempts.promoteReady(attempt.getId(), orderId,
psp.createDeposit(new CreatePaymentRequest("DEPOSIT", amount, currency,
attempt.getReferenceId(), "https://shop.example/return/{id}/{referenceId}/{state}/{type}",
"https://shop.example/webhooks/psp", null,
Map.of("referenceId", "customer_" + orderId),
Map.of("countryCode", "GB", "city", "London"))));
} catch (PspTimeoutException e) {
return resolve(attempt); // outcome unknown: GET, never a second POST
} catch (PspApiException e) {
if (e.status() >= 500) return resolve(attempt); // ambiguous: the payment may exist after all
attempts.markFailed(attempt.getId()); // CONFIRMED refusal, nothing created: frees the order
throw e;
}
// No catch-all is needed HERE only because at baseline an unhandled exception leaves the
// attempt IN_FLIGHT, and `join()` reconciles IN_FLIGHT on the next call — the recovery path
// is the same one. That stops being true the moment you adopt the level-2 model, where a
// sweep resolves UNKNOWN and never looks at IN_FLIGHT: there, an unclassified error must be
// mapped to UNKNOWN explicitly. See hardening-concurrency.md §1 ("never terminal").
}
/** READY -> the stored URL. Still IN_FLIGHT -> reconcile, so a 409 always follows real progress.
* (One extra GET per concurrent click; the cheaper UNKNOWN split: hardening-concurrency.md §1.) */
private String join(PspAttempt a) {
return "READY".equals(a.getState()) ? a.getRedirectUrl() : resolve(a);
}
/** The only way out of an unresolved attempt: GET by the PERSISTED referenceId. */
public String resolve(PspAttempt a) {
var found = psp.findByReferenceId(a.getReferenceId()).stream().findFirst().orElse(null);
if (found == null) throw new PaymentOutcomeUnknownException(a.getReferenceId()); // stays claimed
if ("DECLINED".equals(found.state()) || "CANCELLED".equals(found.state())) {
attempts.markFailed(a.getId()); // frees the order for a NEW attempt
throw new CheckoutFailedException(found.errorCode());
}
return attempts.promoteReady(a.getId(), a.getOrderId(), found);
}
/** Idempotent per (orderId, refundKey); ONLY the inserter of the attempt may POST — referenceId is
* NOT an idempotency key at the PSP, so a second POST is a second payout. */
public PaymentResult refund(long orderId, BigDecimal amount, String currency, String refundKey) {
var reserved = refunds.reserve(orderId, amount, currency, refundKey); // committed
var a = reserved.attempt();
if (a.getPspPaymentId() != null) return psp.getPayment(a.getPspPaymentId()); // DONE
// A settled refusal must keep giving the SAME answer. Falling through to reconciliation here
// would answer "outcome unknown" forever, because no payment was ever created.
if ("FAILED".equals(a.getState())) throw new RefundFailedException(refundKey);
if (!reserved.owner()) return reconcileRefund(a); // someone else's row: reconcile, never POST
var parentId = orders.findById(orderId).orElseThrow().getPspPaymentId();
try {
return refunds.settle(a, psp.createRefund(parentId, amount, currency, a.getReferenceId()));
} catch (PspTimeoutException e) {
return reconcileRefund(a); // outcome unknown: reconcile the SAME ref
} catch (PspApiException e) {
// A confirmed 4xx created nothing. Letting it propagate (or calling it unknown) strands the
// attempt holding the amount reservation: the remainder becomes unrefundable and every retry
// of this key answers 409 forever. Hand the reservation back and close the attempt.
if (e.status() < 500) { refunds.markFailedAndRelease(a); throw e; }
return reconcileRefund(a); // 5xx: the refund may exist after all
}
}
/** Same logical refund => same referenceId, always. A fresh one here is a second payout. */
private PaymentResult reconcileRefund(RefundAttempt a) {
return refunds.settle(a, psp.findByReferenceId(a.getReferenceId()).stream().findFirst()
.orElseThrow(RefundOutcomeUnknownException::new)); // still unresolved: 409, retry later
}
}
/** Separate bean = the tx proxy really applies, so the attempt is COMMITTED before the PSP call. */
@Service
class PspAttemptStore {
private final PspAttemptRepository attempts;
private final OrderRepository orders;
record Claimed(PspAttempt attempt, boolean owner) {}
/** Atomic get-or-create; `owner` says whether THIS call inserted the row. */
@Transactional
Claimed claim(long orderId) {
for (int i = 0; i < 2; i++) { // 2nd pass: the active attempt turned FAILED in between
var fresh = attempts.insertIfAbsent(orderId, "order-%d-%s".formatted(orderId, UUID.randomUUID()));
if (fresh.isPresent()) return new Claimed(fresh.get(), true);
var active = attempts.findActiveByOrderId(orderId);
if (active.isPresent()) return new Claimed(active.get(), false);
}
throw new CheckoutInProgressException();
}
/** FAILED sits outside the partial unique index: the order can start a NEW attempt at once. */
@Transactional void markFailed(long id) { attempts.setState(id, "FAILED"); }
@Transactional
String promoteReady(long attemptId, long orderId, PaymentResult p) {
attempts.promoteReady(attemptId, p.id(), p.redirectUrl()); // every later call reuses this URL
orders.linkProcessing(orderId, p.id());
return p.redirectUrl(); // null once the payment moved past CHECKOUT: poll the order then
}
}
/** Separate bean for the same reason: reserve() must COMMIT before the PSP call, not join it. */
@Service
class RefundAttemptStore {
private final RefundAttemptRepository refunds;
private final OrderRepository orders;
record Reserved(RefundAttempt attempt, boolean owner) {}
/** Attempt row + amount reservation in ONE commit, BEFORE the PSP call. An already existing row
* belongs to another call: reuse ITS referenceId and do NOT reserve the amount again. */
@Transactional
Reserved reserve(long orderId, BigDecimal amount, String currency, String refundKey) {
var ref = "refund-%d-%s".formatted(orderId, UUID.randomUUID());
var fresh = refunds.insertIfAbsent(orderId, refundKey, ref, amount, currency);
if (fresh.isEmpty())
return new Reserved(refunds.findByOrderIdAndRefundKey(orderId, refundKey).orElseThrow(), false);
if (orders.reserveRefund(orderId, amount) == 0) // refund only the remainder
throw new IllegalStateException("refund exceeds remaining refundable amount");
return new Reserved(fresh.get(), true);
}
@Transactional
PaymentResult settle(RefundAttempt a, PaymentResult r) {
boolean failed = "DECLINED".equals(r.state()) || "CANCELLED".equals(r.state());
// State-conditional: the owner and a reconciler can settle the same attempt, and releasing the
// reservation twice would inflate the refundable amount.
if (refunds.finish(a.getId(), r.id(), failed ? "FAILED" : "DONE") == 1 && failed)
orders.releaseRefund(a.getOrderId(), a.getAmount()); // confirmed failure only
return r;
}
/** Confirmed 4xx: nothing was created, so give the reservation back and close the attempt.
* State-conditional for the same reason as settle(). */
@Transactional
void markFailedAndRelease(RefundAttempt a) {
if (refunds.finish(a.getId(), null, "FAILED") == 1)
orders.releaseRefund(a.getOrderId(), a.getAmount());
}
}
interface PspAttemptRepository extends JpaRepository<PspAttempt, Long> {
// Hibernate 6 runs INSERT ... RETURNING as a native query. On older versions: save() and catch
// DataIntegrityViolationException, then re-read the active row — same owner/loser semantics.
@Query(value = """
insert into psp_attempt (order_id, reference_id, state) values (:orderId, :ref, 'IN_FLIGHT')
on conflict (order_id) where state in ('IN_FLIGHT','READY') do nothing
returning *""", nativeQuery = true)
Optional<PspAttempt> insertIfAbsent(long orderId, String ref);
@Query("select a from PspAttempt a where a.orderId = :orderId and a.state in ('IN_FLIGHT','READY')")
Optional<PspAttempt> findActiveByOrderId(long orderId); // FAILED rows are never returned
@Modifying(clearAutomatically = true)
@Query("update PspAttempt a set a.state = :state where a.id = :id")
void setState(long id, String state);
@Modifying(clearAutomatically = true)
@Query("update PspAttempt a set a.state = 'READY', a.pspPaymentId = :paymentId,"
+ " a.redirectUrl = :redirectUrl where a.id = :id")
void promoteReady(long id, String paymentId, String redirectUrl);
long countByOrderId(long orderId);
}
interface RefundAttemptRepository extends JpaRepository<RefundAttempt, Long> {
@Query(value = """
insert into psp_refund_attempt (order_id, refund_key, reference_id, amount, currency, state,
created_at)
values (:orderId, :key, :ref, :amount, :ccy, 'IN_FLIGHT', now())
on conflict (order_id, refund_key) do nothing
returning *""", nativeQuery = true)
Optional<RefundAttempt> insertIfAbsent(long orderId, String key, String ref, BigDecimal amount,
String ccy);
Optional<RefundAttempt> findByOrderIdAndRefundKey(long orderId, String refundKey);
@Modifying // only from a non-terminal state: settling twice must not release the amount twice
@Query("update RefundAttempt r set r.state = :state, r.pspPaymentId = :paymentId"
+ " where r.id = :id and r.state = 'IN_FLIGHT'")
int finish(long id, String paymentId, String state);
}
```
## 5. Tests (JUnit 5 + WireMock + MockMvc)
```java
/** Stub helpers: every API response is wrapped in {timestamp, status, result}. */
abstract class PspStubs {
static ResponseDefinitionBuilder one(String result) {
return okJson("{\"status\":200,\"result\":" + result + "}"); }
static ResponseDefinitionBuilder many(String... rs) {
return okJson("{\"status\":200,\"result\":[" + String.join(",", rs) + "]}"); }
static final String CHECKOUT_1 =
"{\"id\":\"pay1\",\"state\":\"CHECKOUT\",\"redirectUrl\":\"https://checkout.example/pay1\"}";
static final String REFUND_5 = "{\"id\":\"rf9\",\"state\":\"COMPLETED\",\"paymentType\":\"REFUND\","
+ "\"amount\":5.00,\"currency\":\"GBP\"}";
}
class PspClientTest extends PspStubs {
@RegisterExtension static WireMockExtension psp =
WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build();
PspClient client = new PspClient(psp.baseUrl(), "sandbox-key"); // never production credentials
CreatePaymentRequest deposit(String ref) { return new CreatePaymentRequest("DEPOSIT",
new BigDecimal("10.01"), "GBP", ref, null, null, null, null, null); }
@Test void successfulDeposit() {
psp.stubFor(post("/api/v1/payments").willReturn(one(CHECKOUT_1)));
assertEquals("CHECKOUT", client.createDeposit(deposit("order-1-a")).state()); // created, NOT paid
psp.verify(postRequestedFor(urlEqualTo("/api/v1/payments"))
.withHeader("Authorization", equalTo("Bearer sandbox-key"))
.withRequestBody(matchingJsonPath("$.paymentType", equalTo("DEPOSIT"))));
}
@Test void declineIsHttp200WithDeclinedState() {
psp.stubFor(get(urlPathEqualTo("/api/v1/payments/pay2")).willReturn(
one("{\"id\":\"pay2\",\"state\":\"DECLINED\",\"errorCode\":\"4.01\"}")));
assertEquals("4.01", client.getPayment("pay2").errorCode()); // a decline is not an exception
}
@Test void timeoutMeansUnknownOutcome() {
psp.stubFor(post("/api/v1/payments").willReturn(aResponse().withFixedDelay(40_000)));
assertThrows(PspTimeoutException.class, () -> client.createDeposit(deposit("order-9-a")));
}
}
@SpringBootTest @AutoConfigureMockMvc
class PspWebhookControllerTest {
@Autowired MockMvc mvc; @Autowired OrderRepository orders; @Autowired WebhookEventRepository events;
static final String KEY = "test-signing-key"; // == psp.signing-key in test properties
static final String BODY = """
{"id":"pay1","referenceId":"order-1-a","state":"COMPLETED","amount":10.01,"currency":"GBP"}""";
// EARLY: referenceId is the ATTEMPT's reference, not order_ref, and psp_payment_id is not stored
// yet — the real create-payment/webhook race.
static final String EARLY = """
{"id":"pay7","referenceId":"order-1-9f2c","state":"COMPLETED","amount":10.01,"currency":"GBP"}""";
static String sign(String b) throws Exception {
var mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(KEY.getBytes(UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(b.getBytes(UTF_8))); // base64 must pass too
}
ResultActions send(String body, String sig) throws Exception { return mvc.perform(
MockMvcRequestBuilders.post("/webhooks/psp").contentType(MediaType.APPLICATION_JSON)
.header("Signature", sig).content(body.getBytes(UTF_8))); }
ResultActions send(String body) throws Exception { return send(body, sign(body)); }
OrderEntity order() { return orders.findByOrderRef("order-1-a").orElseThrow(); }
@Test void validWebhookMarksOrderPaidWithPayloadAmount() throws Exception {
send(BODY).andExpect(status().isOk()).andExpect(jsonPath("$.status").value("applied"));
assertEquals(OrderStatus.PAID, order().getStatus());
assertEquals(new BigDecimal("10.01"), order().getPaidAmount()); // payload, not the request
}
@Test void invalidSignatureRejectedAndOrderUntouched() throws Exception {
send(BODY, "deadbeef").andExpect(status().isUnauthorized());
assertEquals(OrderStatus.AWAITING_PAYMENT, order().getStatus());
}
@Test void duplicateWebhookIsANoOp() throws Exception {
send(BODY).andExpect(status().isOk());
long version = order().getVersion();
send(BODY).andExpect(status().isOk()).andExpect(jsonPath("$.status").value("duplicate"));
assertEquals(version, order().getVersion());
}
/** The receipt must NOT be marked processed, or the redelivery is dismissed as a duplicate. */
@Test void webhookArrivingBeforeTheOrderLinkIsNotSwallowed() throws Exception {
send(EARLY).andExpect(status().isOk()).andExpect(jsonPath("$.status").value("unknown_payment"));
assertNull(events.findByPaymentIdAndState("pay7", "COMPLETED").orElseThrow().getProcessedAt());
orders.linkPayment("order-1-a", "pay7"); // the create-payment response finally lands
send(EARLY).andExpect(status().isOk()).andExpect(jsonPath("$.status").value("applied"));
assertEquals(OrderStatus.PAID, order().getStatus()); // redelivery wins
}
}
// Needs a REAL PostgreSQL (Testcontainers, not H2): the guarantees rest on ON CONFLICT, partial
// indexes and committed transactions, which no in-memory DB reproduces. The stores are autowired as
// PROXIES, so this also exercises the wiring that actually commits.
@SpringBootTest
class CheckoutServiceConcurrencyTest extends PspStubs {
@RegisterExtension static WireMockExtension psp = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort()).build();
@Autowired CheckoutService checkout; @Autowired PspAttemptRepository attempts;
@Autowired RefundAttemptStore refundStore; @Autowired RefundAttemptRepository refunds;
@Autowired OrderRepository orders;
static final BigDecimal TEN = new BigDecimal("10.01"), FIVE = new BigDecimal("5.00");
static final String POST_PAYMENTS = "/api/v1/payments";
OrderEntity order() { return orders.findById(1L).orElseThrow(); }
void stubFind(String ref, String... results) { // reconciliation lookup by referenceId
var m = get(urlPathEqualTo(POST_PAYMENTS));
psp.stubFor((ref == null ? m : m.withQueryParam("referenceId.eq", equalTo(ref)))
.willReturn(many(results)));
}
/** Two callers, one instant. `deferred` = the exception the loser is allowed to fail with. */
int race(Callable<?> body, Class<? extends RuntimeException> deferred) throws Exception {
var pool = Executors.newFixedThreadPool(2);
var gate = new CountDownLatch(1);
var futures = Stream.generate(() -> pool.submit(() -> { gate.await(); return body.call(); }))
.limit(2).toList();
gate.countDown();
int done = 0;
for (var f : futures) {
try { assertNotNull(f.get()); done++; }
catch (ExecutionException e) { assertInstanceOf(deferred, e.getCause()); done++; }
}
return done;
}
@Test void concurrentStartCheckoutCreatesExactlyOnePayment() throws Exception {
psp.stubFor(post(POST_PAYMENTS).willReturn(one(CHECKOUT_1)));
stubFind(null); // the loser reconciles and finds nothing yet -> 409
assertEquals(2, race(() -> checkout.startCheckout(1L, TEN, "GBP"),
PaymentOutcomeUnknownException.class));
assertEquals(1, attempts.countByOrderId(1L)); // ONE referenceId
psp.verify(1, postRequestedFor(urlEqualTo(POST_PAYMENTS))); // ONE payment created
}
/** Baseline recovery path: no UNKNOWN state and no sweep job — the NEXT call reconciles. */
@Test void checkoutTimeoutThenEmptyReconciliationRecoversOnALaterCall() {
psp.stubFor(post(POST_PAYMENTS).willReturn(aResponse().withFixedDelay(40_000)));
stubFind(null); // not visible yet
assertThrows(PaymentOutcomeUnknownException.class, () -> checkout.startCheckout(2L, TEN, "GBP"));
var stuck = attempts.findActiveByOrderId(2L).orElseThrow(); // still claimed, not a dead end
psp.resetAll();
stubFind(stuck.getReferenceId(),
"{\"id\":\"pay2\",\"state\":\"CHECKOUT\",\"redirectUrl\":\"https://checkout.example/pay2\"}");
assertEquals("https://checkout.example/pay2", checkout.startCheckout(2L, TEN, "GBP"));
assertEquals("READY", attempts.findActiveByOrderId(2L).orElseThrow().getState());
psp.verify(0, postRequestedFor(urlEqualTo(POST_PAYMENTS))); // ONE referenceId, no re-POST
}
@Test void aFailedAttemptLetsANewCheckoutStart() {
psp.stubFor(post(POST_PAYMENTS) // confirmed 4xx: nothing was created
.willReturn(aResponse().withStatus(400).withBody("{\"status\":400,\"errorCode\":\"2.01\"}")));
assertThrows(PspApiException.class, () -> checkout.startCheckout(3L, TEN, "GBP"));
assertTrue(attempts.findActiveByOrderId(3L).isEmpty()); // FAILED is outside the partial index
psp.resetAll();
psp.stubFor(post(POST_PAYMENTS).willReturn(one(
"{\"id\":\"pay3\",\"state\":\"CHECKOUT\",\"redirectUrl\":\"https://checkout.example/pay3\"}")));
assertEquals("https://checkout.example/pay3", // a NEW attempt, a NEW referenceId
checkout.startCheckout(3L, TEN, "GBP"));
assertEquals(2, attempts.countByOrderId(3L));
}
@Test void refundTimeoutThenRetryDoesNotRefundTwice() {
psp.stubFor(post(POST_PAYMENTS).willReturn(aResponse().withFixedDelay(40_000)));
stubFind(null); // not visible yet
assertThrows(RefundOutcomeUnknownException.class, () -> checkout.refund(1L, FIVE, "GBP", "rk-1"));
var stuck = refunds.findByOrderIdAndRefundKey(1L, "rk-1").orElseThrow();
assertEquals("IN_FLIGHT", stuck.getState()); // the attempt row SURVIVED
assertEquals(FIVE, order().getRefundedAmount()); // and so did the reservation
// The PSP had processed it all along. The retry must reconcile the SAME referenceId.
psp.resetRequests();
stubFind(stuck.getReferenceId(), REFUND_5.replace("rf9", "rf1"));
assertEquals("COMPLETED", checkout.refund(1L, FIVE, "GBP", "rk-1").state());
psp.verify(0, postRequestedFor(urlEqualTo(POST_PAYMENTS))); // no second payout
assertEquals(FIVE, order().getRefundedAmount()); // reserved once, not twice
}
@Test void concurrentRefundsWithTheSameKeyPostExactlyOnce() throws Exception {
psp.stubFor(post(POST_PAYMENTS).willReturn(one(REFUND_5)));
stubFind(null); // the non-owner reconciles, finds nothing yet
psp.stubFor(get(urlPathEqualTo("/api/v1/payments/rf9")).willReturn(one(REFUND_5)));
assertEquals(2, race(() -> checkout.refund(1L, FIVE, "GBP", "rk-9"),
RefundOutcomeUnknownException.class));
psp.verify(1, postRequestedFor(urlEqualTo(POST_PAYMENTS))); // ONE payout, never two
assertEquals(FIVE, order().getRefundedAmount()); // reserved once
}
@Test void crashBetweenAnAcceptedRefundPostAndSettleDoesNotPostAgain() {
// The crash state, reproduced through the PROXIED store: attempt committed IN_FLIGHT, amount
// reserved, the PSP already holds the payment, nothing settled.
var a = refundStore.reserve(1L, FIVE, "GBP", "rk-2").attempt();
stubFind(a.getReferenceId(), REFUND_5.replace("rf9", "rf2"));
assertEquals("rf2", checkout.refund(1L, FIVE, "GBP", "rk-2").id());
psp.verify(0, postRequestedFor(urlEqualTo(POST_PAYMENTS))); // reconciled, not re-POSTed
assertEquals("DONE", refunds.findByOrderIdAndRefundKey(1L, "rk-2").orElseThrow().getState());
assertEquals(FIVE, order().getRefundedAmount()); // reserved once
}
}
```
The replay-job test (a stale `AUTHORIZED` receipt closed as `superseded`) exercises the hardened
variant: `references/hardening-concurrency.md` §2.
SHA-256: 32eb3672941af9790ce76576b6648a81ee5cdc2f0cbdd57d6f953f914e46db5a