berrypod/priv/repo/migrations/20260224000001_create_abandoned_carts.exs
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

32 lines
1004 B
Elixir

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