berrypod/docs/plans/shipping-sync.md
jamey 9528700862 rename project from SimpleshopTheme to Berrypod
All modules, configs, paths, and references updated.
836 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:23:15 +00:00

11 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/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

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

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

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

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 * * *", 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:

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