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>
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_ratesetting (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-Languageheader for a country code (e.g.en-GB→GB), fall back toGB. Store in session. Used for cart estimates instead of hardcodedGB - Cart shows "From £X" using guessed country rate — honest estimate based on detected country
- Provider-level shipping grouping — items from the same
print_provider_idship 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:
- Extract unique
{blueprint_id, print_provider_id}pairs from products'provider_data - For each pair, call
Client.get_shipping(blueprint_id, print_provider_id)(100ms sleep between) - Normalize: expand country arrays → individual maps, take max
first_item_costacross variant groups per country - 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 viaRepo.insert_allwithon_conflict: :replaceget_rate(blueprint_id, print_provider_id, country_code)— single rate lookupcalculate_for_cart(hydrated_items, country_code)— main calculation:- Load product
provider_datafor each cart item's variant - Group items by
print_provider_id(not blueprint — items from the same provider ship together) - Per provider group: look up rates for each item's
(blueprint_id, print_provider_id, country_code). Take the highestfirst_item_costacross all items in the group as a single first-item charge. For remaining items (total qty - 1), use each item's ownadditional_item_cost - Sum across provider groups, convert to shop currency
- Return
{:ok, cost_pence}or{:error, :rates_not_found}
- Load product
convert_to_shop_currency(amount_cents, currency)— ifcurrencymatches shop currency (GBP), return as-is. Otherwise readshipping_usd_to_gbp_ratefrom Settings (default 0.80), multiply and round up. Extensible for EUR etc via additional settingslist_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:
- Check session for existing
country_code— if present, skip - Parse
Accept-Languageheader: look for locale tags likeen-GB,de-DE,fr-FR→ extract country part - Fall back to
"GB"if no country found - 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
mix test— all pass- Manual sync via admin → shipping_rates table populated
- Cart page shows "From £X" when rates exist
- Cart page shows "Calculated at checkout" when no rates
- Stripe Checkout shows shipping options (UK / International)
- After payment, Order has
shipping_costandtotal = subtotal + shipping_cost - Scheduled sync fires every 6h (check Oban jobs table)
- Theme editor unaffected