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:
parent
44933acebb
commit
5c2f70ce44
36
PROGRESS.md
36
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 |
|
||||
|
||||
@ -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`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
253
docs/plans/shipping-sync.md
Normal file
253
docs/plans/shipping-sync.md
Normal 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
|
||||
102
lib/simpleshop_theme/exchange_rate.ex
Normal file
102
lib/simpleshop_theme/exchange_rate.ex
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
# =============================================================================
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
357
lib/simpleshop_theme/shipping.ex
Normal file
357
lib/simpleshop_theme/shipping.ex
Normal 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
|
||||
53
lib/simpleshop_theme/shipping/shipping_rate.ex
Normal file
53
lib/simpleshop_theme/shipping/shipping_rate.ex
Normal 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
|
||||
@ -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
|
||||
|
||||
34
lib/simpleshop_theme/sync/scheduled_sync_worker.ex
Normal file
34
lib/simpleshop_theme/sync/scheduled_sync_worker.ex
Normal 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
|
||||
@ -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 """
|
||||
|
||||
@ -24,7 +24,13 @@
|
||||
</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>
|
||||
<% end %>
|
||||
|
||||
@ -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);"
|
||||
>
|
||||
<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;">
|
||||
<span>Delivery</span>
|
||||
<span>Calculated at checkout</span>
|
||||
</div>
|
||||
<.delivery_line
|
||||
shipping_estimate={@shipping_estimate}
|
||||
country_code={@country_code}
|
||||
available_countries={@available_countries}
|
||||
mode={@mode}
|
||||
/>
|
||||
<div
|
||||
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;"
|
||||
>
|
||||
<span>Subtotal</span>
|
||||
<span>{@display_subtotal}</span>
|
||||
<span>{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}</span>
|
||||
<span>{@display_total}</span>
|
||||
</div>
|
||||
<%= if @mode == :preview do %>
|
||||
<button
|
||||
@ -411,15 +417,55 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
|
||||
ProductImage.direct_url(Product.primary_image(product), 400)
|
||||
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 """
|
||||
Renders the order summary card.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `subtotal` - Required. Subtotal amount (in pence/cents).
|
||||
* `delivery` - Optional. Delivery cost. Defaults to 800 (£8.00).
|
||||
* `vat` - Optional. VAT amount. Defaults to 720 (£7.20).
|
||||
* `currency` - Optional. Currency symbol. Defaults to "£".
|
||||
* `shipping_estimate` - Optional. Shipping estimate in pence.
|
||||
* `country_code` - Optional. Current country code. Default "GB".
|
||||
* `available_countries` - Optional. List of `{code, name}` tuples.
|
||||
* `mode` - Either `:live` (default) or `:preview`.
|
||||
|
||||
## Examples
|
||||
@ -427,9 +473,15 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
|
||||
<.order_summary subtotal={3600} />
|
||||
"""
|
||||
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
|
||||
|
||||
def order_summary(assigns) do
|
||||
assigns =
|
||||
assign(assigns, :estimated_total, assigns.subtotal + (assigns.shipping_estimate || 0))
|
||||
|
||||
~H"""
|
||||
<.shop_card class="p-6 sticky top-4">
|
||||
<h2
|
||||
@ -446,17 +498,19 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
|
||||
{SimpleshopTheme.Cart.format_price(@subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span style="color: var(--t-text-secondary);">Delivery</span>
|
||||
<span class="text-sm" style="color: var(--t-text-secondary);">
|
||||
Calculated at checkout
|
||||
</span>
|
||||
</div>
|
||||
<.delivery_line
|
||||
shipping_estimate={@shipping_estimate}
|
||||
country_code={@country_code}
|
||||
available_countries={@available_countries}
|
||||
mode={@mode}
|
||||
/>
|
||||
<div class="border-t pt-3" style="border-color: var(--t-border-default);">
|
||||
<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);">
|
||||
{SimpleshopTheme.Cart.format_price(@subtotal)}
|
||||
{SimpleshopTheme.Cart.format_price(@estimated_total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -52,8 +52,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates
|
||||
# can spread assigns without listing each one explicitly.
|
||||
@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
|
||||
search_query search_results search_open categories)a
|
||||
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
|
||||
search_query search_results search_open categories shipping_estimate
|
||||
country_code available_countries)a
|
||||
|
||||
@doc """
|
||||
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_count, :integer, required: true
|
||||
attr :cart_subtotal, :string, required: true
|
||||
attr :cart_total, :string, default: nil
|
||||
attr :cart_drawer_open, :boolean, default: false
|
||||
attr :cart_status, :string, default: nil
|
||||
attr :active_page, :string, required: true
|
||||
@ -90,6 +92,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
attr :search_query, :string, default: ""
|
||||
attr :search_results, :list, default: []
|
||||
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
|
||||
|
||||
@ -128,10 +133,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
<.cart_drawer
|
||||
cart_items={@cart_items}
|
||||
subtotal={@cart_subtotal}
|
||||
total={@cart_total}
|
||||
cart_count={@cart_count}
|
||||
mode={@mode}
|
||||
open={@cart_drawer_open}
|
||||
cart_status={@cart_status}
|
||||
shipping_estimate={@shipping_estimate}
|
||||
country_code={@country_code}
|
||||
available_countries={@available_countries}
|
||||
/>
|
||||
|
||||
<.search_modal
|
||||
|
||||
@ -3,6 +3,7 @@ defmodule SimpleshopThemeWeb.CheckoutController do
|
||||
|
||||
alias SimpleshopTheme.Cart
|
||||
alias SimpleshopTheme.Orders
|
||||
alias SimpleshopTheme.Shipping
|
||||
|
||||
require Logger
|
||||
|
||||
@ -54,7 +55,8 @@ defmodule SimpleshopThemeWeb.CheckoutController do
|
||||
|
||||
base_url = SimpleshopThemeWeb.Endpoint.url()
|
||||
|
||||
params = %{
|
||||
params =
|
||||
%{
|
||||
mode: "payment",
|
||||
line_items: line_items,
|
||||
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"]
|
||||
}
|
||||
}
|
||||
|> maybe_add_shipping_options(hydrated_items)
|
||||
|
||||
case Stripe.Checkout.Session.create(params) do
|
||||
{:ok, session} ->
|
||||
@ -89,4 +92,38 @@ defmodule SimpleshopThemeWeb.CheckoutController do
|
||||
|> redirect(to: ~p"/cart")
|
||||
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
|
||||
|
||||
@ -36,6 +36,9 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
|
||||
payment_intent_id = session.payment_intent
|
||||
{: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
|
||||
order =
|
||||
if session.shipping_details do
|
||||
@ -111,4 +114,19 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
|
||||
|
||||
Orders.update_order(order, %{shipping_address: shipping_address})
|
||||
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
|
||||
|
||||
@ -5,7 +5,7 @@ defmodule SimpleshopThemeWeb.Shop.Cart do
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :page_title, "Cart")}
|
||||
{:ok, assign(socket, page_title: "Cart")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
55
lib/simpleshop_theme_web/plugs/country_detect.ex
Normal file
55
lib/simpleshop_theme_web/plugs/country_detect.ex
Normal 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
|
||||
@ -13,6 +13,7 @@ defmodule SimpleshopThemeWeb.Router do
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :fetch_current_scope_for_user
|
||||
plug SimpleshopThemeWeb.Plugs.CountryDetect
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
310
test/simpleshop_theme/shipping_test.exs
Normal file
310
test/simpleshop_theme/shipping_test.exs
Normal 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
|
||||
40
test/simpleshop_theme/sync/scheduled_sync_worker_test.exs
Normal file
40
test/simpleshop_theme/sync/scheduled_sync_worker_test.exs
Normal 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
|
||||
99
test/simpleshop_theme_web/plugs/country_detect_test.exs
Normal file
99
test/simpleshop_theme_web/plugs/country_detect_test.exs
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user