Screen orders with the fraud-scoring service at checkout
Java · Spring Boot · intermediate · modification
Checkout now asks the fraud-scoring service for a risk score before charging the card. `FraudScreening` posts the order id and amount through the shared `RestClient.Builder`, declines scores of 80 or more, and approves anything lower. If the service is unreachable or answers with an error or garbage, the order is approved and flagged for manual review so the team can look at it later, and a warning is logged. `CheckoutService` calls it between loading the order and charging the payment.
Shop backend on Spring Boot 3.3 (Java 21), Tomcat with the default 200 request threads, `spring-boot-starter-web` only (no Apache HttpClient or other HTTP client on the classpath). `RestClient.Builder` is the one auto-configured by Spring Boot. `fraud.base-url` and `fraud.api-key` are set in every environment. `Order` has `getId()` and `getTotalCents()`; `OrderRepository` has `findById`, `flagForReview(long id)` and `markPaid(long id, String paymentRef)`; `CheckoutResult` has `PAID`, `DECLINED` and `PAYMENT_FAILED`; `PaymentGateway.charge` throws the unchecked `PaymentException` when a payment is refused. The scoring service is a third party; the service is in production use and answers in well under a second when healthy. `CheckoutService.checkout` runs on the request thread of the checkout endpoint (existing).
Requirements
- At checkout, before the payment is charged, ask the fraud-scoring service for a score: `POST {fraud.base-url}/v1/score` with `{"orderId": ..., "amountCents": ...}` and a bearer `fraud.api-key`. It answers `{"score": n}` with n an integer from 0 to 100, a small JSON document it sends in one piece. A score of `fraud.decline-at-or-above` (default 80) or more declines the order; anything lower proceeds to payment.
- The score is advisory. When the service cannot give one, checkout must not fail and must not wait on it for long: connecting may take at most 2 seconds, and the response may take at most 2 seconds to start arriving. On a timeout, a connection error, a non-2xx status or an unreadable or missing score, approve the order and flag it for manual review (`OrderRepository.flagForReview`, before the charge, so the flag stays even if the payment is then refused), and log a warning.
- One attempt per checkout, no retries. `CheckoutService.checkout` is not transactional and nothing in it is changed except the new screening step; payment handling stays as it is.
- Declining an order does not charge it, and a declined order is never flagged.
Files touched
- src/main/java/com/acme/shop/checkout/CheckoutService.java
- src/main/java/com/acme/shop/checkout/FraudDecision.java
- src/main/java/com/acme/shop/checkout/FraudProperties.java
- src/main/java/com/acme/shop/checkout/FraudScreening.java
--- src/main/java/com/acme/shop/checkout/CheckoutService.java
private final OrderRepository orders;
private final PaymentGateway payments;
+ private final FraudScreening fraud;
- public CheckoutService(OrderRepository orders, PaymentGateway payments) {
+ public CheckoutService(OrderRepository orders, PaymentGateway payments, FraudScreening fraud) {
this.orders = orders;
this.payments = payments;
+ this.fraud = fraud;
}
public CheckoutResult checkout(long orderId) {
Order order = orders.findById(orderId).orElseThrow();
+ FraudDecision decision = fraud.screen(order.getId(), order.getTotalCents());
+ if (!decision.approved()) {
+ return CheckoutResult.DECLINED;
+ }
+ if (decision.manualReview()) {
+ orders.flagForReview(order.getId());
+ }
try {
String ref = payments.charge(order.getId(), order.getTotalCents());
--- src/main/java/com/acme/shop/checkout/FraudDecision.java
+package com.acme.shop.checkout;
+
+public record FraudDecision(boolean approved, boolean manualReview) {
+
+ public static FraudDecision approve() {
+ return new FraudDecision(true, false);