berrypod/lib/berrypod/shipping.ex
jamey 9528700862 rename project from SimpleshopTheme to Berrypod
All modules, configs, paths, and references updated.
836 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:23:15 +00:00

358 lines
10 KiB
Elixir

defmodule Berrypod.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 Berrypod.ExchangeRate
alias Berrypod.Repo
alias Berrypod.Shipping.ShippingRate
alias Berrypod.Products
alias Berrypod.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