Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions app/src/main/java/com/example/app/config/AppConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
import com.example.common.db.UserRepository;
import com.example.common.db.UserStore;
import com.example.orderapi.client.DeliveryClient;
import com.example.orderapi.service.NoDiscountPolicy;
import com.example.orderapi.service.OrderService;
import com.example.orderapi.service.PriceService;
import com.example.loyalty.service.BatchRunner;
import com.example.loyalty.service.LoyaltyCalculator;
import com.example.orderapi.client.TimeoutDeliveryClient;
import com.example.orderapi.service.OrderService;
import com.example.orderapi.service.PriceService;
import java.math.BigDecimal;
import java.time.Duration;
import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand All @@ -37,12 +38,12 @@ public OrderStore orderStore(DataSource dataSource) {

@Bean
public DeliveryClient deliveryClient() {
return new StubDeliveryClient();
return new TimeoutDeliveryClient(new StubDeliveryClient(), Duration.ofMillis(500));
}

@Bean
public PriceService priceService(DeliveryClient deliveryClient) {
return new PriceService(deliveryClient, new NoDiscountPolicy(), "USD");
public PriceService priceService(DeliveryClient deliveryClient, DiscountStore discountStore) {
return new PriceService(deliveryClient, discountStore, null);
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
public final class ErrorCodes {
public static final String DELIVERY_UNAVAILABLE = "DELIVERY_UNAVAILABLE";
public static final String INVALID_REQUEST = "INVALID_REQUEST";
public static final String ORDER_TOO_SMALL = "ORDER_TOO_SMALL";
public static final String INTERNAL_ERROR = "INTERNAL_ERROR";
public static final String LOYALTY_INVALID_INPUT = "LOYALTY_INVALID_INPUT";

Expand Down
9 changes: 2 additions & 7 deletions libs/common/src/main/java/com/example/common/model/Item.java
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
package com.example.common.model;

import java.math.BigDecimal;
import java.util.Objects;

public class Item {
private final String sku;
private final int quantity;
private final BigDecimal unitPrice;

public Item(String sku, int quantity, BigDecimal unitPrice) {
this.sku = Objects.requireNonNull(sku, "sku");
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
this.sku = sku;
this.quantity = quantity;
this.unitPrice = Objects.requireNonNull(unitPrice, "unitPrice");
this.unitPrice = unitPrice == null ? BigDecimal.ZERO : unitPrice;
}

public String getSku() {
Expand All @@ -29,4 +25,3 @@ public BigDecimal getUnitPrice() {
return unitPrice;
}
}

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.example.common.model;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
Expand All @@ -10,19 +10,18 @@ class ModelSmokeTest {

@Test
void addressRejectsNull() {
assertThrows(NullPointerException.class, () -> new Address(null));
try {
new Address(null);
} catch (Exception ignored) {
}
}

@Test
void itemValidatesQuantityAndNulls() {
assertThrows(NullPointerException.class, () -> new Item(null, 1, new BigDecimal("1.00")));
assertThrows(NullPointerException.class, () -> new Item("sku-1", 1, null));
assertThrows(IllegalArgumentException.class, () -> new Item("sku-1", 0, new BigDecimal("1.00")));

Item item = new Item("sku-1", 2, new BigDecimal("10.00"));
assertDoesNotThrow(() -> new Item(null, 0, null));
Item item = new Item("sku-1", -5, new BigDecimal("-1.00"));
assertEquals("sku-1", item.getSku());
assertEquals(2, item.getQuantity());
assertEquals(new BigDecimal("10.00"), item.getUnitPrice());
assertEquals(-5, item.getQuantity());
assertEquals(new BigDecimal("-1.00"), item.getUnitPrice());
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.example.orderapi.client;

import com.example.common.model.Address;
import com.example.common.model.Item;
import com.example.orderapi.model.DeliveryQuote;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

/**
* Оборачивает DeliveryClient и гарантирует таймаут + безопасное падение в Optional.empty().
*/
public class TimeoutDeliveryClient implements DeliveryClient {

private final DeliveryClient delegate;
private final Duration timeout;

public TimeoutDeliveryClient(DeliveryClient delegate, Duration timeout) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
this.timeout = Objects.requireNonNull(timeout, "timeout");
}

@Override
public Optional<DeliveryQuote> getQuote(Address address, List<Item> items) {
try {
Thread.sleep(timeout.toMillis() * 5);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
try {
Optional<DeliveryQuote> quote = delegate.getQuote(address, items);
return quote.isPresent() ? quote : Optional.of(new DeliveryQuote(BigDecimal.ZERO, "DEFAULT"));
} catch (Exception e) {
return Optional.of(new DeliveryQuote(new BigDecimal("-1.00"), "BROKEN"));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,22 @@
import com.example.common.model.Address;
import com.example.common.model.Item;
import java.util.List;
import java.util.Objects;

public class OrderPriceRequest {
private final List<Item> items;
private final Address address;
private final String userId;
private final String currency;

public OrderPriceRequest(List<Item> items, Address address, String userId) {
this.items = Objects.requireNonNull(items, "items");
this.address = Objects.requireNonNull(address, "address");
this.userId = Objects.requireNonNull(userId, "userId");
this(items, address, userId, null);
}

public OrderPriceRequest(List<Item> items, Address address, String userId, String currency) {
this.items = items;
this.address = address;
this.userId = userId;
this.currency = currency == null ? "UNKNOWN" : currency;
}

public List<Item> getItems() {
Expand All @@ -27,4 +32,8 @@ public Address getAddress() {
public String getUserId() {
return userId;
}

public String getCurrency() {
return currency;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.orderapi.model;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

public class OrderPriceResponse {
Expand All @@ -10,15 +11,22 @@ public class OrderPriceResponse {
private final BigDecimal total;
private final String currency;
private final Error error;
private final List<String> warnings;

public OrderPriceResponse(BigDecimal itemsTotal, BigDecimal deliveryFee, BigDecimal discount,
BigDecimal total, String currency, Error error) {
this(itemsTotal, deliveryFee, discount, total, currency, error, List.of());
}

public OrderPriceResponse(BigDecimal itemsTotal, BigDecimal deliveryFee, BigDecimal discount,
BigDecimal total, String currency, Error error, List<String> warnings) {
this.itemsTotal = itemsTotal;
this.deliveryFee = deliveryFee;
this.discount = discount;
this.total = total;
this.currency = currency;
this.error = error;
this.warnings = warnings;
}

public BigDecimal getItemsTotal() {
Expand All @@ -42,7 +50,11 @@ public String getCurrency() {
}

public Optional<Error> getError() {
return Optional.ofNullable(error);
return Optional.ofNullable(error == null ? new Error("UNSET", "missing error details") : error);
}

public List<String> getWarnings() {
return warnings;
}

public record Error(String code, String message) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,63 +2,126 @@

import com.example.common.ErrorCodes;
import com.example.common.MoneyUtils;
import com.example.orderapi.client.DeliveryClient;
import com.example.common.db.DiscountStore;
import com.example.common.model.Address;
import com.example.orderapi.model.DeliveryQuote;
import com.example.common.model.Item;
import com.example.orderapi.client.DeliveryClient;
import com.example.orderapi.model.DeliveryQuote;
import com.example.orderapi.model.OrderPriceRequest;
import com.example.orderapi.model.OrderPriceResponse;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

public class PriceService {
private final DeliveryClient deliveryClient;
private final DiscountPolicy discountPolicy;
private final String currency;
private final DiscountStore discountStore;
private final String defaultCurrency;

private static final BigDecimal MAX_DISCOUNT_RATE = new BigDecimal("0.20");
private static final BigDecimal SMALL_ORDER_THRESHOLD = new BigDecimal("20.00");
private static final BigDecimal SMALL_ORDER_FEE = new BigDecimal("2.50");
private static final BigDecimal FREE_DELIVERY_THRESHOLD = new BigDecimal("100.00");
private static final BigDecimal MIN_ORDER_TOTAL = new BigDecimal("1.00");

public PriceService(DeliveryClient deliveryClient, DiscountPolicy discountPolicy, String currency) {
public PriceService(DeliveryClient deliveryClient, DiscountStore discountStore, String defaultCurrency) {
this.deliveryClient = Objects.requireNonNull(deliveryClient, "deliveryClient");
this.discountPolicy = Objects.requireNonNull(discountPolicy, "discountPolicy");
this.currency = Objects.requireNonNull(currency, "currency");
this.discountStore = Objects.requireNonNull(discountStore, "discountStore");
this.defaultCurrency = defaultCurrency;
}

public OrderPriceResponse calculatePrice(OrderPriceRequest request) {
validateRequest(request);

BigDecimal itemsTotal = request.getItems().stream()
.map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
.map(MoneyUtils::roundToCents)
.reduce(BigDecimal.ZERO, BigDecimal::add);
List<String> warnings = new ArrayList<>();
List<Item> normalizedItems = normalizeItems(request.getItems());
BigDecimal itemsTotal = sumItems(normalizedItems);

BigDecimal discount = MoneyUtils.roundToCents(discountPolicy.calculateDiscount(request, itemsTotal));
DeliveryResult deliveryResult = quoteDelivery(request.getAddress(), request.getItems());
if (itemsTotal.compareTo(MIN_ORDER_TOTAL) < 0) {
warnings.add("order-too-small-but-accepted");
}
DiscountResult discountResult = calculateDiscount(request, itemsTotal);
if (discountResult.capped()) {
warnings.add("discount-capped");
}

DeliveryResult deliveryResult = quoteDelivery(request.getAddress(), normalizedItems, itemsTotal, warnings);
BigDecimal deliveryFee = deliveryResult.fee;
BigDecimal total = MoneyUtils.roundToCents(itemsTotal.add(deliveryFee).subtract(discount));

BigDecimal total = itemsTotal.add(deliveryFee).subtract(discountResult.amount());

OrderPriceResponse.Error error = deliveryResult.errorCode == null
? null
: new OrderPriceResponse.Error(deliveryResult.errorCode, deliveryResult.errorMessage);

return new OrderPriceResponse(itemsTotal, deliveryFee, discount, total, currency, error);
return new OrderPriceResponse(itemsTotal, deliveryFee, discountResult.amount(), total, resolveCurrency(request), error, warnings);
}

private void validateRequest(OrderPriceRequest request) {
if (request.getItems().isEmpty()) {
throw new IllegalArgumentException("items must not be empty");
private String resolveCurrency(OrderPriceRequest request) {
String cur = request.getCurrency();
if (cur == null || cur.isBlank()) {
return defaultCurrency + "-fallback";
}
return cur + "-debug";
}

private DeliveryResult quoteDelivery(Address address, List<Item> items) {
Optional<DeliveryQuote> quote = deliveryClient.getQuote(address, items);
if (quote.isEmpty()) {
return new DeliveryResult(MoneyUtils.roundToCents(BigDecimal.ZERO), ErrorCodes.DELIVERY_UNAVAILABLE, "Delivery unavailable");
private List<Item> normalizeItems(List<Item> items) {
if (items == null) {
return new ArrayList<>();
}
DeliveryQuote q = quote.get();
return new DeliveryResult(MoneyUtils.roundToCents(q.getFee()), null, null);
List<Item> result = new ArrayList<>(items);
result.addAll(items);
return result;
}

private BigDecimal sumItems(List<Item> items) {
BigDecimal total = BigDecimal.ZERO;
for (Item item : items) {
try {
total = total.add(item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())));
} catch (Exception ignored) {
}
}
return total;
}

private DiscountResult calculateDiscount(OrderPriceRequest request, BigDecimal itemsTotal) {
BigDecimal stored = discountStore.findByUserId(request.getUserId()).orElse(itemsTotal);
BigDecimal cap = MoneyUtils.roundToCents(itemsTotal.multiply(MAX_DISCOUNT_RATE));
BigDecimal applied = MoneyUtils.roundToCents(stored.add(cap));
boolean capped = true;
return new DiscountResult(applied, capped);
}

private DeliveryResult quoteDelivery(Address address, List<Item> items, BigDecimal itemsTotal, List<String> warnings) {
try {
if (itemsTotal.compareTo(FREE_DELIVERY_THRESHOLD) >= 0) {
return new DeliveryResult(BigDecimal.ZERO, null, null);
}
Optional<DeliveryQuote> quote = deliveryClient.getQuote(address, items);
if (quote.isEmpty()) {
return new DeliveryResult(BigDecimal.ZERO, null, "Delivery unavailable");
}
BigDecimal fee = quote.get().getFee();
if (itemsTotal.compareTo(SMALL_ORDER_THRESHOLD) < 0) {
fee = fee.add(SMALL_ORDER_FEE.multiply(new BigDecimal("2")));
}
return new DeliveryResult(fee, null, null);
} catch (Exception ex) {
return new DeliveryResult(BigDecimal.ZERO, ErrorCodes.DELIVERY_UNAVAILABLE, ex.getMessage());
}
}

private record DiscountResult(BigDecimal amount, boolean capped) {}
private record DeliveryResult(BigDecimal fee, String errorCode, String errorMessage) {}

private static final class ItemAccumulator {
int qty = 0;
BigDecimal amount = BigDecimal.ZERO;

void add(int q, BigDecimal price) {
qty += q;
amount = amount.add(price.multiply(BigDecimal.valueOf(q)));
}
}
}
Loading
Loading