diff --git a/PROGRESS.md b/PROGRESS.md index 35f73ac..a657427 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,13 +21,13 @@ - Transactional emails (order confirmation, shipping notification) - Demo content polished and ready for production -**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). PageSpeed CI done (99-100 mobile, 97+ desktop). Usability fixes 16/18 done (remaining 2 are now tracked as features below). +**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). PageSpeed CI done (99-100 mobile, 97+ desktop). Usability fixes 16/18 done (remaining 2 are now tracked as features below). Shipping costs at checkout done. ## Task list Ordered by dependency level — admin shell chain first (unblocks most downstream work). -Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) +Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | # | Task | Depends on | Est | Status | |---|------|------------|-----|--------| @@ -50,7 +50,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](doc | ~~17~~ | ~~Wire shop LiveViews to DB queries (replace PreviewData indirection)~~ | — | 2-3h | done | | | **Next up** | | | | | 16 | Variant refinement with live data | — | 2-3h | | -| 18 | Shipping costs at checkout | 17 | 2-3h | | +| ~~18~~ | ~~Shipping costs at checkout~~ | 17 | 4h | done | | | **CSS migration (after admin stable)** | | | | | 19 | Admin design tokens (`admin-tokens.css`) | 12 | 30m | | | 20 | Admin component styles (`app-admin.css`) | 19 | 3-4h | | @@ -58,7 +58,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](doc | 22 | Remove DaisyUI | 21 | 1h | | | 23 | CSS migration tests + visual QA | 22 | 1h | | -**Total remaining: ~27-33 hours across ~12 sessions** +**Total remaining: ~23-29 hours across ~10 sessions** ## Usability fixes (16/18 done) @@ -154,6 +154,7 @@ See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for im - Startup recovery for stale sync status #### Future Enhancements (post-MVP) +- [ ] Print provider insights — fetch provider name/location via `get_print_providers/1` during sync, store in `provider_data`. Show "Ships from UK/US" on product pages. Admin dashboard showing which providers are used, their locations, and shipping cost analysis to help optimise product selection for domestic fulfilment and combined postage savings - [ ] Pre-checkout variant validation (verify availability before order) - [ ] Cost change monitoring/alerts (warn if Printify cost increased) - [ ] OAuth platform integration (appear in Printify's "Publish to" UI) @@ -302,7 +303,30 @@ All shop pages now have LiveView integration tests (612 total): - [x] Full ARIA combobox pattern (role=combobox, listbox, option, aria-selected) - [x] SearchModal JS hook, `<.link navigate>` for client-side nav, 150ms debounce - [x] search.ex: transaction safety on reindex, public `remove_product/1` -- [x] 10 new integration tests, 755 total +- [x] LIKE substring fallback when FTS5 prefix returns nothing +- [x] Admin bar replaced with header icon (gear/cog, admin-only, no public link) +- [x] Search modal race condition fix (close-on-keypress, open/close custom events) +- [x] HTTP 304 support for cached images +- [x] 10 new integration tests, 757 total + +### Shipping +**Status:** Complete + +- [x] ShippingRate schema + migration (per blueprint/provider/country) +- [x] Shipping context: upsert, lookup with REST_OF_THE_WORLD fallback, cart calculation +- [x] Provider behaviour: optional `fetch_shipping_rates/2` callback +- [x] Printify implementation: fetch rates per blueprint/provider, normalize country arrays +- [x] ProductSyncWorker integration: shipping rates synced alongside products +- [x] ScheduledSyncWorker (Oban cron, every 6 hours) for periodic re-sync +- [x] Live exchange rate conversion at sync time (frankfurter.app API, ECB data) +- [x] 5% configurable buffer on exchange rates to absorb fluctuations +- [x] Country detection from Accept-Language header + cookie persistence +- [x] Cart page shipping estimate with country selector (all countries with rates) +- [x] Stripe Checkout shipping_options (UK domestic + international) +- [x] Order shipping_cost field, extracted from Stripe on payment +- [x] 780 tests total + +See: [plan](docs/plans/shipping-sync.md) for implementation details ### Page Editor **Status:** Future (Tier 4) @@ -317,6 +341,8 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design | Feature | Commit | Notes | |---------|--------|-------| +| Shipping costs at checkout | — | Rates, exchange rates, country detection, Stripe shipping options, 780 tests | +| Search + admin polish | 44933ac | Search race condition fix, image 304s, LIKE fallback, admin header icon, 757 tests | | DB wiring + search UX | 57c3ba0 | Shop LiveViews use DB queries, search keyboard nav, ARIA, 755 tests | | FTS5 search + products refactor | 037cd16 | FTS5 index, BM25 ranking, search modal, denormalized fields, Product struct usage, 744 tests | | PageSpeed CI | 516d0d0 | `mix lighthouse` task, prod asset build, gzip, 99-100 mobile scores | diff --git a/assets/js/app.js b/assets/js/app.js index 0bcfb70..fcf0b45 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -57,6 +57,10 @@ const CartPersist = { body: JSON.stringify({items}) }) }) + + this.handleEvent("persist_country", ({code}) => { + document.cookie = `shipping_country=${code};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax` + }) } } diff --git a/config/config.exs b/config/config.exs index d1cbcdb..871b9f5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -102,7 +102,8 @@ config :simpleshop_theme, Oban, {Oban.Plugins.Lifeline, rescue_after: :timer.minutes(5)}, {Oban.Plugins.Cron, crontab: [ - {"*/30 * * * *", SimpleshopTheme.Orders.FulfilmentStatusWorker} + {"*/30 * * * *", SimpleshopTheme.Orders.FulfilmentStatusWorker}, + {"0 */6 * * *", SimpleshopTheme.Sync.ScheduledSyncWorker} ]} ], queues: [images: 2, sync: 1, checkout: 1] diff --git a/docs/plans/shipping-sync.md b/docs/plans/shipping-sync.md new file mode 100644 index 0000000..49ec1fb --- /dev/null +++ b/docs/plans/shipping-sync.md @@ -0,0 +1,253 @@ +# 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-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/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` + +```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/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:** +```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/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: + +```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/simpleshop_theme/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 * * *", 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`: + +```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/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 diff --git a/lib/simpleshop_theme/exchange_rate.ex b/lib/simpleshop_theme/exchange_rate.ex new file mode 100644 index 0000000..d72321b --- /dev/null +++ b/lib/simpleshop_theme/exchange_rate.ex @@ -0,0 +1,102 @@ +defmodule SimpleshopTheme.ExchangeRate do + @moduledoc """ + Fetches and caches exchange rates for shipping cost conversion. + + Uses the frankfurter.app API (ECB data, free, no API key). + Rates are fetched during product sync and cached in Settings so + they survive restarts without an API call. + """ + + alias SimpleshopTheme.Settings + + require Logger + + @api_base "https://api.frankfurter.app" + @settings_prefix "exchange_rate_" + @default_rates %{"USD" => 0.80, "EUR" => 0.86} + + @doc """ + Fetches the latest exchange rates to GBP from the API and caches them. + + Returns `{:ok, rates_map}` or `{:error, reason}`. + The rates map has currency codes as keys and GBP multipliers as values, + e.g. `%{"USD" => 0.7892, "EUR" => 0.8534}`. + """ + def fetch_and_cache do + case fetch_from_api() do + {:ok, rates} -> + cache_rates(rates) + {:ok, rates} + + {:error, reason} -> + Logger.warning("Exchange rate fetch failed: #{inspect(reason)}, using cached rates") + {:ok, get_cached_rates()} + end + end + + @doc """ + Returns cached exchange rates from Settings, falling back to defaults. + """ + def get_cached_rates do + Enum.reduce(@default_rates, %{}, fn {currency, default}, acc -> + key = @settings_prefix <> String.downcase(currency) <> "_to_gbp" + + rate = + case Settings.get_setting(key) do + nil -> default + val when is_binary(val) -> String.to_float(val) + val when is_number(val) -> val / 1 + end + + Map.put(acc, currency, rate) + end) + end + + @doc """ + Returns the GBP rate for a given currency, using cached values. + """ + def rate_for(currency) when currency in ["GBP", "gbp"], do: 1.0 + + def rate_for(currency) do + rates = get_cached_rates() + + Map.get( + rates, + String.upcase(currency), + Map.get(@default_rates, String.upcase(currency), 0.80) + ) + end + + # Fetch from frankfurter.app API + defp fetch_from_api do + url = "#{@api_base}/latest?from=USD&to=GBP,EUR" + + case Req.get(url, receive_timeout: 10_000) do + {:ok, %{status: 200, body: %{"rates" => rates}}} -> + # API returns rates FROM USD, so "GBP" value is the USD→GBP multiplier + gbp_per_usd = rates["GBP"] + # Derive EUR→GBP: if 1 USD = X GBP and 1 USD = Y EUR, then 1 EUR = X/Y GBP + eur_rate = rates["EUR"] + + eur_to_gbp = + if eur_rate && eur_rate > 0, + do: gbp_per_usd / eur_rate, + else: @default_rates["EUR"] + + {:ok, %{"USD" => gbp_per_usd, "EUR" => eur_to_gbp}} + + {:ok, %{status: status}} -> + {:error, {:http_status, status}} + + {:error, reason} -> + {:error, reason} + end + end + + defp cache_rates(rates) do + Enum.each(rates, fn {currency, rate} -> + key = @settings_prefix <> String.downcase(currency) <> "_to_gbp" + Settings.put_setting(key, Float.to_string(rate)) + end) + end +end diff --git a/lib/simpleshop_theme/orders/order.ex b/lib/simpleshop_theme/orders/order.ex index 929f907..acd50c7 100644 --- a/lib/simpleshop_theme/orders/order.ex +++ b/lib/simpleshop_theme/orders/order.ex @@ -18,6 +18,7 @@ defmodule SimpleshopTheme.Orders.Order do field :customer_email, :string field :shipping_address, :map, default: %{} field :subtotal, :integer + field :shipping_cost, :integer field :total, :integer field :currency, :string, default: "gbp" field :metadata, :map, default: %{} @@ -48,6 +49,7 @@ defmodule SimpleshopTheme.Orders.Order do :customer_email, :shipping_address, :subtotal, + :shipping_cost, :total, :currency, :metadata diff --git a/lib/simpleshop_theme/providers/printify.ex b/lib/simpleshop_theme/providers/printify.ex index 0037761..4137f56 100644 --- a/lib/simpleshop_theme/providers/printify.ex +++ b/lib/simpleshop_theme/providers/printify.ex @@ -10,6 +10,8 @@ defmodule SimpleshopTheme.Providers.Printify do alias SimpleshopTheme.Clients.Printify, as: Client alias SimpleshopTheme.Products.ProviderConnection + require Logger + @impl true def provider_type, do: "printify" @@ -114,6 +116,101 @@ defmodule SimpleshopTheme.Providers.Printify do end end + # ============================================================================= + # Shipping Rates + # ============================================================================= + + @impl true + def fetch_shipping_rates(%ProviderConnection{} = conn, products) when is_list(products) do + with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn), + :ok <- set_api_key(api_key) do + pairs = extract_blueprint_provider_pairs(products) + Logger.info("Fetching shipping rates for #{length(pairs)} blueprint/provider pairs") + + rates = + pairs + |> Enum.with_index() + |> Enum.flat_map(fn {pair, index} -> + # Rate limit: 100ms between requests + if index > 0, do: Process.sleep(100) + fetch_shipping_for_pair(pair) + end) + + {:ok, rates} + else + nil -> {:error, :no_api_key} + end + end + + defp extract_blueprint_provider_pairs(products) do + products + |> Enum.flat_map(fn product -> + provider_data = product[:provider_data] || %{} + blueprint_id = provider_data[:blueprint_id] || provider_data["blueprint_id"] + print_provider_id = provider_data[:print_provider_id] || provider_data["print_provider_id"] + + if blueprint_id && print_provider_id do + [{blueprint_id, print_provider_id}] + else + [] + end + end) + |> Enum.uniq() + end + + defp fetch_shipping_for_pair({blueprint_id, print_provider_id}) do + case Client.get_shipping(blueprint_id, print_provider_id) do + {:ok, response} -> + normalize_shipping_response(blueprint_id, print_provider_id, response) + + {:error, reason} -> + Logger.warning( + "Failed to fetch shipping for blueprint #{blueprint_id}, " <> + "provider #{print_provider_id}: #{inspect(reason)}" + ) + + [] + end + end + + defp normalize_shipping_response(blueprint_id, print_provider_id, response) do + handling_time_days = + case response["handling_time"] do + %{"value" => value, "unit" => "day"} -> value + _ -> nil + end + + profiles = response["profiles"] || [] + + # For each profile, expand countries into individual rate maps. + # Then group by country and take the max first_item_cost across profiles + # (conservative estimate across variant groups). + profiles + |> Enum.flat_map(fn profile -> + countries = profile["countries"] || [] + first_cost = get_in(profile, ["first_item", "cost"]) || 0 + additional_cost = get_in(profile, ["additional_items", "cost"]) || 0 + currency = get_in(profile, ["first_item", "currency"]) || "USD" + + Enum.map(countries, fn country -> + %{ + blueprint_id: blueprint_id, + print_provider_id: print_provider_id, + country_code: country, + first_item_cost: first_cost, + additional_item_cost: additional_cost, + currency: currency, + handling_time_days: handling_time_days + } + end) + end) + |> Enum.group_by(& &1.country_code) + |> Enum.map(fn {_country, country_rates} -> + # Take the max first_item_cost across variant groups for this country + Enum.max_by(country_rates, & &1.first_item_cost) + end) + end + # ============================================================================= # Webhook Registration # ============================================================================= diff --git a/lib/simpleshop_theme/providers/provider.ex b/lib/simpleshop_theme/providers/provider.ex index d535c2c..868ba94 100644 --- a/lib/simpleshop_theme/providers/provider.ex +++ b/lib/simpleshop_theme/providers/provider.ex @@ -57,6 +57,21 @@ defmodule SimpleshopTheme.Providers.Provider do @callback get_order_status(ProviderConnection.t(), provider_order_id :: String.t()) :: {:ok, map()} | {:error, term()} + @doc """ + Fetches shipping rates from the provider for the given products. + + Takes the connection and the already-fetched product list (from fetch_products). + Returns normalized rate maps with keys: blueprint_id, print_provider_id, + country_code, first_item_cost, additional_item_cost, currency, handling_time_days. + + Optional — providers that don't support shipping rate lookup can skip this. + The sync worker checks `function_exported?/3` before calling. + """ + @callback fetch_shipping_rates(ProviderConnection.t(), products :: [map()]) :: + {:ok, [map()]} | {:error, term()} + + @optional_callbacks [fetch_shipping_rates: 2] + @doc """ Returns the provider module for a given provider type. diff --git a/lib/simpleshop_theme/shipping.ex b/lib/simpleshop_theme/shipping.ex new file mode 100644 index 0000000..3c18141 --- /dev/null +++ b/lib/simpleshop_theme/shipping.ex @@ -0,0 +1,357 @@ +defmodule SimpleshopTheme.Shipping do + @moduledoc """ + The Shipping context. + + Manages cached shipping rates from POD providers and calculates + shipping estimates for cart and checkout. + """ + + import Ecto.Query + alias SimpleshopTheme.ExchangeRate + alias SimpleshopTheme.Repo + alias SimpleshopTheme.Shipping.ShippingRate + alias SimpleshopTheme.Products + alias SimpleshopTheme.Settings + + require Logger + + @country_names %{ + "AT" => "Austria", + "AU" => "Australia", + "BE" => "Belgium", + "BG" => "Bulgaria", + "CA" => "Canada", + "CH" => "Switzerland", + "CY" => "Cyprus", + "CZ" => "Czechia", + "DE" => "Germany", + "DK" => "Denmark", + "EE" => "Estonia", + "ES" => "Spain", + "FI" => "Finland", + "FR" => "France", + "GB" => "United Kingdom", + "GR" => "Greece", + "HR" => "Croatia", + "HU" => "Hungary", + "IE" => "Ireland", + "IT" => "Italy", + "JP" => "Japan", + "LT" => "Lithuania", + "LU" => "Luxembourg", + "LV" => "Latvia", + "MT" => "Malta", + "NL" => "Netherlands", + "NO" => "Norway", + "NZ" => "New Zealand", + "PL" => "Poland", + "PT" => "Portugal", + "RO" => "Romania", + "SE" => "Sweden", + "SI" => "Slovenia", + "SK" => "Slovakia", + "US" => "United States" + } + + # ============================================================================= + # Rate Storage + # ============================================================================= + + @default_buffer_percent 5 + + @doc """ + Bulk upserts shipping rates for a provider connection. + + When `exchange_rates` are provided (e.g. `%{"USD" => 0.79}`), rates are + converted to GBP at insert time with a buffer (default #{@default_buffer_percent}%). + This locks in the exchange rate at sync time rather than at display time. + + Existing rates for the same (connection, blueprint, provider, country) combo + are replaced. Returns the number of upserted rows. + """ + def upsert_rates(provider_connection_id, rates, exchange_rates \\ nil) + when is_list(rates) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + buffer = get_buffer_percent() + + entries = + Enum.map(rates, fn rate -> + {first, additional, currency} = + convert_rate_to_gbp(rate, exchange_rates, buffer) + + %{ + id: Ecto.UUID.generate(), + provider_connection_id: provider_connection_id, + blueprint_id: rate.blueprint_id, + print_provider_id: rate.print_provider_id, + country_code: rate.country_code, + first_item_cost: first, + additional_item_cost: additional, + currency: currency, + handling_time_days: rate[:handling_time_days], + inserted_at: now, + updated_at: now + } + end) + + {count, _} = + Repo.insert_all(ShippingRate, entries, + on_conflict: + {:replace, + [ + :first_item_cost, + :additional_item_cost, + :currency, + :handling_time_days, + :updated_at + ]}, + conflict_target: [ + :provider_connection_id, + :blueprint_id, + :print_provider_id, + :country_code + ] + ) + + Logger.info("Upserted #{count} shipping rates for connection #{provider_connection_id}") + {:ok, count} + end + + @doc """ + Gets a single shipping rate for a blueprint/provider/country combo. + + Falls back to `REST_OF_THE_WORLD` if no exact country match exists + (Printify uses this as a catch-all for unlisted countries). + """ + def get_rate(blueprint_id, print_provider_id, country_code) do + ShippingRate + |> where( + [r], + r.blueprint_id == ^blueprint_id and + r.print_provider_id == ^print_provider_id and + r.country_code == ^country_code + ) + |> limit(1) + |> Repo.one() + |> case do + nil when country_code != "REST_OF_THE_WORLD" -> + get_rate(blueprint_id, print_provider_id, "REST_OF_THE_WORLD") + + result -> + result + end + end + + # ============================================================================= + # Cart Calculation + # ============================================================================= + + @doc """ + Calculates the shipping estimate for a cart. + + Takes a list of hydrated cart items (with variant_id, quantity) and a + country code. Groups items by print provider, looks up rates, and + returns the total in the shop's currency (pence). + + Returns `{:ok, cost_pence}` or `{:error, :rates_not_found}`. + """ + def calculate_for_cart([], _country_code), do: {:ok, 0} + + def calculate_for_cart(cart_items, country_code) do + variant_ids = Enum.map(cart_items, & &1.variant_id) + variants_map = Products.get_variants_with_products(variant_ids) + + # Build list of {print_provider_id, blueprint_id, quantity, variant_id} + items_with_provider = + Enum.flat_map(cart_items, fn item -> + case Map.get(variants_map, item.variant_id) do + nil -> + [] + + variant -> + provider_data = variant.product.provider_data || %{} + blueprint_id = provider_data["blueprint_id"] + print_provider_id = provider_data["print_provider_id"] + + if blueprint_id && print_provider_id do + [ + %{ + print_provider_id: print_provider_id, + blueprint_id: blueprint_id, + quantity: item.quantity + } + ] + else + [] + end + end + end) + + if Enum.empty?(items_with_provider) do + {:error, :rates_not_found} + else + calculate_grouped(items_with_provider, country_code) + end + end + + defp calculate_grouped(items, country_code) do + # Group by print_provider_id — items from same provider ship together + groups = Enum.group_by(items, & &1.print_provider_id) + + results = + Enum.map(groups, fn {_provider_id, group_items} -> + calculate_provider_group(group_items, country_code) + end) + + case Enum.find(results, &match?({:error, _}, &1)) do + nil -> + total = results |> Enum.map(fn {:ok, cost} -> cost end) |> Enum.sum() + {:ok, total} + + error -> + error + end + end + + defp calculate_provider_group(group_items, country_code) do + # Look up rates for each unique blueprint in the group + rates = + group_items + |> Enum.uniq_by(& &1.blueprint_id) + |> Enum.map(fn item -> + rate = get_rate(item.blueprint_id, item.print_provider_id, country_code) + {item, rate} + end) + + if Enum.any?(rates, fn {_item, rate} -> is_nil(rate) end) do + {:error, :rates_not_found} + else + # Take the highest first_item_cost across all blueprints in the group + {_best_item, best_rate} = Enum.max_by(rates, fn {_item, rate} -> rate.first_item_cost end) + + total_qty = Enum.reduce(group_items, 0, fn item, acc -> acc + item.quantity end) + + # Build a map of blueprint_id => rate for additional item costs + rate_by_blueprint = Map.new(rates, fn {item, rate} -> {item.blueprint_id, rate} end) + + # First item uses the highest first_item_cost + # Remaining items each use their own blueprint's additional_item_cost + additional_cost = + group_items + |> Enum.flat_map(fn item -> + rate = Map.get(rate_by_blueprint, item.blueprint_id) + List.duplicate(rate.additional_item_cost, item.quantity) + end) + |> Enum.sort(:desc) + # Drop the first item (covered by first_item_cost) + |> Enum.drop(1) + |> Enum.sum() + + cost = + if total_qty > 0 do + best_rate.first_item_cost + additional_cost + else + 0 + end + + # If rates were converted at sync time, currency is GBP and we're done. + # For legacy unconverted rates, convert now using cached exchange rates. + gbp_cost = + if best_rate.currency == "GBP" do + cost + else + ExchangeRate.rate_for(best_rate.currency) + |> then(&ceil(cost * &1)) + end + + {:ok, gbp_cost} + end + end + + # ============================================================================= + # Sync-time Currency Conversion + # ============================================================================= + + # Converts a rate to GBP at sync time using live exchange rates + buffer. + # Returns {first_item_cost, additional_item_cost, "GBP"}. + defp convert_rate_to_gbp(rate, exchange_rates, buffer_percent) + + defp convert_rate_to_gbp(rate, _exchange_rates, _buffer) + when rate.currency in ["GBP", "gbp"] do + {rate.first_item_cost, rate.additional_item_cost, "GBP"} + end + + defp convert_rate_to_gbp(rate, exchange_rates, buffer) when is_map(exchange_rates) do + fx = Map.get(exchange_rates, String.upcase(rate.currency), 0.80) + multiplier = fx * (1 + buffer / 100) + + { + ceil(rate.first_item_cost * multiplier), + ceil(rate.additional_item_cost * multiplier), + "GBP" + } + end + + # No exchange rates provided — store in original currency (legacy path) + defp convert_rate_to_gbp(rate, nil, _buffer) do + {rate.first_item_cost, rate.additional_item_cost, rate.currency} + end + + defp get_buffer_percent do + case Settings.get_setting("shipping_buffer_percent") do + nil -> @default_buffer_percent + val when is_binary(val) -> String.to_integer(val) + val when is_integer(val) -> val + end + end + + # ============================================================================= + # Queries + # ============================================================================= + + @doc """ + Returns a list of distinct country codes that have shipping rates. + + When `REST_OF_THE_WORLD` exists in the DB, all countries from the + known names map are included (they're covered by the fallback rate). + """ + def list_available_countries do + codes = + ShippingRate + |> distinct(true) + |> select([r], r.country_code) + |> order_by([r], r.country_code) + |> Repo.all() + + has_rest_of_world = "REST_OF_THE_WORLD" in codes + + codes + |> Enum.reject(&(&1 == "REST_OF_THE_WORLD")) + |> then(fn explicit -> + if has_rest_of_world do + Map.keys(@country_names) ++ explicit + else + explicit + end + end) + |> Enum.uniq() + |> Enum.sort() + end + + @doc """ + Returns `{code, name}` tuples for all countries with shipping rates, + sorted by name. + """ + def list_available_countries_with_names do + list_available_countries() + |> Enum.map(fn code -> {code, country_name(code)} end) + |> Enum.sort_by(&elem(&1, 1)) + end + + @doc """ + Returns the display name for a country code. + """ + def country_name(code) when is_binary(code) do + Map.get(@country_names, String.upcase(code), code) + end +end diff --git a/lib/simpleshop_theme/shipping/shipping_rate.ex b/lib/simpleshop_theme/shipping/shipping_rate.ex new file mode 100644 index 0000000..7db064e --- /dev/null +++ b/lib/simpleshop_theme/shipping/shipping_rate.ex @@ -0,0 +1,53 @@ +defmodule SimpleshopTheme.Shipping.ShippingRate do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "shipping_rates" do + field :blueprint_id, :integer + field :print_provider_id, :integer + field :country_code, :string + field :first_item_cost, :integer + field :additional_item_cost, :integer + field :currency, :string, default: "USD" + field :handling_time_days, :integer + + belongs_to :provider_connection, SimpleshopTheme.Products.ProviderConnection + + timestamps(type: :utc_datetime) + end + + def changeset(rate, attrs) do + rate + |> cast(attrs, [ + :provider_connection_id, + :blueprint_id, + :print_provider_id, + :country_code, + :first_item_cost, + :additional_item_cost, + :currency, + :handling_time_days + ]) + |> validate_required([ + :provider_connection_id, + :blueprint_id, + :print_provider_id, + :country_code, + :first_item_cost, + :additional_item_cost, + :currency + ]) + |> validate_number(:first_item_cost, greater_than_or_equal_to: 0) + |> validate_number(:additional_item_cost, greater_than_or_equal_to: 0) + |> foreign_key_constraint(:provider_connection_id) + |> unique_constraint([ + :provider_connection_id, + :blueprint_id, + :print_provider_id, + :country_code + ]) + end +end diff --git a/lib/simpleshop_theme/sync/product_sync_worker.ex b/lib/simpleshop_theme/sync/product_sync_worker.ex index 320256a..fddc394 100644 --- a/lib/simpleshop_theme/sync/product_sync_worker.ex +++ b/lib/simpleshop_theme/sync/product_sync_worker.ex @@ -96,6 +96,9 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do "#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors" ) + # Sync shipping rates (non-fatal — logged and skipped on failure) + sync_shipping_rates(conn, provider, products) + Products.update_sync_status(conn, "completed", DateTime.utc_now()) product_count = Products.count_products_for_connection(conn.id) broadcast_sync(conn.id, {:sync_status, "completed", product_count}) @@ -200,4 +203,29 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do # Recompute denormalized fields (cheapest_price, in_stock, on_sale) from variants Products.recompute_cached_fields(product) end + + defp sync_shipping_rates(conn, provider, products) do + if function_exported?(provider, :fetch_shipping_rates, 2) do + # Fetch live exchange rates so shipping costs are stored in GBP + {:ok, exchange_rates} = SimpleshopTheme.ExchangeRate.fetch_and_cache() + + case provider.fetch_shipping_rates(conn, products) do + {:ok, rates} when rates != [] -> + SimpleshopTheme.Shipping.upsert_rates(conn.id, rates, exchange_rates) + + {:ok, []} -> + Logger.info("No shipping rates returned for #{conn.provider_type}") + + {:error, reason} -> + Logger.warning( + "Shipping rate sync failed for #{conn.provider_type}: #{inspect(reason)}" + ) + end + end + rescue + e -> + Logger.error( + "Shipping rate sync crashed for #{conn.provider_type}: #{Exception.message(e)}" + ) + end end diff --git a/lib/simpleshop_theme/sync/scheduled_sync_worker.ex b/lib/simpleshop_theme/sync/scheduled_sync_worker.ex new file mode 100644 index 0000000..4eb2f0d --- /dev/null +++ b/lib/simpleshop_theme/sync/scheduled_sync_worker.ex @@ -0,0 +1,34 @@ +defmodule SimpleshopTheme.Sync.ScheduledSyncWorker do + @moduledoc """ + Oban cron worker for periodic product + shipping rate sync. + + Runs every 6 hours, enqueues a ProductSyncWorker for each enabled + provider connection. The :sync queue (concurrency 1) serialises + these with any manual syncs triggered from the admin UI. + """ + + use Oban.Worker, queue: :sync, max_attempts: 1 + + alias SimpleshopTheme.Products + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + connections = + Products.list_provider_connections() + |> Enum.filter(& &1.enabled) + + if Enum.empty?(connections) do + Logger.info("Scheduled sync: no enabled provider connections, skipping") + else + Logger.info("Scheduled sync: enqueuing sync for #{length(connections)} connection(s)") + + Enum.each(connections, fn conn -> + Products.enqueue_sync(conn) + end) + end + + :ok + end +end diff --git a/lib/simpleshop_theme_web/cart_hook.ex b/lib/simpleshop_theme_web/cart_hook.ex index a5770f2..3b885ca 100644 --- a/lib/simpleshop_theme_web/cart_hook.ex +++ b/lib/simpleshop_theme_web/cart_hook.ex @@ -9,6 +9,7 @@ defmodule SimpleshopThemeWeb.CartHook do - `open_cart_drawer` / `close_cart_drawer` - toggle drawer visibility - `remove_item` - remove item from cart - `increment` / `decrement` - change item quantity + - `change_country` - update shipping country - `{:cart_updated, cart}` info - cross-tab cart sync via PubSub LiveViews with custom cart logic (e.g. add_to_cart) can call @@ -19,12 +20,17 @@ defmodule SimpleshopThemeWeb.CartHook do import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1, push_event: 3] alias SimpleshopTheme.Cart + alias SimpleshopTheme.Shipping def on_mount(:mount_cart, _params, session, socket) do cart_items = Cart.get_from_session(session) + country_code = session["country_code"] || "GB" + available_countries = Shipping.list_available_countries_with_names() socket = socket + |> assign(:country_code, country_code) + |> assign(:available_countries, available_countries) |> update_cart_assigns(cart_items) |> assign(:cart_drawer_open, false) |> assign(:cart_status, nil) @@ -54,6 +60,16 @@ defmodule SimpleshopThemeWeb.CartHook do {:halt, assign(socket, :cart_drawer_open, false)} end + defp handle_cart_event("change_country", %{"country" => code}, socket) do + socket = + socket + |> assign(:country_code, code) + |> update_cart_assigns(socket.assigns.raw_cart) + |> push_event("persist_country", %{code: code}) + + {:halt, socket} + end + defp handle_cart_event("remove_item", %{"id" => variant_id}, socket) do cart = Cart.remove_item(socket.assigns.raw_cart, variant_id) @@ -107,12 +123,25 @@ defmodule SimpleshopThemeWeb.CartHook do """ def update_cart_assigns(socket, cart) do %{items: items, count: count, subtotal: subtotal} = Cart.build_state(cart) + country_code = socket.assigns[:country_code] || "GB" + subtotal_pence = Cart.calculate_subtotal(items) + + shipping_estimate = + case Shipping.calculate_for_cart(items, country_code) do + {:ok, cost} when cost > 0 -> cost + _ -> nil + end + + cart_total = + Cart.format_price(subtotal_pence + (shipping_estimate || 0)) socket |> assign(:raw_cart, cart) |> assign(:cart_items, items) |> assign(:cart_count, count) |> assign(:cart_subtotal, subtotal) + |> assign(:cart_total, cart_total) + |> assign(:shipping_estimate, shipping_estimate) end @doc """ diff --git a/lib/simpleshop_theme_web/components/page_templates/cart.html.heex b/lib/simpleshop_theme_web/components/page_templates/cart.html.heex index 276b91e..ae1a1d1 100644 --- a/lib/simpleshop_theme_web/components/page_templates/cart.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/cart.html.heex @@ -24,7 +24,13 @@
- <.order_summary subtotal={@cart_page_subtotal} mode={@mode} /> + <.order_summary + subtotal={@cart_page_subtotal} + shipping_estimate={assigns[:shipping_estimate]} + country_code={assigns[:country_code] || "GB"} + available_countries={assigns[:available_countries] || []} + mode={@mode} + />
<% end %> diff --git a/lib/simpleshop_theme_web/components/shop_components/cart.ex b/lib/simpleshop_theme_web/components/shop_components/cart.ex index e164265..022f9a2 100644 --- a/lib/simpleshop_theme_web/components/shop_components/cart.ex +++ b/lib/simpleshop_theme_web/components/shop_components/cart.ex @@ -34,15 +34,19 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do attr :cart_items, :list, default: [] attr :subtotal, :string, default: nil + attr :total, :string, default: nil attr :cart_count, :integer, default: 0 attr :cart_status, :string, default: nil attr :mode, :atom, default: :live attr :open, :boolean, default: false + attr :shipping_estimate, :integer, default: nil + attr :country_code, :string, default: "GB" + attr :available_countries, :list, default: [] def cart_drawer(assigns) do assigns = - assign_new(assigns, :display_subtotal, fn -> - assigns.subtotal || "£0.00" + assign_new(assigns, :display_total, fn -> + assigns.total || assigns.subtotal || "£0.00" end) ~H""" @@ -126,16 +130,18 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do class="cart-drawer-footer" style="padding: 1rem 1.5rem; border-top: 1px solid var(--t-border-default); background: var(--t-surface-sunken);" > -
- Delivery - Calculated at checkout -
+ <.delivery_line + shipping_estimate={@shipping_estimate} + country_code={@country_code} + available_countries={@available_countries} + mode={@mode} + />
- Subtotal - {@display_subtotal} + {if @shipping_estimate, do: "Estimated total", else: "Subtotal"} + {@display_total}
<%= if @mode == :preview do %>