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

12 KiB

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-GBGB), 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

@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:

{
  "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:

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

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):

{"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:

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