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>
This commit is contained in:
49
lib/berrypod/orders/fulfilment_status_worker.ex
Normal file
49
lib/berrypod/orders/fulfilment_status_worker.ex
Normal file
@@ -0,0 +1,49 @@
|
||||
defmodule Berrypod.Orders.FulfilmentStatusWorker do
|
||||
@moduledoc """
|
||||
Oban Cron worker that polls the fulfilment provider for status updates.
|
||||
|
||||
Runs every 30 minutes as a fallback for missed webhook events.
|
||||
Only checks orders that are submitted or processing (i.e. awaiting
|
||||
further status transitions).
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :sync, max_attempts: 1
|
||||
|
||||
alias Berrypod.Orders
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
orders = Orders.list_submitted_orders()
|
||||
|
||||
if orders == [] do
|
||||
:ok
|
||||
else
|
||||
Logger.info("Polling fulfilment status for #{length(orders)} order(s)")
|
||||
|
||||
Enum.each(orders, fn order ->
|
||||
refresh_order(order)
|
||||
Process.sleep(200)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp refresh_order(order) do
|
||||
case Orders.refresh_fulfilment_status(order) do
|
||||
{:ok, updated} ->
|
||||
if updated.fulfilment_status != order.fulfilment_status do
|
||||
Logger.info(
|
||||
"Order #{order.order_number} status: #{order.fulfilment_status} → #{updated.fulfilment_status}"
|
||||
)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Failed to refresh status for order #{order.order_number}: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
80
lib/berrypod/orders/order.ex
Normal file
80
lib/berrypod/orders/order.ex
Normal file
@@ -0,0 +1,80 @@
|
||||
defmodule Berrypod.Orders.Order do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@payment_statuses ~w(pending paid failed refunded)
|
||||
@fulfilment_statuses ~w(unfulfilled submitted processing shipped delivered failed cancelled)
|
||||
|
||||
def fulfilment_statuses, do: @fulfilment_statuses
|
||||
|
||||
schema "orders" do
|
||||
field :order_number, :string
|
||||
field :stripe_session_id, :string
|
||||
field :stripe_payment_intent_id, :string
|
||||
field :payment_status, :string, default: "pending"
|
||||
field :customer_email, :string
|
||||
field :shipping_address, :map, default: %{}
|
||||
field :subtotal, :integer
|
||||
field :shipping_cost, :integer
|
||||
field :total, :integer
|
||||
field :currency, :string, default: "gbp"
|
||||
field :metadata, :map, default: %{}
|
||||
|
||||
# Fulfilment
|
||||
field :fulfilment_status, :string, default: "unfulfilled"
|
||||
field :provider_order_id, :string
|
||||
field :provider_status, :string
|
||||
field :fulfilment_error, :string
|
||||
field :tracking_number, :string
|
||||
field :tracking_url, :string
|
||||
field :submitted_at, :utc_datetime
|
||||
field :shipped_at, :utc_datetime
|
||||
field :delivered_at, :utc_datetime
|
||||
|
||||
has_many :items, Berrypod.Orders.OrderItem
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(order, attrs) do
|
||||
order
|
||||
|> cast(attrs, [
|
||||
:order_number,
|
||||
:stripe_session_id,
|
||||
:stripe_payment_intent_id,
|
||||
:payment_status,
|
||||
:customer_email,
|
||||
:shipping_address,
|
||||
:subtotal,
|
||||
:shipping_cost,
|
||||
:total,
|
||||
:currency,
|
||||
:metadata
|
||||
])
|
||||
|> validate_required([:order_number, :subtotal, :total, :currency])
|
||||
|> validate_inclusion(:payment_status, @payment_statuses)
|
||||
|> validate_number(:subtotal, greater_than_or_equal_to: 0)
|
||||
|> validate_number(:total, greater_than_or_equal_to: 0)
|
||||
|> unique_constraint(:order_number)
|
||||
|> unique_constraint(:stripe_session_id)
|
||||
end
|
||||
|
||||
def fulfilment_changeset(order, attrs) do
|
||||
order
|
||||
|> cast(attrs, [
|
||||
:fulfilment_status,
|
||||
:provider_order_id,
|
||||
:provider_status,
|
||||
:fulfilment_error,
|
||||
:tracking_number,
|
||||
:tracking_url,
|
||||
:submitted_at,
|
||||
:shipped_at,
|
||||
:delivered_at
|
||||
])
|
||||
|> validate_inclusion(:fulfilment_status, @fulfilment_statuses)
|
||||
end
|
||||
end
|
||||
27
lib/berrypod/orders/order_item.ex
Normal file
27
lib/berrypod/orders/order_item.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule Berrypod.Orders.OrderItem do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "order_items" do
|
||||
field :variant_id, :string
|
||||
field :product_name, :string
|
||||
field :variant_title, :string
|
||||
field :quantity, :integer
|
||||
field :unit_price, :integer
|
||||
|
||||
belongs_to :order, Berrypod.Orders.Order
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(item, attrs) do
|
||||
item
|
||||
|> cast(attrs, [:variant_id, :product_name, :variant_title, :quantity, :unit_price, :order_id])
|
||||
|> validate_required([:variant_id, :product_name, :quantity, :unit_price])
|
||||
|> validate_number(:quantity, greater_than: 0)
|
||||
|> validate_number(:unit_price, greater_than_or_equal_to: 0)
|
||||
end
|
||||
end
|
||||
130
lib/berrypod/orders/order_notifier.ex
Normal file
130
lib/berrypod/orders/order_notifier.ex
Normal file
@@ -0,0 +1,130 @@
|
||||
defmodule Berrypod.Orders.OrderNotifier do
|
||||
@moduledoc """
|
||||
Sends transactional emails for orders.
|
||||
|
||||
Order confirmation after payment, shipping notification when dispatched.
|
||||
"""
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Berrypod.Cart
|
||||
alias Berrypod.Mailer
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Sends an order confirmation email after successful payment.
|
||||
|
||||
Skips silently if the order has no customer email.
|
||||
"""
|
||||
def deliver_order_confirmation(%{customer_email: nil}), do: {:ok, :no_email}
|
||||
def deliver_order_confirmation(%{customer_email: ""}), do: {:ok, :no_email}
|
||||
|
||||
def deliver_order_confirmation(order) do
|
||||
subject = "Order confirmed - #{order.order_number}"
|
||||
|
||||
body = """
|
||||
==============================
|
||||
|
||||
Thanks for your order!
|
||||
|
||||
Order: #{order.order_number}
|
||||
|
||||
#{format_items(order.items)}
|
||||
Total: #{Cart.format_price(order.total)}
|
||||
|
||||
#{format_shipping_address(order.shipping_address)}
|
||||
We'll send you another email when your order ships.
|
||||
|
||||
==============================
|
||||
"""
|
||||
|
||||
deliver(order.customer_email, subject, body)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends a shipping notification with tracking info.
|
||||
|
||||
Skips silently if the order has no customer email.
|
||||
"""
|
||||
def deliver_shipping_notification(%{customer_email: nil}), do: {:ok, :no_email}
|
||||
def deliver_shipping_notification(%{customer_email: ""}), do: {:ok, :no_email}
|
||||
|
||||
def deliver_shipping_notification(order) do
|
||||
subject = "Your order has shipped - #{order.order_number}"
|
||||
|
||||
body = """
|
||||
==============================
|
||||
|
||||
Good news! Your order #{order.order_number} is on its way.
|
||||
|
||||
#{format_tracking(order)}
|
||||
Thanks for shopping with us.
|
||||
|
||||
==============================
|
||||
"""
|
||||
|
||||
deliver(order.customer_email, subject, body)
|
||||
end
|
||||
|
||||
# --- Private ---
|
||||
|
||||
defp deliver(recipient, subject, body) do
|
||||
email =
|
||||
new()
|
||||
|> to(recipient)
|
||||
|> from({"Berrypod", "contact@example.com"})
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _metadata} = result ->
|
||||
result
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.warning("Failed to send email to #{recipient}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp format_items(items) when is_list(items) do
|
||||
items
|
||||
|> Enum.map_join("\n", fn item ->
|
||||
price = Cart.format_price(item.unit_price * item.quantity)
|
||||
" #{item.quantity}x #{item.product_name} (#{item.variant_title}) - #{price}"
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_items(_), do: ""
|
||||
|
||||
defp format_shipping_address(address) when is_map(address) and map_size(address) > 0 do
|
||||
lines =
|
||||
[
|
||||
address["name"],
|
||||
address["line1"],
|
||||
address["line2"],
|
||||
[address["city"], address["postal_code"]] |> Enum.reject(&is_nil/1) |> Enum.join(" "),
|
||||
address["state"],
|
||||
address["country"]
|
||||
]
|
||||
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|
||||
|> Enum.map_join("\n", &" #{&1}")
|
||||
|
||||
"Shipping to:\n#{lines}\n\n"
|
||||
end
|
||||
|
||||
defp format_shipping_address(_), do: ""
|
||||
|
||||
defp format_tracking(order) do
|
||||
cond do
|
||||
order.tracking_url not in [nil, ""] and order.tracking_number not in [nil, ""] ->
|
||||
"Tracking: #{order.tracking_number}\n#{order.tracking_url}\n\n"
|
||||
|
||||
order.tracking_number not in [nil, ""] ->
|
||||
"Tracking: #{order.tracking_number}\n\n"
|
||||
|
||||
true ->
|
||||
"Tracking details will follow once the carrier updates.\n\n"
|
||||
end
|
||||
end
|
||||
end
|
||||
56
lib/berrypod/orders/order_submission_worker.ex
Normal file
56
lib/berrypod/orders/order_submission_worker.ex
Normal file
@@ -0,0 +1,56 @@
|
||||
defmodule Berrypod.Orders.OrderSubmissionWorker do
|
||||
@moduledoc """
|
||||
Oban worker for submitting paid orders to the fulfilment provider.
|
||||
|
||||
Enqueued after Stripe webhook confirms payment. Guards against
|
||||
missing orders, unpaid orders, and already-submitted orders.
|
||||
Retries up to 3 times with backoff for transient failures.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :checkout, max_attempts: 3
|
||||
|
||||
alias Berrypod.Orders
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"order_id" => order_id}}) do
|
||||
case Orders.get_order(order_id) do
|
||||
nil ->
|
||||
Logger.warning("Order submission: order #{order_id} not found")
|
||||
{:cancel, :order_not_found}
|
||||
|
||||
%{payment_status: status} when status != "paid" ->
|
||||
Logger.warning("Order submission: order #{order_id} not paid (#{status})")
|
||||
{:cancel, :not_paid}
|
||||
|
||||
%{provider_order_id: pid} when not is_nil(pid) ->
|
||||
Logger.info("Order submission: order #{order_id} already submitted")
|
||||
:ok
|
||||
|
||||
%{shipping_address: addr} when addr == %{} or is_nil(addr) ->
|
||||
Logger.warning("Order submission: order #{order_id} has no shipping address, will retry")
|
||||
{:error, :no_shipping_address}
|
||||
|
||||
order ->
|
||||
case Orders.submit_to_provider(order) do
|
||||
{:ok, updated} ->
|
||||
Logger.info(
|
||||
"Order #{updated.order_number} submitted to provider (#{updated.provider_order_id})"
|
||||
)
|
||||
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Order #{order.order_number} submission failed: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue(order_id) do
|
||||
%{order_id: order_id}
|
||||
|> new()
|
||||
|> Oban.insert()
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user