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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,9 @@ fails with `WOLT_VENUE_UNRESOLVED` instead of reporting a success that
never reaches your cart.

It then refreshes the exact current item. A non-null `disabled_info`,
non-positive numeric `purchasable_balance`, or a missing current item blocks
the mutation even when `--price`, `--currency`, and `--name` are supplied.
non-positive numeric `purchasable_balance`, an item outside its weekly sale
hours (`available_times`), or a missing current item blocks the mutation even
when `--price`, `--currency`, and `--name` are supplied.
A null balance means Wolt supplied no numeric balance constraint, not unlimited
stock. Currency is resolved from current item, basket, or venue metadata; the
command fails instead of silently defaulting to EUR. See
Expand Down
15 changes: 14 additions & 1 deletion docs/output-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,14 @@ Availability is derived from Wolt's current `disabled_info` and
- `null` means Wolt supplied no numeric balance constraint. It does **not**
mean unlimited stock; availability still depends on `disabled_info` and the
item being present in the current assortment.
- `available_times` lists the weekly hours an item is sold, in the venue's
local time (for example a lunch dish sold only on Mondays 10:30-15:00).
Wolt keeps `disabled_info` null outside those hours, but its basket marks
such a line unavailable. The CLI and MCP therefore treat an item outside its
hours as disabled, with a reason such as
`not sold now; available Mon 10:30-15:00 venue time`. The venue's time
comes from the venue pages the command already loads; when it cannot be
read, the hours are not checked.

`is_sold_out` remains as a deprecated, derived compatibility alias
(`!is_available`); raw `is_sold_out` and `sold_out` fields are not expected
Expand Down Expand Up @@ -470,7 +478,7 @@ basket_id, venue_id, venue_name, venue_slug
selection, currency, total_items
lines[]: {
line_id, item_id, name, count,
options[], price, line_total
options[], price, line_total, is_available?
}
subtotal, fees, total
```
Expand All @@ -482,6 +490,11 @@ 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.

`is_available` is Wolt's own flag for the line and is present when Wolt sends
it. Wolt leaves an unavailable line (sold out, or outside the item's sale
hours) out of `total`, so `total` can be lower than `subtotal`; each such line
also adds a warning.

### `wolt cart add|remove|clear` — CartMutationResult

```
Expand Down
39 changes: 39 additions & 0 deletions internal/cli/cart_state_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,45 @@ func TestBuildCartStateNormalizesMissingCountAndHonorsExplicitZeroTotal(t *testi
}
}

func TestBuildCartStateFlagsLinesWoltMarksUnavailable(t *testing.T) {
page := map[string]any{
"baskets": []any{
map[string]any{
"id": "basket-1",
"total": "€0.00",
"venue": map[string]any{"id": "venue-1"},
"items": []any{
map[string]any{"id": "lunch", "name": "LAMB METHI", "count": 2, "price": 2900, "is_available": false},
map[string]any{"id": "soup", "name": "Soup", "count": 1, "price": 900, "is_available": true},
},
"telemetry": map[string]any{"basket_total": 900},
},
},
}

state, warnings := buildCartState(page, "venue-1")
lines := asSlice(state["lines"])
if got := asMap(lines[0])["is_available"]; got != false {
t.Fatalf("lunch is_available = %#v, want false", got)
}
if got := asMap(lines[1])["is_available"]; got != true {
t.Fatalf("soup is_available = %#v, want true", got)
}
want := `basket item "LAMB METHI" is unavailable now; Wolt leaves it out of the basket total`
found := false
for _, warning := range warnings {
if warning == want {
found = true
}
}
if !found {
t.Fatalf("warnings = %#v, want %q", warnings, want)
}
if asInt(asMap(state["subtotal"])["amount"]) != 3800 || asInt(asMap(state["total"])["amount"]) != 900 {
t.Fatalf("subtotal = %#v, total = %#v", state["subtotal"], state["total"])
}
}

func TestFindBasketLineByIDAcceptsObjectIDCase(t *testing.T) {
basket := map[string]any{
"items": []any{
Expand Down
17 changes: 15 additions & 2 deletions internal/cli/commands_cart.go
Original file line number Diff line number Diff line change
Expand Up @@ -1449,7 +1449,7 @@ func buildCartState(page map[string]any, venueID string) (map[string]any, []stri
priceDisplay = nil
warnings = append(warnings, fmt.Sprintf("basket item %q: %v", asString(item["id"]), unitErr))
}
lines = append(lines, map[string]any{
line := map[string]any{
"line_id": asString(item["id"]),
"item_id": asString(item["id"]),
"name": asString(item["name"]),
Expand All @@ -1463,7 +1463,20 @@ func buildCartState(page map[string]any, venueID string) (map[string]any, []stri
"amount": lineAmount,
"formatted_amount": lineTotalDisplay,
},
})
}
// Wolt leaves an unavailable line (sold out, or outside the item's
// sale hours) out of the basket total, so the total can be below the
// subtotal of the listed lines.
if available, ok := item["is_available"].(bool); ok {
line["is_available"] = available
if !available {
warnings = append(warnings, fmt.Sprintf(
"basket item %q is unavailable now; Wolt leaves it out of the basket total",
fallbackString(asString(item["name"]), asString(item["id"])),
))
}
}
lines = append(lines, line)
}
if !totalsValid {
subtotalAmount = 0
Expand Down
34 changes: 28 additions & 6 deletions internal/gateway/wolt/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ type Client struct {
nextRequestAt time.Time
verboseOutput io.Writer
verboseOutputM sync.RWMutex
now func() time.Time
venueZones sync.Map
}

// Option applies Client options.
Expand Down Expand Up @@ -159,6 +161,13 @@ func WithRequestMinInterval(interval time.Duration) Option {
}
}

// WithClock replaces the clock used to evaluate item sale windows.
func WithClock(now func() time.Time) Option {
return func(c *Client) {
c.now = now
}
}

// WithVerboseOutput enables per-request trace output for upstream HTTP calls.
func WithVerboseOutput(out io.Writer) Option {
return func(c *Client) {
Expand Down Expand Up @@ -196,6 +205,7 @@ func NewClient(opts ...Option) *Client {
},
locale: "en",
webClientID: generateWebClientID(),
now: time.Now,
}
for _, opt := range opts {
opt(c)
Expand Down Expand Up @@ -618,7 +628,11 @@ func (c *Client) searchPage(ctx context.Context, body map[string]any, auth AuthC

// VenuePageStatic returns venue static page payload.
func (c *Client) VenuePageStatic(ctx context.Context, slug string) (map[string]any, error) {
return c.doJSONRequest(ctx, http.MethodGet, c.endpoints.VenuePage+slug+"/static", nil, nil, c.headers(nil, nil))
payload, err := c.doJSONRequest(ctx, http.MethodGet, c.endpoints.VenuePage+slug+"/static", nil, nil, c.headers(nil, nil))
if err == nil {
c.rememberVenueStaticZone(slug, payload)
}
return payload, err
}

// VenuePageDynamic returns venue dynamic page payload.
Expand All @@ -637,19 +651,24 @@ func (c *Client) VenuePageDynamic(ctx context.Context, slug string, options Venu
}
params.Set("selected_delivery_method", selectedDeliveryMethod)
}
return c.doJSONRequest(
payload, err := c.doJSONRequest(
ctx,
http.MethodGet,
endpoint+slug+"/dynamic/",
params,
nil,
c.headers(nil, &options.Auth),
)
if err == nil {
c.rememberVenueDynamicZone(slug, payload)
}
return payload, err
}

// AssortmentByVenueSlug returns full assortment payload for one venue slug.
func (c *Client) AssortmentByVenueSlug(ctx context.Context, slug string) (map[string]any, error) {
return c.doJSONRequest(ctx, http.MethodGet, c.endpoints.Assortment+slug+"/assortment", nil, nil, c.headers(nil, nil))
payload, err := c.doJSONRequest(ctx, http.MethodGet, c.endpoints.Assortment+slug+"/assortment", nil, nil, c.headers(nil, nil))
return c.withItemSchedules(ctx, slug, payload, err)
}

// AssortmentCategoryByVenueSlug returns one category assortment payload by category slug.
Expand All @@ -667,7 +686,8 @@ func (c *Client) AssortmentCategoryByVenueSlug(
params.Set("language", lang)
}
endpoint := c.endpoints.Assortment + slug + "/assortment/categories/slug/" + url.PathEscape(strings.TrimSpace(categorySlug))
return c.doJSONRequest(ctx, http.MethodGet, endpoint, params, nil, c.headers(nil, &auth))
payload, err := c.doJSONRequest(ctx, http.MethodGet, endpoint, params, nil, c.headers(nil, &auth))
return c.withItemSchedules(ctx, slug, payload, err)
}

// AssortmentItemsByVenueSlug returns detailed item payload for selected assortment item ids.
Expand All @@ -689,14 +709,15 @@ func (c *Client) AssortmentItemsByVenueSlug(
"item_ids": ids,
}
endpoint := c.endpoints.Assortment + slug + "/assortment/items"
return c.doJSONRequest(
payload, err := c.doJSONRequest(
ctx,
http.MethodPost,
endpoint,
nil,
body,
c.headers(map[string]string{"Content-Type": "application/json"}, &auth),
)
return c.withItemSchedules(ctx, slug, payload, err)
}

// AssortmentItemsSearchByVenueSlug searches items inside one venue assortment.
Expand All @@ -717,14 +738,15 @@ func (c *Client) AssortmentItemsSearchByVenueSlug(
"q": strings.TrimSpace(query),
}
endpoint := c.endpoints.Assortment + slug + "/assortment/items/search"
return c.doJSONRequest(
payload, err := c.doJSONRequest(
ctx,
http.MethodPost,
endpoint,
params,
body,
c.headers(map[string]string{"Content-Type": "application/json"}, &auth),
)
return c.withItemSchedules(ctx, slug, payload, err)
}

// VenueContentByVenueSlug returns venue-content payload by venue slug.
Expand Down
Loading
Loading