diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml index 0f06f99..a0e8803 100644 --- a/.github/workflows/live-smoke.yml +++ b/.github/workflows/live-smoke.yml @@ -2,9 +2,13 @@ name: Live smoke (daily) # Daily smoke against real Wolt consumer-api endpoints. Detects # upstream contract drift before users do. The read-only surface -# (status/account/feed/venue/cart-show) always runs; a cart round-trip -# (add a live orderable item → CLI checkout preview → MCP checkout preview -# → remove) runs because WOLT_SMOKE_CART=1 is set below. A sell-by-weight leg +# (status/account/feed/venue/cart-show) always runs; a cart round-trip runs +# because WOLT_SMOKE_CART=1 is set below. It adds two units of a live +# orderable item and checks that the line total is count x unit price and +# that Wolt's own basket total equals the CLI subtotal. It previews checkout +# through the CLI, where Wolt's item subtotal must match that total, and +# through MCP. Then it removes one unit, checks the line again and removes +# the rest. A sell-by-weight leg # follows it: weighted items are priced by the selected weight rather than per # unit, so it asserts the basket line price, previews checkout, and confirms # the line refuses partial removal. An option-groups leg adds a restaurant @@ -13,8 +17,8 @@ name: Live smoke (daily) # checkout, and removes one of two units, which rebuilds the remaining line. # The MCP steps spawn wolt-mcp over stdio for read-only venue detail/hours and # checkout preview, so the daily run guards the MCP surface as well as the CLI. -# Checkout is preview-only — no real order is ever placed, and only the unit -# added by this run is removed. No login or sync. +# Checkout is preview-only — no real order is ever placed, and only the units +# added by this run are removed. No login or sync. # # Required repository secret: # WOLT_SMOKE_CONFIG_JSON contents of ~/.wolt/.wolt-config.json diff --git a/docs/commands.md b/docs/commands.md index 612ae52..47b80e6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -372,8 +372,10 @@ the library ID, `venue item` the item-level ID. `--option` accepts either, and the basket always stores the item-level ID, the only one the Wolt app shows as selected. When a dish uses one library group more than once (pick one, two or three toppings), the library ID is ambiguous and the error lists the -item-level IDs to choose from. The saved line price is the dish price plus the -selected paid option values, so `wolt cart` and `wolt checkout` agree. +item-level IDs to choose from. The saved line price is the whole-line total, +count x (dish price plus the selected paid option values), as the Wolt app +stores it, so `wolt cart`, `wolt checkout` and the Wolt app show the same +total. Wolt's basket responses list selected option values without their prices. `cart add`, `cart remove` and `checkout` send every line back, so they take the diff --git a/docs/output-contract.md b/docs/output-contract.md index 6f776db..8a44a3d 100644 --- a/docs/output-contract.md +++ b/docs/output-contract.md @@ -475,6 +475,13 @@ lines[]: { subtotal, fees, total ``` +`line_total` is the price Wolt stores for the whole line: count x (unit price +plus paid option values). `price` is one unit of it. For a weighted line both +are the price of the weighed amount. `subtotal` is the sum of the line totals, +the same item total the Wolt app shows. A line whose stored price does not +divide evenly by its count gets a `null` unit price and a warning; remove the +item and add it again. + ### `wolt cart add|remove|clear` — CartMutationResult ``` 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/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 "" "" "" "