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