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: [
|
crontab: [
|
||||||
{"*/30 * * * *", Berrypod.Orders.FulfilmentStatusWorker},
|
{"*/30 * * * *", Berrypod.Orders.FulfilmentStatusWorker},
|
||||||
{"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker},
|
{"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]
|
queues: [images: 2, sync: 1, checkout: 1]
|
||||||
|
|||||||
@ -9,7 +9,7 @@ defmodule Berrypod.Orders do
|
|||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
alias Berrypod.Repo
|
alias Berrypod.Repo
|
||||||
alias Berrypod.Orders.{Order, OrderItem, OrderNotifier}
|
alias Berrypod.Orders.{AbandonedCart, EmailSuppression, Order, OrderItem, OrderNotifier}
|
||||||
alias Berrypod.Products
|
alias Berrypod.Products
|
||||||
alias Berrypod.Providers.Provider
|
alias Berrypod.Providers.Provider
|
||||||
|
|
||||||
@ -410,4 +410,90 @@ defmodule Berrypod.Orders do
|
|||||||
do: Map.put(attrs, key, DateTime.utc_now() |> DateTime.truncate(:second))
|
do: Map.put(attrs, key, DateTime.utc_now() |> DateTime.truncate(:second))
|
||||||
|
|
||||||
defp maybe_set(attrs, _key, false), do: attrs
|
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
|
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)
|
deliver(order.customer_email, subject, body)
|
||||||
end
|
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 ---
|
# --- Private ---
|
||||||
|
|
||||||
defp deliver(recipient, subject, body) do
|
defp deliver(recipient, subject, body) do
|
||||||
|
|||||||
@ -133,6 +133,23 @@ defmodule Berrypod.Settings do
|
|||||||
put_setting("site_live", live?, "boolean")
|
put_setting("site_live", live?, "boolean")
|
||||||
end
|
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 """
|
@doc """
|
||||||
Deletes a setting by key.
|
Deletes a setting by key.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -68,6 +68,7 @@ defmodule BerrypodWeb.CheckoutController do
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|> maybe_add_shipping_options(hydrated_items)
|
|> maybe_add_shipping_options(hydrated_items)
|
||||||
|
|> maybe_add_cart_recovery_notice()
|
||||||
|
|
||||||
case Stripe.Checkout.Session.create(params) do
|
case Stripe.Checkout.Session.create(params) do
|
||||||
{:ok, session} ->
|
{:ok, session} ->
|
||||||
@ -94,6 +95,19 @@ defmodule BerrypodWeb.CheckoutController do
|
|||||||
end
|
end
|
||||||
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
|
defp maybe_add_shipping_options(params, hydrated_items) do
|
||||||
gb_result = Shipping.calculate_for_cart(hydrated_items, "GB")
|
gb_result = Shipping.calculate_for_cart(hydrated_items, "GB")
|
||||||
us_result = Shipping.calculate_for_cart(hydrated_items, "US")
|
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
|
defp handle_event(%Stripe.Event{type: "checkout.session.expired", data: %{object: session}}) do
|
||||||
order_id = session.metadata["order_id"]
|
order_id = session.metadata["order_id"]
|
||||||
|
order = Orders.get_order(order_id)
|
||||||
|
|
||||||
case Orders.get_order(order_id) do
|
if order, do: Orders.mark_failed(order)
|
||||||
nil -> :ok
|
|
||||||
order -> Orders.mark_failed(order)
|
|
||||||
end
|
|
||||||
|
|
||||||
Logger.info("Stripe checkout session expired for order #{order_id}")
|
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
|
end
|
||||||
|
|
||||||
defp handle_event(%Stripe.Event{type: type}) do
|
defp handle_event(%Stripe.Event{type: type}) do
|
||||||
Logger.debug("Unhandled Stripe event: #{type}")
|
Logger.debug("Unhandled Stripe event: #{type}")
|
||||||
end
|
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
|
defp update_shipping(order, shipping_details) do
|
||||||
address = shipping_details.address || %{}
|
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
|
socket
|
||||||
|> assign(:page_title, "Settings")
|
|> assign(:page_title, "Settings")
|
||||||
|> assign(:site_live, Settings.site_live?())
|
|> assign(:site_live, Settings.site_live?())
|
||||||
|
|> assign(:cart_recovery_enabled, Settings.abandoned_cart_recovery_enabled?())
|
||||||
|> assign_stripe_state()
|
|> assign_stripe_state()
|
||||||
|> assign_products_state()
|
|> assign_products_state()
|
||||||
|> assign_account_state(user)}
|
|> assign_account_state(user)}
|
||||||
@ -90,6 +91,23 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
|> put_flash(:info, message)}
|
|> put_flash(:info, message)}
|
||||||
end
|
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 --
|
# -- Events: Stripe --
|
||||||
|
|
||||||
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
||||||
@ -367,6 +385,49 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</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 --%>
|
<%!-- Account --%>
|
||||||
<section class="mt-10">
|
<section class="mt-10">
|
||||||
<h2 class="text-lg font-semibold">Account</h2>
|
<h2 class="text-lg font-semibold">Account</h2>
|
||||||
|
|||||||
@ -164,6 +164,7 @@ defmodule BerrypodWeb.Router do
|
|||||||
pipe_through [:browser]
|
pipe_through [:browser]
|
||||||
|
|
||||||
get "/orders/verify/:token", OrderLookupController, :verify
|
get "/orders/verify/:token", OrderLookupController, :verify
|
||||||
|
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
|
||||||
end
|
end
|
||||||
|
|
||||||
# Setup page — minimal live_session, no theme/cart/search hooks
|
# 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