diff --git a/lib/towerops/billing/billing_notifier.ex b/lib/towerops/billing/billing_notifier.ex new file mode 100644 index 00000000..748b4de7 --- /dev/null +++ b/lib/towerops/billing/billing_notifier.ex @@ -0,0 +1,101 @@ +defmodule Towerops.Billing.BillingNotifier do + @moduledoc """ + Email notification service for billing events. + + Sends emails for payment failures, subscription updates, and billing alerts. + """ + + use Gettext, backend: ToweropsWeb.Gettext + + import Swoosh.Email + import ToweropsWeb.GettextHelpers + + alias Towerops.Mailer + alias Towerops.Organizations + + require Logger + + defp deliver(recipient, subject, body) do + from_address = Application.get_env(:towerops, :mailer_from, {"Towerops", "hi@towerops.net"}) + + email = + new() + |> to(recipient) + |> from(from_address) + |> subject(subject) + |> text_body(body) + + case Mailer.deliver(email) do + {:ok, _metadata} -> + Logger.info("Billing email sent to #{recipient}: #{subject}") + {:ok, email} + + {:error, reason} = error -> + Logger.error("Failed to send billing email to #{recipient}: #{inspect(reason)}") + error + end + end + + @doc """ + Deliver payment failure notification to organization owners and admins. + + Sends an email to all organization owners and admins informing them of a payment + failure and providing a link to update their payment method. + """ + def deliver_payment_failed_notification(organization) do + # Get all owners and admins (people who can manage billing) + recipients = + organization.id + |> Organizations.list_organization_members() + |> Enum.filter(fn membership -> membership.role in [:owner, :admin] end) + |> Enum.map(fn membership -> membership.user end) + + if Enum.empty?(recipients) do + Logger.warning("No owners or admins found for organization #{organization.slug} to notify about payment failure") + + {:ok, []} + else + subject = t_email("Payment failed for %{org_name}", org_name: organization.name) + + body = + t_email( + """ + Hi, + + We were unable to process the payment for your %{org_name} subscription on Towerops. + + Your subscription is still active, but we'll retry the payment in a few days. To avoid service interruption, please update your payment method as soon as possible. + + You can update your payment method here: + %{billing_url} + + If you have any questions or need assistance, please don't hesitate to reply to this email. + + Thank you, + Towerops Team + """, + org_name: organization.name, + billing_url: billing_portal_url(organization) + ) + + results = + Enum.map(recipients, fn user -> + deliver(user.email, subject, body) + end) + + # Return success if at least one email was sent + if Enum.any?(results, &match?({:ok, _}, &1)) do + {:ok, results} + else + {:error, :all_emails_failed} + end + end + end + + defp billing_portal_url(organization) do + # Generate URL to billing settings page + # In production, this should use the configured app URL + base_url = Application.get_env(:towerops, :app_url, "https://towerops.net") + "#{base_url}/o/#{organization.slug}/settings#billing" + end +end diff --git a/test/towerops/billing/billing_notifier_test.exs b/test/towerops/billing/billing_notifier_test.exs new file mode 100644 index 00000000..cd43f99d --- /dev/null +++ b/test/towerops/billing/billing_notifier_test.exs @@ -0,0 +1,108 @@ +defmodule Towerops.Billing.BillingNotifierTest do + use Towerops.DataCase, async: true + + import Swoosh.TestAssertions + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Billing.BillingNotifier + alias Towerops.Organizations + + describe "deliver_payment_failed_notification/1" do + test "sends email to organization owner" do + user = user_fixture() + organization = organization_fixture(user.id) + + assert {:ok, _results} = BillingNotifier.deliver_payment_failed_notification(organization) + + assert_email_sent(fn email -> + assert email.to == [{"", user.email}] + assert email.subject =~ "Payment failed" + assert email.subject =~ organization.name + assert email.text_body =~ organization.name + assert email.text_body =~ "unable to process the payment" + assert email.text_body =~ "/o/#{organization.slug}/settings#billing" + end) + end + + test "sends email to all owners and admins" do + owner = user_fixture() + admin = user_fixture(%{email: "admin@example.com"}) + member = user_fixture(%{email: "member@example.com"}) + viewer = user_fixture(%{email: "viewer@example.com"}) + + organization = organization_fixture(owner.id) + + # Add admin, member, and viewer + {:ok, _} = + Organizations.create_membership(%{ + organization_id: organization.id, + user_id: admin.id, + role: :admin + }) + + {:ok, _} = + Organizations.create_membership(%{ + organization_id: organization.id, + user_id: member.id, + role: :member + }) + + {:ok, _} = + Organizations.create_membership(%{ + organization_id: organization.id, + user_id: viewer.id, + role: :viewer + }) + + assert {:ok, results} = BillingNotifier.deliver_payment_failed_notification(organization) + + # Should send 2 emails (owner + admin only, not member or viewer) + assert length(results) == 2 + assert Enum.all?(results, &match?({:ok, _}, &1)) + + # Check that owner and admin received emails + assert_email_sent(fn email -> email.to == [{"", owner.email}] end) + assert_email_sent(fn email -> email.to == [{"", admin.email}] end) + end + + test "returns ok with empty list when no owners or admins exist" do + # Create organization with only member (no owner somehow) + user = user_fixture() + organization = organization_fixture(user.id) + + # Delete the owner membership + membership = Organizations.get_membership(organization.id, user.id) + Towerops.Repo.delete!(membership) + + assert {:ok, []} = BillingNotifier.deliver_payment_failed_notification(organization) + + # No emails should be sent + assert_no_email_sent() + end + + test "includes billing portal URL in email" do + user = user_fixture() + organization = organization_fixture(user.id) + + assert {:ok, _results} = BillingNotifier.deliver_payment_failed_notification(organization) + + assert_email_sent(fn email -> + email.text_body =~ "/o/#{organization.slug}/settings#billing" + end) + end + + test "email contains helpful instructions" do + user = user_fixture() + organization = organization_fixture(user.id) + + assert {:ok, _results} = BillingNotifier.deliver_payment_failed_notification(organization) + + assert_email_sent(fn email -> + assert email.text_body =~ "update your payment method" + assert email.text_body =~ "retry the payment" + assert email.text_body =~ "avoid service interruption" + end) + end + end +end