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>
This commit is contained in:
@@ -86,19 +86,65 @@ defmodule BerrypodWeb.StripeWebhookController do
|
||||
|
||||
defp handle_event(%Stripe.Event{type: "checkout.session.expired", data: %{object: session}}) do
|
||||
order_id = session.metadata["order_id"]
|
||||
order = Orders.get_order(order_id)
|
||||
|
||||
case Orders.get_order(order_id) do
|
||||
nil -> :ok
|
||||
order -> Orders.mark_failed(order)
|
||||
end
|
||||
if order, do: Orders.mark_failed(order)
|
||||
|
||||
Logger.info("Stripe checkout session expired for order #{order_id}")
|
||||
|
||||
if Berrypod.Settings.abandoned_cart_recovery_enabled?() do
|
||||
maybe_create_abandoned_cart(session, order)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_event(%Stripe.Event{type: type}) do
|
||||
Logger.debug("Unhandled Stripe event: #{type}")
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
defp update_shipping(order, shipping_details) do
|
||||
address = shipping_details.address || %{}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user