berrypod/docs/plans/shipping-sync.md
jamey 5c2f70ce44 add shipping costs with live exchange rates and country detection
Shipping rates fetched from Printify during product sync, converted to
GBP at sync time using frankfurter.app ECB exchange rates with 5%
buffer. Cached in shipping_rates table per blueprint/provider/country.

Cart page shows shipping estimate with country selector (detected from
Accept-Language header, persisted in cookie). Stripe Checkout includes
shipping_options for UK domestic and international delivery. Order
shipping_cost extracted from Stripe on payment.

ScheduledSyncWorker runs every 6 hours via Oban cron to keep rates
and exchange rates fresh. REST_OF_THE_WORLD fallback covers unlisted
countries. 780 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:48:00 +00:00

254 lines
12 KiB
Markdown

# 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