package cart // EvaluatedItem is one line in the per-line promotion breakdown exposed // alongside a cart. It mirrors the line that was evaluated (sku, // quantity, unit list price, optional OrgPrice for the strikethrough) and // adds the per-line discount the engine applied (orgPrice-based + // promotion-based, additive) plus the resulting effective per-unit and // per-line totals. // // Lives in pkg/cart rather than pkg/promotions so CartGrain can carry the // breakdown as a derived field (populated by the canonical promotion // pipeline that already manages TotalDiscount/AppliedPromotions), and so // every consumer of the grain — UCP cart response, legacy /cart HTTP // handler, cart-mcp, AMQP mutation feed — naturally sees the same shape // without ad-hoc wrappers at each call site. JSON tags are the canonical // EvaluatedItem shape that /promotions/evaluate-with-cart has returned // since the breakdown feature was introduced. // // Distributing the total cart discount down to the line level lets the // storefront render "Item X: 100 kr → 80 kr" without re-doing the math // on the client and lets verifiers confirm which promotion hit which // line (per-line DiscountIncVat is the engine's authoritative answer). type EvaluatedItem struct { Sku string `json:"sku"` Name string `json:"name,omitempty"` Quantity uint16 `json:"qty"` PriceIncVat int64 `json:"priceIncVat"` // per-unit list price OrgPriceIncVat int64 `json:"orgPriceIncVat,omitempty"` // per-unit pre-discount list price (for strikethrough) DiscountIncVat int64 `json:"discountIncVat"` // per-line TOTAL discount (orgPrice + promotion), incVat in öre EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` // per-unit, after discount, incVat in öre (rounded) EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` // per-line, after discount, incVat in öre (exact) } // MapEvaluatedItems walks the grain's items and produces the per-line // EvaluatedItem list. The grain's item.Discount carries the TOTAL per-line // discount (orgPrice + promotion — additive, set by UpdateTotals and the // effects' per-line distribution). The effective totals are derived as // (price * qty - discount), clamped to 0 (a promotion that over-discounted // a line shows as free, not negative). The effective per-unit price is the // per-line total divided by quantity, rounded to the nearest öre so the // per-unit display matches the per-line total when multiplied back. // // Returns nil for a nil grain (callers that always pass a non-nil grain // — the typical case — get a non-nil empty slice for an empty cart, which // is the desired behavior for JSON omitempty at the consumer). // // Exported so the canonical promotion pipeline (pkg/promotions.EvaluateAndApply) // and the manual /promotions/evaluate-with-cart preview handler can produce // the same per-line shape. func MapEvaluatedItems(g *CartGrain) []EvaluatedItem { if g == nil { return nil } out := make([]EvaluatedItem, 0, len(g.Items)) for _, it := range g.Items { if it == nil { continue } var name string if it.Meta != nil { name = it.Meta.Name } var orgPrice int64 if it.OrgPrice != nil { orgPrice = it.OrgPrice.IncVat.Int64() } var discount int64 if it.Discount != nil { discount = it.Discount.IncVat.Int64() } priceRow := it.Price.IncVat.Int64() * int64(it.Quantity) effTotal := priceRow - discount if effTotal < 0 { effTotal = 0 } var effUnit int64 if it.Quantity > 0 { // Nearest-öre rounding so per-unit * qty matches the // per-line total (within 1 öre either way). Half-up: // (effTotal + qty/2) / qty. effUnit = (effTotal + int64(it.Quantity)/2) / int64(it.Quantity) } out = append(out, EvaluatedItem{ Sku: it.Sku, Name: name, Quantity: it.Quantity, PriceIncVat: it.Price.IncVat.Int64(), OrgPriceIncVat: orgPrice, DiscountIncVat: discount, EffectivePriceIncVat: effUnit, EffectiveTotalIncVat: effTotal, }) } return out }