| 1 | package com.rbctcsworld.ecommerce.order; | |
| 2 | ||
| 3 | import com.rbctcsworld.ecommerce.audit.AuditEvent; | |
| 4 | import com.rbctcsworld.ecommerce.auth.AppUser; | |
| 5 | import com.rbctcsworld.ecommerce.auth.UserRepository; | |
| 6 | import com.rbctcsworld.ecommerce.cart.CartItem; | |
| 7 | import com.rbctcsworld.ecommerce.cart.CartItemRepository; | |
| 8 | import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException; | |
| 9 | import com.rbctcsworld.ecommerce.common.exception.NotFoundException; | |
| 10 | import com.rbctcsworld.ecommerce.common.exception.PaymentDeclinedException; | |
| 11 | import com.rbctcsworld.ecommerce.coupon.Coupon; | |
| 12 | import com.rbctcsworld.ecommerce.coupon.CouponService; | |
| 13 | import com.rbctcsworld.ecommerce.inventory.InventoryService; | |
| 14 | import com.rbctcsworld.ecommerce.order.OrderDtos.OrderLine; | |
| 15 | import com.rbctcsworld.ecommerce.order.OrderDtos.OrderResponse; | |
| 16 | import com.rbctcsworld.ecommerce.order.OrderDtos.QuoteResponse; | |
| 17 | import com.rbctcsworld.ecommerce.order.OrderDtos.TrackingResponse; | |
| 18 | import com.rbctcsworld.ecommerce.payment.PaymentDtos.PayRequest; | |
| 19 | import com.rbctcsworld.ecommerce.payment.PaymentDtos.PaySavedRequest; | |
| 20 | import com.rbctcsworld.ecommerce.payment.PaymentMethodService; | |
| 21 | import com.rbctcsworld.ecommerce.payment.PaymentService; | |
| 22 | import com.rbctcsworld.ecommerce.payment.PaymentTransaction; | |
| 23 | import com.rbctcsworld.ecommerce.pricing.PricingService; | |
| 24 | import com.rbctcsworld.ecommerce.pricing.PricingService.PriceBreakdown; | |
| 25 | import com.rbctcsworld.ecommerce.product.Product; | |
| 26 | import com.rbctcsworld.ecommerce.notification.OrderNotificationEvent; | |
| 27 | import org.springframework.context.ApplicationEventPublisher; | |
| 28 | import org.springframework.stereotype.Service; | |
| 29 | import org.springframework.transaction.annotation.Transactional; | |
| 30 | ||
| 31 | import java.math.BigDecimal; | |
| 32 | import java.security.SecureRandom; | |
| 33 | import java.time.LocalDate; | |
| 34 | import java.time.format.DateTimeFormatter; | |
| 35 | import java.util.Comparator; | |
| 36 | import java.util.List; | |
| 37 | ||
| 38 | /** | |
| 39 | * Place order = ONE database transaction: | |
| 40 | * 1. read the customer's cart (400 if empty) and check that there is a delivery address (400 if not) | |
| 41 | * 2. validate the optional coupon and calculate discount, shipping, tax, total | |
| 42 | * 3. for every line (sorted by product id to avoid deadlocks) reserve stock atomically (409 if not possible) | |
| 43 | * 4. save order + items + stock movements + coupon redemption | |
| 44 | * 5. empty the cart | |
| 45 | * If any step fails, everything is rolled back: no stock changes, no order, no coupon use, cart untouched. | |
| 46 | * | |
| 47 | * Pay = PLACED -> PAID with a successful charge (a declined card is recorded but the order stays PLACED). | |
| 48 | * Cancel = PLACED/PAID -> CANCELLED, stock released, and a refund if it was PAID. | |
| 49 | */ | |
| 50 | @Service | |
| 51 | @Transactional | |
| 52 | public class OrderService { | |
| 53 | ||
| 54 | private static final String ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I confusion | |
| 55 | private static final SecureRandom RANDOM = new SecureRandom(); | |
| 56 | ||
| 57 | private final OrderRepository orders; | |
| 58 | private final CartItemRepository cartItems; | |
| 59 | private final UserRepository users; | |
| 60 | private final InventoryService inventory; | |
| 61 | private final PricingService pricing; | |
| 62 | private final CouponService coupons; | |
| 63 | private final PaymentService payments; | |
| 64 | private final PaymentMethodService paymentMethods; | |
| 65 | private final ApplicationEventPublisher events; | |
| 66 | ||
| 67 | public OrderService(OrderRepository orders, CartItemRepository cartItems, UserRepository users, | |
| 68 | InventoryService inventory, PricingService pricing, CouponService coupons, | |
| 69 | PaymentService payments, PaymentMethodService paymentMethods, ApplicationEventPublisher events) { | |
| 70 | this.orders = orders; | |
| 71 | this.cartItems = cartItems; | |
| 72 | this.users = users; | |
| 73 | this.inventory = inventory; | |
| 74 | this.pricing = pricing; | |
| 75 | this.coupons = coupons; | |
| 76 | this.payments = payments; | |
| 77 | this.paymentMethods = paymentMethods; | |
| 78 | this.events = events; | |
| 79 | } | |
| 80 | ||
| 81 | // ---------------------------------------------------------------- checkout | |
| 82 | ||
| 83 | /** Price preview for the current cart (+ optional coupon). Read-only: nothing is reserved or redeemed. */ | |
| 84 | @Transactional(readOnly = true) | |
| 85 | public QuoteResponse quote(String email, String couponCode) { | |
| 86 | AppUser user = user(email); | |
| 87 | List<CartItem> lines = cartLines(user); | |
| 88 | List<OrderLine> orderLines = lines.stream().map(OrderService::toLine).toList(); | |
| 89 | BigDecimal subtotal = orderLines.stream().map(OrderLine::lineTotal).reduce(BigDecimal.ZERO, BigDecimal::add); | |
| 90 |
1
1. quote : removed conditional - replaced equality check with false → NO_COVERAGE |
Coupon coupon = hasText(couponCode) ? coupons.validate(couponCode, user.getId(), subtotal) : null; |
| 91 |
1
1. quote : removed conditional - replaced equality check with false → NO_COVERAGE |
PriceBreakdown p = pricing.price(subtotal, coupon == null ? BigDecimal.ZERO : coupon.discountFor(subtotal)); |
| 92 | int qty = orderLines.stream().mapToInt(OrderLine::quantity).sum(); | |
| 93 |
2
1. quote : removed conditional - replaced equality check with false → NO_COVERAGE 2. quote : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::quote → NO_COVERAGE |
return new QuoteResponse(orderLines, qty, p.subtotal(), coupon == null ? null : coupon.getCode(), |
| 94 | p.discount(), p.shippingFee(), p.tax(), p.total()); | |
| 95 | } | |
| 96 | ||
| 97 | public OrderResponse placeOrder(String email, String couponCode, ShippingAddress address) { | |
| 98 | AppUser user = user(email); | |
| 99 | List<CartItem> lines = cartLines(user); | |
| 100 |
1
1. placeOrder : removed conditional - replaced equality check with false → KILLED |
if (address == null) { |
| 101 | throw new BusinessRuleException("Shipping address is required"); | |
| 102 | } | |
| 103 | ||
| 104 | CustomerOrder order = new CustomerOrder(newOrderNumber(), user.getId()); | |
| 105 |
1
1. placeOrder : removed call to com/rbctcsworld/ecommerce/order/CustomerOrder::shipTo → KILLED |
order.shipTo(address.normalized()); |
| 106 | for (CartItem line : lines) { | |
| 107 | Product p = line.getProduct(); | |
| 108 |
1
1. placeOrder : removed call to com/rbctcsworld/ecommerce/order/CustomerOrder::addItem → KILLED |
order.addItem(new OrderItem(p.getId(), p.getSku(), p.displayName(), p.currentPrice(), line.getQuantity())); |
| 109 | } | |
| 110 | ||
| 111 |
1
1. placeOrder : removed conditional - replaced equality check with false → KILLED |
Coupon coupon = hasText(couponCode) ? coupons.validate(couponCode, user.getId(), order.getSubtotal()) : null; |
| 112 | PriceBreakdown price = pricing.price(order.getSubtotal(), | |
| 113 |
1
1. placeOrder : removed conditional - replaced equality check with false → KILLED |
coupon == null ? BigDecimal.ZERO : coupon.discountFor(order.getSubtotal())); |
| 114 |
1
1. placeOrder : removed call to com/rbctcsworld/ecommerce/order/CustomerOrder::applyPricing → KILLED |
order.applyPricing(price.discount(), price.shippingFee(), price.tax(), price.total(), |
| 115 |
1
1. placeOrder : removed conditional - replaced equality check with false → KILLED |
coupon == null ? null : coupon.getCode()); |
| 116 | orders.save(order); // gets the id used by stock movements / redemption | |
| 117 | ||
| 118 | for (CartItem line : lines) { | |
| 119 |
1
1. placeOrder : removed call to com/rbctcsworld/ecommerce/inventory/InventoryService::reserve → KILLED |
inventory.reserve(line.getProduct(), line.getQuantity(), order.getId(), address.state()); |
| 120 | } | |
| 121 |
1
1. placeOrder : removed conditional - replaced equality check with false → KILLED |
if (coupon != null) { |
| 122 |
1
1. placeOrder : removed call to com/rbctcsworld/ecommerce/coupon/CouponService::redeem → KILLED |
coupons.redeem(coupon, user.getId(), order.getId()); |
| 123 | } | |
| 124 | ||
| 125 |
1
1. placeOrder : removed call to com/rbctcsworld/ecommerce/cart/CartItemRepository::deleteByUserId → KILLED |
cartItems.deleteByUserId(user.getId()); |
| 126 |
1
1. placeOrder : removed call to org/springframework/context/ApplicationEventPublisher::publishEvent → KILLED |
events.publishEvent(OrderNotificationEvent.of(order.getId(), OrderNotificationEvent.Type.ORDER_PLACED)); |
| 127 |
1
1. placeOrder : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::placeOrder → KILLED |
return OrderResponse.from(order); |
| 128 | } | |
| 129 | ||
| 130 | /** | |
| 131 | * noRollbackFor: a declined card must still leave its FAILED payment_transactions row in the database. | |
| 132 | */ | |
| 133 | @Transactional(noRollbackFor = PaymentDeclinedException.class) | |
| 134 | public OrderResponse pay(String email, Long orderId, PayRequest card) { | |
| 135 | CustomerOrder order = owned(email, orderId); | |
| 136 |
1
1. pay : removed call to com/rbctcsworld/ecommerce/order/CustomerOrder::assertCanMoveTo → KILLED |
order.assertCanMoveTo(OrderStatus.PAID); // 409 before any money moves |
| 137 | payments.charge(order.getId(), order.getTotal(), card); // throws 400 / 402 on problems | |
| 138 | OrderResponse paid = markPaid(order); | |
| 139 |
1
1. pay : removed conditional - replaced equality check with false → SURVIVED |
if (Boolean.TRUE.equals(card.saveCard())) { |
| 140 | // "save this card": only after the money moved, and it can never fail the payment | |
| 141 |
1
1. pay : removed call to com/rbctcsworld/ecommerce/payment/PaymentMethodService::saveAfterPayment → NO_COVERAGE |
paymentMethods.saveAfterPayment(email, card.cardNumber(), card.expiryMonth(), card.expiryYear()); |
| 142 | } | |
| 143 |
1
1. pay : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::pay → KILLED |
return paid; |
| 144 | } | |
| 145 | ||
| 146 | /** Pays with a card from the wallet; the CVV is asked again and never kept. 404 for somebody else's card. */ | |
| 147 | @Transactional(noRollbackFor = PaymentDeclinedException.class) | |
| 148 | public OrderResponse paySaved(String email, Long orderId, PaySavedRequest request) { | |
| 149 | CustomerOrder order = owned(email, orderId); | |
| 150 |
1
1. paySaved : removed call to com/rbctcsworld/ecommerce/order/CustomerOrder::assertCanMoveTo → NO_COVERAGE |
order.assertCanMoveTo(OrderStatus.PAID); |
| 151 | payments.chargeSaved(order.getId(), order.getTotal(), paymentMethods.ownedBy(email, request.paymentMethodId())); | |
| 152 |
1
1. paySaved : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::paySaved → NO_COVERAGE |
return markPaid(order); |
| 153 | } | |
| 154 | ||
| 155 | private OrderResponse markPaid(CustomerOrder order) { | |
| 156 | String from = order.getStatus(); | |
| 157 |
1
1. markPaid : removed call to com/rbctcsworld/ecommerce/order/CustomerOrder::markPaid → KILLED |
order.markPaid(); |
| 158 |
1
1. markPaid : removed call to org/springframework/context/ApplicationEventPublisher::publishEvent → KILLED |
events.publishEvent(AuditEvent.orderStatus(order.getId(), from, order.getStatus())); |
| 159 |
1
1. markPaid : removed call to org/springframework/context/ApplicationEventPublisher::publishEvent → KILLED |
events.publishEvent(OrderNotificationEvent.of(order.getId(), OrderNotificationEvent.Type.PAYMENT_RECEIVED)); |
| 160 |
1
1. markPaid : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::markPaid → KILLED |
return OrderResponse.from(order); |
| 161 | } | |
| 162 | ||
| 163 | // ---------------------------------------------------------------- after checkout | |
| 164 | ||
| 165 | @Transactional(readOnly = true) | |
| 166 | public List<OrderResponse> myOrders(String email) { | |
| 167 |
1
1. myOrders : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/order/OrderService::myOrders → NO_COVERAGE |
return orders.findByUserIdOrderByIdDesc(user(email).getId()).stream().map(OrderResponse::from).toList(); |
| 168 | } | |
| 169 | ||
| 170 | @Transactional(readOnly = true) | |
| 171 | public OrderResponse myOrder(String email, Long orderId) { | |
| 172 |
1
1. myOrder : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::myOrder → NO_COVERAGE |
return OrderResponse.from(owned(email, orderId)); |
| 173 | } | |
| 174 | ||
| 175 | @Transactional(readOnly = true) | |
| 176 | public TrackingResponse myTracking(String email, Long orderId) { | |
| 177 |
1
1. myTracking : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::myTracking → NO_COVERAGE |
return TrackingResponse.from(owned(email, orderId)); |
| 178 | } | |
| 179 | ||
| 180 | @Transactional(readOnly = true) | |
| 181 | public List<PaymentTransaction> myPayments(String email, Long orderId) { | |
| 182 |
1
1. myPayments : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/order/OrderService::myPayments → NO_COVERAGE |
return payments.history(owned(email, orderId).getId()); |
| 183 | } | |
| 184 | ||
| 185 | public OrderResponse cancel(String email, Long orderId) { | |
| 186 | CustomerOrder order = owned(email, orderId); | |
| 187 |
1
1. cancel : removed call to com/rbctcsworld/ecommerce/order/CustomerOrder::assertCanMoveTo → KILLED |
order.assertCanMoveTo(OrderStatus.CANCELLED); // SHIPPED and later: 409 |
| 188 | order.getItems().stream() | |
| 189 | .sorted(Comparator.comparing(OrderItem::getProductId)) | |
| 190 |
2
1. cancel : removed call to java/util/stream/Stream::forEach → KILLED 2. lambda$cancel$0 : removed call to com/rbctcsworld/ecommerce/inventory/InventoryService::release → KILLED |
.forEach(i -> inventory.release(i.getProductId(), i.getQuantity(), order.getId())); |
| 191 | BigDecimal refunded = null; | |
| 192 |
1
1. cancel : removed conditional - replaced equality check with false → KILLED |
if (order.isPaid()) { |
| 193 | payments.refund(order.getId(), order.getTotal()); | |
| 194 | refunded = order.getTotal(); | |
| 195 | } | |
| 196 | String from = order.getStatus(); | |
| 197 |
1
1. cancel : removed call to com/rbctcsworld/ecommerce/order/CustomerOrder::cancel → KILLED |
order.cancel(); |
| 198 |
1
1. cancel : removed call to org/springframework/context/ApplicationEventPublisher::publishEvent → KILLED |
events.publishEvent(AuditEvent.orderStatus(order.getId(), from, order.getStatus())); |
| 199 |
1
1. cancel : removed call to org/springframework/context/ApplicationEventPublisher::publishEvent → KILLED |
events.publishEvent(new OrderNotificationEvent(order.getId(), OrderNotificationEvent.Type.ORDER_CANCELLED, refunded)); |
| 200 |
1
1. cancel : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::cancel → KILLED |
return OrderResponse.from(order); |
| 201 | } | |
| 202 | ||
| 203 | @Transactional(readOnly = true) | |
| 204 | public List<OrderResponse> allOrders() { | |
| 205 |
1
1. allOrders : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/order/OrderService::allOrders → NO_COVERAGE |
return orders.findAllByOrderByIdDesc().stream().map(OrderResponse::from).toList(); |
| 206 | } | |
| 207 | ||
| 208 | // ---------------------------------------------------------------- helpers | |
| 209 | ||
| 210 | private List<CartItem> cartLines(AppUser user) { | |
| 211 | List<CartItem> lines = cartItems.findByUserIdOrderByIdAsc(user.getId()).stream() | |
| 212 |
1
1. lambda$cartLines$1 : replaced Long return value with 0L for com/rbctcsworld/ecommerce/order/OrderService::lambda$cartLines$1 → KILLED |
.sorted(Comparator.comparing(ci -> ci.getProduct().getId())) |
| 213 | .toList(); | |
| 214 |
1
1. cartLines : removed conditional - replaced equality check with false → KILLED |
if (lines.isEmpty()) { |
| 215 | throw new BusinessRuleException("Cart is empty"); | |
| 216 | } | |
| 217 |
1
1. cartLines : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/order/OrderService::cartLines → KILLED |
return lines; |
| 218 | } | |
| 219 | ||
| 220 | private static OrderLine toLine(CartItem ci) { | |
| 221 | Product p = ci.getProduct(); | |
| 222 |
1
1. toLine : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::toLine → NO_COVERAGE |
return new OrderLine(p.getId(), p.getSku(), p.displayName(), p.currentPrice(), ci.getQuantity(), |
| 223 | p.currentPrice().multiply(BigDecimal.valueOf(ci.getQuantity()))); | |
| 224 | } | |
| 225 | ||
| 226 | /** Also used by ReturnService: the order if it belongs to this customer, otherwise 404 (IDOR). */ | |
| 227 | public CustomerOrder owned(String email, Long orderId) { | |
| 228 |
1
1. owned : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::owned → KILLED |
return orders.findByIdAndUserId(orderId, user(email).getId()) |
| 229 |
1
1. lambda$owned$2 : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::lambda$owned$2 → KILLED |
.orElseThrow(() -> new NotFoundException("Order not found: " + orderId)); |
| 230 | } | |
| 231 | ||
| 232 | private AppUser user(String email) { | |
| 233 |
2
1. lambda$user$3 : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::lambda$user$3 → NO_COVERAGE 2. user : replaced return value with null for com/rbctcsworld/ecommerce/order/OrderService::user → KILLED |
return users.findByEmail(email).orElseThrow(() -> new NotFoundException("User not found")); |
| 234 | } | |
| 235 | ||
| 236 | private static boolean hasText(String s) { | |
| 237 |
3
1. hasText : replaced boolean return with true for com/rbctcsworld/ecommerce/order/OrderService::hasText → KILLED 2. hasText : removed conditional - replaced equality check with false → KILLED 3. hasText : removed conditional - replaced equality check with false → KILLED |
return s != null && !s.isBlank(); |
| 238 | } | |
| 239 | ||
| 240 | /** e.g. ORD-20261002-7KQ2MZ */ | |
| 241 | static String newOrderNumber() { | |
| 242 | StringBuilder sb = new StringBuilder("ORD-") | |
| 243 | .append(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)).append('-'); | |
| 244 |
2
1. newOrderNumber : removed conditional - replaced comparison check with false → KILLED 2. newOrderNumber : changed conditional boundary → KILLED |
for (int i = 0; i < 6; i++) { |
| 245 | sb.append(ALPHABET.charAt(RANDOM.nextInt(ALPHABET.length()))); | |
| 246 | } | |
| 247 |
1
1. newOrderNumber : replaced return value with "" for com/rbctcsworld/ecommerce/order/OrderService::newOrderNumber → KILLED |
return sb.toString(); |
| 248 | } | |
| 249 | } | |
Mutations | ||
| 90 |
1.1 |
|
| 91 |
1.1 |
|
| 93 |
1.1 2.2 |
|
| 100 |
1.1 |
|
| 105 |
1.1 |
|
| 108 |
1.1 |
|
| 111 |
1.1 |
|
| 113 |
1.1 |
|
| 114 |
1.1 |
|
| 115 |
1.1 |
|
| 119 |
1.1 |
|
| 121 |
1.1 |
|
| 122 |
1.1 |
|
| 125 |
1.1 |
|
| 126 |
1.1 |
|
| 127 |
1.1 |
|
| 136 |
1.1 |
|
| 139 |
1.1 |
|
| 141 |
1.1 |
|
| 143 |
1.1 |
|
| 150 |
1.1 |
|
| 152 |
1.1 |
|
| 157 |
1.1 |
|
| 158 |
1.1 |
|
| 159 |
1.1 |
|
| 160 |
1.1 |
|
| 167 |
1.1 |
|
| 172 |
1.1 |
|
| 177 |
1.1 |
|
| 182 |
1.1 |
|
| 187 |
1.1 |
|
| 190 |
1.1 2.2 |
|
| 192 |
1.1 |
|
| 197 |
1.1 |
|
| 198 |
1.1 |
|
| 199 |
1.1 |
|
| 200 |
1.1 |
|
| 205 |
1.1 |
|
| 212 |
1.1 |
|
| 214 |
1.1 |
|
| 217 |
1.1 |
|
| 222 |
1.1 |
|
| 228 |
1.1 |
|
| 229 |
1.1 |
|
| 233 |
1.1 2.2 |
|
| 237 |
1.1 2.2 3.3 |
|
| 244 |
1.1 2.2 |
|
| 247 |
1.1 |