diff --git a/PROGRESS.md b/PROGRESS.md index f28aa94..79622c6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -17,9 +17,10 @@ - Admin credentials page with guided Stripe setup flow - Encrypted settings for API keys and secrets - Search modal with keyboard shortcut +- Transactional emails (order confirmation, shipping notification) - Demo content polished and ready for production -**Next up:** Transactional emails — order confirmation and shipping notifications (Tier 1, Roadmap #3) +**Next up:** Default content pages — terms, privacy, delivery policy (Tier 1, Roadmap #4) ## Roadmap @@ -27,7 +28,7 @@ 1. ~~**Order management admin**~~ — ✅ Complete (02cdc81). Admin UI at `/admin/orders` with status filter tabs, streamed order table, and detail view showing items, totals, and shipping address. 2. ~~**Orders & fulfilment**~~ — ✅ Complete. Submit paid orders to Printify, track fulfilment status (submitted → processing → shipped → delivered), webhook-driven status updates with polling fallback, admin UI with submit/refresh actions. -3. **Transactional emails** — Order confirmation email on payment. Shipping notification with tracking link. Use Swoosh (already configured) with a simple HTML template. +3. ~~**Transactional emails**~~ — ✅ Complete. Plain text order confirmation (on payment via Stripe webhook) and shipping notification (on dispatch via Printify webhook + polling fallback). OrderNotifier module, 10 tests. 4. **Default content pages** — Static pages for terms of service, delivery & refunds policy, and privacy policy. Needed for legal compliance before taking real orders. Can be simple markdown-rendered pages initially, upgraded to editable via page editor later. ### Tier 2 — Production readiness (can deploy and run reliably) @@ -172,7 +173,7 @@ See: [ROADMAP.md](ROADMAP.md) for design notes - CSSCache test startup crash fixed (handle_continue pattern) ### Orders & Fulfilment -**Status:** Complete (checkout, admin, fulfilment). Transactional emails pending (Roadmap #3). +**Status:** Complete - [x] Orders context with schemas (ff1bc48) - [x] Stripe Checkout integration with webhook handling @@ -191,7 +192,12 @@ See: [ROADMAP.md](ROADMAP.md) for design notes - Stripe webhook auto-enqueues submission after payment confirmed - Admin UI: fulfilment badge column, fulfilment card with tracking, submit/refresh buttons - Mox provider mocking for test isolation, 33 new tests (555 total) -- [ ] Transactional emails (Roadmap #3) +- [x] Transactional emails (Roadmap #3) + - OrderNotifier module with plain text emails via Swoosh + - Order confirmation sent from Stripe webhook after payment + address/email updates + - Shipping notification sent from Printify shipment webhook + polling fallback + - Guards for missing customer_email, graceful tracking info handling + - 10 tests (565 total) See: [docs/plans/products-context.md](docs/plans/products-context.md) for schema design @@ -208,6 +214,7 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design | Feature | Commit | Notes | |---------|--------|-------| +| Transactional emails | — | Plain text order confirmation + shipping notification, 10 tests | | Printify order submission & fulfilment | — | Submit, track, webhooks, polling, admin UI, 33 tests | | Order management admin | 02cdc81 | List/detail views, status filters, 15 tests | | Encrypted settings & Stripe setup | eede9bb | Guided setup flow, encrypted secrets, admin credentials page | diff --git a/lib/simpleshop_theme/orders.ex b/lib/simpleshop_theme/orders.ex index 9af9278..1375f60 100644 --- a/lib/simpleshop_theme/orders.ex +++ b/lib/simpleshop_theme/orders.ex @@ -9,7 +9,7 @@ defmodule SimpleshopTheme.Orders do import Ecto.Query alias SimpleshopTheme.Repo - alias SimpleshopTheme.Orders.{Order, OrderItem} + alias SimpleshopTheme.Orders.{Order, OrderItem, OrderNotifier} alias SimpleshopTheme.Products alias SimpleshopTheme.Providers.Provider @@ -233,7 +233,13 @@ defmodule SimpleshopTheme.Orders do } |> maybe_set_timestamp(order) - update_fulfilment(order, attrs) + with {:ok, updated_order} <- update_fulfilment(order, attrs) do + if attrs[:fulfilment_status] == "shipped" and order.fulfilment_status != "shipped" do + OrderNotifier.deliver_shipping_notification(updated_order) + end + + {:ok, updated_order} + end end end diff --git a/lib/simpleshop_theme/orders/order_notifier.ex b/lib/simpleshop_theme/orders/order_notifier.ex new file mode 100644 index 0000000..171004d --- /dev/null +++ b/lib/simpleshop_theme/orders/order_notifier.ex @@ -0,0 +1,130 @@ +defmodule SimpleshopTheme.Orders.OrderNotifier do + @moduledoc """ + Sends transactional emails for orders. + + Order confirmation after payment, shipping notification when dispatched. + """ + + import Swoosh.Email + + alias SimpleshopTheme.Cart + alias SimpleshopTheme.Mailer + + require Logger + + @doc """ + Sends an order confirmation email after successful payment. + + Skips silently if the order has no customer email. + """ + def deliver_order_confirmation(%{customer_email: nil}), do: {:ok, :no_email} + def deliver_order_confirmation(%{customer_email: ""}), do: {:ok, :no_email} + + def deliver_order_confirmation(order) do + subject = "Order confirmed - #{order.order_number}" + + body = """ + ============================== + + Thanks for your order! + + Order: #{order.order_number} + + #{format_items(order.items)} + Total: #{Cart.format_price(order.total)} + + #{format_shipping_address(order.shipping_address)} + We'll send you another email when your order ships. + + ============================== + """ + + deliver(order.customer_email, subject, body) + end + + @doc """ + Sends a shipping notification with tracking info. + + Skips silently if the order has no customer email. + """ + def deliver_shipping_notification(%{customer_email: nil}), do: {:ok, :no_email} + def deliver_shipping_notification(%{customer_email: ""}), do: {:ok, :no_email} + + def deliver_shipping_notification(order) do + subject = "Your order has shipped - #{order.order_number}" + + body = """ + ============================== + + Good news! Your order #{order.order_number} is on its way. + + #{format_tracking(order)} + Thanks for shopping with us. + + ============================== + """ + + deliver(order.customer_email, subject, body) + end + + # --- Private --- + + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"SimpleshopTheme", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + case Mailer.deliver(email) do + {:ok, _metadata} = result -> + result + + {:error, reason} = error -> + Logger.warning("Failed to send email to #{recipient}: #{inspect(reason)}") + error + end + end + + defp format_items(items) when is_list(items) do + items + |> Enum.map_join("\n", fn item -> + price = Cart.format_price(item.unit_price * item.quantity) + " #{item.quantity}x #{item.product_name} (#{item.variant_title}) - #{price}" + end) + end + + defp format_items(_), do: "" + + defp format_shipping_address(address) when is_map(address) and map_size(address) > 0 do + lines = + [ + address["name"], + address["line1"], + address["line2"], + [address["city"], address["postal_code"]] |> Enum.reject(&is_nil/1) |> Enum.join(" "), + address["state"], + address["country"] + ] + |> Enum.reject(&(is_nil(&1) or &1 == "")) + |> Enum.map_join("\n", &" #{&1}") + + "Shipping to:\n#{lines}\n\n" + end + + defp format_shipping_address(_), do: "" + + defp format_tracking(order) do + cond do + order.tracking_url not in [nil, ""] and order.tracking_number not in [nil, ""] -> + "Tracking: #{order.tracking_number}\n#{order.tracking_url}\n\n" + + order.tracking_number not in [nil, ""] -> + "Tracking: #{order.tracking_number}\n\n" + + true -> + "Tracking details will follow once the carrier updates.\n\n" + end + end +end diff --git a/lib/simpleshop_theme/webhooks.ex b/lib/simpleshop_theme/webhooks.ex index d3ba4b0..6c9c0a5 100644 --- a/lib/simpleshop_theme/webhooks.ex +++ b/lib/simpleshop_theme/webhooks.ex @@ -4,6 +4,7 @@ defmodule SimpleshopTheme.Webhooks do """ alias SimpleshopTheme.Orders + alias SimpleshopTheme.Orders.OrderNotifier alias SimpleshopTheme.Products alias SimpleshopTheme.Sync.ProductSyncWorker alias SimpleshopTheme.Webhooks.ProductDeleteWorker @@ -44,14 +45,17 @@ defmodule SimpleshopTheme.Webhooks do def handle_printify_event("order:shipment:created", resource) do shipment = extract_shipment(resource) - with {:ok, order} <- find_order_from_resource(resource) do - Orders.update_fulfilment(order, %{ - fulfilment_status: "shipped", - provider_status: "shipped", - tracking_number: shipment.tracking_number, - tracking_url: shipment.tracking_url, - shipped_at: DateTime.utc_now() |> DateTime.truncate(:second) - }) + with {:ok, order} <- find_order_from_resource(resource), + {:ok, updated_order} <- + Orders.update_fulfilment(order, %{ + fulfilment_status: "shipped", + provider_status: "shipped", + tracking_number: shipment.tracking_number, + tracking_url: shipment.tracking_url, + shipped_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) do + OrderNotifier.deliver_shipping_notification(updated_order) + {:ok, updated_order} end end diff --git a/lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex b/lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex index d510365..af241e9 100644 --- a/lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex +++ b/lib/simpleshop_theme_web/controllers/stripe_webhook_controller.ex @@ -2,7 +2,7 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do use SimpleshopThemeWeb, :controller alias SimpleshopTheme.Orders - alias SimpleshopTheme.Orders.OrderSubmissionWorker + alias SimpleshopTheme.Orders.{OrderNotifier, OrderSubmissionWorker} require Logger @@ -56,6 +56,9 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do order end + # Reload items for the email (update_order doesn't preload) + order = Orders.get_order(order.id) + # Broadcast to success page via PubSub Phoenix.PubSub.broadcast( SimpleshopTheme.PubSub, @@ -63,6 +66,8 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do {:order_paid, order} ) + OrderNotifier.deliver_order_confirmation(order) + # Submit to fulfilment provider if order.shipping_address && order.shipping_address != %{} do OrderSubmissionWorker.enqueue(order.id) diff --git a/test/simpleshop_theme/orders/order_notifier_test.exs b/test/simpleshop_theme/orders/order_notifier_test.exs new file mode 100644 index 0000000..a3f3a66 --- /dev/null +++ b/test/simpleshop_theme/orders/order_notifier_test.exs @@ -0,0 +1,161 @@ +defmodule SimpleshopTheme.Orders.OrderNotifierTest do + use SimpleshopTheme.DataCase, async: true + + import Swoosh.TestAssertions + import SimpleshopTheme.OrdersFixtures + + alias SimpleshopTheme.Orders + alias SimpleshopTheme.Orders.OrderNotifier + + describe "deliver_order_confirmation/1" do + test "sends confirmation with order details" do + order = + order_fixture(%{ + customer_email: "buyer@example.com", + payment_status: "paid", + shipping_address: %{ + "name" => "Jane Doe", + "line1" => "42 Test Street", + "city" => "London", + "postal_code" => "SW1A 1AA", + "country" => "GB" + } + }) + + assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order) + + assert_email_sent(fn email -> + assert email.to == [{"", "buyer@example.com"}] + assert email.subject =~ "Order confirmed" + assert email.subject =~ order.order_number + assert email.text_body =~ order.order_number + assert email.text_body =~ "Test product" + assert email.text_body =~ "Jane Doe" + assert email.text_body =~ "42 Test Street" + assert email.text_body =~ "London" + end) + end + + test "includes item quantities and prices" do + order = + order_fixture(%{ + customer_email: "buyer@example.com", + payment_status: "paid", + quantity: 2, + unit_price: 1500 + }) + + assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order) + + assert_email_sent(fn email -> + assert email.text_body =~ "2x Test product" + end) + end + + test "includes order total" do + order = + order_fixture(%{ + customer_email: "buyer@example.com", + payment_status: "paid", + unit_price: 2500 + }) + + assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order) + + assert_email_sent(fn email -> + assert email.text_body =~ "Total:" + end) + end + + test "skips when customer_email is nil" do + order = order_fixture(%{customer_email: nil}) + + # Override to nil since fixture sets a default + {:ok, order} = Orders.update_order(order, %{customer_email: nil}) + + assert {:ok, :no_email} = OrderNotifier.deliver_order_confirmation(order) + assert_no_email_sent() + end + + test "skips when customer_email is empty string" do + order = order_fixture() + {:ok, order} = Orders.update_order(order, %{customer_email: ""}) + + assert {:ok, :no_email} = OrderNotifier.deliver_order_confirmation(order) + assert_no_email_sent() + end + + test "handles missing shipping address" do + order = order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"}) + + assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order) + assert_email_sent(subject: "Order confirmed - #{order.order_number}") + end + end + + describe "deliver_shipping_notification/1" do + test "sends notification with tracking info" do + {order, _v, _p, _c} = submitted_order_fixture(%{customer_email: "buyer@example.com"}) + + {:ok, order} = + Orders.update_fulfilment(order, %{ + fulfilment_status: "shipped", + tracking_number: "1Z999AA1", + tracking_url: "https://ups.com/track/1Z999AA1", + shipped_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + assert {:ok, _email} = OrderNotifier.deliver_shipping_notification(order) + + assert_email_sent(fn email -> + assert email.to == [{"", "buyer@example.com"}] + assert email.subject =~ "Your order has shipped" + assert email.subject =~ order.order_number + assert email.text_body =~ "1Z999AA1" + assert email.text_body =~ "https://ups.com/track/1Z999AA1" + end) + end + + test "handles missing tracking info gracefully" do + {order, _v, _p, _c} = submitted_order_fixture(%{customer_email: "buyer@example.com"}) + + {:ok, order} = + Orders.update_fulfilment(order, %{ + fulfilment_status: "shipped", + shipped_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + assert {:ok, _email} = OrderNotifier.deliver_shipping_notification(order) + + assert_email_sent(fn email -> + assert email.text_body =~ "Tracking details will follow" + end) + end + + test "includes tracking number without URL" do + {order, _v, _p, _c} = submitted_order_fixture(%{customer_email: "buyer@example.com"}) + + {:ok, order} = + Orders.update_fulfilment(order, %{ + fulfilment_status: "shipped", + tracking_number: "RM123456789GB", + shipped_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + assert {:ok, _email} = OrderNotifier.deliver_shipping_notification(order) + + assert_email_sent(fn email -> + assert email.text_body =~ "RM123456789GB" + assert not (email.text_body =~ "Tracking details will follow") + end) + end + + test "skips when customer_email is nil" do + {order, _v, _p, _c} = submitted_order_fixture() + {:ok, order} = Orders.update_order(order, %{customer_email: nil}) + + assert {:ok, :no_email} = OrderNotifier.deliver_shipping_notification(order) + assert_no_email_sent() + end + end +end