From afee4651ae4d102461749c1015c0a26daecafad1 Mon Sep 17 00:00:00 2001 From: Nikita R Date: Sat, 17 Oct 2026 23:54:15 +0300 Subject: [PATCH 1/4] fix(cart): store whole-line basket prices and send them as end_amount Wolt stores a basket line's price as the whole-line total, count x (unit price + paid option values), and sums line prices without multiplying by count. Its checkout purchase plan takes end_amount as that same total and base_price as one unit without options. Captured from wolt.com: two 3.50 drinks are saved as price 700 and planned as base_price 350, end_amount 700. The CLI stored the unit price and sent count x price as end_amount. A basket built by `cart add --count N` therefore showed a total that ignored the count in the Wolt app, and `checkout` counted lines from the Wolt app twice (a 37.80 basket previewed at 75.60). - cart add (CLI and MCP) saves count x unit price, options included. - BuildBasketUpsertItem rescales a count-based line's price whenever a merge or partial removal changes its count; weighted lines keep the price their weight builders compute. - cart show sums line prices and reports the unit price as price / count. - checkout sends the line price as end_amount and the per-unit price without options as base_price. Weighted lines are unchanged. - A line price that does not divide by its count is rejected with a hint to re-add the item instead of being rounded. Fixes #59 --- internal/cli/cart_checkout_contract_test.go | 71 +++++++++++----- internal/cli/cart_state_contract_test.go | 4 +- internal/cli/commands_cart.go | 81 +++++++++---------- .../mcpserver/cart_checkout_contract_test.go | 6 +- internal/mcpserver/tools_cart.go | 8 +- .../mcpserver/tools_cart_availability_test.go | 76 +++++++++++++++++ .../checkoutpayload/checkoutpayload.go | 42 ++++++++-- .../checkoutpayload/checkoutpayload_test.go | 15 ++-- .../checkoutpayload/validation_test.go | 11 ++- internal/service/payloadutil/payloadutil.go | 37 ++++++++- .../service/payloadutil/payloadutil_test.go | 61 +++++++++++++- test/e2e/cli_cart_options_test.go | 70 ++++++++++++++-- 12 files changed, 385 insertions(+), 97 deletions(-) diff --git a/internal/cli/cart_checkout_contract_test.go b/internal/cli/cart_checkout_contract_test.go index 0d50209..7911662 100644 --- a/internal/cli/cart_checkout_contract_test.go +++ b/internal/cli/cart_checkout_contract_test.go @@ -77,6 +77,8 @@ func syntheticVenuePayload() map[string]any { } } +// configuredRegressionLine builds a basket line whose price is the whole-line +// total, as Wolt stores it. func configuredRegressionLine(itemID, name string, count, price int) map[string]any { return map[string]any{ "id": itemID, @@ -276,18 +278,47 @@ func TestCartAddSendsWeightedBasketPayload(t *testing.T) { } } -func TestBasketLineConfiguredTotalUsesWeightedPriceAsLineTotal(t *testing.T) { - total, err := basketLineConfiguredTotal(map[string]any{ - "count": 2, - "price": 1600, - "weighted_item_info": map[string]any{ - "count": 2, - "purchased_weight_in_grams": 400, - "weighted_item_input_type": "number_of_items", +func TestBasketItemsSubtotalSumsWholeLinePrices(t *testing.T) { + items := []any{ + map[string]any{"count": 2, "price": 700}, + map[string]any{ + "count": 2, + "price": 3080, + "options": []any{map[string]any{ + "id": "refills", + "values": []any{map[string]any{"id": "yogurt", "count": 1, "price": 100}}, + }}, }, + map[string]any{ + "count": 2, + "price": 1600, + "weighted_item_info": map[string]any{ + "count": 2, + "purchased_weight_in_grams": 400, + }, + }, + } + total, err := basketItemsSubtotal(items) + if err != nil || total != 5380 { + t.Fatalf("basket subtotal = %d, %v; want 5380", total, err) + } +} + +func TestBasketLineUnitPriceSplitsCountBasedLinesOnly(t *testing.T) { + unit, err := basketLineUnitPrice(map[string]any{"count": 2, "price": 3080}) + if err != nil || unit != 1540 { + t.Fatalf("count-based unit price = %d, %v; want 1540", unit, err) + } + unit, err = basketLineUnitPrice(map[string]any{ + "count": 2, + "price": 1601, + "weighted_item_info": map[string]any{"count": 2, "purchased_weight_in_grams": 400}, }) - if err != nil || total != 1600 { - t.Fatalf("weighted configured total = %d, %v; want 1600", total, err) + if err != nil || unit != 1601 { + t.Fatalf("weighted unit price = %d, %v; want the line price 1601", unit, err) + } + if _, err := basketLineUnitPrice(map[string]any{"count": 2, "price": 625}); err == nil { + t.Fatal("expected an error for a line price that is not a multiple of its count") } } @@ -396,7 +427,7 @@ func TestCartAddKeepsFallbackCountsConfiguredTotalAndNestedPrices(t *testing.T) "venue_slug": regressionVenueSlug, "currency": "EUR", "items": []any{ - configuredRegressionLine(regressionOtherID, "Existing configured item", 2, 300), + configuredRegressionLine(regressionOtherID, "Existing configured item", 2, 800), }, }, unrelatedRegressionBasket("000000000000000000000299"), @@ -502,8 +533,8 @@ func TestCartAddKeepsFallbackCountsConfiguredTotalAndNestedPrices(t *testing.T) asInt(added["price"]) != 700 { t.Fatalf("fresh line metadata = %#v", added) } - // The saved line price is the base price (500) plus the selected paid option - // values (2 x 100), so the cart and checkout preview agree. + // The saved line price is the whole-line total: one unit of the base price + // (500) plus the selected paid option values (2 x 100). option := asMap(asSlice(added["options"])[0]) value := asMap(asSlice(option["values"])[0]) if asInt(value["price"]) != 100 || asInt(value["count"]) != 2 { @@ -517,8 +548,8 @@ func TestCartAddKeepsFallbackCountsConfiguredTotalAndNestedPrices(t *testing.T) if asInt(data["total_items"]) != 6 { t.Fatalf("fallback total_items = %v, want 6", data["total_items"]) } - if got := asInt(asMap(data["total"])["amount"]); got != 1700 { - t.Fatalf("fallback total = %d, want configured target-basket total 1700 (fresh line 700 incl. options)", got) + if got := asInt(asMap(data["total"])["amount"]); got != 1500 { + t.Fatalf("fallback total = %d, want target-basket total 1500 (existing line 800 + fresh line 700 incl. options)", got) } } @@ -627,10 +658,10 @@ func TestCartRemoveKeepsFallbackCountsAndConfiguredTotalOnEmptyRefresh(t *testin "id": regressionItemID, "name": "Removed item", "count": 2, - "price": map[string]any{"amount": 500}, + "price": map[string]any{"amount": 1000}, "options": []any{}, }, - configuredRegressionLine(regressionOtherID, "Configured remainder", 2, 300), + configuredRegressionLine(regressionOtherID, "Configured remainder", 2, 800), }, }, unrelatedRegressionBasket("000000000000000000000299"), @@ -682,8 +713,12 @@ func TestCartRemoveKeepsFallbackCountsAndConfiguredTotalOnEmptyRefresh(t *testin if asInt(data["total_items"]) != 6 { t.Fatalf("fallback total_items = %v, want 6", data["total_items"]) } + remaining := asMap(asSlice(mutation["items"])[0]) + if asInt(remaining["count"]) != 1 || asInt(remaining["price"]) != 500 { + t.Fatalf("partially removed line = %#v, want count 1 at line price 500", remaining) + } if got := asInt(asMap(data["total"])["amount"]); got != 1300 { - t.Fatalf("fallback total = %d, want 1300", got) + t.Fatalf("fallback total = %d, want 1300 (500 + 800)", got) } } diff --git a/internal/cli/cart_state_contract_test.go b/internal/cli/cart_state_contract_test.go index 65b5cd5..7f3a728 100644 --- a/internal/cli/cart_state_contract_test.go +++ b/internal/cli/cart_state_contract_test.go @@ -43,7 +43,7 @@ func TestBuildCartStateNormalizesMissingCountAndHonorsExplicitZeroTotal(t *testi map[string]any{ "id": "item-1", "name": "Example item", - "price": 500, + "price": 600, "options": []any{ map[string]any{ "id": "extra", @@ -64,7 +64,7 @@ func TestBuildCartStateNormalizesMissingCountAndHonorsExplicitZeroTotal(t *testi t.Fatalf("missing basket count was not normalized: %#v", state) } if got := asInt(asMap(state["subtotal"])["amount"]); got != 600 { - t.Fatalf("configured subtotal = %d, want 600", got) + t.Fatalf("subtotal = %d, want the line price 600 (paid options included)", got) } total := asMap(state["total"]) if asInt(total["amount"]) != 0 || asString(total["formatted_amount"]) != "€0.00" { diff --git a/internal/cli/commands_cart.go b/internal/cli/commands_cart.go index 9afd6a5..8c8dde8 100644 --- a/internal/cli/commands_cart.go +++ b/internal/cli/commands_cart.go @@ -563,11 +563,24 @@ func newCartAddCommand(deps Dependencies) *cobra.Command { warnings, ) } - // Wolt stores the unit price of a basket line including its paid option - // values (the app does the same). Weighted lines are priced by weight. + // A basket line's price is the whole-line total: count x (unit price + // plus paid option values), as the Wolt app stores it. Weighted lines + // are priced by weight. linePrice := price if _, weighted := payloadutil.WeightConfigFromItem(itemPayload); !weighted { - linePrice += basketOptionsTotal(options) + var ok bool + linePrice, ok = payloadutil.CheckedMultiplyInt(price+basketOptionsTotal(options), count) + if !ok { + return emitError( + cmd, + format, + profile, + flags.Locale, + flags.Output, + "WOLT_INVALID_ARGUMENT", + "Item total exceeds the supported integer range; the item was not added.", + ) + } } newLineItem := map[string]any{ "id": itemID, @@ -1417,12 +1430,8 @@ func buildCartState(page map[string]any, venueID string) (map[string]any, []stri continue } count := basketLineCount(item) - price := payloadutil.MinorAmount(item["price"]) - lineAmount, totalErr := basketLineConfiguredTotal(item) - if totalErr != nil { - totalsValid = false - lineAmount = 0 - } else if nextSubtotal, ok := payloadutil.CheckedAddInt(subtotalAmount, lineAmount); ok { + lineAmount := payloadutil.MinorAmount(item["price"]) + if nextSubtotal, ok := payloadutil.CheckedAddInt(subtotalAmount, lineAmount); ok { subtotalAmount = nextSubtotal } else { totalsValid = false @@ -1433,6 +1442,13 @@ func buildCartState(page map[string]any, venueID string) (map[string]any, []stri if !totalsValid { lineTotalDisplay = nil } + price, unitErr := basketLineUnitPrice(item) + priceDisplay := any(formatMinorAmount(price, currency)) + if unitErr != nil { + price = 0 + priceDisplay = nil + warnings = append(warnings, fmt.Sprintf("basket item %q: %v", asString(item["id"]), unitErr)) + } lines = append(lines, map[string]any{ "line_id": asString(item["id"]), "item_id": asString(item["id"]), @@ -1441,7 +1457,7 @@ func buildCartState(page map[string]any, venueID string) (map[string]any, []stri "options": coalesceAny(item["options"], []any{}), "price": map[string]any{ "amount": price, - "formatted_amount": formatMinorAmount(price, currency), + "formatted_amount": priceDisplay, }, "line_total": map[string]any{ "amount": lineAmount, @@ -1536,15 +1552,13 @@ func basketLineCount(item map[string]any) int { return count } +// basketItemsSubtotal sums the line prices. Each line price is already the +// whole-line total with paid option values included. func basketItemsSubtotal(items []any) (int, error) { total := 0 for _, rawItem := range items { - lineTotal, err := basketLineConfiguredTotal(asMap(rawItem)) - if err != nil { - return 0, err - } var ok bool - total, ok = payloadutil.CheckedAddInt(total, lineTotal) + total, ok = payloadutil.CheckedAddInt(total, payloadutil.MinorAmount(asMap(rawItem)["price"])) if !ok { return 0, fmt.Errorf("basket subtotal exceeds the supported integer range") } @@ -1552,38 +1566,15 @@ func basketItemsSubtotal(items []any) (int, error) { return total, nil } -func basketLineConfiguredTotal(item map[string]any) (int, error) { - count := basketLineCount(item) +// basketLineUnitPrice is the price of one unit of a count-based line. A +// weighted line has no per-unit price in the basket, so its line price is +// returned as is. +func basketLineUnitPrice(item map[string]any) (int, error) { + price := payloadutil.MinorAmount(item["price"]) if asMap(item["weighted_item_info"]) != nil { - count = 1 - } - unitAmount := payloadutil.MinorAmount(item["price"]) - for _, rawOption := range asSlice(item["options"]) { - option := asMap(rawOption) - for _, rawValue := range asSlice(option["values"]) { - value := asMap(rawValue) - valueCount := asInt(value["count"]) - if valueCount <= 0 { - valueCount = 1 - } - optionAmount, ok := payloadutil.CheckedMultiplyInt( - payloadutil.MinorAmount(value["price"]), - valueCount, - ) - if !ok { - return 0, fmt.Errorf("basket option total exceeds the supported integer range") - } - unitAmount, ok = payloadutil.CheckedAddInt(unitAmount, optionAmount) - if !ok { - return 0, fmt.Errorf("basket item unit total exceeds the supported integer range") - } - } - } - lineTotal, ok := payloadutil.CheckedMultiplyInt(unitAmount, count) - if !ok { - return 0, fmt.Errorf("basket item total exceeds the supported integer range") + return price, nil } - return lineTotal, nil + return payloadutil.LineUnitPrice(price, basketLineCount(item)) } func buildCartTable(data map[string]any, includeDetails bool) string { diff --git a/internal/mcpserver/cart_checkout_contract_test.go b/internal/mcpserver/cart_checkout_contract_test.go index 7fab8a7..a9c7a5c 100644 --- a/internal/mcpserver/cart_checkout_contract_test.go +++ b/internal/mcpserver/cart_checkout_contract_test.go @@ -602,8 +602,10 @@ func TestCheckoutPayloadPreservesNestedBasketPricesThroughHandler(t *testing.T) } plan := asMap(captured["purchase_plan"]) item := asMap(asSlice(plan["menu_items"])[0]) - if asInt(item["base_price"]) != 650 { - t.Fatalf("base_price = %v, want 650", item["base_price"]) + // base_price is one unit without the paid option value; end_amount is the + // whole-line price with it. + if asInt(item["base_price"]) != 550 || asInt(item["end_amount"]) != 650 { + t.Fatalf("base_price, end_amount = %v, %v; want 550, 650", item["base_price"], item["end_amount"]) } option := asMap(asSlice(item["options"])[0]) value := asMap(asSlice(option["values"])[0]) diff --git a/internal/mcpserver/tools_cart.go b/internal/mcpserver/tools_cart.go index 85428e9..e56d4f9 100644 --- a/internal/mcpserver/tools_cart.go +++ b/internal/mcpserver/tools_cart.go @@ -280,11 +280,17 @@ func (tc *ToolCtx) handleCartAdd(ctx context.Context, _ *mcp.CallToolRequest, in } orderAvailability, availabilityWarnings := tc.cartVenueAvailability(ctx, ref, loc) + // A basket line's price is the whole-line total, as the Wolt app stores + // it. Weighted lines are repriced by weight when they are built. + linePrice, ok := payloadutil.CheckedMultiplyInt(price, count) + if !ok { + return nil, CartAddOutput{}, toolErrf("item was NOT added because its total exceeds the supported integer range") + } newLine := map[string]any{ "id": in.ItemID, "count": count, "name": name, - "price": price, + "price": linePrice, "options": []any{}, "substitution_settings": map[string]any{ "is_allowed": in.AllowSubstitutions, diff --git a/internal/mcpserver/tools_cart_availability_test.go b/internal/mcpserver/tools_cart_availability_test.go index 87dda85..0481fd4 100644 --- a/internal/mcpserver/tools_cart_availability_test.go +++ b/internal/mcpserver/tools_cart_availability_test.go @@ -563,3 +563,79 @@ func TestCartRemovePricesRemainingOptionValuesFromCatalog(t *testing.T) { t.Fatalf("remaining option = %#v, want grp-extras/val-feta priced 400", option) } } + +func TestCartAddAndRemoveKeepWholeLinePrices(t *testing.T) { + const itemID = "000000000000000000000141" + var captured map[string]any + basketItems := []any{} + wolt := &stubWolt{ + venueStaticFn: func(context.Context, string) (map[string]any, error) { + return scheduledVenueStaticPayload(), nil + }, + venueDynamicFn: func(context.Context, string, woltgateway.VenuePageDynamicOptions) (map[string]any, error) { + return scheduledVenueDynamicPayload(), nil + }, + assortmentItemsFn: func(context.Context, string, []string, woltgateway.AuthContext) (map[string]any, error) { + return map[string]any{"items": []any{ + map[string]any{ + "id": itemID, + "name": "Soft drink", + "price": 350, + "purchasable_balance": 8, + }, + }}, nil + }, + basketsPageFn: func(context.Context, domain.Location, woltgateway.AuthContext) (map[string]any, error) { + if len(basketItems) == 0 { + return map[string]any{"baskets": []any{}}, nil + } + return map[string]any{"baskets": []any{ + map[string]any{ + "id": "basket-1", + "venue_id": scheduledVenueID, + "venue_slug": scheduledVenueSlug, + "currency": scheduledVenueCurrency, + "items": basketItems, + }, + }}, nil + }, + addToBasketFn: func(_ context.Context, payload map[string]any, _ woltgateway.AuthContext) (map[string]any, error) { + captured = payload + basketItems = asSlice(payload["items"]) + return map[string]any{"id": "basket-1", "venue_id": scheduledVenueID}, nil + }, + } + tc := newToolCtx(Deps{ + Wolt: wolt, + Profiles: &stubProfiles{profile: domain.Profile{ + Name: "default", + WToken: "token", + }}, + }) + + if _, _, err := tc.handleCartAdd(context.Background(), nil, CartAddInput{ + LocationInput: LocationInput{Lat: 10.25, Lon: 20.5}, + Venue: scheduledVenueID, + ItemID: itemID, + Count: 2, + }); err != nil { + t.Fatalf("handleCartAdd: %v", err) + } + line := asMap(asSlice(captured["items"])[0]) + if asInt(line["count"]) != 2 || asInt(line["price"]) != 700 { + t.Fatalf("added line = %#v, want count 2 at line price 700", line) + } + + if _, _, err := tc.handleCartRemove(context.Background(), nil, CartRemoveInput{ + LocationInput: LocationInput{Lat: 10.25, Lon: 20.5}, + Venue: scheduledVenueSlug, + ItemID: itemID, + Count: 1, + }); err != nil { + t.Fatalf("handleCartRemove: %v", err) + } + line = asMap(asSlice(captured["items"])[0]) + if asInt(line["count"]) != 1 || asInt(line["price"]) != 350 { + t.Fatalf("remaining line = %#v, want count 1 at line price 350", line) + } +} diff --git a/internal/service/checkoutpayload/checkoutpayload.go b/internal/service/checkoutpayload/checkoutpayload.go index 5a523b4..5c423fa 100644 --- a/internal/service/checkoutpayload/checkoutpayload.go +++ b/internal/service/checkoutpayload/checkoutpayload.go @@ -295,11 +295,11 @@ func Build( categoryIDs := resolveCheckoutCategoryIDs(item, categoryID) valuePrices := payloadutil.OptionValuePriceIndex(detail) options := buildCheckoutOptions(replacement["options"], valuePrices) - endAmount, ok := payloadutil.CheckedMultiplyInt(count, price) - if !ok { - return nil, warnings, fmt.Errorf("basket item %q total exceeds the supported integer range", itemID) - } - basePrice := price + // The basket line price is already the whole-line total, and Wolt reads + // end_amount as that total without multiplying it by count. base_price + // is one unit without its paid option values, as the Wolt app sends it. + endAmount := price + basePrice := 0 isWeighted := false findCatalogItem := func() map[string]any { for _, payload := range catalogPayloads() { @@ -315,9 +315,10 @@ func Build( weightConfig, weighted = payloadutil.WeightConfigFromItem(findCatalogItem()) } if !weighted && isWeightedInBasket { - // end_amount would silently become count * whole-line price here, - // overcharging by the unit count. Refuse instead of previewing a - // total the user would not be billed. + // Without the catalog config the line would be split into count + // equal units, and its base_price would be a fraction of a weighed + // price rather than the price per kilogram. Refuse instead of + // previewing a total the user would not be billed. return nil, warnings, fmt.Errorf( "basket item %q is sold by weight but its catalog pricing is unavailable; cannot build an accurate checkout preview", itemID, @@ -332,6 +333,18 @@ func Build( basePrice = weightConfig.PricePerKilogram endAmount = values.Price isWeighted = true + } else { + unitPrice, unitErr := payloadutil.LineUnitPrice(price, count) + if unitErr != nil { + return nil, warnings, fmt.Errorf("basket item %q: %w", itemID, unitErr) + } + basePrice = unitPrice - checkoutOptionsUnitTotal(options) + if basePrice < 0 { + return nil, warnings, fmt.Errorf( + "basket item %q costs less than its selected options; cannot build an accurate checkout preview", + itemID, + ) + } } menuItems = append(menuItems, map[string]any{ @@ -632,6 +645,19 @@ func resolveCheckoutCategoryIDs(item map[string]any, categoryID string) []any { return categoryIDs } +// checkoutOptionsUnitTotal is the price the selected option values add to one +// unit of a line. +func checkoutOptionsUnitTotal(options []any) int { + total := 0 + for _, option := range options { + for _, rawValue := range payloadutil.Slice(payloadutil.Map(option)["values"]) { + value := payloadutil.Map(rawValue) + total += payloadutil.Int(value["price"]) * payloadutil.Int(value["count"]) + } + } + return total +} + func buildCheckoutOptions(raw any, valuePrices map[string]int) []any { options := make([]any, 0, len(payloadutil.Slice(raw))) for _, optionValue := range payloadutil.Slice(raw) { diff --git a/internal/service/checkoutpayload/checkoutpayload_test.go b/internal/service/checkoutpayload/checkoutpayload_test.go index c780b15..701ae36 100644 --- a/internal/service/checkoutpayload/checkoutpayload_test.go +++ b/internal/service/checkoutpayload/checkoutpayload_test.go @@ -123,9 +123,10 @@ func mapKeys(m map[string]any) []string { // rejects with `('body', 'purchase_plan'): Field required`. func TestBuildProducesPurchasePlanShape(t *testing.T) { basket := basketWithItem("€12.50", map[string]any{ - "id": "627cb2c7e2a6f0a1b2c3d4e5", - "count": 2, - "price": map[string]any{"amount": 625}, + "id": "627cb2c7e2a6f0a1b2c3d4e5", + "count": 2, + // Whole-line total: 2 x (625 base + 2 x 75 paid option values). + "price": map[string]any{"amount": 1550}, "category_id": "cat-burgers", "options": []any{ map[string]any{ @@ -180,7 +181,9 @@ func TestBuildProducesPurchasePlanShape(t *testing.T) { t.Errorf("is_priority_delivery = %v, want false", plan["is_priority_delivery"]) } - // menu_items mapping: count, base_price, derived end_amount, category_id. + // menu_items mapping, as the Wolt app sends it: base_price is one unit + // without options, end_amount is the whole-line total Wolt does not + // multiply by count again. item := firstMenuItem(t, plan) if item["base_price"] != 625 { t.Errorf("base_price = %v, want 625", item["base_price"]) @@ -188,8 +191,8 @@ func TestBuildProducesPurchasePlanShape(t *testing.T) { if item["count"] != 2 { t.Errorf("count = %v, want 2", item["count"]) } - if item["end_amount"] != 1250 { - t.Errorf("end_amount = %v, want 1250 (count*price)", item["end_amount"]) + if item["end_amount"] != 1550 { + t.Errorf("end_amount = %v, want 1550 (the basket line price)", item["end_amount"]) } if item["category_id"] != "cat-burgers" { t.Errorf("category_id = %v, want cat-burgers", item["category_id"]) diff --git a/internal/service/checkoutpayload/validation_test.go b/internal/service/checkoutpayload/validation_test.go index 3b89002..298c7bc 100644 --- a/internal/service/checkoutpayload/validation_test.go +++ b/internal/service/checkoutpayload/validation_test.go @@ -35,12 +35,11 @@ func TestCheckoutCategoryIndexUsesOnlyDeterministicCategoryContainers(t *testing } } -func TestBuildRejectsLineTotalOverflow(t *testing.T) { - maxInt := int(^uint(0) >> 1) - basket := basketWithItem("EUR 1", map[string]any{ +func TestBuildRejectsLinePriceNotDivisibleByCount(t *testing.T) { + basket := basketWithItem("EUR 6.25", map[string]any{ "id": "000000000000000000000503", "count": 2, - "price": maxInt, + "price": 625, "category_id": "category-a", }) _, _, err := Build( @@ -53,8 +52,8 @@ func TestBuildRejectsLineTotalOverflow(t *testing.T) { 0, "", ) - if err == nil || !strings.Contains(err.Error(), "integer range") { - t.Fatalf("overflow error = %v", err) + if err == nil || !strings.Contains(err.Error(), "not a whole multiple") { + t.Fatalf("indivisible line price error = %v", err) } } diff --git a/internal/service/payloadutil/payloadutil.go b/internal/service/payloadutil/payloadutil.go index 9421a2c..a648b81 100644 --- a/internal/service/payloadutil/payloadutil.go +++ b/internal/service/payloadutil/payloadutil.go @@ -264,6 +264,11 @@ func BasketItemRowsForMutation(basket map[string]any) ([]map[string]any, error) // BuildBasketUpsertItem converts one basket response line back into the compact // item shape accepted by the basket replacement endpoint. It rejects // incomplete state instead of silently dropping fields during reconstruction. +// +// A basket line's price is the whole-line total (count x unit price, paid +// option values included); Wolt sums line prices without multiplying by +// count. A count change therefore rescales the price of a count-based line. +// Weighted lines are priced by weight by their own builders and keep theirs. func BuildBasketUpsertItem(line map[string]any, count int) (map[string]any, error) { if line == nil { return nil, fmt.Errorf("basket item must be an object") @@ -275,13 +280,25 @@ func BuildBasketUpsertItem(line map[string]any, count int) (map[string]any, erro if count <= 0 { return nil, fmt.Errorf("basket item %q count must be greater than zero", itemID) } - if _, err := basketMutationCount(line, "basket item"); err != nil { + lineCount, err := basketMutationCount(line, "basket item") + if err != nil { return nil, fmt.Errorf("basket item %q: %w", itemID, err) } price, hasPrice := optionPrice(line) if !hasPrice || price <= 0 { return nil, fmt.Errorf("basket item %q has no positive current price", itemID) } + if count != lineCount && basketWeightedItemInfo(line) == nil { + unitPrice, unitErr := LineUnitPrice(price, lineCount) + if unitErr != nil { + return nil, fmt.Errorf("basket item %q: %w", itemID, unitErr) + } + var ok bool + price, ok = CheckedMultiplyInt(unitPrice, count) + if !ok { + return nil, fmt.Errorf("basket item %q total exceeds the supported integer range", itemID) + } + } rawOptions := []any{} if raw, exists := line["options"]; exists && raw != nil { @@ -376,6 +393,24 @@ func BuildBasketUpsertItem(line map[string]any, count int) (map[string]any, erro return item, nil } +// LineUnitPrice splits a count-based basket line's whole-line price into the +// price of one unit. A price that does not divide evenly is not a line total +// this CLI or the Wolt app would have stored, so it is rejected rather than +// rounded into a different total. +func LineUnitPrice(linePrice int, count int) (int, error) { + if count <= 0 { + return 0, fmt.Errorf("count must be greater than zero") + } + if linePrice%count != 0 { + return 0, fmt.Errorf( + "line price %d is not a whole multiple of its count %d; remove the item and add it again", + linePrice, + count, + ) + } + return linePrice / count, nil +} + // basketWeightedItemInfo returns the weighted selection carried by a basket // line. Basket responses report count and purchased weight but omit // weighted_item_input_type, so the input type is echoed only when the caller diff --git a/internal/service/payloadutil/payloadutil_test.go b/internal/service/payloadutil/payloadutil_test.go index c09a39a..5d6182e 100644 --- a/internal/service/payloadutil/payloadutil_test.go +++ b/internal/service/payloadutil/payloadutil_test.go @@ -2,6 +2,7 @@ package payloadutil import ( "reflect" + "strings" "testing" ) @@ -243,7 +244,7 @@ func TestBasketReplacementHelpersPreserveUnrelatedLines(t *testing.T) { option := Map(Slice(Map(merged[0])["options"])[0]) value := Map(Slice(option["values"])[0]) if String(Map(merged[0])["name"]) != "A refreshed" || - Int(Map(merged[0])["price"]) != 550 || + Int(Map(merged[0])["price"]) != 3*550 || String(value["id"]) != "large" || Int(value["price"]) != 125 || !Bool(Map(Map(merged[0])["substitution_settings"])["is_allowed"]) { @@ -255,6 +256,7 @@ func TestBasketReplacementHelpersPreserveUnrelatedLines(t *testing.T) { t.Fatalf("RemoveBasketItems(partial) error = %v", err) } if removed != 1 || len(remaining) != 2 || Int(Map(remaining[0])["count"]) != 1 || + Int(Map(remaining[0])["price"]) != 250 || String(Map(remaining[1])["id"]) != "item-b" { t.Fatalf("RemoveBasketItems(partial) = (%#v, %d)", remaining, removed) } @@ -625,3 +627,60 @@ func keys(m map[string]OptionGroupSpec) []string { } return out } + +func TestBuildBasketUpsertItemRescalesWholeLinePriceOnCountChange(t *testing.T) { + line := map[string]any{ + "id": "item-a", + "count": 2, + "price": 3080, + "options": []any{map[string]any{ + "id": "refills", + "values": []any{map[string]any{"id": "yogurt", "count": 1, "price": 100}}, + }}, + } + for _, tc := range []struct { + count int + want int + }{ + {count: 2, want: 3080}, + {count: 3, want: 4620}, + {count: 1, want: 1540}, + } { + item, err := BuildBasketUpsertItem(line, tc.count) + if err != nil { + t.Fatalf("BuildBasketUpsertItem(count %d) error = %v", tc.count, err) + } + if Int(item["count"]) != tc.count || Int(item["price"]) != tc.want { + t.Fatalf("BuildBasketUpsertItem(count %d) = %#v, want price %d", tc.count, item, tc.want) + } + value := Map(Slice(Map(Slice(item["options"])[0])["values"])[0]) + if Int(value["price"]) != 100 || Int(value["count"]) != 1 { + t.Fatalf("option value is per unit and must not scale: %#v", value) + } + } +} + +func TestBuildBasketUpsertItemRejectsPriceNotDivisibleByCount(t *testing.T) { + _, err := BuildBasketUpsertItem(map[string]any{"id": "item-a", "count": 2, "price": 625}, 3) + if err == nil || !strings.Contains(err.Error(), "not a whole multiple") { + t.Fatalf("BuildBasketUpsertItem() error = %v, want a not-a-multiple error", err) + } +} + +func TestBuildBasketUpsertItemKeepsWeightedLinePrice(t *testing.T) { + item, err := BuildBasketUpsertItem(map[string]any{ + "id": "potatoes", + "count": 3, + "price": 1161, + "weighted_item_info": map[string]any{ + "count": 3, + "purchased_weight_in_grams": 900, + }, + }, 1) + if err != nil { + t.Fatalf("BuildBasketUpsertItem() error = %v", err) + } + if Int(item["price"]) != 1161 { + t.Fatalf("weighted line price = %v, want 1161 unchanged", item["price"]) + } +} diff --git a/test/e2e/cli_cart_options_test.go b/test/e2e/cli_cart_options_test.go index 5c2af0b..c2bcce6 100644 --- a/test/e2e/cli_cart_options_test.go +++ b/test/e2e/cli_cart_options_test.go @@ -204,6 +204,59 @@ func TestCartAddWithLibraryOptionsSendsItemLevelGroupAndPricedLines(t *testing.T } } +// Covers issue #59: a line added with a count stores the whole-line price +// (count x (base + paid option values)), as the Wolt app does. Wolt sums line +// prices without multiplying them by count. +func TestCartAddWithCountStoresWholeLinePrice(t *testing.T) { + seenAddPayload := map[string]any{} + deps := optionsCLIDeps(&mockWolt{ + venuePageStaticFunc: func(context.Context, string) (map[string]any, error) { + return map[string]any{"venue": map[string]any{"id": optionsVenueID, "slug": optionsVenueSlug, "currency": "EUR"}}, nil + }, + venueItemPageFunc: func(context.Context, string, string) (map[string]any, error) { + return nil, errors.New("item endpoint unavailable") + }, + assortmentItemsFn: func(context.Context, string, []string, woltgateway.AuthContext) (map[string]any, error) { + return optionsVenueAssortment(), nil + }, + assortmentBySlugFunc: func(context.Context, string) (map[string]any, error) { + return optionsVenueAssortment(), nil + }, + basketsPageFunc: func(context.Context, domain.Location, woltgateway.AuthContext) (map[string]any, error) { + return map[string]any{"baskets": []any{}}, nil + }, + addToBasketFunc: func(_ context.Context, payload map[string]any, _ woltgateway.AuthContext) (map[string]any, error) { + seenAddPayload = payload + return map[string]any{"id": "basket-1", "venue_id": optionsVenueID}, nil + }, + basketCountFunc: func(context.Context, woltgateway.AuthContext) (map[string]any, error) { + return map[string]any{"count": 1}, nil + }, + }) + + exitCode, out := runCLIWithDeps(t, deps, + "cart", "add", optionsVenueSlug, "item-pizza", + "--count", "2", + "--option", "Extras=Feta", + "--wtoken", "token", "--format", "json", + ) + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d\noutput:\n%s", exitCode, out) + } + items := asSlicePayload(t, seenAddPayload["items"]) + if len(items) != 1 { + t.Fatalf("expected one line, got %#v", items) + } + added := asMapPayload(t, items[0]) + _, value := selectedOptionValue(t, added) + if asIntPayload(added["count"]) != 2 || asIntPayload(added["price"]) != 3780 { + t.Fatalf("line = %#v, want count 2 at 2 x (1490 + 400) = 3780", added) + } + if asIntPayload(value["price"]) != 400 || asIntPayload(value["count"]) != 1 { + t.Fatalf("option value must stay priced per unit, got %#v", value) + } +} + // `wolt cart remove` rebuilds every remaining line, so it has to restore the // option value prices the basket response leaves out. Prices come from the // venue assortment; the item endpoint is asked only for lines the assortment @@ -239,7 +292,7 @@ func TestCartRemoveRestoresOptionValuePricesFromCatalog(t *testing.T) { }, basketsPageFunc: func(context.Context, domain.Location, woltgateway.AuthContext) (map[string]any, error) { return optionsVenueBasketsPage( - unpricedOptionLine("item-pizza", "Super Pizza", 2, 1890, "item-grp-extras", "val-feta"), + unpricedOptionLine("item-pizza", "Super Pizza", 2, 3780, "item-grp-extras", "val-feta"), unpricedOptionLine("item-kebab", "Kebab", 1, 1470, "item-grp-dip", "val-garlic"), ), nil }, @@ -265,8 +318,9 @@ func TestCartRemoveRestoresOptionValuePricesFromCatalog(t *testing.T) { } pizza := asMapPayload(t, items[0]) groupID, value := selectedOptionValue(t, pizza) - if asIntPayload(pizza["count"]) != 1 || groupID != "item-grp-extras" || asIntPayload(value["price"]) != 400 { - t.Fatalf("pizza line must drop to 1 and keep feta at 400, got %#v", pizza) + if asIntPayload(pizza["count"]) != 1 || asIntPayload(pizza["price"]) != 1890 || + groupID != "item-grp-extras" || asIntPayload(value["price"]) != 400 { + t.Fatalf("pizza line must drop to 1 at line price 1890 and keep feta at 400, got %#v", pizza) } kebab := asMapPayload(t, items[1]) groupID, value = selectedOptionValue(t, kebab) @@ -288,7 +342,7 @@ func TestCartRemoveRefusesOptionValueWithoutAnyPrice(t *testing.T) { }, basketsPageFunc: func(context.Context, domain.Location, woltgateway.AuthContext) (map[string]any, error) { return optionsVenueBasketsPage( - unpricedOptionLine("item-pizza", "Super Pizza", 2, 1890, "item-grp-extras", "val-feta"), + unpricedOptionLine("item-pizza", "Super Pizza", 2, 3780, "item-grp-extras", "val-feta"), unpricedOptionLine("item-kebab", "Kebab", 1, 1350, "item-grp-dip", "val-retired"), ), nil }, @@ -317,7 +371,8 @@ func TestCheckoutPreviewPricesBasketOptionValuesFromCatalog(t *testing.T) { deps := optionsCLIDeps(&mockWolt{ basketsPageFunc: func(context.Context, domain.Location, woltgateway.AuthContext) (map[string]any, error) { return optionsVenueBasketsPage( - unpricedOptionLine("item-pizza", "Super Pizza", 2, 1890, "item-grp-extras", "val-feta"), + // Whole-line price: 2 x (1490 base + 400 feta). + unpricedOptionLine("item-pizza", "Super Pizza", 2, 3780, "item-grp-extras", "val-feta"), ), nil }, venueItemPageFunc: func(context.Context, string, string) (map[string]any, error) { @@ -350,8 +405,9 @@ func TestCheckoutPreviewPricesBasketOptionValuesFromCatalog(t *testing.T) { t.Fatalf("expected one menu item, got %#v", menuItems) } item := asMapPayload(t, menuItems[0]) - if asIntPayload(item["count"]) != 2 || asIntPayload(item["end_amount"]) != 3780 { - t.Fatalf("menu item = %#v, want 2 x 1890 = 3780", item) + if asIntPayload(item["count"]) != 2 || asIntPayload(item["end_amount"]) != 3780 || + asIntPayload(item["base_price"]) != 1490 { + t.Fatalf("menu item = %#v, want count 2, end_amount 3780 (the line price), base_price 1490", item) } groupID, value := selectedOptionValue(t, item) if groupID != "item-grp-extras" || value["id"] != "val-feta" || asIntPayload(value["price"]) != 400 { From a2b6a7439023442fd28b2bea7d5630aba49ce25f Mon Sep 17 00:00:00 2001 From: Nikita R Date: Sat, 17 Oct 2026 23:54:15 +0300 Subject: [PATCH 2/4] ci(live-smoke): add two units and check totals against Wolt's own The cart round-trip now adds two units of the fixture item. It checks that the line total is count x unit price, that the basket total Wolt reports equals the CLI subtotal, and that the checkout item subtotal Wolt computes matches it. After removing one unit it checks the line again, and the cleanup removes every unit of the fixture item, which was absent before the run. --- scripts/live-smoke.sh | 91 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 6 deletions(-) diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 7a323e1..1cc0c41 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -927,6 +927,76 @@ assert_options_line() { return 1 } +# assert_line_pricing "" "" "" "