2026-02-18 21:23:15 +00:00
|
|
|
defmodule BerrypodWeb.StripeWebhookController do
|
|
|
|
|
use BerrypodWeb, :controller
|
2026-02-07 08:30:17 +00:00
|
|
|
|
2026-02-18 21:23:15 +00:00
|
|
|
alias Berrypod.Orders
|
|
|
|
|
alias Berrypod.Orders.{OrderNotifier, OrderSubmissionWorker}
|
2026-02-07 08:30:17 +00:00
|
|
|
|
|
|
|
|
require Logger
|
|
|
|
|
|
|
|
|
|
def handle(conn, _params) do
|
|
|
|
|
raw_body = conn.assigns[:raw_body] || ""
|
|
|
|
|
signature = List.first(get_req_header(conn, "stripe-signature")) || ""
|
|
|
|
|
signing_secret = Application.get_env(:stripity_stripe, :signing_secret) || ""
|
|
|
|
|
|
|
|
|
|
case Stripe.Webhook.construct_event(raw_body, signature, signing_secret) do
|
|
|
|
|
{:ok, %Stripe.Event{} = event} ->
|
|
|
|
|
handle_event(event)
|
|
|
|
|
json(conn, %{received: true})
|
|
|
|
|
|
|
|
|
|
{:error, reason} ->
|
|
|
|
|
Logger.warning("Stripe webhook verification failed: #{inspect(reason)}")
|
|
|
|
|
|
|
|
|
|
conn
|
|
|
|
|
|> put_status(401)
|
|
|
|
|
|> json(%{error: "Invalid signature"})
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_event(%Stripe.Event{type: "checkout.session.completed", data: %{object: session}}) do
|
2026-02-21 23:36:21 +00:00
|
|
|
order_id = session.metadata["order_id"]
|
2026-02-07 08:30:17 +00:00
|
|
|
|
|
|
|
|
case Orders.get_order(order_id) do
|
|
|
|
|
nil ->
|
|
|
|
|
Logger.warning("Stripe webhook: order not found for id=#{order_id}")
|
|
|
|
|
|
|
|
|
|
order ->
|
|
|
|
|
payment_intent_id = session.payment_intent
|
|
|
|
|
{:ok, order} = Orders.mark_paid(order, payment_intent_id)
|
|
|
|
|
|
2026-02-14 10:48:00 +00:00
|
|
|
# Update shipping cost from Stripe (if shipping options were presented)
|
|
|
|
|
order = update_shipping_cost(order, session)
|
|
|
|
|
|
2026-02-07 08:30:17 +00:00
|
|
|
# Update shipping address if collected by Stripe
|
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 =
|
|
|
|
|
if session.shipping_details do
|
|
|
|
|
{:ok, updated} = update_shipping(order, session.shipping_details)
|
|
|
|
|
updated
|
|
|
|
|
else
|
|
|
|
|
order
|
|
|
|
|
end
|
2026-02-07 08:30:17 +00:00
|
|
|
|
|
|
|
|
# Update customer email from Stripe session
|
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 =
|
|
|
|
|
if session.customer_details && session.customer_details.email do
|
|
|
|
|
{:ok, updated} =
|
|
|
|
|
Orders.update_order(order, %{customer_email: session.customer_details.email})
|
|
|
|
|
|
|
|
|
|
updated
|
|
|
|
|
else
|
|
|
|
|
order
|
|
|
|
|
end
|
2026-02-07 08:30:17 +00:00
|
|
|
|
2026-02-08 10:17:19 +00:00
|
|
|
# Reload items for the email (update_order doesn't preload)
|
|
|
|
|
order = Orders.get_order(order.id)
|
|
|
|
|
|
2026-02-07 08:30:17 +00:00
|
|
|
# Broadcast to success page via PubSub
|
|
|
|
|
Phoenix.PubSub.broadcast(
|
2026-02-18 21:23:15 +00:00
|
|
|
Berrypod.PubSub,
|
2026-02-07 08:30:17 +00:00
|
|
|
"order:#{order.id}:status",
|
|
|
|
|
{:order_paid, order}
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-08 10:17:19 +00:00
|
|
|
OrderNotifier.deliver_order_confirmation(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
|
|
|
# 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
|
|
|
|
|
|
2026-02-07 08:30:17 +00:00
|
|
|
Logger.info("Order #{order.order_number} marked as paid")
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_event(%Stripe.Event{type: "checkout.session.expired", data: %{object: session}}) do
|
2026-02-21 23:36:21 +00:00
|
|
|
order_id = session.metadata["order_id"]
|
add abandoned cart recovery
When a Stripe checkout session expires without payment, if the customer
entered their email, we record an AbandonedCart and schedule a single
plain-text recovery email (1h delay via Oban).
Privacy design:
- feature is off by default; shop owner opts in via admin settings
- only contacts customers who entered their email at Stripe checkout
- single email, never more (emailed_at timestamp gate)
- suppression list blocks repeat contact; one-click unsubscribe via
signed token (/unsubscribe/:token)
- records pruned after 30 days (nightly Oban cron)
- no tracking pixels, no redirected links, no HTML
Legal notes:
- custom_text added to Stripe session footer when recovery is on
- UK PECR soft opt-in; EU legitimate interests both satisfied by this design
Files:
- migration: abandoned_carts + email_suppressions tables
- schemas: AbandonedCart, EmailSuppression
- context: Orders.create_abandoned_cart, check_suppression, add_suppression,
has_recent_paid_order?, get_abandoned_cart_by_session, mark_abandoned_cart_emailed
- workers: AbandonedCartEmailWorker (checkout queue), AbandonedCartPruneWorker (cron)
- notifier: OrderNotifier.deliver_cart_recovery/3
- webhook: extended checkout.session.expired handler
- controller: UnsubscribeController, admin settings toggle
- tests: 28 new tests across context, workers, and controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:02:37 +00:00
|
|
|
order = Orders.get_order(order_id)
|
2026-02-07 08:30:17 +00:00
|
|
|
|
add abandoned cart recovery
When a Stripe checkout session expires without payment, if the customer
entered their email, we record an AbandonedCart and schedule a single
plain-text recovery email (1h delay via Oban).
Privacy design:
- feature is off by default; shop owner opts in via admin settings
- only contacts customers who entered their email at Stripe checkout
- single email, never more (emailed_at timestamp gate)
- suppression list blocks repeat contact; one-click unsubscribe via
signed token (/unsubscribe/:token)
- records pruned after 30 days (nightly Oban cron)
- no tracking pixels, no redirected links, no HTML
Legal notes:
- custom_text added to Stripe session footer when recovery is on
- UK PECR soft opt-in; EU legitimate interests both satisfied by this design
Files:
- migration: abandoned_carts + email_suppressions tables
- schemas: AbandonedCart, EmailSuppression
- context: Orders.create_abandoned_cart, check_suppression, add_suppression,
has_recent_paid_order?, get_abandoned_cart_by_session, mark_abandoned_cart_emailed
- workers: AbandonedCartEmailWorker (checkout queue), AbandonedCartPruneWorker (cron)
- notifier: OrderNotifier.deliver_cart_recovery/3
- webhook: extended checkout.session.expired handler
- controller: UnsubscribeController, admin settings toggle
- tests: 28 new tests across context, workers, and controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:02:37 +00:00
|
|
|
if order, do: Orders.mark_failed(order)
|
2026-02-07 08:30:17 +00:00
|
|
|
|
|
|
|
|
Logger.info("Stripe checkout session expired for order #{order_id}")
|
add abandoned cart recovery
When a Stripe checkout session expires without payment, if the customer
entered their email, we record an AbandonedCart and schedule a single
plain-text recovery email (1h delay via Oban).
Privacy design:
- feature is off by default; shop owner opts in via admin settings
- only contacts customers who entered their email at Stripe checkout
- single email, never more (emailed_at timestamp gate)
- suppression list blocks repeat contact; one-click unsubscribe via
signed token (/unsubscribe/:token)
- records pruned after 30 days (nightly Oban cron)
- no tracking pixels, no redirected links, no HTML
Legal notes:
- custom_text added to Stripe session footer when recovery is on
- UK PECR soft opt-in; EU legitimate interests both satisfied by this design
Files:
- migration: abandoned_carts + email_suppressions tables
- schemas: AbandonedCart, EmailSuppression
- context: Orders.create_abandoned_cart, check_suppression, add_suppression,
has_recent_paid_order?, get_abandoned_cart_by_session, mark_abandoned_cart_emailed
- workers: AbandonedCartEmailWorker (checkout queue), AbandonedCartPruneWorker (cron)
- notifier: OrderNotifier.deliver_cart_recovery/3
- webhook: extended checkout.session.expired handler
- controller: UnsubscribeController, admin settings toggle
- tests: 28 new tests across context, workers, and controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:02:37 +00:00
|
|
|
|
|
|
|
|
if Berrypod.Settings.abandoned_cart_recovery_enabled?() do
|
|
|
|
|
maybe_create_abandoned_cart(session, order)
|
|
|
|
|
end
|
2026-02-07 08:30:17 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_event(%Stripe.Event{type: type}) do
|
|
|
|
|
Logger.debug("Unhandled Stripe event: #{type}")
|
|
|
|
|
end
|
|
|
|
|
|
add abandoned cart recovery
When a Stripe checkout session expires without payment, if the customer
entered their email, we record an AbandonedCart and schedule a single
plain-text recovery email (1h delay via Oban).
Privacy design:
- feature is off by default; shop owner opts in via admin settings
- only contacts customers who entered their email at Stripe checkout
- single email, never more (emailed_at timestamp gate)
- suppression list blocks repeat contact; one-click unsubscribe via
signed token (/unsubscribe/:token)
- records pruned after 30 days (nightly Oban cron)
- no tracking pixels, no redirected links, no HTML
Legal notes:
- custom_text added to Stripe session footer when recovery is on
- UK PECR soft opt-in; EU legitimate interests both satisfied by this design
Files:
- migration: abandoned_carts + email_suppressions tables
- schemas: AbandonedCart, EmailSuppression
- context: Orders.create_abandoned_cart, check_suppression, add_suppression,
has_recent_paid_order?, get_abandoned_cart_by_session, mark_abandoned_cart_emailed
- workers: AbandonedCartEmailWorker (checkout queue), AbandonedCartPruneWorker (cron)
- notifier: OrderNotifier.deliver_cart_recovery/3
- webhook: extended checkout.session.expired handler
- controller: UnsubscribeController, admin settings toggle
- tests: 28 new tests across context, workers, and controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:02:37 +00:00
|
|
|
defp maybe_create_abandoned_cart(_session, nil), do: :ok
|
|
|
|
|
|
|
|
|
|
defp maybe_create_abandoned_cart(session, order) do
|
|
|
|
|
email = session.customer_details && session.customer_details.email
|
|
|
|
|
|
|
|
|
|
cond do
|
|
|
|
|
is_nil(email) or email == "" ->
|
|
|
|
|
Logger.debug("Cart recovery: no email captured for session #{session.id}")
|
|
|
|
|
|
|
|
|
|
Orders.check_suppression(email) == :suppressed ->
|
|
|
|
|
Logger.debug("Cart recovery: #{email} is suppressed")
|
|
|
|
|
|
|
|
|
|
Orders.has_recent_paid_order?(email) ->
|
|
|
|
|
Logger.debug("Cart recovery: #{email} has a recent paid order, skipping")
|
|
|
|
|
|
|
|
|
|
not is_nil(Orders.get_abandoned_cart_by_session(session.id)) ->
|
|
|
|
|
Logger.debug("Cart recovery: session #{session.id} already recorded")
|
|
|
|
|
|
|
|
|
|
true ->
|
|
|
|
|
create_cart_and_enqueue(session, order, email)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp create_cart_and_enqueue(session, order, email) do
|
|
|
|
|
expired_at = DateTime.utc_now() |> DateTime.truncate(:second)
|
|
|
|
|
|
|
|
|
|
attrs = %{
|
|
|
|
|
customer_email: email,
|
|
|
|
|
order_id: order.id,
|
|
|
|
|
stripe_session_id: session.id,
|
|
|
|
|
cart_total: order.total,
|
|
|
|
|
expired_at: expired_at
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case Orders.create_abandoned_cart(attrs) do
|
|
|
|
|
{:ok, cart} ->
|
|
|
|
|
Berrypod.Orders.AbandonedCartEmailWorker.enqueue(cart.id)
|
|
|
|
|
Logger.info("Abandoned cart recorded for #{email}, recovery email scheduled")
|
|
|
|
|
|
|
|
|
|
{:error, reason} ->
|
|
|
|
|
Logger.warning("Failed to create abandoned cart: #{inspect(reason)}")
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-07 08:30:17 +00:00
|
|
|
defp update_shipping(order, shipping_details) do
|
|
|
|
|
address = shipping_details.address || %{}
|
|
|
|
|
|
|
|
|
|
shipping_address = %{
|
|
|
|
|
"name" => shipping_details.name,
|
|
|
|
|
"line1" => address.line1,
|
|
|
|
|
"line2" => address.line2,
|
|
|
|
|
"city" => address.city,
|
|
|
|
|
"postal_code" => address.postal_code,
|
|
|
|
|
"state" => address.state,
|
|
|
|
|
"country" => address.country
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Orders.update_order(order, %{shipping_address: shipping_address})
|
|
|
|
|
end
|
2026-02-14 10:48:00 +00:00
|
|
|
|
|
|
|
|
defp update_shipping_cost(order, session) do
|
2026-02-21 23:36:21 +00:00
|
|
|
shipping_amount = session.shipping_cost && session.shipping_cost.amount_total
|
2026-02-14 10:48:00 +00:00
|
|
|
|
|
|
|
|
if is_integer(shipping_amount) and shipping_amount > 0 do
|
|
|
|
|
new_total = order.subtotal + shipping_amount
|
|
|
|
|
|
|
|
|
|
case Orders.update_order(order, %{shipping_cost: shipping_amount, total: new_total}) do
|
|
|
|
|
{:ok, updated} -> updated
|
|
|
|
|
{:error, _} -> order
|
|
|
|
|
end
|
|
|
|
|
else
|
|
|
|
|
order
|
|
|
|
|
end
|
|
|
|
|
end
|
2026-02-07 08:30:17 +00:00
|
|
|
end
|