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:
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
|
||||
Reference in New Issue
Block a user