비교 요약으로 돌아가기

Full output

GPT-5.6 Luna 원문 결과물

주문 금액 계산 엔진 보수 / OpenAI / GPT-5.6 Luna / 실행일 2026-07-28

gpt-5.6-luna-high

구현 요약

  • 주문 행별 상품 할인과 주문 단위 회원·쿠폰 할인을 요구된 순서로 계산하도록 OrderPricingService를 보수했습니다.
  • 회원 할인과 쿠폰 할인은 BigInteger 기반의 최대 잔여법으로 비례 배분하고, 소수 부분이 같은 경우 입력 순서를 유지합니다.
  • 상품 할인율은 버림 처리하며, 패션 상품의 수량 조건을 3개 이상으로 적용했습니다.
  • 쿠폰 임계값과 배송비를 모든 선행 할인 적용 후 상품 금액 기준으로 계산했습니다.
  • Math.multiplyExact, Math.addExact, Math.subtractExact와 JSON 파싱 오류 처리를 사용해 개별 곱셈·합산·최종 계산 오버플로를 계약된 400 응답으로 변환했습니다.
  • null, 빈 주문, 공백 상품 ID, 음수 단가, 잘못된 수량, null 필드, 정의되지 않은 enum을 INVALID_PRICING_REQUEST 오류로 처리했습니다.

변경 파일

  • OrderPricingService.java
  • InvalidPricingRequestException.java
  • PricingController.java
  • PricingExceptionHandler.java
  • OrderPricingControllerTest.java

공개 DTO·enum·JSON 필드명·컨트롤러 경로·Gradle 설정은 변경하지 않았습니다.

검증

실행 명령:

./gradlew test

결과:

BUILD SUCCESSFUL
tests=10, failures=0, errors=0

구현 테스트에서는 상품 할인 버림, 패션 수량 경계, 회원 할인 대상 제외, 최대 잔여법 배분, 쿠폰 임계값·상한, 할인 후 배송비, 원격 배송비, 무료 배송 경계, 입력 오류와 곱셈·합산 오버플로를 검증했습니다.

트레이드오프

금액 비례 계산의 중간 곱셈이 long을 넘을 수 있으므로 표준 라이브러리의 BigInteger를 사용했습니다. 금액 도메인에 맞는 정확성을 확보하는 대신 단순한 long 연산보다 구현과 계산 비용이 조금 증가합니다.

실제 변경 코드

아래는 보존된 격리 작업 사본을 원본 하네스와 대조해 확인한 변경 파일의 전체 내용입니다.

src/main/java/com/samebrief/pricing/api/PricingController.java

package com.samebrief.pricing.api;

import com.samebrief.pricing.service.OrderPricingService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/orders")
public class PricingController {

    private final OrderPricingService orderPricingService;

    public PricingController(OrderPricingService orderPricingService) {
        this.orderPricingService = orderPricingService;
    }

    @PostMapping("/price")
    public ResponseEntity<PricingResponse> price(@RequestBody PricingRequest request) {
        return ResponseEntity.ok(orderPricingService.price(request));
    }
}

src/main/java/com/samebrief/pricing/api/PricingExceptionHandler.java

package com.samebrief.pricing.api;

import com.samebrief.pricing.service.InvalidPricingRequestException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class PricingExceptionHandler {

    private static final String ERROR_CODE = "INVALID_PRICING_REQUEST";
    private static final String ERROR_MESSAGE = "주문 정보를 확인해 주세요.";

    @ExceptionHandler({InvalidPricingRequestException.class, HttpMessageNotReadableException.class})
    public ResponseEntity<PricingErrorResponse> handleInvalidPricingRequest(Exception exception) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new PricingErrorResponse(ERROR_CODE, ERROR_MESSAGE));
    }
}

src/main/java/com/samebrief/pricing/service/InvalidPricingRequestException.java

package com.samebrief.pricing.service;

public class InvalidPricingRequestException extends RuntimeException {

    public InvalidPricingRequestException() {
        super("Invalid pricing request");
    }
}

src/main/java/com/samebrief/pricing/service/OrderPricingService.java

package com.samebrief.pricing.service;

import com.samebrief.pricing.api.PricingRequest;
import com.samebrief.pricing.api.PricingResponse;
import com.samebrief.pricing.domain.Category;
import com.samebrief.pricing.domain.CouponType;
import com.samebrief.pricing.domain.MemberLevel;
import com.samebrief.pricing.domain.Region;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class OrderPricingService {

    private static final long FREE_SHIPPING_THRESHOLD = 50_000L;
    private static final long BASE_SHIPPING_FEE = 3_000L;
    private static final long REMOTE_EXTRA_SHIPPING_FEE = 5_000L;
    private static final long FIXED_COUPON_THRESHOLD = 30_000L;
    private static final long FIXED_COUPON_AMOUNT = 5_000L;
    private static final long RATE_COUPON_THRESHOLD = 50_000L;
    private static final long RATE_COUPON_MAXIMUM = 10_000L;
    private static final BigInteger ONE_HUNDRED = BigInteger.valueOf(100L);

    public PricingResponse price(PricingRequest request) {
        validateRequest(request);

        try {
            List<PricingRequest.Line> requestLines = request.getLines();
            int lineCount = requestLines.size();
            long[] grossAmounts = new long[lineCount];
            long[] itemDiscounts = new long[lineCount];
            long[] memberDiscounts = new long[lineCount];
            long[] couponDiscounts = new long[lineCount];

            long itemSubtotal = 0L;
            long itemDiscountTotal = 0L;
            for (int i = 0; i < lineCount; i++) {
                PricingRequest.Line line = requestLines.get(i);
                long grossAmount = Math.multiplyExact(line.getUnitPrice(), line.getQuantity());
                long itemDiscount = itemDiscountOf(line, grossAmount);

                grossAmounts[i] = grossAmount;
                itemDiscounts[i] = itemDiscount;
                itemSubtotal = Math.addExact(itemSubtotal, grossAmount);
                itemDiscountTotal = Math.addExact(itemDiscountTotal, itemDiscount);
            }

            long afterItemDiscount = Math.subtractExact(itemSubtotal, itemDiscountTotal);
            long[] memberTargets = new long[lineCount];
            long memberTargetTotal = 0L;
            if (request.getMemberLevel() == MemberLevel.GOLD) {
                for (int i = 0; i < lineCount; i++) {
                    if (requestLines.get(i).getCategory() != Category.BOOK) {
                        memberTargets[i] = Math.subtractExact(grossAmounts[i], itemDiscounts[i]);
                        memberTargetTotal = Math.addExact(memberTargetTotal, memberTargets[i]);
                    }
                }
            }

            long memberDiscountTotal = percentageFloor(memberTargetTotal, 5L);
            allocateProportionally(memberDiscountTotal, memberTargets, memberDiscounts);
            long afterMemberDiscount = Math.subtractExact(afterItemDiscount, memberDiscountTotal);

            long[] couponTargets = new long[lineCount];
            for (int i = 0; i < lineCount; i++) {
                couponTargets[i] = Math.subtractExact(
                        Math.subtractExact(grossAmounts[i], itemDiscounts[i]),
                        memberDiscounts[i]);
            }

            long couponDiscountTotal = couponDiscountOf(request.getCouponType(), afterMemberDiscount);
            allocateProportionally(couponDiscountTotal, couponTargets, couponDiscounts);

            List<PricingResponse.Line> responseLines = new ArrayList<>(lineCount);
            long merchandiseTotal = 0L;
            for (int i = 0; i < lineCount; i++) {
                PricingResponse.Line responseLine = new PricingResponse.Line();
                responseLine.setProductId(requestLines.get(i).getProductId());
                responseLine.setGrossAmount(grossAmounts[i]);
                responseLine.setItemDiscount(itemDiscounts[i]);
                responseLine.setMemberDiscount(memberDiscounts[i]);
                responseLine.setCouponDiscount(couponDiscounts[i]);

                long finalAmount = Math.subtractExact(
                        Math.subtractExact(
                                Math.subtractExact(grossAmounts[i], itemDiscounts[i]),
                                memberDiscounts[i]),
                        couponDiscounts[i]);
                responseLine.setFinalAmount(finalAmount);
                responseLines.add(responseLine);
                merchandiseTotal = Math.addExact(merchandiseTotal, finalAmount);
            }

            long baseShippingFee = merchandiseTotal >= FREE_SHIPPING_THRESHOLD
                    ? 0L
                    : BASE_SHIPPING_FEE;
            long remoteShippingFee = request.getRegion() == Region.REMOTE
                    ? REMOTE_EXTRA_SHIPPING_FEE
                    : 0L;
            long shippingFee = Math.addExact(baseShippingFee, remoteShippingFee);
            long grandTotal = Math.addExact(merchandiseTotal, shippingFee);

            PricingResponse response = new PricingResponse();
            response.setItemSubtotal(itemSubtotal);
            response.setItemDiscount(itemDiscountTotal);
            response.setMemberDiscount(memberDiscountTotal);
            response.setCouponDiscount(couponDiscountTotal);
            response.setMerchandiseTotal(merchandiseTotal);
            response.setShippingFee(shippingFee);
            response.setGrandTotal(grandTotal);
            response.setLines(responseLines);
            return response;
        } catch (ArithmeticException exception) {
            throw new InvalidPricingRequestException();
        }
    }

    private void validateRequest(PricingRequest request) {
        if (request == null
                || request.getLines() == null
                || request.getLines().isEmpty()
                || request.getMemberLevel() == null
                || request.getCouponType() == null
                || request.getRegion() == null) {
            throw new InvalidPricingRequestException();
        }

        for (PricingRequest.Line line : request.getLines()) {
            if (line == null
                    || line.getProductId() == null
                    || line.getProductId().trim().isEmpty()
                    || line.getCategory() == null
                    || line.getUnitPrice() == null
                    || line.getQuantity() == null
                    || line.getUnitPrice() < 0L
                    || line.getQuantity() < 1) {
                throw new InvalidPricingRequestException();
            }
        }
    }

    private long itemDiscountOf(PricingRequest.Line line, long grossAmount) {
        if (line.getCategory() == Category.BOOK) {
            return percentageFloor(grossAmount, 10L);
        }
        if (line.getCategory() == Category.FASHION && line.getQuantity() >= 3) {
            return percentageFloor(grossAmount, 15L);
        }
        return 0L;
    }

    private long couponDiscountOf(CouponType couponType, long amountBeforeCoupon) {
        if (amountBeforeCoupon == 0L) {
            return 0L;
        }
        if (couponType == CouponType.FIXED_5000 && amountBeforeCoupon >= FIXED_COUPON_THRESHOLD) {
            return Math.min(FIXED_COUPON_AMOUNT, amountBeforeCoupon);
        }
        if (couponType == CouponType.RATE_10 && amountBeforeCoupon >= RATE_COUPON_THRESHOLD) {
            return Math.min(RATE_COUPON_MAXIMUM, percentageFloor(amountBeforeCoupon, 10L));
        }
        return 0L;
    }

    private long percentageFloor(long amount, long percentage) {
        return BigInteger.valueOf(amount)
                .multiply(BigInteger.valueOf(percentage))
                .divide(ONE_HUNDRED)
                .longValueExact();
    }

    private void allocateProportionally(long totalDiscount, long[] targets, long[] allocations) {
        if (totalDiscount == 0L) {
            return;
        }

        long targetTotal = 0L;
        for (long target : targets) {
            targetTotal = Math.addExact(targetTotal, target);
        }
        if (targetTotal == 0L) {
            return;
        }

        BigInteger total = BigInteger.valueOf(totalDiscount);
        BigInteger denominator = BigInteger.valueOf(targetTotal);
        List<Allocation> remainders = new ArrayList<>(targets.length);
        long floorSum = 0L;

        for (int i = 0; i < targets.length; i++) {
            BigInteger[] quotientAndRemainder = total
                    .multiply(BigInteger.valueOf(targets[i]))
                    .divideAndRemainder(denominator);
            long floor = quotientAndRemainder[0].longValueExact();
            allocations[i] = floor;
            floorSum = Math.addExact(floorSum, floor);
            remainders.add(new Allocation(i, quotientAndRemainder[1]));
        }

        long remaining = totalDiscount - floorSum;
        remainders.sort(Comparator
                .comparing(Allocation::remainder, Comparator.reverseOrder())
                .thenComparingInt(Allocation::index));
        for (Allocation allocation : remainders) {
            if (remaining == 0L) {
                break;
            }
            int index = allocation.index();
            if (allocations[index] < targets[index]) {
                allocations[index]++;
                remaining--;
            }
        }
        if (remaining != 0L) {
            throw new ArithmeticException("Unable to allocate discount");
        }
    }

    private record Allocation(int index, BigInteger remainder) {
    }
}

src/test/java/com/samebrief/pricing/OrderPricingControllerTest.java

package com.samebrief.pricing;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

@SpringBootTest
@AutoConfigureMockMvc
class OrderPricingControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void appliesItemDiscountsAndShipping() throws Exception {
        JsonNode response = price("""
                {"lines":[
                  {"productId":"food-1","category":"FOOD","unitPrice":10000,"quantity":2},
                  {"productId":"book-1","category":"BOOK","unitPrice":15000,"quantity":1}
                ],"memberLevel":"REGULAR","couponType":"NONE","region":"LOCAL"}
                """);

        assertSummary(response, 35000, 1500, 0, 0, 33500, 3000, 36500);
        assertLines(response, List.of(
                line("food-1", 20000, 0, 0, 0, 20000),
                line("book-1", 15000, 1500, 0, 0, 13500)));
    }

    @Test
    void floorsFashionDiscountAndRequiresThreeItems() throws Exception {
        JsonNode discounted = price(request("fashion-1", "FASHION", 10001, 3, "REGULAR", "NONE", "LOCAL"));
        assertSummary(discounted, 30003, 4500, 0, 0, 25503, 3000, 28503);
        assertLines(discounted, List.of(line("fashion-1", 30003, 4500, 0, 0, 25503)));

        JsonNode notDiscounted = price(request("fashion-2", "FASHION", 10000, 2, "REGULAR", "NONE", "LOCAL"));
        assertSummary(notDiscounted, 20000, 0, 0, 0, 20000, 3000, 23000);
    }

    @Test
    void allocatesGoldDiscountByLargestRemainder() throws Exception {
        JsonNode response = price("""
                {"lines":[
                  {"productId":"food-1","category":"FOOD","unitPrice":10001,"quantity":1},
                  {"productId":"food-2","category":"FOOD","unitPrice":10010,"quantity":1}
                ],"memberLevel":"GOLD","couponType":"NONE","region":"LOCAL"}
                """);

        assertSummary(response, 20011, 0, 1000, 0, 19011, 3000, 22011);
        assertLines(response, List.of(
                line("food-1", 10001, 0, 500, 0, 9501),
                line("food-2", 10010, 0, 500, 0, 9510)));
    }

    @Test
    void allocatesFixedCouponInInputOrderWhenRemaindersTie() throws Exception {
        JsonNode response = price("""
                {"lines":[
                  {"productId":"food-1","category":"FOOD","unitPrice":10000,"quantity":1},
                  {"productId":"food-2","category":"FOOD","unitPrice":10000,"quantity":1},
                  {"productId":"food-3","category":"FOOD","unitPrice":10000,"quantity":1}
                ],"memberLevel":"REGULAR","couponType":"FIXED_5000","region":"LOCAL"}
                """);

        assertSummary(response, 30000, 0, 0, 5000, 25000, 3000, 28000);
        assertLines(response, List.of(
                line("food-1", 10000, 0, 0, 1667, 8333),
                line("food-2", 10000, 0, 0, 1667, 8333),
                line("food-3", 10000, 0, 0, 1666, 8334)));
    }

    @Test
    void appliesRateCouponAfterEarlierDiscountsWithCap() throws Exception {
        JsonNode response = price("""
                {"lines":[
                  {"productId":"food-1","category":"FOOD","unitPrice":40000,"quantity":1},
                  {"productId":"food-2","category":"FOOD","unitPrice":40000,"quantity":1},
                  {"productId":"food-3","category":"FOOD","unitPrice":40000,"quantity":1}
                ],"memberLevel":"REGULAR","couponType":"RATE_10","region":"LOCAL"}
                """);

        assertSummary(response, 120000, 0, 0, 10000, 110000, 0, 110000);
        assertLines(response, List.of(
                line("food-1", 40000, 0, 0, 3334, 36666),
                line("food-2", 40000, 0, 0, 3333, 36667),
                line("food-3", 40000, 0, 0, 3333, 36667)));
    }

    @Test
    void calculatesShippingFromMerchandiseTotalAndHandlesRemoteOrders() throws Exception {
        JsonNode couponReducesBelowFreeShipping = price(request("food-1", "FOOD", 52000, 1, "REGULAR", "FIXED_5000", "LOCAL"));
        assertSummary(couponReducesBelowFreeShipping, 52000, 0, 0, 5000, 47000, 3000, 50000);

        JsonNode remote = price(request("food-2", "FOOD", 60000, 1, "REGULAR", "NONE", "REMOTE"));
        assertSummary(remote, 60000, 0, 0, 0, 60000, 5000, 65000);

        JsonNode exactThreshold = price(request("food-3", "FOOD", 25000, 2, "REGULAR", "NONE", "LOCAL"));
        assertSummary(exactThreshold, 50000, 0, 0, 0, 50000, 0, 50000);
    }

    @Test
    void excludesBooksFromGoldDiscount() throws Exception {
        JsonNode mixed = price("""
                {"lines":[
                  {"productId":"book-1","category":"BOOK","unitPrice":10000,"quantity":1},
                  {"productId":"food-1","category":"FOOD","unitPrice":20000,"quantity":1}
                ],"memberLevel":"GOLD","couponType":"NONE","region":"LOCAL"}
                """);
        assertSummary(mixed, 30000, 1000, 1000, 0, 28000, 3000, 31000);
        assertLines(mixed, List.of(
                line("book-1", 10000, 1000, 0, 0, 9000),
                line("food-1", 20000, 0, 1000, 0, 19000)));

        JsonNode onlyBook = price(request("book-2", "BOOK", 10000, 1, "GOLD", "NONE", "LOCAL"));
        assertSummary(onlyBook, 10000, 1000, 0, 0, 9000, 3000, 12000);
        assertLines(onlyBook, List.of(line("book-2", 10000, 1000, 0, 0, 9000)));
    }

    @Test
    void appliesCouponThresholdToAmountBeforeCoupon() throws Exception {
        JsonNode fixedBelow = price(request("food-1", "FOOD", 29999, 1, "REGULAR", "FIXED_5000", "LOCAL"));
        assertSummary(fixedBelow, 29999, 0, 0, 0, 29999, 3000, 32999);

        JsonNode rateBelow = price(request("food-2", "FOOD", 49999, 1, "REGULAR", "RATE_10", "LOCAL"));
        assertSummary(rateBelow, 49999, 0, 0, 0, 49999, 3000, 52999);
    }

    @Test
    void rejectsInvalidRequestsWithContractedError() throws Exception {
        List<String> invalidRequests = List.of(
                "{\"lines\":[],\"memberLevel\":\"REGULAR\",\"couponType\":\"NONE\",\"region\":\"LOCAL\"}",
                "{\"lines\":null,\"memberLevel\":\"REGULAR\",\"couponType\":\"NONE\",\"region\":\"LOCAL\"}",
                request(" ", "FOOD", 1000, 1, "REGULAR", "NONE", "LOCAL"),
                request("food-1", "FOOD", -1, 1, "REGULAR", "NONE", "LOCAL"),
                request("food-1", "FOOD", 1000, 0, "REGULAR", "NONE", "LOCAL"),
                "{\"lines\":[{\"productId\":\"food-1\",\"category\":\"FOOD\",\"unitPrice\":1000,\"quantity\":1}],\"memberLevel\":\"REGULAR\",\"couponType\":\"NONE\",\"region\":null}",
                request("food-1", "FOOD", 1000, 1, "PLATINUM", "NONE", "LOCAL"),
                request("food-1", "FOOD", 9223372036854775807L, 2, "REGULAR", "NONE", "LOCAL"),
                """
                {"lines":[
                  {"productId":"food-1","category":"FOOD","unitPrice":9223372036854775807,"quantity":1},
                  {"productId":"food-2","category":"FOOD","unitPrice":9223372036854775807,"quantity":1}
                ],"memberLevel":"REGULAR","couponType":"NONE","region":"LOCAL"}
                """
        );

        for (String invalidRequest : invalidRequests) {
            mockMvc.perform(post("/api/v1/orders/price")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(invalidRequest))
                    .andExpect(status().isBadRequest())
                    .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                    .andExpect(result -> {
                        JsonNode error = objectMapper.readTree(result.getResponse().getContentAsString());
                        assertEquals("INVALID_PRICING_REQUEST", error.get("code").asText());
                        assertEquals("주문 정보를 확인해 주세요.", error.get("message").asText());
                    });
        }
    }

    private JsonNode price(String requestJson) throws Exception {
        MvcResult result = mockMvc.perform(post("/api/v1/orders/price")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(requestJson))
                .andExpect(status().isOk())
                .andReturn();
        return objectMapper.readTree(result.getResponse().getContentAsString());
    }

    private static String request(String productId, String category, long unitPrice, int quantity,
                                  String memberLevel, String couponType, String region) {
        return "{\"lines\":[{\"productId\":\"" + productId + "\",\"category\":\"" + category
                + "\",\"unitPrice\":" + unitPrice + ",\"quantity\":" + quantity
                + "}],\"memberLevel\":\"" + memberLevel + "\",\"couponType\":\"" + couponType
                + "\",\"region\":\"" + region + "\"}";
    }

    private static ExpectedLine line(String productId, long grossAmount, long itemDiscount,
                                     long memberDiscount, long couponDiscount, long finalAmount) {
        return new ExpectedLine(productId, grossAmount, itemDiscount, memberDiscount, couponDiscount, finalAmount);
    }

    private void assertSummary(JsonNode response, long itemSubtotal, long itemDiscount,
                               long memberDiscount, long couponDiscount, long merchandiseTotal,
                               long shippingFee, long grandTotal) {
        assertEquals(itemSubtotal, response.get("itemSubtotal").asLong());
        assertEquals(itemDiscount, response.get("itemDiscount").asLong());
        assertEquals(memberDiscount, response.get("memberDiscount").asLong());
        assertEquals(couponDiscount, response.get("couponDiscount").asLong());
        assertEquals(merchandiseTotal, response.get("merchandiseTotal").asLong());
        assertEquals(shippingFee, response.get("shippingFee").asLong());
        assertEquals(grandTotal, response.get("grandTotal").asLong());
    }

    private void assertLines(JsonNode response, List<ExpectedLine> expectedLines) {
        assertEquals(expectedLines.size(), response.get("lines").size());
        for (int i = 0; i < expectedLines.size(); i++) {
            ExpectedLine expected = expectedLines.get(i);
            JsonNode actual = response.get("lines").get(i);
            assertEquals(expected.productId(), actual.get("productId").asText());
            assertEquals(expected.grossAmount(), actual.get("grossAmount").asLong());
            assertEquals(expected.itemDiscount(), actual.get("itemDiscount").asLong());
            assertEquals(expected.memberDiscount(), actual.get("memberDiscount").asLong());
            assertEquals(expected.couponDiscount(), actual.get("couponDiscount").asLong());
            assertEquals(expected.finalAmount(), actual.get("finalAmount").asLong());
        }
    }

    private record ExpectedLine(String productId, long grossAmount, long itemDiscount,
                                long memberDiscount, long couponDiscount, long finalAmount) {
    }
}