From d07cf3cb0767555170a89a63be3c98632b2ab819 Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Wed, 7 Oct 2026 13:51:49 -0700 Subject: [PATCH] Add drink sizes to orders Each order item takes an optional size: small (50 cents off the menu price), medium (the menu price, and the default), or large (75 cents more). Every quoted line now returns its size, and an unknown size is rejected with 422. The page gets a size picker on each drink and shows the size on each quoted line. docs/api.md describes the new field and pricing rule. --- docs/api.md | 30 +++++++++++------- orders.go | 29 +++++++++++++++--- orders_test.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 26 ++++++++++++---- 4 files changed, 141 insertions(+), 21 deletions(-) diff --git a/docs/api.md b/docs/api.md index 18dd7d5..c418898 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,12 +33,14 @@ Request: ```json { - "items": [{ "id": "latte", "qty": 2 }], + "items": [{ "id": "latte", "qty": 2, "size": "large" }], "code": "WELCOME10" } ``` - `items` is required and must not be empty. `qty` is 1–20. +- `size` is optional: `small`, `medium`, or `large`. It defaults to `medium`. + It is case-sensitive. - `code` is optional and case-insensitive. Response `200`: @@ -46,25 +48,31 @@ Response `200`: ```json { "items": [ - { "id": "latte", "name": "Latte", "qty": 2, "unit_cents": 450, "line_cents": 900 } + { "id": "latte", "name": "Latte", "qty": 2, "size": "large", "unit_cents": 525, "line_cents": 1050 } ], - "subtotal_cents": 900, - "discount_cents": 90, - "tax_cents": 71, - "total_cents": 881 + "subtotal_cents": 1050, + "discount_cents": 105, + "tax_cents": 83, + "total_cents": 1028 } ``` +Each quoted line includes `size`, which is always set: the size that was +asked for, or `medium` if none was. + Pricing rules: -1. `subtotal_cents` is the sum of `line_cents`. -2. `discount_cents` is the code's percentage of the subtotal, rounded down. -3. Tax is 8.75% of `subtotal_cents - discount_cents`, rounded half up. -4. `total_cents = subtotal_cents - discount_cents + tax_cents`. +1. `unit_cents` is the menu price adjusted for size: `small` is 50 cents + less, `medium` is the menu price, and `large` is 75 cents more. + `line_cents = unit_cents * qty`. +2. `subtotal_cents` is the sum of `line_cents`. +3. `discount_cents` is the code's percentage of the subtotal, rounded down. +4. Tax is 8.75% of `subtotal_cents - discount_cents`, rounded half up. +5. `total_cents = subtotal_cents - discount_cents + tax_cents`. Errors return a JSON body `{"error": "..."}`: | Status | When | |--------|------| | `400` | Body is not valid JSON, or `items` is empty | -| `422` | Unknown item, `qty` out of range, or unknown discount code | +| `422` | Unknown item, `qty` out of range, unknown `size`, or unknown discount code | diff --git a/orders.go b/orders.go index c18413a..c990d8a 100644 --- a/orders.go +++ b/orders.go @@ -33,9 +33,19 @@ const taxBasisPoints = 875 const maxQty = 20 +// sizeAdjustCents maps a drink size to its change from the menu price. +var sizeAdjustCents = map[string]int{ + "small": -50, + "medium": 0, + "large": 75, +} + +const defaultSize = "medium" + type OrderLine struct { - ID string `json:"id"` - Qty int `json:"qty"` + ID string `json:"id"` + Qty int `json:"qty"` + Size string `json:"size,omitempty"` } type OrderRequest struct { @@ -47,6 +57,7 @@ type QuotedLine struct { ID string `json:"id"` Name string `json:"name"` Qty int `json:"qty"` + Size string `json:"size"` UnitCents int `json:"unit_cents"` LineCents int `json:"line_cents"` } @@ -86,12 +97,22 @@ func quoteOrder(req OrderRequest) (Quote, error) { if line.Qty < 1 || line.Qty > maxQty { return Quote{}, fmt.Errorf("qty for %q must be between 1 and %d", line.ID, maxQty) } - lineCents := item.PriceCents * line.Qty + size := line.Size + if size == "" { + size = defaultSize + } + adjust, ok := sizeAdjustCents[size] + if !ok { + return Quote{}, fmt.Errorf("size for %q must be small, medium, or large", line.ID) + } + unitCents := item.PriceCents + adjust + lineCents := unitCents * line.Qty q.Items = append(q.Items, QuotedLine{ ID: item.ID, Name: item.Name, Qty: line.Qty, - UnitCents: item.PriceCents, + Size: size, + UnitCents: unitCents, LineCents: lineCents, }) q.SubtotalCents += lineCents diff --git a/orders_test.go b/orders_test.go index d92ae1e..44650f8 100644 --- a/orders_test.go +++ b/orders_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -32,6 +33,33 @@ func TestQuoteOrder(t *testing.T) { {name: "empty", req: OrderRequest{}, wantErr: true}, {name: "unknown item", req: OrderRequest{Items: []OrderLine{{ID: "tea", Qty: 1}}}, wantErr: true}, {name: "zero qty", req: OrderRequest{Items: []OrderLine{{ID: "latte", Qty: 0}}}, wantErr: true}, + { + name: "large adds 75 cents", + req: OrderRequest{Items: []OrderLine{{ID: "latte", Qty: 1, Size: "large"}}}, + // 525 subtotal, tax 45.9375 -> 46 + want: Quote{SubtotalCents: 525, TaxCents: 46, TotalCents: 571}, + }, + { + name: "small takes off 50 cents", + req: OrderRequest{Items: []OrderLine{{ID: "espresso", Qty: 2, Size: "small"}}}, + // 2 x 250 = 500 subtotal, tax 43.75 -> 44 + want: Quote{SubtotalCents: 500, TaxCents: 44, TotalCents: 544}, + }, + { + name: "mixed sizes with discount", + req: OrderRequest{ + Items: []OrderLine{ + {ID: "cappuccino", Qty: 1, Size: "small"}, + {ID: "cold-brew", Qty: 2, Size: "medium"}, + {ID: "latte", Qty: 1, Size: "large"}, + }, + Code: "STAFF25", + }, + // 375 + 800 + 525 = 1700 subtotal, 425 off, 1275 taxable, + // tax 111.5625 -> 112 + want: Quote{SubtotalCents: 1700, DiscountCents: 425, TaxCents: 112, TotalCents: 1387}, + }, + {name: "unknown size", req: OrderRequest{Items: []OrderLine{{ID: "latte", Qty: 1, Size: "venti"}}}, wantErr: true}, {name: "bad code", req: OrderRequest{Items: []OrderLine{{ID: "latte", Qty: 1}}, Code: "FREE"}, wantErr: true}, } @@ -70,4 +98,53 @@ func TestOrdersEndpoint(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } + + var body struct { + Items []map[string]any `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body.Items) != 1 || body.Items[0]["size"] != "medium" { + t.Errorf("items = %v, want one line with size medium", body.Items) + } +} + +func TestOrdersEndpointUnknownSize(t *testing.T) { + srv := httptest.NewServer(newMux()) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/api/orders", "application/json", + strings.NewReader(`{"items":[{"id":"latte","qty":1,"size":"venti"}]}`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422", resp.StatusCode) + } +} + +func TestQuotedLineSize(t *testing.T) { + got, err := quoteOrder(OrderRequest{Items: []OrderLine{ + {ID: "latte", Qty: 1}, + {ID: "latte", Qty: 3, Size: "small"}, + {ID: "espresso", Qty: 2, Size: "large"}, + }}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []QuotedLine{ + {ID: "latte", Name: "Latte", Qty: 1, Size: "medium", UnitCents: 450, LineCents: 450}, + {ID: "latte", Name: "Latte", Qty: 3, Size: "small", UnitCents: 400, LineCents: 1200}, + {ID: "espresso", Name: "Espresso", Qty: 2, Size: "large", UnitCents: 375, LineCents: 750}, + } + if len(got.Items) != len(want) { + t.Fatalf("got %d lines, want %d", len(got.Items), len(want)) + } + for i := range want { + if got.Items[i] != want[i] { + t.Errorf("line %d: got %+v, want %+v", i, got.Items[i], want[i]) + } + } } diff --git a/static/index.html b/static/index.html index 0ab04f0..caf437b 100644 --- a/static/index.html +++ b/static/index.html @@ -17,6 +17,8 @@ .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 16px; margin-bottom: 16px; } .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; } input[type=number] { width: 64px; padding: 4px 6px; } + select { padding: 4px 6px; } + .controls { display: flex; gap: 8px; } input[type=text] { width: 100%; padding: 6px 8px; } button { background: var(--accent); color: #fff; border: 0; border-radius: 6px; padding: 10px 16px; font-size: 16px; cursor: pointer; } #quote .row.total { font-weight: 700; border-top: 1px solid var(--line); margin-top: 6px; padding-top: 10px; } @@ -40,21 +42,33 @@

Brewbar