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:
parent
758e66db5c
commit
2f4cd81f98
@ -94,7 +94,8 @@ config :berrypod, Oban,
|
||||
crontab: [
|
||||
{"*/30 * * * *", Berrypod.Orders.FulfilmentStatusWorker},
|
||||
{"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker},
|
||||
{"0 3 * * *", Berrypod.Analytics.RetentionWorker}
|
||||
{"0 3 * * *", Berrypod.Analytics.RetentionWorker},
|
||||
{"0 4 * * *", Berrypod.Orders.AbandonedCartPruneWorker}
|
||||
]}
|
||||
],
|
||||
queues: [images: 2, sync: 1, checkout: 1]
|
||||
|
||||
@ -9,7 +9,7 @@ defmodule Berrypod.Orders do
|
||||
|
||||
import Ecto.Query
|
||||
alias Berrypod.Repo
|
||||
alias Berrypod.Orders.{Order, OrderItem, OrderNotifier}
|
||||
alias Berrypod.Orders.{AbandonedCart, EmailSuppression, Order, OrderItem, OrderNotifier}
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Providers.Provider
|
||||
|
||||
@ -410,4 +410,90 @@ defmodule Berrypod.Orders do
|
||||
do: Map.put(attrs, key, DateTime.utc_now() |> DateTime.truncate(:second))
|
||||
|
||||
defp maybe_set(attrs, _key, false), do: attrs
|
||||
|
||||
# =============================================================================
|
||||
# Abandoned cart
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Creates an abandoned cart record.
|
||||
"""
|
||||
def create_abandoned_cart(attrs) do
|
||||
%AbandonedCart{}
|
||||
|> AbandonedCart.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets an abandoned cart by ID.
|
||||
"""
|
||||
def get_abandoned_cart(id) do
|
||||
Repo.get(AbandonedCart, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets an abandoned cart by Stripe session ID, or nil if not found.
|
||||
"""
|
||||
def get_abandoned_cart_by_session(stripe_session_id) do
|
||||
Repo.get_by(AbandonedCart, stripe_session_id: stripe_session_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stamps an abandoned cart as emailed.
|
||||
"""
|
||||
def mark_abandoned_cart_emailed(cart) do
|
||||
cart
|
||||
|> AbandonedCart.changeset(%{emailed_at: DateTime.utc_now() |> DateTime.truncate(:second)})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the given email has a paid order within the last `hours` hours.
|
||||
|
||||
Used to skip recovery emails for customers who completed a purchase shortly
|
||||
after their session expired (e.g. started over in a new tab).
|
||||
"""
|
||||
def has_recent_paid_order?(email, hours \\ 48) do
|
||||
normalised = String.downcase(String.trim(email))
|
||||
cutoff = DateTime.add(DateTime.utc_now(), -(hours * 3600), :second)
|
||||
|
||||
Order
|
||||
|> where([o], fragment("lower(trim(?))", o.customer_email) == ^normalised)
|
||||
|> where([o], o.payment_status == "paid")
|
||||
|> where([o], o.inserted_at >= ^cutoff)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Email suppression
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Checks whether an email address is suppressed from marketing emails.
|
||||
|
||||
Returns `:ok` if the address is not suppressed, `:suppressed` if it is.
|
||||
Only applies to marketing emails (cart recovery). Transactional emails
|
||||
(order confirmation, shipping) are not affected.
|
||||
"""
|
||||
def check_suppression(email) do
|
||||
normalised = String.downcase(String.trim(email))
|
||||
|
||||
case Repo.get_by(EmailSuppression, email: normalised) do
|
||||
nil -> :ok
|
||||
_suppression -> :suppressed
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds an email address to the suppression list.
|
||||
|
||||
Idempotent — inserting an already-suppressed address is a no-op.
|
||||
"""
|
||||
def add_suppression(email, reason \\ "unsubscribed") do
|
||||
normalised = String.downcase(String.trim(email))
|
||||
|
||||
%EmailSuppression{}
|
||||
|> EmailSuppression.changeset(%{email: normalised, reason: reason})
|
||||
|> Repo.insert(on_conflict: :nothing, conflict_target: :email)
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@ -133,6 +133,23 @@ defmodule Berrypod.Settings do
|
||||
put_setting("site_live", live?, "boolean")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns whether abandoned cart recovery emails are enabled.
|
||||
|
||||
Defaults to false — shop owners must explicitly opt in, since the
|
||||
feature has legal implications (privacy policy wording, PECR/GDPR compliance).
|
||||
"""
|
||||
def abandoned_cart_recovery_enabled? do
|
||||
get_setting("abandoned_cart_recovery", false) == true
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enables or disables abandoned cart recovery emails.
|
||||
"""
|
||||
def set_abandoned_cart_recovery(enabled?) when is_boolean(enabled?) do
|
||||
put_setting("abandoned_cart_recovery", enabled?, "boolean")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a setting by key.
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
defmodule Berrypod.Repo.Migrations.CreateAbandonedCarts do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:abandoned_carts, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :customer_email, :string, null: false
|
||||
add :order_id, references(:orders, type: :binary_id, on_delete: :nilify_all)
|
||||
add :stripe_session_id, :string, null: false
|
||||
add :cart_total, :integer
|
||||
add :expired_at, :utc_datetime, null: false
|
||||
add :emailed_at, :utc_datetime
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:abandoned_carts, [:stripe_session_id])
|
||||
create index(:abandoned_carts, [:customer_email])
|
||||
create index(:abandoned_carts, [:inserted_at])
|
||||
|
||||
create table(:email_suppressions, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :email, :string, null: false
|
||||
add :reason, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:email_suppressions, [:email])
|
||||
end
|
||||
end
|
||||
78
test/berrypod/orders/abandoned_cart_email_worker_test.exs
Normal file
78
test/berrypod/orders/abandoned_cart_email_worker_test.exs
Normal file
@ -0,0 +1,78 @@
|
||||
defmodule Berrypod.Orders.AbandonedCartEmailWorkerTest do
|
||||
use Berrypod.DataCase, async: false
|
||||
|
||||
import Swoosh.TestAssertions
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Orders.AbandonedCartEmailWorker
|
||||
|
||||
import Berrypod.OrdersFixtures
|
||||
|
||||
defp build_cart(attrs \\ %{}) do
|
||||
order = order_fixture(%{customer_email: Map.get(attrs, :customer_email, "buyer@example.com")})
|
||||
|
||||
defaults = %{
|
||||
customer_email: order.customer_email,
|
||||
order_id: order.id,
|
||||
stripe_session_id: "cs_#{System.unique_integer([:positive])}",
|
||||
cart_total: order.total,
|
||||
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
}
|
||||
|
||||
{:ok, cart} = Orders.create_abandoned_cart(Map.merge(defaults, attrs))
|
||||
{cart, order}
|
||||
end
|
||||
|
||||
describe "perform/1 — sends email" do
|
||||
test "sends cart recovery email and marks as emailed" do
|
||||
{cart, _order} = build_cart()
|
||||
|
||||
assert :ok =
|
||||
AbandonedCartEmailWorker.new(%{abandoned_cart_id: cart.id})
|
||||
|> Oban.insert!()
|
||||
|> then(fn job -> AbandonedCartEmailWorker.perform(job) end)
|
||||
|
||||
assert_email_sent(subject: "You left something behind", to: cart.customer_email)
|
||||
|
||||
updated = Orders.get_abandoned_cart(cart.id)
|
||||
assert not is_nil(updated.emailed_at)
|
||||
end
|
||||
|
||||
test "does not send twice if already emailed" do
|
||||
{cart, _order} = build_cart()
|
||||
{:ok, cart} = Orders.mark_abandoned_cart_emailed(cart)
|
||||
|
||||
AbandonedCartEmailWorker.perform(%Oban.Job{args: %{"abandoned_cart_id" => cart.id}})
|
||||
|
||||
assert_no_email_sent()
|
||||
end
|
||||
|
||||
test "skips send if email is suppressed" do
|
||||
{cart, _} = build_cart(%{customer_email: "suppressed@example.com"})
|
||||
Orders.add_suppression("suppressed@example.com", "unsubscribed")
|
||||
|
||||
AbandonedCartEmailWorker.perform(%Oban.Job{args: %{"abandoned_cart_id" => cart.id}})
|
||||
|
||||
assert_no_email_sent()
|
||||
end
|
||||
|
||||
test "skips send if customer has a recent paid order" do
|
||||
{cart, _} = build_cart(%{customer_email: "recent@example.com"})
|
||||
order_fixture(%{customer_email: "recent@example.com", payment_status: "paid"})
|
||||
|
||||
AbandonedCartEmailWorker.perform(%Oban.Job{args: %{"abandoned_cart_id" => cart.id}})
|
||||
|
||||
assert_no_email_sent()
|
||||
end
|
||||
|
||||
test "cancels if cart not found" do
|
||||
result =
|
||||
AbandonedCartEmailWorker.perform(%Oban.Job{
|
||||
args: %{"abandoned_cart_id" => Ecto.UUID.generate()}
|
||||
})
|
||||
|
||||
assert {:cancel, :not_found} = result
|
||||
assert_no_email_sent()
|
||||
end
|
||||
end
|
||||
end
|
||||
51
test/berrypod/orders/abandoned_cart_prune_worker_test.exs
Normal file
51
test/berrypod/orders/abandoned_cart_prune_worker_test.exs
Normal file
@ -0,0 +1,51 @@
|
||||
defmodule Berrypod.Orders.AbandonedCartPruneWorkerTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Orders.AbandonedCartPruneWorker
|
||||
|
||||
defp create_cart(attrs \\ %{}) do
|
||||
defaults = %{
|
||||
customer_email: "test@example.com",
|
||||
stripe_session_id: "cs_#{System.unique_integer([:positive])}",
|
||||
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
}
|
||||
|
||||
{:ok, cart} = Orders.create_abandoned_cart(Map.merge(defaults, attrs))
|
||||
cart
|
||||
end
|
||||
|
||||
defp backdate(cart, days) do
|
||||
Berrypod.Repo.update_all(
|
||||
Ecto.Query.from(a in Berrypod.Orders.AbandonedCart, where: a.id == ^cart.id),
|
||||
set: [inserted_at: DateTime.add(DateTime.utc_now(), -days, :day)]
|
||||
)
|
||||
end
|
||||
|
||||
describe "perform/1" do
|
||||
test "deletes carts older than 30 days" do
|
||||
old_cart = create_cart()
|
||||
backdate(old_cart, 31)
|
||||
|
||||
recent_cart = create_cart()
|
||||
|
||||
assert :ok = AbandonedCartPruneWorker.perform(%Oban.Job{args: %{}})
|
||||
|
||||
assert is_nil(Orders.get_abandoned_cart(old_cart.id))
|
||||
assert not is_nil(Orders.get_abandoned_cart(recent_cart.id))
|
||||
end
|
||||
|
||||
test "keeps carts exactly at the 30-day boundary" do
|
||||
boundary_cart = create_cart()
|
||||
backdate(boundary_cart, 29)
|
||||
|
||||
assert :ok = AbandonedCartPruneWorker.perform(%Oban.Job{args: %{}})
|
||||
|
||||
assert not is_nil(Orders.get_abandoned_cart(boundary_cart.id))
|
||||
end
|
||||
|
||||
test "is a no-op when there's nothing to prune" do
|
||||
assert :ok = AbandonedCartPruneWorker.perform(%Oban.Job{args: %{}})
|
||||
end
|
||||
end
|
||||
end
|
||||
167
test/berrypod/orders/abandoned_cart_test.exs
Normal file
167
test/berrypod/orders/abandoned_cart_test.exs
Normal file
@ -0,0 +1,167 @@
|
||||
defmodule Berrypod.Orders.AbandonedCartTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
|
||||
alias Berrypod.Orders
|
||||
|
||||
import Berrypod.OrdersFixtures
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abandoned carts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
describe "create_abandoned_cart/1" do
|
||||
test "creates a record with required fields" do
|
||||
{:ok, cart} =
|
||||
Orders.create_abandoned_cart(%{
|
||||
customer_email: "test@example.com",
|
||||
stripe_session_id: "cs_test_abc",
|
||||
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
})
|
||||
|
||||
assert cart.customer_email == "test@example.com"
|
||||
assert cart.stripe_session_id == "cs_test_abc"
|
||||
assert is_nil(cart.emailed_at)
|
||||
end
|
||||
|
||||
test "links to an existing order" do
|
||||
order = order_fixture()
|
||||
|
||||
{:ok, cart} =
|
||||
Orders.create_abandoned_cart(%{
|
||||
customer_email: "buyer@example.com",
|
||||
stripe_session_id: "cs_test_xyz",
|
||||
order_id: order.id,
|
||||
cart_total: order.total,
|
||||
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
})
|
||||
|
||||
assert cart.order_id == order.id
|
||||
end
|
||||
|
||||
test "enforces unique stripe_session_id" do
|
||||
attrs = %{
|
||||
customer_email: "test@example.com",
|
||||
stripe_session_id: "cs_test_dup",
|
||||
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
}
|
||||
|
||||
{:ok, _} = Orders.create_abandoned_cart(attrs)
|
||||
{:error, changeset} = Orders.create_abandoned_cart(attrs)
|
||||
assert "has already been taken" in errors_on(changeset).stripe_session_id
|
||||
end
|
||||
|
||||
test "requires customer_email" do
|
||||
{:error, changeset} =
|
||||
Orders.create_abandoned_cart(%{
|
||||
stripe_session_id: "cs_test_noemail",
|
||||
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
})
|
||||
|
||||
assert "can't be blank" in errors_on(changeset).customer_email
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_abandoned_cart_by_session/1" do
|
||||
test "returns the cart for a known session" do
|
||||
{:ok, cart} =
|
||||
Orders.create_abandoned_cart(%{
|
||||
customer_email: "a@example.com",
|
||||
stripe_session_id: "cs_find_me",
|
||||
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
})
|
||||
|
||||
found = Orders.get_abandoned_cart_by_session("cs_find_me")
|
||||
assert found.id == cart.id
|
||||
end
|
||||
|
||||
test "returns nil for unknown session" do
|
||||
assert is_nil(Orders.get_abandoned_cart_by_session("cs_nonexistent"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "mark_abandoned_cart_emailed/1" do
|
||||
test "sets emailed_at timestamp" do
|
||||
{:ok, cart} =
|
||||
Orders.create_abandoned_cart(%{
|
||||
customer_email: "b@example.com",
|
||||
stripe_session_id: "cs_mark_test",
|
||||
expired_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
})
|
||||
|
||||
assert is_nil(cart.emailed_at)
|
||||
|
||||
{:ok, updated} = Orders.mark_abandoned_cart_emailed(cart)
|
||||
assert not is_nil(updated.emailed_at)
|
||||
end
|
||||
end
|
||||
|
||||
describe "has_recent_paid_order?/2" do
|
||||
test "returns true when email has a paid order within the window" do
|
||||
order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"})
|
||||
|
||||
assert Orders.has_recent_paid_order?("buyer@example.com", 48)
|
||||
end
|
||||
|
||||
test "returns false when no paid order exists" do
|
||||
refute Orders.has_recent_paid_order?("nobody@example.com", 48)
|
||||
end
|
||||
|
||||
test "returns false when paid order is outside the window" do
|
||||
order = order_fixture(%{customer_email: "old@example.com", payment_status: "paid"})
|
||||
|
||||
# Backdating: force inserted_at to be 3 days ago
|
||||
Berrypod.Repo.update_all(
|
||||
Ecto.Query.from(o in Berrypod.Orders.Order, where: o.id == ^order.id),
|
||||
set: [inserted_at: DateTime.add(DateTime.utc_now(), -3, :day)]
|
||||
)
|
||||
|
||||
refute Orders.has_recent_paid_order?("old@example.com", 48)
|
||||
end
|
||||
|
||||
test "is case-insensitive" do
|
||||
order_fixture(%{customer_email: "Buyer@Example.COM", payment_status: "paid"})
|
||||
|
||||
assert Orders.has_recent_paid_order?("buyer@example.com")
|
||||
end
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Email suppression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
describe "check_suppression/1" do
|
||||
test "returns :ok for a non-suppressed email" do
|
||||
assert :ok == Orders.check_suppression("fresh@example.com")
|
||||
end
|
||||
|
||||
test "returns :suppressed after adding suppression" do
|
||||
Orders.add_suppression("blocked@example.com", "unsubscribed")
|
||||
assert :suppressed == Orders.check_suppression("blocked@example.com")
|
||||
end
|
||||
|
||||
test "is case-insensitive" do
|
||||
Orders.add_suppression("upper@example.com", "unsubscribed")
|
||||
assert :suppressed == Orders.check_suppression("UPPER@EXAMPLE.COM")
|
||||
end
|
||||
end
|
||||
|
||||
describe "add_suppression/2" do
|
||||
test "creates a suppression record" do
|
||||
{:ok, sup} = Orders.add_suppression("new@example.com", "unsubscribed")
|
||||
assert sup.email == "new@example.com"
|
||||
assert sup.reason == "unsubscribed"
|
||||
end
|
||||
|
||||
test "is idempotent — second insert is a no-op" do
|
||||
{:ok, _} = Orders.add_suppression("once@example.com", "unsubscribed")
|
||||
{:ok, _} = Orders.add_suppression("once@example.com", "unsubscribed")
|
||||
|
||||
assert :suppressed == Orders.check_suppression("once@example.com")
|
||||
end
|
||||
|
||||
test "normalises email to lowercase" do
|
||||
Orders.add_suppression("Mixed@Example.COM", "test")
|
||||
assert :suppressed == Orders.check_suppression("mixed@example.com")
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,36 @@
|
||||
defmodule BerrypodWeb.UnsubscribeControllerTest do
|
||||
use BerrypodWeb.ConnCase, async: true
|
||||
|
||||
alias Berrypod.Orders
|
||||
|
||||
defp sign_token(email) do
|
||||
Phoenix.Token.sign(BerrypodWeb.Endpoint, "email-unsub", email)
|
||||
end
|
||||
|
||||
describe "GET /unsubscribe/:token" do
|
||||
test "valid token: suppresses email and returns 200", %{conn: conn} do
|
||||
token = sign_token("unsub@example.com")
|
||||
conn = get(conn, ~p"/unsubscribe/#{token}")
|
||||
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body =~ "Unsubscribed"
|
||||
assert Orders.check_suppression("unsub@example.com") == :suppressed
|
||||
end
|
||||
|
||||
test "invalid token returns 400", %{conn: conn} do
|
||||
conn = get(conn, ~p"/unsubscribe/bad_token_here")
|
||||
|
||||
assert conn.status == 400
|
||||
assert conn.resp_body =~ "invalid or expired"
|
||||
end
|
||||
|
||||
test "already suppressed — idempotent, still returns 200", %{conn: conn} do
|
||||
Orders.add_suppression("already@example.com", "unsubscribed")
|
||||
token = sign_token("already@example.com")
|
||||
conn = get(conn, ~p"/unsubscribe/#{token}")
|
||||
|
||||
assert conn.status == 200
|
||||
assert Orders.check_suppression("already@example.com") == :suppressed
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user