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:
34
lib/berrypod/orders/abandoned_cart.ex
Normal file
34
lib/berrypod/orders/abandoned_cart.ex
Normal file
@@ -0,0 +1,34 @@
|
||||
defmodule Berrypod.Orders.AbandonedCart do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "abandoned_carts" do
|
||||
field :customer_email, :string
|
||||
field :stripe_session_id, :string
|
||||
field :cart_total, :integer
|
||||
field :expired_at, :utc_datetime
|
||||
field :emailed_at, :utc_datetime
|
||||
|
||||
belongs_to :order, Berrypod.Orders.Order
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(cart, attrs) do
|
||||
cart
|
||||
|> cast(attrs, [
|
||||
:customer_email,
|
||||
:order_id,
|
||||
:stripe_session_id,
|
||||
:cart_total,
|
||||
:expired_at,
|
||||
:emailed_at
|
||||
])
|
||||
|> validate_required([:customer_email, :stripe_session_id, :expired_at])
|
||||
|> validate_format(:customer_email, ~r/@/)
|
||||
|> unique_constraint(:stripe_session_id)
|
||||
end
|
||||
end
|
||||
69
lib/berrypod/orders/abandoned_cart_email_worker.ex
Normal file
69
lib/berrypod/orders/abandoned_cart_email_worker.ex
Normal file
@@ -0,0 +1,69 @@
|
||||
defmodule Berrypod.Orders.AbandonedCartEmailWorker do
|
||||
@moduledoc """
|
||||
Sends a single cart recovery email after an abandoned Stripe checkout session.
|
||||
|
||||
Enqueued with a 1-hour delay from the webhook handler. Re-checks suppression
|
||||
and recent paid orders at send time in case anything changed in the interim.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :checkout, max_attempts: 3
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Orders.OrderNotifier
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"abandoned_cart_id" => cart_id}}) do
|
||||
with {:cart, cart} when not is_nil(cart) <- {:cart, Orders.get_abandoned_cart(cart_id)},
|
||||
{:not_emailed, true} <- {:not_emailed, is_nil(cart.emailed_at)},
|
||||
{:suppression, :ok} <- {:suppression, Orders.check_suppression(cart.customer_email)},
|
||||
{:no_recent_order, false} <-
|
||||
{:no_recent_order, Orders.has_recent_paid_order?(cart.customer_email)},
|
||||
{:order, order} when not is_nil(order) <- {:order, Orders.get_order(cart.order_id)} do
|
||||
unsubscribe_url = build_unsubscribe_url(cart.customer_email)
|
||||
|
||||
case OrderNotifier.deliver_cart_recovery(cart, order, unsubscribe_url) do
|
||||
{:ok, _} ->
|
||||
{:ok, _} = Orders.mark_abandoned_cart_emailed(cart)
|
||||
Logger.info("Cart recovery email sent to #{cart.customer_email}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Cart recovery email failed: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
else
|
||||
{:cart, nil} ->
|
||||
Logger.warning("Cart recovery: abandoned cart #{cart_id} not found")
|
||||
{:cancel, :not_found}
|
||||
|
||||
{:not_emailed, false} ->
|
||||
Logger.info("Cart recovery: already emailed for cart #{cart_id}")
|
||||
:ok
|
||||
|
||||
{:suppression, :suppressed} ->
|
||||
Logger.info("Cart recovery: email suppressed for cart #{cart_id}")
|
||||
:ok
|
||||
|
||||
{:no_recent_order, true} ->
|
||||
Logger.info("Cart recovery: recent paid order found, skipping #{cart_id}")
|
||||
:ok
|
||||
|
||||
{:order, nil} ->
|
||||
Logger.warning("Cart recovery: order not found for cart #{cart_id}")
|
||||
{:cancel, :order_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue(abandoned_cart_id) do
|
||||
%{abandoned_cart_id: abandoned_cart_id}
|
||||
|> new(schedule_in: 3600)
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
defp build_unsubscribe_url(email) do
|
||||
token = Phoenix.Token.sign(BerrypodWeb.Endpoint, "email-unsub", email)
|
||||
BerrypodWeb.Endpoint.url() <> "/unsubscribe/" <> token
|
||||
end
|
||||
end
|
||||
25
lib/berrypod/orders/abandoned_cart_prune_worker.ex
Normal file
25
lib/berrypod/orders/abandoned_cart_prune_worker.ex
Normal file
@@ -0,0 +1,25 @@
|
||||
defmodule Berrypod.Orders.AbandonedCartPruneWorker do
|
||||
@moduledoc """
|
||||
Nightly cron job that deletes abandoned cart records older than 30 days.
|
||||
|
||||
After 30 days the recovery window is well past. Pruning keeps the table
|
||||
small and avoids retaining customer email addresses indefinitely.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :checkout
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Berrypod.Orders.AbandonedCart
|
||||
alias Berrypod.Repo
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(_job) do
|
||||
cutoff = DateTime.add(DateTime.utc_now(), -30, :day)
|
||||
{count, _} = Repo.delete_all(from a in AbandonedCart, where: a.inserted_at < ^cutoff)
|
||||
Logger.info("Pruned #{count} abandoned cart records older than 30 days")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
22
lib/berrypod/orders/email_suppression.ex
Normal file
22
lib/berrypod/orders/email_suppression.ex
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule Berrypod.Orders.EmailSuppression do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "email_suppressions" do
|
||||
field :email, :string
|
||||
field :reason, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(suppression, attrs) do
|
||||
suppression
|
||||
|> cast(attrs, [:email, :reason])
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, ~r/@/)
|
||||
|> unique_constraint(:email)
|
||||
end
|
||||
end
|
||||
@@ -92,6 +92,53 @@ defmodule Berrypod.Orders.OrderNotifier do
|
||||
deliver(order.customer_email, subject, body)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends a cart recovery email to a customer who abandoned a Stripe checkout.
|
||||
|
||||
Plain text, no tracking pixels, single send. Includes an unsubscribe link.
|
||||
"""
|
||||
def deliver_cart_recovery(cart, order, unsubscribe_url) do
|
||||
from_address = Berrypod.Settings.get_setting("email_from_address", "contact@example.com")
|
||||
subject = "You left something behind"
|
||||
|
||||
body = """
|
||||
==============================
|
||||
|
||||
You recently started a checkout but didn't complete it.
|
||||
|
||||
Your cart had:
|
||||
#{format_items(order.items)}
|
||||
Total: #{Cart.format_price(cart.cart_total)}
|
||||
|
||||
If you'd like to complete your order, head to our shop and add these items again.
|
||||
|
||||
We're only sending this once.
|
||||
|
||||
Don't want to hear from us? Unsubscribe: #{unsubscribe_url}
|
||||
|
||||
==============================
|
||||
"""
|
||||
|
||||
email =
|
||||
new()
|
||||
|> to(cart.customer_email)
|
||||
|> from(from_address)
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _metadata} = result ->
|
||||
result
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.warning(
|
||||
"Failed to send cart recovery email to #{cart.customer_email}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
# --- Private ---
|
||||
|
||||
defp deliver(recipient, subject, body) do
|
||||
|
||||
Reference in New Issue
Block a user