diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 91759195b..5e765574c 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -99,9 +99,11 @@ {"test/support/conn_case.ex", :unknown_function}, {"test/support/data_case.ex", :unknown_function}, - # Sync connections_live - MapSet opaque type false positives in topo_sort/visit_node - # Same pattern as context_selector.ex - MapSet.t() opaque type through recursive functions - # Matches both standard Dialyxir format (call_without_opaque) and legacy format (opaque term) - ~r/lib\/modules\/sync\/web\/connections_live\.ex:.*call_without_opaque/, - ~r/lib\/modules\/sync\/web\/connections_live\.ex:.*opaque term/ + # Extracted module references — conditionally loaded via Code.ensure_loaded? + # These modules live in separate packages (phoenix_kit_ecommerce, phoenix_kit_billing) + {"lib/phoenix_kit_web/integration.ex", :unknown_function}, + {"lib/phoenix_kit/utils/country_data.ex", :unknown_function}, + {"lib/phoenix_kit_web/users/auth.ex", :unknown_function}, + {"lib/modules/sitemap/sources/shop.ex", :unknown_function}, + {"lib/phoenix_kit/users/auth.ex", :unknown_function} ] diff --git a/lib/mix/tasks/shop.deduplicate_products.ex b/lib/mix/tasks/shop.deduplicate_products.ex deleted file mode 100644 index 17acb873e..000000000 --- a/lib/mix/tasks/shop.deduplicate_products.ex +++ /dev/null @@ -1,221 +0,0 @@ -defmodule Mix.Tasks.Shop.DeduplicateProducts do - # Ignore Mix.Task behaviour callback info (unavailable in PLT) - @dialyzer :no_undefined_callbacks - - @moduledoc """ - Finds and merges duplicate products by slug. - - After V47 migration converted slug to JSONB, products can have duplicates - where multiple records share the same slug value in a specific language. - - This task: - 1. Finds products with duplicate en-US slugs (or default language) - 2. Keeps the product with the lowest ID (oldest) - 3. Merges localized fields from duplicates into the kept product - 4. Updates related cart_items and order_items references - 5. Deletes duplicate products - - ## Usage - - mix shop.deduplicate_products - mix shop.deduplicate_products --dry-run - mix shop.deduplicate_products --language es-ES - - ## Options - - * `--dry-run` - Show what would be done without making changes - * `--language` - Language to check for duplicates (default: en-US) - * `--verbose` - Show detailed progress - - """ - - use Mix.Task - - # Dialyzer can't trace Mix.shell() dynamic module returns - @dialyzer {:nowarn_function, run: 1} - @dialyzer {:nowarn_function, find_duplicates: 2} - @dialyzer {:nowarn_function, process_duplicate_group: 6} - @dialyzer {:nowarn_function, update_cart_items: 3} - @dialyzer {:nowarn_function, update_order_items: 3} - - import Ecto.Query - - alias PhoenixKit.Modules.Shop.Product - - @shortdoc "Merge duplicate products by slug" - - @switches [ - dry_run: :boolean, - language: :string, - verbose: :boolean - ] - - @impl Mix.Task - def run(args) do - {opts, _args} = OptionParser.parse!(args, strict: @switches) - - dry_run = Keyword.get(opts, :dry_run, false) - language = Keyword.get(opts, :language, "en-US") - verbose = Keyword.get(opts, :verbose, false) - - Mix.Task.run("app.start") - - repo = PhoenixKit.RepoHelper.repo() - - if dry_run do - Mix.shell().info("🔍 DRY RUN MODE - No changes will be made\n") - end - - Mix.shell().info("Finding duplicate products by slug (language: #{language})...") - - duplicates = find_duplicates(repo, language) - - if Enum.empty?(duplicates) do - Mix.shell().info("✅ No duplicate products found!") - else - Mix.shell().info("Found #{length(duplicates)} duplicate slug groups\n") - - Enum.each(duplicates, fn {slug, ids} -> - process_duplicate_group(repo, slug, ids, language, dry_run, verbose) - end) - - if dry_run do - Mix.shell().info("\n🔍 DRY RUN complete. Run without --dry-run to apply changes.") - else - Mix.shell().info("\n✅ Deduplication complete!") - end - end - end - - defp find_duplicates(repo, language) do - # Find slugs that appear in multiple products - query = """ - SELECT slug->>$1 as slug_value, array_agg(uuid ORDER BY uuid) as ids - FROM phoenix_kit_shop_products - WHERE slug->>$1 IS NOT NULL - GROUP BY slug->>$1 - HAVING COUNT(*) > 1 - """ - - case repo.query(query, [language]) do - {:ok, %{rows: rows}} -> - Enum.map(rows, fn [slug, ids] -> {slug, ids} end) - - {:error, error} -> - Mix.shell().error("Failed to find duplicates: #{inspect(error)}") - [] - end - end - - defp process_duplicate_group(repo, slug, uuids, _language, dry_run, verbose) do - [keep_uuid | remove_uuids] = uuids - - Mix.shell().info("Processing slug: \"#{slug}\"") - Mix.shell().info(" Keep: #{keep_uuid}") - Mix.shell().info(" Remove: #{inspect(remove_uuids)}") - - if verbose do - # Show product details - products = repo.all(from(p in Product, where: p.uuid in ^uuids)) - - Enum.each(products, fn product -> - Mix.shell().info(" #{product.uuid}: #{inspect(product.title)}") - end) - end - - unless dry_run do - repo.transaction(fn -> - # 1. Load all products - keep_product = repo.get!(Product, keep_uuid) - remove_products = repo.all(from(p in Product, where: p.uuid in ^remove_uuids)) - - # 2. Merge localized fields - merged_attrs = merge_all_localized_fields(keep_product, remove_products) - - # 3. Update the product we're keeping - keep_product - |> Ecto.Changeset.change(merged_attrs) - |> repo.update!() - - # 4. Update cart_items references - update_cart_items(repo, keep_uuid, remove_uuids) - - # 5. Update order_items references (if they have product_uuid) - update_order_items(repo, keep_uuid, remove_uuids) - - # 6. Delete duplicate products - repo.delete_all(from(p in Product, where: p.uuid in ^remove_uuids)) - - Mix.shell().info(" ✅ Merged and removed #{length(remove_uuids)} duplicate(s)") - end) - end - end - - defp merge_all_localized_fields(keep_product, remove_products) do - localized_fields = [:title, :slug, :description, :body_html, :seo_title, :seo_description] - - Enum.reduce(localized_fields, %{}, fn field, acc -> - # Start with the keep product's values - base_map = Map.get(keep_product, field) || %{} - - # Merge in values from each remove product (keep_product values take precedence) - merged = - Enum.reduce(remove_products, base_map, fn product, map_acc -> - product_map = Map.get(product, field) || %{} - # Map.merge puts second map's values on top, so we put base values last - Map.merge(product_map, map_acc) - end) - - if merged != base_map do - Map.put(acc, field, merged) - else - acc - end - end) - end - - defp update_cart_items(repo, keep_uuid, remove_uuids) do - query = """ - UPDATE phoenix_kit_shop_cart_items - SET product_uuid = $1 - WHERE product_uuid = ANY($2::uuid[]) - """ - - case repo.query(query, [keep_uuid, remove_uuids]) do - {:ok, %{num_rows: num}} when num > 0 -> - Mix.shell().info(" Updated #{num} cart item(s)") - - {:ok, _} -> - :ok - - {:error, %Postgrex.Error{postgres: %{code: :undefined_table}}} -> - :ok - - {:error, error} -> - Mix.shell().info(" Note: Could not update cart_items: #{inspect(error)}") - end - end - - defp update_order_items(repo, keep_uuid, remove_uuids) do - query = """ - UPDATE phoenix_kit_order_items - SET product_uuid = $1 - WHERE product_uuid = ANY($2::uuid[]) - """ - - case repo.query(query, [keep_uuid, remove_uuids]) do - {:ok, %{num_rows: num}} when num > 0 -> - Mix.shell().info(" Updated #{num} order item(s)") - - {:ok, _} -> - :ok - - {:error, %Postgrex.Error{postgres: %{code: :undefined_table}}} -> - :ok - - {:error, _error} -> - # Order items table might not exist or have different structure - :ok - end - end -end diff --git a/lib/modules/billing/billing.ex b/lib/modules/billing/billing.ex deleted file mode 100644 index 56ce95baf..000000000 --- a/lib/modules/billing/billing.ex +++ /dev/null @@ -1,3340 +0,0 @@ -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.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: "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: "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: "billing/orders", - priority: 522, - level: :admin, - permission: "billing", - parent: :admin_billing - ), - Tab.new!( - id: :admin_billing_invoices, - label: "Invoices", - icon: "hero-document-text", - path: "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: "billing/transactions", - priority: 524, - level: :admin, - permission: "billing", - parent: :admin_billing - ), - Tab.new!( - id: :admin_billing_subscriptions, - label: "Subscriptions", - icon: "hero-arrow-path", - path: "billing/subscriptions", - priority: 525, - level: :admin, - permission: "billing", - parent: :admin_billing - ), - Tab.new!( - id: :admin_billing_subscription_types, - label: "Subscription Types", - icon: "hero-rectangle-stack", - path: "billing/subscription-types", - priority: 526, - level: :admin, - permission: "billing", - parent: :admin_billing - ), - Tab.new!( - id: :admin_billing_profiles, - label: "Billing Profiles", - icon: "hero-identification", - path: "billing/profiles", - priority: 527, - level: :admin, - permission: "billing", - parent: :admin_billing - ), - Tab.new!( - id: :admin_billing_currencies, - label: "Currencies", - icon: "hero-currency-dollar", - path: "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: "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: "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: "orders", - priority: 200, - match: :prefix, - group: :main - ), - Tab.new!( - id: :dashboard_billing_profiles, - label: "Billing Profiles", - icon: "hero-identification", - path: "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_binary(id) do - if UUIDUtils.valid?(id) do - repo().get_by(Currency, uuid: id) - else - nil - 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_uuid` - Filter by user UUID - - `: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_uuid(Keyword.get(opts, :user_uuid)) - |> 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_uuid(query, nil), do: query - - defp filter_by_user_uuid(query, user_uuid) do - user_uuid = extract_user_uuid(user_uuid) - 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_uuid) do - list_billing_profiles(user_uuid: user_uuid) - 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_uuid) do - user_uuid = extract_user_uuid(user_uuid) - - 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_binary(id) do - if UUIDUtils.valid?(id) do - repo().get_by(BillingProfile, uuid: id) - else - nil - 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_uuid, attrs) do - user_uuid = extract_user_uuid(user_or_uuid) - - result = - %BillingProfile{} - |> BillingProfile.changeset( - attrs - |> 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_uuid) == 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_uuid) do - user_uuid = extract_user_uuid(user_uuid) - - 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_uuid, filters \\ %{}) do - user_uuid = extract_user_uuid(user_uuid) - - 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_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 - nil - 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_uuid, attrs) do - user_uuid = extract_user_uuid(user_or_uuid) - config = get_config() - - # Use string key to match other attrs (avoid mixed keys error) - 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 """ - Creates an order from attributes (user_uuid included in attrs). - """ - def create_order(attrs) when is_map(attrs) do - config = get_config() - - # Resolve user_uuid from attrs - user_uuid = Map.get(attrs, :user_uuid) || Map.get(attrs, "user_uuid") - - 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_uuid 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_uuid = Map.get(attrs, :billing_profile_uuid) || Map.get(attrs, "billing_profile_uuid") - - case profile_uuid do - nil -> - attrs - - "" -> - attrs - - uuid -> - profile = get_billing_profile!(uuid) - - attrs - |> Map.put("billing_snapshot", BillingProfile.to_snapshot(profile)) - |> Map.put("billing_profile_uuid", profile.uuid) - end - end - - # Updates billing_snapshot if billing_profile_uuid changed or snapshot is empty - defp maybe_update_billing_snapshot(%Order{} = order, attrs) do - new_profile_uuid = - Map.get(attrs, :billing_profile_uuid) || Map.get(attrs, "billing_profile_uuid") - - cond do - # No billing_profile_uuid in attrs - no change - is_nil(new_profile_uuid) -> - attrs - - # Empty string means clearing the profile - new_profile_uuid == "" -> - attrs - |> Map.put("billing_snapshot", %{}) - |> Map.put("billing_profile_uuid", nil) - - # Profile UUID present - update snapshot if changed or empty - true -> - profile = get_billing_profile!(new_profile_uuid) - - 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_uuid, filters \\ %{}) do - user_uuid = extract_user_uuid(user_uuid) - - 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_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 - nil - 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 - - @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_uuid, attrs) do - user_uuid = extract_user_uuid(user_or_uuid) - config = get_config() - - attrs = - attrs - |> 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" => UtilsDate.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) - - PhoenixKit.Mailer.send_from_template( - "billing_invoice", - email, - variables, - user_uuid: user && user.uuid, - metadata: %{invoice_uuid: invoice.uuid, 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" => UtilsDate.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) - - PhoenixKit.Mailer.send_from_template( - "billing_receipt", - email, - variables, - user_uuid: user && user.uuid, - metadata: %{ - invoice_uuid: invoice.uuid, - 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" => UtilsDate.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) - - PhoenixKit.Mailer.send_from_template( - "billing_credit_note", - email, - variables, - user_uuid: user && user.uuid, - metadata: %{ - invoice_uuid: invoice.uuid, - transaction_uuid: transaction.uuid, - 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" => UtilsDate.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) - - PhoenixKit.Mailer.send_from_template( - "billing_payment_confirmation", - email, - variables, - user_uuid: user && user.uuid, - metadata: %{ - invoice_uuid: invoice.uuid, - transaction_uuid: transaction.uuid, - 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: "
- <%= if @profile do %> - Edit billing profile details - <% else %> - Create a new billing profile for a user - <% end %> -
- - - <.form for={@form} phx-change="validate" phx-submit="save" class="space-y-6"> - <%!-- User Selection --%> -{@total_count} total profiles
- <:actions> - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/profiles/new")} - class="btn btn-primary btn-sm" - > - <.icon name="hero-plus" class="w-4 h-4" /> New Profile - - - - - <%!-- Filters --%> -- <%= if @search != "" or @type_filter != "all" do %> - Try adjusting your filters or search terms - <% else %> - Billing profiles are created by users - <% end %> -
-| User | -Type | -Name / Company | -Location | -Default | -Created | -- |
|---|---|---|---|---|---|---|
|
- <%= if profile.user do %>
-
- <.user_avatar user={profile.user} size="sm" />
-
- <% else %>
- -
- <% end %>
- {profile.user.email}
- |
- - - {String.capitalize(profile.type)} - - | -
- <%= if profile.type == "company" do %>
- {profile.company_name}
- <%= if profile.company_vat_number do %>
-
- VAT: {profile.company_vat_number}
-
- <% end %>
- <% else %>
-
- {profile.first_name} {profile.last_name}
-
- <%= if profile.email do %>
- {profile.email}
- <% end %>
- <% end %>
- |
- - <%= if profile.city do %> - {profile.city}, {profile.country} - <% else %> - {profile.country || "-"} - <% end %> - | -- <%= if profile.is_default do %> - Default - <% end %> - | -- <.time_ago datetime={profile.inserted_at} /> - | -- <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/billing/profiles/#{profile.uuid}/edit" - ) - } - class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom" - data-tip={gettext("Edit")} - > - <.icon name="hero-pencil" class="w-4 h-4 hidden sm:inline" /> - {gettext("Edit")} - - | -
{@transaction.description}
-| Description | -Amount | -
|---|---|
|
- Refund for Invoice {@invoice.invoice_number}
- <%= if @transaction.description && @transaction.description != "" do %>
- {@transaction.description}
- <% end %>
- |
- - {Decimal.to_string(Decimal.abs(@transaction.amount), :normal)} {@transaction.currency} - | -
| Total Refund: | -- {Decimal.to_string(Decimal.abs(@transaction.amount), :normal)} {@transaction.currency} - | -
The refund will be processed to your original payment method or bank account.
- <%= if @invoice.billing_details && @invoice.billing_details["bank_iban"] do %> -Customer IBAN: {@invoice.billing_details["bank_iban"]}
- <% end %> -- Please allow 5-10 business days for the refund to appear in your account. -
-- Add currencies manually or import from the ISO 4217 library -
-| Currency | -Symbol | -Decimal Places | -Exchange Rate | -Status | -Default | -Actions | -
|---|---|---|---|---|---|---|
|
-
-
- {currency.code}
- {currency.name}
- |
- - {currency.symbol} - | -{currency.decimal_places} | -- <%= if currency.is_default do %> - Base - <% else %> - - {Decimal.to_string(currency.exchange_rate)} - - <% end %> - | -- - | -- <%= if currency.is_default do %> - Default - <% else %> - - <% end %> - | -
-
-
- <%= unless currency.is_default do %>
-
- <% end %>
-
- |
-
- The default currency is used for all new orders. Exchange rates are relative to the default currency (base rate = 1.0). - Enable only the currencies you plan to accept for billing. -
-All available currencies have already been added
-| - | Code | -Name | -Symbol | -Decimals | -
|---|---|---|---|---|
| - - | -{cur.code} | -{cur.name} | -{cur.symbol_native} | -{cur.decimal_digits} | -
No orders yet
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/orders/new")} - class="btn btn-primary btn-sm mt-4" - > - Create First Order - -| Order # | -Status | -Total | -Date | -
|---|---|---|---|
| {order.order_number} | -<.order_status_badge status={order.status} /> | -- <.currency_compact amount={order.total} currency={order.currency} /> - | -- <.time_ago datetime={order.inserted_at} /> - | -
No invoices yet
-Create an order first to generate invoices
-| Invoice # | -Status | -Total | -Due Date | -
|---|---|---|---|
| {invoice.invoice_number} | -<.invoice_status_badge status={invoice.status} /> | -- <.currency_compact amount={invoice.total} currency={invoice.currency} /> - | -- <%= if invoice.due_date do %> - {Calendar.strftime(invoice.due_date, "%b %d, %Y")} - <% else %> - - - <% end %> - | -
- Created <.time_ago datetime={@invoice.inserted_at} /> -
- <:actions> - <%!-- Send/Resend Invoice - available for all except void --%> - <%= if @invoice.status != "void" do %> - - <% end %> - - <%!-- Mark as Paid - available for sent/overdue --%> - <%= if @invoice.status in ["sent", "overdue"] do %> - - <% end %> - - <%!-- Online Payment Providers - available for sent/overdue with remaining balance --%> - <%= if @invoice.status in ["sent", "overdue"] && Decimal.positive?(PhoenixKit.Modules.Billing.Invoice.remaining_amount(@invoice)) do %> - <%= for provider <- @available_providers do %> - - <% end %> - <% end %> - - <%!-- Issue Refund - available if has payments --%> - <%= if PhoenixKit.Modules.Billing.Invoice.has_payments?(@invoice) do %> - - <% end %> - - <%!-- Generate Receipt - available when paid_amount > 0 and no receipt yet --%> - <%= if is_nil(@invoice.receipt_number) && @invoice.paid_amount && Decimal.gt?(@invoice.paid_amount, Decimal.new(0)) do %> - - <% end %> - - <%!-- Void - available for draft/sent/overdue --%> - <%= if @invoice.status in ["draft", "sent", "overdue"] do %> - - <% end %> - - <.link - navigate={ - PhoenixKit.Utils.Routes.path("/admin/billing/invoices/#{@invoice.uuid}/print") - } - class="btn btn-outline btn-sm" - target="_blank" - > - <.icon name="hero-printer" class="w-4 h-4" /> Print / PDF - - - <%!-- View Receipt - available when receipt is generated --%> - <%= if @invoice.receipt_number do %> - <.link - navigate={ - PhoenixKit.Utils.Routes.path("/admin/billing/invoices/#{@invoice.uuid}/receipt") - } - class="btn btn-success btn-sm" - target="_blank" - > - <.icon name="hero-document-check" class="w-4 h-4" /> View Receipt - - - <% end %> - - - -| Item | -Qty | -Unit Price | -Total | -
|---|---|---|---|
|
- {item["name"]}
- <%= if item["description"] do %>
- {item["description"]}
- <% end %>
- |
- {item["quantity"]} | -- <.currency_compact - amount={item["unit_price"]} - currency={@invoice.currency} - /> - | -- <.currency_compact amount={item["total"]} currency={@invoice.currency} /> - | -
| Subtotal | -- <.currency_compact amount={@invoice.subtotal} currency={@invoice.currency} /> - | -||
| - Tax ({Decimal.round( - Decimal.mult(@invoice.tax_rate || Decimal.new(0), 100), - 2 - ) - |> Decimal.normalize() - |> Decimal.to_string()}%) - | -- <.currency_compact - amount={@invoice.tax_amount} - currency={@invoice.currency} - /> - | -||
| Total | -- <.currency_amount amount={@invoice.total} currency={@invoice.currency} /> - | -||
No transactions recorded yet
-| Date | -Number | -Type | -Amount | -Description | -Actions | -
|---|---|---|---|---|---|
| - <.time_ago datetime={transaction.inserted_at} /> - | -{transaction.transaction_number} | -- <.transaction_type_badge type={ - PhoenixKit.Modules.Billing.Transaction.type(transaction) - } /> - | -- - <%= if Decimal.positive?(transaction.amount) do %> - +<.currency_compact - amount={transaction.amount} - currency={transaction.currency} - /> - <% else %> - <.currency_compact - amount={transaction.amount} - currency={transaction.currency} - /> - <% end %> - - | -- {transaction.description || "-"} - | -
-
- <%= if Decimal.negative?(transaction.amount) do %>
- <%!-- Refund: Credit Note buttons --%>
- <.link
- navigate={
- PhoenixKit.Utils.Routes.path(
- "/admin/billing/invoices/#{@invoice.uuid}/credit-note/#{transaction.uuid}"
- )
- }
- class="btn btn-warning btn-xs tooltip tooltip-bottom"
- target="_blank"
- data-tip={gettext("Print Credit Note")}
- >
- <.icon name="hero-printer" class="w-3 h-3 hidden sm:inline" />
-
- {gettext("Print Credit Note")}
-
-
-
- <% else %>
- <%!-- Payment: Payment Confirmation buttons --%>
- <.link
- navigate={
- PhoenixKit.Utils.Routes.path(
- "/admin/billing/invoices/#{@invoice.uuid}/payment/#{transaction.uuid}"
- )
- }
- class="btn btn-success btn-xs tooltip tooltip-bottom"
- target="_blank"
- data-tip={gettext("Print Payment Confirmation")}
- >
- <.icon name="hero-printer" class="w-3 h-3 hidden sm:inline" />
-
- {gettext("Print Payment Confirmation")}
-
-
-
- <% end %>
-
- |
-
{@invoice.payment_terms}
-{@invoice.notes}
-{@invoice.receipt_number}
-- Generated <.time_ago datetime={@invoice.receipt_generated_at} /> -
-No customer linked
- <% end %> -No billing details
- <% end %> -- Recording payment for invoice {@invoice.invoice_number} -
- - -- Issue refund for invoice {@invoice.invoice_number} -
- - -- {if @invoice.status == "draft", - do: "Send invoice to customer's email address", - else: "Resend invoice to customer's email address"} -
- - -- Send payment receipt to customer's email address -
- - -- Send credit note (refund document) to customer's email address -
- - -- Send payment confirmation to customer's email address -
- - -| Description | -Qty | -Unit Price | -Amount | -
|---|---|---|---|
|
- {item["name"]}
- <%= if item["description"] && item["description"] != "" do %>
- {item["description"]}
- <% end %>
- |
- {item["quantity"]} | -{item["unit_price"]} {@invoice.currency} | -{item["total"]} {@invoice.currency} | -
| Subtotal: | -- {Decimal.to_string(@invoice.subtotal || Decimal.new(0), :normal)} {@invoice.currency} - | -
| - Tax ({Decimal.round(Decimal.mult(@invoice.tax_rate || Decimal.new(0), 100), 2) - |> Decimal.normalize() - |> Decimal.to_string()}%): - | -- {Decimal.to_string(@invoice.tax_amount, :normal)} {@invoice.currency} - | -
| Total: | -- {Decimal.to_string(@invoice.total || Decimal.new(0), :normal)} {@invoice.currency} - | -
| Bank: | -- {@invoice.bank_details["bank_name"] || @company.bank_name} - | -
| IBAN: | -{@invoice.bank_details["iban"] || @company.bank_iban} | -
| SWIFT: | -{@invoice.bank_details["swift"] || @company.bank_swift} | -
| Reference: | -{@invoice.invoice_number} | -
| Date | -Type | -Method | -Amount | -
|---|---|---|---|
| {Calendar.strftime(txn.inserted_at, "%B %d, %Y")} | -- <%= if Decimal.negative?(txn.amount) do %> - Refund - <% else %> - Payment - <% end %> - | -{String.capitalize(txn.payment_method || "bank")} | -- <%= if Decimal.negative?(txn.amount) do %> - -{Decimal.to_string(Decimal.abs(txn.amount), :normal)} {@invoice.currency} - <% else %> - +{Decimal.to_string(txn.amount, :normal)} {@invoice.currency} - <% end %> - | -
{@invoice.notes}
-{@total_count} total invoices
- <:actions> - - - - - <%!-- Filters --%> -- <%= if @search != "" or @status_filter != "all" do %> - Try adjusting your filters or search terms - <% else %> - Invoices are generated from orders - <% end %> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/orders")} - class="btn btn-primary" - > - View Orders - -| Invoice # | -Order # | -Customer | -Status | -Total | -Due Date | -- |
|---|---|---|---|---|---|---|
| {invoice.invoice_number} | -- <%= if invoice.order do %> - {invoice.order.order_number} - <% else %> - - - <% end %> - | -
- <%= if invoice.user do %>
-
- <.user_avatar user={invoice.user} size="sm" />
-
- <% else %>
- -
- <% end %>
- {invoice.user.email}
- |
- <.invoice_status_badge status={invoice.status} /> | -- <.currency_compact amount={invoice.total} currency={invoice.currency} /> - | -- <%= if invoice.due_date do %> - <% is_overdue = - Date.compare(invoice.due_date, Date.utc_today()) == :lt and - invoice.status != "paid" %> - - {Calendar.strftime(invoice.due_date, "%b %d, %Y")} - - <% else %> - - - <% end %> - | -- <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/billing/invoices/#{invoice.uuid}" - ) - } - class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom" - data-tip={gettext("View")} - > - <.icon name="hero-eye" class="w-4 h-4 hidden sm:inline" /> - {gettext("View")} - - | -
- Created <.time_ago datetime={@order.inserted_at} /> -
- <:actions> - <%= case @order.status do %> - <% "draft" -> %> - - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/orders/#{@order.uuid}/edit")} - class="btn btn-outline btn-sm" - > - <.icon name="hero-pencil" class="w-4 h-4" /> Edit - - <% "pending" -> %> - - - <% "confirmed" -> %> - - - <% _ -> %> - <% end %> - <%= if @order.status in ["draft", "pending", "confirmed"] do %> - - <% end %> - - - -| Item | -Qty | -Unit Price | -Total | -
|---|---|---|---|
|
- {item["name"]}
- <%= if item["description"] do %>
- {item["description"]}
- <% end %>
- |
- {item["quantity"]} | -- <.currency_compact amount={item["unit_price"]} currency={@order.currency} /> - | -- <.currency_compact amount={item["total"]} currency={@order.currency} /> - | -
| Subtotal | -- <.currency_compact amount={@order.subtotal} currency={@order.currency} /> - | -||
| - Tax ({Decimal.round( - Decimal.mult(@order.tax_rate || Decimal.new(0), 100), - 2 - ) - |> Decimal.normalize() - |> Decimal.to_string()}%) - | -- <.currency_compact amount={@order.tax_amount} currency={@order.currency} /> - | -||
| - Discount - <%= if @order.discount_code do %> - - {@order.discount_code} - - <% end %> - | -- -<.currency_compact - amount={@order.discount_amount} - currency={@order.currency} - /> - | -||
| Total | -- <.currency_amount amount={@order.total} currency={@order.currency} /> - | -||
{@order.notes}
-{@order.internal_notes}
-No invoices generated yet
-| Invoice # | -Status | -Total | -Due Date | -- |
|---|---|---|---|---|
| {invoice.invoice_number} | -<.invoice_status_badge status={invoice.status} /> | -- <.currency_compact amount={invoice.total} currency={invoice.currency} /> - | -- <%= if invoice.due_date do %> - {Calendar.strftime(invoice.due_date, "%b %d, %Y")} - <% else %> - - - <% end %> - | -- <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/billing/invoices/#{invoice.uuid}" - ) - } - class="btn btn-ghost btn-xs tooltip tooltip-bottom" - data-tip={gettext("View Invoice")} - > - <.icon name="hero-eye" class="w-4 h-4 hidden sm:inline" /> - - {gettext("View Invoice")} - - - | -
No customer linked
- <% end %> -No billing information
- <%= if @order.billing_profile do %> - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/profiles")} - class="btn btn-outline btn-sm mt-2" - > - View Billing Profile - - <% end %> - <% end %> -- {if @order, do: "Modify order details", else: "Create a new order"} -
- - - <.form for={@form} phx-submit="save" class="space-y-6"> - <%!-- Customer Selection --%> -No billing profile found
-- This customer has no billing profile. A billing profile is required to create an order. - <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/billing/profiles/new?user_uuid=#{@selected_user_uuid}" - ) - } - class="link font-medium" - > - Create one now - -
-| Name | -Description | -Qty | -Unit Price | -- |
|---|---|---|---|---|
| - - | -- - | -- - | -- - | -- - | -
{@total_count} total orders
- <:actions> - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/orders/new")} - class="btn btn-primary btn-sm" - > - <.icon name="hero-plus" class="w-4 h-4" /> New Order - - - - - <%!-- Filters --%> -- <%= if @search != "" or @status_filter != "all" do %> - Try adjusting your filters or search terms - <% else %> - Get started by creating your first order - <% end %> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/orders/new")} - class="btn btn-primary" - > - <.icon name="hero-plus" class="w-4 h-4" /> Create Order - -| Order # | -Customer | -Status | -Payment | -Total | -Date | -- |
|---|---|---|---|---|---|---|
| {order.order_number} | -
- <%= if order.user do %>
-
- <.user_avatar user={order.user} size="sm" />
-
- <% else %>
- -
- <% end %>
-
-
- {order.user.email}
- |
- <.order_status_badge status={order.status} /> | -- <%= if order.payment_method do %> - - {String.upcase(order.payment_method)} - - <% else %> - - - <% end %> - | -- <.currency_compact amount={order.total} currency={order.currency} /> - | -- <.time_ago datetime={order.inserted_at} /> - | -- <.link - navigate={ - PhoenixKit.Utils.Routes.path("/admin/billing/orders/#{order.uuid}") - } - class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom" - data-tip={gettext("View")} - > - <.icon name="hero-eye" class="w-4 h-4 hidden sm:inline" /> - {gettext("View")} - - | -
{@transaction.description}
-Cards, Apple Pay, Google Pay
-PayPal, Venmo, Cards
-India payments (UPI, Cards)
-| Description | -Qty | -Unit Price | -Amount | -
|---|---|---|---|
|
- {item["name"]}
- <%= if item["description"] && item["description"] != "" do %>
- {item["description"]}
- <% end %>
- |
- {item["quantity"]} | -{item["unit_price"]} {@invoice.currency} | -{item["total"]} {@invoice.currency} | -
| Subtotal: | -- {Decimal.to_string(@invoice.subtotal || Decimal.new(0), :normal)} {@invoice.currency} - | -
| - Tax ({Decimal.round(Decimal.mult(@invoice.tax_rate || Decimal.new(0), 100), 2) - |> Decimal.normalize() - |> Decimal.to_string()}%): - | -- {Decimal.to_string(@invoice.tax_amount, :normal)} {@invoice.currency} - | -
| Total Paid: | -- {Decimal.to_string(@invoice.paid_amount || @invoice.total, :normal)} {@invoice.currency} - | -
| Date | -Transaction # | -Method | -Description | -Amount | -
|---|---|---|---|---|
| {Calendar.strftime(txn.inserted_at, "%b %d, %Y")} | -{txn.transaction_number} | -{String.capitalize(txn.payment_method)} | -{txn.description || "-"} | -- - {if Decimal.positive?(txn.amount), do: "+", else: ""}{Decimal.to_string( - txn.amount, - :normal - )} {@invoice.currency} - - | -
{@invoice.notes}
-Shown on invoices and receipts
-{@company_info["name"]}
- <%= if @company_info["registration_number"] && @company_info["registration_number"] != "" do %> -- Reg. No: {@company_info["registration_number"]} -
- <% end %> -{@company_info["address_line1"]}
- <%= if @company_info["address_line2"] && @company_info["address_line2"] != "" do %> -{@company_info["address_line2"]}
- <% end %> -- {@company_info["city"]} - <%= if @company_info["state"] && @company_info["state"] != "" do %> - , {@company_info["state"]} - <% end %> - {@company_info["postal_code"]} -
- <%= if @company_country_name != "" do %> -{@company_country_name}
- <% end %> -{@bank_details["bank_name"]}
-- Created <.time_ago datetime={@subscription.inserted_at} /> -
- <:actions> - <%!-- Change Subscription Type Button (for active/trialing subscriptions) --%> - <%= if @subscription.status in ["active", "trialing"] && !@subscription.cancel_at_period_end do %> - - <% end %> - - <%!-- Action Buttons based on status --%> - <%= case @subscription.status do %> - <% "active" -> %> - <%= if @subscription.cancel_at_period_end do %> - - <% else %> - - - <% end %> - <% "trialing" -> %> - - <% "paused" -> %> - - - <% "past_due" -> %> - - <% _ -> %> - <% end %> - - - -- {@subscription.subscription_type.description} -
-No subscription type associated
-This subscription will cancel at the end of the current billing period.
- <% end %> - <%= if @subscription.cancelled_at do %> -- Cancelled <.time_ago datetime={@subscription.cancelled_at} /> -
- <% end %> -No customer linked
- <% end %> -No payment method
-- Manually create a subscription for a customer -
- - - <%!-- Error Alert --%> - <%= if @error do %> -- <%= if @mode == :new do %> - Create a new subscription type - <% else %> - Edit subscription type details and pricing - <% end %> -
- - -- List the features included in this plan (one per line) -
- -- {@form[:description].value || "Description"} -
- -- Create your first subscription type to start accepting recurring payments. -
-{type.description}
-No subscriptions found
-- Subscriptions will appear here when customers subscribe to plans -
-| Customer | -Subscription Type | -Status | -Current Period | -Price | -Actions | -
|---|---|---|---|---|---|
|
- <%= if subscription.user do %>
-
- <.user_avatar user={subscription.user} size="sm" />
-
- <% else %>
- No user
- <% end %>
-
-
- {subscription.user.email}
-
- ID: {subscription.uuid}
-
- |
-
- <%= if subscription.subscription_type do %>
-
-
- <% else %>
- No subscription type
- <% end %>
- {subscription.subscription_type.name}
-
- {format_interval(
- subscription.subscription_type.interval,
- subscription.subscription_type.interval_count
- )}
-
- |
- - - {subscription.status} - - <%= if subscription.cancel_at_period_end do %> - Cancels at end - <% end %> - | -
-
- <%= if subscription.current_period_start && subscription.current_period_end do %>
-
-
- <.time_ago datetime={subscription.current_period_start} /> →
-
-
- <.time_ago datetime={subscription.current_period_end} />
-
- <% else %>
- -
- <% end %>
- |
- - <%= if subscription.subscription_type do %> - <.currency_compact - amount={subscription.subscription_type.price} - currency={subscription.subscription_type.currency} - /> - <% else %> - - - <% end %> - | -
-
- <.link
- navigate={
- PhoenixKit.Utils.Routes.path(
- "/admin/billing/subscriptions/#{subscription.uuid}"
- )
- }
- class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom"
- data-tip={gettext("View")}
- >
- <.icon name="hero-eye" class="w-4 h-4 hidden sm:inline" />
- {gettext("View")}
-
- <%= if subscription.status in ["active", "trialing"] && !subscription.cancel_at_period_end do %>
-
- <% end %>
-
- |
-
{@total_count} total transactions
- <:actions> - - - - - <%!-- Filters --%> -- <%= if @search != "" or @type_filter != "all" or @payment_method_filter != "all" do %> - Try adjusting your filters or search terms - <% else %> - Transactions will appear here when payments or refunds are recorded - <% end %> -
-| Transaction # | -Invoice | -Type | -Amount | -Method | -Description | -Date | -- |
|---|---|---|---|---|---|---|---|
| {transaction.transaction_number} | -- <%= if transaction.invoice do %> - <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/billing/invoices/#{transaction.invoice_uuid}" - ) - } - class="link link-hover font-mono text-sm" - > - {transaction.invoice.invoice_number} - - <% else %> - - - <% end %> - | -- <.transaction_type_badge type={transaction_type(transaction)} /> - | -- - <%= if Decimal.positive?(transaction.amount) do %> - +<.currency_compact - amount={transaction.amount} - currency={transaction.currency} - /> - <% else %> - <.currency_compact - amount={transaction.amount} - currency={transaction.currency} - /> - <% end %> - - | -- - {String.upcase(transaction.payment_method || "bank")} - - | -- {transaction.description || "-"} - | -- <.time_ago datetime={transaction.inserted_at} /> - | -- <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/billing/invoices/#{transaction.invoice_uuid}" - ) - } - class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom" - data-tip={gettext("View Invoice")} - > - <.icon name="hero-eye" class="w-4 h-4 hidden sm:inline" /> - - {gettext("View Invoice")} - - - | -
- <%= if @profile do %> - Update your billing information - <% else %> - Create a new billing profile for orders - <% end %> -
-- Manage your billing information for orders and invoices -
-- Create a billing profile to use for your orders -
- <.link navigate={Routes.path("/dashboard/billing-profiles/new")} class="btn btn-primary"> - Create Your First Profile - -- VAT: {profile.company_vat_number} -
- <% end %> - <% else %> -{profile.email}
- <% end %> - <% end %> - - <%= if profile.address_line1 do %> -- {profile.address_line1} - <%= if profile.city do %> - , {profile.city} - <% end %> - <%= if profile.country do %> - , {profile.country} - <% end %> -
- <% end %> -Review your items before checkout
-Add some products to get started
- <.link navigate={Shop.catalog_url(@current_language)} class="btn btn-primary"> - Browse Products - -| Product | -Quantity | -Price | -- |
|---|---|---|---|
|
-
- <%= if item.product_image do %>
- <%= if item.product_slug do %>
- <.link
- navigate={product_item_url(item, @current_language)}
- class="w-16 h-16 bg-base-200 rounded-lg overflow-hidden flex-shrink-0 block"
- >
-
-
-
- <% end %>
- <% else %>
- <%= if item.product_slug do %>
- <.link
- navigate={product_item_url(item, @current_language)}
- class="w-16 h-16 bg-base-200 rounded-lg flex items-center justify-center flex-shrink-0 block"
- >
- <.icon name="hero-cube" class="w-8 h-8 opacity-30" />
-
- <% else %>
-
- <.icon name="hero-cube" class="w-8 h-8 opacity-30" />
-
- <% end %>
- <% end %>
-
-
-
- <%= if item.product_slug do %>
- <.link
- navigate={product_item_url(item, @current_language)}
- class="hover:text-primary transition-colors"
- >
- {item.product_title}
-
- <% else %>
- {item.product_title}
- <% end %>
-
- <%= if item.product_sku do %>
-
- SKU: {item.product_sku}
-
- <% end %>
- <%= if item.selected_specs && item.selected_specs != %{} do %>
-
- <%= for {key, value} <- item.selected_specs do %>
-
- {humanize_key(key)}:
- {value}
-
- <% end %>
-
- <% end %>
- <%= if item.compare_at_price && Decimal.compare(item.compare_at_price, item.unit_price) == :gt do %>
-
-
- {format_price(item.compare_at_price, @currency)}
-
- On sale!
-
- <% end %>
- |
- - - | -
-
- {format_price(item.line_total, @currency)}
-
-
- {format_price(item.unit_price, @currency)} each
-
- |
- - - | -
- Secure checkout powered by PhoenixKit -
- <% end %> -{@total} carts total
- - - <%!-- Controls Bar --%> -| Customer | -Items | -Total | -Status | -Updated | -
|---|---|---|---|---|
|
- <.icon name="hero-shopping-cart" class="w-12 h-12 mx-auto mb-3 opacity-50" />
- No carts found -Carts will appear here when customers add items - |
- ||||
|
- <%= if cart.user do %>
- {cart.user.email}
- User UUID: {cart.user.uuid}
- <% else %>
- Guest
-
- {String.slice(cart.session_id || "", 0, 16)}...
-
- <% end %>
- |
- - {cart.items_count || 0} items - <%= if cart.total_weight_grams && cart.total_weight_grams > 0 do %> - - {format_weight(cart.total_weight_grams)} - - <% end %> - | -
- {format_price(cart.total, @currency)}
- <%= if Decimal.compare(cart.subtotal || Decimal.new("0"), cart.total || Decimal.new("0")) != :eq do %>
-
- Subtotal: {format_price(cart.subtotal, @currency)}
-
- <% end %>
- |
- - {cart.status} - | -
- {format_datetime(cart.updated_at)}
- <%= if cart.expires_at do %>
-
- Expires: {format_datetime(cart.expires_at)}
-
- <% end %>
- |
-
{@localized_description}
- <% end %> -- {@total_products} product(s) found -
-- Check back soon or browse other categories -
- <.link - navigate={Shop.catalog_url(@current_language) <> @filter_qs} - class="btn btn-primary" - > - Browse All Products - -{@localized_description}
- <% end %> -- {@total_products} product(s) found -
-- Check back soon or browse other categories -
- <.link - navigate={Shop.catalog_url(@current_language) <> @filter_qs} - class="btn btn-primary" - > - Browse All Products - -by {@product.vendor}
- <% end %> -| {label} | -- {format_spec_value(value)} - <%= if unit do %> - {unit} - <% end %> - | -
- {if @total == 1, do: "1 category", else: "#{@total} categories"} -
- - - <%!-- Controls Bar --%> -| - - | -Name | -Slug | -Parent | -Status | -Position | -Products | -Actions | -
|---|---|---|---|---|---|---|---|
|
- <.icon name="hero-folder" class="w-12 h-12 mx-auto mb-3 opacity-50" />
- No categories found -Create your first category to organize products - |
- |||||||
| - - | -
-
-
-
-
- {cat_name}
-
- <%= if image_url = Category.get_image_url(category, size: "thumbnail") do %>
-
- |
- {cat_slug} | -- <%= if category.parent do %> - - {Translations.get(category.parent, :name, @current_language)} - - <% else %> - — - <% end %> - | -- - {category.status || "active"} - - | -{category.position} | -- - {Map.get(@product_counts, category.uuid, 0)} - - | -
-
- <.link
- navigate={Routes.path("/admin/shop/categories/#{category.uuid}/edit")}
- class="btn btn-xs btn-outline btn-secondary tooltip tooltip-bottom"
- data-tip={gettext("Edit")}
- >
- <.icon name="hero-pencil" class="h-4 w-4 hidden sm:inline" />
- {gettext("Edit")}
-
-
-
- |
-
- Update status for {MapSet.size(@selected_uuids)} selected categories -
-- Set parent for {MapSet.size(@selected_uuids)} selected categories -
-- Are you sure you want to delete {MapSet.size(@selected_uuids)} categories? - This action cannot be undone. -
-- {if @live_action == :new, do: "Create a new category", else: "Edit category details"} -
- - - <%!-- Form --%> - <.form - for={@changeset} - phx-change="validate" - phx-submit="save" - class="space-y-6" - > -- Translate category content for different languages. The default language uses the main fields above. -
- - <%!-- Language Tabs --%> - <.translation_tabs - languages={@enabled_languages} - current_language={@current_translation_language} - translations={@category_translations} - translatable_fields={Translations.category_fields()} - on_click="switch_language" - /> - - <%!-- Translation Fields for Current Language --%> -- Define options specific to this category. - These override global options with the same key. -
- - <%= if @category_options == [] do %> -No category-specific options
-Products will use global options only
-{opt["key"]}
- - Products in this category will show these options: -
-- Blue - = Category specific, Gray - = Global -
-- Thank you for your order. We've received your order and will process it shortly. -
-- We've sent a confirmation email to {@order_email}. -
-- Don't see it? Check your spam or junk folder. The email may take a minute to arrive. -
-Your order is being processed
-- An account with this email is already registered. - Please log in to complete your order. -
-No options available
- <% else %> - <%= for item <- @values do %> - - <% end %> - <% end %> -- Showing {min(@page * @per_page, @total_products)} of {@total_products} products -
-Total Products
-{@stats.total_products}
-Active Products
-{@stats.active_products}
-Draft Products
-{@stats.draft_products}
-Categories
-{@stats.total_categories}
-{@stats.physical_products}
-Products requiring shipping
-{@stats.digital_products}
-Downloadable products
-Import filter configurations
-- Each config defines keyword filters and category rules for CSV imports. - The default config is used when no specific config is selected during import. -
-No configurations defined yet
-Add your first import configuration to get started
-| Title | -Slug | -Price | -- |
|---|---|---|---|
| - {get_localized(product.title)} - | -- {get_localized(product.slug) || "-"} - | -{format_price(product.price)} | -
-
- <.link
- navigate={Routes.path("/admin/shop/products/#{product.uuid}")}
- class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom"
- data-tip={gettext("View")}
- >
- <.icon name="hero-eye" class="w-4 h-4 hidden sm:inline" />
- {gettext("View")}
-
- <.link
- navigate={Routes.path("/admin/shop/products/#{product.uuid}/edit")}
- class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom"
- data-tip={gettext("Edit")}
- >
- <.icon name="hero-pencil" class="w-4 h-4 hidden sm:inline" />
- {gettext("Edit")}
-
-
- |
-
| Handle | -Error | -Time | -
|---|---|---|
| {error["handle"]} | -- {error["error"]} - | -- {error["timestamp"]} - | -
- Showing first 50 of {length(@import.error_details)} errors -
- <% end %> -- Import products from CSV files - <%= if @format_name do %> - {@format_name} - <% end %> -
- - - <%!-- Import Wizard Card --%> -- Migrate product images from external CDN URLs to the Storage module for better control and reliability. -
- - <%!-- Migration Stats --%> -- {progress_percent}% complete ({@migration_stats.migrated}/{@migration_stats.total}) -
-No imports yet
-| File | -Status | -Progress | -Results | -Date | -- |
|---|---|---|---|---|---|
| - <.link - navigate={Routes.path("/admin/shop/imports/#{import.uuid}")} - class="link link-hover" - > - {import.filename} - - | -- <.status_badge status={import.status} /> - | -- <%= if import.status == "processing" do %> - - <% else %> - {ImportLog.progress_percent(import)}% - <% end %> - | -- {import.imported_count} new - {import.updated_count} updated - <%= if import.error_count > 0 do %> - {import.error_count} errors - <% end %> - | -- {format_datetime(import.inserted_at)} - | -
-
- <.link
- navigate={Routes.path("/admin/shop/imports/#{import.uuid}")}
- class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom"
- data-tip={gettext("View Details")}
- >
- <.icon name="hero-eye" class="w-4 h-4 hidden sm:inline" />
-
- {gettext("View Details")}
-
-
- <%= if import.status == "failed" do %>
-
- <% end %>
- <%= if import.status in ["completed", "failed"] do %>
-
- <% end %>
-
- |
-
File: {@uploaded_filename}
-- Found {@csv_analysis.total_products} products with {@csv_analysis.total_variants} variants -
- <%= if @csv_analysis.total_skipped > 0 do %> -- {@csv_analysis.total_skipped} products filtered out by import config -
- <% end %> -- Position {@mapping.csv_position} · {@mapping.csv_values |> length()} values -
-- <.icon name="hero-exclamation-triangle" class="w-4 h-4 inline" /> - {length(@mapping.new_values)} new values not in global option -
-File: {@uploaded_filename}
-- Found {@confirm_product_count} products to import -
-- {@import_progress.current} / {@import_progress.total} products ({@import_progress.percent}%) -
-Preparing import...
- <% end %> -- Add your first global option to get started -
- -
- {opt["key"]}
-
- <%= if opt["options"] && opt["options"] != [] do %>
-
- {format_options_with_modifiers(opt)}
-
- <% end %>
- - Enable "Allow Override" for per-product values -
-Add at least one option
- <% end %> -- <%= if @form_data.modifier_type == "percent" do %> - Set percentage adjustment for each option (use 0 for no change) - <% else %> - Set price adjustment for each option (use 0 for no change) - <% end %> -
- <%= for opt <- @form_data.options do %> -{@product_seo_title}
-{@product_seo_description}
-- Are you sure you want to delete this product? This action cannot be undone. -
- -- {if @live_action == :new, do: "Create a new product", else: "Edit product details"} -
- - - <%!-- Form --%> - <.form - for={@changeset} - phx-change="validate" - phx-submit="save" - class="space-y-6" - > - <%!-- Card 1: Basic Info & Organization --%> -- Translate product content for different languages. The default language uses the main fields above. -
- - <%!-- Language Tabs --%> - <.translation_tabs - languages={@enabled_languages} - current_language={@current_translation_language} - translations={@product_translations} - translatable_fields={Translations.product_fields()} - on_click="switch_language" - /> - - <%!-- Translation Fields for Current Language --%> -Full translated description...
" - }, - %{ - key: :seo_title, - label: "SEO Title", - type: :text, - placeholder: "Page title for search engines (max 60 chars)" - }, - %{ - key: :seo_description, - label: "SEO Description", - type: :text, - placeholder: "Meta description for search engines (max 160 chars)" - } - ]} - /> -- <%= if all_select_options != [] do %> - Select which option values are available for this product. - <% else %> - Add custom options for this product. - <% end %> -
- -- Enter an existing option key to add a new value, or a new key to create a new option. -
-- Base price: - - {format_price(Ecto.Changeset.get_field(@changeset, :price), @currency)} - - — Options that affect the final price -
- - <%!-- Editable Options (Allow Override) --%> - <%= if editable_options != [] do %> -- Leave as "Default" to use global option values, or set custom values per-product. -
-| Value | -Default Price | -Custom Price | -
|---|---|---|
| - {opt_value} - | -- {format_price(default_final, @currency)} - - (<%= if default_type == "percent" do %> - +{default_val}% - <% else %> - +{default_val} - <% end %>) - - | -
-
- <%= if is_from_schema do %>
- <%!-- Schema values: use [final_price] suffix for map structure --%>
-
- <%= if custom_final do %>
- Custom
- <% end %>
- <% else %>
- <%!-- Added values: use simple format like imported --%>
- <% # Check if there's already a stored modifier for this value
- stored_mod =
- get_in(@metadata, [
- "_price_modifiers",
- option["key"],
- opt_value
- ])
-
- display_final =
- if is_binary(stored_mod) do
- Decimal.add(base_price, parse_decimal(stored_mod))
- else
- default_final
- end %>
-
-
- {currency_symbol(@currency)}
-
- <% end %>
-
- |
-
| Option | -Value | -Modifier | -Type | -
|---|---|---|---|
| {option["label"]} | -{value} | -- <%= if option["modifier_type"] == "percent" do %> - +{modifier}% - <% else %> - +{format_price(modifier, @currency)} - <% end %> - | -- - {option["modifier_type"] || "fixed"} - - | -
- Set prices for each option value. Enter the final price (base price + modifier). -
- - <% base_price = Ecto.Changeset.get_field(@changeset, :price) || Decimal.new("0") %> - -| Value | -Current Modifier | -Final Price | -
|---|---|---|
| {value} | -- <%= if modifier_decimal != Decimal.new("0") do %> - +{modifier_value} - <% else %> - +0 - <% end %> - | -
-
-
-
- {currency_symbol(@currency)}
-
-
- |
-
- Link images to option values. When a customer selects an option, the corresponding image displays. -
- - <%= for {option_key, option_values} <- get_mappable_options(assigns) do %> -- Drag images to reorder. First image is the featured (main) image. -
- - <.draggable_list - id="product-images" - items={@all_image_uuids} - on_reorder="reorder_images" - item_id={& &1} - cols={6} - gap="gap-3" - item_class="relative group" - > - <:item :let={image_uuid}> -- Fill in the product specifications based on global and category options. -
- -- {if @total == 1, do: "1 product", else: "#{@total} products"} -
- - - <%!-- Controls Bar --%> -| - - | -Product | -Status | -Type | -Category | -Price | -Actions | -
|---|---|---|---|---|---|---|
|
- <.icon name="hero-cube" class="w-12 h-12 mx-auto mb-3 opacity-50" />
- No products found -Create your first product to get started - |
- ||||||
| - - | -
-
- <% product_title = Translations.get(product, :title, @current_language) %>
- <% product_slug = Translations.get(product, :slug, @current_language) %>
-
-
-
-
- <%= if thumb_url = get_product_thumbnail(product) do %>
-
-
-
- {product_title}
- {product_slug}
- |
- - - {product.status} - - | -- - {product.product_type} - - | -- <%= if product.category do %> - - {Translations.get(product.category, :name, @current_language)} - - <% else %> - — - <% end %> - | -- {format_price(product.price, @currency)} - | -
-
- <.link
- navigate={Routes.path("/admin/shop/products/#{product.uuid}")}
- class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom"
- data-tip={gettext("View")}
- >
- <.icon name="hero-eye" class="h-4 w-4 hidden sm:inline" />
- {gettext("View")}
-
- <.link
- navigate={Routes.path("/admin/shop/products/#{product.uuid}/edit")}
- class="btn btn-xs btn-outline btn-secondary tooltip tooltip-bottom"
- data-tip={gettext("Edit")}
- >
- <.icon name="hero-pencil" class="h-4 w-4 hidden sm:inline" />
- {gettext("Edit")}
-
-
-
- |
-
- Update status for {MapSet.size(@selected_uuids)} selected products -
-- Move {MapSet.size(@selected_uuids)} selected products to a category -
-- Are you sure you want to delete {MapSet.size(@selected_uuids)} products? - This action cannot be undone. -
- -Are you sure you want to delete this product?
- -- Currency and tax configuration is managed in the - <.link navigate={Routes.path("/admin/settings/billing")} class="link font-medium"> - Billing module settings - -
-- Configure product filters shown on the storefront sidebar. - Customers can filter by price, vendor, and product options. -
- - <%!-- Current Filters Table --%> -| Filter | -Type | -Label | -Enabled | -- |
|---|---|---|---|---|
| {filter["key"]} | -- {filter["type"]} - | -- - | -- - | -- <%= if filter["type"] == "metadata_option" do %> - - <% end %> - | -
- These option keys were found in product metadata. Click to add as a filter. -
-- How category names should be displayed in the sidebar -
-- Show icons next to category names in sidebar -
-- {length(@methods)} methods configured -
- <:actions> - <.link navigate={Routes.path("/admin/shop/shipping/new")} class="btn btn-primary btn-sm"> - <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Method - - - - -| Method | -Price | -Constraints | -Delivery | -Status | -Actions | -
|---|---|---|---|---|---|
|
- <.icon name="hero-truck" class="w-12 h-12 mx-auto mb-3 opacity-50" />
- No shipping methods -Create your first shipping method to get started - |
- |||||
|
- {method.name}
- <%= if method.description do %>
-
- {method.description}
-
- <% end %>
- |
-
- {format_price(method.price, @currency)}
- <%= if method.free_above_amount do %>
-
- Free above {format_price(method.free_above_amount, @currency)}
-
- <% end %>
- |
-
-
- <%= if method.max_weight_grams do %>
-
- Max {format_weight(method.max_weight_grams)}
-
- <% end %>
- <%= if method.countries != [] do %>
-
- {length(method.countries)} countries
-
- <% end %>
- <%= if method.countries == [] && is_nil(method.max_weight_grams) do %>
- No limits
- <% end %>
-
- |
- - <%= if estimate = PhoenixKit.Modules.Shop.ShippingMethod.delivery_estimate(method) do %> - {estimate} - <% else %> - - - <% end %> - | -- - | -
-
- <.link
- navigate={Routes.path("/admin/shop/shipping/#{method.uuid}/edit")}
- class="btn btn-ghost btn-sm"
- >
- <.icon name="hero-pencil" class="w-4 h-4" />
-
-
-
- |
-
- Browse our collection of products across various categories -
-- <%= if FilterHelpers.has_active_filters?(@active_filters) do %> - No products match your filters. - - <% else %> - Check back soon for new arrivals - <% end %> -
-- Verify specification-based pricing (fixed and percent modifiers) and Storage image integration. -
-| Test | -Status | -Details | -
|---|---|---|
| {result.name} | -- <%= case result.status do %> - <% :ok -> %> - PASS - <% :error -> %> - FAIL - <% :skip -> %> - SKIP - <% end %> - | -- {result.details} - | -
| Product | -Base Price | -Has Options | -Has Images | -Actions | -
|---|---|---|---|---|
|
-
- {Translations.get(product, :title, default_lang)}
-
-
- {Translations.get(product, :slug, default_lang)}
-
- |
- ${Decimal.round(product.price || Decimal.new("0"), 2)} | -- <%= if price_specs != [] do %> - - {length(price_specs)} options - - <% else %> - None - <% end %> - | -- <%= if has_storage_images do %> - - <.icon name="hero-check" class="w-3 h-3" /> Storage - - <% else %> - Legacy - <% end %> - | -- - | -
- {gettext("If you have questions about your order, please contact our support team.")} -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/shop")} - class="btn btn-primary btn-block" - > - <.icon name="hero-shopping-bag" class="w-5 h-5" /> - {gettext("Continue Shopping")} - -{gettext("View your order history")}
-- <%= if @status_filter do %> - {gettext("No orders with this status. Try a different filter.")} - <% else %> - {gettext("Start shopping to see your orders here.")} - <% end %> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/shop")} - class="btn btn-primary" - > - <.icon name="hero-shopping-bag" class="h-5 w-5" /> {gettext("Browse Products")} - -- {items_count(order.line_items)} {ngettext( - "item", - "items", - items_count(order.line_items) - )} -
-- Configure storage provider settings and access credentials + {gettext("Configure storage provider settings and access credentials")}
@@ -21,15 +21,17 @@ <%!-- Basic Information --%>