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:
jamey
2026-02-24 10:02:37 +00:00
parent 758e66db5c
commit 2f4cd81f98
18 changed files with 844 additions and 6 deletions

View File

@@ -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>