How I review code

A pull request written for this page, a refund feature in a Spring Boot payments service, with the review I would give it. Read it with my comments, or hide them and look for the problems first.

Add partial refunds

feature/partial-refunds into main · 7 files · +199 −0

Adds POST /payments/{id}/refunds for partial refunds, a nightly job that reconciles refunds with the payment provider, and retries for provider timeouts. A refund is allowed for 30 days after capture, up to the captured amount.

src/main/java/com/example/payments/refund/RefundController.javanew file · +37 −0

Old lineNew lineCode
@@ -0,0 +1,37 @@
1package com.example.payments.refund;
2
3import java.util.List;
4import java.util.UUID;
5
6import org.slf4j.Logger;
7import org.slf4j.LoggerFactory;
8import org.springframework.http.HttpStatus;
9import org.springframework.http.ResponseEntity;
10import org.springframework.web.bind.annotation.*;
11
12@RestController
13@RequestMapping("/payments/{paymentId}/refunds")
14class RefundController {
15
16 private static final Logger log = LoggerFactory.getLogger(RefundController.class);
17
18 private final RefundService refunds;
19 private final PaymentQueries payments;
20
21 RefundController(RefundService refunds, PaymentQueries payments) {
22 this.refunds = refunds;
23 this.payments = payments;
24 }
25
26 @PostMapping
27 ResponseEntity<RefundResponse> refund(@PathVariable UUID paymentId, @RequestBody RefundRequest request) {
28 log.info("Refund of {} requested for {}", request.amount(), payments.view(paymentId));

issue (blocking)This writes full card numbers to the log.

PaymentView is a record, so its toString prints every component, cardNumber included. A log is storage like any other under PCI DSS, and it is copied to places we do not control. Log the payment id; and give PaymentView the last four digits instead of the number, so that nothing downstream can leak it either.

29 Refund refund = refunds.refund(paymentId, request.amount(), request.reason());

issue (blocking)A retried request refunds twice.

A client that times out and sends this POST again gets a second refund, and so does a proxy that replays it. Accept an Idempotency-Key header, store it with the refund under a unique constraint, and answer a repeat with the first refund.

30 return ResponseEntity.status(HttpStatus.CREATED).body(RefundResponse.of(refund));
31 }
32
33 @GetMapping
34 List<RefundResponse> list(@PathVariable UUID paymentId) {
35 return refunds.forPayment(paymentId).stream().map(RefundResponse::of).toList();
36 }
37}

src/main/java/com/example/payments/refund/PaymentView.javanew file · +7 −0

Old lineNew lineCode
@@ -0,0 +1,7 @@
1package com.example.payments.refund;
2
3import java.util.UUID;
4
5/** What the support screen shows about a payment. */
6public record PaymentView(UUID id, String cardNumber, String cardholder, double amount, String currency) {
7}

src/main/java/com/example/payments/refund/RefundService.javanew file · +51 −0

Old lineNew lineCode
@@ -0,0 +1,51 @@
1package com.example.payments.refund;
2
3import java.time.LocalDateTime;
4import java.util.List;
5import java.util.UUID;
6
7import com.example.payments.Payment;
8import com.example.payments.PaymentRepository;
9import com.example.payments.provider.ProviderClient;
10import org.springframework.stereotype.Service;
11import org.springframework.transaction.annotation.Transactional;
12
13@Service
14public class RefundService {
15
16 private static final int REFUND_WINDOW = 30;

nitpick (non-blocking)30 what?

Duration.ofDays(30), or a configuration property, says the unit, and lets support change the window without a release.

17
18 private final PaymentRepository payments;
19 private final RefundRepository refunds;
20 private final ProviderClient provider;
21
22 public RefundService(PaymentRepository payments, RefundRepository refunds, ProviderClient provider) {
23 this.payments = payments;
24 this.refunds = refunds;
25 this.provider = provider;
26 }
27
28 public Refund refund(UUID paymentId, double amount, String reason) {

issue (blocking)Money as double.

Binary floating point cannot hold most cents exactly, so sums of refunds drift, and the comparison below can let a cent through or refuse a valid refund. Use BigDecimal at the currency’s scale, or a long of minor units, here, in Refund, in PaymentView, and in the migration, where DOUBLE PRECISION has the same problem.

29 Payment payment = payments.findById(paymentId).orElseThrow(() -> new PaymentNotFoundException(paymentId));
30 if (payment.capturedAt().isBefore(LocalDateTime.now().minusDays(REFUND_WINDOW))) {

issueThe window depends on the server’s time zone.

LocalDateTime.now() reads the JVM’s zone, so the cut-off moves when the container’s zone does, and around a daylight-saving change a day is not 24 hours. Keep capturedAt as an Instant and compare with an injected Clock: the second test can then fix the time instead of building a payment 31 days old.

31 throw new RefundWindowClosedException(paymentId);
32 }
33 double alreadyRefunded = refunds.findByPaymentId(paymentId).stream().mapToDouble(Refund::amount).sum();

issue (blocking)Two refunds at the same moment can both pass this check.

The sum is read, compared and written in separate steps, so two requests for 60 on a payment of 100 both see nothing refunded and both succeed. Make the check and the write one step: lock the payment row in the transaction (@Lock(PESSIMISTIC_WRITE) on the query), or keep refunded_amount on the payment and update it WHERE refunded_amount + :amount <= amount, checking that one row changed.

34 if (alreadyRefunded + amount > payment.amount()) {
35 throw new RefundExceedsPaymentException(paymentId, amount);
36 }
37 return issue(payment, amount, reason);

issue (blocking)@Transactional does nothing on this call.

Spring applies it through a proxy, and a call from inside the class never passes through the proxy. So issue runs without a transaction, and markSucceeded() changes an object that nobody saves. Make refund the transactional method, or move issue to a bean of its own.

38 }
39
40 @Transactional
41 Refund issue(Payment payment, double amount, String reason) {
42 Refund refund = refunds.save(new Refund(UUID.randomUUID(), payment.id(), amount, reason));
43 provider.refund(payment.providerReference(), amount);

suggestionKeep the provider call out of the database transaction.

Once the transaction works, it holds a connection and row locks for as long as the provider takes to answer, and a rollback after the provider refunded leaves the customer’s money returned with no record of it. Commit the refund as PENDING, then call the provider; an outbox makes that call survive a crash.

44 refund.markSucceeded();

questionWhat happens if the provider refunds and this process dies before markSucceeded()?

The customer has the money and we have a PENDING refund, or none at all. Does anything pick up refunds stuck in PENDING and ask the provider? The reconciler only reads succeeded ones.

45 return refund;
46 }
47
48 public List<Refund> forPayment(UUID paymentId) {
49 return refunds.findByPaymentId(paymentId);
50 }
51}

src/main/java/com/example/payments/refund/RefundReconciler.javanew file · +36 −0

Old lineNew lineCode
@@ -0,0 +1,36 @@
1package com.example.payments.refund;
2
3import java.math.BigDecimal;
4
5import com.example.payments.Payment;
6import com.example.payments.PaymentRepository;
7import com.example.payments.provider.ProviderClient;
8import com.example.payments.provider.ProviderRefund;
9import org.springframework.scheduling.annotation.Scheduled;
10import org.springframework.stereotype.Component;
11
12/** Every night, checks the refunds the provider reports against ours. */
13@Component
14class RefundReconciler {
15
16 private final RefundRepository refunds;
17 private final PaymentRepository payments;
18 private final ProviderClient provider;
19
20 RefundReconciler(RefundRepository refunds, PaymentRepository payments, ProviderClient provider) {
21 this.refunds = refunds;
22 this.payments = payments;
23 this.provider = provider;
24 }
25
26 @Scheduled(cron = "0 0 3 * * *")
27 void reconcile() {
28 for (Refund refund : refunds.findAllSucceededSince(yesterday())) {
29 Payment payment = payments.findById(refund.paymentId()).orElseThrow();

issueOne query per refund.

Each refund loads its payment separately, so a night with ten thousand refunds makes ten thousand round trips. Load the payments with the refunds, in one join or one findAllById; and if the provider has a refund report, ask for it once instead of once per refund.

30 ProviderRefund reported = provider.findRefund(payment.providerReference(), refund.id());
31 if (!reported.amount().equals(BigDecimal.valueOf(refund.amount()))) {

issueBigDecimal.equals compares the scale too.

new BigDecimal("10.0").equals(new BigDecimal("10.00")) is false, so a provider that sends two decimals flags every refund as a mismatch. Compare with compareTo(...) != 0, once the amounts are no longer double.

32 refund.flagMismatch(reported.amount());
33 }
34 }
35 }
36}

src/main/java/com/example/payments/provider/ProviderClient.java+28 −0

Old lineNew lineCode
@@ -3,9 +3,12 @@
33import java.net.URI;
44import java.time.Duration;
55
6import org.springframework.web.client.ResourceAccessException;
67import org.springframework.web.client.RestClient;
78
89public class ProviderClient {
10
11 private static final int MAX_ATTEMPTS = 3;
912
1013 private final RestClient http;
1114
@@ -20,9 +23,34 @@
2023 .toBodilessEntity();
2124 }
2225
26 public void refund(String providerReference, double amount) {
27 for (int attempt = 1; ; attempt++) {
28 try {
29 http.post().uri("/payments/{reference}/refunds", providerReference)
30 .body(new RefundCommand(amount))
31 .retrieve()
32 .toBodilessEntity();
33 return;
34 } catch (ResourceAccessException timeout) {

issue (blocking)A timeout is not a failure: retrying this POST can refund twice at the provider.

After a timeout we do not know whether the provider refunded. Sending the request again asks for a second refund. Send our refund id as the provider’s idempotency key, so that a retry returns the first refund instead of making another.

35 if (attempt == MAX_ATTEMPTS) {
36 throw timeout;
37 }
38 sleep(Duration.ofSeconds(1));

suggestionBack off, with jitter.

Every client caught by the same outage retries on the same one-second beat, so the provider gets its load back in waves just as it recovers. Exponential backoff with full jitter spreads the retries out; the lab’s Charged twice experiment shows the difference.

39 }
40 }
41 }
42
2343 public ProviderRefund findRefund(String providerReference, java.util.UUID refundId) {
2444 return http.get().uri("/payments/{reference}/refunds/{id}", providerReference, refundId)
2545 .retrieve()
2646 .body(ProviderRefund.class);
2747 }
48
49 private static void sleep(Duration pause) {
50 try {
51 Thread.sleep(pause);
52 } catch (InterruptedException e) {
53 Thread.currentThread().interrupt();
54 }
55 }
2856}

src/main/resources/db/migration/V7__refunds.sqlnew file · +12 −0

Old lineNew lineCode
@@ -0,0 +1,12 @@
1CREATE TABLE refund (
2 id UUID PRIMARY KEY,
3 payment_id UUID NOT NULL REFERENCES payment (id),
4 amount DOUBLE PRECISION NOT NULL,
5 reason TEXT,
6 status VARCHAR(16) NOT NULL,
7 created_at TIMESTAMP NOT NULL DEFAULT now()
8);
9
10CREATE INDEX refund_payment_id ON refund (payment_id);
11
12ALTER TABLE payment ADD COLUMN refunded_amount NUMERIC(12, 2) NOT NULL;

issue (blocking)This migration fails on any database that has payments.

A NOT NULL column without a default cannot be added to a table that has rows, so the deploy stops here in production and nowhere else. Add it with DEFAULT 0, which PostgreSQL 11 and later apply without rewriting the table, or add it nullable, backfill it, then set NOT NULL.

src/test/java/com/example/payments/refund/RefundServiceTest.javanew file · +28 −0

Old lineNew lineCode
@@ -0,0 +1,28 @@
1package com.example.payments.refund;
2
3import org.junit.jupiter.api.Test;
4
5import static org.assertj.core.api.Assertions.assertThatThrownBy;
6
7class RefundServiceTest {
8
9 private final Fixtures fixtures = new Fixtures();
10 private final RefundService service = fixtures.refundService();
11
12 @Test
13 void refusesARefundLargerThanWhatIsLeftOfThePayment() {

praiseThese tests read like the refund rules.

Each name states a rule, each test checks one behaviour, and the second refund in the first test is exactly the case that matters. A test with two refunds at the same moment would pin down the race in RefundService.

14 var payment = fixtures.capturedPayment(100.00);
15 service.refund(payment.id(), 60.00, "damaged");
16
17 assertThatThrownBy(() -> service.refund(payment.id(), 50.00, "damaged"))
18 .isInstanceOf(RefundExceedsPaymentException.class);
19 }
20
21 @Test
22 void refusesARefundAfterTheWindowHasClosed() {
23 var payment = fixtures.paymentCapturedDaysAgo(31);
24
25 assertThatThrownBy(() -> service.refund(payment.id(), 10.00, "late"))
26 .isInstanceOf(RefundWindowClosedException.class);
27 }
28}

Request changes

The shape is right: refunds as their own resource, a reconciliation job, tests that read like the rules. Before this goes in, money needs an exact type, the refund needs an idempotency key at both ends, the refundable amount needs to be checked and updated in one step, and the migration has to run on a database with payments in it. Happy to pair on the transaction boundary.

7 blocking issues, 3 more issues, 2 suggestions, 1 nitpick, 1 question, 1 praise.

How I review

I read for what costs a customer or a night first: money, concurrency, retries, data that must not leak, migrations. Style comes last, and a linter is better at it than I am.

Every comment carries a label, so that its weight is never a guess: a blocking issue stops the merge, a suggestion or a nitpick does not, a question asks before assuming, and praise names what to keep. Each one says why, and what to do instead.

I review the tests as code: they say what the author believes the rules are. The decisions behind this site are written up the same way, as decision records.