add shipping costs with live exchange rates and country detection

Shipping rates fetched from Printify during product sync, converted to
GBP at sync time using frankfurter.app ECB exchange rates with 5%
buffer. Cached in shipping_rates table per blueprint/provider/country.

Cart page shows shipping estimate with country selector (detected from
Accept-Language header, persisted in cookie). Stripe Checkout includes
shipping_options for UK domestic and international delivery. Order
shipping_cost extracted from Stripe on payment.

ScheduledSyncWorker runs every 6 hours via Oban cron to keep rates
and exchange rates fresh. REST_OF_THE_WORLD fallback covers unlisted
countries. 780 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-14 10:48:00 +00:00
parent 44933acebb
commit 5c2f70ce44
26 changed files with 1707 additions and 38 deletions

View File

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

View File

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

View File

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