# 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