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 HttpClientErrorException400 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:

  1. when(paymentGatewayService.sendPaymentRequestToBank(...)) — You cannot stub methods on the class under test when it is only @InjectMocks without @Spy. Those stubs have no effect; tests may pass for the wrong reason or fail unpredictably.
  2. shouldReturnIsAuthorizedWhenBankAuthorized — Calls sendPaymentRequestToBank twice; the first call’s result is discarded.
  3. paymentsRepository.add(payment) on a @Mock in shouldReturnPaymentWhenIdExists — redundant noise; only when(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

  1. docker compose up — bank responds on http://localhost:8080/payments.
  2. ./gradlew bootRun — app on http://localhost:8090.
  3. POST http://localhost:8090/api/payments with a valid body → 201 and masked card in body.
  4. GET http://localhost:8090/api/payments/{id} for stored id → 200.
  5. Unknown id → 404 with Payment not found.
  6. Invalid body → 400 with Rejected and validation messages.
  7. Card ending in 0 (per simulator) → bank 503 → gateway returns 503 with bank error payload shape.
  8. ./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).