2026-02-18 21:23:15 +00:00
|
|
|
defmodule BerrypodWeb.CheckoutController do
|
|
|
|
|
use BerrypodWeb, :controller
|
2026-02-07 08:30:17 +00:00
|
|
|
|
2026-02-22 12:50:55 +00:00
|
|
|
alias Berrypod.{Analytics, Cart}
|
2026-02-18 21:23:15 +00:00
|
|
|
alias Berrypod.Orders
|
|
|
|
|
alias Berrypod.Shipping
|
2026-02-07 08:30:17 +00:00
|
|
|
|
|
|
|
|
require Logger
|
|
|
|
|
|
|
|
|
|
def create(conn, _params) do
|
|
|
|
|
cart_items = Cart.get_from_session(get_session(conn))
|
|
|
|
|
hydrated = Cart.hydrate(cart_items)
|
|
|
|
|
|
2026-02-08 15:19:42 +00:00
|
|
|
if hydrated == [] do
|
|
|
|
|
conn
|
|
|
|
|
|> put_flash(:error, "Your basket is empty")
|
|
|
|
|
|> redirect(to: ~p"/cart")
|
|
|
|
|
else
|
2026-02-22 12:50:55 +00:00
|
|
|
track_checkout_start(conn)
|
2026-02-08 15:19:42 +00:00
|
|
|
create_checkout(conn, hydrated)
|
2026-02-07 08:30:17 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp create_checkout(conn, hydrated_items) do
|
|
|
|
|
# Create a pending order with price snapshots
|
|
|
|
|
case Orders.create_order(%{items: hydrated_items}) do
|
|
|
|
|
{:ok, order} ->
|
|
|
|
|
create_stripe_session(conn, order, hydrated_items)
|
|
|
|
|
|
|
|
|
|
{:error, _changeset} ->
|
|
|
|
|
Logger.error("Failed to create order")
|
|
|
|
|
|
|
|
|
|
conn
|
|
|
|
|
|> put_flash(:error, "Something went wrong. Please try again.")
|
|
|
|
|
|> redirect(to: ~p"/cart")
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp create_stripe_session(conn, order, hydrated_items) do
|
|
|
|
|
line_items =
|
|
|
|
|
Enum.map(hydrated_items, fn item ->
|
|
|
|
|
product_name =
|
|
|
|
|
if item.variant,
|
|
|
|
|
do: "#{item.name} — #{item.variant}",
|
|
|
|
|
else: item.name
|
|
|
|
|
|
|
|
|
|
%{
|
|
|
|
|
price_data: %{
|
|
|
|
|
currency: "gbp",
|
|
|
|
|
unit_amount: item.price,
|
|
|
|
|
product_data: %{name: product_name}
|
|
|
|
|
},
|
|
|
|
|
quantity: item.quantity
|
|
|
|
|
}
|
|
|
|
|
end)
|
|
|
|
|
|
2026-02-18 21:23:15 +00:00
|
|
|
base_url = BerrypodWeb.Endpoint.url()
|
2026-02-07 08:30:17 +00:00
|
|
|
|
2026-02-14 10:48:00 +00:00
|
|
|
params =
|
|
|
|
|
%{
|
|
|
|
|
mode: "payment",
|
|
|
|
|
line_items: line_items,
|
|
|
|
|
success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
|
|
|
|
|
cancel_url: "#{base_url}/cart",
|
|
|
|
|
metadata: %{"order_id" => order.id},
|
|
|
|
|
shipping_address_collection: %{
|
|
|
|
|
allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"]
|
|
|
|
|
}
|
2026-02-07 08:30:17 +00:00
|
|
|
}
|
2026-02-14 10:48:00 +00:00
|
|
|
|> maybe_add_shipping_options(hydrated_items)
|
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
|
|
|
|> maybe_add_cart_recovery_notice()
|
2026-02-07 08:30:17 +00:00
|
|
|
|
|
|
|
|
case Stripe.Checkout.Session.create(params) do
|
|
|
|
|
{:ok, session} ->
|
|
|
|
|
{:ok, _order} = Orders.set_stripe_session(order, session.id)
|
|
|
|
|
|
|
|
|
|
conn
|
|
|
|
|
|> redirect(external: session.url)
|
|
|
|
|
|
|
|
|
|
{:error, %Stripe.Error{message: message}} ->
|
|
|
|
|
Logger.error("Stripe session creation failed: #{message}")
|
|
|
|
|
Orders.mark_failed(order)
|
|
|
|
|
|
|
|
|
|
conn
|
|
|
|
|
|> put_flash(:error, "Payment setup failed. Please try again.")
|
|
|
|
|
|> redirect(to: ~p"/cart")
|
|
|
|
|
|
|
|
|
|
{:error, reason} ->
|
|
|
|
|
Logger.error("Stripe session creation failed: #{inspect(reason)}")
|
|
|
|
|
Orders.mark_failed(order)
|
|
|
|
|
|
|
|
|
|
conn
|
|
|
|
|
|> put_flash(:error, "Payment setup failed. Please try again.")
|
|
|
|
|
|> redirect(to: ~p"/cart")
|
|
|
|
|
end
|
|
|
|
|
end
|
2026-02-14 10:48:00 +00:00
|
|
|
|
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
|
|
|
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
|
|
|
|
|
|
2026-02-14 10:48:00 +00:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
options =
|
|
|
|
|
[]
|
|
|
|
|
|> maybe_add_option(gb_result, "UK delivery", 5, 10)
|
|
|
|
|
|> maybe_add_option(us_result, "International delivery", 10, 20)
|
|
|
|
|
|
|
|
|
|
if options == [] do
|
|
|
|
|
params
|
|
|
|
|
else
|
|
|
|
|
Map.put(params, :shipping_options, options)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp maybe_add_option(options, {:ok, cost}, name, min_days, max_days) when cost > 0 do
|
|
|
|
|
option = %{
|
|
|
|
|
shipping_rate_data: %{
|
|
|
|
|
type: "fixed_amount",
|
|
|
|
|
display_name: name,
|
|
|
|
|
fixed_amount: %{amount: cost, currency: "gbp"},
|
|
|
|
|
delivery_estimate: %{
|
|
|
|
|
minimum: %{unit: "business_day", value: min_days},
|
|
|
|
|
maximum: %{unit: "business_day", value: max_days}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
options ++ [option]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp maybe_add_option(options, _result, _name, _min, _max), do: options
|
2026-02-22 12:50:55 +00:00
|
|
|
|
|
|
|
|
defp track_checkout_start(conn) do
|
|
|
|
|
visitor_hash = get_session(conn, "analytics_visitor_hash")
|
|
|
|
|
|
|
|
|
|
if visitor_hash do
|
|
|
|
|
Analytics.track_event("checkout_start", %{
|
|
|
|
|
pathname: "/checkout",
|
2026-02-22 21:13:47 +00:00
|
|
|
visitor_hash: visitor_hash,
|
|
|
|
|
browser: get_session(conn, "analytics_browser"),
|
|
|
|
|
os: get_session(conn, "analytics_os"),
|
|
|
|
|
screen_size: get_session(conn, "analytics_screen_size"),
|
|
|
|
|
country_code: get_session(conn, "country_code")
|
2026-02-22 12:50:55 +00:00
|
|
|
})
|
|
|
|
|
end
|
|
|
|
|
end
|
2026-02-07 08:30:17 +00:00
|
|
|
end
|