defmodule PhoenixKit.Modules.Billing do @moduledoc """ Main context for PhoenixKit Billing system. Provides comprehensive billing functionality including currencies, billing profiles, orders, and invoices with manual bank transfer payments (Phase 1). ## Features - **Currencies**: Multi-currency support with exchange rates - **Billing Profiles**: User billing information (individuals & companies) - **Orders**: Order management with line items and status tracking - **Invoices**: Invoice generation with receipt functionality - **Bank Payments**: Manual bank transfer workflow ## System Enable/Disable # Check if billing is enabled PhoenixKit.Modules.Billing.enabled?() # Enable/disable billing system PhoenixKit.Modules.Billing.enable_system() PhoenixKit.Modules.Billing.disable_system() ## Order Workflow # Create order {:ok, order} = Billing.create_order(user, %{...}) # Confirm order {:ok, order} = Billing.confirm_order(order) # Generate invoice {:ok, invoice} = Billing.create_invoice_from_order(order) # Send invoice {:ok, invoice} = Billing.send_invoice(invoice) # Mark as paid (generates receipt) {:ok, invoice} = Billing.mark_invoice_paid(invoice) """ use PhoenixKit.Module import Ecto.Query, warn: false alias PhoenixKit.Dashboard.Tab alias PhoenixKit.Modules.Billing.BillingProfile alias PhoenixKit.Modules.Billing.CountryData alias PhoenixKit.Modules.Billing.Currency alias PhoenixKit.Modules.Billing.Events alias PhoenixKit.Modules.Billing.Invoice alias PhoenixKit.Modules.Billing.Order alias PhoenixKit.Modules.Billing.PaymentOption alias PhoenixKit.Modules.Billing.Providers alias PhoenixKit.Modules.Billing.Transaction alias PhoenixKit.Modules.Emails.Templates alias PhoenixKit.Settings alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.UUID, as: UUIDUtils # ============================================ # SYSTEM ENABLE/DISABLE # ============================================ @impl PhoenixKit.Module @doc """ Checks if the billing system is enabled. """ def enabled? do Settings.get_boolean_setting("billing_enabled", false) end @impl PhoenixKit.Module @doc """ Enables the billing system. """ def enable_system do result = Settings.update_boolean_setting_with_module("billing_enabled", true, "billing") refresh_dashboard_tabs() result end @impl PhoenixKit.Module @doc """ Disables the billing system. """ def disable_system do result = Settings.update_boolean_setting_with_module("billing_enabled", false, "billing") refresh_dashboard_tabs() result end defp refresh_dashboard_tabs do if Code.ensure_loaded?(PhoenixKit.Dashboard.Registry) and PhoenixKit.Dashboard.Registry.initialized?() do PhoenixKit.Dashboard.Registry.load_defaults() end end # ============================================ # MODULE BEHAVIOUR CALLBACKS # ============================================ @impl PhoenixKit.Module def module_key, do: "billing" @impl PhoenixKit.Module def module_name, do: "Billing" @impl PhoenixKit.Module def permission_metadata do %{ key: "billing", label: "Billing", icon: "hero-credit-card", description: "Payment providers, subscriptions, and invoices" } end @impl PhoenixKit.Module def admin_tabs do [ Tab.new!( id: :admin_billing, label: "Billing", icon: "hero-banknotes", path: "/admin/billing", priority: 520, level: :admin, permission: "billing", match: :prefix, group: :admin_modules, subtab_display: :when_active, highlight_with_subtabs: false ), Tab.new!( id: :admin_billing_dashboard, label: "Dashboard", icon: "hero-chart-bar-square", path: "/admin/billing", priority: 521, level: :admin, permission: "billing", parent: :admin_billing, match: :exact ), Tab.new!( id: :admin_billing_orders, label: "Orders", icon: "hero-shopping-bag", path: "/admin/billing/orders", priority: 522, level: :admin, permission: "billing", parent: :admin_billing ), Tab.new!( id: :admin_billing_invoices, label: "Invoices", icon: "hero-document-text", path: "/admin/billing/invoices", priority: 523, level: :admin, permission: "billing", parent: :admin_billing ), Tab.new!( id: :admin_billing_transactions, label: "Transactions", icon: "hero-arrows-right-left", path: "/admin/billing/transactions", priority: 524, level: :admin, permission: "billing", parent: :admin_billing ), Tab.new!( id: :admin_billing_subscriptions, label: "Subscriptions", icon: "hero-arrow-path", path: "/admin/billing/subscriptions", priority: 525, level: :admin, permission: "billing", parent: :admin_billing ), Tab.new!( id: :admin_billing_plans, label: "Plans", icon: "hero-rectangle-stack", path: "/admin/billing/plans", priority: 526, level: :admin, permission: "billing", parent: :admin_billing ), Tab.new!( id: :admin_billing_profiles, label: "Billing Profiles", icon: "hero-identification", path: "/admin/billing/profiles", priority: 527, level: :admin, permission: "billing", parent: :admin_billing ), Tab.new!( id: :admin_billing_currencies, label: "Currencies", icon: "hero-currency-dollar", path: "/admin/billing/currencies", priority: 528, level: :admin, permission: "billing", parent: :admin_billing ), Tab.new!( id: :admin_billing_providers, label: "Payment Providers", icon: "hero-credit-card", path: "/admin/settings/billing/providers", priority: 529, level: :admin, permission: "billing", parent: :admin_billing ) ] end @impl PhoenixKit.Module def settings_tabs do [ Tab.new!( id: :admin_settings_billing, label: "Billing", icon: "hero-banknotes", path: "/admin/settings/billing", priority: 926, level: :admin, parent: :admin_settings, permission: "billing", match: :exact ) ] end @impl PhoenixKit.Module def user_dashboard_tabs do [ Tab.new!( id: :dashboard_orders, label: "My Orders", icon: "hero-shopping-bag", path: "/dashboard/orders", priority: 200, match: :prefix, group: :main ), Tab.new!( id: :dashboard_billing_profiles, label: "Billing Profiles", icon: "hero-identification", path: "/dashboard/billing-profiles", priority: 850, match: :prefix, group: :account ) ] end @impl PhoenixKit.Module @doc """ Returns the current billing configuration. """ def get_config do %{ enabled: enabled?(), default_currency: Settings.get_setting_cached("billing_default_currency", "EUR"), tax_enabled: Settings.get_setting_cached("billing_tax_enabled", "false") == "true", default_tax_rate: Settings.get_setting_cached("billing_default_tax_rate", "0"), invoice_prefix: Settings.get_setting_cached("billing_invoice_prefix", "INV"), order_prefix: Settings.get_setting_cached("billing_order_prefix", "ORD"), receipt_prefix: Settings.get_setting_cached("billing_receipt_prefix", "RCP"), invoice_due_days: String.to_integer(Settings.get_setting_cached("billing_invoice_due_days", "14")), orders_count: count_orders(), invoices_count: count_invoices(), currencies_count: count_currencies() } end @doc """ Returns dashboard statistics. """ def get_dashboard_stats do today = Date.utc_today() start_of_month = Date.beginning_of_month(today) default_currency = Settings.get_setting("billing_default_currency", "EUR") %{ total_orders: count_orders(), orders_this_month: count_orders_since(start_of_month), total_invoices: count_invoices(), invoices_this_month: count_invoices_since(start_of_month), total_paid_revenue: calculate_paid_revenue(), pending_revenue: calculate_pending_revenue(), paid_invoices_count: count_invoices_by_status("paid"), pending_invoices_count: count_invoices_by_status("sent") + count_invoices_by_status("overdue"), default_currency: default_currency } end defp count_orders do Order |> repo().aggregate(:count) rescue _ -> 0 end defp count_invoices do Invoice |> repo().aggregate(:count) rescue _ -> 0 end defp count_currencies do Currency |> where([c], c.enabled == true) |> repo().aggregate(:count) rescue _ -> 0 end defp count_orders_since(date) do Order |> where([o], o.inserted_at >= ^NaiveDateTime.new!(date, ~T[00:00:00])) |> repo().aggregate(:count) rescue _ -> 0 end defp count_invoices_since(date) do Invoice |> where([i], i.inserted_at >= ^NaiveDateTime.new!(date, ~T[00:00:00])) |> repo().aggregate(:count) rescue _ -> 0 end defp count_invoices_by_status(status) do Invoice |> where([i], i.status == ^status) |> repo().aggregate(:count) rescue _ -> 0 end defp calculate_paid_revenue do result = Invoice |> where([i], i.status == "paid") |> select([i], sum(i.total)) |> repo().one() result || Decimal.new(0) rescue _ -> Decimal.new(0) end defp calculate_pending_revenue do result = Invoice |> where([i], i.status in ["sent", "overdue"]) |> select([i], sum(i.total)) |> repo().one() result || Decimal.new(0) rescue _ -> Decimal.new(0) end # ============================================ # CURRENCIES # ============================================ @doc """ Lists all currencies with optional filters. ## Options - `:enabled` - Filter by enabled status - `:order_by` - Custom ordering """ def list_currencies(opts \\ []) do query = Currency query = case Keyword.get(opts, :enabled) do true -> where(query, [c], c.enabled == true) false -> where(query, [c], c.enabled == false) _ -> query end query = case Keyword.get(opts, :order_by) do nil -> order_by(query, [c], [c.sort_order, c.code]) custom -> order_by(query, ^custom) end repo().all(query) end @doc """ Lists enabled currencies. """ def list_enabled_currencies do list_currencies(enabled: true) end @doc """ Gets the default currency. """ def get_default_currency do Currency |> where([c], c.is_default == true) |> repo().one() end @doc """ Gets a currency by ID or UUID. """ def get_currency(id) when is_integer(id), do: repo().get_by(Currency, id: id) def get_currency(id) when is_binary(id) do if UUIDUtils.valid?(id) do repo().get_by(Currency, uuid: id) else case Integer.parse(id) do {int_id, ""} -> get_currency(int_id) _ -> nil end end end def get_currency(_), do: nil @doc """ Gets a currency by ID or UUID, raises if not found. """ def get_currency!(id) do case get_currency(id) do nil -> raise Ecto.NoResultsError, queryable: Currency currency -> currency end end @doc """ Gets a currency by code. """ def get_currency_by_code(code) do Currency |> where([c], c.code == ^String.upcase(code)) |> repo().one() end @doc """ Creates a currency. """ def create_currency(attrs) do %Currency{} |> Currency.changeset(attrs) |> repo().insert() end @doc """ Updates a currency. """ def update_currency(%Currency{} = currency, attrs) do currency |> Currency.changeset(attrs) |> repo().update() end @doc """ Sets a currency as default. """ def set_default_currency(%Currency{} = currency) do repo().transaction(fn -> # Clear existing default Currency |> where([c], c.is_default == true) |> repo().update_all(set: [is_default: false]) # Set new default (also enable if disabled) currency |> Currency.changeset(%{is_default: true, enabled: true}) |> repo().update!() end) end @doc """ Deletes a currency. The default currency and currencies referenced by orders cannot be deleted. """ def delete_currency(%Currency{} = currency) do cond do currency.is_default -> {:error, :is_default} order_count_for_currency(currency.code) > 0 -> {:error, :currency_in_use} true -> repo().delete(currency) end end defp order_count_for_currency(code) do from(o in Order, where: o.currency == ^code, select: count(o.uuid)) |> repo().one() end # ============================================ # BILLING PROFILES # ============================================ @doc """ Lists billing profiles with optional filters. ## Options - `:user_id` - Filter by user ID - `:type` - Filter by type ("individual" or "company") - `:search` - Search in name/email/company fields - `:page` - Page number - `:per_page` - Items per page - `:preload` - Associations to preload """ def list_billing_profiles(opts \\ []) do BillingProfile |> filter_by_user_id(Keyword.get(opts, :user_id)) |> filter_by_type(Keyword.get(opts, :type)) |> filter_by_search(Keyword.get(opts, :search)) |> order_by([bp], desc: bp.is_default, desc: bp.inserted_at) |> maybe_preload(Keyword.get(opts, :preload)) |> repo().all() end defp filter_by_user_id(query, nil), do: query defp filter_by_user_id(query, user_id) do user_uuid = extract_user_uuid(user_id) where(query, [bp], bp.user_uuid == ^user_uuid) end defp filter_by_type(query, nil), do: query defp filter_by_type(query, type), do: where(query, [bp], bp.type == ^type) defp filter_by_search(query, nil), do: query defp filter_by_search(query, ""), do: query defp filter_by_search(query, search) do search_term = "%#{search}%" where( query, [bp], ilike(bp.first_name, ^search_term) or ilike(bp.last_name, ^search_term) or ilike(bp.email, ^search_term) or ilike(bp.company_name, ^search_term) ) end defp maybe_preload(query, nil), do: query defp maybe_preload(query, preloads), do: preload(query, ^preloads) @doc """ Lists billing profiles for a user (shorthand). """ def list_user_billing_profiles(user_id) do list_billing_profiles(user_id: user_id) end @doc """ Lists billing profiles with count for pagination. """ def list_billing_profiles_with_count(opts \\ []) do page = Keyword.get(opts, :page, 1) per_page = Keyword.get(opts, :per_page, 25) offset = (page - 1) * per_page base_query = BillingProfile base_query = case Keyword.get(opts, :type) do nil -> base_query type -> where(base_query, [bp], bp.type == ^type) end base_query = case Keyword.get(opts, :search) do nil -> base_query "" -> base_query search -> search_term = "%#{search}%" where( base_query, [bp], ilike(bp.first_name, ^search_term) or ilike(bp.last_name, ^search_term) or ilike(bp.email, ^search_term) or ilike(bp.company_name, ^search_term) ) end total = repo().aggregate(base_query, :count, :uuid) preloads = Keyword.get(opts, :preload, [:user]) profiles = base_query |> order_by([bp], desc: bp.is_default, desc: bp.inserted_at) |> limit(^per_page) |> offset(^offset) |> preload(^preloads) |> repo().all() {profiles, total} end @doc """ Gets the default billing profile for a user. """ def get_default_billing_profile(user_id) do user_uuid = extract_user_uuid(user_id) BillingProfile |> where([bp], bp.user_uuid == ^user_uuid and bp.is_default == true) |> repo().one() end @doc """ Gets a billing profile by ID or UUID, returns nil if not found. """ def get_billing_profile(id) when is_integer(id), do: repo().get_by(BillingProfile, id: id) def get_billing_profile(id) when is_binary(id) do if UUIDUtils.valid?(id) do repo().get_by(BillingProfile, uuid: id) else case Integer.parse(id) do {int_id, ""} -> get_billing_profile(int_id) _ -> nil end end end def get_billing_profile(_), do: nil @doc """ Gets a billing profile by ID or UUID, raises if not found. """ def get_billing_profile!(id) do case get_billing_profile(id) do nil -> raise Ecto.NoResultsError, queryable: BillingProfile profile -> profile end end @doc """ Returns a changeset for billing profile form. """ def change_billing_profile(%BillingProfile{} = profile, attrs \\ %{}) do BillingProfile.changeset(profile, attrs) end @doc """ Creates a billing profile. """ def create_billing_profile(user_or_id, attrs) do user_id = extract_user_id(user_or_id) user_uuid = extract_user_uuid(user_or_id) result = %BillingProfile{} |> BillingProfile.changeset( attrs |> Map.put("user_id", user_id) |> Map.put("user_uuid", user_uuid) ) |> repo().insert() # If this is the first profile, make it default case result do {:ok, profile} -> Events.broadcast_profile_created(profile) if count_user_profiles(user_id) == 1 do set_default_billing_profile(profile) else {:ok, profile} end error -> error end end @doc """ Updates a billing profile. """ def update_billing_profile(%BillingProfile{} = profile, attrs) do result = profile |> BillingProfile.changeset(attrs) |> repo().update() case result do {:ok, updated_profile} -> Events.broadcast_profile_updated(updated_profile) {:ok, updated_profile} error -> error end end @doc """ Deletes a billing profile. """ def delete_billing_profile(%BillingProfile{} = profile) do result = repo().delete(profile) case result do {:ok, deleted_profile} -> Events.broadcast_profile_deleted(deleted_profile) {:ok, deleted_profile} error -> error end end @doc """ Sets a billing profile as default. """ def set_default_billing_profile(%BillingProfile{} = profile) do repo().transaction(fn -> # Clear existing default for user BillingProfile |> where([bp], bp.user_uuid == ^profile.user_uuid and bp.is_default == true) |> repo().update_all(set: [is_default: false]) # Set new default profile |> BillingProfile.changeset(%{is_default: true}) |> repo().update!() end) end defp count_user_profiles(user_id) do user_uuid = extract_user_uuid(user_id) BillingProfile |> where([bp], bp.user_uuid == ^user_uuid) |> repo().aggregate(:count) end # ============================================ # ORDERS # ============================================ @doc """ Lists all orders with optional filters. """ def list_orders(filters \\ %{}) do Order |> apply_order_filters(filters) |> order_by([o], desc: o.inserted_at) |> preload([:user, :billing_profile]) |> repo().all() end @doc """ Lists orders for a specific user. """ def list_user_orders(user_id, filters \\ %{}) do user_uuid = extract_user_uuid(user_id) Order |> where([o], o.user_uuid == ^user_uuid) |> apply_order_filters(filters) |> order_by([o], desc: o.inserted_at) |> repo().all() end @doc """ Lists orders with count for pagination. """ def list_orders_with_count(opts \\ []) do page = Keyword.get(opts, :page, 1) per_page = Keyword.get(opts, :per_page, 25) offset = (page - 1) * per_page search = Keyword.get(opts, :search) status = Keyword.get(opts, :status) base_query = Order base_query = case status do nil -> base_query status -> where(base_query, [o], o.status == ^status) end base_query = case search do nil -> base_query "" -> base_query search -> search_term = "%#{search}%" base_query |> join(:left, [o], u in assoc(o, :user)) |> where( [o, u], ilike(o.order_number, ^search_term) or ilike(u.email, ^search_term) ) end total = repo().aggregate(base_query, :count, :uuid) preloads = Keyword.get(opts, :preload, [:user]) orders = base_query |> order_by([o], desc: o.inserted_at) |> limit(^per_page) |> offset(^offset) |> preload(^preloads) |> repo().all() {orders, total} end @doc """ Gets an order by ID or UUID. """ def get_order!(id) do case get_order(id) do nil -> raise Ecto.NoResultsError, queryable: Order order -> order end end @doc """ Gets an order by ID or UUID with optional preloads. """ def get_order(id, opts \\ []) def get_order(id, opts) when is_integer(id) do preloads = Keyword.get(opts, :preload, [:user, :billing_profile]) Order |> where([o], o.id == ^id) |> preload(^preloads) |> repo().one() end def get_order(id, opts) when is_binary(id) do preloads = Keyword.get(opts, :preload, [:user, :billing_profile]) if UUIDUtils.valid?(id) do Order |> where([o], o.uuid == ^id) |> preload(^preloads) |> repo().one() else case Integer.parse(id) do {int_id, ""} -> get_order(int_id, opts) _ -> nil end end end def get_order(_, _opts), do: nil @doc """ Gets an order by order number. """ def get_order_by_number(order_number) do Order |> where([o], o.order_number == ^order_number) |> preload([:user, :billing_profile]) |> repo().one() end @doc """ Gets an order by UUID with optional preloads. Used for public-facing URLs to prevent ID enumeration. """ def get_order_by_uuid(uuid, opts \\ []) do preloads = Keyword.get(opts, :preload, [:user, :billing_profile]) Order |> where([o], o.uuid == ^uuid) |> preload(^preloads) |> repo().one() end @doc """ Creates an order for a user. """ def create_order(user_or_id, attrs) do user_id = extract_user_id(user_or_id) user_uuid = extract_user_uuid(user_or_id) config = get_config() # Use string key to match other attrs (avoid mixed keys error) attrs = attrs |> Map.put("user_id", user_id) |> Map.put("user_uuid", user_uuid) |> maybe_set_default_currency() |> maybe_set_order_number(config) |> maybe_set_billing_snapshot() result = %Order{} |> Order.changeset(attrs) |> repo().insert() case result do {:ok, order} -> Events.broadcast_order_created(order) {:ok, order} error -> error end end @doc """ Creates an order from attributes (user_id included in attrs). """ def create_order(attrs) when is_map(attrs) do config = get_config() # Resolve user_uuid from user_id if not already present user_id = Map.get(attrs, :user_id) || Map.get(attrs, "user_id") user_uuid = Map.get(attrs, :user_uuid) || Map.get(attrs, "user_uuid") user_uuid = if user_uuid do user_uuid else cond do is_integer(user_id) -> extract_user_uuid(user_id) is_binary(user_id) -> case Integer.parse(user_id) do {int_id, ""} -> extract_user_uuid(int_id) _ -> user_id end true -> nil end end attrs = attrs |> Map.put("user_uuid", user_uuid) |> maybe_set_default_currency() |> maybe_set_order_number(config) |> maybe_set_billing_snapshot() result = %Order{} |> Order.changeset(attrs) |> repo().insert() case result do {:ok, order} -> Events.broadcast_order_created(order) {:ok, order} error -> error end end @doc """ Returns an order changeset for form building. """ def change_order(%Order{} = order, attrs \\ %{}) do Order.changeset(order, attrs) end @doc """ Updates an order. """ def update_order(%Order{} = order, attrs) do if Order.editable?(order) do # Update billing_snapshot if billing_profile_id changed attrs = maybe_update_billing_snapshot(order, attrs) result = order |> Order.changeset(attrs) |> repo().update() case result do {:ok, updated_order} -> Events.broadcast_order_updated(updated_order) {:ok, updated_order} error -> error end else {:error, :order_not_editable} end end @doc """ Confirms an order. """ def confirm_order(%Order{} = order) do result = order |> Order.status_changeset("confirmed") |> repo().update() case result do {:ok, confirmed_order} -> Events.broadcast_order_confirmed(confirmed_order) {:ok, confirmed_order} error -> error end end @doc """ Marks an order as paid. ## Options - `:payment_method` - The payment method used (e.g., "bank", "stripe", "paypal") """ def mark_order_paid(%Order{} = order, opts \\ []) do if Order.payable?(order) do changeset = Order.status_changeset(order, "paid") changeset = case opts[:payment_method] do nil -> changeset pm -> Ecto.Changeset.put_change(changeset, :payment_method, pm) end result = repo().update(changeset) case result do {:ok, paid_order} -> Events.broadcast_order_paid(paid_order) {:ok, paid_order} error -> error end else {:error, :order_not_payable} end end @doc """ Marks an order as refunded. """ def mark_order_refunded(%Order{} = order) do if order.status == "paid" do order |> Order.status_changeset("refunded") |> repo().update() else {:error, :order_not_refundable} end end @doc """ Cancels an order. """ def cancel_order(%Order{} = order, reason \\ nil) do if Order.cancellable?(order) do changeset = order |> Order.status_changeset("cancelled") changeset = if reason do Ecto.Changeset.put_change(changeset, :internal_notes, reason) else changeset end result = repo().update(changeset) case result do {:ok, cancelled_order} -> Events.broadcast_order_cancelled(cancelled_order) {:ok, cancelled_order} error -> error end else {:error, :order_not_cancellable} end end @doc """ Deletes an order (only drafts). """ def delete_order(%Order{status: "draft"} = order) do repo().delete(order) end def delete_order(_order), do: {:error, :can_only_delete_drafts} defp apply_order_filters(query, filters) do Enum.reduce(filters, query, fn {:status, status}, q when is_binary(status) -> where(q, [o], o.status == ^status) {:statuses, statuses}, q when is_list(statuses) -> where(q, [o], o.status in ^statuses) {:from_date, date}, q -> where(q, [o], o.inserted_at >= ^date) {:to_date, date}, q -> where(q, [o], o.inserted_at <= ^date) _, q -> q end) end defp maybe_set_order_number(attrs, config) do # Check both atom and string keys since params may come from forms (string keys) if Map.has_key?(attrs, :order_number) || Map.has_key?(attrs, "order_number") do attrs else Map.put(attrs, "order_number", generate_order_number(config.order_prefix)) end end defp maybe_set_default_currency(attrs) do # Check both atom and string keys if Map.has_key?(attrs, :currency) || Map.has_key?(attrs, "currency") do attrs else default = Settings.get_setting("billing_default_currency", "EUR") Map.put(attrs, "currency", default) end end defp maybe_set_billing_snapshot(attrs) do # Check both atom and string keys profile_id = Map.get(attrs, :billing_profile_id) || Map.get(attrs, "billing_profile_id") case profile_id do nil -> attrs "" -> attrs id -> profile = get_billing_profile!(id) attrs |> Map.put("billing_snapshot", BillingProfile.to_snapshot(profile)) |> Map.put("billing_profile_uuid", profile.uuid) end end # Updates billing_snapshot if billing_profile_id changed or snapshot is empty defp maybe_update_billing_snapshot(%Order{} = order, attrs) do new_profile_id = Map.get(attrs, :billing_profile_id) || Map.get(attrs, "billing_profile_id") cond do # No billing_profile_id in attrs - no change is_nil(new_profile_id) -> attrs # Empty string means clearing the profile new_profile_id == "" -> attrs |> Map.put("billing_snapshot", %{}) |> Map.put("billing_profile_id", nil) # Profile ID present - update snapshot if changed or empty true -> profile = get_billing_profile!(new_profile_id) snapshot_empty? = is_nil(order.billing_snapshot) || order.billing_snapshot == %{} if profile.uuid != order.billing_profile_uuid || snapshot_empty? do attrs |> Map.put("billing_snapshot", BillingProfile.to_snapshot(profile)) |> Map.put("billing_profile_uuid", profile.uuid) else attrs end end end # ============================================ # INVOICES # ============================================ @doc """ Lists all invoices with optional filters. """ def list_invoices(filters \\ %{}) do Invoice |> apply_invoice_filters(filters) |> order_by([i], desc: i.inserted_at) |> preload([:user, :order]) |> repo().all() end @doc """ Lists invoices for a specific user. """ def list_user_invoices(user_id, filters \\ %{}) do user_uuid = extract_user_uuid(user_id) Invoice |> where([i], i.user_uuid == ^user_uuid) |> apply_invoice_filters(filters) |> order_by([i], desc: i.inserted_at) |> repo().all() end @doc """ Lists invoices with count for pagination. """ def list_invoices_with_count(opts \\ []) do page = Keyword.get(opts, :page, 1) per_page = Keyword.get(opts, :per_page, 25) offset = (page - 1) * per_page search = Keyword.get(opts, :search) status = Keyword.get(opts, :status) base_query = Invoice base_query = case status do nil -> base_query status -> where(base_query, [i], i.status == ^status) end base_query = case search do nil -> base_query "" -> base_query search -> search_term = "%#{search}%" base_query |> join(:left, [i], u in assoc(i, :user)) |> where( [i, u], ilike(i.invoice_number, ^search_term) or ilike(u.email, ^search_term) ) end total = repo().aggregate(base_query, :count, :uuid) preloads = Keyword.get(opts, :preload, [:user, :order]) invoices = base_query |> order_by([i], desc: i.inserted_at) |> limit(^per_page) |> offset(^offset) |> preload(^preloads) |> repo().all() {invoices, total} end @doc """ Gets an invoice by ID or UUID. """ def get_invoice!(id) do case get_invoice(id) do nil -> raise Ecto.NoResultsError, queryable: Invoice invoice -> invoice end end @doc """ Gets an invoice by ID or UUID with optional preloads. """ def get_invoice(id, opts \\ []) def get_invoice(id, opts) when is_integer(id) do preloads = Keyword.get(opts, :preload, [:user, :order]) Invoice |> where([i], i.id == ^id) |> preload(^preloads) |> repo().one() end def get_invoice(id, opts) when is_binary(id) do preloads = Keyword.get(opts, :preload, [:user, :order]) if UUIDUtils.valid?(id) do Invoice |> where([i], i.uuid == ^id) |> preload(^preloads) |> repo().one() else case Integer.parse(id) do {int_id, ""} -> get_invoice(int_id, opts) _ -> nil end end end def get_invoice(_, _opts), do: nil @doc """ Lists invoices for a specific order. """ def list_invoices_for_order(order_uuid) when is_binary(order_uuid) do Invoice |> where([i], i.order_uuid == ^order_uuid) |> order_by([i], desc: i.inserted_at) |> repo().all() end def list_invoices_for_order(order_id) when is_integer(order_id) do Invoice |> where([i], i.order_id == ^order_id) |> order_by([i], desc: i.inserted_at) |> repo().all() end @doc """ Gets an invoice by invoice number. """ def get_invoice_by_number(invoice_number) do Invoice |> where([i], i.invoice_number == ^invoice_number) |> preload([:user, :order]) |> repo().one() end @doc """ Creates an invoice from an order. """ def create_invoice_from_order(%Order{} = order, opts \\ []) do config = get_config() opts = opts |> Keyword.put_new(:due_days, config.invoice_due_days) |> Keyword.put_new(:invoice_number, generate_invoice_number(config.invoice_prefix)) |> Keyword.put_new(:bank_details, get_bank_details()) |> Keyword.put_new(:payment_terms, get_payment_terms()) invoice = Invoice.from_order(order, opts) result = invoice |> Invoice.changeset(%{}) |> repo().insert() case result do {:ok, created_invoice} -> Events.broadcast_invoice_created(created_invoice) {:ok, created_invoice} error -> error end end @doc """ Creates a standalone invoice (without order). """ def create_invoice(user_or_id, attrs) do user_id = extract_user_id(user_or_id) user_uuid = extract_user_uuid(user_or_id) config = get_config() attrs = attrs |> Map.put(:user_id, user_id) |> Map.put(:user_uuid, user_uuid) |> Map.put_new(:invoice_number, generate_invoice_number(config.invoice_prefix)) result = %Invoice{} |> Invoice.changeset(attrs) |> repo().insert() case result do {:ok, created_invoice} -> Events.broadcast_invoice_created(created_invoice) {:ok, created_invoice} error -> error end end @doc """ Updates an invoice. """ def update_invoice(%Invoice{} = invoice, attrs) do if Invoice.editable?(invoice) do invoice |> Invoice.changeset(attrs) |> repo().update() else {:error, :invoice_not_editable} end end @doc """ Sends an invoice (marks as sent and sends email). Options: - `:send_email` - Whether to send email (default: true) - `:invoice_url` - URL to view invoice online (optional) """ def send_invoice(%Invoice{} = invoice, opts \\ []) do cond do Invoice.sendable?(invoice) -> # First send - change status to "sent" do_send_invoice(invoice, opts, change_status: true) Invoice.resendable?(invoice) -> # Resend - don't change status, just send email and record in history do_send_invoice(invoice, opts, change_status: false) true -> {:error, :invoice_not_sendable} end end defp do_send_invoice(invoice, opts, change_status: change_status) do send_email? = Keyword.get(opts, :send_email, true) to_email = Keyword.get(opts, :to_email) # Preload user if not loaded invoice = ensure_preloaded(invoice, [:user, :order]) # Determine recipient email recipient_email = to_email || (invoice.user && invoice.user.email) if is_nil(recipient_email) do {:error, :no_recipient_email} else # Build send history entry send_entry = %{ "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), "email" => recipient_email } # Get current send history from metadata current_metadata = invoice.metadata || %{} send_history = Map.get(current_metadata, "send_history", []) updated_send_history = send_history ++ [send_entry] updated_metadata = Map.put(current_metadata, "send_history", updated_send_history) # Build changeset changeset = if change_status do invoice |> Invoice.status_changeset("sent") |> Ecto.Changeset.put_change(:metadata, updated_metadata) else invoice |> Ecto.Changeset.change(%{metadata: updated_metadata}) end case repo().update(changeset) do {:ok, updated_invoice} -> # Broadcast invoice sent event Events.broadcast_invoice_sent(updated_invoice) # Send email if requested if send_email? do send_invoice_email(updated_invoice, Keyword.put(opts, :to_email, recipient_email)) end {:ok, updated_invoice} error -> error end end end @doc """ Sends invoice email to the customer. """ def send_invoice_email(%Invoice{} = invoice, opts \\ []) do # Preload user if not loaded invoice = ensure_preloaded(invoice, [:user, :order]) # Use to_email from opts, or fall back to user email to_email = Keyword.get(opts, :to_email) recipient_email = to_email || (invoice.user && invoice.user.email) case recipient_email do nil -> {:error, :no_recipient_email} email -> user = invoice.user variables = build_invoice_email_variables(invoice, user, opts) Templates.send_email( "billing_invoice", email, variables, user_id: user && user.id, user_uuid: user && user.uuid, metadata: %{invoice_id: invoice.id, invoice_number: invoice.invoice_number} ) end end @doc """ Sends receipt for a paid invoice. Options: - `:send_email` - Whether to send email (default: true) - `:to_email` - Override recipient email address - `:receipt_url` - URL to view receipt online (optional) """ def send_receipt(%Invoice{} = invoice, opts \\ []) do cond do # Has receipt number - can send not is_nil(invoice.receipt_number) -> do_send_receipt(invoice, opts) # No receipt generated yet is_nil(invoice.receipt_number) -> {:error, :receipt_not_generated} true -> {:error, :receipt_not_sendable} end end defp do_send_receipt(invoice, opts) do send_email? = Keyword.get(opts, :send_email, true) to_email = Keyword.get(opts, :to_email) # Preload user if not loaded invoice = ensure_preloaded(invoice, [:user, :order]) # Get recipient email recipient_email = to_email || (invoice.user && invoice.user.email) if is_nil(recipient_email) do {:error, :no_recipient_email} else # Record in receipt_data.send_history (analogous to metadata.send_history for invoices) send_entry = %{ "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), "email" => recipient_email } current_receipt_data = invoice.receipt_data || %{} send_history = Map.get(current_receipt_data, "send_history", []) updated_send_history = send_history ++ [send_entry] updated_receipt_data = Map.put(current_receipt_data, "send_history", updated_send_history) changeset = invoice |> Ecto.Changeset.change(%{receipt_data: updated_receipt_data}) case repo().update(changeset) do {:ok, updated_invoice} -> # Send email if requested if send_email? do send_receipt_email(updated_invoice, Keyword.put(opts, :to_email, recipient_email)) end {:ok, updated_invoice} error -> error end end end @doc """ Sends receipt email to the customer. """ def send_receipt_email(%Invoice{} = invoice, opts \\ []) do # Preload user if not loaded invoice = ensure_preloaded(invoice, [:user, :order]) # Use to_email from opts, or fall back to user email to_email = Keyword.get(opts, :to_email) recipient_email = to_email || (invoice.user && invoice.user.email) case recipient_email do nil -> {:error, :no_recipient_email} email -> user = invoice.user variables = build_receipt_email_variables(invoice, user, opts) Templates.send_email( "billing_receipt", email, variables, user_id: user && user.id, user_uuid: user && user.uuid, metadata: %{ invoice_id: invoice.id, receipt_number: invoice.receipt_number, invoice_number: invoice.invoice_number } ) end end @doc """ Sends a credit note email for a refund transaction. ## Parameters - `invoice` - The invoice associated with the refund - `transaction` - The refund transaction - `opts` - Options: - `:to_email` - Override recipient email - `:credit_note_url` - URL to view credit note online ## Examples {:ok, invoice} = Billing.send_credit_note(invoice, transaction, credit_note_url: "https://...") """ def send_credit_note(%Invoice{} = invoice, %Transaction{} = transaction, opts \\ []) do # Verify transaction is a refund if Transaction.refund?(transaction) do do_send_credit_note(invoice, transaction, opts) else {:error, :not_a_refund} end end defp do_send_credit_note(invoice, transaction, opts) do send_email? = Keyword.get(opts, :send_email, true) to_email = Keyword.get(opts, :to_email) # Preload user if not loaded invoice = ensure_preloaded(invoice, [:user, :order]) # Get recipient email recipient_email = to_email || (invoice.user && invoice.user.email) if is_nil(recipient_email) do {:error, :no_recipient_email} else # Record in transaction metadata.send_history send_entry = %{ "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), "email" => recipient_email } current_metadata = transaction.metadata || %{} send_history = Map.get(current_metadata, "credit_note_send_history", []) updated_send_history = send_history ++ [send_entry] updated_metadata = Map.put(current_metadata, "credit_note_send_history", updated_send_history) changeset = transaction |> Ecto.Changeset.change(%{metadata: updated_metadata}) case repo().update(changeset) do {:ok, updated_transaction} -> # Broadcast credit note sent event Events.broadcast_credit_note_sent(invoice, updated_transaction) # Send email if requested if send_email? do send_credit_note_email( invoice, updated_transaction, Keyword.put(opts, :to_email, recipient_email) ) end {:ok, updated_transaction} error -> error end end end @doc """ Sends credit note email to the customer. """ def send_credit_note_email(%Invoice{} = invoice, %Transaction{} = transaction, opts \\ []) do # Preload user if not loaded invoice = ensure_preloaded(invoice, [:user, :order]) # Use to_email from opts, or fall back to user email to_email = Keyword.get(opts, :to_email) recipient_email = to_email || (invoice.user && invoice.user.email) case recipient_email do nil -> {:error, :no_recipient_email} email -> user = invoice.user variables = build_credit_note_email_variables(invoice, transaction, user, opts) Templates.send_email( "billing_credit_note", email, variables, user_id: user && user.id, user_uuid: user && user.uuid, metadata: %{ invoice_id: invoice.id, transaction_id: transaction.id, invoice_number: invoice.invoice_number, transaction_number: transaction.transaction_number } ) end end defp build_credit_note_email_variables(invoice, transaction, user, opts) do credit_note_url = Keyword.get(opts, :credit_note_url, "") billing_details = invoice.billing_details || %{} prefix = Settings.get_setting("billing_credit_note_prefix", "CN") suffix = transaction.transaction_number |> String.replace(~r/^TXN-/, "") credit_note_number = "#{prefix}-#{suffix}" company = get_company_details() %{ "user_email" => user && user.email, "user_name" => extract_user_name(billing_details, user), "credit_note_number" => credit_note_number, "invoice_number" => invoice.invoice_number, "refund_date" => format_date(transaction.inserted_at), "refund_amount" => format_decimal(Decimal.abs(transaction.amount)), "refund_reason" => transaction.description || "Refund issued", "transaction_number" => transaction.transaction_number, "currency" => transaction.currency, "company_name" => company.name, "company_address" => company.address, "company_vat" => company.vat, "credit_note_url" => credit_note_url } end @doc """ Sends a payment confirmation email for an individual payment transaction. ## Parameters - `invoice` - The invoice associated with the payment - `transaction` - The payment transaction - `opts` - Options including: - `:to_email` - Override recipient email address - `:payment_url` - URL to view payment confirmation online - `:send_email` - Whether to send email (default: true) """ def send_payment_confirmation(%Invoice{} = invoice, %Transaction{} = transaction, opts \\ []) do # Verify transaction is a payment (positive amount) if Transaction.payment?(transaction) do do_send_payment_confirmation(invoice, transaction, opts) else {:error, :not_a_payment} end end defp do_send_payment_confirmation(invoice, transaction, opts) do send_email? = Keyword.get(opts, :send_email, true) to_email = Keyword.get(opts, :to_email) # Preload user if not loaded invoice = ensure_preloaded(invoice, [:user, :order]) # Get recipient email recipient_email = to_email || (invoice.user && invoice.user.email) if is_nil(recipient_email) do {:error, :no_recipient_email} else # Record in transaction metadata.payment_confirmation_send_history send_entry = %{ "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), "email" => recipient_email } current_metadata = transaction.metadata || %{} send_history = Map.get(current_metadata, "payment_confirmation_send_history", []) updated_send_history = send_history ++ [send_entry] updated_metadata = Map.put(current_metadata, "payment_confirmation_send_history", updated_send_history) changeset = transaction |> Ecto.Changeset.change(%{metadata: updated_metadata}) case repo().update(changeset) do {:ok, updated_transaction} -> # Send email if requested if send_email? do send_payment_confirmation_email( invoice, updated_transaction, Keyword.put(opts, :to_email, recipient_email) ) end {:ok, updated_transaction} error -> error end end end @doc """ Sends payment confirmation email to the customer. """ def send_payment_confirmation_email( %Invoice{} = invoice, %Transaction{} = transaction, opts \\ [] ) do # Preload user if not loaded invoice = ensure_preloaded(invoice, [:user, :order]) # Use to_email from opts, or fall back to user email to_email = Keyword.get(opts, :to_email) recipient_email = to_email || (invoice.user && invoice.user.email) case recipient_email do nil -> {:error, :no_recipient_email} email -> user = invoice.user variables = build_payment_confirmation_email_variables(invoice, transaction, user, opts) Templates.send_email( "billing_payment_confirmation", email, variables, user_id: user && user.id, user_uuid: user && user.uuid, metadata: %{ invoice_id: invoice.id, transaction_id: transaction.id, invoice_number: invoice.invoice_number, transaction_number: transaction.transaction_number } ) end end defp build_payment_confirmation_email_variables(invoice, transaction, user, opts) do payment_url = Keyword.get(opts, :payment_url, "") billing_details = invoice.billing_details || %{} prefix = Settings.get_setting("billing_payment_confirmation_prefix", "PMT") suffix = transaction.transaction_number |> String.replace(~r/^TXN-/, "") confirmation_number = "#{prefix}-#{suffix}" company = get_company_details() # Calculate remaining balance remaining_balance = Decimal.sub(invoice.total, invoice.paid_amount || Decimal.new(0)) is_final_payment = Decimal.lte?(remaining_balance, Decimal.new(0)) %{ "user_email" => user && user.email, "user_name" => extract_user_name(billing_details, user), "confirmation_number" => confirmation_number, "invoice_number" => invoice.invoice_number, "payment_date" => format_date(transaction.inserted_at), "payment_amount" => format_decimal(transaction.amount), "payment_method" => String.capitalize(transaction.payment_method || "bank"), "transaction_number" => transaction.transaction_number, "invoice_total" => format_decimal(invoice.total), "total_paid" => format_decimal(invoice.paid_amount), "remaining_balance" => format_decimal(Decimal.max(remaining_balance, Decimal.new(0))), "is_final_payment" => is_final_payment, "currency" => invoice.currency, "company_name" => company.name, "company_address" => company.address, "payment_url" => payment_url } end defp build_receipt_email_variables(invoice, user, opts) do receipt_url = Keyword.get(opts, :receipt_url, "") billing_details = invoice.billing_details || %{} company = get_company_details() %{ "user_email" => user.email, "user_name" => extract_user_name(billing_details, user), "receipt_number" => invoice.receipt_number, "invoice_number" => invoice.invoice_number, "payment_date" => format_date(invoice.paid_at), "subtotal" => format_decimal(invoice.subtotal), "tax_amount" => format_decimal(invoice.tax_amount), "total" => format_decimal(invoice.total), "paid_amount" => format_decimal(invoice.paid_amount), "currency" => invoice.currency, "line_items_html" => format_line_items_html(invoice.line_items), "line_items_text" => format_line_items_text(invoice.line_items), "company_name" => company.name, "company_address" => company.address, "company_vat" => company.vat, "receipt_url" => receipt_url } end defp ensure_preloaded(%{__struct__: _} = struct, preloads) do Enum.reduce(preloads, struct, fn preload, acc -> case Map.get(acc, preload) do %Ecto.Association.NotLoaded{} -> repo().preload(acc, preload) _ -> acc end end) end defp build_invoice_email_variables(invoice, user, opts) do invoice_url = Keyword.get(opts, :invoice_url, "") invoice_bank = invoice.bank_details || %{} billing_details = invoice.billing_details || %{} company = get_company_details() bank = CountryData.get_bank_details() %{ "user_email" => user.email, "user_name" => extract_user_name(billing_details, user), "invoice_number" => invoice.invoice_number, "invoice_date" => format_date(invoice.inserted_at), "due_date" => format_date(invoice.due_date), "subtotal" => format_decimal(invoice.subtotal), "tax_amount" => format_decimal(invoice.tax_amount), "total" => format_decimal(invoice.total), "currency" => invoice.currency, "line_items_html" => format_line_items_html(invoice.line_items), "line_items_text" => format_line_items_text(invoice.line_items), "company_name" => company.name, "company_address" => company.address, "company_vat" => company.vat, "bank_name" => invoice_bank["bank_name"] || bank["bank_name"] || "", "bank_iban" => invoice_bank["iban"] || bank["iban"] || "", "bank_swift" => invoice_bank["swift"] || bank["swift"] || "", "payment_terms" => invoice.payment_terms || Settings.get_setting("billing_payment_terms", "Payment due within 14 days."), "invoice_url" => invoice_url } end defp extract_user_name(%{"company_name" => name}, _user) when is_binary(name) and name != "", do: name defp extract_user_name(%{"first_name" => first, "last_name" => last}, _user) when is_binary(first) and first != "", do: "#{first} #{last}" defp extract_user_name(_billing, %{first_name: first, last_name: last}) when is_binary(first) and first != "", do: "#{first} #{last}" defp extract_user_name(_billing, user), do: user.email defp format_line_items_html(nil), do: "" defp format_line_items_html(items) do Enum.map_join(items, "\n", fn item -> desc = if item["description"], do: "