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:
@@ -68,6 +68,7 @@ defmodule BerrypodWeb.CheckoutController do
|
||||
}
|
||||
}
|
||||
|> maybe_add_shipping_options(hydrated_items)
|
||||
|> maybe_add_cart_recovery_notice()
|
||||
|
||||
case Stripe.Checkout.Session.create(params) do
|
||||
{:ok, session} ->
|
||||
@@ -94,6 +95,19 @@ defmodule BerrypodWeb.CheckoutController do
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_cart_recovery_notice(params) do
|
||||
if Berrypod.Settings.abandoned_cart_recovery_enabled?() do
|
||||
Map.put(params, :custom_text, %{
|
||||
after_submit: %{
|
||||
message:
|
||||
"If your payment doesn't complete, we may send you one follow-up email. You can unsubscribe at any time."
|
||||
}
|
||||
})
|
||||
else
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_shipping_options(params, hydrated_items) do
|
||||
gb_result = Shipping.calculate_for_cart(hydrated_items, "GB")
|
||||
us_result = Shipping.calculate_for_cart(hydrated_items, "US")
|
||||
|
||||
@@ -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 || %{}
|
||||
|
||||
|
||||
52
lib/berrypod_web/controllers/unsubscribe_controller.ex
Normal file
52
lib/berrypod_web/controllers/unsubscribe_controller.ex
Normal file
@@ -0,0 +1,52 @@
|
||||
defmodule BerrypodWeb.UnsubscribeController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Orders
|
||||
|
||||
# Unsubscribe links should be long-lived — use 2 years
|
||||
@max_age 2 * 365 * 24 * 3600
|
||||
|
||||
def unsubscribe(conn, %{"token" => token}) do
|
||||
case Phoenix.Token.verify(BerrypodWeb.Endpoint, "email-unsub", token, max_age: @max_age) do
|
||||
{:ok, email} ->
|
||||
Orders.add_suppression(email, "unsubscribed")
|
||||
|
||||
conn
|
||||
|> put_status(200)
|
||||
|> html("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Unsubscribed</title>
|
||||
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">You've been unsubscribed</h1>
|
||||
<p style="color:#555">We've removed #{email} from our marketing list. You won't receive any more cart recovery emails from us.</p>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> html("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Invalid link</title>
|
||||
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">Link invalid or expired</h1>
|
||||
<p style="color:#555">This unsubscribe link has expired or is invalid. If you'd like to unsubscribe, reply to any email we've sent you.</p>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,7 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
socket
|
||||
|> assign(:page_title, "Settings")
|
||||
|> assign(:site_live, Settings.site_live?())
|
||||
|> assign(:cart_recovery_enabled, Settings.abandoned_cart_recovery_enabled?())
|
||||
|> assign_stripe_state()
|
||||
|> assign_products_state()
|
||||
|> assign_account_state(user)}
|
||||
@@ -90,6 +91,23 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
|> put_flash(:info, message)}
|
||||
end
|
||||
|
||||
# -- Events: cart recovery --
|
||||
|
||||
def handle_event("toggle_cart_recovery", _params, socket) do
|
||||
new_value = !socket.assigns.cart_recovery_enabled
|
||||
{:ok, _} = Settings.set_abandoned_cart_recovery(new_value)
|
||||
|
||||
message =
|
||||
if new_value,
|
||||
do: "Cart recovery emails enabled",
|
||||
else: "Cart recovery emails disabled"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:cart_recovery_enabled, new_value)
|
||||
|> put_flash(:info, message)}
|
||||
end
|
||||
|
||||
# -- Events: Stripe --
|
||||
|
||||
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
||||
@@ -367,6 +385,49 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<%!-- Cart recovery --%>
|
||||
<section class="mt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Cart recovery</h2>
|
||||
<%= if @cart_recovery_enabled do %>
|
||||
<.status_pill color="green">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" /> On
|
||||
</.status_pill>
|
||||
<% else %>
|
||||
<.status_pill color="zinc">Off</.status_pill>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-base-content/60">
|
||||
When on, customers who entered their email at Stripe checkout but didn't complete
|
||||
payment receive a single plain-text recovery email one hour later.
|
||||
No tracking pixels. One email, never more.
|
||||
</p>
|
||||
<%= if @cart_recovery_enabled do %>
|
||||
<p class="mt-2 text-sm text-amber-700">
|
||||
Make sure your privacy policy mentions that a single recovery email may be sent,
|
||||
and that customers can unsubscribe at any time.
|
||||
</p>
|
||||
<% end %>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
phx-click="toggle_cart_recovery"
|
||||
class={[
|
||||
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold shadow-xs",
|
||||
if(@cart_recovery_enabled,
|
||||
do: "bg-base-200 text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset",
|
||||
else: "bg-base-content text-white hover:bg-base-content/80"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= if @cart_recovery_enabled do %>
|
||||
Turn off
|
||||
<% else %>
|
||||
Turn on
|
||||
<% end %>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%!-- Account --%>
|
||||
<section class="mt-10">
|
||||
<h2 class="text-lg font-semibold">Account</h2>
|
||||
|
||||
@@ -164,6 +164,7 @@ defmodule BerrypodWeb.Router do
|
||||
pipe_through [:browser]
|
||||
|
||||
get "/orders/verify/:token", OrderLookupController, :verify
|
||||
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
|
||||
end
|
||||
|
||||
# Setup page — minimal live_session, no theme/cart/search hooks
|
||||
|
||||
Reference in New Issue
Block a user