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>
This commit is contained in:
jamey 2026-02-14 10:48:00 +00:00
parent 44933acebb
commit 5c2f70ce44
26 changed files with 1707 additions and 38 deletions

View File

@ -21,13 +21,13 @@
- Transactional emails (order confirmation, shipping notification) - Transactional emails (order confirmation, shipping notification)
- Demo content polished and ready for production - 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 ## Task list
Ordered by dependency level — admin shell chain first (unblocks most downstream work). 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 | | # | 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 | | ~~17~~ | ~~Wire shop LiveViews to DB queries (replace PreviewData indirection)~~ | — | 2-3h | done |
| | **Next up** | | | | | | **Next up** | | | |
| 16 | Variant refinement with live data | — | 2-3h | | | 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)** | | | | | | **CSS migration (after admin stable)** | | | |
| 19 | Admin design tokens (`admin-tokens.css`) | 12 | 30m | | | 19 | Admin design tokens (`admin-tokens.css`) | 12 | 30m | |
| 20 | Admin component styles (`app-admin.css`) | 19 | 3-4h | | | 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 | | | 22 | Remove DaisyUI | 21 | 1h | |
| 23 | CSS migration tests + visual QA | 22 | 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) ## 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 - Startup recovery for stale sync status
#### Future Enhancements (post-MVP) #### 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) - [ ] Pre-checkout variant validation (verify availability before order)
- [ ] Cost change monitoring/alerts (warn if Printify cost increased) - [ ] Cost change monitoring/alerts (warn if Printify cost increased)
- [ ] OAuth platform integration (appear in Printify's "Publish to" UI) - [ ] 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] Full ARIA combobox pattern (role=combobox, listbox, option, aria-selected)
- [x] SearchModal JS hook, `<.link navigate>` for client-side nav, 150ms debounce - [x] SearchModal JS hook, `<.link navigate>` for client-side nav, 150ms debounce
- [x] search.ex: transaction safety on reindex, public `remove_product/1` - [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 ### Page Editor
**Status:** Future (Tier 4) **Status:** Future (Tier 4)
@ -317,6 +341,8 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
| Feature | Commit | Notes | | 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 | | 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 | | 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 | | PageSpeed CI | 516d0d0 | `mix lighthouse` task, prod asset build, gzip, 99-100 mobile scores |

View File

@ -57,6 +57,10 @@ const CartPersist = {
body: JSON.stringify({items}) body: JSON.stringify({items})
}) })
}) })
this.handleEvent("persist_country", ({code}) => {
document.cookie = `shipping_country=${code};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax`
})
} }
} }

View File

@ -102,7 +102,8 @@ config :simpleshop_theme, Oban,
{Oban.Plugins.Lifeline, rescue_after: :timer.minutes(5)}, {Oban.Plugins.Lifeline, rescue_after: :timer.minutes(5)},
{Oban.Plugins.Cron, {Oban.Plugins.Cron,
crontab: [ crontab: [
{"*/30 * * * *", SimpleshopTheme.Orders.FulfilmentStatusWorker} {"*/30 * * * *", SimpleshopTheme.Orders.FulfilmentStatusWorker},
{"0 */6 * * *", SimpleshopTheme.Sync.ScheduledSyncWorker}
]} ]}
], ],
queues: [images: 2, sync: 1, checkout: 1] queues: [images: 2, sync: 1, checkout: 1]

253
docs/plans/shipping-sync.md Normal file
View File

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

View File

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

View File

@ -18,6 +18,7 @@ defmodule SimpleshopTheme.Orders.Order do
field :customer_email, :string field :customer_email, :string
field :shipping_address, :map, default: %{} field :shipping_address, :map, default: %{}
field :subtotal, :integer field :subtotal, :integer
field :shipping_cost, :integer
field :total, :integer field :total, :integer
field :currency, :string, default: "gbp" field :currency, :string, default: "gbp"
field :metadata, :map, default: %{} field :metadata, :map, default: %{}
@ -48,6 +49,7 @@ defmodule SimpleshopTheme.Orders.Order do
:customer_email, :customer_email,
:shipping_address, :shipping_address,
:subtotal, :subtotal,
:shipping_cost,
:total, :total,
:currency, :currency,
:metadata :metadata

View File

@ -10,6 +10,8 @@ defmodule SimpleshopTheme.Providers.Printify do
alias SimpleshopTheme.Clients.Printify, as: Client alias SimpleshopTheme.Clients.Printify, as: Client
alias SimpleshopTheme.Products.ProviderConnection alias SimpleshopTheme.Products.ProviderConnection
require Logger
@impl true @impl true
def provider_type, do: "printify" def provider_type, do: "printify"
@ -114,6 +116,101 @@ defmodule SimpleshopTheme.Providers.Printify do
end end
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 # Webhook Registration
# ============================================================================= # =============================================================================

View File

@ -57,6 +57,21 @@ defmodule SimpleshopTheme.Providers.Provider do
@callback get_order_status(ProviderConnection.t(), provider_order_id :: String.t()) :: @callback get_order_status(ProviderConnection.t(), provider_order_id :: String.t()) ::
{:ok, map()} | {:error, term()} {: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 """ @doc """
Returns the provider module for a given provider type. Returns the provider module for a given provider type.

View File

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

View File

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

View File

@ -96,6 +96,9 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
"#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors" "#{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()) Products.update_sync_status(conn, "completed", DateTime.utc_now())
product_count = Products.count_products_for_connection(conn.id) product_count = Products.count_products_for_connection(conn.id)
broadcast_sync(conn.id, {:sync_status, "completed", product_count}) 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 # Recompute denormalized fields (cheapest_price, in_stock, on_sale) from variants
Products.recompute_cached_fields(product) Products.recompute_cached_fields(product)
end 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 end

View File

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

View File

@ -9,6 +9,7 @@ defmodule SimpleshopThemeWeb.CartHook do
- `open_cart_drawer` / `close_cart_drawer` - toggle drawer visibility - `open_cart_drawer` / `close_cart_drawer` - toggle drawer visibility
- `remove_item` - remove item from cart - `remove_item` - remove item from cart
- `increment` / `decrement` - change item quantity - `increment` / `decrement` - change item quantity
- `change_country` - update shipping country
- `{:cart_updated, cart}` info - cross-tab cart sync via PubSub - `{:cart_updated, cart}` info - cross-tab cart sync via PubSub
LiveViews with custom cart logic (e.g. add_to_cart) can call 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] import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1, push_event: 3]
alias SimpleshopTheme.Cart alias SimpleshopTheme.Cart
alias SimpleshopTheme.Shipping
def on_mount(:mount_cart, _params, session, socket) do def on_mount(:mount_cart, _params, session, socket) do
cart_items = Cart.get_from_session(session) cart_items = Cart.get_from_session(session)
country_code = session["country_code"] || "GB"
available_countries = Shipping.list_available_countries_with_names()
socket = socket =
socket socket
|> assign(:country_code, country_code)
|> assign(:available_countries, available_countries)
|> update_cart_assigns(cart_items) |> update_cart_assigns(cart_items)
|> assign(:cart_drawer_open, false) |> assign(:cart_drawer_open, false)
|> assign(:cart_status, nil) |> assign(:cart_status, nil)
@ -54,6 +60,16 @@ defmodule SimpleshopThemeWeb.CartHook do
{:halt, assign(socket, :cart_drawer_open, false)} {:halt, assign(socket, :cart_drawer_open, false)}
end 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 defp handle_cart_event("remove_item", %{"id" => variant_id}, socket) do
cart = Cart.remove_item(socket.assigns.raw_cart, variant_id) 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 def update_cart_assigns(socket, cart) do
%{items: items, count: count, subtotal: subtotal} = Cart.build_state(cart) %{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 socket
|> assign(:raw_cart, cart) |> assign(:raw_cart, cart)
|> assign(:cart_items, items) |> assign(:cart_items, items)
|> assign(:cart_count, count) |> assign(:cart_count, count)
|> assign(:cart_subtotal, subtotal) |> assign(:cart_subtotal, subtotal)
|> assign(:cart_total, cart_total)
|> assign(:shipping_estimate, shipping_estimate)
end end
@doc """ @doc """

View File

@ -24,7 +24,13 @@
</div> </div>
<div> <div>
<.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}
/>
</div> </div>
</div> </div>
<% end %> <% end %>

View File

@ -34,15 +34,19 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
attr :cart_items, :list, default: [] attr :cart_items, :list, default: []
attr :subtotal, :string, default: nil attr :subtotal, :string, default: nil
attr :total, :string, default: nil
attr :cart_count, :integer, default: 0 attr :cart_count, :integer, default: 0
attr :cart_status, :string, default: nil attr :cart_status, :string, default: nil
attr :mode, :atom, default: :live attr :mode, :atom, default: :live
attr :open, :boolean, default: false 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 def cart_drawer(assigns) do
assigns = assigns =
assign_new(assigns, :display_subtotal, fn -> assign_new(assigns, :display_total, fn ->
assigns.subtotal || "£0.00" assigns.total || assigns.subtotal || "£0.00"
end) end)
~H""" ~H"""
@ -126,16 +130,18 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
class="cart-drawer-footer" class="cart-drawer-footer"
style="padding: 1rem 1.5rem; border-top: 1px solid var(--t-border-default); background: var(--t-surface-sunken);" style="padding: 1rem 1.5rem; border-top: 1px solid var(--t-border-default); background: var(--t-surface-sunken);"
> >
<div style="display: flex; justify-content: space-between; font-family: var(--t-font-body); font-size: var(--t-text-small); color: var(--t-text-secondary); margin-bottom: 0.5rem;"> <.delivery_line
<span>Delivery</span> shipping_estimate={@shipping_estimate}
<span>Calculated at checkout</span> country_code={@country_code}
</div> available_countries={@available_countries}
mode={@mode}
/>
<div <div
class="cart-drawer-total" class="cart-drawer-total"
style="display: flex; justify-content: space-between; font-family: var(--t-font-body); font-size: var(--t-text-base); font-weight: 600; color: var(--t-text-primary); margin-bottom: 1rem;" style="display: flex; justify-content: space-between; font-family: var(--t-font-body); font-size: var(--t-text-base); font-weight: 600; color: var(--t-text-primary); margin-bottom: 1rem;"
> >
<span>Subtotal</span> <span>{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}</span>
<span>{@display_subtotal}</span> <span>{@display_total}</span>
</div> </div>
<%= if @mode == :preview do %> <%= if @mode == :preview do %>
<button <button
@ -411,15 +417,55 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
ProductImage.direct_url(Product.primary_image(product), 400) ProductImage.direct_url(Product.primary_image(product), 400)
end end
# Shared delivery line used by both cart_drawer and order_summary.
# Shows a country <select> when rates are available, falls back to plain text.
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
attr :mode, :atom, default: :live
defp delivery_line(assigns) do
~H"""
<div
class="flex justify-between items-center"
style="font-family: var(--t-font-body); font-size: var(--t-text-small); color: var(--t-text-secondary);"
>
<span class="flex items-center gap-1">
Delivery to
<%= if @available_countries != [] and @mode != :preview do %>
<form phx-change="change_country" style="display: inline;">
<select
name="country"
aria-label="Delivery country"
style="appearance: auto; background: transparent; border: none; color: inherit; font: inherit; padding: 0; cursor: pointer; text-decoration: underline; text-underline-offset: 2px;"
>
<%= for {code, name} <- @available_countries do %>
<option value={code} selected={code == @country_code}>{name}</option>
<% end %>
</select>
</form>
<% else %>
<span>{SimpleshopTheme.Shipping.country_name(@country_code)}</span>
<% end %>
</span>
<%= if @shipping_estimate do %>
<span>{SimpleshopTheme.Cart.format_price(@shipping_estimate)}</span>
<% else %>
<span>Calculated at checkout</span>
<% end %>
</div>
"""
end
@doc """ @doc """
Renders the order summary card. Renders the order summary card.
## Attributes ## Attributes
* `subtotal` - Required. Subtotal amount (in pence/cents). * `subtotal` - Required. Subtotal amount (in pence/cents).
* `delivery` - Optional. Delivery cost. Defaults to 800 (£8.00). * `shipping_estimate` - Optional. Shipping estimate in pence.
* `vat` - Optional. VAT amount. Defaults to 720 (£7.20). * `country_code` - Optional. Current country code. Default "GB".
* `currency` - Optional. Currency symbol. Defaults to "£". * `available_countries` - Optional. List of `{code, name}` tuples.
* `mode` - Either `:live` (default) or `:preview`. * `mode` - Either `:live` (default) or `:preview`.
## Examples ## Examples
@ -427,9 +473,15 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
<.order_summary subtotal={3600} /> <.order_summary subtotal={3600} />
""" """
attr :subtotal, :integer, required: true attr :subtotal, :integer, required: true
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
attr :mode, :atom, default: :live attr :mode, :atom, default: :live
def order_summary(assigns) do def order_summary(assigns) do
assigns =
assign(assigns, :estimated_total, assigns.subtotal + (assigns.shipping_estimate || 0))
~H""" ~H"""
<.shop_card class="p-6 sticky top-4"> <.shop_card class="p-6 sticky top-4">
<h2 <h2
@ -446,17 +498,19 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
{SimpleshopTheme.Cart.format_price(@subtotal)} {SimpleshopTheme.Cart.format_price(@subtotal)}
</span> </span>
</div> </div>
<div class="flex justify-between"> <.delivery_line
<span style="color: var(--t-text-secondary);">Delivery</span> shipping_estimate={@shipping_estimate}
<span class="text-sm" style="color: var(--t-text-secondary);"> country_code={@country_code}
Calculated at checkout available_countries={@available_countries}
</span> mode={@mode}
</div> />
<div class="border-t pt-3" style="border-color: var(--t-border-default);"> <div class="border-t pt-3" style="border-color: var(--t-border-default);">
<div class="flex justify-between text-lg"> <div class="flex justify-between text-lg">
<span class="font-semibold" style="color: var(--t-text-primary);">Subtotal</span> <span class="font-semibold" style="color: var(--t-text-primary);">
{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}
</span>
<span class="font-bold" style="color: var(--t-text-primary);"> <span class="font-bold" style="color: var(--t-text-primary);">
{SimpleshopTheme.Cart.format_price(@subtotal)} {SimpleshopTheme.Cart.format_price(@estimated_total)}
</span> </span>
</div> </div>
</div> </div>

View File

@ -52,8 +52,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates # Keys accepted by shop_layout — used by layout_assigns/1 so page templates
# can spread assigns without listing each one explicitly. # can spread assigns without listing each one explicitly.
@layout_keys ~w(theme_settings logo_image header_image mode cart_items cart_count @layout_keys ~w(theme_settings logo_image header_image mode cart_items cart_count
cart_subtotal cart_drawer_open cart_status active_page error_page is_admin cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories)a search_query search_results search_open categories shipping_estimate
country_code available_countries)a
@doc """ @doc """
Extracts the assigns relevant to `shop_layout` from a full assigns map. Extracts the assigns relevant to `shop_layout` from a full assigns map.
@ -82,6 +83,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
attr :cart_items, :list, required: true attr :cart_items, :list, required: true
attr :cart_count, :integer, required: true attr :cart_count, :integer, required: true
attr :cart_subtotal, :string, required: true attr :cart_subtotal, :string, required: true
attr :cart_total, :string, default: nil
attr :cart_drawer_open, :boolean, default: false attr :cart_drawer_open, :boolean, default: false
attr :cart_status, :string, default: nil attr :cart_status, :string, default: nil
attr :active_page, :string, required: true attr :active_page, :string, required: true
@ -90,6 +92,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
attr :search_query, :string, default: "" attr :search_query, :string, default: ""
attr :search_results, :list, default: [] attr :search_results, :list, default: []
attr :search_open, :boolean, default: false attr :search_open, :boolean, default: false
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
slot :inner_block, required: true slot :inner_block, required: true
@ -128,10 +133,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
<.cart_drawer <.cart_drawer
cart_items={@cart_items} cart_items={@cart_items}
subtotal={@cart_subtotal} subtotal={@cart_subtotal}
total={@cart_total}
cart_count={@cart_count} cart_count={@cart_count}
mode={@mode} mode={@mode}
open={@cart_drawer_open} open={@cart_drawer_open}
cart_status={@cart_status} cart_status={@cart_status}
shipping_estimate={@shipping_estimate}
country_code={@country_code}
available_countries={@available_countries}
/> />
<.search_modal <.search_modal

View File

@ -3,6 +3,7 @@ defmodule SimpleshopThemeWeb.CheckoutController do
alias SimpleshopTheme.Cart alias SimpleshopTheme.Cart
alias SimpleshopTheme.Orders alias SimpleshopTheme.Orders
alias SimpleshopTheme.Shipping
require Logger require Logger
@ -54,7 +55,8 @@ defmodule SimpleshopThemeWeb.CheckoutController do
base_url = SimpleshopThemeWeb.Endpoint.url() base_url = SimpleshopThemeWeb.Endpoint.url()
params = %{ params =
%{
mode: "payment", mode: "payment",
line_items: line_items, line_items: line_items,
success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}", success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
@ -64,6 +66,7 @@ defmodule SimpleshopThemeWeb.CheckoutController do
allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"] allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"]
} }
} }
|> maybe_add_shipping_options(hydrated_items)
case Stripe.Checkout.Session.create(params) do case Stripe.Checkout.Session.create(params) do
{:ok, session} -> {:ok, session} ->
@ -89,4 +92,38 @@ defmodule SimpleshopThemeWeb.CheckoutController do
|> redirect(to: ~p"/cart") |> redirect(to: ~p"/cart")
end end
end end
defp maybe_add_shipping_options(params, hydrated_items) do
gb_result = Shipping.calculate_for_cart(hydrated_items, "GB")
us_result = Shipping.calculate_for_cart(hydrated_items, "US")
options =
[]
|> maybe_add_option(gb_result, "UK delivery", 5, 10)
|> maybe_add_option(us_result, "International delivery", 10, 20)
if options == [] do
params
else
Map.put(params, :shipping_options, options)
end
end
defp maybe_add_option(options, {:ok, cost}, name, min_days, max_days) when cost > 0 do
option = %{
shipping_rate_data: %{
type: "fixed_amount",
display_name: name,
fixed_amount: %{amount: cost, currency: "gbp"},
delivery_estimate: %{
minimum: %{unit: "business_day", value: min_days},
maximum: %{unit: "business_day", value: max_days}
}
}
}
options ++ [option]
end
defp maybe_add_option(options, _result, _name, _min, _max), do: options
end end

View File

@ -36,6 +36,9 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
payment_intent_id = session.payment_intent payment_intent_id = session.payment_intent
{:ok, order} = Orders.mark_paid(order, payment_intent_id) {:ok, order} = Orders.mark_paid(order, payment_intent_id)
# Update shipping cost from Stripe (if shipping options were presented)
order = update_shipping_cost(order, session)
# Update shipping address if collected by Stripe # Update shipping address if collected by Stripe
order = order =
if session.shipping_details do if session.shipping_details do
@ -111,4 +114,19 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
Orders.update_order(order, %{shipping_address: shipping_address}) Orders.update_order(order, %{shipping_address: shipping_address})
end end
defp update_shipping_cost(order, session) do
shipping_amount = get_in(session, [Access.key(:shipping_cost), Access.key(:amount_total)])
if is_integer(shipping_amount) and shipping_amount > 0 do
new_total = order.subtotal + shipping_amount
case Orders.update_order(order, %{shipping_cost: shipping_amount, total: new_total}) do
{:ok, updated} -> updated
{:error, _} -> order
end
else
order
end
end
end end

View File

@ -5,7 +5,7 @@ defmodule SimpleshopThemeWeb.Shop.Cart do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, assign(socket, :page_title, "Cart")} {:ok, assign(socket, page_title: "Cart")}
end end
@impl true @impl true

View File

@ -0,0 +1,55 @@
defmodule SimpleshopThemeWeb.Plugs.CountryDetect do
@moduledoc """
Plug that detects the visitor's country from cookies or Accept-Language.
Priority:
1. `shipping_country` cookie (set when user explicitly changes country)
2. Accept-Language header (locale tags like `en-GB` `GB`)
3. Falls back to `"GB"`
The result is stored in the session as `country_code` so LiveViews can
read it. Only runs once per session skips if `country_code` is already
set (unless the cookie has changed).
"""
import Plug.Conn
@default_country "GB"
@cookie_name "shipping_country"
def init(opts), do: opts
def call(conn, _opts) do
cookie_country = conn.cookies[@cookie_name]
session_country = get_session(conn, "country_code")
cond do
# Cookie takes priority — user explicitly chose this country
cookie_country not in [nil, ""] and cookie_country != session_country ->
put_session(conn, "country_code", cookie_country)
# Session already set and no cookie override
session_country != nil ->
conn
# First visit: detect from Accept-Language
true ->
country = detect_from_header(conn)
put_session(conn, "country_code", country)
end
end
defp detect_from_header(conn) do
conn
|> get_req_header("accept-language")
|> List.first("")
|> parse_country()
end
defp parse_country(header) do
case Regex.run(~r/[a-z]{2}-([A-Z]{2})/, header) do
[_, country] -> country
nil -> @default_country
end
end
end

View File

@ -13,6 +13,7 @@ defmodule SimpleshopThemeWeb.Router do
plug :protect_from_forgery plug :protect_from_forgery
plug :put_secure_browser_headers plug :put_secure_browser_headers
plug :fetch_current_scope_for_user plug :fetch_current_scope_for_user
plug SimpleshopThemeWeb.Plugs.CountryDetect
end end
pipeline :api do pipeline :api do

View File

@ -0,0 +1,30 @@
defmodule SimpleshopTheme.Repo.Migrations.CreateShippingRates do
use Ecto.Migration
def change do
create table(:shipping_rates, primary_key: false) do
add :id, :binary_id, primary_key: true
add :provider_connection_id,
references(:provider_connections, type: :binary_id, on_delete: :delete_all),
null: false
add :blueprint_id, :integer, null: false
add :print_provider_id, :integer, null: false
add :country_code, :string, null: false
add :first_item_cost, :integer, null: false
add :additional_item_cost, :integer, null: false
add :currency, :string, null: false, default: "USD"
add :handling_time_days, :integer
timestamps(type: :utc_datetime)
end
create unique_index(:shipping_rates, [
:provider_connection_id,
:blueprint_id,
:print_provider_id,
:country_code
])
end
end

View File

@ -0,0 +1,9 @@
defmodule SimpleshopTheme.Repo.Migrations.AddShippingCostToOrders do
use Ecto.Migration
def change do
alter table(:orders) do
add :shipping_cost, :integer
end
end
end

View File

@ -0,0 +1,310 @@
defmodule SimpleshopTheme.ShippingTest do
use SimpleshopTheme.DataCase, async: false
alias SimpleshopTheme.Shipping
alias SimpleshopTheme.Shipping.ShippingRate
import SimpleshopTheme.ProductsFixtures
describe "upsert_rates/2" do
test "inserts new rates" do
conn = provider_connection_fixture()
rates = [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
},
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "US",
first_item_cost: 650,
additional_item_cost: 150,
currency: "USD"
}
]
assert {:ok, 2} = Shipping.upsert_rates(conn.id, rates)
assert Repo.aggregate(ShippingRate, :count) == 2
end
test "replaces existing rates on conflict" do
conn = provider_connection_fixture()
rates = [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
}
]
{:ok, 1} = Shipping.upsert_rates(conn.id, rates)
updated_rates = [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 550,
additional_item_cost: 200,
currency: "GBP"
}
]
{:ok, 1} = Shipping.upsert_rates(conn.id, updated_rates)
assert Repo.aggregate(ShippingRate, :count) == 1
rate = Repo.one(ShippingRate)
assert rate.first_item_cost == 550
assert rate.additional_item_cost == 200
end
end
describe "get_rate/3" do
test "returns rate when it exists" do
conn = provider_connection_fixture()
{:ok, _} =
Shipping.upsert_rates(conn.id, [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
}
])
rate = Shipping.get_rate(100, 200, "GB")
assert rate.first_item_cost == 450
assert rate.country_code == "GB"
end
test "returns nil when no rate exists" do
assert Shipping.get_rate(999, 999, "XX") == nil
end
end
describe "calculate_for_cart/2" do
test "returns {:ok, 0} for empty cart" do
assert {:ok, 0} = Shipping.calculate_for_cart([], "GB")
end
test "calculates shipping for single item" do
conn = provider_connection_fixture()
product =
product_fixture(%{
provider_connection: conn,
provider_data: %{"blueprint_id" => 100, "print_provider_id" => 200}
})
variant = product_variant_fixture(%{product: product})
{:ok, _} =
Shipping.upsert_rates(conn.id, [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
}
])
cart_items = [%{variant_id: variant.id, quantity: 1}]
assert {:ok, 450} = Shipping.calculate_for_cart(cart_items, "GB")
end
test "calculates shipping for multiple quantities" do
conn = provider_connection_fixture()
product =
product_fixture(%{
provider_connection: conn,
provider_data: %{"blueprint_id" => 100, "print_provider_id" => 200}
})
variant = product_variant_fixture(%{product: product})
{:ok, _} =
Shipping.upsert_rates(conn.id, [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
}
])
cart_items = [%{variant_id: variant.id, quantity: 3}]
# first_item_cost + 2 * additional_item_cost = 450 + 200 = 650
assert {:ok, 650} = Shipping.calculate_for_cart(cart_items, "GB")
end
test "returns error when no rates found" do
conn = provider_connection_fixture()
product =
product_fixture(%{
provider_connection: conn,
provider_data: %{"blueprint_id" => 100, "print_provider_id" => 200}
})
variant = product_variant_fixture(%{product: product})
cart_items = [%{variant_id: variant.id, quantity: 1}]
assert {:error, :rates_not_found} = Shipping.calculate_for_cart(cart_items, "XX")
end
test "groups items by print provider" do
conn = provider_connection_fixture()
product1 =
product_fixture(%{
provider_connection: conn,
provider_data: %{"blueprint_id" => 100, "print_provider_id" => 200}
})
product2 =
product_fixture(%{
provider_connection: conn,
provider_data: %{"blueprint_id" => 101, "print_provider_id" => 200}
})
variant1 = product_variant_fixture(%{product: product1})
variant2 = product_variant_fixture(%{product: product2})
{:ok, _} =
Shipping.upsert_rates(conn.id, [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
},
%{
blueprint_id: 101,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 500,
additional_item_cost: 150,
currency: "GBP"
}
])
cart_items = [
%{variant_id: variant1.id, quantity: 1},
%{variant_id: variant2.id, quantity: 1}
]
# Same provider: highest first_item_cost (500) + 1 additional item cost
assert {:ok, cost} = Shipping.calculate_for_cart(cart_items, "GB")
# 500 (highest first item) + one additional item cost (100 or 150)
assert cost in [600, 650]
end
end
describe "upsert_rates/3 with exchange rates" do
test "converts rates to GBP at sync time with buffer" do
conn = provider_connection_fixture()
exchange_rates = %{"USD" => 0.80}
rates = [
%{
blueprint_id: 100,
print_provider_id: 1,
country_code: "US",
first_item_cost: 1000,
additional_item_cost: 500,
currency: "USD"
}
]
{:ok, 1} = Shipping.upsert_rates(conn.id, rates, exchange_rates)
rate = Shipping.get_rate(100, 1, "US")
assert rate.currency == "GBP"
# 1000 USD cents * 0.80 rate * 1.05 buffer, ceil'd
assert rate.first_item_cost == 841
assert rate.additional_item_cost == 421
end
test "leaves GBP rates unconverted" do
conn = provider_connection_fixture()
exchange_rates = %{"USD" => 0.80}
rates = [
%{
blueprint_id: 100,
print_provider_id: 1,
country_code: "GB",
first_item_cost: 500,
additional_item_cost: 200,
currency: "GBP"
}
]
{:ok, 1} = Shipping.upsert_rates(conn.id, rates, exchange_rates)
rate = Shipping.get_rate(100, 1, "GB")
assert rate.currency == "GBP"
assert rate.first_item_cost == 500
assert rate.additional_item_cost == 200
end
end
describe "list_available_countries/0" do
test "returns distinct country codes" do
conn = provider_connection_fixture()
{:ok, _} =
Shipping.upsert_rates(conn.id, [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
},
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "US",
first_item_cost: 650,
additional_item_cost: 150,
currency: "USD"
},
%{
blueprint_id: 101,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 500,
additional_item_cost: 100,
currency: "GBP"
}
])
countries = Shipping.list_available_countries()
assert "GB" in countries
assert "US" in countries
assert length(countries) == 2
end
end
end

View File

@ -0,0 +1,40 @@
defmodule SimpleshopTheme.Sync.ScheduledSyncWorkerTest do
use SimpleshopTheme.DataCase, async: false
use Oban.Testing, repo: SimpleshopTheme.Repo
alias SimpleshopTheme.Sync.ScheduledSyncWorker
alias SimpleshopTheme.Sync.ProductSyncWorker
import SimpleshopTheme.ProductsFixtures
describe "perform/1" do
test "enqueues sync for enabled connections" do
conn = provider_connection_fixture(%{enabled: true})
Oban.Testing.with_testing_mode(:manual, fn ->
assert :ok = perform_job(ScheduledSyncWorker, %{})
assert_enqueued(
worker: ProductSyncWorker,
args: %{provider_connection_id: conn.id}
)
end)
end
test "skips disabled connections" do
_conn = provider_connection_fixture(%{enabled: false})
Oban.Testing.with_testing_mode(:manual, fn ->
assert :ok = perform_job(ScheduledSyncWorker, %{})
refute_enqueued(worker: ProductSyncWorker)
end)
end
test "handles no connections gracefully" do
Oban.Testing.with_testing_mode(:manual, fn ->
assert :ok = perform_job(ScheduledSyncWorker, %{})
refute_enqueued(worker: ProductSyncWorker)
end)
end
end
end

View File

@ -0,0 +1,99 @@
defmodule SimpleshopThemeWeb.Plugs.CountryDetectTest do
use SimpleshopThemeWeb.ConnCase, async: true
alias SimpleshopThemeWeb.Plugs.CountryDetect
defp with_cookies(conn) do
Plug.Conn.fetch_cookies(conn)
end
describe "call/2" do
test "detects GB from en-GB header", %{conn: conn} do
conn =
conn
|> init_test_session(%{})
|> with_cookies()
|> put_req_header("accept-language", "en-GB,en;q=0.9")
|> CountryDetect.call([])
assert get_session(conn, "country_code") == "GB"
end
test "detects DE from de-DE header", %{conn: conn} do
conn =
conn
|> init_test_session(%{})
|> with_cookies()
|> put_req_header("accept-language", "de-DE,de;q=0.9,en;q=0.8")
|> CountryDetect.call([])
assert get_session(conn, "country_code") == "DE"
end
test "detects FR from fr-FR header", %{conn: conn} do
conn =
conn
|> init_test_session(%{})
|> with_cookies()
|> put_req_header("accept-language", "fr-FR,fr;q=0.9")
|> CountryDetect.call([])
assert get_session(conn, "country_code") == "FR"
end
test "defaults to GB when no country in header", %{conn: conn} do
conn =
conn
|> init_test_session(%{})
|> with_cookies()
|> put_req_header("accept-language", "en;q=0.9")
|> CountryDetect.call([])
assert get_session(conn, "country_code") == "GB"
end
test "defaults to GB when no Accept-Language header", %{conn: conn} do
conn =
conn
|> init_test_session(%{})
|> with_cookies()
|> CountryDetect.call([])
assert get_session(conn, "country_code") == "GB"
end
test "does not overwrite existing country_code in session", %{conn: conn} do
conn =
conn
|> init_test_session(%{"country_code" => "US"})
|> with_cookies()
|> put_req_header("accept-language", "en-GB,en;q=0.9")
|> CountryDetect.call([])
assert get_session(conn, "country_code") == "US"
end
test "cookie overrides session country", %{conn: conn} do
conn =
conn
|> init_test_session(%{"country_code" => "GB"})
|> put_req_cookie("shipping_country", "DE")
|> with_cookies()
|> CountryDetect.call([])
assert get_session(conn, "country_code") == "DE"
end
test "cookie overrides Accept-Language detection", %{conn: conn} do
conn =
conn
|> init_test_session(%{})
|> put_req_cookie("shipping_country", "US")
|> with_cookies()
|> put_req_header("accept-language", "en-GB,en;q=0.9")
|> CountryDetect.call([])
assert get_session(conn, "country_code") == "US"
end
end
end