berrypod/lib/berrypod/orders/abandoned_cart_prune_worker.ex
jamey 2f4cd81f98 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>
2026-02-24 10:02:37 +00:00

26 lines
718 B
Elixir

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