diff --git a/config/config.exs b/config/config.exs index 0b6e012..ea4b501 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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] diff --git a/lib/berrypod/orders.ex b/lib/berrypod/orders.ex index d16b6f6..37a5930 100644 --- a/lib/berrypod/orders.ex +++ b/lib/berrypod/orders.ex @@ -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 diff --git a/lib/berrypod/orders/abandoned_cart.ex b/lib/berrypod/orders/abandoned_cart.ex new file mode 100644 index 0000000..50c0fce --- /dev/null +++ b/lib/berrypod/orders/abandoned_cart.ex @@ -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 diff --git a/lib/berrypod/orders/abandoned_cart_email_worker.ex b/lib/berrypod/orders/abandoned_cart_email_worker.ex new file mode 100644 index 0000000..b276b7b --- /dev/null +++ b/lib/berrypod/orders/abandoned_cart_email_worker.ex @@ -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 diff --git a/lib/berrypod/orders/abandoned_cart_prune_worker.ex b/lib/berrypod/orders/abandoned_cart_prune_worker.ex new file mode 100644 index 0000000..5e54bc8 --- /dev/null +++ b/lib/berrypod/orders/abandoned_cart_prune_worker.ex @@ -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 diff --git a/lib/berrypod/orders/email_suppression.ex b/lib/berrypod/orders/email_suppression.ex new file mode 100644 index 0000000..d174d56 --- /dev/null +++ b/lib/berrypod/orders/email_suppression.ex @@ -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 diff --git a/lib/berrypod/orders/order_notifier.ex b/lib/berrypod/orders/order_notifier.ex index f2473fb..90fbaef 100644 --- a/lib/berrypod/orders/order_notifier.ex +++ b/lib/berrypod/orders/order_notifier.ex @@ -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 diff --git a/lib/berrypod/settings.ex b/lib/berrypod/settings.ex index 23db60e..88c56da 100644 --- a/lib/berrypod/settings.ex +++ b/lib/berrypod/settings.ex @@ -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. """ diff --git a/lib/berrypod_web/controllers/checkout_controller.ex b/lib/berrypod_web/controllers/checkout_controller.ex index 12e2ada..fe989e6 100644 --- a/lib/berrypod_web/controllers/checkout_controller.ex +++ b/lib/berrypod_web/controllers/checkout_controller.ex @@ -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") diff --git a/lib/berrypod_web/controllers/stripe_webhook_controller.ex b/lib/berrypod_web/controllers/stripe_webhook_controller.ex index 3891ab0..94e4922 100644 --- a/lib/berrypod_web/controllers/stripe_webhook_controller.ex +++ b/lib/berrypod_web/controllers/stripe_webhook_controller.ex @@ -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 || %{} diff --git a/lib/berrypod_web/controllers/unsubscribe_controller.ex b/lib/berrypod_web/controllers/unsubscribe_controller.ex new file mode 100644 index 0000000..e4e4074 --- /dev/null +++ b/lib/berrypod_web/controllers/unsubscribe_controller.ex @@ -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(""" + + + + + + Unsubscribed + + + +

You've been unsubscribed

+

We've removed #{email} from our marketing list. You won't receive any more cart recovery emails from us.

+ + + """) + + {:error, _reason} -> + conn + |> put_status(400) + |> html(""" + + + + + + Invalid link + + + +

Link invalid or expired

+

This unsubscribe link has expired or is invalid. If you'd like to unsubscribe, reply to any email we've sent you.

+ + + """) + end + end +end diff --git a/lib/berrypod_web/live/admin/settings.ex b/lib/berrypod_web/live/admin/settings.ex index 12ea7b4..7c0b07b 100644 --- a/lib/berrypod_web/live/admin/settings.ex +++ b/lib/berrypod_web/live/admin/settings.ex @@ -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 %> + <%!-- Cart recovery --%> +
+
+

Cart recovery

+ <%= if @cart_recovery_enabled do %> + <.status_pill color="green"> + <.icon name="hero-check-circle-mini" class="size-3" /> On + + <% else %> + <.status_pill color="zinc">Off + <% end %> +
+

+ 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. +

+ <%= if @cart_recovery_enabled do %> +

+ Make sure your privacy policy mentions that a single recovery email may be sent, + and that customers can unsubscribe at any time. +

+ <% end %> +
+ +
+
+ <%!-- Account --%>

Account

diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index 573a0e1..4dd67c6 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -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 diff --git a/priv/repo/migrations/20260224000001_create_abandoned_carts.exs b/priv/repo/migrations/20260224000001_create_abandoned_carts.exs new file mode 100644 index 0000000..ea72f3d --- /dev/null +++ b/priv/repo/migrations/20260224000001_create_abandoned_carts.exs @@ -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 diff --git a/test/berrypod/orders/abandoned_cart_email_worker_test.exs b/test/berrypod/orders/abandoned_cart_email_worker_test.exs new file mode 100644 index 0000000..5213294 --- /dev/null +++ b/test/berrypod/orders/abandoned_cart_email_worker_test.exs @@ -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 diff --git a/test/berrypod/orders/abandoned_cart_prune_worker_test.exs b/test/berrypod/orders/abandoned_cart_prune_worker_test.exs new file mode 100644 index 0000000..fa5ae55 --- /dev/null +++ b/test/berrypod/orders/abandoned_cart_prune_worker_test.exs @@ -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 diff --git a/test/berrypod/orders/abandoned_cart_test.exs b/test/berrypod/orders/abandoned_cart_test.exs new file mode 100644 index 0000000..e61991f --- /dev/null +++ b/test/berrypod/orders/abandoned_cart_test.exs @@ -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 diff --git a/test/berrypod_web/controllers/unsubscribe_controller_test.exs b/test/berrypod_web/controllers/unsubscribe_controller_test.exs new file mode 100644 index 0000000..00de83f --- /dev/null +++ b/test/berrypod_web/controllers/unsubscribe_controller_test.exs @@ -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