# Data model

Two metaobject definitions hold cycle configuration. Per-customer selections live on a
customer metafield. All wine data (name, vintage, price, red/white) comes from real
Shopify **products/variants** — we never duplicate wine data into metaobjects, we
reference products by GID and read live values.

---

## Metaobject: `wineclub_cycle`

One entry per delivery cycle (e.g. "Q2 2026").

| Field key        | Type                        | Notes                                              |
|------------------|-----------------------------|----------------------------------------------------|
| `name`           | single_line_text            | "Q2 2026"                                           |
| `handle`         | (system handle)             | `q2-2026`                                           |
| `cadence`        | single_line_text            | `quarterly` \| `bi-annual`                          |
| `open_at`        | date                        | customization window opens                          |
| `close_at`       | date                        | customization window closes (deadline shown to user)|
| `status`         | single_line_text            | `draft` \| `open` \| `closed` \| `fulfilled`        |
| `tier_pools`     | list.metaobject_reference   | → `wineclub_tier_pool` entries for this cycle       |

## Metaobject: `wineclub_tier_pool`

One entry per (cycle × tier). Defines the default box and the approved swap pool.

| Field key        | Type                        | Notes                                                        |
|------------------|-----------------------------|-------------------------------------------------------------|
| `cycle`          | metaobject_reference        | → parent `wineclub_cycle`                                    |
| `tier`           | single_line_text            | `classic-3` \| `classic-6` \| `reserve-3` \| `reserve-6` \| `magnum` |
| `tier_label`     | single_line_text            | "Classic Club · 3 bottles"                                   |
| `bottle_count`   | number_integer              | 3 or 6                                                       |
| `base_price`     | money                       | shipment base ($150 …). Editable → future renewals only.    |
| `default_wines`  | list.product_reference      | admin's default box (length = `bottle_count`)               |
| `swap_pool`      | list.product_reference      | approved wines a member may swap INTO for this tier          |
| `upgrade_target` | metaobject_reference        | → tier_pool the "Switch tier" button upgrades to (nullable)  |

### Why product_reference (not stored prices)

Decision #3: pricing is read live. `default_wines` / `swap_pool` reference Shopify
products; at render time Liquid reads `variant.price`, `variant.title`, the wine's
`vintage`/`type` metafields, etc. Admin changes a price once in Shopify and every cycle
reflects it. No per-cycle price entry.

### Wine product metafields (on the product, namespace `wineclub`)

| Metafield            | Type              | Used for                    |
|----------------------|-------------------|-----------------------------|
| `wineclub.vintage`   | single_line_text  | "2022", "Alder Springs · 2022" |
| `wineclub.wine_type` | single_line_text  | `red` \| `white`  (filter dots) |
| `wineclub.catalog_group` | single_line_text | "Reserve selection" \| "Store catalog" (swap-panel grouping) |

---

## Customer metafield: `wineclub.selection`

Per-customer saved selection, keyed by cycle handle so history is preserved.

- **Namespace/key:** `wineclub.selection`
- **Type:** `json`

```json
{
  "q2-2026": {
    "tier": "classic-3",
    "bottles": [
      { "productId": "gid://shopify/Product/111", "variantId": "gid://shopify/ProductVariant/1", "price": 50 },
      { "productId": "gid://shopify/Product/222", "variantId": "gid://shopify/ProductVariant/2", "price": 52 },
      { "productId": "gid://shopify/Product/333", "variantId": "gid://shopify/ProductVariant/3", "price": 48 }
    ],
    "addOnBottles": [],
    "basePrice": 150.0,
    "adjustments": 0.0,
    "total": 150.0,
    "savedAt": "2026-05-20T14:00:00Z",
    "chargeStatus": "none",        // none | pending | paid | failed
    "chargeId": null,
    "fulfillmentStatus": "pending" // pending | default_applied | generated
  }
}
```

- `chargeStatus` tracks the one-time extras charge (decision #2: extras are one-time).
- `fulfillmentStatus` lets the cycle-close job know who to generate vs. who gets the
  default (fallback).
- Writes to this metafield happen **only** via the App Proxy Worker (Admin API,
  signature-verified). Never from client JS.

---

## Price computation rules (single source of truth)

Given a tier pool and a selection:

```
lineTotal      = Σ selectedVariant.price   (live, per bottle in the box)
addOnTotal     = Σ addOnBottle.price       (one-time extras beyond bottle_count)
adjustments    = (lineTotal − base_price) + addOnTotal
shipmentTotal  = lineTotal + addOnTotal
```

- Minimum bottles = `bottle_count` (the tier's allocation). "Lock in" disabled below it.
- Maximum bottles in-box = `bottle_count`. Extras above that are `addOnBottles`
  (one-time charge), not box swaps.
- The **base subscription charge is unchanged** — extras are billed as a separate
  one-time charge at cycle close, per decision #2.
