berrypod/docs/plans/shipping-sync.md
jamey edef628214 tidy docs: condense progress, trim readme, mark plan statuses
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:18 +00:00

254 lines
11 KiB
Markdown

# Plan: Shipping rates + scheduled sync
**Status:** Complete
**Task:** #18 — 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/berrypod/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/berrypod/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/berrypod/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/berrypod/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/berrypod/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/berrypod/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 * * *", Berrypod.Sync.ScheduledSyncWorker}
```
Every 6 hours. `:sync` queue concurrency 1 serialises with manual syncs.
## 8. Checkout: Stripe shipping_options
**File:** `lib/berrypod_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/berrypod_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/berrypod_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/berrypod_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/berrypod_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/berrypod/shipping/shipping_rate.ex` | New schema |
| `lib/berrypod/shipping.ex` | New context |
| `lib/berrypod/providers/provider.ex` | Add optional callback |
| `lib/berrypod/providers/printify.ex` | Implement `fetch_shipping_rates/2` |
| `lib/berrypod/sync/product_sync_worker.ex` | Wire shipping into sync |
| `lib/berrypod/sync/scheduled_sync_worker.ex` | New Oban cron worker |
| `config/config.exs` | Add ScheduledSyncWorker to crontab |
| `lib/berrypod/orders/order.ex` | Add `shipping_cost` field |
| `lib/berrypod/orders.ex` | Cast shipping_cost, update total logic |
| `lib/berrypod/products.ex` | Add `get_variants_with_products/1` |
| `lib/berrypod_web/controllers/checkout_controller.ex` | Stripe shipping_options |
| `lib/berrypod_web/controllers/stripe_webhook_controller.ex` | Extract shipping from Stripe |
| `lib/berrypod_web/plugs/country_detect.ex` | New plug: country from Accept-Language |
| `lib/berrypod_web/router.ex` | Add CountryDetect to `:browser` pipeline |
| `lib/berrypod_web/live/shop/cart.ex` | Shipping estimate assign + country from session |
| `lib/berrypod_web/components/shop_components/cart.ex` | Display estimate |
| `lib/berrypod_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