Earn loyalty points on paid orders
Java · Spring Boot · beginner · modification
Customers now earn loyalty points when they pay. A new `LoyaltyPoints` component turns an order total into points at the 5% earn rate, and `OrderPaymentService.markPaid` records a `LoyaltyEntry` for the order in the same transaction as the status change, so the points and the PAID status commit together. Orders that come to zero points are skipped, and the existing `PENDING_PAYMENT` guard (under the order row lock) means a redelivered webhook does not award points twice.
Shop backend on Spring Boot 3.3 (Java 21), Spring Data JPA (Hibernate 6) on PostgreSQL. `Order.total` is a non-null `numeric(12,2)` (`BigDecimal` with scale 2) between 0.00 and 100000.00: what the customer paid, shipping and tax included, 0.00 for a fully discounted order. Every order has a non-null `customerId` (there is no guest checkout). Existing and unchanged: the `Order` entity with `markPaid(Instant)`, `OrderStatus`, `OrderRepository.findByIdForUpdate` (`@Lock(PESSIMISTIC_WRITE)`, a row write lock: `SELECT ... FOR NO KEY UPDATE` on PostgreSQL), `OrderNotFoundException` (the webhook handler answers it with 404), a `Clock` bean, and the `LoyaltyEntry` entity with `LoyaltyEntryRepository` on the `loyalty_entries` table, already used by the points-for-a-product-review feature (entries are insert-only; a customer's balance is the sum of their entries). The webhook handler verifies the provider's signature, then calls `markPaid`; it opens no transaction of its own.
Requirements
- When an order's payment is confirmed (`OrderPaymentService.markPaid`, which the existing payment webhook handler calls), the customer earns loyalty points worth 5% of the order total, rounded half up to a whole point: half a point or more rounds up, less rounds down. So a 10.00 order earns 1 point, a 30.00 order earns 2 and a 9.99 order earns 0.
- The points are recorded as one `LoyaltyEntry` for the order (customer id, points, description `Order #<id>`, order id), in the same transaction that marks the order paid. An order that earns 0 points gets no entry. The account page already shows the balance as the sum of a customer's entries.
- An order earns points once. The provider can deliver the same payment confirmation more than once; a repeat call for an order that is no longer `PENDING_PAYMENT` changes nothing. Points for refunded or cancelled orders are out of scope (the existing refund flow handles them).
Files touched
- src/main/java/com/acme/shop/loyalty/LoyaltyPoints.java
- src/main/java/com/acme/shop/orders/OrderPaymentService.java
--- src/main/java/com/acme/shop/loyalty/LoyaltyPoints.java
+package com.acme.shop.loyalty;
+
+import java.math.BigDecimal;
+import org.springframework.stereotype.Component;
+
+@Component
+public class LoyaltyPoints {
+
+ private static final BigDecimal EARN_RATE = new BigDecimal("0.05");
+
+ public int forOrderTotal(BigDecimal total) {
+ return total.multiply(EARN_RATE).intValue();
+ }
+}
+
--- src/main/java/com/acme/shop/orders/OrderPaymentService.java
package com.acme.shop.orders;
+import com.acme.shop.loyalty.LoyaltyEntry;
+import com.acme.shop.loyalty.LoyaltyEntryRepository;
+import com.acme.shop.loyalty.LoyaltyPoints;
import java.time.Clock;
import org.springframework.stereotype.Service;
private final OrderRepository orders;
+ private final LoyaltyEntryRepository loyaltyEntries;
+ private final LoyaltyPoints loyaltyPoints;
private final Clock clock;