Spread order coupons across the lines
Java · Spring Boot · intermediate · modification
Partial refunds currently return the list price of a line even when the order had a coupon, so customers get back more than they paid. `applyCoupon` now also spreads the coupon over the lines in proportion to their totals, with the division done in `BigDecimal` at cent scale, and stores each line's part in `OrderLine.discount` for the refund service to use. The allocation is a small package-private helper. Checked on a few staging orders: a 50.00 coupon on a 30.00 + 20.00 order gives 30.00 and 20.00.
Shop backend on Spring Boot 3.3 (Java 21), JPA entities persisted on PostgreSQL. All money is `BigDecimal` with scale 2: `OrderLine.unitPrice` is between 0.01 and 100000.00, `quantity` is between 1 and 99, an order has 1 to 100 lines, and the coupon amount arrives with scale 2 (validated at the API). `OrderLine` (existing) has `total()` (`unitPrice * quantity`) and a `discount` column (`NUMERIC(12,2)`, default 0.00) with getter and setter. The existing `CheckoutService.applyCoupon` loads the order inside a `@Transactional` method and calls `order.applyCoupon(amount)`, so changes to the lines are flushed with the order. Lines are fixed once an order enters checkout (`CheckoutService` rejects line changes after that), and `applyCoupon` is only called during checkout.
Requirements
- When a fixed-amount coupon is applied to an order, store each line's part of it in `OrderLine.discount`, so a partial refund can return what the customer actually paid for that line.
- A line's part is proportional to its total (`unitPrice * quantity`): its exact share is `coupon * lineTotal / subtotal`. Each line discount is whole cents and less than one cent away from the exact share.
- The line discounts add up exactly to `couponAmount`. The refund-service change that reads them ships next and will refuse to refund any line of an order where they do not.
- Applying a coupon again replaces the previous line discounts. The existing validation of the coupon amount stays as it is. Orders that got a coupon before this release are backfilled by a one-off job that belongs to the refund-service change.
Files touched
- src/main/java/com/acme/shop/order/Order.java
- src/main/java/com/acme/shop/order/CouponAllocation.java
--- src/main/java/com/acme/shop/order/Order.java
}
this.couponAmount = amount;
+
+ List<BigDecimal> shares = CouponAllocation.allocate(
+ amount, lines.stream().map(OrderLine::total).toList());
+ for (int i = 0; i < lines.size(); i++) {
+ lines.get(i).setDiscount(shares.get(i));
+ }
}
}
--- src/main/java/com/acme/shop/order/CouponAllocation.java
+package com.acme.shop.order;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Splits an order-level coupon across the order's lines. */
+final class CouponAllocation {
+
+ private CouponAllocation() {
+ }
+
+ /**
+ * Returns each line's share of {@code coupon}, in the order of {@code lineTotals},
+ * proportional to the line's total.
+ */
+ static List<BigDecimal> allocate(BigDecimal coupon, List<BigDecimal> lineTotals) {