2026-02-18 21:23:15 +00:00
|
|
|
defmodule Berrypod.Webhooks do
|
2026-01-31 22:41:15 +00:00
|
|
|
@moduledoc """
|
|
|
|
|
Handles incoming webhook events from POD providers.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-02-18 21:23:15 +00:00
|
|
|
alias Berrypod.Orders
|
|
|
|
|
alias Berrypod.Orders.OrderNotifier
|
|
|
|
|
alias Berrypod.Products
|
|
|
|
|
alias Berrypod.Sync.ProductSyncWorker
|
|
|
|
|
alias Berrypod.Webhooks.ProductDeleteWorker
|
2026-01-31 22:41:15 +00:00
|
|
|
|
|
|
|
|
require Logger
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Handles a Printify webhook event.
|
|
|
|
|
|
|
|
|
|
Returns :ok or {:ok, job} on success, {:error, reason} on failure.
|
|
|
|
|
"""
|
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>
2026-02-08 09:51:51 +00:00
|
|
|
|
|
|
|
|
# --- Product events ---
|
|
|
|
|
|
2026-01-31 22:41:15 +00:00
|
|
|
def handle_printify_event("product:updated", %{"id" => _product_id}) do
|
|
|
|
|
enqueue_product_sync()
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_printify_event("product:publish:started", %{"id" => _product_id}) do
|
|
|
|
|
enqueue_product_sync()
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_printify_event("product:deleted", %{"id" => product_id}) do
|
|
|
|
|
ProductDeleteWorker.enqueue(product_id)
|
|
|
|
|
end
|
|
|
|
|
|
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>
2026-02-08 09:51:51 +00:00
|
|
|
# --- 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)
|
|
|
|
|
|
2026-02-08 10:17:19 +00:00
|
|
|
with {:ok, order} <- find_order_from_resource(resource),
|
|
|
|
|
{:ok, updated_order} <-
|
|
|
|
|
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)
|
|
|
|
|
}) do
|
|
|
|
|
OrderNotifier.deliver_shipping_notification(updated_order)
|
|
|
|
|
{:ok, updated_order}
|
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>
2026-02-08 09:51:51 +00:00
|
|
|
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 ---
|
|
|
|
|
|
2026-01-31 22:41:15 +00:00
|
|
|
def handle_printify_event("shop:disconnected", _resource) do
|
|
|
|
|
Logger.warning("Printify shop disconnected - manual intervention needed")
|
|
|
|
|
:ok
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_printify_event(event_type, _resource) do
|
|
|
|
|
Logger.info("Ignoring unhandled Printify event: #{event_type}")
|
|
|
|
|
:ok
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-15 09:32:14 +00:00
|
|
|
# =============================================================================
|
|
|
|
|
# Printful events
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
|
Handles a Printful webhook event.
|
|
|
|
|
|
|
|
|
|
Returns :ok or {:ok, result} on success, {:error, reason} on failure.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
# --- Order events ---
|
|
|
|
|
|
|
|
|
|
def handle_printful_event("package_shipped", data) do
|
|
|
|
|
with {:ok, order} <- find_printful_order(data) do
|
|
|
|
|
shipment = extract_printful_shipment(data)
|
|
|
|
|
|
|
|
|
|
{:ok, updated} =
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
OrderNotifier.deliver_shipping_notification(updated)
|
|
|
|
|
{:ok, updated}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_printful_event("order_failed", data) do
|
|
|
|
|
with {:ok, order} <- find_printful_order(data) do
|
|
|
|
|
Orders.update_fulfilment(order, %{
|
|
|
|
|
fulfilment_status: "failed",
|
|
|
|
|
provider_status: "failed",
|
|
|
|
|
fulfilment_error: data["reason"] || "Order failed at Printful"
|
|
|
|
|
})
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_printful_event("order_canceled", data) do
|
|
|
|
|
with {:ok, order} <- find_printful_order(data) do
|
|
|
|
|
Orders.update_fulfilment(order, %{
|
|
|
|
|
fulfilment_status: "cancelled",
|
|
|
|
|
provider_status: "canceled"
|
|
|
|
|
})
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# --- Product events ---
|
|
|
|
|
|
|
|
|
|
def handle_printful_event("product_updated", _data) do
|
|
|
|
|
enqueue_printful_sync()
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_printful_event("product_synced", _data) do
|
|
|
|
|
enqueue_printful_sync()
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_printful_event("product_deleted", %{"sync_product" => %{"id" => product_id}}) do
|
|
|
|
|
ProductDeleteWorker.enqueue(to_string(product_id))
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def handle_printful_event("product_deleted", _data) do
|
|
|
|
|
enqueue_printful_sync()
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# --- Catch-all ---
|
|
|
|
|
|
|
|
|
|
def handle_printful_event(event_type, _data) do
|
|
|
|
|
Logger.info("Ignoring unhandled Printful event: #{event_type}")
|
|
|
|
|
:ok
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Private helpers
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
# --- Printify helpers ---
|
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>
2026-02-08 09:51:51 +00:00
|
|
|
|
2026-01-31 22:41:15 +00:00
|
|
|
defp enqueue_product_sync do
|
|
|
|
|
case Products.get_provider_connection_by_type("printify") do
|
|
|
|
|
nil -> {:error, :no_connection}
|
|
|
|
|
conn -> ProductSyncWorker.enqueue(conn.id)
|
|
|
|
|
end
|
|
|
|
|
end
|
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>
2026-02-08 09:51:51 +00:00
|
|
|
|
|
|
|
|
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
|
2026-02-15 09:32:14 +00:00
|
|
|
|
|
|
|
|
# --- Printful helpers ---
|
|
|
|
|
|
|
|
|
|
defp enqueue_printful_sync do
|
|
|
|
|
case Products.get_provider_connection_by_type("printful") do
|
|
|
|
|
nil -> {:error, :no_connection}
|
|
|
|
|
conn -> ProductSyncWorker.enqueue(conn.id)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Printful order webhooks include external_id in the order data
|
|
|
|
|
defp find_printful_order(%{"order" => %{"external_id" => ext_id}})
|
|
|
|
|
when is_binary(ext_id) and ext_id != "" do
|
|
|
|
|
find_order_by_external_id(ext_id)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Fallback: look for external_id at top level
|
|
|
|
|
defp find_printful_order(%{"external_id" => ext_id})
|
|
|
|
|
when is_binary(ext_id) and ext_id != "" do
|
|
|
|
|
find_order_by_external_id(ext_id)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp find_printful_order(data) do
|
|
|
|
|
Logger.warning("Printful order webhook: can't find external_id in #{inspect(data)}")
|
|
|
|
|
{:error, :missing_external_id}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp find_order_by_external_id(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 extract_printful_shipment(data) do
|
|
|
|
|
shipment = data["shipment"] || %{}
|
|
|
|
|
|
|
|
|
|
%{
|
|
|
|
|
tracking_number: shipment["tracking_number"],
|
|
|
|
|
tracking_url: shipment["tracking_url"]
|
|
|
|
|
}
|
|
|
|
|
end
|
2026-01-31 22:41:15 +00:00
|
|
|
end
|