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 line | New line | Code |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| 1 | package com.example.payments.refund; | |
| 2 | ||
| 3 | import java.util.List; | |
| 4 | import java.util.UUID; | |
| 5 | ||
| 6 | import org.slf4j.Logger; | |
| 7 | import org.slf4j.LoggerFactory; | |
| 8 | import org.springframework.http.HttpStatus; | |
| 9 | import org.springframework.http.ResponseEntity; | |
| 10 | import org.springframework.web.bind.annotation.*; | |
| 11 | ||
| 12 | @RestController | |
| 13 | @RequestMapping("/payments/{paymentId}/refunds") | |
| 14 | class 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)); | |
| 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 | ||
| 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 line | New line | Code |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| 1 | package com.example.payments.refund; | |
| 2 | ||
| 3 | import java.util.UUID; | |
| 4 | ||
| 5 | /** What the support screen shows about a payment. */ | |
| 6 | public 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 line | New line | Code |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| 1 | package com.example.payments.refund; | |
| 2 | ||
| 3 | import java.time.LocalDateTime; | |
| 4 | import java.util.List; | |
| 5 | import java.util.UUID; | |
| 6 | ||
| 7 | import com.example.payments.Payment; | |
| 8 | import com.example.payments.PaymentRepository; | |
| 9 | import com.example.payments.provider.ProviderClient; | |
| 10 | import org.springframework.stereotype.Service; | |
| 11 | import org.springframework.transaction.annotation.Transactional; | |
| 12 | ||
| 13 | @Service | |
| 14 | public class RefundService { | |
| 15 | ||
| 16 | private static final int REFUND_WINDOW = 30; | |
nitpick (non-blocking)30 what?
| ||
| 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 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 | ||
| 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.
| ||
| 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 ( | ||
| 34 | if (alreadyRefunded + amount > payment.amount()) { | |
| 35 | throw new RefundExceedsPaymentException(paymentId, amount); | |
| 36 | } | |
| 37 | return issue(payment, amount, reason); | |
issue (blocking) Spring applies it through a proxy, and a call from inside the class never passes through the proxy. So | ||
| 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 | ||
| 44 | refund.markSucceeded(); | |
questionWhat happens if the provider refunds and this process dies before The customer has the money and we have a | ||
| 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 line | New line | Code |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| 1 | package com.example.payments.refund; | |
| 2 | ||
| 3 | import java.math.BigDecimal; | |
| 4 | ||
| 5 | import com.example.payments.Payment; | |
| 6 | import com.example.payments.PaymentRepository; | |
| 7 | import com.example.payments.provider.ProviderClient; | |
| 8 | import com.example.payments.provider.ProviderRefund; | |
| 9 | import org.springframework.scheduling.annotation.Scheduled; | |
| 10 | import org.springframework.stereotype.Component; | |
| 11 | ||
| 12 | /** Every night, checks the refunds the provider reports against ours. */ | |
| 13 | @Component | |
| 14 | class 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 | ||
| 30 | ProviderRefund reported = provider.findRefund(payment.providerReference(), refund.id()); | |
| 31 | if (!reported.amount().equals(BigDecimal.valueOf(refund.amount()))) { | |
issue
| ||
| 32 | refund.flagMismatch(reported.amount()); | |
| 33 | } | |
| 34 | } | |
| 35 | } | |
| 36 | } | |
src/main/java/com/example/payments/provider/ProviderClient.java+28 −0
| Old line | New line | Code |
|---|---|---|
| @@ -3,9 +3,12 @@ | ||
| 3 | 3 | import java.net.URI; |
| 4 | 4 | import java.time.Duration; |
| 5 | 5 | |
| 6 | import org.springframework.web.client.ResourceAccessException; | |
| 6 | 7 | import org.springframework.web.client.RestClient; |
| 7 | 8 | |
| 8 | 9 | public class ProviderClient { |
| 10 | ||
| 11 | private static final int MAX_ATTEMPTS = 3; | |
| 9 | 12 | |
| 10 | 13 | private final RestClient http; |
| 11 | 14 | |
| @@ -20,9 +23,34 @@ | ||
| 20 | 23 | .toBodilessEntity(); |
| 21 | 24 | } |
| 22 | 25 | |
| 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 | ||
| 23 | 43 | public ProviderRefund findRefund(String providerReference, java.util.UUID refundId) { |
| 24 | 44 | return http.get().uri("/payments/{reference}/refunds/{id}", providerReference, refundId) |
| 25 | 45 | .retrieve() |
| 26 | 46 | .body(ProviderRefund.class); |
| 27 | 47 | } |
| 48 | ||
| 49 | private static void sleep(Duration pause) { | |
| 50 | try { | |
| 51 | Thread.sleep(pause); | |
| 52 | } catch (InterruptedException e) { | |
| 53 | Thread.currentThread().interrupt(); | |
| 54 | } | |
| 55 | } | |
| 28 | 56 | } |
src/main/resources/db/migration/V7__refunds.sqlnew file · +12 −0
| Old line | New line | Code |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| 1 | CREATE 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 | ||
| 10 | CREATE INDEX refund_payment_id ON refund (payment_id); | |
| 11 | ||
| 12 | ALTER TABLE payment ADD COLUMN refunded_amount NUMERIC(12, 2) NOT NULL; | |
issue (blocking)This migration fails on any database that has payments. A | ||
src/test/java/com/example/payments/refund/RefundServiceTest.javanew file · +28 −0
| Old line | New line | Code |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| 1 | package com.example.payments.refund; | |
| 2 | ||
| 3 | import org.junit.jupiter.api.Test; | |
| 4 | ||
| 5 | import static org.assertj.core.api.Assertions.assertThatThrownBy; | |
| 6 | ||
| 7 | class 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 | ||
| 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.
issue (blocking)This writes full card numbers to the log.
PaymentViewis a record, so itstoStringprints every component,cardNumberincluded. 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 givePaymentViewthe last four digits instead of the number, so that nothing downstream can leak it either.