# Plan: Shipping rates + scheduled sync

**Status:** Planning
**Task:** #18 from PROGRESS.md — shipping costs at checkout
**Scope:** Shipping rate caching, scheduled product sync, provider behaviour extension, cart/checkout integration

---

## Overview

Fetch shipping rates from Printify during product sync, cache them in DB, display estimates in the cart, and pass actual rates to Stripe Checkout. Add a scheduled Oban cron worker for periodic product + shipping sync. Extend the Provider behaviour so future providers slot in cleanly.

## Key design decisions

- **Store rates per (blueprint, print_provider, country)** — one DB row per combo, conservative max across variant groups
- **Currency: store in original provider currency, convert at read time** — providers return their own currency (USD for US providers, GBP for UK, EUR for EU). A `shipping_usd_to_gbp_rate` setting (default 0.80) handles USD→GBP; if rate currency already matches shop currency, no conversion needed
- **Country guessing from Accept-Language** — parse the browser's `Accept-Language` header for a country code (e.g. `en-GB` → `GB`), fall back to `GB`. Store in session. Used for cart estimates instead of hardcoded `GB`
- **Cart shows "From £X" using guessed country rate** — honest estimate based on detected country
- **Provider-level shipping grouping** — items from the same `print_provider_id` ship together regardless of product type (blueprint). Cart calculation groups by provider, not by blueprint+provider
- **Shipping sync failures don't break product sync** — logged and skipped
- **No on-the-fly API calls at cart time** — always use cached rates

---

## 1. Migration + schema: `shipping_rates`

**New file:** `lib/simpleshop_theme/shipping/shipping_rate.ex`

```
shipping_rates table:
  id                      binary_id PK
  provider_connection_id  FK → provider_connections (on_delete: delete_all)
  blueprint_id            integer, not null
  print_provider_id       integer, not null
  country_code            string, not null (ISO 3166-1 alpha-2)
  first_item_cost         integer, not null (minor units in provider currency)
  additional_item_cost    integer, not null
  currency                string, not null, default "USD"
  handling_time_days      integer, nullable
  timestamps
```

Unique index on `(provider_connection_id, blueprint_id, print_provider_id, country_code)`.

## 2. Migration: add `shipping_cost` to orders

```
alter orders:
  add shipping_cost  integer, nullable (nil = not calculated / legacy)
```

Update `Order` changeset to cast `:shipping_cost`. Update `total` calculation: `total = subtotal + (shipping_cost || 0)`.

## 3. Provider behaviour: add `fetch_shipping_rates/2`

**File:** `lib/simpleshop_theme/providers/provider.ex`

```elixir
@callback fetch_shipping_rates(ProviderConnection.t(), products :: [map()]) ::
            {:ok, [map()]} | {:error, term()}
```

Takes the connection + the already-fetched product list (avoids re-fetching — we extract unique blueprint/provider pairs from `provider_data`). Returns normalized rate maps.

Use `@optional_callbacks [fetch_shipping_rates: 2]` — the sync worker checks `function_exported?/3` before calling.

## 4. Printify implementation

**File:** `lib/simpleshop_theme/providers/printify.ex`

New function `fetch_shipping_rates/2`:

1. Extract unique `{blueprint_id, print_provider_id}` pairs from products' `provider_data`
2. For each pair, call `Client.get_shipping(blueprint_id, print_provider_id)` (100ms sleep between)
3. Normalize: expand country arrays → individual maps, take max `first_item_cost` across variant groups per country
4. Return flat list of rate maps

**Printify response format:**
```json
{
  "handling_time": {"value": 30, "unit": "day"},
  "profiles": [
    {"variant_ids": [12345], "first_item": {"currency": "USD", "cost": 450},
     "additional_items": {"currency": "USD", "cost": 0}, "countries": ["US"]},
    {"variant_ids": [12345], "first_item": {"currency": "USD", "cost": 650},
     "additional_items": {"currency": "USD", "cost": 0}, "countries": ["CA", "GB", "DE", ...]}
  ]
}
```

## 5. Shipping context

**New file:** `lib/simpleshop_theme/shipping.ex`

Functions:
- `upsert_rates(provider_connection_id, rates)` — bulk upsert via `Repo.insert_all` with `on_conflict: :replace`
- `get_rate(blueprint_id, print_provider_id, country_code)` — single rate lookup
- `calculate_for_cart(hydrated_items, country_code)` — main calculation:
  1. Load product `provider_data` for each cart item's variant
  2. Group items by `print_provider_id` (not blueprint — items from the same provider ship together)
  3. Per provider group: look up rates for each item's `(blueprint_id, print_provider_id, country_code)`. Take the highest `first_item_cost` across all items in the group as a single first-item charge. For remaining items (total qty - 1), use each item's own `additional_item_cost`
  4. Sum across provider groups, convert to shop currency
  5. Return `{:ok, cost_pence}` or `{:error, :rates_not_found}`
- `convert_to_shop_currency(amount_cents, currency)` — if `currency` matches shop currency (GBP), return as-is. Otherwise read `shipping_usd_to_gbp_rate` from Settings (default 0.80), multiply and round up. Extensible for EUR etc via additional settings
- `list_available_countries()` — distinct country codes with rates

**Helper in Products context:** `get_variants_with_products(variant_ids)` — loads variants with product `provider_data` preloaded.

## 6. Wire shipping into ProductSyncWorker

**File:** `lib/simpleshop_theme/sync/product_sync_worker.ex`

In `do_sync_products/1`, after `sync_all_products(conn, products)` and before `update_sync_status(conn, "completed", ...)` (line 99), add:

```elixir
sync_shipping_rates(conn, provider, products)
```

Private function wraps `provider.fetch_shipping_rates(conn, products)` → `Shipping.upsert_rates(conn.id, rates)`. Rescue and log on failure — never fails the product sync.

## 7. Scheduled sync worker

**New file:** `lib/simpleshop_theme/sync/scheduled_sync_worker.ex`

```elixir
use Oban.Worker, queue: :sync, max_attempts: 1
```

Calls `Products.list_provider_connections()`, filters enabled, enqueues `Products.enqueue_sync(conn)` for each.

**Oban cron config** (`config/config.exs`):
```elixir
{"0 */6 * * *", SimpleshopTheme.Sync.ScheduledSyncWorker}
```

Every 6 hours. `:sync` queue concurrency 1 serialises with manual syncs.

## 8. Checkout: Stripe shipping_options

**File:** `lib/simpleshop_theme_web/controllers/checkout_controller.ex`

Calculate shipping for GB (domestic) and US (international representative). Build `shipping_options` with inline `shipping_rate_data`:

```elixir
shipping_options: [
  %{shipping_rate_data: %{type: "fixed_amount", display_name: "UK delivery",
    fixed_amount: %{amount: gb_cost, currency: "gbp"},
    delivery_estimate: %{minimum: %{unit: "business_day", value: 5},
                         maximum: %{unit: "business_day", value: 10}}}},
  %{shipping_rate_data: %{type: "fixed_amount", display_name: "International delivery",
    fixed_amount: %{amount: intl_cost, currency: "gbp"},
    delivery_estimate: %{minimum: %{unit: "business_day", value: 10},
                         maximum: %{unit: "business_day", value: 20}}}}
]
```

If no rates found, omit `shipping_options` (current behaviour preserved).

**File:** `lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex`

On `checkout.session.completed`, extract `session.shipping_cost.amount_total` and update order's `shipping_cost` and `total`.

## 9. Country detection plug

**New file:** `lib/simpleshop_theme_web/plugs/country_detect.ex`

Simple plug that runs on the `:browser` pipeline:
1. Check session for existing `country_code` — if present, skip
2. Parse `Accept-Language` header: look for locale tags like `en-GB`, `de-DE`, `fr-FR` → extract country part
3. Fall back to `"GB"` if no country found
4. Store in session via `put_session(:country_code, code)`

LiveViews read it from the session in `mount/3` via `get_session(session, "country_code")`.

## 10. Cart display

**File:** `lib/simpleshop_theme_web/live/shop/cart.ex`

Read `country_code` from session (default `"GB"`). Add `shipping_estimate` assign on mount using `Shipping.calculate_for_cart(cart_items, country_code)`.

**File:** `lib/simpleshop_theme_web/components/shop_components/cart.ex`

Update `order_summary` component — add `attr :shipping_estimate, :integer, default: nil`:
- Non-nil → show `"From #{format_price(shipping_estimate)}"` + total includes estimate
- Nil → show `"Calculated at checkout"` (current behaviour)

Cart drawer unchanged (compact view, no shipping detail).

---

## Files to modify

| File | Change |
|------|--------|
| `priv/repo/migrations/..._create_shipping_rates.exs` | New migration |
| `priv/repo/migrations/..._add_shipping_cost_to_orders.exs` | New migration |
| `lib/simpleshop_theme/shipping/shipping_rate.ex` | New schema |
| `lib/simpleshop_theme/shipping.ex` | New context |
| `lib/simpleshop_theme/providers/provider.ex` | Add optional callback |
| `lib/simpleshop_theme/providers/printify.ex` | Implement `fetch_shipping_rates/2` |
| `lib/simpleshop_theme/sync/product_sync_worker.ex` | Wire shipping into sync |
| `lib/simpleshop_theme/sync/scheduled_sync_worker.ex` | New Oban cron worker |
| `config/config.exs` | Add ScheduledSyncWorker to crontab |
| `lib/simpleshop_theme/orders/order.ex` | Add `shipping_cost` field |
| `lib/simpleshop_theme/orders.ex` | Cast shipping_cost, update total logic |
| `lib/simpleshop_theme/products.ex` | Add `get_variants_with_products/1` |
| `lib/simpleshop_theme_web/controllers/checkout_controller.ex` | Stripe shipping_options |
| `lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex` | Extract shipping from Stripe |
| `lib/simpleshop_theme_web/plugs/country_detect.ex` | New plug: country from Accept-Language |
| `lib/simpleshop_theme_web/router.ex` | Add CountryDetect to `:browser` pipeline |
| `lib/simpleshop_theme_web/live/shop/cart.ex` | Shipping estimate assign + country from session |
| `lib/simpleshop_theme_web/components/shop_components/cart.ex` | Display estimate |
| `lib/simpleshop_theme_web/components/page_templates/cart.html.heex` | Pass shipping_estimate |

**New files:** 5 (schema, context, worker, plug, 2 migrations)
**Modified files:** 14

---

## Implementation order

| # | Task | Est |
|---|------|-----|
| 1 | Migrations + ShippingRate schema | 15m |
| 2 | Shipping context (upsert, lookup, calculate, convert) | 40m |
| 3 | Provider behaviour + Printify `fetch_shipping_rates` | 30m |
| 4 | Wire shipping into ProductSyncWorker | 15m |
| 5 | ScheduledSyncWorker + Oban cron config | 15m |
| 6 | Order schema: add shipping_cost | 15m |
| 7 | Checkout controller: Stripe shipping_options | 25m |
| 8 | Stripe webhook: extract shipping cost | 15m |
| 9 | Country detection plug + router | 15m |
| 10 | Cart page: shipping estimate display | 20m |
| 11 | Tests | 45m |
| 12 | `mix precommit` clean | 15m |

**Total: ~4.5 hours** (2 sessions)

**Session 1 (tasks 1-5):** Storage, provider, sync. Rates cached but not yet displayed.
**Session 2 (tasks 6-12):** Order changes, Stripe, country detection, cart display, tests.

---

## Verification

1. `mix test` — all pass
2. Manual sync via admin → shipping_rates table populated
3. Cart page shows "From £X" when rates exist
4. Cart page shows "Calculated at checkout" when no rates
5. Stripe Checkout shows shipping options (UK / International)
6. After payment, Order has `shipping_cost` and `total = subtotal + shipping_cost`
7. Scheduled sync fires every 6h (check Oban jobs table)
8. Theme editor unaffected
