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(""" + + +
+ + +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(""" + + + + + +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 --%> ++ 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 %> +