feat: add Printify order submission and fulfilment tracking
Submit paid orders to Printify via provider API with idempotent guards, Stripe address mapping, and error handling. Track fulfilment status through submitted → processing → shipped → delivered via webhook-driven updates (primary) and Oban Cron polling fallback. - 9 fulfilment fields on orders (status, provider IDs, tracking, timestamps) - OrderSubmissionWorker with retry logic, auto-enqueued after Stripe payment - FulfilmentStatusWorker polls every 30 mins for missed webhook events - Printify order webhook handlers (sent-to-production, shipment, delivered) - Admin UI: fulfilment column in table, fulfilment card with tracking info, submit/retry and refresh buttons on order detail - Mox provider mocking for test isolation (Provider.for_type configurable) - 33 new tests (555 total), verified against real Printify API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
02cdc810f2
commit
3e19887499
30
PROGRESS.md
30
PROGRESS.md
@ -19,14 +19,14 @@
|
|||||||
- Search modal with keyboard shortcut
|
- Search modal with keyboard shortcut
|
||||||
- Demo content polished and ready for production
|
- Demo content polished and ready for production
|
||||||
|
|
||||||
**Next up:** Order management and fulfilment (Tier 1)
|
**Next up:** Transactional emails — order confirmation and shipping notifications (Tier 1, Roadmap #3)
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Tier 1 — MVP (can take real orders and fulfil them)
|
### Tier 1 — MVP (can take real orders and fulfil them)
|
||||||
|
|
||||||
1. **Order management admin** — Admin UI to view orders, filter by status, see order details and line items. The orders context and schemas exist, this is purely the admin LiveView.
|
1. ~~**Order management admin**~~ — ✅ Complete (02cdc81). Admin UI at `/admin/orders` with status filter tabs, streamed order table, and detail view showing items, totals, and shipping address.
|
||||||
2. **Orders & fulfilment** — Submit paid orders to Printify via their API. Track fulfilment status (submitted → in production → shipped → delivered). Handle shipping notifications and tracking numbers.
|
2. ~~**Orders & fulfilment**~~ — ✅ Complete. Submit paid orders to Printify, track fulfilment status (submitted → processing → shipped → delivered), webhook-driven status updates with polling fallback, admin UI with submit/refresh actions.
|
||||||
3. **Transactional emails** — Order confirmation email on payment. Shipping notification with tracking link. Use Swoosh (already configured) with a simple HTML template.
|
3. **Transactional emails** — Order confirmation email on payment. Shipping notification with tracking link. Use Swoosh (already configured) with a simple HTML template.
|
||||||
4. **Default content pages** — Static pages for terms of service, delivery & refunds policy, and privacy policy. Needed for legal compliance before taking real orders. Can be simple markdown-rendered pages initially, upgraded to editable via page editor later.
|
4. **Default content pages** — Static pages for terms of service, delivery & refunds policy, and privacy policy. Needed for legal compliance before taking real orders. Can be simple markdown-rendered pages initially, upgraded to editable via page editor later.
|
||||||
|
|
||||||
@ -117,7 +117,7 @@ See: [docs/plans/image-optimization.md](docs/plans/image-optimization.md) for im
|
|||||||
- [ ] OAuth platform integration (appear in Printify's "Publish to" UI)
|
- [ ] OAuth platform integration (appear in Printify's "Publish to" UI)
|
||||||
|
|
||||||
#### Technical Debt
|
#### Technical Debt
|
||||||
- [ ] Add HTTP mocking (Mox/Bypass) for Printify API tests
|
- [x] Mox provider mocking for fulfilment tests (Provider.for_type configurable via app env)
|
||||||
|
|
||||||
See: [docs/plans/products-context.md](docs/plans/products-context.md) for implementation details
|
See: [docs/plans/products-context.md](docs/plans/products-context.md) for implementation details
|
||||||
See: [docs/plans/printify-integration-research.md](docs/plans/printify-integration-research.md) for API research & risk analysis
|
See: [docs/plans/printify-integration-research.md](docs/plans/printify-integration-research.md) for API research & risk analysis
|
||||||
@ -172,13 +172,25 @@ See: [ROADMAP.md](ROADMAP.md) for design notes
|
|||||||
- CSSCache test startup crash fixed (handle_continue pattern)
|
- CSSCache test startup crash fixed (handle_continue pattern)
|
||||||
|
|
||||||
### Orders & Fulfilment
|
### Orders & Fulfilment
|
||||||
**Status:** In progress — schemas and checkout done, fulfilment pending (Tier 1)
|
**Status:** Complete (checkout, admin, fulfilment). Transactional emails pending (Roadmap #3).
|
||||||
|
|
||||||
- [x] Orders context with schemas (ff1bc48)
|
- [x] Orders context with schemas (ff1bc48)
|
||||||
- [x] Stripe Checkout integration with webhook handling
|
- [x] Stripe Checkout integration with webhook handling
|
||||||
- [ ] Order management admin UI (Roadmap #1)
|
- [x] Order management admin UI (02cdc81, Roadmap #1)
|
||||||
- [ ] Printify order submission (Roadmap #2)
|
- Order list with status filter tabs (all/paid/pending/failed/refunded) and counts
|
||||||
- [ ] Fulfilment status tracking (Roadmap #2)
|
- Streamed table with row click navigation to detail
|
||||||
|
- Order detail with info card, shipping address, line items table with totals
|
||||||
|
- Nav link in admin bar, 15 tests
|
||||||
|
- [x] Printify order submission and fulfilment tracking (Roadmap #2)
|
||||||
|
- 9 fulfilment fields on orders (status, provider_order_id, tracking, timestamps)
|
||||||
|
- `submit_to_provider/1` with idempotent guard, error handling, address mapping
|
||||||
|
- `refresh_fulfilment_status/1` polls provider for status updates
|
||||||
|
- OrderSubmissionWorker (Oban, :checkout queue, max_attempts: 3)
|
||||||
|
- FulfilmentStatusWorker (Oban Cron, every 30 mins, :sync queue)
|
||||||
|
- Printify order webhook handlers (sent-to-production, shipment:created, shipment:delivered)
|
||||||
|
- Stripe webhook auto-enqueues submission after payment confirmed
|
||||||
|
- Admin UI: fulfilment badge column, fulfilment card with tracking, submit/refresh buttons
|
||||||
|
- Mox provider mocking for test isolation, 33 new tests (555 total)
|
||||||
- [ ] Transactional emails (Roadmap #3)
|
- [ ] Transactional emails (Roadmap #3)
|
||||||
|
|
||||||
See: [docs/plans/products-context.md](docs/plans/products-context.md) for schema design
|
See: [docs/plans/products-context.md](docs/plans/products-context.md) for schema design
|
||||||
@ -196,6 +208,8 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
|
|||||||
|
|
||||||
| Feature | Commit | Notes |
|
| Feature | Commit | Notes |
|
||||||
|---------|--------|-------|
|
|---------|--------|-------|
|
||||||
|
| Printify order submission & fulfilment | — | Submit, track, webhooks, polling, admin UI, 33 tests |
|
||||||
|
| Order management admin | 02cdc81 | List/detail views, status filters, 15 tests |
|
||||||
| Encrypted settings & Stripe setup | eede9bb | Guided setup flow, encrypted secrets, admin credentials page |
|
| Encrypted settings & Stripe setup | eede9bb | Guided setup flow, encrypted secrets, admin credentials page |
|
||||||
| Stripe checkout & orders | ff1bc48 | Stripe Checkout, webhooks, order persistence |
|
| Stripe checkout & orders | ff1bc48 | Stripe Checkout, webhooks, order persistence |
|
||||||
| Demo content & link fixes | cff2170 | Broken links, placeholder text, responsive about image |
|
| Demo content & link fixes | cff2170 | Broken links, placeholder text, responsive about image |
|
||||||
|
|||||||
@ -92,7 +92,11 @@ config :simpleshop_theme, Oban,
|
|||||||
repo: SimpleshopTheme.Repo,
|
repo: SimpleshopTheme.Repo,
|
||||||
plugins: [
|
plugins: [
|
||||||
{Oban.Plugins.Pruner, max_age: 60},
|
{Oban.Plugins.Pruner, max_age: 60},
|
||||||
{Oban.Plugins.Lifeline, rescue_after: :timer.minutes(5)}
|
{Oban.Plugins.Lifeline, rescue_after: :timer.minutes(5)},
|
||||||
|
{Oban.Plugins.Cron,
|
||||||
|
crontab: [
|
||||||
|
{"*/30 * * * *", SimpleshopTheme.Orders.FulfilmentStatusWorker}
|
||||||
|
]}
|
||||||
],
|
],
|
||||||
queues: [images: 2, sync: 1, checkout: 1]
|
queues: [images: 2, sync: 1, checkout: 1]
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,18 @@ defmodule SimpleshopTheme.Orders do
|
|||||||
@moduledoc """
|
@moduledoc """
|
||||||
The Orders context.
|
The Orders context.
|
||||||
|
|
||||||
Handles order creation, payment status tracking, and order retrieval.
|
Handles order creation, payment status tracking, fulfilment submission,
|
||||||
Payment-provider agnostic — all Stripe-specific logic lives in controllers.
|
and order retrieval. Payment-provider agnostic — all Stripe-specific
|
||||||
|
logic lives in controllers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
alias SimpleshopTheme.Repo
|
alias SimpleshopTheme.Repo
|
||||||
alias SimpleshopTheme.Orders.{Order, OrderItem}
|
alias SimpleshopTheme.Orders.{Order, OrderItem}
|
||||||
|
alias SimpleshopTheme.Products
|
||||||
|
alias SimpleshopTheme.Providers.Provider
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Lists orders, optionally filtered by payment status.
|
Lists orders, optionally filtered by payment status.
|
||||||
@ -166,4 +171,186 @@ defmodule SimpleshopTheme.Orders do
|
|||||||
random = :crypto.strong_rand_bytes(2) |> Base.encode16()
|
random = :crypto.strong_rand_bytes(2) |> Base.encode16()
|
||||||
"SS-#{date}-#{random}"
|
"SS-#{date}-#{random}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fulfilment
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Submits an order to the fulfilment provider.
|
||||||
|
|
||||||
|
Looks up product variant data from order items, builds the provider payload,
|
||||||
|
and calls the provider's submit_order callback. Idempotent — returns {:ok, order}
|
||||||
|
if already submitted.
|
||||||
|
"""
|
||||||
|
def submit_to_provider(%Order{provider_order_id: pid} = order) when not is_nil(pid) do
|
||||||
|
{:ok, order}
|
||||||
|
end
|
||||||
|
|
||||||
|
def submit_to_provider(%Order{} = order) do
|
||||||
|
order = Repo.preload(order, :items)
|
||||||
|
|
||||||
|
with {:ok, conn} <- get_provider_connection(),
|
||||||
|
{:ok, provider} <- Provider.for_connection(conn),
|
||||||
|
{:ok, enriched_items} <- enrich_items(order.items),
|
||||||
|
order_data <- build_submission_data(order, enriched_items),
|
||||||
|
{:ok, %{provider_order_id: pid}} <- provider.submit_order(conn, order_data) do
|
||||||
|
update_fulfilment(order, %{
|
||||||
|
fulfilment_status: "submitted",
|
||||||
|
provider_order_id: pid,
|
||||||
|
fulfilment_error: nil,
|
||||||
|
submitted_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
})
|
||||||
|
else
|
||||||
|
{:error, reason} ->
|
||||||
|
error_msg = format_submission_error(reason)
|
||||||
|
|
||||||
|
update_fulfilment(order, %{
|
||||||
|
fulfilment_status: "failed",
|
||||||
|
fulfilment_error: error_msg
|
||||||
|
})
|
||||||
|
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Polls the provider for the current fulfilment status of an order.
|
||||||
|
Updates tracking info and timestamps on status transitions.
|
||||||
|
"""
|
||||||
|
def refresh_fulfilment_status(%Order{provider_order_id: nil} = order), do: {:ok, order}
|
||||||
|
|
||||||
|
def refresh_fulfilment_status(%Order{} = order) do
|
||||||
|
with {:ok, conn} <- get_provider_connection(),
|
||||||
|
{:ok, provider} <- Provider.for_connection(conn),
|
||||||
|
{:ok, status_data} <- provider.get_order_status(conn, order.provider_order_id) do
|
||||||
|
attrs =
|
||||||
|
%{
|
||||||
|
fulfilment_status: status_data.status,
|
||||||
|
provider_status: status_data.provider_status,
|
||||||
|
tracking_number: status_data.tracking_number,
|
||||||
|
tracking_url: status_data.tracking_url
|
||||||
|
}
|
||||||
|
|> maybe_set_timestamp(order)
|
||||||
|
|
||||||
|
update_fulfilment(order, attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates an order's fulfilment fields.
|
||||||
|
"""
|
||||||
|
def update_fulfilment(%Order{} = order, attrs) do
|
||||||
|
order
|
||||||
|
|> Order.fulfilment_changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists orders that need fulfilment status polling (submitted or processing).
|
||||||
|
"""
|
||||||
|
def list_submitted_orders do
|
||||||
|
from(o in Order,
|
||||||
|
where: o.fulfilment_status in ["submitted", "processing"],
|
||||||
|
where: not is_nil(o.provider_order_id),
|
||||||
|
order_by: [asc: o.submitted_at],
|
||||||
|
preload: :items
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets an order by its order number.
|
||||||
|
"""
|
||||||
|
def get_order_by_number(order_number) do
|
||||||
|
Order
|
||||||
|
|> where([o], o.order_number == ^order_number)
|
||||||
|
|> preload(:items)
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_provider_connection do
|
||||||
|
case Products.get_provider_connection_by_type("printify") do
|
||||||
|
nil -> {:error, :no_provider_connection}
|
||||||
|
%{enabled: false} -> {:error, :provider_disabled}
|
||||||
|
conn -> {:ok, conn}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp enrich_items(items) do
|
||||||
|
variant_ids = Enum.map(items, & &1.variant_id)
|
||||||
|
variants_map = Products.get_variants_with_products(variant_ids)
|
||||||
|
|
||||||
|
results =
|
||||||
|
Enum.map(items, fn item ->
|
||||||
|
case Map.get(variants_map, item.variant_id) do
|
||||||
|
nil -> {:error, {:variant_not_found, item.variant_id, item.product_name}}
|
||||||
|
variant -> {:ok, %{item: item, variant: variant}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
case Enum.find(results, &match?({:error, _}, &1)) do
|
||||||
|
nil -> {:ok, Enum.map(results, fn {:ok, e} -> e end)}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_submission_data(order, enriched_items) do
|
||||||
|
%{
|
||||||
|
order_number: order.order_number,
|
||||||
|
customer_email: order.customer_email,
|
||||||
|
shipping_address: order.shipping_address,
|
||||||
|
line_items:
|
||||||
|
Enum.map(enriched_items, fn %{item: item, variant: variant} ->
|
||||||
|
%{
|
||||||
|
provider_product_id: variant.product.provider_product_id,
|
||||||
|
provider_variant_id: variant.provider_variant_id,
|
||||||
|
quantity: item.quantity
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_submission_error({:variant_not_found, _id, name}) do
|
||||||
|
"Variant for '#{name}' no longer exists in the product catalog"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_submission_error(:no_provider_connection) do
|
||||||
|
"No fulfilment provider connected"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_submission_error(:provider_disabled) do
|
||||||
|
"Fulfilment provider is disabled"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_submission_error(:no_api_key) do
|
||||||
|
"Provider API key is missing"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_submission_error(:no_shop_id) do
|
||||||
|
"Provider shop ID is not configured"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_submission_error({status, body}) when is_integer(status) do
|
||||||
|
message = if is_map(body), do: body["message"] || body["error"], else: inspect(body)
|
||||||
|
"Provider API error (#{status}): #{message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_submission_error(reason) do
|
||||||
|
"Submission failed: #{inspect(reason)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_set_timestamp(attrs, order) do
|
||||||
|
attrs
|
||||||
|
|> maybe_set(:shipped_at, attrs[:fulfilment_status] == "shipped" and is_nil(order.shipped_at))
|
||||||
|
|> maybe_set(
|
||||||
|
:delivered_at,
|
||||||
|
attrs[:fulfilment_status] == "delivered" and is_nil(order.delivered_at)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_set(attrs, key, true),
|
||||||
|
do: Map.put(attrs, key, DateTime.utc_now() |> DateTime.truncate(:second))
|
||||||
|
|
||||||
|
defp maybe_set(attrs, _key, false), do: attrs
|
||||||
end
|
end
|
||||||
|
|||||||
46
lib/simpleshop_theme/orders/fulfilment_status_worker.ex
Normal file
46
lib/simpleshop_theme/orders/fulfilment_status_worker.ex
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
defmodule SimpleshopTheme.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 SimpleshopTheme.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 ->
|
||||||
|
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
|
||||||
|
|
||||||
|
Process.sleep(200)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -6,6 +6,9 @@ defmodule SimpleshopTheme.Orders.Order do
|
|||||||
@foreign_key_type :binary_id
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
@payment_statuses ~w(pending paid failed refunded)
|
@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
|
schema "orders" do
|
||||||
field :order_number, :string
|
field :order_number, :string
|
||||||
@ -19,6 +22,17 @@ defmodule SimpleshopTheme.Orders.Order do
|
|||||||
field :currency, :string, default: "gbp"
|
field :currency, :string, default: "gbp"
|
||||||
field :metadata, :map, default: %{}
|
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, SimpleshopTheme.Orders.OrderItem
|
has_many :items, SimpleshopTheme.Orders.OrderItem
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
@ -45,4 +59,20 @@ defmodule SimpleshopTheme.Orders.Order do
|
|||||||
|> unique_constraint(:order_number)
|
|> unique_constraint(:order_number)
|
||||||
|> unique_constraint(:stripe_session_id)
|
|> unique_constraint(:stripe_session_id)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
56
lib/simpleshop_theme/orders/order_submission_worker.ex
Normal file
56
lib/simpleshop_theme/orders/order_submission_worker.ex
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
defmodule SimpleshopTheme.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 SimpleshopTheme.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
|
||||||
@ -118,7 +118,14 @@ defmodule SimpleshopTheme.Providers.Printify do
|
|||||||
# Webhook Registration
|
# Webhook Registration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@webhook_events ["product:updated", "product:deleted", "product:publish:started"]
|
@webhook_events [
|
||||||
|
"product:updated",
|
||||||
|
"product:deleted",
|
||||||
|
"product:publish:started",
|
||||||
|
"order:sent-to-production",
|
||||||
|
"order:shipment:created",
|
||||||
|
"order:shipment:delivered"
|
||||||
|
]
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Registers webhooks for product events with Printify.
|
Registers webhooks for product events with Printify.
|
||||||
@ -337,15 +344,16 @@ defmodule SimpleshopTheme.Providers.Printify do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp map_order_status("pending"), do: "pending"
|
defp map_order_status("pending"), do: "submitted"
|
||||||
defp map_order_status("on-hold"), do: "pending"
|
defp map_order_status("on-hold"), do: "submitted"
|
||||||
defp map_order_status("payment-not-received"), do: "pending"
|
defp map_order_status("payment-not-received"), do: "submitted"
|
||||||
|
defp map_order_status("cost-calculation"), do: "submitted"
|
||||||
defp map_order_status("in-production"), do: "processing"
|
defp map_order_status("in-production"), do: "processing"
|
||||||
defp map_order_status("partially-shipped"), do: "processing"
|
defp map_order_status("partially-shipped"), do: "processing"
|
||||||
defp map_order_status("shipped"), do: "shipped"
|
defp map_order_status("shipped"), do: "shipped"
|
||||||
defp map_order_status("delivered"), do: "delivered"
|
defp map_order_status("delivered"), do: "delivered"
|
||||||
defp map_order_status("canceled"), do: "cancelled"
|
defp map_order_status("canceled"), do: "cancelled"
|
||||||
defp map_order_status(_), do: "pending"
|
defp map_order_status(_), do: "submitted"
|
||||||
|
|
||||||
defp extract_tracking(raw) do
|
defp extract_tracking(raw) do
|
||||||
case raw["shipments"] do
|
case raw["shipments"] do
|
||||||
@ -365,34 +373,72 @@ defmodule SimpleshopTheme.Providers.Printify do
|
|||||||
# Order Building
|
# Order Building
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
defp build_order_payload(order) do
|
defp build_order_payload(order_data) do
|
||||||
%{
|
%{
|
||||||
external_id: order.order_number,
|
external_id: order_data.order_number,
|
||||||
label: order.order_number,
|
label: order_data.order_number,
|
||||||
line_items:
|
line_items:
|
||||||
Enum.map(order.line_items, fn item ->
|
Enum.map(order_data.line_items, fn item ->
|
||||||
%{
|
%{
|
||||||
product_id: item.product_variant.product.provider_product_id,
|
product_id: item.provider_product_id,
|
||||||
variant_id: String.to_integer(item.product_variant.provider_variant_id),
|
variant_id: parse_variant_id(item.provider_variant_id),
|
||||||
quantity: item.quantity
|
quantity: item.quantity
|
||||||
}
|
}
|
||||||
end),
|
end),
|
||||||
shipping_method: 1,
|
shipping_method: 1,
|
||||||
address_to: %{
|
address_to: build_address(order_data.shipping_address, order_data.customer_email)
|
||||||
first_name: order.shipping_address["first_name"],
|
|
||||||
last_name: order.shipping_address["last_name"],
|
|
||||||
email: order.customer_email,
|
|
||||||
phone: order.shipping_address["phone"],
|
|
||||||
country: order.shipping_address["country"],
|
|
||||||
region: order.shipping_address["state"] || order.shipping_address["region"],
|
|
||||||
address1: order.shipping_address["address1"],
|
|
||||||
address2: order.shipping_address["address2"],
|
|
||||||
city: order.shipping_address["city"],
|
|
||||||
zip: order.shipping_address["zip"] || order.shipping_address["postal_code"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Maps Stripe shipping_details address fields to Printify's expected format.
|
||||||
|
# Stripe gives us: name, line1, line2, city, postal_code, state, country
|
||||||
|
# Printify wants: first_name, last_name, address1, address2, city, zip, region, country
|
||||||
|
defp build_address(address, email) when is_map(address) do
|
||||||
|
{first, last} = split_name(address["name"])
|
||||||
|
|
||||||
|
%{
|
||||||
|
first_name: first,
|
||||||
|
last_name: last,
|
||||||
|
email: email,
|
||||||
|
phone: address["phone"] || "",
|
||||||
|
country: address["country"] || "",
|
||||||
|
region: address["state"] || address["region"] || "",
|
||||||
|
address1: address["line1"] || address["address1"] || "",
|
||||||
|
address2: address["line2"] || address["address2"] || "",
|
||||||
|
city: address["city"] || "",
|
||||||
|
zip: address["postal_code"] || address["zip"] || ""
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_address(_address, email) do
|
||||||
|
%{
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: email,
|
||||||
|
phone: "",
|
||||||
|
country: "",
|
||||||
|
region: "",
|
||||||
|
address1: "",
|
||||||
|
address2: "",
|
||||||
|
city: "",
|
||||||
|
zip: ""
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp split_name(nil), do: {"", ""}
|
||||||
|
defp split_name(""), do: {"", ""}
|
||||||
|
|
||||||
|
defp split_name(name) do
|
||||||
|
case String.split(name, " ", parts: 2) do
|
||||||
|
[first] -> {first, ""}
|
||||||
|
[first, last] -> {first, last}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Printify variant IDs are integers, but we store them as strings
|
||||||
|
defp parse_variant_id(id) when is_integer(id), do: id
|
||||||
|
defp parse_variant_id(id) when is_binary(id), do: String.to_integer(id)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# API Key Management
|
# API Key Management
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -59,12 +59,28 @@ defmodule SimpleshopTheme.Providers.Provider do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the provider module for a given provider type.
|
Returns the provider module for a given provider type.
|
||||||
|
|
||||||
|
Checks `:provider_modules` application config first, allowing test
|
||||||
|
overrides via Mox. Falls back to hardcoded dispatch.
|
||||||
"""
|
"""
|
||||||
def for_type("printify"), do: {:ok, SimpleshopTheme.Providers.Printify}
|
def for_type(type) do
|
||||||
def for_type("gelato"), do: {:error, :not_implemented}
|
case Application.get_env(:simpleshop_theme, :provider_modules, %{}) do
|
||||||
def for_type("prodigi"), do: {:error, :not_implemented}
|
modules when is_map(modules) ->
|
||||||
def for_type("printful"), do: {:error, :not_implemented}
|
case Map.get(modules, type) do
|
||||||
def for_type(type), do: {:error, {:unknown_provider, type}}
|
nil -> default_for_type(type)
|
||||||
|
module -> {:ok, module}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
default_for_type(type)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp default_for_type("printify"), do: {:ok, SimpleshopTheme.Providers.Printify}
|
||||||
|
defp default_for_type("gelato"), do: {:error, :not_implemented}
|
||||||
|
defp default_for_type("prodigi"), do: {:error, :not_implemented}
|
||||||
|
defp default_for_type("printful"), do: {:error, :not_implemented}
|
||||||
|
defp default_for_type(type), do: {:error, {:unknown_provider, type}}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the provider module for a provider connection.
|
Returns the provider module for a provider connection.
|
||||||
|
|||||||
@ -3,6 +3,7 @@ defmodule SimpleshopTheme.Webhooks do
|
|||||||
Handles incoming webhook events from POD providers.
|
Handles incoming webhook events from POD providers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Orders
|
||||||
alias SimpleshopTheme.Products
|
alias SimpleshopTheme.Products
|
||||||
alias SimpleshopTheme.Sync.ProductSyncWorker
|
alias SimpleshopTheme.Sync.ProductSyncWorker
|
||||||
alias SimpleshopTheme.Webhooks.ProductDeleteWorker
|
alias SimpleshopTheme.Webhooks.ProductDeleteWorker
|
||||||
@ -14,6 +15,9 @@ defmodule SimpleshopTheme.Webhooks do
|
|||||||
|
|
||||||
Returns :ok or {:ok, job} on success, {:error, reason} on failure.
|
Returns :ok or {:ok, job} on success, {:error, reason} on failure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# --- Product events ---
|
||||||
|
|
||||||
def handle_printify_event("product:updated", %{"id" => _product_id}) do
|
def handle_printify_event("product:updated", %{"id" => _product_id}) do
|
||||||
enqueue_product_sync()
|
enqueue_product_sync()
|
||||||
end
|
end
|
||||||
@ -26,6 +30,43 @@ defmodule SimpleshopTheme.Webhooks do
|
|||||||
ProductDeleteWorker.enqueue(product_id)
|
ProductDeleteWorker.enqueue(product_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# --- Order events ---
|
||||||
|
|
||||||
|
def handle_printify_event("order:sent-to-production", resource) do
|
||||||
|
with {:ok, order} <- find_order_from_resource(resource) do
|
||||||
|
Orders.update_fulfilment(order, %{
|
||||||
|
fulfilment_status: "processing",
|
||||||
|
provider_status: "in-production"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_printify_event("order:shipment:created", resource) do
|
||||||
|
shipment = extract_shipment(resource)
|
||||||
|
|
||||||
|
with {:ok, order} <- find_order_from_resource(resource) do
|
||||||
|
Orders.update_fulfilment(order, %{
|
||||||
|
fulfilment_status: "shipped",
|
||||||
|
provider_status: "shipped",
|
||||||
|
tracking_number: shipment.tracking_number,
|
||||||
|
tracking_url: shipment.tracking_url,
|
||||||
|
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_printify_event("order:shipment:delivered", resource) do
|
||||||
|
with {:ok, order} <- find_order_from_resource(resource) do
|
||||||
|
Orders.update_fulfilment(order, %{
|
||||||
|
fulfilment_status: "delivered",
|
||||||
|
provider_status: "delivered",
|
||||||
|
delivered_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Catch-all ---
|
||||||
|
|
||||||
def handle_printify_event("shop:disconnected", _resource) do
|
def handle_printify_event("shop:disconnected", _resource) do
|
||||||
Logger.warning("Printify shop disconnected - manual intervention needed")
|
Logger.warning("Printify shop disconnected - manual intervention needed")
|
||||||
:ok
|
:ok
|
||||||
@ -36,10 +77,39 @@ defmodule SimpleshopTheme.Webhooks do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# --- Private helpers ---
|
||||||
|
|
||||||
defp enqueue_product_sync do
|
defp enqueue_product_sync do
|
||||||
case Products.get_provider_connection_by_type("printify") do
|
case Products.get_provider_connection_by_type("printify") do
|
||||||
nil -> {:error, :no_connection}
|
nil -> {:error, :no_connection}
|
||||||
conn -> ProductSyncWorker.enqueue(conn.id)
|
conn -> ProductSyncWorker.enqueue(conn.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Printify order webhooks include external_id (our order_number) in the resource
|
||||||
|
defp find_order_from_resource(%{"external_id" => external_id}) when is_binary(external_id) do
|
||||||
|
case Orders.get_order_by_number(external_id) do
|
||||||
|
nil ->
|
||||||
|
Logger.warning("Order webhook: no order found for external_id=#{external_id}")
|
||||||
|
{:error, :order_not_found}
|
||||||
|
|
||||||
|
order ->
|
||||||
|
{:ok, order}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_order_from_resource(resource) do
|
||||||
|
Logger.warning("Order webhook: missing external_id in resource #{inspect(resource)}")
|
||||||
|
{:error, :missing_external_id}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_shipment(resource) do
|
||||||
|
shipments = resource["shipments"] || []
|
||||||
|
shipment = List.last(shipments) || %{}
|
||||||
|
|
||||||
|
%{
|
||||||
|
tracking_number: shipment["tracking_number"],
|
||||||
|
tracking_url: shipment["tracking_url"]
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -2,6 +2,7 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
|
|||||||
use SimpleshopThemeWeb, :controller
|
use SimpleshopThemeWeb, :controller
|
||||||
|
|
||||||
alias SimpleshopTheme.Orders
|
alias SimpleshopTheme.Orders
|
||||||
|
alias SimpleshopTheme.Orders.OrderSubmissionWorker
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@ -36,14 +37,24 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
|
|||||||
{:ok, order} = Orders.mark_paid(order, payment_intent_id)
|
{:ok, order} = Orders.mark_paid(order, payment_intent_id)
|
||||||
|
|
||||||
# Update shipping address if collected by Stripe
|
# Update shipping address if collected by Stripe
|
||||||
if session.shipping_details do
|
order =
|
||||||
update_shipping(order, session.shipping_details)
|
if session.shipping_details do
|
||||||
end
|
{:ok, updated} = update_shipping(order, session.shipping_details)
|
||||||
|
updated
|
||||||
|
else
|
||||||
|
order
|
||||||
|
end
|
||||||
|
|
||||||
# Update customer email from Stripe session
|
# Update customer email from Stripe session
|
||||||
if session.customer_details && session.customer_details.email do
|
order =
|
||||||
Orders.update_order(order, %{customer_email: session.customer_details.email})
|
if session.customer_details && session.customer_details.email do
|
||||||
end
|
{:ok, updated} =
|
||||||
|
Orders.update_order(order, %{customer_email: session.customer_details.email})
|
||||||
|
|
||||||
|
updated
|
||||||
|
else
|
||||||
|
order
|
||||||
|
end
|
||||||
|
|
||||||
# Broadcast to success page via PubSub
|
# Broadcast to success page via PubSub
|
||||||
Phoenix.PubSub.broadcast(
|
Phoenix.PubSub.broadcast(
|
||||||
@ -52,6 +63,15 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
|
|||||||
{:order_paid, order}
|
{:order_paid, order}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Submit to fulfilment provider
|
||||||
|
if order.shipping_address && order.shipping_address != %{} do
|
||||||
|
OrderSubmissionWorker.enqueue(order.id)
|
||||||
|
else
|
||||||
|
Logger.warning(
|
||||||
|
"Order #{order.order_number} paid but no shipping address — manual submit needed"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
Logger.info("Order #{order.order_number} marked as paid")
|
Logger.info("Order #{order.order_number} marked as paid")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -79,7 +79,7 @@ defmodule SimpleshopThemeWeb.AdminLive.OrderShow do
|
|||||||
<:item :if={@order.shipping_address["city"]} title="City">
|
<:item :if={@order.shipping_address["city"]} title="City">
|
||||||
{@order.shipping_address["city"]}
|
{@order.shipping_address["city"]}
|
||||||
</:item>
|
</:item>
|
||||||
<:item :if={@order.shipping_address["state"]} title="State">
|
<:item :if={@order.shipping_address["state"] not in [nil, ""]} title="State">
|
||||||
{@order.shipping_address["state"]}
|
{@order.shipping_address["state"]}
|
||||||
</:item>
|
</:item>
|
||||||
<:item :if={@order.shipping_address["postal_code"]} title="Postcode">
|
<:item :if={@order.shipping_address["postal_code"]} title="Postcode">
|
||||||
@ -96,6 +96,64 @@ defmodule SimpleshopThemeWeb.AdminLive.OrderShow do
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%!-- fulfilment --%>
|
||||||
|
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="card-title text-base">Fulfilment</h3>
|
||||||
|
<.fulfilment_badge status={@order.fulfilment_status} />
|
||||||
|
</div>
|
||||||
|
<.list>
|
||||||
|
<:item :if={@order.provider_order_id} title="Provider order ID">
|
||||||
|
<code class="text-xs">{@order.provider_order_id}</code>
|
||||||
|
</:item>
|
||||||
|
<:item :if={@order.provider_status} title="Provider status">
|
||||||
|
{@order.provider_status}
|
||||||
|
</:item>
|
||||||
|
<:item :if={@order.submitted_at} title="Submitted">
|
||||||
|
{format_date(@order.submitted_at)}
|
||||||
|
</:item>
|
||||||
|
<:item :if={@order.tracking_number} title="Tracking">
|
||||||
|
<%= if @order.tracking_url do %>
|
||||||
|
<a href={@order.tracking_url} target="_blank" class="link link-primary">
|
||||||
|
{@order.tracking_number}
|
||||||
|
</a>
|
||||||
|
<% else %>
|
||||||
|
{@order.tracking_number}
|
||||||
|
<% end %>
|
||||||
|
</:item>
|
||||||
|
<:item :if={@order.shipped_at} title="Shipped">
|
||||||
|
{format_date(@order.shipped_at)}
|
||||||
|
</:item>
|
||||||
|
<:item :if={@order.delivered_at} title="Delivered">
|
||||||
|
{format_date(@order.delivered_at)}
|
||||||
|
</:item>
|
||||||
|
<:item :if={@order.fulfilment_error} title="Error">
|
||||||
|
<span class="text-error text-sm">{@order.fulfilment_error}</span>
|
||||||
|
</:item>
|
||||||
|
</.list>
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
:if={can_submit?(@order)}
|
||||||
|
phx-click="submit_to_provider"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
<.icon name="hero-paper-airplane-mini" class="size-4" />
|
||||||
|
{if @order.fulfilment_status == "failed",
|
||||||
|
do: "Retry submission",
|
||||||
|
else: "Submit to provider"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:if={can_refresh?(@order)}
|
||||||
|
phx-click="refresh_status"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
<.icon name="hero-arrow-path-mini" class="size-4" /> Refresh status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%!-- line items --%>
|
<%!-- line items --%>
|
||||||
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
|
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@ -136,6 +194,96 @@ defmodule SimpleshopThemeWeb.AdminLive.OrderShow do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("submit_to_provider", _params, socket) do
|
||||||
|
order = socket.assigns.order
|
||||||
|
|
||||||
|
case Orders.submit_to_provider(order) do
|
||||||
|
{:ok, updated} ->
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:order, updated)
|
||||||
|
|> put_flash(:info, "Order submitted to provider")
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
order = Orders.get_order(order.id)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:order, order)
|
||||||
|
|> put_flash(:error, order.fulfilment_error || "Submission failed")
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("refresh_status", _params, socket) do
|
||||||
|
order = socket.assigns.order
|
||||||
|
|
||||||
|
case Orders.refresh_fulfilment_status(order) do
|
||||||
|
{:ok, updated} ->
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:order, updated)
|
||||||
|
|> put_flash(:info, "Status refreshed")
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Failed to refresh: #{inspect(reason)}")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp can_submit?(order) do
|
||||||
|
order.payment_status == "paid" and order.fulfilment_status in ["unfulfilled", "failed"]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp can_refresh?(order) do
|
||||||
|
not is_nil(order.provider_order_id) and
|
||||||
|
order.fulfilment_status in ["submitted", "processing", "shipped"]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fulfilment_badge(assigns) do
|
||||||
|
{bg, text, ring, icon} =
|
||||||
|
case assigns.status do
|
||||||
|
"submitted" ->
|
||||||
|
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
|
||||||
|
|
||||||
|
"processing" ->
|
||||||
|
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
|
||||||
|
|
||||||
|
"shipped" ->
|
||||||
|
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
|
||||||
|
|
||||||
|
"delivered" ->
|
||||||
|
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||||
|
|
||||||
|
"failed" ->
|
||||||
|
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||||
|
|
||||||
|
"cancelled" ->
|
||||||
|
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-no-symbol-mini"}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-minus-circle-mini"}
|
||||||
|
end
|
||||||
|
|
||||||
|
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<span class={[
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||||
|
@bg,
|
||||||
|
@text,
|
||||||
|
@ring
|
||||||
|
]}>
|
||||||
|
<.icon name={@icon} class="size-3" /> {@status}
|
||||||
|
</span>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
defp status_badge(assigns) do
|
defp status_badge(assigns) do
|
||||||
{bg, text, ring, icon} =
|
{bg, text, ring, icon} =
|
||||||
case assigns.status do
|
case assigns.status do
|
||||||
|
|||||||
@ -86,6 +86,9 @@ defmodule SimpleshopThemeWeb.AdminLive.Orders do
|
|||||||
<:col :let={order} label="Customer">{order.customer_email || "—"}</:col>
|
<:col :let={order} label="Customer">{order.customer_email || "—"}</:col>
|
||||||
<:col :let={order} label="Total">{Cart.format_price(order.total)}</:col>
|
<:col :let={order} label="Total">{Cart.format_price(order.total)}</:col>
|
||||||
<:col :let={order} label="Status"><.status_badge status={order.payment_status} /></:col>
|
<:col :let={order} label="Status"><.status_badge status={order.payment_status} /></:col>
|
||||||
|
<:col :let={order} label="Fulfilment">
|
||||||
|
<.fulfilment_badge status={order.fulfilment_status} />
|
||||||
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
|
|
||||||
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60">
|
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60">
|
||||||
@ -156,6 +159,45 @@ defmodule SimpleshopThemeWeb.AdminLive.Orders do
|
|||||||
Calendar.strftime(datetime, "%d %b %Y %H:%M")
|
Calendar.strftime(datetime, "%d %b %Y %H:%M")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp fulfilment_badge(assigns) do
|
||||||
|
{bg, text, ring, icon} =
|
||||||
|
case assigns.status do
|
||||||
|
"submitted" ->
|
||||||
|
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
|
||||||
|
|
||||||
|
"processing" ->
|
||||||
|
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
|
||||||
|
|
||||||
|
"shipped" ->
|
||||||
|
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
|
||||||
|
|
||||||
|
"delivered" ->
|
||||||
|
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
||||||
|
|
||||||
|
"failed" ->
|
||||||
|
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
||||||
|
|
||||||
|
"cancelled" ->
|
||||||
|
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-no-symbol-mini"}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-minus-circle-mini"}
|
||||||
|
end
|
||||||
|
|
||||||
|
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<span class={[
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
||||||
|
@bg,
|
||||||
|
@text,
|
||||||
|
@ring
|
||||||
|
]}>
|
||||||
|
<.icon name={@icon} class="size-3" /> {@status}
|
||||||
|
</span>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
defp total_count(counts) do
|
defp total_count(counts) do
|
||||||
counts |> Map.values() |> Enum.sum()
|
counts |> Map.values() |> Enum.sum()
|
||||||
end
|
end
|
||||||
|
|||||||
1
mix.exs
1
mix.exs
@ -48,6 +48,7 @@ defmodule SimpleshopTheme.MixProject do
|
|||||||
{:phoenix_html, "~> 4.1"},
|
{:phoenix_html, "~> 4.1"},
|
||||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||||
{:phoenix_live_view, "~> 1.1.0"},
|
{:phoenix_live_view, "~> 1.1.0"},
|
||||||
|
{:mox, "~> 1.0", only: :test},
|
||||||
{:lazy_html, ">= 0.1.0", only: :test},
|
{:lazy_html, ">= 0.1.0", only: :test},
|
||||||
{:phoenix_live_dashboard, "~> 0.8.3"},
|
{:phoenix_live_dashboard, "~> 0.8.3"},
|
||||||
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
|
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
|
||||||
|
|||||||
2
mix.lock
2
mix.lock
@ -37,7 +37,9 @@
|
|||||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
|
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
|
||||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||||
|
"mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
|
||||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||||
|
"nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"},
|
||||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||||
"oban": {:hex, :oban, "2.20.2", "f23313d83b578305cafa825a036cad84e7e2d61549ecbece3a2e6526d347cc3b", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "523365ef0217781c061d15f496e3200a5f1b43e08b1a27c34799ef8bfe95815f"},
|
"oban": {:hex, :oban, "2.20.2", "f23313d83b578305cafa825a036cad84e7e2d61549ecbece3a2e6526d347cc3b", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "523365ef0217781c061d15f496e3200a5f1b43e08b1a27c34799ef8bfe95815f"},
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
defmodule SimpleshopTheme.Repo.Migrations.AddFulfilmentFieldsToOrders do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:orders) do
|
||||||
|
add :fulfilment_status, :string, null: false, default: "unfulfilled"
|
||||||
|
add :provider_order_id, :string
|
||||||
|
add :provider_status, :string
|
||||||
|
add :fulfilment_error, :string
|
||||||
|
add :tracking_number, :string
|
||||||
|
add :tracking_url, :string
|
||||||
|
add :submitted_at, :utc_datetime
|
||||||
|
add :shipped_at, :utc_datetime
|
||||||
|
add :delivered_at, :utc_datetime
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:orders, [:fulfilment_status])
|
||||||
|
create index(:orders, [:provider_order_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
defmodule SimpleshopTheme.Orders.FulfilmentStatusWorkerTest do
|
||||||
|
use SimpleshopTheme.DataCase, async: false
|
||||||
|
|
||||||
|
import Mox
|
||||||
|
import SimpleshopTheme.OrdersFixtures
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Orders
|
||||||
|
alias SimpleshopTheme.Orders.FulfilmentStatusWorker
|
||||||
|
alias SimpleshopTheme.Providers.MockProvider
|
||||||
|
|
||||||
|
setup :verify_on_exit!
|
||||||
|
|
||||||
|
setup do
|
||||||
|
Application.put_env(:simpleshop_theme, :provider_modules, %{
|
||||||
|
"printify" => MockProvider
|
||||||
|
})
|
||||||
|
|
||||||
|
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "no-op when no submitted orders" do
|
||||||
|
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates status from submitted to processing" do
|
||||||
|
{order, _variant, _product, _conn} = submitted_order_fixture()
|
||||||
|
|
||||||
|
expect(MockProvider, :get_order_status, fn _conn, provider_order_id ->
|
||||||
|
assert provider_order_id == order.provider_order_id
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
status: "processing",
|
||||||
|
provider_status: "in-production",
|
||||||
|
tracking_number: nil,
|
||||||
|
tracking_url: nil,
|
||||||
|
shipments: []
|
||||||
|
}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
|
||||||
|
|
||||||
|
updated = Orders.get_order(order.id)
|
||||||
|
assert updated.fulfilment_status == "processing"
|
||||||
|
assert updated.provider_status == "in-production"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sets tracking info when shipped" do
|
||||||
|
{order, _variant, _product, _conn} = submitted_order_fixture()
|
||||||
|
|
||||||
|
expect(MockProvider, :get_order_status, fn _conn, _pid ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
status: "shipped",
|
||||||
|
provider_status: "shipped",
|
||||||
|
tracking_number: "1Z999AA10123456784",
|
||||||
|
tracking_url: "https://tracking.example.com/1Z999AA10123456784",
|
||||||
|
shipments: []
|
||||||
|
}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
|
||||||
|
|
||||||
|
updated = Orders.get_order(order.id)
|
||||||
|
assert updated.fulfilment_status == "shipped"
|
||||||
|
assert updated.tracking_number == "1Z999AA10123456784"
|
||||||
|
assert updated.tracking_url == "https://tracking.example.com/1Z999AA10123456784"
|
||||||
|
assert updated.shipped_at != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles provider error gracefully" do
|
||||||
|
{_order, _variant, _product, _conn} = submitted_order_fixture()
|
||||||
|
|
||||||
|
expect(MockProvider, :get_order_status, fn _conn, _pid ->
|
||||||
|
{:error, {500, %{"message" => "Internal error"}}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Should not raise, just log the error
|
||||||
|
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
112
test/simpleshop_theme/orders/order_submission_worker_test.exs
Normal file
112
test/simpleshop_theme/orders/order_submission_worker_test.exs
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
defmodule SimpleshopTheme.Orders.OrderSubmissionWorkerTest do
|
||||||
|
use SimpleshopTheme.DataCase, async: false
|
||||||
|
|
||||||
|
import Mox
|
||||||
|
import SimpleshopTheme.OrdersFixtures
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Orders
|
||||||
|
alias SimpleshopTheme.Orders.OrderSubmissionWorker
|
||||||
|
alias SimpleshopTheme.Providers.MockProvider
|
||||||
|
|
||||||
|
setup :verify_on_exit!
|
||||||
|
|
||||||
|
setup do
|
||||||
|
Application.put_env(:simpleshop_theme, :provider_modules, %{
|
||||||
|
"printify" => MockProvider
|
||||||
|
})
|
||||||
|
|
||||||
|
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "cancels if order not found" do
|
||||||
|
fake_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
assert {:cancel, :order_not_found} =
|
||||||
|
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => fake_id}})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cancels if order is not paid" do
|
||||||
|
order = order_fixture()
|
||||||
|
assert order.payment_status == "pending"
|
||||||
|
|
||||||
|
assert {:cancel, :not_paid} =
|
||||||
|
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :ok if already submitted" do
|
||||||
|
{order, _variant, _product, _conn} = submitted_order_fixture()
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "errors if no shipping address (will retry)" do
|
||||||
|
order = order_fixture(payment_status: "paid")
|
||||||
|
assert order.shipping_address == %{}
|
||||||
|
|
||||||
|
assert {:error, :no_shipping_address} =
|
||||||
|
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "submits to provider successfully" do
|
||||||
|
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
|
||||||
|
|
||||||
|
expect(MockProvider, :submit_order, fn _conn, order_data ->
|
||||||
|
assert order_data.order_number == order.order_number
|
||||||
|
assert length(order_data.line_items) == 1
|
||||||
|
{:ok, %{provider_order_id: "printify_order_123"}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
|
||||||
|
|
||||||
|
updated = Orders.get_order(order.id)
|
||||||
|
assert updated.fulfilment_status == "submitted"
|
||||||
|
assert updated.provider_order_id == "printify_order_123"
|
||||||
|
assert updated.submitted_at != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sets failed status when provider returns error" do
|
||||||
|
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
|
||||||
|
|
||||||
|
expect(MockProvider, :submit_order, fn _conn, _order_data ->
|
||||||
|
{:error, {422, %{"message" => "Invalid address"}}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:error, {422, _}} =
|
||||||
|
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
|
||||||
|
|
||||||
|
updated = Orders.get_order(order.id)
|
||||||
|
assert updated.fulfilment_status == "failed"
|
||||||
|
assert updated.fulfilment_error =~ "Provider API error (422)"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sets failed status when variant not found in local DB" do
|
||||||
|
# Create order with a variant_id that doesn't exist in product variants
|
||||||
|
order =
|
||||||
|
order_fixture(%{
|
||||||
|
variant_id: Ecto.UUID.generate(),
|
||||||
|
payment_status: "paid",
|
||||||
|
shipping_address: %{
|
||||||
|
"name" => "Test",
|
||||||
|
"line1" => "1 Street",
|
||||||
|
"city" => "London",
|
||||||
|
"postal_code" => "SW1",
|
||||||
|
"country" => "GB"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Need a printify connection for the lookup to proceed
|
||||||
|
SimpleshopTheme.ProductsFixtures.provider_connection_fixture(%{provider_type: "printify"})
|
||||||
|
|
||||||
|
assert {:error, {:variant_not_found, _, _}} =
|
||||||
|
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
|
||||||
|
|
||||||
|
updated = Orders.get_order(order.id)
|
||||||
|
assert updated.fulfilment_status == "failed"
|
||||||
|
assert updated.fulfilment_error =~ "no longer exists"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,10 +1,14 @@
|
|||||||
defmodule SimpleshopTheme.OrdersTest do
|
defmodule SimpleshopTheme.OrdersTest do
|
||||||
use SimpleshopTheme.DataCase, async: false
|
use SimpleshopTheme.DataCase, async: false
|
||||||
|
|
||||||
alias SimpleshopTheme.Orders
|
import Mox
|
||||||
|
|
||||||
import SimpleshopTheme.OrdersFixtures
|
import SimpleshopTheme.OrdersFixtures
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Orders
|
||||||
|
alias SimpleshopTheme.Providers.MockProvider
|
||||||
|
|
||||||
|
setup :verify_on_exit!
|
||||||
|
|
||||||
describe "list_orders/1" do
|
describe "list_orders/1" do
|
||||||
test "returns all orders" do
|
test "returns all orders" do
|
||||||
order1 = order_fixture()
|
order1 = order_fixture()
|
||||||
@ -62,4 +66,154 @@ defmodule SimpleshopTheme.OrdersTest do
|
|||||||
assert counts["failed"] == 1
|
assert counts["failed"] == 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "submit_to_provider/1" do
|
||||||
|
setup do
|
||||||
|
Application.put_env(:simpleshop_theme, :provider_modules, %{
|
||||||
|
"printify" => MockProvider
|
||||||
|
})
|
||||||
|
|
||||||
|
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "submits order and sets fulfilment status" do
|
||||||
|
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
|
||||||
|
|
||||||
|
expect(MockProvider, :submit_order, fn _conn, order_data ->
|
||||||
|
assert order_data.order_number == order.order_number
|
||||||
|
assert is_list(order_data.line_items)
|
||||||
|
assert hd(order_data.line_items).quantity == 1
|
||||||
|
{:ok, %{provider_order_id: "pfy_123"}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, updated} = Orders.submit_to_provider(order)
|
||||||
|
assert updated.fulfilment_status == "submitted"
|
||||||
|
assert updated.provider_order_id == "pfy_123"
|
||||||
|
assert updated.submitted_at != nil
|
||||||
|
assert updated.fulfilment_error == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "is idempotent when already submitted" do
|
||||||
|
{order, _variant, _product, _conn} = submitted_order_fixture()
|
||||||
|
|
||||||
|
# No mock expectations — provider should not be called
|
||||||
|
assert {:ok, ^order} = Orders.submit_to_provider(order)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sets failed status on provider error" do
|
||||||
|
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
|
||||||
|
|
||||||
|
expect(MockProvider, :submit_order, fn _conn, _data ->
|
||||||
|
{:error, {500, %{"message" => "Server error"}}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:error, {500, _}} = Orders.submit_to_provider(order)
|
||||||
|
|
||||||
|
updated = Orders.get_order(order.id)
|
||||||
|
assert updated.fulfilment_status == "failed"
|
||||||
|
assert updated.fulfilment_error =~ "Provider API error (500)"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "refresh_fulfilment_status/1" do
|
||||||
|
setup do
|
||||||
|
Application.put_env(:simpleshop_theme, :provider_modules, %{
|
||||||
|
"printify" => MockProvider
|
||||||
|
})
|
||||||
|
|
||||||
|
on_exit(fn -> Application.delete_env(:simpleshop_theme, :provider_modules) end)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates tracking info from provider" do
|
||||||
|
{order, _variant, _product, _conn} = submitted_order_fixture()
|
||||||
|
|
||||||
|
expect(MockProvider, :get_order_status, fn _conn, pid ->
|
||||||
|
assert pid == order.provider_order_id
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
status: "shipped",
|
||||||
|
provider_status: "shipped",
|
||||||
|
tracking_number: "TRACK123",
|
||||||
|
tracking_url: "https://track.example.com/TRACK123"
|
||||||
|
}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, updated} = Orders.refresh_fulfilment_status(order)
|
||||||
|
assert updated.fulfilment_status == "shipped"
|
||||||
|
assert updated.tracking_number == "TRACK123"
|
||||||
|
assert updated.tracking_url == "https://track.example.com/TRACK123"
|
||||||
|
assert updated.shipped_at != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no-op when no provider_order_id" do
|
||||||
|
order = order_fixture(payment_status: "paid")
|
||||||
|
assert is_nil(order.provider_order_id)
|
||||||
|
|
||||||
|
assert {:ok, ^order} = Orders.refresh_fulfilment_status(order)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update_fulfilment/2" do
|
||||||
|
test "updates fulfilment fields" do
|
||||||
|
order = order_fixture()
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
Orders.update_fulfilment(order, %{
|
||||||
|
fulfilment_status: "submitted",
|
||||||
|
provider_order_id: "test_123"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert updated.fulfilment_status == "submitted"
|
||||||
|
assert updated.provider_order_id == "test_123"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates fulfilment status inclusion" do
|
||||||
|
order = order_fixture()
|
||||||
|
|
||||||
|
assert {:error, changeset} =
|
||||||
|
Orders.update_fulfilment(order, %{fulfilment_status: "bogus"})
|
||||||
|
|
||||||
|
assert errors_on(changeset).fulfilment_status != []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "list_submitted_orders/0" do
|
||||||
|
test "returns orders with submitted or processing status" do
|
||||||
|
{submitted, _v, _p, _c} = submitted_order_fixture()
|
||||||
|
|
||||||
|
{processing, _v2, _p2, _c2} = submitted_order_fixture()
|
||||||
|
|
||||||
|
{:ok, processing} =
|
||||||
|
Orders.update_fulfilment(processing, %{fulfilment_status: "processing"})
|
||||||
|
|
||||||
|
_unfulfilled = order_fixture(payment_status: "paid")
|
||||||
|
|
||||||
|
orders = Orders.list_submitted_orders()
|
||||||
|
ids = Enum.map(orders, & &1.id)
|
||||||
|
|
||||||
|
assert submitted.id in ids
|
||||||
|
assert processing.id in ids
|
||||||
|
assert length(orders) == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty list when no submitted orders" do
|
||||||
|
order_fixture()
|
||||||
|
assert Orders.list_submitted_orders() == []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_order_by_number/1" do
|
||||||
|
test "finds order by order number" do
|
||||||
|
order = order_fixture()
|
||||||
|
found = Orders.get_order_by_number(order.order_number)
|
||||||
|
assert found.id == order.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for unknown order number" do
|
||||||
|
assert is_nil(Orders.get_order_by_number("SS-000000-XXXX"))
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
defmodule SimpleshopTheme.WebhooksTest do
|
defmodule SimpleshopTheme.WebhooksTest do
|
||||||
use SimpleshopTheme.DataCase
|
use SimpleshopTheme.DataCase, async: false
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Orders
|
||||||
alias SimpleshopTheme.Webhooks
|
alias SimpleshopTheme.Webhooks
|
||||||
|
|
||||||
import SimpleshopTheme.ProductsFixtures
|
import SimpleshopTheme.ProductsFixtures
|
||||||
|
import SimpleshopTheme.OrdersFixtures
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
conn = provider_connection_fixture(%{provider_type: "printify"})
|
conn = provider_connection_fixture(%{provider_type: "printify"})
|
||||||
{:ok, provider_connection: conn}
|
{:ok, provider_connection: conn}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "handle_printify_event/2" do
|
describe "handle_printify_event/2 — product events" do
|
||||||
test "product:updated triggers sync", %{provider_connection: _conn} do
|
test "product:updated triggers sync", %{provider_connection: _conn} do
|
||||||
# With inline Oban, the job executes immediately (and fails due to no real API key)
|
|
||||||
# But the handler should still return {:ok, _} after inserting the job
|
|
||||||
result =
|
result =
|
||||||
Webhooks.handle_printify_event(
|
Webhooks.handle_printify_event(
|
||||||
"product:updated",
|
"product:updated",
|
||||||
@ -52,7 +52,6 @@ defmodule SimpleshopTheme.WebhooksTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "returns error when no provider connection" do
|
test "returns error when no provider connection" do
|
||||||
# Delete all connections first
|
|
||||||
SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection)
|
SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection)
|
||||||
|
|
||||||
assert {:error, :no_connection} =
|
assert {:error, :no_connection} =
|
||||||
@ -62,4 +61,75 @@ defmodule SimpleshopTheme.WebhooksTest do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "handle_printify_event/2 — order events" do
|
||||||
|
test "order:sent-to-production updates fulfilment status" do
|
||||||
|
{order, _v, _p, _c} = submitted_order_fixture()
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
Webhooks.handle_printify_event("order:sent-to-production", %{
|
||||||
|
"id" => "printify_abc",
|
||||||
|
"external_id" => order.order_number
|
||||||
|
})
|
||||||
|
|
||||||
|
assert updated.fulfilment_status == "processing"
|
||||||
|
assert updated.provider_status == "in-production"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "order:shipment:created sets tracking info and shipped_at" do
|
||||||
|
{order, _v, _p, _c} = submitted_order_fixture()
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
Webhooks.handle_printify_event("order:shipment:created", %{
|
||||||
|
"id" => "printify_abc",
|
||||||
|
"external_id" => order.order_number,
|
||||||
|
"shipments" => [
|
||||||
|
%{
|
||||||
|
"tracking_number" => "1Z999AA1",
|
||||||
|
"tracking_url" => "https://ups.com/track/1Z999AA1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert updated.fulfilment_status == "shipped"
|
||||||
|
assert updated.tracking_number == "1Z999AA1"
|
||||||
|
assert updated.tracking_url == "https://ups.com/track/1Z999AA1"
|
||||||
|
assert updated.shipped_at != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "order:shipment:delivered sets delivered_at" do
|
||||||
|
{order, _v, _p, _c} = submitted_order_fixture()
|
||||||
|
|
||||||
|
# First mark as shipped
|
||||||
|
{:ok, order} =
|
||||||
|
Orders.update_fulfilment(order, %{
|
||||||
|
fulfilment_status: "shipped",
|
||||||
|
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
Webhooks.handle_printify_event("order:shipment:delivered", %{
|
||||||
|
"id" => "printify_abc",
|
||||||
|
"external_id" => order.order_number
|
||||||
|
})
|
||||||
|
|
||||||
|
assert updated.fulfilment_status == "delivered"
|
||||||
|
assert updated.delivered_at != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "order event with unknown external_id returns error" do
|
||||||
|
assert {:error, :order_not_found} =
|
||||||
|
Webhooks.handle_printify_event("order:sent-to-production", %{
|
||||||
|
"id" => "printify_abc",
|
||||||
|
"external_id" => "SS-000000-NOPE"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "order event with missing external_id returns error" do
|
||||||
|
assert {:error, :missing_external_id} =
|
||||||
|
Webhooks.handle_printify_event("order:sent-to-production", %{
|
||||||
|
"id" => "printify_abc"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -115,5 +115,76 @@ defmodule SimpleshopThemeWeb.AdminLive.OrdersTest do
|
|||||||
{:error, {:live_redirect, %{to: "/admin/orders"}}} =
|
{:error, {:live_redirect, %{to: "/admin/orders"}}} =
|
||||||
live(conn, ~p"/admin/orders/#{fake_id}")
|
live(conn, ~p"/admin/orders/#{fake_id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "shows fulfilment card", %{conn: conn} do
|
||||||
|
order = order_fixture(payment_status: "paid")
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
|
||||||
|
|
||||||
|
assert html =~ "Fulfilment"
|
||||||
|
assert html =~ "unfulfilled"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows submit button for paid unfulfilled orders", %{conn: conn} do
|
||||||
|
order = order_fixture(payment_status: "paid")
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
|
||||||
|
|
||||||
|
assert html =~ "Submit to provider"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows retry button for failed fulfilment", %{conn: conn} do
|
||||||
|
order = order_fixture(payment_status: "paid")
|
||||||
|
|
||||||
|
{:ok, order} =
|
||||||
|
SimpleshopTheme.Orders.update_fulfilment(order, %{fulfilment_status: "failed"})
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
|
||||||
|
|
||||||
|
assert html =~ "Retry submission"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows refresh button for submitted orders", %{conn: conn} do
|
||||||
|
{order, _v, _p, _c} =
|
||||||
|
SimpleshopTheme.OrdersFixtures.submitted_order_fixture()
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
|
||||||
|
|
||||||
|
assert html =~ "Refresh status"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows tracking info when available", %{conn: conn} do
|
||||||
|
{order, _v, _p, _c} =
|
||||||
|
SimpleshopTheme.OrdersFixtures.submitted_order_fixture()
|
||||||
|
|
||||||
|
{:ok, order} =
|
||||||
|
SimpleshopTheme.Orders.update_fulfilment(order, %{
|
||||||
|
fulfilment_status: "shipped",
|
||||||
|
tracking_number: "TRACK123",
|
||||||
|
tracking_url: "https://track.example.com/TRACK123",
|
||||||
|
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
|
||||||
|
|
||||||
|
assert html =~ "TRACK123"
|
||||||
|
assert html =~ "https://track.example.com/TRACK123"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "order list fulfilment column" do
|
||||||
|
setup %{conn: conn, user: user} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
%{conn: conn}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows fulfilment badge in table", %{conn: conn} do
|
||||||
|
order_fixture(payment_status: "paid")
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/orders")
|
||||||
|
|
||||||
|
assert html =~ "Fulfilment"
|
||||||
|
assert html =~ "unfulfilled"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -5,12 +5,14 @@ defmodule SimpleshopTheme.OrdersFixtures do
|
|||||||
|
|
||||||
alias SimpleshopTheme.Orders
|
alias SimpleshopTheme.Orders
|
||||||
|
|
||||||
|
import SimpleshopTheme.ProductsFixtures
|
||||||
|
|
||||||
def order_fixture(attrs \\ %{}) do
|
def order_fixture(attrs \\ %{}) do
|
||||||
attrs = Enum.into(attrs, %{})
|
attrs = Enum.into(attrs, %{})
|
||||||
|
|
||||||
items = [
|
items = [
|
||||||
%{
|
%{
|
||||||
variant_id: "var_#{System.unique_integer([:positive])}",
|
variant_id: Map.get(attrs, :variant_id, "var_#{System.unique_integer([:positive])}"),
|
||||||
name: Map.get(attrs, :product_name, "Test product"),
|
name: Map.get(attrs, :product_name, "Test product"),
|
||||||
variant: Map.get(attrs, :variant_title, "Red / Large"),
|
variant: Map.get(attrs, :variant_title, "Red / Large"),
|
||||||
price: Map.get(attrs, :unit_price, 1999),
|
price: Map.get(attrs, :unit_price, 1999),
|
||||||
@ -26,6 +28,17 @@ defmodule SimpleshopTheme.OrdersFixtures do
|
|||||||
|
|
||||||
{:ok, order} = Orders.create_order(order_attrs)
|
{:ok, order} = Orders.create_order(order_attrs)
|
||||||
|
|
||||||
|
# Apply shipping address if provided
|
||||||
|
order =
|
||||||
|
case attrs[:shipping_address] do
|
||||||
|
nil ->
|
||||||
|
order
|
||||||
|
|
||||||
|
addr ->
|
||||||
|
{:ok, order} = Orders.update_order(order, %{shipping_address: addr})
|
||||||
|
order
|
||||||
|
end
|
||||||
|
|
||||||
case attrs[:payment_status] do
|
case attrs[:payment_status] do
|
||||||
"paid" ->
|
"paid" ->
|
||||||
{:ok, order} = Orders.mark_paid(order, "pi_test_#{System.unique_integer([:positive])}")
|
{:ok, order} = Orders.mark_paid(order, "pi_test_#{System.unique_integer([:positive])}")
|
||||||
@ -39,4 +52,59 @@ defmodule SimpleshopTheme.OrdersFixtures do
|
|||||||
order
|
order
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a paid order with real product variants in the DB,
|
||||||
|
so enrich_items/1 can look up provider IDs. Also sets a shipping
|
||||||
|
address matching Stripe's format.
|
||||||
|
|
||||||
|
Returns `{order, variant, product, conn}`.
|
||||||
|
"""
|
||||||
|
def paid_order_with_products_fixture(attrs \\ %{}) do
|
||||||
|
attrs = Enum.into(attrs, %{})
|
||||||
|
conn = provider_connection_fixture(%{provider_type: "printify"})
|
||||||
|
product = product_fixture(%{provider_connection: conn})
|
||||||
|
variant = product_variant_fixture(%{product: product})
|
||||||
|
|
||||||
|
shipping =
|
||||||
|
Map.get(attrs, :shipping_address, %{
|
||||||
|
"name" => "Jane Doe",
|
||||||
|
"line1" => "42 Test Street",
|
||||||
|
"line2" => nil,
|
||||||
|
"city" => "London",
|
||||||
|
"postal_code" => "SW1A 1AA",
|
||||||
|
"state" => nil,
|
||||||
|
"country" => "GB"
|
||||||
|
})
|
||||||
|
|
||||||
|
order =
|
||||||
|
order_fixture(%{
|
||||||
|
variant_id: variant.id,
|
||||||
|
payment_status: "paid",
|
||||||
|
shipping_address: shipping,
|
||||||
|
customer_email: Map.get(attrs, :customer_email, "buyer@example.com")
|
||||||
|
})
|
||||||
|
|
||||||
|
{order, variant, product, conn}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a submitted order (has provider_order_id set).
|
||||||
|
Returns `{order, variant, product, conn}`.
|
||||||
|
"""
|
||||||
|
def submitted_order_fixture(attrs \\ %{}) do
|
||||||
|
{order, variant, product, conn} = paid_order_with_products_fixture(attrs)
|
||||||
|
|
||||||
|
attrs = Enum.into(attrs, %{})
|
||||||
|
|
||||||
|
{:ok, order} =
|
||||||
|
Orders.update_fulfilment(order, %{
|
||||||
|
fulfilment_status: "submitted",
|
||||||
|
provider_order_id:
|
||||||
|
Map.get(attrs, :provider_order_id, "printify_#{System.unique_integer([:positive])}"),
|
||||||
|
submitted_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
})
|
||||||
|
|
||||||
|
{order, variant, product, conn}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
3
test/support/mocks.ex
Normal file
3
test/support/mocks.ex
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Mox.defmock(SimpleshopTheme.Providers.MockProvider,
|
||||||
|
for: SimpleshopTheme.Providers.Provider
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user