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
30 changes: 19 additions & 11 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,38 +33,46 @@ 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`:

```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 |
29 changes: 25 additions & 4 deletions orders.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"`
}
Expand Down Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions orders_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -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},
}

Expand Down Expand Up @@ -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])
}
}
}
26 changes: 20 additions & 6 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -40,21 +42,33 @@ <h1>Brewbar</h1>

<script>
const dollars = (cents) => "$" + (cents / 100).toFixed(2);
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);

async function loadMenu() {
const res = await fetch("/api/menu");
const { items } = await res.json();
document.getElementById("menu").innerHTML = items.map((m) => `
<label class="row">
<div class="row" data-id="${m.id}">
<span>${m.name} <small>${dollars(m.price_cents)}</small></span>
<input type="number" min="0" max="20" value="0" data-id="${m.id}">
</label>`).join("");
<span class="controls">
<select aria-label="${m.name} size">
<option value="small">Small (−$0.50)</option>
<option value="medium" selected>Medium</option>
<option value="large">Large (+$0.75)</option>
</select>
<input type="number" min="0" max="20" value="0" aria-label="${m.name} quantity">
</span>
</div>`).join("");
}

document.getElementById("order").addEventListener("submit", async (e) => {
e.preventDefault();
const items = [...document.querySelectorAll("#menu input")]
.map((el) => ({ id: el.dataset.id, qty: Number(el.value) }))
const items = [...document.querySelectorAll("#menu .row")]
.map((row) => ({
id: row.dataset.id,
qty: Number(row.querySelector("input").value),
size: row.querySelector("select").value,
}))
.filter((l) => l.qty > 0);
const code = document.getElementById("code").value.trim();

Expand All @@ -75,7 +89,7 @@ <h1>Brewbar</h1>
errEl.textContent = "";
quoteEl.hidden = false;
quoteEl.innerHTML =
data.items.map((l) => `<div class="row"><span>${l.qty} × ${l.name}</span><span>${dollars(l.line_cents)}</span></div>`).join("") +
data.items.map((l) => `<div class="row"><span>${l.qty} × ${capitalize(l.size)} ${l.name}</span><span>${dollars(l.line_cents)}</span></div>`).join("") +
`<div class="row"><span>Subtotal</span><span>${dollars(data.subtotal_cents)}</span></div>` +
(data.discount_cents ? `<div class="row"><span>Discount</span><span>−${dollars(data.discount_cents)}</span></div>` : "") +
`<div class="row"><span>Tax</span><span>${dollars(data.tax_cents)}</span></div>` +
Expand Down
Loading