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
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.
-`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.
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.
Read `country_code` from session (default `"GB"`). Add `shipping_estimate` assign on mount using `Shipping.calculate_for_cart(cart_items, country_code)`.