CouponService.java
package com.rbctcsworld.ecommerce.coupon;
import com.rbctcsworld.ecommerce.audit.AuditChange;
import com.rbctcsworld.ecommerce.audit.AuditEvent;
import com.rbctcsworld.ecommerce.common.exception.NotFoundException;
import org.springframework.context.ApplicationEventPublisher;
import java.util.Locale;
import java.util.Map;
import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
import com.rbctcsworld.ecommerce.common.exception.ConflictException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.List;
/**
* Coupon rules, checked in this order:
* 1. code exists and is active -> else 400 "Invalid coupon code"
* 2. now is inside valid_from .. valid_until -> else 400 "Coupon has expired" / "Coupon is not active yet"
* 3. subtotal >= min_order_amount -> else 400 "Minimum order amount ..."
* 4. this customer has not used it before -> else 409 "Coupon already used"
* 5. global usage limit not reached (atomic) -> else 409 "Coupon usage limit reached"
*/
@Service
@Transactional
public class CouponService {
private final CouponRepository coupons;
private final CouponRedemptionRepository redemptions;
private final Clock clock;
private final ApplicationEventPublisher events;
public CouponService(CouponRepository coupons, CouponRedemptionRepository redemptions, Clock clock,
ApplicationEventPublisher events) {
this.coupons = coupons;
this.redemptions = redemptions;
this.clock = clock;
this.events = events;
}
/** Validates rules 1-4 (and a non-atomic look at rule 5) and returns the coupon. Used by quote and order. */
@Transactional(readOnly = true)
public Coupon validate(String rawCode, Long userId, BigDecimal subtotal) {
String code = normalize(rawCode);
Coupon c = coupons.findByCode(code)
.filter(Coupon::isActive)
.orElseThrow(() -> new BusinessRuleException("Invalid coupon code: " + code));
LocalDateTime now = LocalDateTime.now(clock);
if (c.getValidFrom() != null && now.isBefore(c.getValidFrom())) {
throw new BusinessRuleException("Coupon is not active yet: " + code);
}
if (c.getValidUntil() != null && now.isAfter(c.getValidUntil())) {
throw new BusinessRuleException("Coupon has expired: " + code);
}
if (subtotal.compareTo(c.getMinOrderAmount()) < 0) {
throw new BusinessRuleException("Minimum order amount for " + code + " is " + c.getMinOrderAmount());
}
if (redemptions.existsByCouponIdAndUserId(c.getId(), userId)) {
throw new ConflictException("Coupon already used: " + code);
}
if (c.usageLimitReached()) {
throw new ConflictException("Coupon usage limit reached: " + code);
}
return c;
}
/** Records the redemption inside the order transaction; the atomic counter protects the global limit. */
public void redeem(Coupon coupon, Long userId, Long orderId) {
if (coupons.incrementUsage(coupon.getId()) == 0) {
throw new ConflictException("Coupon usage limit reached: " + coupon.getCode());
}
redemptions.save(new CouponRedemption(coupon.getId(), userId, orderId));
}
public Coupon create(CouponDtos.CreateCouponRequest r) {
String code = normalize(r.code());
if (coupons.existsByCode(code)) {
throw new ConflictException("Coupon code already exists: " + code);
}
checkTerms(r.type(), r.value(), r.validFrom(), r.validUntil());
Coupon c = coupons.save(new Coupon(code, r.type(), r.value(), r.minOrderAmount(), r.maxUses(),
r.validFrom(), r.validUntil()));
events.publishEvent(AuditEvent.of(AuditChange.diff(AuditChange.COUPON, c.getId(), "COUPON_CREATED", null,
audited(c))));
return c;
}
/** New terms for an existing coupon. maxUses may not go below the times it was already used. */
public Coupon update(long id, CouponDtos.UpdateCouponRequest r) {
Coupon c = coupons.findById(id).orElseThrow(() -> new NotFoundException("Coupon not found: " + id));
checkTerms(r.type(), r.value(), r.validFrom(), r.validUntil());
if (r.maxUses() != null && r.maxUses() < c.getUsedCount()) {
throw new BusinessRuleException("maxUses cannot be below the times already used (" + c.getUsedCount() + ")");
}
Map<String, Object> before = audited(c);
c.update(r.type(), r.value(), r.minOrderAmount(), r.maxUses(), r.validFrom(), r.validUntil());
events.publishEvent(AuditEvent.of(AuditChange.diff(AuditChange.COUPON, id, "COUPON_UPDATED", before,
audited(c))));
return c;
}
/** Switches a coupon off (customers get "Invalid coupon code") or on again. Same state: nothing recorded. */
public Coupon setActive(long id, boolean active) {
Coupon c = coupons.findById(id).orElseThrow(() -> new NotFoundException("Coupon not found: " + id));
if (c.isActive() != active) {
c.setActive(active);
events.publishEvent(AuditEvent.of(List.of(new AuditChange(AuditChange.COUPON, id,
active ? "COUPON_ENABLED" : "COUPON_DISABLED", "active", Boolean.toString(!active),
Boolean.toString(active)))));
}
return c;
}
/**
* Coupons for the admin list. q = part of the code; status = ACTIVE (usable now) | SCHEDULED (starts
* later) | EXPIRED | USED_UP | DISABLED | null (all).
*/
@Transactional(readOnly = true)
public List<Coupon> search(String q, String status) {
String part = q == null ? "" : normalize(q);
String wanted = status == null || status.isBlank() ? null : status.trim().toUpperCase(Locale.ROOT);
if (wanted != null && !STATES.contains(wanted)) {
throw new BusinessRuleException("status must be one of " + STATES);
}
return coupons.findAllByOrderByIdAsc().stream()
.filter(c -> c.getCode().contains(part))
.filter(c -> wanted == null || wanted.equals(state(c)))
.toList();
}
public static final List<String> STATES = List.of("ACTIVE", "SCHEDULED", "EXPIRED", "USED_UP", "DISABLED");
/** What a customer would experience with this coupon right now. */
public String state(Coupon c) {
LocalDateTime now = LocalDateTime.now(clock);
if (!c.isActive()) return "DISABLED";
if (c.getValidUntil() != null && now.isAfter(c.getValidUntil())) return "EXPIRED";
if (c.usageLimitReached()) return "USED_UP";
if (c.getValidFrom() != null && now.isBefore(c.getValidFrom())) return "SCHEDULED";
return "ACTIVE";
}
private static void checkTerms(String type, BigDecimal value, LocalDateTime from, LocalDateTime until) {
if (Coupon.PERCENT.equals(type) && value.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new BusinessRuleException("PERCENT coupon value must be between 0 and 100");
}
if (from != null && until != null && until.isBefore(from)) {
throw new BusinessRuleException("validUntil must be after validFrom");
}
}
static Map<String, Object> audited(Coupon c) {
return AuditChange.fields("code", c.getCode(), "type", c.getType(), "value", c.getDiscountValue(),
"minOrderAmount", c.getMinOrderAmount(), "maxUses", c.getMaxUses(), "validFrom", c.getValidFrom(),
"validUntil", c.getValidUntil(), "active", c.isActive());
}
@Transactional(readOnly = true)
public List<Coupon> all() {
return coupons.findAllByOrderByIdAsc();
}
public static String normalize(String code) {
return code == null ? "" : code.trim().toUpperCase();
}
}