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)
|
- 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 |
|
||||||
|
|||||||
@ -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`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
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 :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
|
||||||
|
|||||||
@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
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"
|
"#{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
|
||||||
|
|||||||
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
|
- `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 """
|
||||||
|
|||||||
@ -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 %>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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 :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
|
||||||
|
|||||||
@ -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