This document maps each commitment in the project README.md to concrete actions and suggested code. The upstream interview template is payment-gateway-challenge-java; it also states not to change imposters/ or .editorconfig.
1. Environment and how to run
| README item | What to do |
|---|---|
| JDK 17 | Install JDK 17 and point JAVA_HOME at it. Use ./gradlew -version to confirm the daemon uses Java 17. If Lombok fails to compile on a newer JDK, align Lombok with your toolchain (e.g. bump lombok in build.gradle to a version that supports your JDK). |
| Docker | Install Docker Desktop (or Engine) so docker compose works. |
| Bank first, then app | Run docker compose up from the repo root so Mountebank exposes the bank on host port 8080 (see docker-compose.yml). Then run ./gradlew bootRun. |
| Tests | Run ./gradlew test. |
Port layout (important): The Spring app listens on 8090 (application.properties). The bank simulator listens on 8080 on the host. The README’s “Key features” URLs currently say localhost:8080 for the gateway API; that conflicts with the bank unless you change ports. The official template documents Swagger at 8090. Align documentation with reality: gateway at 8090, bank at 8080.
Suggested README.md correction for the “Key features” section:
1. Process a card payment
POST http://localhost:8090/api/payments
2. Retrieve a previously processed payment
GET http://localhost:8090/api/payments/{id}
2. API paths: match the README (/api/payments)
The README documents plural payments. The controller today uses /api/payment (singular). Update the controller and any tests/clients to use /api/payments and /api/payments/{id}.
File: src/main/java/com/checkout/payment/gateway/controller/PaymentGatewayController.java
Replace the mapping annotations so paths are plural:
package com.checkout.payment.gateway.controller;
import com.checkout.payment.gateway.model.GetPaymentResponse;
import com.checkout.payment.gateway.model.PostPaymentRequest;
import com.checkout.payment.gateway.model.PostPaymentResponse;
import com.checkout.payment.gateway.service.PaymentGatewayService;
import java.util.UUID;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/payments")
public class PaymentGatewayController {
private final PaymentGatewayService paymentGatewayService;
public PaymentGatewayController(PaymentGatewayService paymentGatewayService) {
this.paymentGatewayService = paymentGatewayService;
}
@GetMapping("/{id}")
public ResponseEntity<GetPaymentResponse> getPostPaymentEventById(@PathVariable UUID id) {
return new ResponseEntity<>(paymentGatewayService.getPaymentById(id), HttpStatus.OK);
}
@PostMapping
public ResponseEntity<PostPaymentResponse> createPayment(@Valid @RequestBody PostPaymentRequest request) {
return new ResponseEntity<>(paymentGatewayService.createPayment(request), HttpStatus.CREATED);
}
}
File: src/test/java/com/checkout/payment/gateway/controller/PaymentGatewayControllerTest.java
Change the endpoint constant to match:
private static final String PROCESS_PAYMENT_ENDPOINT = "/api/payments";
Update get calls to use the new shape (collection + id):
mvc.perform(MockMvcRequestBuilders.get(PROCESS_PAYMENT_ENDPOINT + "/" + payment.getId()))
POST already targets PROCESS_PAYMENT_ENDPOINT with no extra path segment; with @RequestMapping("/api/payments") and @PostMapping on the method, that remains correct.
3. Design considerations from the README
3.1 Jakarta Bean Validation
Already applied on PostPaymentRequest with @Valid on the controller. Fulfillment: keep DTOs annotated; add tests for any new rules you introduce.
3.2 Lombok
Already used. Fulfillment: keep Lombok versions compatible with your JDK (see §1).
3.3 Consistent error format
CommonExceptionHandler returns ErrorResponse with error and message list. Fulfillment: ensure every handler returns the same JSON shape and status codes that match your assumptions (§4).
3.4 Separate DTOs and mapper
Already split (PostPaymentRequest, PostPaymentResponse, GetPaymentResponse, BankPaymentRequest, BankPaymentResponse, PaymentMapper). Fulfillment: extend the same pattern for any new fields.
3.5 Card masking and CVV
Mapper stores only last four; CVV is only on the bank request. Fulfillment: avoid logging full card numbers or CVV anywhere (audit LOG statements). The service currently logs the entire BankPaymentResponse at DEBUG; prefer logging non-sensitive fields (e.g. authorized only) if DEBUG logs might appear in shared environments.
3.6 Log incoming API requests
RequestResponseLogger logs method and URI. Fulfillment: sufficient for “incoming API requests”; optionally add a correlation id filter later.
3.7 Bank exceptions caught and mapped
PaymentGatewayService.sendPaymentRequestToBank catches exceptions and wraps BankErrorException. Gap vs README assumptions: HttpClientErrorException (4xx from the bank) is not a subclass of HttpServerErrorException, so it currently falls into catch (Exception e) and becomes 500 INTERNAL_SERVER_ERROR. The README assumes other client errors from the bank should surface as gateway 400.
File: src/main/java/com/checkout/payment/gateway/service/PaymentGatewayService.java
Use a configurable bank URL (see §5), map 4xx from the bank to a BankErrorException with BAD_REQUEST, and optionally treat null responses defensively:
package com.checkout.payment.gateway.service;
import com.checkout.payment.gateway.enums.PaymentStatus;
import com.checkout.payment.gateway.exception.BankErrorException;
import com.checkout.payment.gateway.exception.EventProcessingException;
import com.checkout.payment.gateway.model.BankPaymentRequest;
import com.checkout.payment.gateway.model.BankPaymentResponse;
import com.checkout.payment.gateway.model.GetPaymentResponse;
import com.checkout.payment.gateway.model.PaymentMapper;
import com.checkout.payment.gateway.model.PostPaymentRequest;
import com.checkout.payment.gateway.model.PostPaymentResponse;
import com.checkout.payment.gateway.repository.PaymentsRepository;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestTemplate;
@Service
public class PaymentGatewayService {
private static final Logger LOG = LoggerFactory.getLogger(PaymentGatewayService.class);
private final PaymentsRepository paymentsRepository;
private final RestTemplate restTemplate;
private final String acquiringBankPaymentsUrl;
public PaymentGatewayService(
PaymentsRepository paymentsRepository,
RestTemplate restTemplate,
@Value("${acquiring.bank.payments-url}") String acquiringBankPaymentsUrl) {
this.paymentsRepository = paymentsRepository;
this.restTemplate = restTemplate;
this.acquiringBankPaymentsUrl = acquiringBankPaymentsUrl;
}
public GetPaymentResponse getPaymentById(UUID id) {
LOG.debug("Requesting access to payment with ID {}", id);
return PaymentMapper.toGetResponse(
paymentsRepository.get(id).orElseThrow(() -> new EventProcessingException("Invalid ID")));
}
public PostPaymentResponse createPayment(PostPaymentRequest paymentRequest) {
LOG.debug("Requesting to create payment");
PostPaymentResponse result = processPayment(paymentRequest);
paymentsRepository.add(result);
LOG.info(
"Payment {} with {}{} (card ending with {})",
result.getStatus().getName(),
result.getCurrency(),
result.getAmount(),
result.getCardNumberLastFour());
return result;
}
public PostPaymentResponse processPayment(PostPaymentRequest paymentRequest) {
BankPaymentResponse response =
sendPaymentRequestToBank(PaymentMapper.toBankRequest(paymentRequest));
PaymentStatus paymentStatus =
response.isAuthorized() ? PaymentStatus.AUTHORIZED : PaymentStatus.DECLINED;
return PaymentMapper.toPostResponse(paymentRequest, UUID.randomUUID(), paymentStatus);
}
public BankPaymentResponse sendPaymentRequestToBank(BankPaymentRequest paymentRequest) {
LOG.debug("Sending request to the bank {}", acquiringBankPaymentsUrl);
BankPaymentResponse response;
try {
response =
restTemplate.postForObject(
acquiringBankPaymentsUrl, paymentRequest, BankPaymentResponse.class);
if (response == null) {
throw new BankErrorException(
HttpStatus.BAD_GATEWAY, "Empty response from acquiring bank");
}
LOG.debug(
"Received response from the bank authorized={}",
response.isAuthorized());
} catch (HttpServerErrorException.ServiceUnavailable e) {
throw new BankErrorException(e.getStatusCode(), e.getMessage());
} catch (HttpServerErrorException e) {
throw new BankErrorException(HttpStatus.BAD_GATEWAY, e.getMessage());
} catch (HttpClientErrorException e) {
throw new BankErrorException(HttpStatus.BAD_REQUEST, e.getMessage());
} catch (Exception e) {
throw new BankErrorException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
}
return response;
}
}
File: src/main/resources/application.properties
Add the bank URL (matches Mountebank stub path /payments on port 8080):
server.port=8090
springdoc.swagger-ui.enabled=true
springdoc.api-docs.enabled=true
acquiring.bank.payments-url=http://localhost:8080/payments
For tests that load the full context, either rely on this default or supply the same property in src/test/resources/application.properties.
4. README “Assumptions” — behavior checklist
| Assumption | Implementation note |
|---|---|
| Currency is valid ISO | Document only unless you add an enum or service (listed under “Future improvement”). |
| Full PAN / CVV not stored | Already satisfied; keep it that way. |
| Bank 503 → temporary bank issue | BankErrorException with 503; handler adds a clear message. |
| Other bank 5xx → bank server issues | Map to 502 BAD_GATEWAY (or another 5xx you document consistently). |
| Other bank errors / exceptions → gateway 400 where appropriate | Add HttpClientErrorException → 400 as in §3.7. |
| On acquiring bank error, do not store payment | Today createPayment only adds after processPayment completes; if sendPaymentRequestToBank throws, nothing is stored. Do not call paymentsRepository.add before the bank call succeeds. |
| In-memory store, one bank, synchronous | Current design matches. |
File: src/main/java/com/checkout/payment/gateway/exception/CommonExceptionHandler.java
Refine messages so 502 vs 503 vs 400 are distinct (still one JSON shape):
package com.checkout.payment.gateway.exception;
import com.checkout.payment.gateway.enums.PaymentStatus;
import com.checkout.payment.gateway.model.ErrorResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class CommonExceptionHandler {
private static final Logger LOG = LoggerFactory.getLogger(CommonExceptionHandler.class);
@ExceptionHandler(EventProcessingException.class)
public ResponseEntity<ErrorResponse> handlePaymentException(EventProcessingException ex) {
LOG.error("Payment not found: {}", ex.getMessage());
return new ResponseEntity<>(new ErrorResponse("Payment not found"), HttpStatus.NOT_FOUND);
}
/**
* Handle the errors from data validation
* @param ex - MethodArgumentNotValidException thrown by Jakarta
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException ex) {
LOG.warn("Validation failed: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(PaymentStatus.REJECTED.getName());
for (ObjectError error : ex.getBindingResult().getAllErrors()) {
errorResponse.getMessage().add(error.getDefaultMessage());
}
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
/**
* Handle the errors while calling acquiring bank
*/
@ExceptionHandler(BankErrorException.class)
public ResponseEntity<ErrorResponse> handleBankErrorException(BankErrorException ex) {
LOG.error("Acquiring bank error: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse("Acquiring bank error");
HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value());
if (status == HttpStatus.SERVICE_UNAVAILABLE) {
errorResponse.getMessage().add("Bank service is currently unavailable");
} else if (status == HttpStatus.BAD_GATEWAY) {
errorResponse.getMessage().add("Acquiring bank returned a server error");
} else if (status == HttpStatus.BAD_REQUEST) {
errorResponse.getMessage().add("Request rejected by the acquiring bank");
} else {
errorResponse.getMessage().add("Unknown error happened when calling the bank");
}
return new ResponseEntity<>(errorResponse, ex.getStatusCode());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception ex) {
LOG.error("Exception happened", ex);
return new ResponseEntity<>(new ErrorResponse("Server Error"), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
5. Tests: fix bugs and anti-patterns (company feedback)
Problems in PaymentGatewayServiceTest:
when(paymentGatewayService.sendPaymentRequestToBank(...))— You cannot stub methods on the class under test when it is only@InjectMockswithout@Spy. Those stubs have no effect; tests may pass for the wrong reason or fail unpredictably.shouldReturnIsAuthorizedWhenBankAuthorized— CallssendPaymentRequestToBanktwice; the first call’s result is discarded.paymentsRepository.add(payment)on a@MockinshouldReturnPaymentWhenIdExists— redundant noise; onlywhen(paymentsRepository.get(...))matters.
Fulfillment: Stub RestTemplate.postForObject only, using the same URL the service uses (acquiring.bank.payments-url), or inject a fixed test URL.
File: src/test/java/com/checkout/payment/gateway/service/PaymentGatewayServiceTest.java
Example of a corrected test class (constructor injection must match the updated service):
package com.checkout.payment.gateway.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.checkout.payment.gateway.enums.PaymentStatus;
import com.checkout.payment.gateway.exception.BankErrorException;
import com.checkout.payment.gateway.exception.EventProcessingException;
import com.checkout.payment.gateway.model.BankPaymentRequest;
import com.checkout.payment.gateway.model.BankPaymentResponse;
import com.checkout.payment.gateway.model.PaymentMapper;
import com.checkout.payment.gateway.model.PostPaymentRequest;
import com.checkout.payment.gateway.model.PostPaymentResponse;
import com.checkout.payment.gateway.repository.PaymentsRepository;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestTemplate;
@ExtendWith(MockitoExtension.class)
class PaymentGatewayServiceTest {
private static final String BANK_URL = "http://localhost:8080/payments";
@Mock private PaymentsRepository paymentsRepository;
@Mock private RestTemplate restTemplate;
private PaymentGatewayService paymentGatewayService;
@BeforeEach
void setUp() {
paymentGatewayService = new PaymentGatewayService(paymentsRepository, restTemplate, BANK_URL);
}
@Test
void shouldReturnPaymentWhenIdExists() {
PostPaymentResponse payment =
PostPaymentResponse.builder()
.cardNumberLastFour("4321")
.id(UUID.randomUUID())
.amount(10)
.currency("USD")
.status(PaymentStatus.AUTHORIZED)
.expiryYear(2024)
.expiryMonth(12)
.build();
when(paymentsRepository.get(payment.getId())).thenReturn(Optional.of(payment));
assertEquals(PaymentMapper.toGetResponse(payment), paymentGatewayService.getPaymentById(payment.getId()));
}
@Test
void shouldThrowExceptionWhenIdNotExists() {
UUID id = UUID.randomUUID();
when(paymentsRepository.get(id)).thenReturn(Optional.empty());
assertThrows(EventProcessingException.class, () -> paymentGatewayService.getPaymentById(id));
}
@Test
void shouldReturnAuthorizedWhenBankAuthorized() {
BankPaymentRequest request =
BankPaymentRequest.builder()
.cardNumber("2222405343248877")
.expiryDate("04/2025")
.currency("GBP")
.amount(100)
.cvv("123")
.build();
when(restTemplate.postForObject(eq(BANK_URL), eq(request), eq(BankPaymentResponse.class)))
.thenReturn(
BankPaymentResponse.builder()
.authorized(true)
.authorizationCode("123456")
.build());
BankPaymentResponse response = paymentGatewayService.sendPaymentRequestToBank(request);
assertTrue(response.isAuthorized());
verify(restTemplate).postForObject(eq(BANK_URL), eq(request), eq(BankPaymentResponse.class));
}
@Test
void shouldThrowExceptionWhenBankServiceUnavailable() {
BankPaymentRequest request =
BankPaymentRequest.builder()
.cardNumber("2222405343248870")
.expiryDate("04/2025")
.currency("GBP")
.amount(100)
.cvv("123")
.build();
when(restTemplate.postForObject(eq(BANK_URL), eq(request), eq(BankPaymentResponse.class)))
.thenThrow(new HttpServerErrorException(HttpStatus.SERVICE_UNAVAILABLE));
assertThrows(BankErrorException.class, () -> paymentGatewayService.sendPaymentRequestToBank(request));
}
@Test
void shouldThrowBankErrorExceptionWhenBankReturnsBadRequest() {
BankPaymentRequest request =
BankPaymentRequest.builder()
.cardNumber("2222405343248870")
.expiryDate("04/2025")
.currency("GBP")
.amount(100)
.cvv("123")
.build();
when(restTemplate.postForObject(eq(BANK_URL), eq(request), eq(BankPaymentResponse.class)))
.thenThrow(new HttpClientErrorException(HttpStatus.BAD_REQUEST));
BankErrorException ex =
assertThrows(
BankErrorException.class, () -> paymentGatewayService.sendPaymentRequestToBank(request));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
}
@Test
void shouldMaskCardNumberWhenGeneratingResponse() {
PostPaymentRequest request =
PostPaymentRequest.builder()
.cardNumber("2222405343248878")
.amount(10)
.currency("USD")
.expiryYear(2024)
.expiryMonth(12)
.cvv("123")
.build();
ArgumentCaptor<BankPaymentRequest> bankReq = ArgumentCaptor.forClass(BankPaymentRequest.class);
when(restTemplate.postForObject(eq(BANK_URL), bankReq.capture(), eq(BankPaymentResponse.class)))
.thenReturn(
BankPaymentResponse.builder()
.authorized(true)
.authorizationCode("123456")
.build());
assertEquals("8878", paymentGatewayService.createPayment(request).getCardNumberLastFour());
verify(paymentsRepository).add(any(PostPaymentResponse.class));
}
@Test
void shouldReturnAuthorizedStatusWhenBankAuthorized() {
PostPaymentRequest request =
PostPaymentRequest.builder()
.cardNumber("2222405343248878")
.amount(10)
.currency("USD")
.expiryYear(2024)
.expiryMonth(12)
.cvv("123")
.build();
when(restTemplate.postForObject(eq(BANK_URL), any(BankPaymentRequest.class), eq(BankPaymentResponse.class)))
.thenReturn(
BankPaymentResponse.builder()
.authorized(true)
.authorizationCode("123456")
.build());
PostPaymentResponse result = paymentGatewayService.processPayment(request);
assertEquals(PaymentStatus.AUTHORIZED, result.getStatus());
}
@Test
void shouldReturnDeclinedStatusWhenBankNotAuthorized() {
PostPaymentRequest request =
PostPaymentRequest.builder()
.cardNumber("2222405343248878")
.amount(10)
.currency("USD")
.expiryYear(2024)
.expiryMonth(12)
.cvv("123")
.build();
when(restTemplate.postForObject(eq(BANK_URL), any(BankPaymentRequest.class), eq(BankPaymentResponse.class)))
.thenReturn(
BankPaymentResponse.builder()
.authorized(false)
.authorizationCode("")
.build());
PostPaymentResponse result = paymentGatewayService.processPayment(request);
assertEquals(PaymentStatus.DECLINED, result.getStatus());
}
}
6. “Future improvement” and “Feedback” (optional follow-ups)
| README item | Fulfillment approach |
|---|---|
| Validation messages from properties | messages.properties + MessageSource in exception handler. |
| Pageable queries | Not applicable until persistence supports listing. |
| Separate bank client | Extract AcquiringBankClient interface + RestTemplate (or WebClient) implementation. |
| WebClient | Replace RestTemplate with WebClient in a dedicated client bean. |
| Retries on bank | Spring Retry or Resilience4j; only retry idempotent failures (e.g. 503), with backoff. |
| WireMock / HTTP error simulation | Add test-scoped WireMock rule instead of relying on Mountebank for unit tests. |
| Currency ISO validation | Enum CurrencyCode, allow-list table, or external reference data. |
| Company feedback (spec + design + tests) | Align API docs and code, map bank errors per assumptions, fix Mockito usage, reduce redundant test setup, avoid logging sensitive payloads. |
7. Files touched summary
| File | Why |
|---|---|
README.md |
Fix gateway host port and paths so they match application.properties and the controller. |
PaymentGatewayController.java |
Plural /api/payments routes. |
PaymentGatewayControllerTest.java |
Same base path as controller. |
PaymentGatewayService.java |
Configurable bank URL; correct 4xx mapping; safer logging / null response. |
application.properties |
acquiring.bank.payments-url. |
CommonExceptionHandler.java |
Clearer bank-error messages per HTTP status. |
PaymentGatewayServiceTest.java |
Remove invalid self-mocking; stub RestTemplate; manual construction with bank URL. |
src/test/resources/application.properties |
Optional duplicate of acquiring.bank.payments-url if you prefer test-local config. |
Do not change imposters/bank_simulator.ejs per upstream instructions unless your own README explicitly overrides that rule.
8. Quick verification checklist
docker compose up— bank responds onhttp://localhost:8080/payments../gradlew bootRun— app onhttp://localhost:8090.POST http://localhost:8090/api/paymentswith a valid body →201and masked card in body.GET http://localhost:8090/api/payments/{id}for stored id →200.- Unknown id →
404withPayment not found. - Invalid body →
400withRejectedand validation messages. - Card ending in
0(per simulator) → bank503→ gateway returns503with bank error payload shape. ./gradlew test— all green after test refactors.
This guide is the single place that ties README commitments to exact file-level changes; apply the sections in order that matches your priorities (API alignment and bank error mapping first if you are closing interview feedback).