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: "
#{item["description"]}
", - else: "" - - """ - - -
#{item["name"]}
- #{desc} - - #{item["quantity"]} - #{item["unit_price"]} - #{item["total"]} - - """ - end) - end - - defp format_line_items_text(nil), do: "" - - defp format_line_items_text(items) do - Enum.map_join(items, "\n", fn item -> - "#{item["name"]} x #{item["quantity"]} @ #{item["unit_price"]} = #{item["total"]}" - end) - end - - defp format_date(nil), do: "-" - defp format_date(%Date{} = date), do: Calendar.strftime(date, "%B %d, %Y") - defp format_date(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%B %d, %Y") - defp format_date(%DateTime{} = dt), do: Calendar.strftime(dt, "%B %d, %Y") - - defp format_decimal(nil), do: "0.00" - defp format_decimal(%Decimal{} = d), do: Decimal.to_string(d, :normal) - - @doc """ - Marks an invoice as paid (generates receipt). - """ - def mark_invoice_paid(%Invoice{} = invoice) do - if Invoice.payable?(invoice) do - config = get_config() - receipt_number = generate_receipt_number(config.receipt_prefix) - - result = - invoice - |> Invoice.paid_changeset(receipt_number) - |> repo().update() - - # Also mark the order as paid if linked - case result do - {:ok, paid_invoice} -> - Events.broadcast_invoice_paid(paid_invoice) - maybe_mark_linked_order_paid(paid_invoice) - {:ok, paid_invoice} - - error -> - error - end - else - {:error, :invoice_not_payable} - end - end - - @doc """ - Voids an invoice. - """ - def void_invoice(%Invoice{} = invoice, reason \\ nil) do - if Invoice.voidable?(invoice) do - changeset = Invoice.status_changeset(invoice, "void") - - changeset = - if reason do - Ecto.Changeset.put_change(changeset, :notes, reason) - else - changeset - end - - result = repo().update(changeset) - - case result do - {:ok, voided_invoice} -> - Events.broadcast_invoice_voided(voided_invoice) - {:ok, voided_invoice} - - error -> - error - end - else - {:error, :invoice_not_voidable} - end - end - - @doc """ - Generates a receipt for an invoice. - - Receipts can be generated: - - When invoice is fully paid (status: "paid") - - When invoice has any payment (paid_amount > 0) - partial receipt - - Receipt status: - - "paid" - fully paid - - "partially_paid" - partial payment received - - "refunded" - fully refunded after payment - """ - def generate_receipt(%Invoice{} = invoice) do - cond do - # Already has a receipt - not is_nil(invoice.receipt_number) -> - {:error, :receipt_already_generated} - - # No payments yet - is_nil(invoice.paid_amount) or Decimal.eq?(invoice.paid_amount, Decimal.new(0)) -> - {:error, :no_payments} - - # Has payments - generate receipt - true -> - config = get_config() - receipt_number = generate_receipt_number(config.receipt_prefix) - - invoice - |> Ecto.Changeset.change(%{ - receipt_number: receipt_number, - receipt_generated_at: UtilsDate.utc_now(), - receipt_data: build_receipt_data(invoice) - }) - |> repo().update() - end - end - - defp build_receipt_data(invoice) do - receipt_status = calculate_receipt_status(invoice) - - %{ - "invoice_number" => invoice.invoice_number, - "total" => Decimal.to_string(invoice.total), - "paid_amount" => Decimal.to_string(invoice.paid_amount || Decimal.new(0)), - "currency" => invoice.currency, - "paid_at" => if(invoice.paid_at, do: DateTime.to_iso8601(invoice.paid_at), else: nil), - "billing_details" => invoice.billing_details, - "status" => receipt_status - } - end - - @doc """ - Calculates the current receipt status based on invoice state and transactions. - """ - def calculate_receipt_status(invoice, transactions \\ nil) do - # Get transactions if not provided - transactions = transactions || list_invoice_transactions(invoice.uuid) - - total_refunded = - transactions - |> Enum.filter(&Decimal.negative?(&1.amount)) - |> Enum.map(& &1.amount) - |> Enum.reduce(Decimal.new(0), &Decimal.add/2) - |> Decimal.abs() - - paid_amount = invoice.paid_amount || Decimal.new(0) - - cond do - # Fully refunded - Decimal.gt?(total_refunded, Decimal.new(0)) and - Decimal.gte?(total_refunded, paid_amount) -> - "refunded" - - # Fully paid - invoice.status == "paid" or Decimal.gte?(paid_amount, invoice.total) -> - "paid" - - # Partially paid - Decimal.gt?(paid_amount, Decimal.new(0)) -> - "partially_paid" - - # No payment - true -> - "unpaid" - end - end - - @doc """ - Updates the receipt status based on current invoice state. - Call this after refunds to update the receipt status. - """ - def update_receipt_status(%Invoice{} = invoice) do - if invoice.receipt_number do - current_receipt_data = invoice.receipt_data || %{} - new_status = calculate_receipt_status(invoice) - - updated_receipt_data = Map.put(current_receipt_data, "status", new_status) - - invoice - |> Ecto.Changeset.change(%{receipt_data: updated_receipt_data}) - |> repo().update() - else - {:ok, invoice} - end - end - - @doc """ - Marks overdue invoices. - """ - def mark_overdue_invoices do - today = Date.utc_today() - - {count, _} = - Invoice - |> where([i], i.status == "sent" and i.due_date < ^today) - |> repo().update_all(set: [status: "overdue"]) - - {:ok, count} - end - - defp apply_invoice_filters(query, filters) do - Enum.reduce(filters, query, fn - {:status, status}, q when is_binary(status) -> - where(q, [i], i.status == ^status) - - {:statuses, statuses}, q when is_list(statuses) -> - where(q, [i], i.status in ^statuses) - - {:from_date, date}, q -> - where(q, [i], i.inserted_at >= ^date) - - {:to_date, date}, q -> - where(q, [i], i.inserted_at <= ^date) - - {:overdue, true}, q -> - today = Date.utc_today() - where(q, [i], i.status in ["sent", "overdue"] and i.due_date < ^today) - - _, q -> - q - end) - end - - # ============================================ - # NUMBER GENERATION - # ============================================ - - defp generate_order_number(prefix) do - year = Date.utc_today().year - sequence = get_next_sequence("order", year) - "#{prefix}-#{year}-#{String.pad_leading(to_string(sequence), 4, "0")}" - end - - defp generate_invoice_number(prefix) do - year = Date.utc_today().year - sequence = get_next_sequence("invoice", year) - "#{prefix}-#{year}-#{String.pad_leading(to_string(sequence), 4, "0")}" - end - - defp generate_receipt_number(prefix) do - year = Date.utc_today().year - sequence = get_next_sequence("receipt", year) - "#{prefix}-#{year}-#{String.pad_leading(to_string(sequence), 4, "0")}" - end - - defp get_next_sequence(type, year) do - # Simple approach: count existing records for the year - # For production, consider using a separate sequence table - start_of_year = Date.new!(year, 1, 1) - end_of_year = Date.new!(year, 12, 31) - - count = - case type do - "order" -> - Order - |> where([o], fragment("DATE(?)", o.inserted_at) >= ^start_of_year) - |> where([o], fragment("DATE(?)", o.inserted_at) <= ^end_of_year) - |> repo().aggregate(:count) - - "invoice" -> - Invoice - |> where([i], fragment("DATE(?)", i.inserted_at) >= ^start_of_year) - |> where([i], fragment("DATE(?)", i.inserted_at) <= ^end_of_year) - |> repo().aggregate(:count) - - "receipt" -> - Invoice - |> where([i], not is_nil(i.receipt_number)) - |> where([i], fragment("DATE(?)", i.receipt_generated_at) >= ^start_of_year) - |> where([i], fragment("DATE(?)", i.receipt_generated_at) <= ^end_of_year) - |> repo().aggregate(:count) - end - - count + 1 - end - - # ============================================ - # TRANSACTIONS - # ============================================ - - @doc """ - Lists all transactions with optional filters. - - ## Options - - - `:invoice_uuid` - Filter by invoice UUID - - `:user_uuid` - Filter by user who created the transaction - - `:payment_method` - Filter by payment method - - `:type` - Filter by type: "payment" (amount > 0) or "refund" (amount < 0) - - `:search` - Search by transaction number - - `:limit` - Limit results - - `:offset` - Offset for pagination - - `:preload` - Associations to preload - - ## Examples - - Billing.list_transactions(invoice_uuid: "some-uuid") - Billing.list_transactions(type: "payment", limit: 10) - """ - def list_transactions(opts \\ []) do - transactions = - Transaction - |> order_by([t], desc: t.inserted_at) - |> filter_transactions(opts) - |> repo().all() - - if preloads = opts[:preload] do - repo().preload(transactions, preloads) - else - transactions - end - end - - defp filter_transactions(query, opts) do - query - |> filter_transactions_by_invoice(opts) - |> filter_transactions_by_user(opts[:user_uuid]) - |> filter_transactions_by_payment_method(opts[:payment_method]) - |> filter_transactions_by_type(opts[:type]) - |> filter_transactions_by_search(opts[:search]) - |> maybe_limit(opts[:limit]) - |> maybe_offset(opts[:offset]) - end - - defp filter_transactions_by_invoice(query, opts) do - if invoice_uuid = opts[:invoice_uuid] do - where(query, [t], t.invoice_uuid == ^invoice_uuid) - else - query - end - end - - defp filter_transactions_by_user(query, nil), do: query - - defp filter_transactions_by_user(query, user_uuid) do - where(query, [t], t.user_uuid == ^user_uuid) - end - - defp filter_transactions_by_payment_method(query, nil), do: query - - defp filter_transactions_by_payment_method(query, payment_method) do - where(query, [t], t.payment_method == ^payment_method) - end - - defp filter_transactions_by_type(query, "payment"), do: where(query, [t], t.amount > 0) - defp filter_transactions_by_type(query, "refund"), do: where(query, [t], t.amount < 0) - defp filter_transactions_by_type(query, _), do: query - - defp filter_transactions_by_search(query, nil), do: query - - defp filter_transactions_by_search(query, search) do - search_term = "%#{search}%" - where(query, [t], ilike(t.transaction_number, ^search_term)) - end - - defp maybe_limit(query, nil), do: query - defp maybe_limit(query, limit), do: limit(query, ^limit) - - defp maybe_offset(query, nil), do: query - defp maybe_offset(query, offset), do: offset(query, ^offset) - - @doc """ - Lists transactions with count for pagination. - """ - def list_transactions_with_count(opts \\ []) do - transactions = list_transactions(opts) - - count_query = - Transaction - |> select([t], count(t.uuid)) - - count_query = - if invoice_uuid = opts[:invoice_uuid] do - where(count_query, [t], t.invoice_uuid == ^invoice_uuid) - else - count_query - end - - count_query = - if payment_method = opts[:payment_method] do - where(count_query, [t], t.payment_method == ^payment_method) - else - count_query - end - - count_query = - case opts[:type] do - "payment" -> where(count_query, [t], t.amount > 0) - "refund" -> where(count_query, [t], t.amount < 0) - _ -> count_query - end - - count_query = - if search = opts[:search] do - search_term = "%#{search}%" - where(count_query, [t], ilike(t.transaction_number, ^search_term)) - else - count_query - end - - count = repo().one(count_query) - - {transactions, count} - end - - @doc """ - Gets transactions for a specific invoice. - """ - def list_invoice_transactions(invoice_uuid) when is_binary(invoice_uuid) do - list_transactions(invoice_uuid: invoice_uuid, preload: [:user]) - end - - @doc """ - Gets a transaction by ID or UUID. - """ - def get_transaction(id, opts \\ []) - - def get_transaction(id, opts) when is_binary(id) do - transaction = - if UUIDUtils.valid?(id) do - repo().get_by(Transaction, uuid: id) - else - nil - end - - if transaction && opts[:preload] do - repo().preload(transaction, opts[:preload]) - else - transaction - end - end - - def get_transaction(_, _opts), do: nil - - @doc """ - Gets a transaction by ID or UUID, raises if not found. - """ - def get_transaction!(id, opts \\ []) do - case get_transaction(id, opts) do - nil -> raise Ecto.NoResultsError, queryable: Transaction - transaction -> transaction - end - end - - @doc """ - Gets a transaction by number. - """ - def get_transaction_by_number(number) do - repo().get_by(Transaction, transaction_number: number) - end - - @doc """ - Records a payment for an invoice. - - Creates a transaction with positive amount and updates invoice's paid_amount. - If paid_amount >= total, marks invoice as paid and generates receipt. - - ## Parameters - - - `invoice` - The invoice to pay - - `attrs` - Transaction attributes including :amount, :payment_method, :description - - `admin_user` - The admin user recording the payment - - ## Examples - - {:ok, transaction} = Billing.record_payment(invoice, %{amount: "100.00", payment_method: "bank"}, admin) - """ - def record_payment(%Invoice{} = invoice, attrs, admin_user) do - amount = parse_decimal(attrs[:amount] || attrs["amount"]) - - if Decimal.compare(amount, Decimal.new(0)) != :gt do - {:error, :invalid_amount} - else - do_record_transaction(invoice, amount, attrs, admin_user) - end - end - - @doc """ - Records a refund for an invoice. - - Creates a transaction with negative amount and updates invoice's paid_amount. - - ## Parameters - - - `invoice` - The invoice to refund - - `attrs` - Transaction attributes including :amount (positive value), :description (reason) - - `admin_user` - The admin user recording the refund - - ## Examples - - {:ok, transaction} = Billing.record_refund(invoice, %{amount: "50.00", description: "Partial refund"}, admin) - """ - def record_refund(%Invoice{} = invoice, attrs, admin_user) do - amount = parse_decimal(attrs[:amount] || attrs["amount"]) - max_refund = invoice.paid_amount - - cond do - Decimal.compare(amount, Decimal.new(0)) != :gt -> - {:error, :invalid_amount} - - Decimal.compare(amount, max_refund) == :gt -> - {:error, :exceeds_paid_amount} - - true -> - # Convert to negative for refund - negative_amount = Decimal.negate(amount) - do_record_transaction(invoice, negative_amount, attrs, admin_user) - end - end - - defp do_record_transaction(invoice, amount, attrs, admin_user) do - transaction_number = generate_transaction_number() - - transaction_attrs = %{ - transaction_number: transaction_number, - amount: amount, - currency: invoice.currency, - payment_method: attrs[:payment_method] || attrs["payment_method"] || "bank", - description: attrs[:description] || attrs["description"], - invoice_uuid: invoice.uuid, - user_uuid: extract_user_uuid(admin_user) - } - - repo().transaction(fn -> - # Create transaction - case %Transaction{} |> Transaction.changeset(transaction_attrs) |> repo().insert() do - {:ok, transaction} -> - # Update invoice paid_amount - new_paid_amount = calculate_invoice_paid_amount(invoice.uuid) - - invoice - |> Invoice.paid_amount_changeset(new_paid_amount) - |> repo().update!() - - # Check if fully paid and update status - updated_invoice = get_invoice!(invoice.uuid) - - if Invoice.fully_paid?(updated_invoice) && updated_invoice.status in ["sent", "overdue"] do - config = get_config() - receipt_number = generate_receipt_number(config.receipt_prefix) - - updated_invoice - |> Invoice.paid_changeset(receipt_number) - |> repo().update!() - - # Mark linked order as paid if applicable - maybe_mark_linked_order_paid(updated_invoice) - end - - # Handle refund: update receipt status and check for full refund - if Decimal.negative?(amount) do - handle_refund_transaction(invoice.uuid) - Events.broadcast_transaction_refunded(transaction) - else - Events.broadcast_transaction_created(transaction) - end - - transaction - - {:error, changeset} -> - repo().rollback(changeset) - end - end) - end - - @doc """ - Calculates the total paid amount for an invoice from all transactions. - """ - def calculate_invoice_paid_amount(invoice_uuid) when is_binary(invoice_uuid) do - Transaction - |> where([t], t.invoice_uuid == ^invoice_uuid) - |> select([t], sum(t.amount)) - |> repo().one() - |> case do - nil -> Decimal.new(0) - amount -> amount - end - end - - def calculate_invoice_paid_amount(_), do: Decimal.new(0) - - @doc """ - Updates an invoice's paid_amount based on its transactions. - """ - def update_invoice_paid_amount(%Invoice{} = invoice) do - new_paid_amount = calculate_invoice_paid_amount(invoice.uuid) - - invoice - |> Invoice.paid_amount_changeset(new_paid_amount) - |> repo().update() - end - - @doc """ - Gets the remaining amount for an invoice. - """ - def get_invoice_remaining_amount(%Invoice{} = invoice) do - Invoice.remaining_amount(invoice) - end - - @doc """ - Generates a unique transaction number. - """ - def generate_transaction_number do - prefix = Settings.get_setting("billing_transaction_prefix", "TXN") - year = Date.utc_today().year - count = count_transactions_this_year() - "#{prefix}-#{year}-#{String.pad_leading(Integer.to_string(count), 4, "0")}" - end - - defp count_transactions_this_year do - year = Date.utc_today().year - start_of_year = Date.new!(year, 1, 1) - end_of_year = Date.new!(year, 12, 31) - - count = - Transaction - |> where([t], fragment("DATE(?)", t.inserted_at) >= ^start_of_year) - |> where([t], fragment("DATE(?)", t.inserted_at) <= ^end_of_year) - |> repo().aggregate(:count) - - count + 1 - end - - defp parse_decimal(value) when is_binary(value) do - case Decimal.parse(value) do - {decimal, _} -> decimal - :error -> Decimal.new(0) - end - end - - defp parse_decimal(%Decimal{} = value), do: value - defp parse_decimal(value) when is_integer(value), do: Decimal.new(value) - defp parse_decimal(value) when is_float(value), do: Decimal.from_float(value) - defp parse_decimal(_), do: Decimal.new(0) - - # ============================================ - # SUBSCRIPTIONS - # ============================================ - - alias PhoenixKit.Modules.Billing.{PaymentMethod, Subscription, SubscriptionType} - - @doc """ - Lists all subscriptions for a user. - - ## Options - - - `:status` - Filter by status (e.g., "active", "cancelled") - - `:preload` - Associations to preload (default: [:subscription_type]) - - ## Examples - - Billing.list_subscriptions(user_uuid) - Billing.list_subscriptions(user_uuid, status: "active") - """ - def list_subscriptions(opts \\ []) - - def list_subscriptions(opts) when is_list(opts) do - status = Keyword.get(opts, :status) - search = Keyword.get(opts, :search) - preloads = Keyword.get(opts, :preload, [:subscription_type]) - - query = - from(s in Subscription, - order_by: [desc: s.inserted_at] - ) - - query = - if status do - from(s in query, where: s.status == ^status) - else - query - end - - query = - if search && search != "" do - search_term = "%#{search}%" - from(s in query, join: u in assoc(s, :user), where: ilike(u.email, ^search_term)) - else - query - end - - query - |> repo().all() - |> repo().preload(preloads) - end - - @doc """ - Lists all subscriptions for a specific user. - - ## Options - * `:status` - filter by status (e.g., "active", "cancelled") - * `:preload` - list of associations to preload (default: [:subscription_type]) - - ## Examples - - Billing.list_user_subscriptions(user.uuid) - Billing.list_user_subscriptions(user.uuid, status: "active") - """ - def list_user_subscriptions(user_uuid, opts \\ []) do - status = Keyword.get(opts, :status) - preloads = Keyword.get(opts, :preload, [:subscription_type]) - - query = - from(s in Subscription, - where: s.user_uuid == ^user_uuid, - order_by: [desc: s.inserted_at] - ) - - query = - if status do - from(s in query, where: s.status == ^status) - else - query - end - - query - |> repo().all() - |> repo().preload(preloads) - end - - @doc """ - Gets a subscription by ID or UUID. - - ## Options - * `:preload` - list of associations to preload (default: []) - """ - def get_subscription(id, opts \\ []) - - def get_subscription(id, opts) when is_binary(id) do - preloads = Keyword.get(opts, :preload, []) - - subscription = - if UUIDUtils.valid?(id) do - repo().get_by(Subscription, uuid: id) - else - nil - end - - if subscription, do: repo().preload(subscription, preloads), else: nil - end - - def get_subscription(_, _opts), do: nil - - @doc """ - Gets a subscription by ID or UUID, raises if not found. - """ - def get_subscription!(id) do - case get_subscription(id) do - nil -> raise Ecto.NoResultsError, queryable: Subscription - subscription -> subscription - end - end - - @doc """ - Creates a new subscription for a user. - - This creates the master subscription record. The first payment should be - processed separately via checkout session. - - ## Parameters - - - `user_uuid` - The user creating the subscription (UUID) - - `attrs` - Subscription attributes: - - `:subscription_type_uuid` - Required: subscription type UUID - - `:billing_profile_uuid` - Optional: billing profile UUID to use - - `:payment_method_uuid` - Optional: saved payment method UUID for renewals - - `:trial_days` - Optional: override type's trial days - - `:plan_uuid` - Alternative: can use `:plan_uuid` instead of `:subscription_type_uuid` - - ## Examples - - Billing.create_subscription(user.uuid, %{subscription_type_uuid: type.uuid}) - Billing.create_subscription(user.uuid, %{subscription_type_uuid: type.uuid, trial_days: 14}) - - # Using plan_uuid parameter - Billing.create_subscription(user.uuid, %{plan_uuid: type.uuid}) - """ - def create_subscription(user_uuid, attrs) do - type_uuid = - attrs[:subscription_type_uuid] || attrs["subscription_type_uuid"] || - attrs[:plan_uuid] || attrs["plan_uuid"] - - with {:ok, type} <- get_subscription_type(type_uuid) do - trial_days = attrs[:trial_days] || type.trial_days || 0 - now = UtilsDate.utc_now() - - {status, trial_end, period_start, period_end} = - if trial_days > 0 do - trial_end = DateTime.add(now, trial_days, :day) - period_end = SubscriptionType.next_billing_date(type, DateTime.to_date(trial_end)) - {"trialing", trial_end, now, datetime_from_date(period_end)} - else - period_end = SubscriptionType.next_billing_date(type, Date.utc_today()) - {"active", nil, now, datetime_from_date(period_end)} - end - - billing_profile_uuid = attrs[:billing_profile_uuid] - payment_method_uuid = attrs[:payment_method_uuid] - - subscription_attrs = %{ - user_uuid: user_uuid, - subscription_type_uuid: type.uuid, - billing_profile_uuid: billing_profile_uuid, - payment_method_uuid: payment_method_uuid, - status: status, - current_period_start: period_start, - current_period_end: period_end, - trial_start: if(trial_days > 0, do: now), - trial_end: trial_end - } - - result = - %Subscription{} - |> Subscription.changeset(subscription_attrs) - |> repo().insert() - - case result do - {:ok, subscription} -> - Events.broadcast_subscription_created(subscription) - {:ok, subscription} - - error -> - error - end - end - end - - @doc """ - Cancels a subscription. - - ## Options - - - `immediately: true` - Cancel immediately instead of at period end - - ## Examples - - Billing.cancel_subscription(subscription) - Billing.cancel_subscription(subscription, immediately: true) - """ - def cancel_subscription(%Subscription{} = subscription, opts \\ []) do - immediately = Keyword.get(opts, :immediately, false) - - result = - subscription - |> Subscription.cancel_changeset(immediately) - |> repo().update() - - case result do - {:ok, cancelled_subscription} -> - Events.broadcast_subscription_cancelled(cancelled_subscription) - {:ok, cancelled_subscription} - - error -> - error - end - end - - @doc """ - Pauses a subscription. - - Paused subscriptions don't renew until resumed. - """ - def pause_subscription(%Subscription{} = subscription) do - subscription - |> Subscription.pause_changeset() - |> repo().update() - end - - @doc """ - Resumes a paused subscription. - """ - def resume_subscription(%Subscription{} = subscription) do - subscription - |> Subscription.resume_changeset() - |> repo().update() - end - - @doc """ - Changes a subscription's type. - - By default, the new type takes effect at the next billing cycle. - """ - def change_subscription_type(%Subscription{} = subscription, new_type_uuid, _opts \\ []) do - old_type_uuid = subscription.subscription_type_uuid - - type_uuid = resolve_subscription_type_uuid(new_type_uuid) - - result = - subscription - |> Ecto.Changeset.change(%{subscription_type_uuid: type_uuid}) - |> repo().update() - - case result do - {:ok, updated_subscription} -> - Events.broadcast_subscription_type_changed( - updated_subscription, - old_type_uuid, - new_type_uuid - ) - - {:ok, updated_subscription} - - error -> - error - end - end - - # ============================================ - # SUBSCRIPTION TYPES - # ============================================ - - @doc """ - Lists all subscription types. - - ## Options - - - `:active_only` - Only return active types (default: true) - """ - def list_subscription_types(opts \\ []) do - active_only = Keyword.get(opts, :active_only, true) - - query = - from(t in SubscriptionType, - order_by: [asc: t.sort_order, asc: t.name] - ) - - query = - if active_only do - from(t in query, where: t.active == true) - else - query - end - - repo().all(query) - end - - @doc """ - Gets a subscription type by ID or UUID. - """ - def get_subscription_type(id) when is_binary(id) do - type = - if UUIDUtils.valid?(id) do - repo().get_by(SubscriptionType, uuid: id) - else - nil - end - - case type do - nil -> {:error, :subscription_type_not_found} - type -> {:ok, type} - end - end - - def get_subscription_type(_), do: {:error, :subscription_type_not_found} - - @doc """ - Gets a subscription type by slug. - """ - def get_subscription_type_by_slug(slug) do - case repo().get_by(SubscriptionType, slug: slug) do - nil -> {:error, :subscription_type_not_found} - type -> {:ok, type} - end - end - - @doc """ - Creates a subscription type. - """ - def create_subscription_type(attrs) do - %SubscriptionType{} - |> SubscriptionType.changeset(attrs) - |> repo().insert() - end - - @doc """ - Updates a subscription type. - """ - def update_subscription_type(%SubscriptionType{} = type, attrs) do - type - |> SubscriptionType.changeset(attrs) - |> repo().update() - end - - @doc """ - Deletes a subscription type. - - Types with active subscriptions cannot be deleted. - """ - def delete_subscription_type(%SubscriptionType{} = type) do - active_count = - from(s in Subscription, - where: - s.subscription_type_uuid == ^type.uuid and - s.status in ["active", "trialing", "past_due"], - select: count(s.uuid) - ) - |> repo().one() - - if active_count > 0 do - {:error, :has_active_subscriptions} - else - repo().delete(type) - end - end - - # ============================================ - # PAYMENT METHODS - # ============================================ - - @doc """ - Returns list of available payment methods for manual recording. - Bank transfer is always available, plus any enabled providers (Stripe/PayPal/Razorpay). - - ## Examples - - iex> Billing.available_payment_methods() - ["bank"] # Only bank if no providers enabled - - iex> Billing.available_payment_methods() - ["bank", "stripe", "paypal"] # Bank + enabled providers - """ - def available_payment_methods do - providers = Providers.list_available_providers() - provider_names = Enum.map(providers, &Atom.to_string/1) - ["bank" | provider_names] |> Enum.uniq() - end - - @doc """ - Lists saved payment methods for a user. - """ - def list_payment_methods(user_uuid, opts \\ []) do - active_only = Keyword.get(opts, :active_only, true) - - query = - from(pm in PaymentMethod, - where: pm.user_uuid == ^user_uuid, - order_by: [desc: pm.is_default, desc: pm.inserted_at] - ) - - query = - if active_only do - from(pm in query, where: pm.status == "active") - else - query - end - - repo().all(query) - end - - @doc """ - Gets a payment method by ID or UUID. - """ - def get_payment_method(id) when is_binary(id) do - if UUIDUtils.valid?(id) do - repo().get_by(PaymentMethod, uuid: id) - else - nil - end - end - - def get_payment_method(_), do: nil - - @doc """ - Gets the default payment method for a user. - """ - def get_default_payment_method(user_uuid) do - from(pm in PaymentMethod, - where: pm.user_uuid == ^user_uuid and pm.is_default == true and pm.status == "active", - limit: 1 - ) - |> repo().one() - end - - @doc """ - Creates a payment method record. - - Usually called after a successful setup session webhook. - """ - def create_payment_method(attrs) do - %PaymentMethod{} - |> PaymentMethod.changeset(attrs) - |> repo().insert() - end - - @doc """ - Sets a payment method as the default for a user. - - Unsets any existing default. - """ - def set_default_payment_method(%PaymentMethod{} = payment_method) do - repo().transaction(fn -> - # Unset current default - from(pm in PaymentMethod, - where: pm.user_uuid == ^payment_method.user_uuid and pm.is_default == true - ) - |> repo().update_all(set: [is_default: false]) - - # Set new default - payment_method - |> PaymentMethod.set_default_changeset() - |> repo().update!() - end) - end - - @doc """ - Removes a payment method. - - Marks as removed in database. Should also delete from provider. - """ - def remove_payment_method(%PaymentMethod{} = payment_method) do - payment_method - |> PaymentMethod.remove_changeset() - |> repo().update() - end - - # ============================================ - # CHECKOUT SESSIONS - # ============================================ - - @doc """ - Creates a checkout session for paying an invoice. - - Returns the checkout URL to redirect the user to. - - ## Parameters - - - `invoice` - The invoice to pay - - `provider` - Payment provider atom (:stripe, :paypal, :razorpay) - - `opts` - Options: - - `:success_url` - URL to redirect after success - - `:cancel_url` - URL to redirect if cancelled - - ## Examples - - {:ok, url} = Billing.create_checkout_session(invoice, :stripe, success_url: "/success") - """ - def create_checkout_session(%Invoice{} = invoice, provider, opts \\ []) do - success_url = Keyword.fetch!(opts, :success_url) - cancel_url = Keyword.get(opts, :cancel_url, success_url) - - amount_cents = Decimal.to_integer(Decimal.mult(invoice.total, 100)) - - session_opts = %{ - amount: amount_cents, - currency: invoice.currency, - description: "Invoice #{invoice.invoice_number}", - success_url: success_url, - cancel_url: cancel_url, - metadata: %{ - invoice_uuid: invoice.uuid, - invoice_number: invoice.invoice_number - } - } - - case Providers.create_checkout_session(provider, session_opts) do - {:ok, session} -> - # Update invoice with checkout session info - invoice - |> Ecto.Changeset.change(%{ - checkout_session_id: session.id, - checkout_url: session.url - }) - |> repo().update() - - {:ok, session.url} - - {:error, reason} -> - {:error, reason} - end - end - - @doc """ - Creates a setup session for saving a payment method. - - Returns the setup URL to redirect the user to. - - ## Parameters - - - `user_uuid` - The user saving the payment method - - `provider` - Payment provider atom - - `opts` - Options (success_url required) - """ - def create_setup_session(user_uuid, provider, opts \\ []) do - success_url = Keyword.fetch!(opts, :success_url) - cancel_url = Keyword.get(opts, :cancel_url, success_url) - - session_opts = %{ - uuid: user_uuid, - success_url: success_url, - cancel_url: cancel_url - } - - Providers.create_setup_session(provider, session_opts) - end - - defp datetime_from_date(date) do - DateTime.new!(date, ~T[00:00:00], "Etc/UTC") - end - - # ============================================ - # HELPERS - # ============================================ - - defp extract_user_uuid(%{user: %{uuid: uuid}}), do: uuid - defp extract_user_uuid(%{uuid: uuid}) when is_binary(uuid), do: uuid - defp extract_user_uuid(uuid) when is_binary(uuid), do: uuid - defp extract_user_uuid(_), do: nil - - # Resolves subscription type UUID from various input types - defp resolve_subscription_type_uuid(id) when is_binary(id) do - case Ecto.UUID.cast(id) do - {:ok, _} -> id - :error -> nil - end - end - - defp resolve_subscription_type_uuid(_), do: nil - - defp maybe_mark_linked_order_paid(%{order_uuid: nil}), do: :ok - - defp maybe_mark_linked_order_paid(%{order_uuid: order_uuid} = invoice) do - # Get the primary payment method from the invoice's transactions - invoice_with_txns = repo().preload(invoice, :transactions) - payment_method = Invoice.primary_payment_method(invoice_with_txns) - - case get_order!(order_uuid) do - %Order{status: "confirmed"} = order -> - mark_order_paid(order, payment_method: payment_method) - - %Order{status: "draft"} = order -> - # Auto-confirm draft order, then mark as paid - with {:ok, confirmed_order} <- confirm_order(order) do - mark_order_paid(confirmed_order, payment_method: payment_method) - end - - %Order{status: "pending"} = order -> - # Auto-confirm pending order, then mark as paid - with {:ok, confirmed_order} <- confirm_order(order) do - mark_order_paid(confirmed_order, payment_method: payment_method) - end - - _ -> - :ok - end - end - - defp maybe_mark_linked_order_refunded(%{order_uuid: nil}), do: :ok - - defp maybe_mark_linked_order_refunded(%{order_uuid: order_uuid}) do - case get_order!(order_uuid) do - %Order{status: "paid"} = order -> - mark_order_refunded(order) - - _ -> - :ok - end - end - - defp handle_refund_transaction(invoice_uuid) do - invoice = get_invoice!(invoice_uuid) - update_receipt_status(invoice) - - # If fully refunded (paid_amount = 0), mark invoice as void and order as refunded - if Decimal.eq?(invoice.paid_amount, Decimal.new(0)) do - invoice - |> Invoice.status_changeset("void") - |> repo().update!() - - maybe_mark_linked_order_refunded(invoice) - end - end - - defp get_bank_details do - bank = CountryData.get_bank_details() - - %{ - bank_name: bank["bank_name"] || "", - iban: bank["iban"] || "", - swift: bank["swift"] || "", - account_holder: Settings.get_setting("billing_bank_account_holder", "") - } - end - - defp get_payment_terms do - Settings.get_setting("billing_payment_terms", "Payment due within 14 days of invoice date.") - end - - # Returns company details for email templates using consolidated Settings - defp get_company_details do - company = CountryData.get_company_info() - - %{ - name: company["name"] || "", - address: CountryData.format_company_address(), - vat: company["vat_number"] || "" - } - end - - # ============================================ - # PAYMENT OPTIONS - # ============================================ - - @doc """ - Lists all payment options. - """ - def list_payment_options do - PaymentOption - |> order_by([p], [p.position, p.name]) - |> repo().all() - end - - @doc """ - Lists active payment options for checkout. - """ - def list_active_payment_options do - PaymentOption - |> where([p], p.active == true) - |> order_by([p], [p.position, p.name]) - |> repo().all() - end - - @doc """ - Gets a payment option by ID. - """ - def get_payment_option(uuid) when is_binary(uuid) do - repo().get_by(PaymentOption, uuid: uuid) - end - - @doc """ - Gets a payment option by code. - """ - def get_payment_option_by_code(code) when is_binary(code) do - PaymentOption - |> where([p], p.code == ^code) - |> repo().one() - end - - @doc """ - Creates a new payment option. - """ - def create_payment_option(attrs) do - %PaymentOption{} - |> PaymentOption.changeset(attrs) - |> repo().insert() - end - - @doc """ - Updates a payment option. - """ - def update_payment_option(%PaymentOption{} = payment_option, attrs) do - payment_option - |> PaymentOption.changeset(attrs) - |> repo().update() - end - - @doc """ - Deletes a payment option. - """ - def delete_payment_option(%PaymentOption{} = payment_option) do - repo().delete(payment_option) - end - - @doc """ - Toggles the active status of a payment option. - """ - def toggle_payment_option_active(%PaymentOption{} = payment_option) do - update_payment_option(payment_option, %{active: !payment_option.active}) - end - - @doc """ - Checks if a payment option requires a billing profile. - """ - def payment_option_requires_billing?(%PaymentOption{requires_billing_profile: true}), do: true - def payment_option_requires_billing?(_), do: false - - @doc """ - Returns a changeset for tracking payment option changes. - """ - def change_payment_option(%PaymentOption{} = payment_option, attrs \\ %{}) do - PaymentOption.changeset(payment_option, attrs) - end - - defp repo, do: PhoenixKit.RepoHelper.repo() -end diff --git a/lib/modules/billing/events.ex b/lib/modules/billing/events.ex deleted file mode 100644 index 5163375be..000000000 --- a/lib/modules/billing/events.ex +++ /dev/null @@ -1,342 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Events do - @moduledoc """ - PubSub events for PhoenixKit Billing system. - - Broadcasts billing-related events for real-time updates in LiveViews. - Uses `PhoenixKit.PubSub.Manager` for self-contained PubSub operations. - - ## Topics - - - `phoenix_kit:billing:orders` - Order events (created, updated, confirmed, paid, cancelled) - - `phoenix_kit:billing:invoices` - Invoice events (created, sent, paid, voided) - - `phoenix_kit:billing:profiles` - Billing profile events (created, updated, deleted) - - `phoenix_kit:billing:transactions` - Transaction events (created, refunded) - - `phoenix_kit:billing:credit_notes` - Credit note events (sent, applied) - - ## Usage Examples - - # Subscribe to order events - PhoenixKit.Modules.Billing.Events.subscribe_orders() - - # Handle in LiveView - def handle_info({:order_created, order}, socket) do - # Update UI - {:noreply, socket} - end - - # Broadcast order created - PhoenixKit.Modules.Billing.Events.broadcast_order_created(order) - """ - - alias PhoenixKit.PubSub.Manager - - @orders_topic "phoenix_kit:billing:orders" - @invoices_topic "phoenix_kit:billing:invoices" - @profiles_topic "phoenix_kit:billing:profiles" - @transactions_topic "phoenix_kit:billing:transactions" - @credit_notes_topic "phoenix_kit:billing:credit_notes" - @subscriptions_topic "phoenix_kit:billing:subscriptions" - - # ============================================ - # SUBSCRIPTIONS - # ============================================ - - @doc """ - Subscribes to order events. - """ - def subscribe_orders do - Manager.subscribe(@orders_topic) - end - - @doc """ - Subscribes to invoice events. - """ - def subscribe_invoices do - Manager.subscribe(@invoices_topic) - end - - @doc """ - Subscribes to billing profile events. - """ - def subscribe_profiles do - Manager.subscribe(@profiles_topic) - end - - @doc """ - Subscribes to transaction events. - """ - def subscribe_transactions do - Manager.subscribe(@transactions_topic) - end - - @doc """ - Subscribes to credit note events. - """ - def subscribe_credit_notes do - Manager.subscribe(@credit_notes_topic) - end - - @doc """ - Subscribes to subscription events. - """ - def subscribe_subscriptions do - Manager.subscribe(@subscriptions_topic) - end - - @doc """ - Subscribes to subscription events for a specific user. - """ - def subscribe_user_subscriptions(user_uuid) do - Manager.subscribe("#{@subscriptions_topic}:user:#{user_uuid}") - end - - @doc """ - Subscribes to order events for a specific user. - """ - def subscribe_user_orders(user_uuid) do - Manager.subscribe("#{@orders_topic}:user:#{user_uuid}") - end - - @doc """ - Subscribes to invoice events for a specific user. - """ - def subscribe_user_invoices(user_uuid) do - Manager.subscribe("#{@invoices_topic}:user:#{user_uuid}") - end - - @doc """ - Subscribes to transaction events for a specific user. - """ - def subscribe_user_transactions(user_uuid) do - Manager.subscribe("#{@transactions_topic}:user:#{user_uuid}") - end - - # ============================================ - # ORDER BROADCASTS - # ============================================ - - @doc """ - Broadcasts order created event. - """ - def broadcast_order_created(order) do - broadcast(@orders_topic, {:order_created, order}) - broadcast("#{@orders_topic}:user:#{order.user_uuid}", {:order_created, order}) - end - - @doc """ - Broadcasts order updated event. - """ - def broadcast_order_updated(order) do - broadcast(@orders_topic, {:order_updated, order}) - broadcast("#{@orders_topic}:user:#{order.user_uuid}", {:order_updated, order}) - end - - @doc """ - Broadcasts order confirmed event. - """ - def broadcast_order_confirmed(order) do - broadcast(@orders_topic, {:order_confirmed, order}) - broadcast("#{@orders_topic}:user:#{order.user_uuid}", {:order_confirmed, order}) - end - - @doc """ - Broadcasts order paid event. - """ - def broadcast_order_paid(order) do - broadcast(@orders_topic, {:order_paid, order}) - broadcast("#{@orders_topic}:user:#{order.user_uuid}", {:order_paid, order}) - end - - @doc """ - Broadcasts order cancelled event. - """ - def broadcast_order_cancelled(order) do - broadcast(@orders_topic, {:order_cancelled, order}) - broadcast("#{@orders_topic}:user:#{order.user_uuid}", {:order_cancelled, order}) - end - - # ============================================ - # INVOICE BROADCASTS - # ============================================ - - @doc """ - Broadcasts invoice created event. - """ - def broadcast_invoice_created(invoice) do - broadcast(@invoices_topic, {:invoice_created, invoice}) - broadcast("#{@invoices_topic}:user:#{invoice.user_uuid}", {:invoice_created, invoice}) - end - - @doc """ - Broadcasts invoice sent event. - """ - def broadcast_invoice_sent(invoice) do - broadcast(@invoices_topic, {:invoice_sent, invoice}) - broadcast("#{@invoices_topic}:user:#{invoice.user_uuid}", {:invoice_sent, invoice}) - end - - @doc """ - Broadcasts invoice paid event. - """ - def broadcast_invoice_paid(invoice) do - broadcast(@invoices_topic, {:invoice_paid, invoice}) - broadcast("#{@invoices_topic}:user:#{invoice.user_uuid}", {:invoice_paid, invoice}) - end - - @doc """ - Broadcasts invoice voided event. - """ - def broadcast_invoice_voided(invoice) do - broadcast(@invoices_topic, {:invoice_voided, invoice}) - broadcast("#{@invoices_topic}:user:#{invoice.user_uuid}", {:invoice_voided, invoice}) - end - - # ============================================ - # BILLING PROFILE BROADCASTS - # ============================================ - - @doc """ - Broadcasts billing profile created event. - """ - def broadcast_profile_created(profile) do - broadcast(@profiles_topic, {:profile_created, profile}) - end - - @doc """ - Broadcasts billing profile updated event. - """ - def broadcast_profile_updated(profile) do - broadcast(@profiles_topic, {:profile_updated, profile}) - end - - @doc """ - Broadcasts billing profile deleted event. - """ - def broadcast_profile_deleted(profile) do - broadcast(@profiles_topic, {:profile_deleted, profile}) - end - - # ============================================ - # TRANSACTION BROADCASTS - # ============================================ - - @doc """ - Broadcasts transaction created event. - """ - def broadcast_transaction_created(transaction) do - broadcast(@transactions_topic, {:transaction_created, transaction}) - - broadcast( - "#{@transactions_topic}:user:#{transaction.user_uuid}", - {:transaction_created, transaction} - ) - end - - @doc """ - Broadcasts transaction refunded event. - """ - def broadcast_transaction_refunded(transaction) do - broadcast(@transactions_topic, {:transaction_refunded, transaction}) - - broadcast( - "#{@transactions_topic}:user:#{transaction.user_uuid}", - {:transaction_refunded, transaction} - ) - end - - # ============================================ - # CREDIT NOTE BROADCASTS - # ============================================ - - @doc """ - Broadcasts credit note sent event. - """ - def broadcast_credit_note_sent(invoice, transaction) do - broadcast(@credit_notes_topic, {:credit_note_sent, invoice, transaction}) - end - - @doc """ - Broadcasts credit note applied event. - """ - def broadcast_credit_note_applied(invoice, transaction, amount) do - broadcast(@credit_notes_topic, {:credit_note_applied, invoice, transaction, amount}) - end - - # ============================================ - # SUBSCRIPTION BROADCASTS - # ============================================ - - @doc """ - Broadcasts subscription created event. - """ - def broadcast_subscription_created(subscription) do - broadcast(@subscriptions_topic, {:subscription_created, subscription}) - - broadcast( - "#{@subscriptions_topic}:user:#{subscription.user_uuid}", - {:subscription_created, subscription} - ) - end - - @doc """ - Broadcasts subscription cancelled event. - """ - def broadcast_subscription_cancelled(subscription) do - broadcast(@subscriptions_topic, {:subscription_cancelled, subscription}) - - broadcast( - "#{@subscriptions_topic}:user:#{subscription.user_uuid}", - {:subscription_cancelled, subscription} - ) - end - - @doc """ - Broadcasts subscription renewed event. - """ - def broadcast_subscription_renewed(subscription) do - broadcast(@subscriptions_topic, {:subscription_renewed, subscription}) - - broadcast( - "#{@subscriptions_topic}:user:#{subscription.user_uuid}", - {:subscription_renewed, subscription} - ) - end - - @doc """ - Broadcasts subscription type changed event. - """ - def broadcast_subscription_type_changed(subscription, old_type_uuid, new_type_uuid) do - broadcast( - @subscriptions_topic, - {:subscription_type_changed, subscription, old_type_uuid, new_type_uuid} - ) - - broadcast( - "#{@subscriptions_topic}:user:#{subscription.user_uuid}", - {:subscription_type_changed, subscription, old_type_uuid, new_type_uuid} - ) - end - - @doc """ - Broadcasts subscription status changed event. - """ - def broadcast_subscription_status_changed(subscription, old_status, new_status) do - broadcast( - @subscriptions_topic, - {:subscription_status_changed, subscription, old_status, new_status} - ) - - broadcast( - "#{@subscriptions_topic}:user:#{subscription.user_uuid}", - {:subscription_status_changed, subscription, old_status, new_status} - ) - end - - # ============================================ - # HELPERS - # ============================================ - - defp broadcast(topic, message) do - Manager.broadcast(topic, message) - end -end diff --git a/lib/modules/billing/providers/paypal.ex b/lib/modules/billing/providers/paypal.ex deleted file mode 100644 index c8e2f3da9..000000000 --- a/lib/modules/billing/providers/paypal.ex +++ /dev/null @@ -1,611 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers.PayPal do - @moduledoc """ - PayPal payment provider implementation. - - Uses PayPal REST API v2 for: - - Checkout sessions (Orders API) - - Saved payment methods (Vault API) - - Refunds - - ## Configuration - - Required settings in database: - - `billing_paypal_enabled` - "true" to enable - - `billing_paypal_client_id` - PayPal Client ID - - `billing_paypal_client_secret` - PayPal Client Secret - - `billing_paypal_mode` - "sandbox" or "live" - - `billing_paypal_webhook_id` - Webhook ID for signature verification - - ## PayPal API Flow - - 1. Get OAuth2 access token (cached) - 2. Create Order with intent: "CAPTURE" - 3. Redirect user to PayPal approval URL - 4. User approves payment on PayPal - 5. PayPal redirects to success_url with token - 6. Capture payment via webhook or on return - - ## Webhook Events - - - `CHECKOUT.ORDER.APPROVED` - User approved the payment - - `PAYMENT.CAPTURE.COMPLETED` - Payment captured successfully - - `PAYMENT.CAPTURE.DENIED` - Payment capture failed - - `PAYMENT.CAPTURE.REFUNDED` - Refund completed - """ - - @behaviour PhoenixKit.Modules.Billing.Providers.Provider - - alias PhoenixKit.Modules.Billing.Providers.Types.{ - ChargeResult, - CheckoutSession, - RefundResult, - SetupSession, - WebhookEventData - } - - alias PhoenixKit.Settings - - require Logger - - @sandbox_url "https://api-m.sandbox.paypal.com" - @live_url "https://api-m.paypal.com" - - # ============================================ - # Provider Behaviour Implementation - # ============================================ - - @impl true - def provider_name, do: :paypal - - @impl true - def available? do - Settings.get_setting("billing_paypal_enabled", "false") == "true" && - has_credentials?() - end - - @impl true - def create_checkout_session(invoice, opts) do - # Merge invoice data with opts - merged_opts = Keyword.merge(opts, invoice_to_opts(invoice)) - - with {:ok, token} <- get_access_token(), - {:ok, order} <- create_order(token, merged_opts) do - # Find the approval URL - approve_link = - order["links"] - |> Enum.find(fn link -> link["rel"] == "approve" end) - - {:ok, - %CheckoutSession{ - id: order["id"], - url: approve_link["href"], - provider: :paypal, - expires_at: nil - }} - end - end - - @impl true - def create_setup_session(user, opts) do - # Add user_id to opts - merged_opts = - Keyword.put(opts, :user_uuid, user[:uuid] || user["uuid"] || user[:id] || user["id"]) - - with {:ok, token} <- get_access_token(), - {:ok, setup_token} <- create_setup_token(token, merged_opts) do - # Find the approval URL - approve_link = - setup_token["links"] - |> Enum.find(fn link -> link["rel"] == "approve" end) - - {:ok, - %SetupSession{ - id: setup_token["id"], - url: approve_link["href"], - provider: :paypal - }} - end - end - - @impl true - def charge_payment_method(payment_method, amount, opts) do - with {:ok, token} <- get_access_token(), - {:ok, order} <- create_order_with_vault(token, payment_method, amount, opts), - {:ok, capture} <- capture_order(token, order["id"]) do - {:ok, - %ChargeResult{ - id: capture["id"], - status: capture["status"], - amount: amount - }} - end - end - - @impl true - def verify_webhook_signature(payload, signature, _secret) do - # PayPal requires verifying via API call - with {:ok, token} <- get_access_token() do - verify_webhook_via_api(token, payload, signature) - end - end - - @impl true - def handle_webhook_event(payload) do - event_type = payload["event_type"] - resource = payload["resource"] - - case event_type do - "CHECKOUT.ORDER.APPROVED" -> - handle_order_approved(resource, payload) - - "PAYMENT.CAPTURE.COMPLETED" -> - handle_capture_completed(resource, payload) - - "PAYMENT.CAPTURE.DENIED" -> - handle_capture_denied(resource, payload) - - "PAYMENT.CAPTURE.REFUNDED" -> - handle_capture_refunded(resource, payload) - - _ -> - {:error, :unknown_event} - end - end - - @impl true - def create_refund(provider_transaction_id, amount, opts) do - with {:ok, token} <- get_access_token(), - {:ok, refund} <- do_create_refund(token, provider_transaction_id, amount, opts) do - {:ok, - %RefundResult{ - id: refund["id"], - provider_refund_id: refund["id"], - status: refund["status"], - amount: amount - }} - end - end - - @impl true - def get_payment_method_details(provider_payment_method_id) do - with {:ok, token} <- get_access_token(), - {:ok, vault_token} <- get_vault_payment_token(token, provider_payment_method_id) do - source = vault_token["payment_source"] - - details = - cond do - card = source["card"] -> - %{ - type: "card", - brand: card["brand"], - last4: card["last_digits"], - exp_month: - card["expiry"] |> String.split("-") |> List.last() |> String.to_integer(), - exp_year: card["expiry"] |> String.split("-") |> List.first() |> String.to_integer() - } - - _paypal = source["paypal"] -> - %{ - type: "paypal", - brand: "paypal", - last4: nil - } - - true -> - %{type: "unknown"} - end - - {:ok, details} - end - end - - # ============================================ - # PayPal API Calls - # ============================================ - - defp create_order(token, opts) do - amount = opts[:amount] || opts["amount"] - currency = opts[:currency] || opts["currency"] || "EUR" - description = opts[:description] || opts["description"] || "Payment" - success_url = opts[:success_url] || opts["success_url"] - cancel_url = opts[:cancel_url] || opts["cancel_url"] - metadata = opts[:metadata] || opts["metadata"] || %{} - - # Convert cents to decimal string - amount_str = format_amount(amount) - - body = %{ - intent: "CAPTURE", - purchase_units: [ - %{ - amount: %{ - currency_code: String.upcase(currency), - value: amount_str - }, - description: description, - custom_id: Jason.encode!(metadata) - } - ], - payment_source: %{ - paypal: %{ - experience_context: %{ - payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED", - brand_name: Settings.get_setting("billing_company_name", ""), - locale: "en-US", - landing_page: "LOGIN", - user_action: "PAY_NOW", - return_url: success_url, - cancel_url: cancel_url - } - } - } - } - - request(:post, "/v2/checkout/orders", token, body) - end - - defp create_order_with_vault(token, payment_method, amount, opts) do - currency = Keyword.get(opts, :currency, "EUR") - description = Keyword.get(opts, :description, "Payment") - metadata = Keyword.get(opts, :metadata, %{}) - - amount_str = - if is_integer(amount) do - # Cents to dollars - :erlang.float_to_binary(amount / 100, decimals: 2) - else - Decimal.to_string(Decimal.round(amount, 2)) - end - - body = %{ - intent: "CAPTURE", - purchase_units: [ - %{ - amount: %{ - currency_code: String.upcase(currency), - value: amount_str - }, - description: description, - custom_id: Jason.encode!(metadata) - } - ], - payment_source: %{ - token: %{ - id: payment_method.provider_payment_method_id, - type: "PAYMENT_METHOD_TOKEN" - } - } - } - - request(:post, "/v2/checkout/orders", token, body) - end - - defp capture_order(token, order_id) do - request(:post, "/v2/checkout/orders/#{order_id}/capture", token, %{}) - end - - defp create_setup_token(token, opts) do - success_url = opts[:success_url] || opts["success_url"] - cancel_url = opts[:cancel_url] || opts["cancel_url"] - user_uuid = opts[:user_uuid] || opts["user_uuid"] - - body = %{ - payment_source: %{ - paypal: %{ - description: "Save payment method", - usage_type: "MERCHANT", - customer_type: "CONSUMER", - experience_context: %{ - return_url: success_url, - cancel_url: cancel_url - } - } - }, - customer: %{ - id: "user_#{user_uuid}" - } - } - - request(:post, "/v3/vault/setup-tokens", token, body) - end - - defp get_vault_payment_token(token, vault_id) do - request(:get, "/v3/vault/payment-tokens/#{vault_id}", token) - end - - defp do_create_refund(token, capture_id, amount, opts) do - currency = Keyword.get(opts, :currency, "EUR") - note = Keyword.get(opts, :note, "Refund") - - body = - if amount do - amount_str = - if is_integer(amount) do - :erlang.float_to_binary(amount / 100, decimals: 2) - else - Decimal.to_string(Decimal.round(amount, 2)) - end - - %{ - amount: %{ - currency_code: String.upcase(currency), - value: amount_str - }, - note_to_payer: note - } - else - %{note_to_payer: note} - end - - request(:post, "/v2/payments/captures/#{capture_id}/refund", token, body) - end - - defp verify_webhook_via_api(token, payload, headers) when is_map(headers) do - webhook_id = Settings.get_setting("billing_paypal_webhook_id", "") - - body = %{ - auth_algo: headers["paypal-auth-algo"], - cert_url: headers["paypal-cert-url"], - transmission_id: headers["paypal-transmission-id"], - transmission_sig: headers["paypal-transmission-sig"], - transmission_time: headers["paypal-transmission-time"], - webhook_id: webhook_id, - webhook_event: payload - } - - case request(:post, "/v1/notifications/verify-webhook-signature", token, body) do - {:ok, %{"verification_status" => "SUCCESS"}} -> :ok - {:ok, _} -> {:error, :invalid_signature} - error -> error - end - end - - defp verify_webhook_via_api(_token, _payload, _signature) do - # If signature is just a string, we can't verify properly - # In production, headers should be passed - Logger.warning("PayPal webhook verification requires full headers map") - :ok - end - - # ============================================ - # Webhook Event Handlers - # ============================================ - - defp handle_order_approved(resource, payload) do - order_id = resource["id"] - custom_id = get_custom_id(resource) - - {:ok, - %WebhookEventData{ - event_id: payload["id"], - type: "checkout.completed", - provider: :paypal, - data: %{ - session_id: order_id, - mode: "payment", - invoice_uuid: custom_id["invoice_uuid"] || custom_id["invoice_id"], - payment_intent_id: order_id - }, - raw_payload: payload - }} - end - - defp handle_capture_completed(resource, payload) do - capture_id = resource["id"] - amount = resource["amount"] - custom_id = get_custom_id_from_capture(resource) - - {:ok, - %WebhookEventData{ - event_id: payload["id"], - type: "payment.succeeded", - provider: :paypal, - data: %{ - charge_id: capture_id, - invoice_uuid: custom_id["invoice_uuid"] || custom_id["invoice_id"], - amount: parse_amount(amount["value"]), - currency: amount["currency_code"] - }, - raw_payload: payload - }} - end - - defp handle_capture_denied(resource, payload) do - custom_id = get_custom_id_from_capture(resource) - - {:ok, - %WebhookEventData{ - event_id: payload["id"], - type: "payment.failed", - provider: :paypal, - data: %{ - invoice_uuid: custom_id["invoice_uuid"] || custom_id["invoice_id"], - error_code: "CAPTURE_DENIED", - error_message: "Payment capture was denied" - }, - raw_payload: payload - }} - end - - defp handle_capture_refunded(resource, payload) do - refund_id = resource["id"] - amount = resource["amount"] - - {:ok, - %WebhookEventData{ - event_id: payload["id"], - type: "refund.created", - provider: :paypal, - data: %{ - refund_id: refund_id, - charge_id: resource["links"] |> find_capture_id(), - amount_refunded: parse_amount(amount["value"]) - }, - raw_payload: payload - }} - end - - # ============================================ - # OAuth2 Token Management - # ============================================ - - defp get_access_token do - # In production, this should be cached - client_id = Settings.get_setting("billing_paypal_client_id", "") - client_secret = Settings.get_setting("billing_paypal_client_secret", "") - - if client_id == "" or client_secret == "" do - {:error, :not_configured} - else - auth = Base.encode64("#{client_id}:#{client_secret}") - - case Req.post( - "#{base_url()}/v1/oauth2/token", - headers: [ - {"Authorization", "Basic #{auth}"}, - {"Content-Type", "application/x-www-form-urlencoded"} - ], - body: "grant_type=client_credentials" - ) do - {:ok, %{status: 200, body: body}} -> - {:ok, body["access_token"]} - - {:ok, %{status: status, body: body}} -> - Logger.error("PayPal OAuth error: #{status} - #{inspect(body)}") - {:error, :authentication_failed} - - {:error, reason} -> - Logger.error("PayPal OAuth request failed: #{inspect(reason)}") - {:error, :request_failed} - end - end - end - - # ============================================ - # HTTP Helpers - # ============================================ - - defp request(method, path, token, body \\ nil) do - url = "#{base_url()}#{path}" - - headers = [ - {"Authorization", "Bearer #{token}"}, - {"Content-Type", "application/json"}, - {"PayPal-Request-Id", generate_request_id()} - ] - - opts = - case method do - :get -> [headers: headers] - _ -> [headers: headers, json: body] - end - - result = - case method do - :get -> Req.get(url, opts) - :post -> Req.post(url, opts) - end - - case result do - {:ok, %{status: status, body: body}} when status in 200..299 -> - {:ok, body} - - {:ok, %{status: status, body: body}} -> - Logger.error("PayPal API error: #{status} - #{inspect(body)}") - error_message = get_in(body, ["details", Access.at(0), "description"]) || "API error" - {:error, error_message} - - {:error, reason} -> - Logger.error("PayPal request failed: #{inspect(reason)}") - {:error, :request_failed} - end - end - - # ============================================ - # Helpers - # ============================================ - - defp base_url do - case Settings.get_setting("billing_paypal_mode", "sandbox") do - "live" -> @live_url - _ -> @sandbox_url - end - end - - defp has_credentials? do - Settings.get_setting("billing_paypal_client_id", "") != "" && - Settings.get_setting("billing_paypal_client_secret", "") != "" - end - - defp format_amount(amount) when is_integer(amount) do - # Cents to dollars - :erlang.float_to_binary(amount / 100, decimals: 2) - end - - defp format_amount(%Decimal{} = amount) do - Decimal.to_string(Decimal.round(amount, 2)) - end - - defp format_amount(amount) when is_float(amount) do - :erlang.float_to_binary(amount, decimals: 2) - end - - defp parse_amount(amount_str) when is_binary(amount_str) do - {float, _} = Float.parse(amount_str) - round(float * 100) - end - - defp parse_amount(amount), do: amount - - defp get_custom_id(resource) do - custom_id_json = - resource["purchase_units"] - |> List.first() - |> Map.get("custom_id", "{}") - - case Jason.decode(custom_id_json) do - {:ok, map} -> map - _ -> %{} - end - end - - defp get_custom_id_from_capture(resource) do - # Try to get from supplementary_data or links - custom_id_json = resource["custom_id"] || "{}" - - case Jason.decode(custom_id_json) do - {:ok, map} -> map - _ -> %{} - end - end - - defp find_capture_id(links) when is_list(links) do - case Enum.find(links, fn link -> link["rel"] == "up" end) do - %{"href" => href} -> href |> String.split("/") |> List.last() - _ -> nil - end - end - - defp find_capture_id(_), do: nil - - defp generate_request_id do - "req_" <> (:crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)) - end - - defp invoice_to_opts(invoice) when is_map(invoice) do - amount = invoice[:total] || invoice["total"] || Decimal.new(0) - amount_cents = Decimal.to_integer(Decimal.mult(amount, 100)) - - [ - amount: amount_cents, - currency: invoice[:currency] || invoice["currency"] || "EUR", - description: "Invoice #{invoice[:invoice_number] || invoice["invoice_number"]}", - metadata: %{ - invoice_uuid: invoice[:uuid] || invoice["uuid"] || invoice[:id] || invoice["id"], - invoice_number: invoice[:invoice_number] || invoice["invoice_number"] - } - ] - end - - defp invoice_to_opts(_), do: [] -end diff --git a/lib/modules/billing/providers/provider.ex b/lib/modules/billing/providers/provider.ex deleted file mode 100644 index d8a6975a9..000000000 --- a/lib/modules/billing/providers/provider.ex +++ /dev/null @@ -1,259 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers.Provider do - @moduledoc """ - Behaviour for payment providers. - - Defines a unified interface for all payment systems (Stripe, PayPal, Razorpay). - Each provider implements this behaviour to handle payments, refunds, and webhooks. - - ## Provider Architecture - - PhoenixKit uses Internal Subscription Control - subscriptions are managed - in our database, not by providers. Providers only handle: - - One-time payments (checkout sessions) - - Saving payment methods for recurring billing - - Charging saved payment methods - - Processing refunds - - ## Hosted Checkout Flow - - 1. User clicks "Pay with Stripe" on invoice - 2. Backend calls create_checkout_session/2 - 3. User redirected to provider's checkout page - 4. Provider processes payment - 5. Provider sends webhook - 6. WebhookProcessor updates invoice status - - ## Implementation Example - - defmodule PhoenixKit.Modules.Billing.Providers.Stripe do - @behaviour PhoenixKit.Modules.Billing.Providers.Provider - - @impl true - def provider_name, do: :stripe - - @impl true - def available? do - config = get_config() - config && config.enabled && config.api_key - end - - @impl true - def create_checkout_session(invoice, opts) do - # Implementation - end - # ... other callbacks - end - """ - - alias PhoenixKit.Modules.Billing.Providers.Types.{ - ChargeResult, - CheckoutSession, - PaymentMethodInfo, - RefundResult, - SetupSession, - WebhookEventData - } - - @type checkout_session :: CheckoutSession.t() - @type setup_session :: SetupSession.t() - @type webhook_event :: WebhookEventData.t() - @type payment_method :: PaymentMethodInfo.t() - @type charge_result :: ChargeResult.t() - @type refund_result :: RefundResult.t() - - @doc """ - Returns the provider name as an atom. - - ## Examples - - iex> Stripe.provider_name() - :stripe - - iex> PayPal.provider_name() - :paypal - """ - @callback provider_name() :: atom() - - @doc """ - Checks if the provider is configured and available for use. - - Returns `true` if: - - Provider is enabled in settings - - API credentials are configured - - Provider passed verification (if applicable) - - ## Examples - - iex> Stripe.available?() - true - """ - @callback available?() :: boolean() - - @doc """ - Creates a checkout session for one-time payment. - - This is used for paying invoices. The user is redirected to the - provider's hosted checkout page where they enter payment details. - - ## Parameters - - - `invoice` - The invoice to pay (must include amount, currency, line_items) - - `opts` - Options: - - `:success_url` - URL to redirect after successful payment - - `:cancel_url` - URL to redirect if user cancels - - `:save_payment_method` - Whether to save card for future use (default: false) - - ## Returns - - - `{:ok, checkout_session}` - Session created, redirect user to `session.url` - - `{:error, reason}` - Failed to create session - """ - @callback create_checkout_session(invoice :: map(), opts :: keyword()) :: - {:ok, checkout_session()} | {:error, term()} - - @doc """ - Creates a setup session to save a payment method without charging. - - Used when a user wants to add a payment method for future subscriptions - without making an immediate payment. - - ## Parameters - - - `user` - The user to save payment method for - - `opts` - Options: - - `:success_url` - URL to redirect after success - - `:cancel_url` - URL to redirect if user cancels - - ## Returns - - - `{:ok, setup_session}` - Session created - - `{:error, reason}` - Failed to create session - """ - @callback create_setup_session(user :: map(), opts :: keyword()) :: - {:ok, setup_session()} | {:error, term()} - - @doc """ - Charges a saved payment method. - - Used for subscription renewals. The payment method was previously - saved during checkout or setup session. - - ## Parameters - - - `payment_method` - The saved payment method record - - `amount` - Amount to charge (Decimal) - - `opts` - Options: - - `:currency` - Currency code (default: from payment method) - - `:description` - Description for the charge - - `:invoice_uuid` - Associated invoice UUID - - `:metadata` - Additional metadata - - ## Returns - - - `{:ok, charge_result}` - Charge successful - - `{:error, :card_declined}` - Card was declined - - `{:error, :payment_method_expired}` - Payment method expired - - `{:error, reason}` - Other error - """ - @callback charge_payment_method( - payment_method :: map(), - amount :: Decimal.t(), - opts :: keyword() - ) :: {:ok, charge_result()} | {:error, term()} - - @doc """ - Verifies webhook signature to ensure request is from the provider. - - ## Parameters - - - `payload` - Raw request body as binary - - `signature` - Signature from request headers - - `secret` - Webhook secret for this provider - - ## Returns - - - `:ok` - Signature is valid - - `{:error, :invalid_signature}` - Signature verification failed - """ - @callback verify_webhook_signature( - payload :: binary(), - signature :: String.t(), - secret :: String.t() - ) :: :ok | {:error, :invalid_signature} - - @doc """ - Handles and normalizes a webhook event payload. - - Converts provider-specific event format to a normalized format - that can be processed by WebhookProcessor. - - ## Parameters - - - `payload` - Decoded JSON payload from webhook - - ## Returns - - - `{:ok, webhook_event}` - Event parsed successfully - - `{:error, :unknown_event}` - Event type not recognized - - `{:error, reason}` - Failed to parse event - """ - @callback handle_webhook_event(payload :: map()) :: - {:ok, webhook_event()} | {:error, term()} - - @doc """ - Creates a refund for a transaction. - - ## Parameters - - - `provider_transaction_id` - The provider's transaction/charge ID - - `amount` - Amount to refund (Decimal, nil for full refund) - - `opts` - Options: - - `:reason` - Reason for refund - - `:metadata` - Additional metadata - - ## Returns - - - `{:ok, refund_result}` - Refund created - - `{:error, :already_refunded}` - Transaction already refunded - - `{:error, reason}` - Refund failed - """ - @callback create_refund( - provider_transaction_id :: String.t(), - amount :: Decimal.t() | nil, - opts :: keyword() - ) :: {:ok, refund_result()} | {:error, term()} - - @doc """ - Gets details of a saved payment method. - - ## Parameters - - - `provider_payment_method_id` - The provider's payment method ID - - ## Returns - - - `{:ok, payment_method}` - Payment method details - - `{:error, :not_found}` - Payment method not found - - `{:error, reason}` - Failed to get details - """ - @callback get_payment_method_details(provider_payment_method_id :: String.t()) :: - {:ok, payment_method()} | {:error, term()} - - @doc """ - Detaches/removes a saved payment method from the provider. - - ## Parameters - - - `provider_payment_method_id` - The provider's payment method ID - - ## Returns - - - `:ok` - Payment method removed - - `{:error, :not_found}` - Payment method not found - - `{:error, reason}` - Failed to remove - """ - @callback detach_payment_method(provider_payment_method_id :: String.t()) :: - :ok | {:error, term()} - - @optional_callbacks detach_payment_method: 1 -end diff --git a/lib/modules/billing/providers/providers.ex b/lib/modules/billing/providers/providers.ex deleted file mode 100644 index b6df4056c..000000000 --- a/lib/modules/billing/providers/providers.ex +++ /dev/null @@ -1,373 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers do - @moduledoc """ - Provider registry and helper functions for payment providers. - - This module serves as the central point for working with payment providers. - It handles provider lookup, availability checking, and configuration. - - ## Available Providers - - - `:stripe` - Stripe payments (cards, wallets) - - `:paypal` - PayPal payments - - `:razorpay` - Razorpay payments (India) - - ## Usage - - # Get a provider module - provider = Providers.get_provider(:stripe) - provider.create_checkout_session(invoice, opts) - - # List available providers - Providers.list_available_providers() - #=> [:stripe, :paypal] - - # Check if provider is available - Providers.provider_enabled?(:stripe) - #=> true - """ - - alias PhoenixKit.Modules.Billing.Providers.Provider - alias PhoenixKit.Modules.Billing.Providers.Types.ProviderInfo - alias PhoenixKit.Settings - - @providers %{ - stripe: PhoenixKit.Modules.Billing.Providers.Stripe, - paypal: PhoenixKit.Modules.Billing.Providers.PayPal, - razorpay: PhoenixKit.Modules.Billing.Providers.Razorpay - } - - @provider_names Map.keys(@providers) - - @doc """ - Returns the provider module for the given provider name. - - ## Parameters - - - `name` - Provider name as atom or string - - ## Returns - - - Provider module if found - - `nil` if provider not found - - ## Examples - - iex> Providers.get_provider(:stripe) - PhoenixKit.Modules.Billing.Providers.Stripe - - iex> Providers.get_provider("paypal") - PhoenixKit.Modules.Billing.Providers.PayPal - - iex> Providers.get_provider(:unknown) - nil - """ - @spec get_provider(atom() | String.t()) :: module() | nil - def get_provider(name) when is_atom(name), do: @providers[name] - def get_provider(name) when is_binary(name), do: @providers[String.to_existing_atom(name)] - - @doc """ - Returns a list of all provider names. - - ## Examples - - iex> Providers.all_providers() - [:stripe, :paypal, :razorpay] - """ - @spec all_providers() :: [atom()] - def all_providers, do: @provider_names - - @doc """ - Returns a list of available (enabled and configured) provider names. - - Checks each provider's `available?/0` callback to determine availability. - - ## Examples - - iex> Providers.list_available_providers() - [:stripe, :paypal] - """ - @spec list_available_providers() :: [atom()] - def list_available_providers do - @providers - |> Enum.filter(fn {_name, module} -> - Code.ensure_loaded?(module) && function_exported?(module, :available?, 0) && - module.available?() - end) - |> Enum.map(fn {name, _module} -> name end) - end - - @doc """ - Checks if a provider is enabled and available. - - ## Parameters - - - `name` - Provider name as atom or string - - ## Returns - - - `true` if provider is available - - `false` if provider is not available or not found - - ## Examples - - iex> Providers.provider_enabled?(:stripe) - true - - iex> Providers.provider_enabled?(:unknown) - false - """ - @spec provider_enabled?(atom() | String.t()) :: boolean() - def provider_enabled?(name) do - case get_provider(name) do - nil -> false - module -> Code.ensure_loaded?(module) && module.available?() - end - end - - @doc """ - Checks if a provider exists (regardless of availability). - - ## Examples - - iex> Providers.provider_exists?(:stripe) - true - - iex> Providers.provider_exists?(:bitcoin) - false - """ - @spec provider_exists?(atom() | String.t()) :: boolean() - def provider_exists?(name) when is_atom(name), do: Map.has_key?(@providers, name) - - def provider_exists?(name) when is_binary(name) do - provider_exists?(String.to_existing_atom(name)) - rescue - ArgumentError -> false - end - - @doc """ - Gets the setting key for a provider's enabled status. - - ## Examples - - iex> Providers.enabled_setting_key(:stripe) - "billing_stripe_enabled" - """ - @spec enabled_setting_key(atom()) :: String.t() - def enabled_setting_key(provider) do - "billing_#{provider}_enabled" - end - - @doc """ - Checks if a provider is enabled in settings. - - This is a lower-level check that only looks at the setting, - not whether the provider is fully configured. - - ## Examples - - iex> Providers.setting_enabled?(:stripe) - true - """ - @spec setting_enabled?(atom()) :: boolean() - def setting_enabled?(provider) do - Settings.get_setting(enabled_setting_key(provider), "false") == "true" - end - - @doc """ - Creates a checkout session using the specified provider. - - Convenience function that looks up the provider and calls - `create_checkout_session/2`. - - ## Parameters - - - `provider` - Provider name - - `invoice` - Invoice to pay - - `opts` - Options passed to provider - - ## Returns - - - `{:ok, checkout_session}` - Session created - - `{:error, :provider_not_found}` - Provider doesn't exist - - `{:error, :provider_not_available}` - Provider not configured - - `{:error, reason}` - Provider-specific error - """ - @spec create_checkout_session(atom() | String.t(), map(), keyword()) :: - {:ok, Provider.checkout_session()} | {:error, term()} - def create_checkout_session(provider, invoice, opts \\ []) do - with {:ok, module} <- get_available_provider(provider) do - module.create_checkout_session(invoice, opts) - end - end - - @doc """ - Creates a setup session using the specified provider. - - ## Parameters - - - `provider` - Provider name - - `user` - User to save payment method for - - `opts` - Options passed to provider - - ## Returns - - - `{:ok, setup_session}` - Session created - - `{:error, reason}` - Failed - """ - @spec create_setup_session(atom() | String.t(), map(), keyword()) :: - {:ok, Provider.setup_session()} | {:error, term()} - def create_setup_session(provider, user, opts \\ []) do - with {:ok, module} <- get_available_provider(provider) do - module.create_setup_session(user, opts) - end - end - - @doc """ - Charges a saved payment method using the appropriate provider. - - ## Parameters - - - `payment_method` - Saved payment method record (must include :provider) - - `amount` - Amount to charge - - `opts` - Options passed to provider - - ## Returns - - - `{:ok, charge_result}` - Charge successful - - `{:error, reason}` - Charge failed - """ - @spec charge_payment_method(map(), Decimal.t(), keyword()) :: - {:ok, Provider.charge_result()} | {:error, term()} - def charge_payment_method(%{provider: provider} = payment_method, amount, opts \\ []) do - with {:ok, module} <- get_available_provider(provider) do - module.charge_payment_method(payment_method, amount, opts) - end - end - - @doc """ - Verifies a webhook signature for the specified provider. - - ## Parameters - - - `provider` - Provider name - - `payload` - Raw request body - - `signature` - Signature from headers - - `secret` - Webhook secret - - ## Returns - - - `:ok` - Signature valid - - `{:error, :invalid_signature}` - Signature invalid - - `{:error, :provider_not_found}` - Provider doesn't exist - """ - @spec verify_webhook_signature(atom() | String.t(), binary(), String.t(), String.t()) :: - :ok | {:error, term()} - def verify_webhook_signature(provider, payload, signature, secret) do - case get_provider(provider) do - nil -> {:error, :provider_not_found} - module -> module.verify_webhook_signature(payload, signature, secret) - end - end - - @doc """ - Handles a webhook event for the specified provider. - - ## Parameters - - - `provider` - Provider name - - `payload` - Decoded JSON payload - - ## Returns - - - `{:ok, webhook_event}` - Event parsed - - `{:error, reason}` - Failed to parse - """ - @spec handle_webhook_event(atom() | String.t(), map()) :: - {:ok, Provider.webhook_event()} | {:error, term()} - def handle_webhook_event(provider, payload) do - case get_provider(provider) do - nil -> {:error, :provider_not_found} - module -> module.handle_webhook_event(payload) - end - end - - @doc """ - Creates a refund using the appropriate provider. - - ## Parameters - - - `provider` - Provider name - - `provider_transaction_id` - Provider's transaction ID - - `amount` - Amount to refund (nil for full refund) - - `opts` - Options - - ## Returns - - - `{:ok, refund_result}` - Refund created - - `{:error, reason}` - Refund failed - """ - @spec create_refund(atom() | String.t(), String.t(), Decimal.t() | nil, keyword()) :: - {:ok, Provider.refund_result()} | {:error, term()} - def create_refund(provider, provider_transaction_id, amount, opts \\ []) do - with {:ok, module} <- get_available_provider(provider) do - module.create_refund(provider_transaction_id, amount, opts) - end - end - - @doc """ - Returns display information for a provider. - - ## Examples - - iex> Providers.provider_info(:stripe) - %{name: "Stripe", icon: "stripe", color: "#635BFF"} - """ - @spec provider_info(atom()) :: ProviderInfo.t() - def provider_info(:stripe) do - %ProviderInfo{ - name: "Stripe", - icon: "stripe", - color: "#635BFF", - description: "Accept cards, wallets, and more" - } - end - - def provider_info(:paypal) do - %ProviderInfo{ - name: "PayPal", - icon: "paypal", - color: "#003087", - description: "PayPal and credit/debit cards" - } - end - - def provider_info(:razorpay) do - %ProviderInfo{ - name: "Razorpay", - icon: "razorpay", - color: "#072654", - description: "Popular payment gateway in India" - } - end - - def provider_info(_) do - %ProviderInfo{name: "Unknown", icon: "credit-card", color: "#6B7280"} - end - - # Private helpers - - defp get_available_provider(provider) do - case get_provider(provider) do - nil -> - {:error, :provider_not_found} - - module -> - if Code.ensure_loaded?(module) && function_exported?(module, :available?, 0) && - module.available?() do - {:ok, module} - else - {:error, :provider_not_available} - end - end - end -end diff --git a/lib/modules/billing/providers/razorpay.ex b/lib/modules/billing/providers/razorpay.ex deleted file mode 100644 index 0b973fd65..000000000 --- a/lib/modules/billing/providers/razorpay.ex +++ /dev/null @@ -1,509 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers.Razorpay do - @moduledoc """ - Razorpay payment provider implementation. - - Razorpay is a popular payment gateway in India. Uses their REST API for: - - Payment Links (hosted checkout) - - Orders API - - Customers and Tokens (saved payment methods) - - Refunds - - ## Configuration - - Required settings in database: - - `billing_razorpay_enabled` - "true" to enable - - `billing_razorpay_key_id` - Razorpay Key ID - - `billing_razorpay_key_secret` - Razorpay Key Secret - - `billing_razorpay_webhook_secret` - Webhook secret for signature verification - - ## Razorpay Flow - - 1. Create Order with amount and currency - 2. Create Payment Link or use Checkout.js - 3. User completes payment on Razorpay - 4. Razorpay sends webhook on payment success - 5. Verify signature and process payment - - ## Webhook Events - - - `payment.authorized` - Payment authorized (for 2-step payments) - - `payment.captured` - Payment captured successfully - - `payment.failed` - Payment failed - - `refund.created` - Refund initiated - - `refund.processed` - Refund completed - - ## Currency Support - - Primary currency is INR. International payments supported with: - USD, EUR, GBP, SGD, AED, CAD, CNY, SEK, NZD, MXN, etc. - """ - - @behaviour PhoenixKit.Modules.Billing.Providers.Provider - - alias PhoenixKit.Modules.Billing.Providers.Types.{ - ChargeResult, - CheckoutSession, - PaymentMethodInfo, - RefundResult, - WebhookEventData - } - - alias PhoenixKit.Settings - - require Logger - - @base_url "https://api.razorpay.com" - - # ============================================ - # Provider Behaviour Implementation - # ============================================ - - @impl true - def provider_name, do: :razorpay - - @impl true - def available? do - Settings.get_setting("billing_razorpay_enabled", "false") == "true" && - has_credentials?() - end - - @impl true - def create_checkout_session(invoice, opts) do - # Merge invoice data with opts - merged_opts = Keyword.merge(opts, invoice_to_opts(invoice)) - - with {:ok, order} <- create_order(merged_opts), - {:ok, payment_link} <- create_payment_link(order, merged_opts) do - {:ok, - %CheckoutSession{ - id: order["id"], - url: payment_link["short_url"], - provider: :razorpay, - expires_at: payment_link["expire_by"] |> datetime_from_unix() - }} - end - end - - @impl true - def create_setup_session(_user, _opts) do - # Razorpay doesn't have direct setup sessions like Stripe - # We create a zero-amount authorization to save the card - # Or use their emandate/subscription API - - # For now, return an error - implement with emandate if needed - {:error, :not_supported} - end - - @impl true - def charge_payment_method(payment_method, amount, opts) do - # Razorpay recurring payments use tokens - token_id = payment_method.provider_payment_method_id - customer_id = payment_method.provider_customer_id - - with {:ok, order} <- create_order_for_recurring(amount, opts), - {:ok, payment} <- create_recurring_payment(order, token_id, customer_id, opts) do - {:ok, - %ChargeResult{ - id: payment["id"], - status: payment["status"], - amount: amount - }} - end - end - - @impl true - def verify_webhook_signature(payload, signature, secret) do - # Razorpay uses HMAC SHA256 - expected_signature = - :crypto.mac(:hmac, :sha256, secret, payload) - |> Base.encode16(case: :lower) - - if Plug.Crypto.secure_compare(expected_signature, signature) do - :ok - else - {:error, :invalid_signature} - end - end - - @impl true - def handle_webhook_event(payload) do - event = payload["event"] - event_payload = payload["payload"] - - case event do - "payment.captured" -> - handle_payment_captured(event_payload, payload) - - "payment.authorized" -> - handle_payment_authorized(event_payload, payload) - - "payment.failed" -> - handle_payment_failed(event_payload, payload) - - "refund.created" -> - handle_refund_created(event_payload, payload) - - "refund.processed" -> - handle_refund_processed(event_payload, payload) - - "order.paid" -> - handle_order_paid(event_payload, payload) - - _ -> - {:error, :unknown_event} - end - end - - @impl true - def create_refund(provider_transaction_id, amount, opts) do - with {:ok, refund} <- do_create_refund(provider_transaction_id, amount, opts) do - {:ok, - %RefundResult{ - id: refund["id"], - provider_refund_id: refund["id"], - status: refund["status"], - amount: refund["amount"] - }} - end - end - - @impl true - def get_payment_method_details(token_id) do - # Razorpay tokens don't expose card details easily - # Return minimal structure matching the payment_method type - {:ok, - %PaymentMethodInfo{ - id: token_id, - provider: :razorpay, - provider_payment_method_id: token_id, - provider_customer_id: nil, - type: "card", - brand: nil, - last4: nil, - exp_month: nil, - exp_year: nil, - metadata: %{} - }} - end - - # ============================================ - # Razorpay API Calls - # ============================================ - - defp create_order(opts) do - amount = opts[:amount] || opts["amount"] - currency = opts[:currency] || opts["currency"] || "INR" - metadata = opts[:metadata] || opts["metadata"] || %{} - - # Razorpay expects amount in smallest currency unit (paise for INR) - amount_paise = - if is_integer(amount) do - amount - else - Decimal.to_integer(Decimal.mult(amount, 100)) - end - - body = %{ - amount: amount_paise, - currency: String.upcase(currency), - notes: metadata, - receipt: "receipt_#{System.system_time(:millisecond)}" - } - - request(:post, "/v1/orders", body) - end - - defp create_order_for_recurring(amount, opts) do - currency = Keyword.get(opts, :currency, "INR") - metadata = Keyword.get(opts, :metadata, %{}) - - amount_paise = - if is_integer(amount) do - amount - else - Decimal.to_integer(Decimal.mult(amount, 100)) - end - - body = %{ - amount: amount_paise, - currency: String.upcase(currency), - notes: metadata, - receipt: "recurring_#{System.system_time(:millisecond)}" - } - - request(:post, "/v1/orders", body) - end - - defp create_payment_link(order, opts) do - description = opts[:description] || opts["description"] || "Payment" - success_url = opts[:success_url] || opts["success_url"] - # cancel_url not used in Razorpay payment links - they use callback_url only - metadata = opts[:metadata] || opts["metadata"] || %{} - - body = %{ - amount: order["amount"], - currency: order["currency"], - description: description, - callback_url: success_url, - callback_method: "get", - notes: Map.merge(metadata, %{order_id: order["id"]}), - # Expire in 30 minutes - expire_by: System.system_time(:second) + 1800 - } - - request(:post, "/v1/payment_links", body) - end - - defp create_recurring_payment(order, token_id, customer_id, opts) do - description = Keyword.get(opts, :description, "Recurring payment") - - body = %{ - email: Keyword.get(opts, :email, "customer@example.com"), - contact: Keyword.get(opts, :phone, "9999999999"), - amount: order["amount"], - currency: order["currency"], - order_id: order["id"], - customer_id: customer_id, - token: token_id, - recurring: "1", - description: description - } - - request(:post, "/v1/payments/create/recurring", body) - end - - defp do_create_refund(payment_id, amount, opts) do - notes = Keyword.get(opts, :notes, %{}) - - body = - if amount do - amount_paise = - if is_integer(amount) do - amount - else - Decimal.to_integer(Decimal.mult(amount, 100)) - end - - %{amount: amount_paise, notes: notes} - else - %{notes: notes} - end - - request(:post, "/v1/payments/#{payment_id}/refund", body) - end - - # ============================================ - # Webhook Event Handlers - # ============================================ - - defp handle_payment_captured(event_payload, raw_payload) do - payment = event_payload["payment"]["entity"] - order_id = payment["order_id"] - notes = payment["notes"] || %{} - - {:ok, - %WebhookEventData{ - event_id: raw_payload["event_id"] || payment["id"], - type: "payment.succeeded", - provider: :razorpay, - data: %{ - charge_id: payment["id"], - order_id: order_id, - invoice_uuid: notes["invoice_uuid"] || notes["invoice_id"], - amount: payment["amount"], - currency: payment["currency"] - }, - raw_payload: raw_payload - }} - end - - defp handle_payment_authorized(event_payload, raw_payload) do - payment = event_payload["payment"]["entity"] - - # For 2-step payments, we may need to capture manually - # Auto-capture is usually enabled, so this is informational - {:ok, - %WebhookEventData{ - event_id: raw_payload["event_id"] || payment["id"], - type: "payment.authorized", - provider: :razorpay, - data: %{ - payment_id: payment["id"], - order_id: payment["order_id"], - amount: payment["amount"] - }, - raw_payload: raw_payload - }} - end - - defp handle_payment_failed(event_payload, raw_payload) do - payment = event_payload["payment"]["entity"] - error = payment["error_code"] || "unknown" - error_desc = payment["error_description"] || "Payment failed" - notes = payment["notes"] || %{} - - {:ok, - %WebhookEventData{ - event_id: raw_payload["event_id"] || payment["id"], - type: "payment.failed", - provider: :razorpay, - data: %{ - payment_id: payment["id"], - order_id: payment["order_id"], - invoice_uuid: notes["invoice_uuid"] || notes["invoice_id"], - error_code: error, - error_message: error_desc - }, - raw_payload: raw_payload - }} - end - - defp handle_order_paid(event_payload, raw_payload) do - order = event_payload["order"]["entity"] - payment = event_payload["payment"]["entity"] - notes = order["notes"] || %{} - - {:ok, - %WebhookEventData{ - event_id: raw_payload["event_id"] || order["id"], - type: "checkout.completed", - provider: :razorpay, - data: %{ - mode: "payment", - session_id: order["id"], - payment_intent_id: payment["id"], - invoice_uuid: notes["invoice_uuid"] || notes["invoice_id"], - amount_total: order["amount_paid"], - currency: order["currency"] - }, - raw_payload: raw_payload - }} - end - - defp handle_refund_created(event_payload, raw_payload) do - refund = event_payload["refund"]["entity"] - - {:ok, - %WebhookEventData{ - event_id: raw_payload["event_id"] || refund["id"], - type: "refund.created", - provider: :razorpay, - data: %{ - refund_id: refund["id"], - charge_id: refund["payment_id"], - amount_refunded: refund["amount"], - status: refund["status"] - }, - raw_payload: raw_payload - }} - end - - defp handle_refund_processed(event_payload, raw_payload) do - refund = event_payload["refund"]["entity"] - - {:ok, - %WebhookEventData{ - event_id: raw_payload["event_id"] || refund["id"], - type: "refund.completed", - provider: :razorpay, - data: %{ - refund_id: refund["id"], - charge_id: refund["payment_id"], - amount_refunded: refund["amount"], - status: "succeeded" - }, - raw_payload: raw_payload - }} - end - - # ============================================ - # HTTP Helpers - # ============================================ - - defp request(method, path, body) do - with {:ok, credentials} <- get_credentials() do - execute_request(method, path, body, credentials) - end - end - - defp get_credentials do - key_id = Settings.get_setting("billing_razorpay_key_id", "") - key_secret = Settings.get_setting("billing_razorpay_key_secret", "") - - if key_id == "" or key_secret == "" do - {:error, :not_configured} - else - {:ok, {key_id, key_secret}} - end - end - - defp execute_request(method, path, body, {key_id, key_secret}) do - url = "#{@base_url}#{path}" - auth = Base.encode64("#{key_id}:#{key_secret}") - - headers = [ - {"Authorization", "Basic #{auth}"}, - {"Content-Type", "application/json"} - ] - - opts = build_request_opts(method, headers, body) - - method - |> do_http_request(url, opts) - |> handle_response() - end - - defp build_request_opts(_method, headers, body), do: [headers: headers, json: body] - - defp do_http_request(:post, url, opts), do: Req.post(url, opts) - - defp handle_response({:ok, %{status: status, body: body}}) when status in 200..299 do - {:ok, body} - end - - defp handle_response({:ok, %{status: status, body: body}}) do - Logger.error("Razorpay API error: #{status} - #{inspect(body)}") - error_message = body["error"]["description"] || "API error" - {:error, error_message} - end - - defp handle_response({:error, reason}) do - Logger.error("Razorpay request failed: #{inspect(reason)}") - {:error, :request_failed} - end - - # ============================================ - # Helpers - # ============================================ - - defp has_credentials? do - Settings.get_setting("billing_razorpay_key_id", "") != "" && - Settings.get_setting("billing_razorpay_key_secret", "") != "" - end - - defp datetime_from_unix(nil), do: nil - - defp datetime_from_unix(unix_timestamp) when is_integer(unix_timestamp) do - DateTime.from_unix!(unix_timestamp) - end - - defp datetime_from_unix(_), do: nil - - defp invoice_to_opts(invoice) when is_map(invoice) do - amount = invoice[:total] || invoice["total"] || Decimal.new(0) - # Razorpay expects amount in smallest currency unit (paise for INR, cents for others) - amount_paise = Decimal.to_integer(Decimal.mult(amount, 100)) - - [ - amount: amount_paise, - currency: invoice[:currency] || invoice["currency"] || "INR", - description: "Invoice #{invoice[:invoice_number] || invoice["invoice_number"]}", - metadata: %{ - invoice_uuid: invoice[:uuid] || invoice["uuid"] || invoice[:id] || invoice["id"], - invoice_number: invoice[:invoice_number] || invoice["invoice_number"] - } - ] - end - - defp invoice_to_opts(_), do: [] -end diff --git a/lib/modules/billing/providers/stripe.ex b/lib/modules/billing/providers/stripe.ex deleted file mode 100644 index 3a43965c9..000000000 --- a/lib/modules/billing/providers/stripe.ex +++ /dev/null @@ -1,802 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers.Stripe do - @moduledoc """ - Stripe payment provider implementation. - - This module implements the `PhoenixKit.Modules.Billing.Providers.Provider` behaviour - for Stripe payments. It supports: - - - Hosted Checkout for one-time payments - - Setup sessions for saving payment methods - - Charging saved payment methods (for subscription renewals) - - Webhook signature verification - - Refunds - - ## Configuration - - Configure Stripe in your provider settings: - - # Via Admin UI: /admin/settings/billing/providers - # Or via Settings API: - PhoenixKit.Modules.Billing.update_provider_config(:stripe, %{ - enabled: true, - mode: "test", - api_key: "sk_test_...", - webhook_secret: "whsec_..." - }) - - ## Webhook Events - - Configure your Stripe webhook to send these events: - - `checkout.session.completed` - Payment completed - - `checkout.session.expired` - Session expired - - `payment_intent.succeeded` - Payment succeeded (for saved cards) - - `payment_intent.payment_failed` - Payment failed - - `charge.refunded` - Refund processed - - `setup_intent.succeeded` - Card saved successfully - - ## Dependencies - - Requires the `stripe` hex package: - - {:stripe, "~> 1.1"} - """ - - @behaviour PhoenixKit.Modules.Billing.Providers.Provider - - alias PhoenixKit.Modules.Billing.Providers.Types.{ - ChargeResult, - CheckoutSession, - PaymentMethodInfo, - RefundResult, - SetupSession, - WebhookEventData - } - - alias PhoenixKit.Settings - - require Logger - - @stripe_api_version "2023-10-16" - - # Provider identification - @impl true - def provider_name, do: :stripe - - @impl true - def available? do - config = get_config() - config[:enabled] && config[:api_key] && config[:api_key] != "" - end - - @doc """ - Creates a Stripe Checkout Session for one-time payment. - - ## Options - - - `:success_url` - URL to redirect after successful payment (required) - - `:cancel_url` - URL to redirect if user cancels (required) - - `:save_payment_method` - Whether to save card for future use (default: false) - - `:customer_email` - Pre-fill customer email - - `:metadata` - Additional metadata to attach - - ## Examples - - iex> create_checkout_session(invoice, success_url: "https://...", cancel_url: "https://...") - {:ok, %{id: "cs_test_...", url: "https://checkout.stripe.com/..."}} - """ - @impl true - def create_checkout_session(invoice, opts) do - with {:ok, config} <- ensure_configured() do - line_items = build_line_items(invoice) - - params = %{ - mode: "payment", - line_items: line_items, - success_url: Keyword.fetch!(opts, :success_url), - cancel_url: Keyword.fetch!(opts, :cancel_url), - client_reference_id: to_string(invoice.uuid), - metadata: %{ - invoice_uuid: to_string(invoice.uuid), - invoice_number: invoice.invoice_number - } - } - - params = - params - |> maybe_add_customer_email(invoice, opts) - |> maybe_add_save_payment_method(opts) - |> maybe_add_custom_metadata(opts) - - case stripe_request(:post, "/checkout/sessions", params, config) do - {:ok, %{"id" => id, "url" => url, "expires_at" => expires_at}} -> - {:ok, - %CheckoutSession{ - id: id, - url: url, - provider: :stripe, - expires_at: DateTime.from_unix!(expires_at), - metadata: %{invoice_uuid: invoice.uuid} - }} - - {:error, reason} -> - Logger.error("Stripe checkout session creation failed: #{inspect(reason)}") - {:error, reason} - end - end - end - - @doc """ - Creates a Stripe Setup Session to save a payment method. - - ## Options - - - `:success_url` - URL to redirect after success (required) - - `:cancel_url` - URL to redirect if user cancels (required) - - `:customer_email` - Customer email - - ## Examples - - iex> create_setup_session(user, success_url: "https://...", cancel_url: "https://...") - {:ok, %{id: "seti_...", url: "https://checkout.stripe.com/..."}} - """ - @impl true - def create_setup_session(user, opts) do - with {:ok, config} <- ensure_configured(), - {:ok, customer_id} <- ensure_customer(user, config) do - params = %{ - mode: "setup", - customer: customer_id, - success_url: Keyword.fetch!(opts, :success_url), - cancel_url: Keyword.fetch!(opts, :cancel_url), - payment_method_types: ["card"], - metadata: %{ - user_uuid: to_string(user.uuid) - } - } - - case stripe_request(:post, "/checkout/sessions", params, config) do - {:ok, %{"id" => id, "url" => url}} -> - {:ok, - %SetupSession{ - id: id, - url: url, - provider: :stripe, - metadata: %{user_uuid: user.uuid, customer_id: customer_id} - }} - - {:error, reason} -> - Logger.error("Stripe setup session creation failed: #{inspect(reason)}") - {:error, reason} - end - end - end - - @doc """ - Charges a saved payment method. - - Used for subscription renewals where the payment method was previously saved. - - ## Options - - - `:currency` - Currency code (default: EUR) - - `:description` - Description for the charge - - `:invoice_uuid` - Associated invoice UUID - - `:metadata` - Additional metadata - - ## Examples - - iex> charge_payment_method(payment_method, Decimal.new("99.00"), currency: "EUR") - {:ok, %{id: "pi_...", provider_transaction_id: "ch_...", status: "succeeded"}} - """ - @impl true - def charge_payment_method(payment_method, amount, opts) do - with {:ok, config} <- ensure_configured() do - currency = Keyword.get(opts, :currency, "EUR") |> String.downcase() - amount_cents = Decimal.mult(amount, 100) |> Decimal.round() |> Decimal.to_integer() - - params = %{ - amount: amount_cents, - currency: currency, - customer: payment_method.provider_customer_id, - payment_method: payment_method.provider_payment_method_id, - off_session: true, - confirm: true, - description: Keyword.get(opts, :description, "PhoenixKit subscription payment"), - metadata: - %{ - payment_method_uuid: to_string(payment_method.uuid) - } - |> maybe_merge_invoice_metadata(opts) - } - - case stripe_request(:post, "/payment_intents", params, config) do - {:ok, %{"id" => id, "status" => "succeeded", "latest_charge" => charge_id}} -> - {:ok, - %ChargeResult{ - id: id, - provider_transaction_id: charge_id, - amount: amount, - currency: String.upcase(currency), - status: "succeeded", - metadata: %{payment_intent_id: id} - }} - - {:ok, %{"status" => "requires_action"}} -> - {:error, :requires_action} - - {:ok, %{"status" => "requires_payment_method"}} -> - {:error, :card_declined} - - {:error, %{"code" => "card_declined"}} -> - {:error, :card_declined} - - {:error, %{"code" => "expired_card"}} -> - {:error, :payment_method_expired} - - {:error, reason} -> - Logger.error("Stripe charge failed: #{inspect(reason)}") - {:error, reason} - end - end - end - - @doc """ - Verifies Stripe webhook signature. - - Uses Stripe's signature verification to ensure the webhook came from Stripe. - - ## Examples - - iex> verify_webhook_signature(raw_body, signature_header, webhook_secret) - :ok - - iex> verify_webhook_signature(raw_body, "invalid", webhook_secret) - {:error, :invalid_signature} - """ - @impl true - def verify_webhook_signature(payload, signature, secret) do - # Stripe signature format: t=timestamp,v1=signature - with {:ok, parts} <- parse_signature(signature), - {:ok, timestamp} <- Map.fetch(parts, "t"), - {:ok, expected_sig} <- Map.fetch(parts, "v1"), - :ok <- verify_timestamp(timestamp), - :ok <- verify_signature(payload, timestamp, expected_sig, secret) do - :ok - else - _ -> {:error, :invalid_signature} - end - end - - @doc """ - Handles and normalizes Stripe webhook events. - - ## Supported Events - - - `checkout.session.completed` - Checkout payment completed - - `checkout.session.expired` - Checkout session expired - - `payment_intent.succeeded` - Payment intent succeeded - - `payment_intent.payment_failed` - Payment failed - - `charge.refunded` - Charge refunded - - `setup_intent.succeeded` - Setup intent completed (card saved) - - ## Examples - - iex> handle_webhook_event(%{"type" => "checkout.session.completed", ...}) - {:ok, %{type: "checkout.completed", event_id: "evt_...", data: %{...}}} - """ - @impl true - def handle_webhook_event(%{"type" => type, "id" => event_id, "data" => %{"object" => object}}) do - case normalize_event(type, object) do - {:ok, normalized} -> - {:ok, - %WebhookEventData{ - type: normalized.type, - event_id: event_id, - data: normalized.data, - provider: :stripe, - raw_payload: object - }} - - {:error, :unknown_event} -> - Logger.debug("Unknown Stripe event type: #{type}") - {:error, :unknown_event} - end - end - - def handle_webhook_event(_payload) do - {:error, :invalid_payload} - end - - @doc """ - Creates a refund for a Stripe charge. - - ## Options - - - `:reason` - Reason for refund ("duplicate", "fraudulent", "requested_by_customer") - - `:metadata` - Additional metadata - - ## Examples - - iex> create_refund("ch_xxx", Decimal.new("50.00"), reason: "requested_by_customer") - {:ok, %{id: "re_...", provider_refund_id: "re_...", amount: #Decimal<50.00>}} - """ - @impl true - def create_refund(provider_transaction_id, amount, opts) do - with {:ok, config} <- ensure_configured() do - params = %{ - charge: provider_transaction_id - } - - params = - if amount do - amount_cents = Decimal.mult(amount, 100) |> Decimal.round() |> Decimal.to_integer() - Map.put(params, :amount, amount_cents) - else - params - end - - params = - case Keyword.get(opts, :reason) do - nil -> params - reason -> Map.put(params, :reason, reason) - end - - case stripe_request(:post, "/refunds", params, config) do - {:ok, %{"id" => id, "amount" => amount_cents, "status" => status}} -> - {:ok, - %RefundResult{ - id: id, - provider_refund_id: id, - amount: Decimal.div(Decimal.new(amount_cents), 100), - status: status, - metadata: %{} - }} - - {:error, %{"code" => "charge_already_refunded"}} -> - {:error, :already_refunded} - - {:error, reason} -> - Logger.error("Stripe refund failed: #{inspect(reason)}") - {:error, reason} - end - end - end - - @doc """ - Gets details of a saved payment method from Stripe. - - ## Examples - - iex> get_payment_method_details("pm_xxx") - {:ok, %{id: "pm_xxx", type: "card", brand: "visa", last4: "4242", ...}} - """ - @impl true - def get_payment_method_details(provider_payment_method_id) do - with {:ok, config} <- ensure_configured() do - case stripe_request(:get, "/payment_methods/#{provider_payment_method_id}", nil, config) do - {:ok, - %{ - "id" => id, - "type" => type, - "card" => %{ - "brand" => brand, - "last4" => last4, - "exp_month" => exp_month, - "exp_year" => exp_year - } - }} -> - {:ok, - %PaymentMethodInfo{ - id: id, - provider: :stripe, - provider_payment_method_id: id, - provider_customer_id: nil, - type: type, - brand: brand, - last4: last4, - exp_month: exp_month, - exp_year: exp_year, - metadata: %{} - }} - - {:ok, %{"id" => id, "type" => type}} -> - {:ok, - %PaymentMethodInfo{ - id: id, - provider: :stripe, - provider_payment_method_id: id, - provider_customer_id: nil, - type: type, - brand: nil, - last4: nil, - exp_month: nil, - exp_year: nil, - metadata: %{} - }} - - {:error, %{"code" => "resource_missing"}} -> - {:error, :not_found} - - {:error, reason} -> - Logger.error("Stripe get payment method failed: #{inspect(reason)}") - {:error, reason} - end - end - end - - @doc """ - Detaches a payment method from its customer. - - ## Examples - - iex> detach_payment_method("pm_xxx") - :ok - """ - @impl true - def detach_payment_method(provider_payment_method_id) do - with {:ok, config} <- ensure_configured() do - case stripe_request( - :post, - "/payment_methods/#{provider_payment_method_id}/detach", - %{}, - config - ) do - {:ok, _} -> :ok - {:error, %{"code" => "resource_missing"}} -> {:error, :not_found} - {:error, reason} -> {:error, reason} - end - end - end - - # =========================================== - # Private Helpers - # =========================================== - - defp get_config do - %{ - enabled: Settings.get_setting("billing_stripe_enabled", "false") == "true", - api_key: Settings.get_setting("billing_stripe_api_key", ""), - webhook_secret: Settings.get_setting("billing_stripe_webhook_secret", "") - } - end - - defp ensure_configured do - config = get_config() - - if config[:enabled] && config[:api_key] && config[:api_key] != "" do - {:ok, config} - else - {:error, :not_configured} - end - end - - defp stripe_request(method, path, body, config) do - url = "https://api.stripe.com/v1#{path}" - - headers = [ - {"Authorization", "Bearer #{config[:api_key]}"}, - {"Content-Type", "application/x-www-form-urlencoded"}, - {"Stripe-Version", @stripe_api_version} - ] - - body_encoded = if body, do: encode_body(body), else: "" - - request = - case method do - :get -> Req.new(method: :get, url: url, headers: headers) - :post -> Req.new(method: :post, url: url, headers: headers, body: body_encoded) - end - - case Req.request(request) do - {:ok, %{status: status, body: response_body}} when status in 200..299 -> - {:ok, response_body} - - {:ok, %{status: _status, body: %{"error" => error}}} -> - {:error, error} - - {:ok, %{status: status, body: body}} -> - {:error, %{"status" => status, "body" => body}} - - {:error, reason} -> - {:error, reason} - end - end - - defp encode_body(map) when is_map(map) do - map - |> flatten_map() - |> URI.encode_query() - end - - defp flatten_map(map, prefix \\ "") do - Enum.flat_map(map, fn {key, value} -> - new_key = if prefix == "", do: to_string(key), else: "#{prefix}[#{key}]" - flatten_value(new_key, value) - end) - end - - defp flatten_value(key, %{} = nested), do: flatten_map(nested, key) - - defp flatten_value(key, list) when is_list(list) do - list - |> Enum.with_index() - |> Enum.flat_map(fn {item, idx} -> flatten_list_item(key, item, idx) end) - end - - defp flatten_value(key, value), do: [{key, to_string(value)}] - - defp flatten_list_item(key, item, idx) when is_map(item) do - flatten_map(item, "#{key}[#{idx}]") - end - - defp flatten_list_item(key, item, idx) do - [{"#{key}[#{idx}]", to_string(item)}] - end - - defp build_line_items(invoice) do - (invoice.line_items || []) - |> Enum.map(fn item -> - %{ - price_data: %{ - currency: String.downcase(invoice.currency || "EUR"), - product_data: %{ - name: item["name"] || "Item" - }, - unit_amount: parse_amount_cents(item["unit_price"]) - }, - quantity: item["quantity"] || 1 - } - end) - end - - defp parse_amount_cents(nil), do: 0 - - defp parse_amount_cents(amount) when is_binary(amount) do - amount - |> Decimal.new() - |> Decimal.mult(100) - |> Decimal.round() - |> Decimal.to_integer() - end - - defp parse_amount_cents(%Decimal{} = amount) do - amount - |> Decimal.mult(100) - |> Decimal.round() - |> Decimal.to_integer() - end - - defp parse_amount_cents(amount) when is_number(amount) do - round(amount * 100) - end - - defp maybe_add_customer_email(params, invoice, opts) do - email = Keyword.get(opts, :customer_email) || get_invoice_email(invoice) - - if email do - Map.put(params, :customer_email, email) - else - params - end - end - - defp get_invoice_email(invoice) do - case invoice do - %{billing_details: %{"email" => email}} when is_binary(email) -> email - %{user: %{email: email}} when is_binary(email) -> email - _ -> nil - end - end - - defp maybe_add_save_payment_method(params, opts) do - if Keyword.get(opts, :save_payment_method, false) do - Map.merge(params, %{ - payment_intent_data: %{ - setup_future_usage: "off_session" - } - }) - else - params - end - end - - defp maybe_add_custom_metadata(params, opts) do - case Keyword.get(opts, :metadata) do - nil -> params - custom -> Map.update!(params, :metadata, &Map.merge(&1, custom)) - end - end - - defp maybe_merge_invoice_metadata(metadata, opts) do - case Keyword.get(opts, :invoice_uuid) do - nil -> metadata - invoice_uuid -> Map.put(metadata, :invoice_uuid, to_string(invoice_uuid)) - end - end - - defp ensure_customer(user, config) do - # Check if user already has a Stripe customer ID from saved payment methods - case get_stripe_customer_id_for_user(user.uuid) do - nil -> - # Create new customer - params = %{ - email: user.email, - metadata: %{ - user_uuid: to_string(user.uuid) - } - } - - case stripe_request(:post, "/customers", params, config) do - {:ok, %{"id" => customer_id}} -> - {:ok, customer_id} - - {:error, reason} -> - {:error, reason} - end - - customer_id -> - {:ok, customer_id} - end - end - - defp get_stripe_customer_id_for_user(user_uuid) do - import Ecto.Query - - query = - from pm in PhoenixKit.Modules.Billing.PaymentMethod, - where: pm.user_uuid == ^user_uuid, - where: pm.provider == "stripe", - where: not is_nil(pm.provider_customer_id), - where: pm.status == "active", - select: pm.provider_customer_id, - limit: 1 - - PhoenixKit.RepoHelper.repo().one(query) - end - - defp parse_signature(signature) do - parts = - signature - |> String.split(",") - |> Enum.map(fn part -> - case String.split(part, "=", parts: 2) do - [key, value] -> {key, value} - _ -> nil - end - end) - |> Enum.reject(&is_nil/1) - |> Map.new() - - {:ok, parts} - rescue - _ -> {:error, :invalid_format} - end - - defp verify_timestamp(timestamp) do - # Stripe recommends rejecting webhooks older than 5 minutes - timestamp_int = String.to_integer(timestamp) - now = System.system_time(:second) - tolerance = 300 - - if abs(now - timestamp_int) <= tolerance do - :ok - else - {:error, :timestamp_too_old} - end - rescue - _ -> {:error, :invalid_timestamp} - end - - defp verify_signature(payload, timestamp, expected_sig, secret) do - signed_payload = "#{timestamp}.#{payload}" - - computed_sig = - :crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower) - - if Plug.Crypto.secure_compare(computed_sig, expected_sig) do - :ok - else - {:error, :signature_mismatch} - end - end - - defp normalize_event("checkout.session.completed", object) do - {:ok, - %{ - type: "checkout.completed", - data: %{ - session_id: object["id"], - payment_status: object["payment_status"], - customer_id: object["customer"], - customer_email: object["customer_email"], - payment_intent_id: object["payment_intent"], - setup_intent_id: object["setup_intent"], - invoice_uuid: - get_in(object, ["metadata", "invoice_uuid"]) || - get_in(object, ["metadata", "invoice_id"]), - mode: object["mode"], - amount_total: object["amount_total"], - currency: object["currency"] - } - }} - end - - defp normalize_event("checkout.session.expired", object) do - {:ok, - %{ - type: "checkout.expired", - data: %{ - session_id: object["id"], - invoice_uuid: - get_in(object, ["metadata", "invoice_uuid"]) || - get_in(object, ["metadata", "invoice_id"]) - } - }} - end - - defp normalize_event("payment_intent.succeeded", object) do - {:ok, - %{ - type: "payment.succeeded", - data: %{ - payment_intent_id: object["id"], - charge_id: object["latest_charge"], - amount: object["amount"], - currency: object["currency"], - customer_id: object["customer"], - provider_payment_method_id: object["payment_method"], - invoice_uuid: - get_in(object, ["metadata", "invoice_uuid"]) || - get_in(object, ["metadata", "invoice_id"]) - } - }} - end - - defp normalize_event("payment_intent.payment_failed", object) do - {:ok, - %{ - type: "payment.failed", - data: %{ - payment_intent_id: object["id"], - error_code: get_in(object, ["last_payment_error", "code"]), - error_message: get_in(object, ["last_payment_error", "message"]), - customer_id: object["customer"], - invoice_uuid: - get_in(object, ["metadata", "invoice_uuid"]) || - get_in(object, ["metadata", "invoice_id"]) - } - }} - end - - defp normalize_event("charge.refunded", object) do - {:ok, - %{ - type: "refund.created", - data: %{ - charge_id: object["id"], - amount_refunded: object["amount_refunded"], - currency: object["currency"], - refund_id: List.first(object["refunds"]["data"] || [])["id"] - } - }} - end - - defp normalize_event("setup_intent.succeeded", object) do - {:ok, - %{ - type: "setup.completed", - data: %{ - setup_intent_id: object["id"], - provider_payment_method_id: object["payment_method"], - customer_id: object["customer"], - user_uuid: - get_in(object, ["metadata", "user_uuid"]) || - get_in(object, ["metadata", "user_id"]) - } - }} - end - - defp normalize_event(_type, _object) do - {:error, :unknown_event} - end -end diff --git a/lib/modules/billing/providers/types/charge_result.ex b/lib/modules/billing/providers/types/charge_result.ex deleted file mode 100644 index ae2a8dbbf..000000000 --- a/lib/modules/billing/providers/types/charge_result.ex +++ /dev/null @@ -1,26 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers.Types.ChargeResult do - @moduledoc """ - Struct returned by `Provider.charge_payment_method/3`. - - ## Fields - - - `id` - Provider-specific charge/payment identifier - - `provider_transaction_id` - Provider's transaction ID for tracking - - `amount` - Charged amount as Decimal - - `currency` - Currency code (e.g., `"EUR"`, `"USD"`) - - `status` - Charge status (e.g., `"succeeded"`) - - `metadata` - Provider-specific metadata - """ - - @enforce_keys [:id, :status] - defstruct [:id, :provider_transaction_id, :amount, :currency, :status, metadata: %{}] - - @type t :: %__MODULE__{ - id: String.t(), - provider_transaction_id: String.t() | nil, - amount: Decimal.t() | nil, - currency: String.t() | nil, - status: String.t(), - metadata: map() - } -end diff --git a/lib/modules/billing/providers/types/checkout_session.ex b/lib/modules/billing/providers/types/checkout_session.ex deleted file mode 100644 index a5f2d7bac..000000000 --- a/lib/modules/billing/providers/types/checkout_session.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers.Types.CheckoutSession do - @moduledoc """ - Struct returned by `Provider.create_checkout_session/2`. - - ## Fields - - - `id` - Provider-specific session identifier - - `url` - Redirect URL for the hosted checkout page - - `provider` - Provider atom (`:stripe`, `:paypal`, `:razorpay`) - - `expires_at` - When the session expires (nil if no expiry) - - `metadata` - Provider-specific metadata - """ - - @enforce_keys [:id, :url, :provider] - defstruct [:id, :url, :provider, :expires_at, metadata: %{}] - - @type t :: %__MODULE__{ - id: String.t(), - url: String.t(), - provider: atom(), - expires_at: DateTime.t() | nil, - metadata: map() - } -end diff --git a/lib/modules/billing/providers/types/payment_method_info.ex b/lib/modules/billing/providers/types/payment_method_info.ex deleted file mode 100644 index afdf98ba0..000000000 --- a/lib/modules/billing/providers/types/payment_method_info.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers.Types.PaymentMethodInfo do - @moduledoc """ - Struct returned by `Provider.get_payment_method_details/1`. - - Named `PaymentMethodInfo` to avoid clash with the `PaymentMethod` Ecto schema. - - ## Fields - - - `id` - Provider-specific payment method identifier - - `provider` - Provider atom (`:stripe`, `:paypal`, `:razorpay`) - - `provider_payment_method_id` - Provider's payment method ID - - `provider_customer_id` - Provider's customer ID (nil if unknown) - - `type` - Payment method type (e.g., `"card"`, `"paypal"`) - - `brand` - Card brand (e.g., `"visa"`, `"mastercard"`) or nil - - `last4` - Last 4 digits of card number or nil - - `exp_month` - Expiration month or nil - - `exp_year` - Expiration year or nil - - `metadata` - Provider-specific metadata - """ - - @enforce_keys [:id, :provider, :provider_payment_method_id] - defstruct [ - :id, - :provider, - :provider_payment_method_id, - :provider_customer_id, - :type, - :brand, - :last4, - :exp_month, - :exp_year, - metadata: %{} - ] - - @type t :: %__MODULE__{ - id: String.t(), - provider: atom(), - provider_payment_method_id: String.t(), - provider_customer_id: String.t() | nil, - type: String.t(), - brand: String.t() | nil, - last4: String.t() | nil, - exp_month: integer() | nil, - exp_year: integer() | nil, - metadata: map() - } -end diff --git a/lib/modules/billing/providers/types/provider_info.ex b/lib/modules/billing/providers/types/provider_info.ex deleted file mode 100644 index e1fe5e87d..000000000 --- a/lib/modules/billing/providers/types/provider_info.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers.Types.ProviderInfo do - @moduledoc """ - Struct for payment provider display information. - - ## Fields - - - `name` - Human-readable provider name (e.g., `"Stripe"`) - - `icon` - Icon identifier for rendering - - `color` - Brand color hex code - - `description` - Short description of the provider - """ - - @enforce_keys [:name, :icon, :color] - defstruct [:name, :icon, :color, :description] - - @type t :: %__MODULE__{ - name: String.t(), - icon: String.t(), - color: String.t(), - description: String.t() | nil - } -end diff --git a/lib/modules/billing/providers/types/refund_result.ex b/lib/modules/billing/providers/types/refund_result.ex deleted file mode 100644 index d4268a0f9..000000000 --- a/lib/modules/billing/providers/types/refund_result.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers.Types.RefundResult do - @moduledoc """ - Struct returned by `Provider.create_refund/3`. - - ## Fields - - - `id` - Provider-specific refund identifier - - `provider_refund_id` - Provider's refund ID for tracking - - `amount` - Refunded amount as Decimal or integer (provider-dependent) - - `status` - Refund status (e.g., `"succeeded"`, `"pending"`) - - `metadata` - Provider-specific metadata - """ - - @enforce_keys [:id, :status] - defstruct [:id, :provider_refund_id, :amount, :status, metadata: %{}] - - @type t :: %__MODULE__{ - id: String.t(), - provider_refund_id: String.t() | nil, - amount: Decimal.t() | integer() | nil, - status: String.t(), - metadata: map() - } -end diff --git a/lib/modules/billing/providers/types/setup_session.ex b/lib/modules/billing/providers/types/setup_session.ex deleted file mode 100644 index 8653a96f4..000000000 --- a/lib/modules/billing/providers/types/setup_session.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers.Types.SetupSession do - @moduledoc """ - Struct returned by `Provider.create_setup_session/2`. - - ## Fields - - - `id` - Provider-specific session identifier - - `url` - Redirect URL for saving a payment method - - `provider` - Provider atom (`:stripe`, `:paypal`, `:razorpay`) - - `metadata` - Provider-specific metadata - """ - - @enforce_keys [:id, :url, :provider] - defstruct [:id, :url, :provider, metadata: %{}] - - @type t :: %__MODULE__{ - id: String.t(), - url: String.t(), - provider: atom(), - metadata: map() - } -end diff --git a/lib/modules/billing/providers/types/webhook_event_data.ex b/lib/modules/billing/providers/types/webhook_event_data.ex deleted file mode 100644 index e4e811945..000000000 --- a/lib/modules/billing/providers/types/webhook_event_data.ex +++ /dev/null @@ -1,26 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Providers.Types.WebhookEventData do - @moduledoc """ - Struct returned by `Provider.handle_webhook_event/1`. - - Named `WebhookEventData` to avoid clash with the `WebhookEvent` Ecto schema. - - ## Fields - - - `type` - Normalized event type (e.g., `"checkout.completed"`, `"payment.succeeded"`) - - `event_id` - Provider-specific event identifier - - `data` - Normalized event payload - - `provider` - Provider atom (`:stripe`, `:paypal`, `:razorpay`) - - `raw_payload` - Original provider payload - """ - - @enforce_keys [:type, :event_id, :provider] - defstruct [:type, :event_id, :provider, data: %{}, raw_payload: %{}] - - @type t :: %__MODULE__{ - type: String.t(), - event_id: String.t(), - data: map(), - provider: atom(), - raw_payload: map() - } -end diff --git a/lib/modules/billing/schemas/billing_profile.ex b/lib/modules/billing/schemas/billing_profile.ex deleted file mode 100644 index a75d1df50..000000000 --- a/lib/modules/billing/schemas/billing_profile.ex +++ /dev/null @@ -1,269 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.BillingProfile do - @moduledoc """ - Billing profile schema for PhoenixKit Billing system. - - Stores user billing information for individuals and companies (EU Standard). - Used for generating invoices and order billing snapshots. - - ## Schema Fields - - ### Profile Identity - - `user_uuid`: Foreign key to the user - - `type`: Profile type - "individual" or "company" - - `is_default`: Whether this is the user's default billing profile - - `name`: Display name for the profile - - ### Individual Fields - - `first_name`, `last_name`, `middle_name`: Person's name - - `phone`: Contact phone number - - `email`: Billing email (can differ from user email) - - ### Company Fields (EU Standard) - - `company_name`: Legal company name - - `company_vat_number`: EU VAT Number (e.g., "EE123456789") - - `company_registration_number`: Company registration number - - `company_legal_address`: Registered legal address - - ### Billing Address - - `address_line1`, `address_line2`: Street address - - `city`, `state`, `postal_code`, `country`: Location - - ## Usage Examples - - # Create individual billing profile - {:ok, profile} = Billing.create_billing_profile(user, %{ - type: "individual", - first_name: "John", - last_name: "Doe", - email: "john@example.com", - address_line1: "123 Main St", - city: "Tallinn", - country: "EE", - is_default: true - }) - - # Create company billing profile - {:ok, profile} = Billing.create_billing_profile(user, %{ - type: "company", - company_name: "Acme Corp OÜ", - company_vat_number: "EE123456789", - company_registration_number: "12345678", - address_line1: "Business Park 1", - city: "Tallinn", - country: "EE" - }) - """ - - use Ecto.Schema - import Ecto.Changeset - import Ecto.Query, warn: false - - alias PhoenixKit.Modules.Billing.CountryData - alias PhoenixKit.Users.Auth.User - alias PhoenixKit.Utils.Date, as: UtilsDate - - @primary_key {:uuid, UUIDv7, autogenerate: true} - @valid_types ~w(individual company) - - schema "phoenix_kit_billing_profiles" do - field :type, :string, default: "individual" - field :is_default, :boolean, default: false - field :name, :string - - # Individual fields - field :first_name, :string - field :last_name, :string - field :middle_name, :string - field :phone, :string - field :email, :string - - # Company fields (EU Standard) - field :company_name, :string - field :company_vat_number, :string - field :company_registration_number, :string - field :company_legal_address, :string - - # Billing address - field :address_line1, :string - field :address_line2, :string - field :city, :string - field :state, :string - field :postal_code, :string - field :country, :string, default: "EE" - - field :metadata, :map, default: %{} - - belongs_to :user, User, foreign_key: :user_uuid, references: :uuid, type: UUIDv7 - - timestamps(type: :utc_datetime) - end - - @doc """ - Creates a changeset for billing profile creation and updates. - """ - def changeset(profile, attrs) do - profile - |> cast(attrs, [ - :user_uuid, - :type, - :is_default, - :name, - :first_name, - :last_name, - :middle_name, - :phone, - :email, - :company_name, - :company_vat_number, - :company_registration_number, - :company_legal_address, - :address_line1, - :address_line2, - :city, - :state, - :postal_code, - :country, - :metadata - ]) - |> validate_required([:user_uuid, :type]) - |> validate_inclusion(:type, @valid_types) - |> validate_length(:country, is: 2) - |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must be a valid email address") - |> validate_type_specific_fields() - |> validate_vat_number() - |> maybe_set_display_name() - |> foreign_key_constraint(:user_uuid) - end - - defp validate_type_specific_fields(changeset) do - type = get_field(changeset, :type) - - case type do - "individual" -> - changeset - |> validate_required([:first_name, :last_name], message: "is required for individuals") - - "company" -> - changeset - |> validate_required([:company_name], message: "is required for companies") - - _ -> - changeset - end - end - - defp validate_vat_number(changeset) do - vat = get_field(changeset, :company_vat_number) - country = get_field(changeset, :country) - - cond do - is_nil(vat) or vat == "" -> - changeset - - CountryData.eu_member?(country) -> - # Basic EU VAT format validation - if Regex.match?(~r/^[A-Z]{2}[0-9A-Z]{2,12}$/, String.upcase(vat)) do - put_change(changeset, :company_vat_number, String.upcase(vat)) - else - add_error( - changeset, - :company_vat_number, - "must be a valid EU VAT number (e.g., #{country}123456789)" - ) - end - - true -> - changeset - end - end - - defp maybe_set_display_name(changeset) do - if get_field(changeset, :name) do - changeset - else - type = get_field(changeset, :type) - - name = - case type do - "individual" -> - first = get_field(changeset, :first_name) || "" - last = get_field(changeset, :last_name) || "" - String.trim("#{first} #{last}") - - "company" -> - get_field(changeset, :company_name) || "" - - _ -> - "" - end - - if name != "" do - put_change(changeset, :name, name) - else - changeset - end - end - end - - @doc """ - Returns a snapshot of billing profile for order/invoice storage. - - This creates an immutable copy of billing details at a point in time. - """ - def to_snapshot(%__MODULE__{} = profile) do - %{ - profile_uuid: profile.uuid, - type: profile.type, - name: profile.name, - # Individual - first_name: profile.first_name, - last_name: profile.last_name, - middle_name: profile.middle_name, - phone: profile.phone, - email: profile.email, - # Company - company_name: profile.company_name, - company_vat_number: profile.company_vat_number, - company_registration_number: profile.company_registration_number, - company_legal_address: profile.company_legal_address, - # Address - address_line1: profile.address_line1, - address_line2: profile.address_line2, - city: profile.city, - state: profile.state, - postal_code: profile.postal_code, - country: profile.country, - # Timestamp - snapshot_at: UtilsDate.utc_now() - } - |> Enum.reject(fn {_k, v} -> is_nil(v) end) - |> Map.new() - end - - @doc """ - Returns formatted address as a multi-line string. - """ - def formatted_address(%__MODULE__{} = profile) do - [ - profile.address_line1, - profile.address_line2, - [profile.postal_code, profile.city] |> Enum.reject(&is_nil/1) |> Enum.join(" "), - profile.state, - profile.country - ] - |> Enum.reject(&(is_nil(&1) or &1 == "")) - |> Enum.join("\n") - end - - @doc """ - Returns the display name for the billing profile. - """ - def display_name(%__MODULE__{name: name}) when is_binary(name) and name != "", do: name - - def display_name(%__MODULE__{type: "individual", first_name: first, last_name: last}) do - "#{first} #{last}" |> String.trim() - end - - def display_name(%__MODULE__{type: "company", company_name: name}), do: name || "" - def display_name(_), do: "" -end diff --git a/lib/modules/billing/schemas/currency.ex b/lib/modules/billing/schemas/currency.ex deleted file mode 100644 index b549c842b..000000000 --- a/lib/modules/billing/schemas/currency.ex +++ /dev/null @@ -1,154 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Currency do - @moduledoc """ - Currency schema for PhoenixKit Billing system. - - Manages supported currencies with exchange rates for multi-currency billing. - - ## Schema Fields - - - `code`: ISO 4217 currency code (e.g., "EUR", "USD", "GBP") - - `name`: Full currency name (e.g., "Euro", "US Dollar") - - `symbol`: Currency symbol (e.g., "€", "$", "£") - - `decimal_places`: Number of decimal places (usually 2) - - `is_default`: Whether this is the default currency - - `enabled`: Whether currency is available for use - - `exchange_rate`: Rate relative to base currency - - `sort_order`: Display order in currency lists - - ## Usage Examples - - # List all enabled currencies - currencies = PhoenixKit.Modules.Billing.list_currencies() - - # Get default currency - currency = PhoenixKit.Modules.Billing.get_default_currency() - - # Format amount in currency - PhoenixKit.Modules.Billing.Currency.format_amount(99.99, currency) - # => "€99.99" - """ - - use Ecto.Schema - import Ecto.Changeset - import Ecto.Query, warn: false - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_currencies" do - field :code, :string - field :name, :string - field :symbol, :string - field :decimal_places, :integer, default: 2 - field :is_default, :boolean, default: false - field :enabled, :boolean, default: true - field :exchange_rate, :decimal, default: Decimal.new("1.0") - field :sort_order, :integer, default: 0 - - timestamps(type: :utc_datetime) - end - - @doc """ - Creates a changeset for currency creation and updates. - """ - def changeset(currency, attrs) do - currency - |> cast(attrs, [ - :code, - :name, - :symbol, - :decimal_places, - :is_default, - :enabled, - :exchange_rate, - :sort_order - ]) - |> validate_required([:code, :name, :symbol]) - |> validate_length(:code, is: 3) - |> validate_length(:symbol, min: 1, max: 5) - |> validate_number(:decimal_places, greater_than_or_equal_to: 0, less_than_or_equal_to: 4) - |> validate_number(:exchange_rate, greater_than: 0) - |> unique_constraint(:code) - |> upcase_code() - end - - defp upcase_code(changeset) do - case get_change(changeset, :code) do - nil -> changeset - code -> put_change(changeset, :code, String.upcase(code)) - end - end - - @doc """ - Formats an amount with currency symbol. - - ## Examples - - iex> currency = %Currency{symbol: "€", decimal_places: 2} - iex> Currency.format_amount(Decimal.new("99.99"), currency) - "€99.99" - - iex> Currency.format_amount(1234.5, currency) - "€1,234.50" - """ - def format_amount(amount, %__MODULE__{symbol: symbol, decimal_places: places}) do - amount - |> to_decimal() - |> Decimal.round(places) - |> format_with_thousands() - |> then(&"#{symbol}#{&1}") - end - - @doc """ - Formats an amount without currency symbol. - """ - def format_amount_plain(amount, %__MODULE__{decimal_places: places}) do - amount - |> to_decimal() - |> Decimal.round(places) - |> format_with_thousands() - end - - defp to_decimal(%Decimal{} = d), do: d - defp to_decimal(n) when is_number(n), do: Decimal.from_float(n * 1.0) - defp to_decimal(s) when is_binary(s), do: Decimal.new(s) - - defp format_with_thousands(decimal) do - decimal - |> Decimal.to_string(:normal) - |> String.split(".") - |> case do - [integer] -> - format_integer_part(integer) - - [integer, fraction] -> - "#{format_integer_part(integer)}.#{fraction}" - end - end - - defp format_integer_part(str) do - str - |> String.reverse() - |> String.graphemes() - |> Enum.chunk_every(3) - |> Enum.join(",") - |> String.reverse() - end - - @doc """ - Converts amount from one currency to another. - - ## Examples - - iex> from = %Currency{exchange_rate: Decimal.new("1.0")} # EUR (base) - iex> to = %Currency{exchange_rate: Decimal.new("1.1")} # USD - iex> Currency.convert(100, from, to) - Decimal.new("110.00") - """ - def convert(amount, %__MODULE__{exchange_rate: from_rate}, %__MODULE__{exchange_rate: to_rate}) do - amount - |> to_decimal() - |> Decimal.div(from_rate) - |> Decimal.mult(to_rate) - |> Decimal.round(2) - end -end diff --git a/lib/modules/billing/schemas/invoice.ex b/lib/modules/billing/schemas/invoice.ex deleted file mode 100644 index 3a1ddbe5f..000000000 --- a/lib/modules/billing/schemas/invoice.ex +++ /dev/null @@ -1,399 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Invoice do - @moduledoc """ - Invoice schema for PhoenixKit Billing system. - - Invoices are generated from orders and sent to customers for payment. - They include receipt functionality once payment is confirmed. - - ## Schema Fields - - ### Identity & Relations - - `user_uuid`: Foreign key to the user - - `order_uuid`: Foreign key to the source order (optional) - - `invoice_number`: Unique invoice identifier (e.g., "INV-2024-0001") - - `status`: Invoice status workflow - - ### Financial - - `subtotal`, `tax_amount`, `tax_rate`, `total`: Financial amounts - - `currency`: ISO 4217 currency code - - `due_date`: Payment due date - - ### Billing Details - - `billing_details`: Full snapshot of billing profile - - `line_items`: Copy of order line items - - `payment_terms`: Payment terms text - - `bank_details`: Bank account for payment - - ### Receipt - - `receipt_number`: Receipt identifier (generated after payment) - - `receipt_generated_at`: When receipt was generated - - `receipt_data`: Additional receipt data (PDF URL, etc.) - - ## Status Workflow - - ``` - draft → sent → paid - ↘ - overdue → paid - ↘ - void - ``` - - ## Usage Examples - - # Generate invoice from order - {: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) - - # Get receipt - receipt = Billing.get_receipt(invoice) - """ - - use Ecto.Schema - import Ecto.Changeset - import Ecto.Query, warn: false - - alias PhoenixKit.Modules.Billing.Order - alias PhoenixKit.Modules.Billing.Transaction - alias PhoenixKit.Users.Auth.User - alias PhoenixKit.Utils.Date, as: UtilsDate - - @primary_key {:uuid, UUIDv7, autogenerate: true} - @valid_statuses ~w(draft sent paid void overdue) - - schema "phoenix_kit_invoices" do - field :invoice_number, :string - field :status, :string, default: "draft" - - # Financial - field :subtotal, :decimal, default: Decimal.new("0") - field :tax_amount, :decimal, default: Decimal.new("0") - field :tax_rate, :decimal, default: Decimal.new("0") - field :total, :decimal - field :paid_amount, :decimal, default: Decimal.new("0") - field :currency, :string, default: "EUR" - field :due_date, :date - - # Billing details (snapshot) - field :billing_details, :map, default: %{} - field :line_items, {:array, :map}, default: [] - field :payment_terms, :string - field :bank_details, :map, default: %{} - field :notes, :string - - field :metadata, :map, default: %{} - - # Receipt (integrated) - field :receipt_number, :string - field :receipt_generated_at, :utc_datetime - field :receipt_data, :map, default: %{} - - # Timestamps - field :sent_at, :utc_datetime - field :paid_at, :utc_datetime - field :voided_at, :utc_datetime - - belongs_to :user, User, foreign_key: :user_uuid, references: :uuid, type: UUIDv7 - belongs_to :order, Order, foreign_key: :order_uuid, references: :uuid, type: UUIDv7 - field :subscription_uuid, UUIDv7 - has_many :transactions, Transaction, foreign_key: :invoice_uuid, references: :uuid - - timestamps(type: :utc_datetime) - end - - @doc """ - Creates a changeset for invoice creation. - """ - def changeset(invoice, attrs) do - invoice - |> cast(attrs, [ - :user_uuid, - :order_uuid, - :subscription_uuid, - :invoice_number, - :status, - :subtotal, - :tax_amount, - :tax_rate, - :total, - :paid_amount, - :currency, - :due_date, - :billing_details, - :line_items, - :payment_terms, - :bank_details, - :notes, - :metadata, - :receipt_number, - :receipt_generated_at, - :receipt_data, - :sent_at, - :paid_at, - :voided_at - ]) - |> validate_required([:user_uuid, :total, :currency]) - |> validate_inclusion(:status, @valid_statuses) - |> validate_length(:currency, is: 3) - |> validate_number(:total, greater_than_or_equal_to: 0) - |> validate_number(:paid_amount, greater_than_or_equal_to: 0) - |> unique_constraint(:invoice_number) - |> foreign_key_constraint(:user_uuid) - |> foreign_key_constraint(:order_uuid) - end - - @doc """ - Changeset for status transitions. - """ - def status_changeset(invoice, new_status) do - changeset = - invoice - |> change(status: new_status) - |> validate_status_transition(invoice.status, new_status) - - case new_status do - "sent" -> put_change(changeset, :sent_at, UtilsDate.utc_now()) - "paid" -> put_change(changeset, :paid_at, UtilsDate.utc_now()) - "void" -> put_change(changeset, :voided_at, UtilsDate.utc_now()) - _ -> changeset - end - end - - @doc """ - Changeset for marking invoice as paid and generating receipt. - """ - def paid_changeset(invoice, receipt_number) do - now = UtilsDate.utc_now() - - invoice - |> change(%{ - status: "paid", - paid_at: now, - receipt_number: receipt_number, - receipt_generated_at: now, - receipt_data: %{ - generated_at: DateTime.to_iso8601(now), - amount_paid: Decimal.to_string(invoice.total), - currency: invoice.currency - } - }) - |> validate_status_transition(invoice.status, "paid") - end - - defp validate_status_transition(changeset, from, to) do - valid_transitions = %{ - "draft" => ~w(sent void), - "sent" => ~w(paid overdue void), - "overdue" => ~w(paid void), - "paid" => ~w(void), - "void" => [] - } - - allowed = Map.get(valid_transitions, from, []) - - if to in allowed do - changeset - else - add_error(changeset, :status, "cannot transition from #{from} to #{to}") - end - end - - @doc """ - Creates an invoice from an order. - """ - def from_order(%Order{} = order, opts \\ []) do - due_days = Keyword.get(opts, :due_days, 14) - invoice_number = Keyword.get(opts, :invoice_number) - bank_details = Keyword.get(opts, :bank_details, %{}) - payment_terms = Keyword.get(opts, :payment_terms) - - %__MODULE__{ - user_uuid: order.user_uuid, - order_uuid: order.uuid, - invoice_number: invoice_number, - status: "draft", - subtotal: order.subtotal, - tax_amount: order.tax_amount, - tax_rate: order.tax_rate, - total: order.total, - currency: order.currency, - due_date: Date.add(Date.utc_today(), due_days), - billing_details: order.billing_snapshot, - line_items: order.line_items, - payment_terms: payment_terms, - bank_details: bank_details, - notes: order.notes - } - end - - @doc """ - Checks if invoice can be edited. - """ - def editable?(%__MODULE__{status: "draft"}), do: true - def editable?(_), do: false - - @doc """ - Checks if invoice can be sent (first time - changes status to sent). - """ - def sendable?(%__MODULE__{status: "draft"}), do: true - def sendable?(_), do: false - - @doc """ - Checks if invoice can be resent (already sent, paid, or overdue). - """ - def resendable?(%__MODULE__{status: status}) when status in ~w(sent paid overdue), do: true - def resendable?(_), do: false - - @doc """ - Checks if invoice can be marked as paid. - """ - def payable?(%__MODULE__{status: status}) when status in ~w(sent overdue), do: true - def payable?(_), do: false - - @doc """ - Checks if invoice can be voided. - """ - def voidable?(%__MODULE__{status: status}) when status in ~w(draft sent overdue), do: true - def voidable?(_), do: false - - @doc """ - Checks if invoice has a receipt. - """ - def has_receipt?(%__MODULE__{receipt_number: nil}), do: false - def has_receipt?(%__MODULE__{receipt_number: _}), do: true - - @doc """ - Checks if invoice is overdue. - """ - def overdue?(%__MODULE__{status: "paid"}), do: false - def overdue?(%__MODULE__{status: "void"}), do: false - def overdue?(%__MODULE__{due_date: nil}), do: false - - def overdue?(%__MODULE__{due_date: due_date}) do - Date.compare(due_date, Date.utc_today()) == :lt - end - - @doc """ - Returns human-readable status label. - """ - def status_label("draft"), do: "Draft" - def status_label("sent"), do: "Sent" - def status_label("paid"), do: "Paid" - def status_label("void"), do: "Void" - def status_label("overdue"), do: "Overdue" - def status_label(_), do: "Unknown" - - @doc """ - Returns status badge color class. - """ - def status_color("draft"), do: "badge-neutral" - def status_color("sent"), do: "badge-info" - def status_color("paid"), do: "badge-success" - def status_color("void"), do: "badge-error" - def status_color("overdue"), do: "badge-warning" - def status_color(_), do: "badge-ghost" - - @doc """ - Returns the billing name from billing_details snapshot. - """ - def billing_name(%__MODULE__{billing_details: %{"name" => name}}) when is_binary(name), do: name - - def billing_name(%__MODULE__{billing_details: %{"company_name" => name}}) when is_binary(name), - do: name - - def billing_name(%__MODULE__{billing_details: %{"first_name" => first, "last_name" => last}}) do - "#{first} #{last}" |> String.trim() - end - - def billing_name(_), do: "" - - @doc """ - Returns the remaining amount to be paid. - """ - def remaining_amount(%__MODULE__{total: total, paid_amount: paid_amount}) do - Decimal.sub(total, paid_amount) - end - - @doc """ - Checks if invoice is fully paid (paid_amount >= total). - """ - def fully_paid?(%__MODULE__{total: total, paid_amount: paid_amount}) do - Decimal.compare(paid_amount, total) != :lt - end - - @doc """ - Checks if invoice has any payments (paid_amount > 0). - """ - def has_payments?(%__MODULE__{paid_amount: paid_amount}) do - Decimal.positive?(paid_amount) - end - - @doc """ - Checks if invoice can receive a refund (has payments). - """ - def refundable?(%__MODULE__{} = invoice) do - has_payments?(invoice) - end - - @doc """ - Changeset for updating paid_amount. - """ - def paid_amount_changeset(invoice, paid_amount) do - invoice - |> change(paid_amount: paid_amount) - |> validate_number(:paid_amount, greater_than_or_equal_to: 0) - end - - # ============================================ - # PAYMENT METHODS AGGREGATION - # ============================================ - - @doc """ - Returns all unique payment methods used in transactions for this invoice. - Requires transactions to be preloaded. - - ## Examples - - iex> Invoice.payment_methods(invoice_with_transactions) - ["bank", "stripe"] - - iex> Invoice.payment_methods(invoice_without_transactions) - [] - """ - def payment_methods(%__MODULE__{transactions: txns}) when is_list(txns) do - txns - |> Enum.map(& &1.payment_method) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - end - - def payment_methods(_), do: [] - - @doc """ - Returns the primary payment method (most used in positive transactions). - Useful for display when there are multiple payment methods. - Requires transactions to be preloaded. - - ## Examples - - iex> Invoice.primary_payment_method(invoice) - "stripe" - - iex> Invoice.primary_payment_method(invoice_without_transactions) - nil - """ - def primary_payment_method(%__MODULE__{transactions: txns}) when is_list(txns) do - txns - |> Enum.filter(&Decimal.positive?(&1.amount)) - |> Enum.frequencies_by(& &1.payment_method) - |> Enum.max_by(fn {_method, count} -> count end, fn -> {nil, 0} end) - |> elem(0) - end - - def primary_payment_method(_), do: nil -end diff --git a/lib/modules/billing/schemas/order.ex b/lib/modules/billing/schemas/order.ex deleted file mode 100644 index c1aee69e6..000000000 --- a/lib/modules/billing/schemas/order.ex +++ /dev/null @@ -1,391 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Order do - @moduledoc """ - Order schema for PhoenixKit Billing system. - - Manages orders with line items, amounts, and billing information. - Orders serve as the primary document for tracking what users purchased. - - ## Schema Fields - - ### Identity & Relations - - `user_uuid`: Foreign key to the user who placed the order - - `billing_profile_uuid`: Foreign key to the billing profile used - - `order_number`: Unique order identifier (e.g., "ORD-2024-0001") - - `status`: Order status workflow - - ### Payment - - `payment_method`: Payment method (Phase 1: "bank" only) - - `currency`: ISO 4217 currency code - - ### Line Items - - `line_items`: JSONB array of items purchased - - ### Financial - - `subtotal`: Sum of line items before tax/discount - - `tax_amount`: Calculated tax amount - - `tax_rate`: Applied tax rate (0.20 = 20%) - - `discount_amount`: Discount applied - - `discount_code`: Coupon/referral code used - - `total`: Final amount to be paid - - ### Snapshots & Notes - - `billing_snapshot`: Copy of billing profile at order time - - `notes`: Customer-visible notes - - `internal_notes`: Admin-only notes - - ## Status Workflow - - ``` - draft → pending → confirmed → paid - ↘ ↘ - cancelled refunded - ``` - - ## Line Item Structure - - ```json - [ - { - "name": "Pro Plan - Monthly", - "description": "Professional subscription plan", - "quantity": 1, - "unit_price": "99.00", - "total": "99.00", - "sku": "PLAN-PRO-M" - } - ] - ``` - - ## Usage Examples - - # Create an order - {:ok, order} = Billing.create_order(user, %{ - billing_profile_uuid: profile.uuid, - currency: "EUR", - line_items: [ - %{name: "Pro Plan", quantity: 1, unit_price: "99.00", total: "99.00"} - ], - subtotal: "99.00", - total: "99.00" - }) - - # Confirm order - {:ok, order} = Billing.confirm_order(order) - - # Mark as paid - {:ok, order} = Billing.mark_order_paid(order) - """ - - use Ecto.Schema - import Ecto.Changeset - import Ecto.Query, warn: false - - alias PhoenixKit.Modules.Billing.BillingProfile - alias PhoenixKit.Modules.Billing.CountryData - alias PhoenixKit.Users.Auth.User - alias PhoenixKit.Utils.Date, as: UtilsDate - - @primary_key {:uuid, UUIDv7, autogenerate: true} - @valid_statuses ~w(draft pending confirmed paid cancelled refunded) - @valid_payment_methods ~w(bank stripe paypal razorpay) - - schema "phoenix_kit_orders" do - field :order_number, :string - field :status, :string, default: "draft" - field :payment_method, :string - - # Line items (JSONB) - field :line_items, {:array, :map}, default: [] - - # Financial - field :subtotal, :decimal, default: Decimal.new("0") - field :tax_amount, :decimal, default: Decimal.new("0") - field :tax_rate, :decimal, default: Decimal.new("0") - field :discount_amount, :decimal, default: Decimal.new("0") - field :discount_code, :string - field :total, :decimal - field :currency, :string, default: "EUR" - - # Snapshots - field :billing_snapshot, :map, default: %{} - - # Notes - field :notes, :string - field :internal_notes, :string - - field :metadata, :map, default: %{} - - # Timestamps - field :confirmed_at, :utc_datetime - field :paid_at, :utc_datetime - field :cancelled_at, :utc_datetime - - belongs_to :user, User, foreign_key: :user_uuid, references: :uuid, type: UUIDv7 - - belongs_to :billing_profile, BillingProfile, - foreign_key: :billing_profile_uuid, - references: :uuid, - type: UUIDv7 - - has_many :invoices, PhoenixKit.Modules.Billing.Invoice, - foreign_key: :order_uuid, - references: :uuid - - timestamps(type: :utc_datetime) - end - - @doc """ - Creates a changeset for order creation. - """ - def changeset(order, attrs) do - order - |> cast(attrs, [ - :user_uuid, - :billing_profile_uuid, - :order_number, - :status, - :payment_method, - :line_items, - :subtotal, - :tax_amount, - :tax_rate, - :discount_amount, - :discount_code, - :total, - :currency, - :billing_snapshot, - :notes, - :internal_notes, - :metadata, - :confirmed_at, - :paid_at, - :cancelled_at - ]) - |> validate_required([:total, :currency]) - |> validate_guest_order_billing() - |> validate_inclusion(:status, @valid_statuses) - |> validate_payment_method() - |> validate_length(:currency, is: 3) - |> validate_number(:total, greater_than_or_equal_to: 0) - |> validate_number(:subtotal, greater_than_or_equal_to: 0) - |> validate_number(:tax_amount, greater_than_or_equal_to: 0) - |> validate_number(:discount_amount, greater_than_or_equal_to: 0) - |> validate_line_items() - |> maybe_generate_order_number() - |> unique_constraint(:order_number) - |> foreign_key_constraint(:user_uuid) - |> foreign_key_constraint(:billing_profile_uuid) - end - - # Guest orders must have billing_snapshot with email when no billing_profile_uuid - defp validate_guest_order_billing(changeset) do - billing_profile_uuid = get_field(changeset, :billing_profile_uuid) - billing_snapshot = get_field(changeset, :billing_snapshot) - - cond do - # Has billing profile - OK - not is_nil(billing_profile_uuid) -> - changeset - - # No billing profile but has billing snapshot with email - OK (guest order) - is_map(billing_snapshot) and is_binary(billing_snapshot["email"]) and - billing_snapshot["email"] != "" -> - changeset - - # No billing profile and no valid billing snapshot - error - true -> - add_error(changeset, :billing_snapshot, "must have email for guest orders") - end - end - - @doc """ - Changeset for status transitions. - """ - def status_changeset(order, new_status) do - changeset = - order - |> change(status: new_status) - |> validate_status_transition(order.status, new_status) - - case new_status do - "confirmed" -> - put_change(changeset, :confirmed_at, UtilsDate.utc_now()) - - "paid" -> - put_change(changeset, :paid_at, UtilsDate.utc_now()) - - "cancelled" -> - put_change(changeset, :cancelled_at, UtilsDate.utc_now()) - - _ -> - changeset - end - end - - defp validate_status_transition(changeset, from, to) do - valid_transitions = %{ - "draft" => ~w(pending confirmed cancelled), - "pending" => ~w(confirmed cancelled), - "confirmed" => ~w(paid cancelled refunded), - "paid" => ~w(refunded), - "cancelled" => [], - "refunded" => [] - } - - allowed = Map.get(valid_transitions, from, []) - - if to in allowed do - changeset - else - add_error(changeset, :status, "cannot transition from #{from} to #{to}") - end - end - - # Validate payment_method only when provided (nil is allowed) - defp validate_payment_method(changeset) do - case get_field(changeset, :payment_method) do - nil -> changeset - _ -> validate_inclusion(changeset, :payment_method, @valid_payment_methods) - end - end - - defp validate_line_items(changeset) do - items = get_field(changeset, :line_items) || [] - - errors = - items - |> Enum.with_index() - |> Enum.flat_map(fn {item, idx} -> - cond do - not is_map(item) -> - ["Item #{idx + 1}: must be a map"] - - not Map.has_key?(item, "name") and not Map.has_key?(item, :name) -> - ["Item #{idx + 1}: missing name"] - - true -> - [] - end - end) - - if errors == [] do - changeset - else - add_error(changeset, :line_items, Enum.join(errors, "; ")) - end - end - - defp maybe_generate_order_number(changeset) do - if get_field(changeset, :order_number) do - changeset - else - # Will be set by context with proper prefix from settings - changeset - end - end - - @doc """ - Calculates totals from line items. - - Returns `{subtotal, tax_amount, total}` as Decimals. - """ - def calculate_totals(line_items, tax_rate \\ Decimal.new("0"), discount \\ Decimal.new("0")) do - subtotal = - line_items - |> Enum.reduce(Decimal.new("0"), fn item, acc -> - item_total = - item - |> Map.get("total", Map.get(item, :total, "0")) - |> to_decimal() - - Decimal.add(acc, item_total) - end) - - taxable = Decimal.sub(subtotal, discount) - tax_amount = Decimal.mult(taxable, tax_rate) |> Decimal.round(2) - total = Decimal.add(taxable, tax_amount) - - {subtotal, tax_amount, total} - end - - @doc """ - Calculates totals with automatic tax rate from country. - - Uses standard VAT rate from BeamLabCountries based on the billing country. - Returns `{subtotal, tax_amount, total}` as Decimals. - - ## Examples - - iex> items = [%{"total" => "100.00"}] - iex> {subtotal, tax, total} = Order.calculate_totals_for_country(items, "EE") - iex> Decimal.to_string(tax) - "20.00" - iex> Decimal.to_string(total) - "120.00" - """ - def calculate_totals_for_country(line_items, country_code, discount \\ Decimal.new("0")) do - tax_rate = CountryData.get_standard_vat_rate(country_code) - calculate_totals(line_items, tax_rate, discount) - end - - @doc """ - Gets the standard VAT rate for a country as a Decimal. - - ## Examples - - iex> Order.get_country_tax_rate("EE") - #Decimal<0.20> - - iex> Order.get_country_tax_rate("US") - #Decimal<0> - """ - def get_country_tax_rate(country_code) do - CountryData.get_standard_vat_rate(country_code) - end - - defp to_decimal(%Decimal{} = d), do: d - defp to_decimal(n) when is_number(n), do: Decimal.from_float(n * 1.0) - defp to_decimal(s) when is_binary(s), do: Decimal.new(s) - - @doc """ - Checks if order can be edited (is in draft or pending status). - """ - def editable?(%__MODULE__{status: status}) when status in ~w(draft pending), do: true - def editable?(_), do: false - - @doc """ - Checks if order can be cancelled. - """ - def cancellable?(%__MODULE__{status: status}) when status in ~w(draft pending confirmed), - do: true - - def cancellable?(_), do: false - - @doc """ - Checks if order can be marked as paid. - """ - def payable?(%__MODULE__{status: "confirmed"}), do: true - def payable?(_), do: false - - @doc """ - Returns human-readable status label. - """ - def status_label("draft"), do: "Draft" - def status_label("pending"), do: "Pending" - def status_label("confirmed"), do: "Confirmed" - def status_label("paid"), do: "Paid" - def status_label("cancelled"), do: "Cancelled" - def status_label("refunded"), do: "Refunded" - def status_label(_), do: "Unknown" - - @doc """ - Returns status badge color class. - """ - def status_color("draft"), do: "badge-neutral" - def status_color("pending"), do: "badge-warning" - def status_color("confirmed"), do: "badge-info" - def status_color("paid"), do: "badge-success" - def status_color("cancelled"), do: "badge-error" - def status_color("refunded"), do: "badge-secondary" - def status_color(_), do: "badge-ghost" -end diff --git a/lib/modules/billing/schemas/payment_method.ex b/lib/modules/billing/schemas/payment_method.ex deleted file mode 100644 index 099fc3104..000000000 --- a/lib/modules/billing/schemas/payment_method.ex +++ /dev/null @@ -1,200 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.PaymentMethod do - @moduledoc """ - Schema for saved payment methods (cards, bank accounts, wallets). - - Payment methods are saved via provider setup sessions and can be - used for recurring payments without requiring user interaction. - - ## Provider Integration - - Each provider stores payment method tokens: - - **Stripe**: `pm_*` payment method IDs + `cus_*` customer IDs - - **PayPal**: Billing agreement IDs - - **Razorpay**: Token IDs + customer IDs - - ## Security - - - No raw card data is ever stored - - Only tokenized references from providers - - Tokens are provider-specific and non-transferable - - ## Lifecycle - - - Created via setup session (hosted checkout for saving card) - - Can be set as default for user - - Can be used for subscription renewals - - Can be removed (deletes token from provider) - - Automatically marked expired based on exp_month/exp_year - """ - - use Ecto.Schema - import Ecto.Changeset - - @types ~w(card bank_account wallet paypal) - @statuses ~w(active expired removed failed) - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_payment_methods" do - field :provider, :string - field :provider_payment_method_id, :string - field :provider_customer_id, :string - - # Type and display info - field :type, :string, default: "card" - field :brand, :string - field :last4, :string - field :exp_month, :integer - field :exp_year, :integer - - # Status - field :is_default, :boolean, default: false - field :status, :string, default: "active" - - # Metadata - field :label, :string - field :metadata, :map, default: %{} - - # Association - belongs_to :user, PhoenixKit.Users.Auth.User, - foreign_key: :user_uuid, - references: :uuid, - type: UUIDv7 - - timestamps(type: :utc_datetime) - end - - @doc """ - Creates a changeset for a payment method. - """ - def changeset(payment_method, attrs) do - payment_method - |> cast(attrs, [ - :provider, - :provider_payment_method_id, - :provider_customer_id, - :type, - :brand, - :last4, - :exp_month, - :exp_year, - :is_default, - :status, - :label, - :metadata, - :user_uuid - ]) - |> validate_required([:provider, :provider_payment_method_id, :user_uuid]) - |> validate_inclusion(:type, @types) - |> validate_inclusion(:status, @statuses) - |> validate_number(:exp_month, greater_than: 0, less_than_or_equal_to: 12) - |> validate_number(:exp_year, greater_than_or_equal_to: 2020) - |> foreign_key_constraint(:user_uuid) - |> unique_constraint([:provider, :provider_payment_method_id], - name: :phoenix_kit_payment_methods_provider_pm_id_index - ) - end - - @doc """ - Changeset for setting as default payment method. - """ - def set_default_changeset(payment_method) do - payment_method - |> change(%{is_default: true}) - end - - @doc """ - Changeset for marking as removed. - """ - def remove_changeset(payment_method) do - payment_method - |> change(%{status: "removed"}) - end - - @doc """ - Changeset for marking as expired. - """ - def expire_changeset(payment_method) do - payment_method - |> change(%{status: "expired"}) - end - - # ============================================ - # Status Helpers - # ============================================ - - @doc """ - Returns true if the payment method is usable for charges. - """ - def usable?(%__MODULE__{status: "active"} = pm) do - not expired?(pm) - end - - def usable?(_), do: false - - @doc """ - Returns true if the card has expired based on exp_month/exp_year. - """ - def expired?(%__MODULE__{exp_month: nil}), do: false - def expired?(%__MODULE__{exp_year: nil}), do: false - - def expired?(%__MODULE__{exp_month: month, exp_year: year}) do - now = Date.utc_today() - current_year = now.year - current_month = now.month - - year < current_year or (year == current_year and month < current_month) - end - - @doc """ - Returns a display string for the payment method (e.g., "Visa **** 4242"). - """ - def display_name(%__MODULE__{type: "card", brand: brand, last4: last4}) - when not is_nil(brand) and not is_nil(last4) do - brand_name = String.capitalize(brand || "Card") - "#{brand_name} **** #{last4}" - end - - def display_name(%__MODULE__{type: "paypal"}) do - "PayPal" - end - - def display_name(%__MODULE__{type: "bank_account", last4: last4}) when not is_nil(last4) do - "Bank Account **** #{last4}" - end - - def display_name(%__MODULE__{type: _type, label: label}) when not is_nil(label) do - label - end - - def display_name(%__MODULE__{type: type}) do - String.capitalize(type) - end - - @doc """ - Returns expiration string (e.g., "12/25"). - """ - def expiration_string(%__MODULE__{exp_month: nil}), do: nil - def expiration_string(%__MODULE__{exp_year: nil}), do: nil - - def expiration_string(%__MODULE__{exp_month: month, exp_year: year}) do - month_str = String.pad_leading(to_string(month), 2, "0") - year_str = String.slice(to_string(year), -2, 2) - "#{month_str}/#{year_str}" - end - - @doc """ - Returns the icon class for the card brand (for UI display). - """ - def brand_icon(%__MODULE__{brand: brand}) do - case String.downcase(brand || "") do - "visa" -> "fa-cc-visa" - "mastercard" -> "fa-cc-mastercard" - "amex" -> "fa-cc-amex" - "discover" -> "fa-cc-discover" - "diners" -> "fa-cc-diners-club" - "jcb" -> "fa-cc-jcb" - _ -> "fa-credit-card" - end - end -end diff --git a/lib/modules/billing/schemas/payment_option.ex b/lib/modules/billing/schemas/payment_option.ex deleted file mode 100644 index fcd8941c4..000000000 --- a/lib/modules/billing/schemas/payment_option.ex +++ /dev/null @@ -1,128 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.PaymentOption do - @moduledoc """ - Payment option schema for checkout. - - Represents available payment methods during checkout, including: - - Offline methods: Cash on Delivery (COD), Bank Transfer - - Online methods: Stripe, PayPal, Razorpay - - ## Type - - - `offline` - Payment handled outside the system (COD, bank transfer) - - `online` - Payment processed through a provider (Stripe, PayPal) - - ## Billing Profile Requirement - - Some payment methods (like COD or Bank Transfer) require billing information - for invoicing purposes. Online card payments typically don't need this as - the payment provider handles customer details. - """ - - use Ecto.Schema - import Ecto.Changeset - - @types ~w(offline online) - @codes ~w(cod bank_transfer stripe paypal razorpay) - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_payment_options" do - # Identity - field :name, :string - field :code, :string - field :type, :string, default: "offline" - - # Provider (for online payments) - field :provider, :string - - # Display - field :description, :string - field :instructions, :string - field :icon, :string, default: "hero-banknotes" - - # Configuration - field :active, :boolean, default: false - field :position, :integer, default: 0 - field :requires_billing_profile, :boolean, default: true - - # Additional settings - field :settings, :map, default: %{} - - timestamps(type: :utc_datetime) - end - - @doc """ - Changeset for creating and updating payment options. - """ - def changeset(payment_option, attrs) do - payment_option - |> cast(attrs, [ - :name, - :code, - :type, - :provider, - :description, - :instructions, - :icon, - :active, - :position, - :requires_billing_profile, - :settings - ]) - |> validate_required([:name, :code, :type]) - |> validate_inclusion(:type, @types) - |> validate_inclusion(:code, @codes) - |> unique_constraint(:code) - |> validate_provider() - end - - @doc """ - Returns true if this payment option is an online payment. - """ - def online?(%__MODULE__{type: "online"}), do: true - def online?(_), do: false - - @doc """ - Returns true if this payment option is an offline payment. - """ - def offline?(%__MODULE__{type: "offline"}), do: true - def offline?(_), do: false - - @doc """ - Returns true if this payment option requires a billing profile. - """ - def requires_billing?(%__MODULE__{requires_billing_profile: true}), do: true - def requires_billing?(_), do: false - - @doc """ - Returns list of valid type values. - """ - def types, do: @types - - @doc """ - Returns list of valid code values. - """ - def codes, do: @codes - - @doc """ - Returns the icon name for a payment option. - """ - def icon_name(%__MODULE__{icon: icon}) when is_binary(icon), do: icon - def icon_name(%__MODULE__{code: "cod"}), do: "hero-banknotes" - def icon_name(%__MODULE__{code: "bank_transfer"}), do: "hero-building-library" - def icon_name(%__MODULE__{code: "stripe"}), do: "hero-credit-card" - def icon_name(%__MODULE__{code: "paypal"}), do: "hero-credit-card" - def icon_name(%__MODULE__{code: "razorpay"}), do: "hero-credit-card" - def icon_name(_), do: "hero-credit-card" - - defp validate_provider(changeset) do - type = get_field(changeset, :type) - provider = get_field(changeset, :provider) - - if type == "online" and is_nil(provider) do - add_error(changeset, :provider, "is required for online payment options") - else - changeset - end - end -end diff --git a/lib/modules/billing/schemas/subscription.ex b/lib/modules/billing/schemas/subscription.ex deleted file mode 100644 index 093ccbe82..000000000 --- a/lib/modules/billing/schemas/subscription.ex +++ /dev/null @@ -1,286 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Subscription do - @moduledoc """ - Schema for subscriptions (master record). - - Subscriptions are controlled internally by PhoenixKit, NOT by payment providers. - This allows using any payment provider (even those without subscription APIs) - and provides full control over subscription lifecycle. - - ## Status Lifecycle - - ``` - trialing -> active -> [past_due -> active] -> cancelled - -> paused -> active - -> cancelled - ``` - - - `trialing` - Free trial period active - - `active` - Subscription is active and paid - - `past_due` - Payment failed, in grace period - - `paused` - Subscription temporarily paused by user - - `cancelled` - Subscription ended - - ## Renewal Process - - Renewals are handled by Oban workers: - 1. `SubscriptionRenewalWorker` runs daily, checks subscriptions near period end - 2. Creates invoice for the subscription - 3. Charges saved payment method via provider - 4. On success: extends `current_period_end` - 5. On failure: sets status to `past_due`, increments `renewal_attempts` - - ## Grace Period (Dunning) - - When payment fails: - 1. Status changes to `past_due` - 2. `grace_period_end` is set (configurable days) - 3. `SubscriptionDunningWorker` retries payment - 4. After max attempts or grace period end: subscription cancelled - """ - - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Modules.Billing.{BillingProfile, PaymentMethod, SubscriptionType} - alias PhoenixKit.Users.Auth.User - alias PhoenixKit.Utils.Date, as: UtilsDate - - @statuses ~w(trialing active past_due paused cancelled) - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_subscriptions" do - field :status, :string, default: "active" - - # Billing period - field :current_period_start, :utc_datetime - field :current_period_end, :utc_datetime - - # Cancellation - field :cancel_at_period_end, :boolean, default: false - field :cancelled_at, :utc_datetime - - # Trial - field :trial_start, :utc_datetime - field :trial_end, :utc_datetime - - # Dunning (failed payment handling) - field :grace_period_end, :utc_datetime - field :renewal_attempts, :integer, default: 0 - field :last_renewal_attempt_at, :utc_datetime - - # Metadata - field :metadata, :map, default: %{} - - # Associations - belongs_to :user, User, foreign_key: :user_uuid, references: :uuid, type: UUIDv7 - - belongs_to :billing_profile, BillingProfile, - foreign_key: :billing_profile_uuid, - references: :uuid, - type: UUIDv7 - - belongs_to :subscription_type, SubscriptionType, - foreign_key: :subscription_type_uuid, - references: :uuid, - type: UUIDv7 - - belongs_to :payment_method, PaymentMethod, - foreign_key: :payment_method_uuid, - references: :uuid, - type: UUIDv7 - - timestamps(type: :utc_datetime) - end - - @doc """ - Creates a changeset for creating a new subscription. - """ - def changeset(subscription, attrs) do - subscription - |> cast(attrs, [ - :status, - :current_period_start, - :current_period_end, - :cancel_at_period_end, - :cancelled_at, - :trial_start, - :trial_end, - :grace_period_end, - :renewal_attempts, - :last_renewal_attempt_at, - :metadata, - :user_uuid, - :billing_profile_uuid, - :subscription_type_uuid, - :payment_method_uuid - ]) - |> validate_required([ - :user_uuid, - :subscription_type_uuid, - :current_period_start, - :current_period_end - ]) - |> validate_inclusion(:status, @statuses) - |> foreign_key_constraint(:user_uuid) - |> foreign_key_constraint(:billing_profile_uuid) - |> foreign_key_constraint(:subscription_type_uuid) - |> foreign_key_constraint(:payment_method_uuid) - end - - @doc """ - Changeset for activating a subscription after successful payment. - """ - def activate_changeset(subscription, period_end) do - subscription - |> change(%{ - status: "active", - current_period_end: period_end, - renewal_attempts: 0, - grace_period_end: nil - }) - end - - @doc """ - Changeset for marking subscription as past_due. - """ - def past_due_changeset(subscription, grace_period_end) do - subscription - |> change(%{ - status: "past_due", - grace_period_end: grace_period_end, - renewal_attempts: subscription.renewal_attempts + 1, - last_renewal_attempt_at: UtilsDate.utc_now() - }) - end - - @doc """ - Changeset for pausing a subscription. - """ - def pause_changeset(subscription) do - subscription - |> change(%{status: "paused"}) - end - - @doc """ - Changeset for resuming a paused subscription. - """ - def resume_changeset(subscription) do - subscription - |> change(%{status: "active"}) - end - - @doc """ - Changeset for cancelling a subscription. - """ - def cancel_changeset(subscription, immediately \\ false) do - if immediately do - subscription - |> change(%{ - status: "cancelled", - cancelled_at: UtilsDate.utc_now() - }) - else - subscription - |> change(%{ - cancel_at_period_end: true - }) - end - end - - @doc """ - Changeset for starting a trial. - """ - def trial_changeset(subscription, trial_end) do - subscription - |> change(%{ - status: "trialing", - trial_start: UtilsDate.utc_now(), - trial_end: trial_end - }) - end - - # ============================================ - # Status Helpers - # ============================================ - - @doc """ - Returns true if the subscription is currently active (can use service). - """ - def active?(%__MODULE__{status: status}) when status in ["active", "trialing", "past_due"] do - true - end - - def active?(_), do: false - - @doc """ - Returns true if the subscription is in trial period. - """ - def trialing?(%__MODULE__{status: "trialing"}), do: true - def trialing?(_), do: false - - @doc """ - Returns true if the subscription is past due (payment failed). - """ - def past_due?(%__MODULE__{status: "past_due"}), do: true - def past_due?(_), do: false - - @doc """ - Returns true if the subscription is cancelled. - """ - def cancelled?(%__MODULE__{status: "cancelled"}), do: true - def cancelled?(_), do: false - - @doc """ - Returns true if the subscription is paused. - """ - def paused?(%__MODULE__{status: "paused"}), do: true - def paused?(_), do: false - - @doc """ - Returns true if the subscription will be cancelled at period end. - """ - def cancelling?(%__MODULE__{cancel_at_period_end: true}), do: true - def cancelling?(_), do: false - - @doc """ - Returns true if renewal is due (period end is near or past). - """ - def renewal_due?(%__MODULE__{current_period_end: period_end}) when not is_nil(period_end) do - DateTime.compare(period_end, UtilsDate.utc_now()) != :gt - end - - def renewal_due?(_), do: false - - @doc """ - Returns true if we should attempt renewal (within 24 hours of period end). - """ - def should_renew?(%__MODULE__{current_period_end: period_end, status: status}) - when status in ["active", "trialing"] and not is_nil(period_end) do - hours_until_end = DateTime.diff(period_end, UtilsDate.utc_now(), :hour) - hours_until_end <= 24 - end - - def should_renew?(_), do: false - - @doc """ - Returns true if grace period has expired. - """ - def grace_period_expired?(%__MODULE__{grace_period_end: nil}), do: false - - def grace_period_expired?(%__MODULE__{grace_period_end: grace_end}) do - DateTime.compare(grace_end, UtilsDate.utc_now()) != :gt - end - - @doc """ - Returns the number of days remaining in the current period. - """ - def days_remaining(%__MODULE__{current_period_end: nil}), do: 0 - - def days_remaining(%__MODULE__{current_period_end: period_end}) do - case DateTime.diff(period_end, UtilsDate.utc_now(), :day) do - days when days > 0 -> days - _ -> 0 - end - end -end diff --git a/lib/modules/billing/schemas/subscription_type.ex b/lib/modules/billing/schemas/subscription_type.ex deleted file mode 100644 index bef26414c..000000000 --- a/lib/modules/billing/schemas/subscription_type.ex +++ /dev/null @@ -1,179 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.SubscriptionType do - @moduledoc """ - Schema for subscription types (pricing tiers). - - Subscription types define the pricing, billing interval, and features - available at each tier. Types are managed internally and used to - create subscriptions. - - ## Fields - - - `name` - Display name (e.g., "Basic", "Pro", "Enterprise") - - `slug` - Unique identifier (e.g., "basic", "pro") - - `description` - Marketing description - - `price` - Price per billing period (Decimal) - - `currency` - Three-letter currency code (default: "EUR") - - `interval` - Billing interval: "day", "week", "month", "year" - - `interval_count` - Number of intervals (e.g., 3 months) - - `trial_days` - Free trial period in days (default: 0) - - `features` - JSON map of features included in this type - - `active` - Whether this type is available for new subscriptions - - `sort_order` - Display order in type listings - - ## Examples - - %SubscriptionType{ - name: "Professional", - slug: "pro", - price: Decimal.new("29.99"), - currency: "EUR", - interval: "month", - interval_count: 1, - trial_days: 14, - features: %{"api_calls" => 10000, "storage_gb" => 50}, - active: true - } - """ - - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.RepoHelper - - @intervals ~w(day week month year) - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_subscription_types" do - field :name, :string - field :slug, :string - field :description, :string - field :price, :decimal - field :currency, :string, default: "EUR" - field :interval, :string, default: "month" - field :interval_count, :integer, default: 1 - field :trial_days, :integer, default: 0 - field :features, {:array, :string}, default: [] - field :active, :boolean, default: true - field :sort_order, :integer, default: 0 - field :metadata, :map, default: %{} - - timestamps(type: :utc_datetime) - end - - @doc """ - Creates a changeset for a subscription type. - - ## Required fields - - `name` - Display name - - `slug` - Unique identifier (URL-friendly) - - `price` - Price per period - - ## Optional fields - - `description`, `currency`, `interval`, `interval_count` - - `trial_days`, `features`, `active`, `sort_order`, `metadata` - """ - def changeset(type, attrs) do - type - |> cast(attrs, [ - :name, - :slug, - :description, - :price, - :currency, - :interval, - :interval_count, - :trial_days, - :features, - :active, - :sort_order, - :metadata - ]) - |> validate_required([:name, :slug, :price]) - |> validate_inclusion(:interval, @intervals) - |> validate_number(:price, greater_than_or_equal_to: 0) - |> validate_number(:interval_count, greater_than: 0) - |> validate_number(:trial_days, greater_than_or_equal_to: 0) - |> validate_length(:slug, min: 1, max: 50) - |> validate_length(:currency, is: 3) - |> unique_constraint(:slug) - end - - @doc """ - Returns the billing period in days for this subscription type. - """ - def billing_period_days(%__MODULE__{interval: interval, interval_count: count}) do - base_days = - case interval do - "day" -> 1 - "week" -> 7 - "month" -> 30 - "year" -> 365 - end - - base_days * count - end - - @doc """ - Calculates the next billing date from a given start date. - """ - def next_billing_date(%__MODULE__{interval: interval, interval_count: count}, from_date) do - case interval do - "day" -> - Date.add(from_date, count) - - "week" -> - Date.add(from_date, count * 7) - - "month" -> - # Use Elixir's Date.shift for proper month handling - Date.shift(from_date, month: count) - - "year" -> - Date.shift(from_date, year: count) - end - end - - @doc """ - Returns the formatted price string with currency. - """ - def formatted_price(%__MODULE__{price: price, currency: currency}) do - "#{Decimal.round(price, 2)} #{currency}" - end - - @doc """ - Returns the billing interval description (e.g., "monthly", "every 3 months"). - """ - def interval_description(%__MODULE__{interval: interval, interval_count: 1}) do - case interval do - "day" -> "daily" - "week" -> "weekly" - "month" -> "monthly" - "year" -> "yearly" - end - end - - def interval_description(%__MODULE__{interval: interval, interval_count: count}) do - "every #{count} #{interval}s" - end - - @doc """ - Lists all active subscription types ordered by sort_order. - """ - def list_active do - import Ecto.Query - - from(t in __MODULE__, - where: t.active == true, - order_by: [asc: t.sort_order, asc: t.name] - ) - |> RepoHelper.repo().all() - end - - @doc """ - Gets a subscription type by its slug. - """ - def get_by_slug(slug) do - RepoHelper.repo().get_by(__MODULE__, slug: slug) - end -end diff --git a/lib/modules/billing/schemas/transaction.ex b/lib/modules/billing/schemas/transaction.ex deleted file mode 100644 index 0bcc60bfc..000000000 --- a/lib/modules/billing/schemas/transaction.ex +++ /dev/null @@ -1,109 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Transaction do - @moduledoc """ - Schema for payment transactions. - - Transactions record actual payments and refunds for invoices. - - Positive amount = payment - - Negative amount = refund - - Transactions are created when: - - Admin marks invoice as paid (creates payment transaction) - - Admin issues a refund (creates refund transaction) - - There are no pending/failed statuses - a transaction is only recorded - when the payment/refund has actually occurred. - """ - - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Modules.Billing.Invoice - alias PhoenixKit.Users.Auth.User - - @payment_methods ~w(bank stripe paypal razorpay) - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_transactions" do - field :transaction_number, :string - field :amount, :decimal - field :currency, :string, default: "EUR" - field :payment_method, :string, default: "bank" - field :description, :string - field :metadata, :map, default: %{} - - # For future payment provider integrations - field :provider_transaction_id, :string - field :provider_data, :map, default: %{} - - belongs_to :invoice, Invoice, foreign_key: :invoice_uuid, references: :uuid, type: UUIDv7 - belongs_to :user, User, foreign_key: :user_uuid, references: :uuid, type: UUIDv7 - - timestamps(type: :utc_datetime) - end - - @doc """ - Creates a changeset for a transaction. - """ - def changeset(transaction, attrs) do - transaction - |> cast(attrs, [ - :transaction_number, - :amount, - :currency, - :payment_method, - :description, - :metadata, - :provider_transaction_id, - :provider_data, - :invoice_uuid, - :user_uuid - ]) - |> validate_required([ - :transaction_number, - :amount, - :currency, - :payment_method, - :invoice_uuid, - :user_uuid - ]) - |> validate_inclusion(:payment_method, @payment_methods) - |> validate_number(:amount, not_equal_to: 0) - |> unique_constraint(:transaction_number) - |> foreign_key_constraint(:invoice_uuid) - |> foreign_key_constraint(:user_uuid) - end - - @doc """ - Returns true if this transaction is a payment (positive amount). - """ - def payment?(%__MODULE__{amount: amount}) do - Decimal.positive?(amount) - end - - @doc """ - Returns true if this transaction is a refund (negative amount). - """ - def refund?(%__MODULE__{amount: amount}) do - Decimal.negative?(amount) - end - - @doc """ - Returns the transaction type as a string. - """ - def type(%__MODULE__{} = transaction) do - if payment?(transaction), do: "payment", else: "refund" - end - - @doc """ - Returns the absolute amount (always positive). - """ - def absolute_amount(%__MODULE__{amount: amount}) do - Decimal.abs(amount) - end - - @doc """ - Returns the list of valid payment methods. - """ - def payment_methods, do: @payment_methods -end diff --git a/lib/modules/billing/schemas/webhook_event.ex b/lib/modules/billing/schemas/webhook_event.ex deleted file mode 100644 index 1c4f4a64d..000000000 --- a/lib/modules/billing/schemas/webhook_event.ex +++ /dev/null @@ -1,117 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.WebhookEvent do - @moduledoc """ - Schema for webhook event logging and idempotency. - - Every webhook received from payment providers is logged here to: - - Ensure idempotency (same event_id is never processed twice) - - Track processing status and errors - - Enable debugging and auditing - - Support retry logic for failed events - - ## Idempotency - - Before processing a webhook, we check if an event with the same - `provider` + `event_id` combination exists. If it does, we skip - processing and return success to prevent retries from provider. - - ## Retry Logic - - Failed events can be retried: - 1. Provider sends retry (we check idempotency, process if not done) - 2. Manual retry via admin interface - 3. Background worker for events stuck in processing - """ - - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Utils.Date, as: UtilsDate - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_webhook_events" do - field :provider, :string - field :event_id, :string - field :event_type, :string - field :payload, :map, default: %{} - field :processed, :boolean, default: false - field :processed_at, :utc_datetime - field :error_message, :string - field :retry_count, :integer, default: 0 - - timestamps(type: :utc_datetime) - end - - @doc """ - Creates a changeset for a webhook event. - """ - def changeset(event, attrs) do - event - |> cast(attrs, [ - :provider, - :event_id, - :event_type, - :payload, - :processed, - :processed_at, - :error_message, - :retry_count - ]) - |> validate_required([:provider, :event_id, :event_type]) - |> unique_constraint([:provider, :event_id], - name: :phoenix_kit_webhook_events_provider_event_id_index - ) - end - - @doc """ - Changeset for marking an event as processed. - """ - def processed_changeset(event) do - event - |> change(%{ - processed: true, - processed_at: UtilsDate.utc_now(), - error_message: nil - }) - end - - @doc """ - Changeset for marking an event as failed. - """ - def failed_changeset(event, error_message) do - event - |> change(%{ - processed: false, - error_message: error_message, - retry_count: event.retry_count + 1 - }) - end - - # ============================================ - # Status Helpers - # ============================================ - - @doc """ - Returns true if the event was successfully processed. - """ - def processed?(%__MODULE__{processed: true}), do: true - def processed?(_), do: false - - @doc """ - Returns true if the event has failed and can be retried. - """ - def retriable?(%__MODULE__{processed: false, retry_count: count}) when count < 5 do - true - end - - def retriable?(_), do: false - - @doc """ - Returns true if the event has exceeded max retries. - """ - def max_retries_exceeded?(%__MODULE__{retry_count: count}) when count >= 5 do - true - end - - def max_retries_exceeded?(_), do: false -end diff --git a/lib/modules/billing/utils/iban_data.ex b/lib/modules/billing/utils/iban_data.ex deleted file mode 100644 index 7923e094c..000000000 --- a/lib/modules/billing/utils/iban_data.ex +++ /dev/null @@ -1,199 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.IbanData do - @moduledoc """ - IBAN specifications by country. - - Provides IBAN length and SEPA membership data for banking validation. - Data sourced from IBAN.com/structure. - - ## Examples - - iex> IbanData.get_iban_length("EE") - 20 - - iex> IbanData.sepa_member?("EE") - true - - iex> IbanData.country_uses_iban?("US") - false - """ - - @enforce_keys [:length, :sepa] - defstruct [:length, :sepa] - - @type t :: %__MODULE__{ - length: pos_integer(), - sepa: boolean() - } - - @iban_specs %{ - # EU/EEA SEPA Countries - "AD" => %{length: 24, sepa: true}, - "AT" => %{length: 20, sepa: true}, - "BE" => %{length: 16, sepa: true}, - "BG" => %{length: 22, sepa: true}, - "CH" => %{length: 21, sepa: true}, - "CY" => %{length: 28, sepa: true}, - "CZ" => %{length: 24, sepa: true}, - "DE" => %{length: 22, sepa: true}, - "DK" => %{length: 18, sepa: true}, - "EE" => %{length: 20, sepa: true}, - "ES" => %{length: 24, sepa: true}, - "FI" => %{length: 18, sepa: true}, - "FR" => %{length: 27, sepa: true}, - "GB" => %{length: 22, sepa: true}, - "GI" => %{length: 23, sepa: true}, - "GR" => %{length: 27, sepa: true}, - "HR" => %{length: 21, sepa: true}, - "HU" => %{length: 28, sepa: true}, - "IE" => %{length: 22, sepa: true}, - "IS" => %{length: 26, sepa: true}, - "IT" => %{length: 27, sepa: true}, - "LI" => %{length: 21, sepa: true}, - "LT" => %{length: 20, sepa: true}, - "LU" => %{length: 20, sepa: true}, - "LV" => %{length: 21, sepa: true}, - "MC" => %{length: 27, sepa: true}, - "MD" => %{length: 24, sepa: true}, - "ME" => %{length: 22, sepa: true}, - "MK" => %{length: 19, sepa: true}, - "MT" => %{length: 31, sepa: true}, - "NL" => %{length: 18, sepa: true}, - "NO" => %{length: 15, sepa: true}, - "PL" => %{length: 28, sepa: true}, - "PT" => %{length: 25, sepa: true}, - "RO" => %{length: 24, sepa: true}, - "RS" => %{length: 22, sepa: true}, - "SE" => %{length: 24, sepa: true}, - "SI" => %{length: 19, sepa: true}, - "SK" => %{length: 24, sepa: true}, - "SM" => %{length: 27, sepa: true}, - "VA" => %{length: 22, sepa: true}, - "XK" => %{length: 20, sepa: true}, - # Non-SEPA Countries with IBAN - "AE" => %{length: 23, sepa: false}, - "AL" => %{length: 28, sepa: false}, - "AZ" => %{length: 28, sepa: false}, - "BA" => %{length: 20, sepa: false}, - "BH" => %{length: 22, sepa: false}, - "BR" => %{length: 29, sepa: false}, - "BY" => %{length: 28, sepa: false}, - "CR" => %{length: 22, sepa: false}, - "DO" => %{length: 28, sepa: false}, - "EG" => %{length: 29, sepa: false}, - "FO" => %{length: 18, sepa: false}, - "GE" => %{length: 22, sepa: false}, - "GL" => %{length: 18, sepa: false}, - "GT" => %{length: 28, sepa: false}, - "IL" => %{length: 23, sepa: false}, - "IQ" => %{length: 23, sepa: false}, - "JO" => %{length: 30, sepa: false}, - "KW" => %{length: 30, sepa: false}, - "KZ" => %{length: 20, sepa: false}, - "LB" => %{length: 28, sepa: false}, - "LC" => %{length: 32, sepa: false}, - "MR" => %{length: 27, sepa: false}, - "MU" => %{length: 30, sepa: false}, - "PK" => %{length: 24, sepa: false}, - "PS" => %{length: 29, sepa: false}, - "QA" => %{length: 29, sepa: false}, - "RU" => %{length: 33, sepa: false}, - "SA" => %{length: 24, sepa: false}, - "SC" => %{length: 31, sepa: false}, - "TL" => %{length: 23, sepa: false}, - "TN" => %{length: 24, sepa: false}, - "TR" => %{length: 26, sepa: false}, - "UA" => %{length: 29, sepa: false}, - "VG" => %{length: 24, sepa: false} - } - - @all_specs Map.new(@iban_specs, fn {code, %{length: length, sepa: sepa}} -> - {code, %{__struct__: __MODULE__, length: length, sepa: sepa}} - end) - - @doc """ - Get IBAN length for a country. - - Returns the expected IBAN length for the country code, or nil if the country - does not use IBAN. - - ## Examples - - iex> IbanData.get_iban_length("EE") - 20 - - iex> IbanData.get_iban_length("DE") - 22 - - iex> IbanData.get_iban_length("US") - nil - """ - def get_iban_length(country_code) when is_binary(country_code) do - case Map.get(@iban_specs, String.upcase(country_code)) do - %{length: length} -> length - _ -> nil - end - end - - def get_iban_length(_), do: nil - - @doc """ - Check if a country is a SEPA member. - - ## Examples - - iex> IbanData.sepa_member?("EE") - true - - iex> IbanData.sepa_member?("TR") - false - - iex> IbanData.sepa_member?("US") - false - """ - def sepa_member?(country_code) when is_binary(country_code) do - case Map.get(@iban_specs, String.upcase(country_code)) do - %{sepa: true} -> true - _ -> false - end - end - - def sepa_member?(_), do: false - - @doc """ - Check if a country uses IBAN. - - ## Examples - - iex> IbanData.country_uses_iban?("EE") - true - - iex> IbanData.country_uses_iban?("US") - false - """ - def country_uses_iban?(country_code) when is_binary(country_code) do - Map.has_key?(@iban_specs, String.upcase(country_code)) - end - - def country_uses_iban?(_), do: false - - @doc """ - Get the IBAN specification for a country. - - Returns a `%IbanData{}` struct or nil if the country does not use IBAN. - """ - def get_spec(country_code) when is_binary(country_code) do - case Map.get(@iban_specs, String.upcase(country_code)) do - %{length: length, sepa: sepa} -> %__MODULE__{length: length, sepa: sepa} - _ -> nil - end - end - - def get_spec(_), do: nil - - @doc """ - Get all IBAN specifications. - - Returns a map of country codes to `%IbanData{}` structs. - """ - def all_specs, do: @all_specs -end diff --git a/lib/modules/billing/utils/webhook_processor.ex b/lib/modules/billing/utils/webhook_processor.ex deleted file mode 100644 index f70f90fe2..000000000 --- a/lib/modules/billing/utils/webhook_processor.ex +++ /dev/null @@ -1,356 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.WebhookProcessor do - @moduledoc """ - Processes normalized webhook events from payment providers. - - This module handles the business logic for webhook events after they've - been verified and normalized by the provider modules. It ensures: - - - **Idempotency**: Events are tracked by event_id to prevent double-processing - - **Error handling**: Failed events are logged with retry counts - - **Business logic**: Invoices are marked paid, receipts generated, etc. - - ## Event Types - - - `checkout.completed` - Checkout session completed (payment succeeded) - - `checkout.expired` - Checkout session expired - - `payment.succeeded` - Direct payment succeeded (for saved cards) - - `payment.failed` - Payment failed - - `refund.created` - Refund was processed - - `setup.completed` - Setup session completed (card saved) - - ## Usage - - # Called by BillingWebhookController - WebhookProcessor.process(normalized_event) - """ - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.WebhookEvent - alias PhoenixKit.RepoHelper - alias PhoenixKit.Utils.Date, as: UtilsDate - - require Logger - - @doc """ - Processes a normalized webhook event. - - Checks for idempotency, processes the event, and logs the result. - - ## Returns - - - `{:ok, result}` - Event processed successfully - - `{:error, :duplicate_event}` - Event already processed - - `{:error, reason}` - Processing failed - """ - @spec process(map()) :: {:ok, any()} | {:error, atom()} - def process(%{event_id: event_id, provider: provider, type: _type} = event) do - # Check idempotency - case check_idempotency(provider, event_id) do - :new -> - # Log event as processing - {:ok, webhook_event} = create_webhook_event(event) - - # Process the event - result = process_event(event) - - # Update event status - mark_event_processed(webhook_event, result) - - result - - :duplicate -> - {:error, :duplicate_event} - end - rescue - e -> - Logger.error("Webhook processing error: #{inspect(e)}") - {:error, :processing_error} - end - - # =========================================== - # Event Handlers - # =========================================== - - defp process_event(%{type: "checkout.completed", data: data}) do - Logger.info("Processing checkout.completed: #{inspect(data)}") - - case data do - %{mode: "payment", invoice_uuid: invoice_uuid} when not is_nil(invoice_uuid) -> - # One-time payment for invoice - process_invoice_payment(invoice_uuid, data) - - %{mode: "setup", user_uuid: user_uuid} when not is_nil(user_uuid) -> - # Setup session - card saved - process_setup_completed(data) - - _ -> - Logger.warning("Unhandled checkout.completed mode: #{inspect(data)}") - {:ok, :ignored} - end - end - - defp process_event(%{type: "checkout.expired", data: data}) do - Logger.info("Checkout session expired: #{inspect(data[:session_id])}") - # Clear checkout session from order if needed - {:ok, :expired} - end - - defp process_event(%{type: "payment.succeeded", data: data}) do - Logger.info("Processing payment.succeeded: #{inspect(data)}") - - case data do - %{invoice_uuid: invoice_uuid} when not is_nil(invoice_uuid) -> - # Payment for invoice (e.g., subscription renewal) - process_invoice_payment(invoice_uuid, data) - - _ -> - Logger.warning("Payment succeeded without invoice_uuid: #{inspect(data)}") - {:ok, :ignored} - end - end - - defp process_event(%{type: "payment.failed", data: data}) do - Logger.warning("Payment failed: #{inspect(data)}") - - case data do - %{invoice_uuid: invoice_uuid} when not is_nil(invoice_uuid) -> - # Update invoice/subscription status - process_payment_failure(invoice_uuid, data) - - _ -> - {:ok, :ignored} - end - end - - defp process_event(%{type: "refund.created", data: data}) do - Logger.info("Processing refund.created: #{inspect(data)}") - # Record refund transaction - process_refund(data) - end - - defp process_event(%{type: "setup.completed", data: data}) do - Logger.info("Processing setup.completed: #{inspect(data)}") - # Save payment method for user - process_setup_completed(data) - end - - defp process_event(%{type: type}) do - Logger.debug("Unhandled webhook event type: #{type}") - {:ok, :unhandled} - end - - # =========================================== - # Business Logic - # =========================================== - - defp process_invoice_payment(invoice_uuid, data) do - invoice_uuid = parse_id(invoice_uuid) - - with {:ok, invoice} <- get_invoice(invoice_uuid), - :ok <- validate_invoice_status(invoice) do - # Determine amount from event data - amount = calculate_payment_amount(invoice, data) - - # Record the payment - payment_attrs = %{ - amount: amount, - payment_method: to_string(data[:provider] || "stripe"), - description: "Online payment via #{data[:provider] || "Stripe"}", - provider_transaction_id: data[:charge_id] || data[:payment_intent_id], - provider_data: data - } - - # Pass nil for admin_user - system/webhook initiated payment - case Billing.record_payment(invoice, payment_attrs, nil) do - {:ok, updated_invoice} -> - Logger.info("Invoice #{invoice.invoice_number} marked as paid") - - # Generate receipt if fully paid - if updated_invoice.status == "paid" do - Billing.generate_receipt(updated_invoice) - Billing.send_receipt(updated_invoice, []) - end - - {:ok, updated_invoice} - - {:error, reason} -> - Logger.error("Failed to record payment for invoice #{invoice_uuid}: #{inspect(reason)}") - {:error, reason} - end - else - {:error, :invoice_not_found} -> - Logger.warning("Invoice not found for webhook: #{invoice_uuid}") - {:error, :invoice_not_found} - - {:error, :already_paid} -> - Logger.debug("Invoice #{invoice_uuid} already paid") - {:ok, :already_paid} - - {:error, reason} -> - {:error, reason} - end - end - - defp process_payment_failure(invoice_uuid, data) do - invoice_uuid = parse_id(invoice_uuid) - - # Log the failure for dunning/retry logic - Logger.warning( - "Payment failed for invoice #{invoice_uuid}: #{data[:error_code]} - #{data[:error_message]}" - ) - - # If this invoice is tied to a subscription, update subscription status - # This will be handled by the subscription renewal worker - - {:ok, :logged} - end - - defp process_refund(data) do - # Find the original transaction by charge_id and record a refund - # This is handled by Billing.record_refund if we have the invoice - - case data do - %{charge_id: charge_id, amount_refunded: amount_cents} when not is_nil(charge_id) -> - Logger.info("Refund recorded: #{charge_id} - #{amount_cents} cents") - {:ok, :refund_logged} - - _ -> - {:ok, :ignored} - end - end - - defp process_setup_completed(data) do - # Save the payment method for the user - case data do - %{provider_payment_method_id: pm_id, customer_id: _customer_id, user_uuid: user_uuid} - when not is_nil(pm_id) -> - Logger.info("Payment method saved for user #{user_uuid}: #{pm_id}") - - # Get payment method details from provider and save - # This should create a PaymentMethod record - {:ok, :payment_method_saved} - - _ -> - {:ok, :ignored} - end - end - - # =========================================== - # Idempotency & Event Logging - # =========================================== - - defp check_idempotency(provider, event_id) do - repo = RepoHelper.repo() - - import Ecto.Query - - query = - from we in WebhookEvent, - where: we.provider == ^to_string(provider) and we.event_id == ^event_id, - select: we.uuid - - case repo.one(query) do - nil -> :new - _uuid -> :duplicate - end - rescue - _ -> :new - end - - defp create_webhook_event(%{event_id: event_id, provider: provider, type: type} = event) do - repo = RepoHelper.repo() - - attrs = %{ - provider: to_string(provider), - event_id: event_id, - event_type: type, - payload: event.raw_payload || %{}, - processed: false, - retry_count: 0, - inserted_at: UtilsDate.utc_now(), - updated_at: UtilsDate.utc_now() - } - - case repo.insert_all("phoenix_kit_webhook_events", [attrs], returning: [:id]) do - {1, [%{id: id}]} -> {:ok, %{id: id}} - _ -> {:error, :insert_failed} - end - rescue - e -> - Logger.error("Failed to create webhook event: #{inspect(e)}") - {:ok, %{id: nil}} - end - - defp mark_event_processed(%{id: nil}, _result), do: :ok - - defp mark_event_processed(%{id: id}, result) do - repo = RepoHelper.repo() - - import Ecto.Query - - {error_message, processed} = - case result do - {:ok, _} -> {nil, true} - {:error, reason} -> {inspect(reason), false} - end - - query = - from we in "phoenix_kit_webhook_events", - where: we.id == ^id - - repo.update_all(query, - set: [ - processed: processed, - processed_at: UtilsDate.utc_now(), - error_message: error_message, - updated_at: UtilsDate.utc_now() - ] - ) - - :ok - rescue - _ -> :ok - end - - # =========================================== - # Helpers - # =========================================== - - defp get_invoice(invoice_id) do - case Billing.get_invoice(invoice_id) do - nil -> {:error, :invoice_not_found} - invoice -> {:ok, invoice} - end - end - - defp validate_invoice_status(%{status: status}) when status in ["draft", "sent", "overdue"] do - :ok - end - - defp validate_invoice_status(%{status: "paid"}) do - {:error, :already_paid} - end - - defp validate_invoice_status(%{status: status}) do - {:error, {:invalid_status, status}} - end - - defp calculate_payment_amount(invoice, data) do - # Use amount from webhook if available, otherwise use invoice total - case data do - %{amount_total: amount_cents} when is_integer(amount_cents) -> - Decimal.div(Decimal.new(amount_cents), 100) - - %{amount: amount_cents} when is_integer(amount_cents) -> - Decimal.div(Decimal.new(amount_cents), 100) - - _ -> - # Use remaining balance on invoice - Decimal.sub(invoice.total, invoice.paid_amount || Decimal.new(0)) - end - end - - defp parse_id(id) when is_binary(id), do: id - defp parse_id(id) when is_integer(id), do: id - defp parse_id(_), do: nil -end diff --git a/lib/modules/billing/web/README.md b/lib/modules/billing/web/README.md deleted file mode 100644 index 263056d60..000000000 --- a/lib/modules/billing/web/README.md +++ /dev/null @@ -1,299 +0,0 @@ -# Billing Module - -The PhoenixKit Billing module provides a complete solution for managing orders, invoices, and payments with EU Standard support. - -## Features - -### Phase 1 (Current) - -- **Orders** - Create and manage orders with line items -- **Invoices** - Generate invoices from orders, track status -- **Billing Profiles** - EU-compliant billing profiles (Individual/Company) -- **Currencies** - Multi-currency support with exchange rates -- **Manual Payments** - Bank transfer workflow with admin confirmation -- **Receipts** - Automatic receipt generation after payment - -### Future Phases - -- Payment Methods (saved cards, wallets) -- Stripe Integration -- PayPal Integration -- Razorpay Integration -- Subscriptions & Recurring Payments - -## Workflow - -``` -1. User fills Billing Profile (personal or company details) -2. Admin creates Order for user -3. Invoice is generated from Order -4. Invoice is sent to user (email) -5. User pays via bank transfer -6. User notifies admin about payment -7. Admin marks Invoice as "paid" -8. Receipt is automatically generated -``` - -## Database Schema - -### Tables - -- `phoenix_kit_currencies` - Currency definitions (EUR, USD, etc.) -- `phoenix_kit_billing_profiles` - User billing information -- `phoenix_kit_orders` - Orders with line items -- `phoenix_kit_invoices` - Invoices with receipt support - -### Order Statuses - -| Status | Description | -|--------|-------------| -| `draft` | Order created, not yet confirmed | -| `pending` | Awaiting confirmation | -| `confirmed` | Order confirmed, ready for payment | -| `paid` | Payment received | -| `cancelled` | Order cancelled | -| `refunded` | Payment refunded | - -### Invoice Statuses - -| Status | Description | -|--------|-------------| -| `draft` | Invoice created, not sent | -| `sent` | Invoice sent to customer | -| `paid` | Payment received | -| `void` | Invoice cancelled | -| `overdue` | Past due date | - -## Admin Routes - -| Path | Description | -|------|-------------| -| `/admin/billing` | Billing dashboard with statistics | -| `/admin/billing/orders` | Orders list with filters | -| `/admin/billing/orders/new` | Create new order | -| `/admin/billing/orders/:id` | Order details | -| `/admin/billing/orders/:id/edit` | Edit order | -| `/admin/billing/invoices` | Invoices list with filters | -| `/admin/billing/invoices/:id` | Invoice details | -| `/admin/billing/profiles` | Billing profiles list | -| `/admin/billing/currencies` | Currency management | -| `/admin/settings/billing` | Module settings | - -## Configuration - -### Settings (via Admin UI) - -- **Default Currency** - Default currency for new orders (EUR) -- **Invoice Prefix** - Prefix for invoice numbers (INV) -- **Order Prefix** - Prefix for order numbers (ORD) -- **Receipt Prefix** - Prefix for receipt numbers (RCP) -- **Invoice Due Days** - Default days until invoice due date (14) -- **Default Tax Rate** - Default tax rate for new orders (0%) -- **Company Information** - Your company billing details -- **Bank Details** - Bank account for payments - -## API Usage - -### Enable/Disable Module - -```elixir -# Check if billing is enabled -PhoenixKit.Modules.Billing.enabled?() - -# Enable billing module -PhoenixKit.Modules.Billing.enable_system() - -# Disable billing module -PhoenixKit.Modules.Billing.disable_system() -``` - -### Orders - -```elixir -# Create order -{:ok, order} = PhoenixKit.Modules.Billing.create_order(user, %{ - currency: "EUR", - payment_method: "bank", - line_items: [ - %{name: "Service", quantity: 1, unit_price: "100.00"} - ] -}) - -# Get order with preloads -order = PhoenixKit.Modules.Billing.get_order(id, preload: [:user, :billing_profile]) - -# List orders with pagination -{orders, total} = PhoenixKit.Modules.Billing.list_orders_with_count( - page: 1, - per_page: 25, - status: "confirmed", - search: "customer@example.com" -) - -# Update order status -{:ok, order} = PhoenixKit.Modules.Billing.confirm_order(order) -{:ok, order} = PhoenixKit.Modules.Billing.mark_order_paid(order) -{:ok, order} = PhoenixKit.Modules.Billing.cancel_order(order) -``` - -### Invoices - -```elixir -# Generate invoice from order -{:ok, invoice} = PhoenixKit.Modules.Billing.create_invoice_from_order(order) - -# Update invoice status -{:ok, invoice} = PhoenixKit.Modules.Billing.send_invoice(invoice) -{:ok, invoice} = PhoenixKit.Modules.Billing.mark_invoice_paid(invoice) -{:ok, invoice} = PhoenixKit.Modules.Billing.void_invoice(invoice) - -# Generate receipt after payment -{:ok, invoice} = PhoenixKit.Modules.Billing.generate_receipt(invoice) - -# List invoices with pagination -{invoices, total} = PhoenixKit.Modules.Billing.list_invoices_with_count( - page: 1, - per_page: 25, - status: "sent" -) -``` - -### Billing Profiles - -```elixir -# Create individual profile -{:ok, profile} = PhoenixKit.Modules.Billing.create_billing_profile(user, %{ - type: "individual", - first_name: "John", - last_name: "Doe", - address_line1: "123 Main St", - city: "Tallinn", - country: "EE" -}) - -# Create company profile (EU Standard) -{:ok, profile} = PhoenixKit.Modules.Billing.create_billing_profile(user, %{ - type: "company", - company_name: "Acme OÜ", - company_vat_number: "EE123456789", - company_registration_number: "12345678", - address_line1: "Business St 1", - city: "Tallinn", - country: "EE" -}) - -# Get user's billing profiles -profiles = PhoenixKit.Modules.Billing.list_user_billing_profiles(user_id) - -# Set default profile -{:ok, profile} = PhoenixKit.Modules.Billing.set_default_billing_profile(profile) -``` - -### Currencies - -```elixir -# List enabled currencies -currencies = PhoenixKit.Modules.Billing.list_currencies(enabled: true) - -# Get default currency -currency = PhoenixKit.Modules.Billing.get_default_currency() - -# Update currency -{:ok, currency} = PhoenixKit.Modules.Billing.update_currency(currency, %{ - exchange_rate: Decimal.new("1.08") -}) -``` - -## Components - -### Status Badges - -```heex -<%!-- Order status badge --%> -<.order_status_badge status={@order.status} /> -<.order_status_badge status={@order.status} size={:sm} /> -<.order_status_badge status={@order.status} size={:lg} /> - -<%!-- Invoice status badge --%> -<.invoice_status_badge status={@invoice.status} /> -<.invoice_status_badge status={@invoice.status} size={:md} /> -``` - -### Currency Display - -```heex -<%!-- Format currency amount --%> -<.currency_amount amount={@invoice.total} currency="EUR" /> -<%!-- Output: €100.00 --%> - -<%!-- Compact format (smaller) --%> -<.currency_compact amount={@order.subtotal} currency="USD" /> - -<%!-- Currency badge --%> -<.currency_badge code="EUR" /> -<.currency_badge code="USD" size={:sm} /> -``` - -## Events (PubSub) - -The billing module broadcasts events for real-time updates: - -```elixir -# Subscribe to billing events -PhoenixKit.Modules.Billing.Events.subscribe() - -# Events broadcasted: -# {:order_created, order} -# {:order_updated, order} -# {:order_confirmed, order} -# {:order_paid, order} -# {:order_cancelled, order} -# {:invoice_created, invoice} -# {:invoice_sent, invoice} -# {:invoice_paid, invoice} -# {:invoice_voided, invoice} -``` - -## EU Compliance - -The billing module supports EU Standard requirements: - -- **VAT Number** - Company VAT registration (format: CC123456789) -- **Registration Number** - Company registration number -- **Legal Address** - Company legal address -- **Individual Data** - First name, last name, personal ID -- **Address Fields** - Full address with country codes - -## Files Structure - -``` -lib/phoenix_kit/ -├── billing/ -│ ├── billing.ex # Main context API -│ ├── currency.ex # Currency schema -│ ├── billing_profile.ex # Billing profile schema -│ ├── order.ex # Order schema -│ ├── invoice.ex # Invoice schema -│ └── events.ex # PubSub events -│ -├── migrations/postgres/ -│ └── v29.ex # Billing tables migration - -lib/phoenix_kit_web/ -├── live/modules/billing/ -│ ├── README.md # This file -│ ├── index.ex # Dashboard -│ ├── orders.ex # Orders list -│ ├── order_detail.ex # Order details -│ ├── order_form.ex # Create/Edit order -│ ├── invoices.ex # Invoices list -│ ├── invoice_detail.ex # Invoice details -│ ├── billing_profiles.ex # Profiles list -│ ├── currencies.ex # Currency management -│ └── settings.ex # Module settings -│ -└── components/core/ - ├── order_status_badge.ex - ├── invoice_status_badge.ex - └── currency_display.ex -``` diff --git a/lib/modules/billing/web/billing_profile_form.ex b/lib/modules/billing/web/billing_profile_form.ex deleted file mode 100644 index 3216d3dbb..000000000 --- a/lib/modules/billing/web/billing_profile_form.ex +++ /dev/null @@ -1,143 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.BillingProfileForm do - @moduledoc """ - Billing profile form LiveView for creating and editing billing profiles. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.BillingProfile - alias PhoenixKit.Modules.Billing.CountryData - alias PhoenixKit.Settings - alias PhoenixKit.Users.Auth - alias PhoenixKit.Utils.Routes - - @impl true - def mount(params, _session, socket) do - if Billing.enabled?() do - project_title = Settings.get_project_title() - %{users: users} = Auth.list_users_paginated(limit: 100) - countries = CountryData.countries_for_select() - - socket = - socket - |> assign(:project_title, project_title) - |> assign(:users, users) - |> assign(:countries, countries) - |> assign(:profile_type, "individual") - |> load_profile(params["id"]) - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - defp load_profile(socket, nil) do - # New profile - changeset = Billing.change_billing_profile(%BillingProfile{type: "individual"}) - - socket - |> assign(:page_title, "New Billing Profile") - |> assign(:url_path, Routes.path("/admin/billing/profiles/new")) - |> assign(:profile, nil) - |> assign(:form, to_form(changeset)) - |> assign(:selected_user_uuid, nil) - |> assign(:subdivision_label, "Region") - end - - defp load_profile(socket, id) do - case Billing.get_billing_profile(id) do - nil -> - socket - |> put_flash(:error, "Billing profile not found") - |> push_navigate(to: Routes.path("/admin/billing/profiles")) - - profile -> - changeset = Billing.change_billing_profile(profile) - - socket - |> assign(:page_title, "Edit Billing Profile") - |> assign(:url_path, Routes.path("/admin/billing/profiles/#{profile.uuid}/edit")) - |> assign(:profile, profile) - |> assign(:form, to_form(changeset)) - |> assign(:selected_user_uuid, profile.user_uuid) - |> assign(:profile_type, profile.type) - |> assign(:subdivision_label, CountryData.get_subdivision_label(profile.country)) - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("select_user", %{"user_uuid" => user_uuid}, socket) do - user_uuid = if user_uuid == "", do: nil, else: user_uuid - {:noreply, assign(socket, :selected_user_uuid, user_uuid)} - end - - @impl true - def handle_event("change_type", %{"type" => type}, socket) do - {:noreply, assign(socket, :profile_type, type)} - end - - @impl true - def handle_event("validate", %{"billing_profile" => params}, socket) do - changeset = - (socket.assigns.profile || %BillingProfile{}) - |> Billing.change_billing_profile(params) - |> Map.put(:action, :validate) - - # Update subdivision label when country changes - subdivision_label = CountryData.get_subdivision_label(params["country"]) - - {:noreply, - socket - |> assign(:form, to_form(changeset)) - |> assign(:subdivision_label, subdivision_label)} - end - - @impl true - def handle_event("save", %{"billing_profile" => params}, socket) do - params = - params - |> Map.put("user_uuid", socket.assigns.selected_user_uuid) - |> Map.put("type", socket.assigns.profile_type) - - save_profile(socket, params) - end - - defp save_profile(socket, params) do - result = - if socket.assigns.profile do - Billing.update_billing_profile(socket.assigns.profile, params) - else - case socket.assigns.selected_user_uuid do - nil -> - {:error, :no_user} - - user_uuid -> - Billing.create_billing_profile(user_uuid, params) - end - end - - case result do - {:ok, _profile} -> - {:noreply, - socket - |> put_flash(:info, "Billing profile saved successfully") - |> push_navigate(to: Routes.path("/admin/billing/profiles"))} - - {:error, :no_user} -> - {:noreply, put_flash(socket, :error, "Please select a user")} - - {:error, changeset} -> - {:noreply, assign(socket, :form, to_form(changeset))} - end - end -end diff --git a/lib/modules/billing/web/billing_profile_form.html.heex b/lib/modules/billing/web/billing_profile_form.html.heex deleted file mode 100644 index 1b93424bf..000000000 --- a/lib/modules/billing/web/billing_profile_form.html.heex +++ /dev/null @@ -1,450 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/billing/profiles")}> -

{@page_title}

-

- <%= 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 --%> -
-
-

- <.icon name="hero-user" class="w-5 h-5" /> User -

- -
- - - <%= if @profile do %> - - <% end %> -
-
-
- - <%!-- Profile Type --%> -
-
-

- <.icon name="hero-identification" class="w-5 h-5" /> Profile Type -

- -
- - - -
-
-
- - <%!-- Individual Fields --%> - <%= if @profile_type == "individual" do %> -
-
-

- <.icon name="hero-user-circle" class="w-5 h-5" /> Personal Information -

- -
-
- - - <.error :for={msg <- @form[:first_name].errors |> Enum.map(&elem(&1, 0))}> - {msg} - -
- -
- - -
- -
- - - <.error :for={msg <- @form[:last_name].errors |> Enum.map(&elem(&1, 0))}> - {msg} - -
-
- -
-
- - - -
- -
- - -
-
-
-
- <% end %> - - <%!-- Company Fields --%> - <%= if @profile_type == "company" do %> -
-
-

- <.icon name="hero-building-office" class="w-5 h-5" /> Company Information -

- -
- - - <.error :for={msg <- @form[:company_name].errors |> Enum.map(&elem(&1, 0))}> - {msg} - -
- -
-
- - - - <.error :for={msg <- @form[:company_vat_number].errors |> Enum.map(&elem(&1, 0))}> - {msg} - -
- -
- - -
-
- -
- - -
- -
Contact
- -
-
- - -
- -
- - -
-
-
-
- <% end %> - - <%!-- Billing Address (Country first) --%> -
-
-

- <.icon name="hero-map-pin" class="w-5 h-5" /> Billing Address -

- - <%!-- Country first --%> -
- - -
- -
- - -
- -
- - -
- -
-
- - -
- -
- - -
- -
- - -
-
-
-
- - <%!-- Options --%> -
-
-

- <.icon name="hero-cog-6-tooth" class="w-5 h-5" /> Options -

- -
- -
- -
- - - -
-
-
- - <%!-- Actions --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/profiles")} - class="btn btn-ghost" - > - Cancel - - -
- -
-
diff --git a/lib/modules/billing/web/billing_profiles.ex b/lib/modules/billing/web/billing_profiles.ex deleted file mode 100644 index b43376967..000000000 --- a/lib/modules/billing/web/billing_profiles.ex +++ /dev/null @@ -1,150 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.BillingProfiles do - @moduledoc """ - Billing profiles list LiveView for the billing module. - - Provides billing profile management interface. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Events - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @default_per_page 25 - - @impl true - def mount(_params, _session, socket) do - if Billing.enabled?() do - # Subscribe to billing profile events for real-time updates - if connected?(socket), do: Events.subscribe_profiles() - - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Billing Profiles") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing/profiles")) - |> assign(:profiles, []) - |> assign(:total_count, 0) - |> assign(:loading, true) - |> assign(:search, "") - |> assign(:type_filter, "all") - |> assign(:page, 1) - |> assign(:per_page, @default_per_page) - |> assign(:total_pages, 1) - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(params, _url, socket) do - socket = - socket - |> apply_params(params) - |> load_profiles() - - {:noreply, socket} - end - - defp apply_params(socket, params) do - page = max(1, String.to_integer(params["page"] || "1")) - search = params["search"] || "" - type = params["type"] || "all" - - socket - |> assign(:page, page) - |> assign(:search, search) - |> assign(:type_filter, type) - end - - defp load_profiles(socket) do - %{page: page, per_page: per_page, search: search, type_filter: type} = socket.assigns - - opts = [ - page: page, - per_page: per_page, - search: search, - type: if(type == "all", do: nil, else: type), - preload: [:user] - ] - - {profiles, total_count} = Billing.list_billing_profiles_with_count(opts) - total_pages = ceil(total_count / per_page) - - socket - |> assign(:profiles, profiles) - |> assign(:total_count, total_count) - |> assign(:total_pages, max(1, total_pages)) - |> assign(:loading, false) - end - - @impl true - def handle_event("filter", params, socket) do - query_params = - %{ - "search" => params["search"] || socket.assigns.search, - "type" => params["type"] || socket.assigns.type_filter, - "page" => "1" - } - |> Enum.reject(fn {_k, v} -> v == "" or v == "all" end) - |> URI.encode_query() - - path = - if query_params == "", - do: Routes.path("/admin/billing/profiles"), - else: Routes.path("/admin/billing/profiles?#{query_params}") - - {:noreply, push_patch(socket, to: path)} - end - - @impl true - def handle_event("clear_filters", _params, socket) do - {:noreply, push_patch(socket, to: Routes.path("/admin/billing/profiles"))} - end - - @impl true - def handle_event("page_change", %{"page" => page}, socket) do - query_params = - %{ - "search" => socket.assigns.search, - "type" => socket.assigns.type_filter, - "page" => page - } - |> Enum.reject(fn {k, v} -> v == "" or v == "all" or (k == "page" and v == "1") end) - |> URI.encode_query() - - path = - if query_params == "", - do: Routes.path("/admin/billing/profiles"), - else: Routes.path("/admin/billing/profiles?#{query_params}") - - {:noreply, push_patch(socket, to: path)} - end - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, socket |> assign(:loading, true) |> load_profiles()} - end - - # PubSub event handlers for real-time updates - @impl true - def handle_info({event, _profile}, socket) - when event in [:profile_created, :profile_updated, :profile_deleted] do - {:noreply, load_profiles(socket)} - end - - # Catch-all for any other messages (ignore them) - @impl true - def handle_info(_msg, socket) do - {:noreply, socket} - end -end diff --git a/lib/modules/billing/web/billing_profiles.html.heex b/lib/modules/billing/web/billing_profiles.html.heex deleted file mode 100644 index 617ca1ef7..000000000 --- a/lib/modules/billing/web/billing_profiles.html.heex +++ /dev/null @@ -1,186 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/billing")}> -

- Billing Profiles -

-

{@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 --%> -
-
-
-
- - -
- -
- - -
- - -
-
-
- - <%!-- Profiles Table --%> -
-
- <%= if @loading do %> -
- -
- <% else %> - <%= if Enum.empty?(@profiles) do %> -
- <.icon name="hero-user-circle" class="w-16 h-16 mx-auto mb-4 text-base-content/30" /> -

No billing profiles found

-

- <%= if @search != "" or @type_filter != "all" do %> - Try adjusting your filters or search terms - <% else %> - Billing profiles are created by users - <% end %> -

-
- <% else %> -
- - - - - - - - - - - - - - <%= for profile <- @profiles do %> - - - - - - - - - - <% end %> - -
UserTypeName / CompanyLocationDefaultCreated
- <%= if profile.user do %> -
- <.user_avatar user={profile.user} size="sm" /> -
{profile.user.email}
-
- <% else %> - - - <% end %> -
- - {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")} - -
-
- - <%!-- Pagination --%> - <%= if @total_pages > 1 do %> -
- <.pagination - current_page={@page} - total_pages={@total_pages} - base_path={PhoenixKit.Utils.Routes.path("/admin/billing/profiles")} - params={%{"search" => @search, "type" => @type_filter}} - /> -
- <% end %> - <% end %> - <% end %> -
-
-
-
diff --git a/lib/modules/billing/web/credit_note_print.ex b/lib/modules/billing/web/credit_note_print.ex deleted file mode 100644 index 361d03917..000000000 --- a/lib/modules/billing/web/credit_note_print.ex +++ /dev/null @@ -1,98 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.CreditNotePrint do - @moduledoc """ - Printable credit note view - displays refund/credit note in a print-friendly format. - - This page is designed to be printed or saved as PDF directly from the browser. - Credit notes are generated for refund transactions. - - IMPORTANT: In a credit note, the roles are reversed compared to invoice: - - The company (seller) is now the PAYER (issuing the refund) - - The customer is now the PAYEE (receiving the refund) - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.CountryData - alias PhoenixKit.Modules.Billing.Transaction - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"id" => invoice_uuid, "transaction_uuid" => transaction_uuid}, _session, socket) do - with true <- Billing.enabled?(), - %{} = invoice <- Billing.get_invoice(invoice_uuid, preload: [:user, :order]), - %Transaction{} = transaction <- Billing.get_transaction(transaction_uuid), - true <- Transaction.refund?(transaction) do - mount_credit_note(socket, invoice, transaction) - else - false -> - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - - nil -> - error_msg = - if Billing.get_invoice(invoice_uuid) == nil, - do: "Invoice not found", - else: "Transaction not found" - - redirect_path = - if Billing.get_invoice(invoice_uuid) == nil, - do: Routes.path("/admin/billing/invoices"), - else: Routes.path("/admin/billing/invoices/#{invoice_uuid}") - - {:ok, - socket - |> put_flash(:error, error_msg) - |> push_navigate(to: redirect_path)} - - %Transaction{} -> - {:ok, - socket - |> put_flash(:error, "Transaction is not a refund") - |> push_navigate(to: Routes.path("/admin/billing/invoices/#{invoice_uuid}"))} - end - end - - defp mount_credit_note(socket, invoice, transaction) do - project_title = Settings.get_project_title() - company_info = get_company_info() - credit_note_number = generate_credit_note_number(transaction) - - socket = - socket - |> assign(:page_title, "Credit Note #{credit_note_number}") - |> assign(:project_title, project_title) - |> assign(:invoice, invoice) - |> assign(:transaction, transaction) - |> assign(:credit_note_number, credit_note_number) - |> assign(:company, company_info) - - {:ok, socket, layout: false} - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - defp get_company_info do - %{ - name: Settings.get_setting("billing_company_name", ""), - address: CountryData.format_company_address(), - vat: Settings.get_setting("billing_company_vat", ""), - bank_name: Settings.get_setting("billing_bank_name", ""), - bank_iban: Settings.get_setting("billing_bank_iban", ""), - bank_swift: Settings.get_setting("billing_bank_swift", "") - } - end - - defp generate_credit_note_number(transaction) do - prefix = Settings.get_setting("billing_credit_note_prefix", "CN") - # Use transaction number suffix for credit note - suffix = transaction.transaction_number |> String.replace(~r/^TXN-/, "") - "#{prefix}-#{suffix}" - end -end diff --git a/lib/modules/billing/web/credit_note_print.html.heex b/lib/modules/billing/web/credit_note_print.html.heex deleted file mode 100644 index ad19e0a59..000000000 --- a/lib/modules/billing/web/credit_note_print.html.heex +++ /dev/null @@ -1,654 +0,0 @@ - - - - - - Credit Note {@credit_note_number} - {@project_title} - - - - - -
-
-
-

CREDIT NOTE

-
{@credit_note_number}
-
-
- REFUND ISSUED -
-
- -
-
- <%!-- IMPORTANT: Roles are REVERSED for credit notes --%> - <%!-- Company is the PAYER (issuing refund) --%> -
-

Issued By (Payer)

-

- {@company.name} -
- <%= for line <- String.split(@company.address || "", "\n") do %> - {line}
- <% end %> - <%= if @company.vat != "" do %> - VAT: {@company.vat} - <% end %> -

-
- - <%!-- Customer is the PAYEE (receiving refund) --%> -
-

Issued To (Payee)

-

- <%= if @invoice.billing_details && map_size(@invoice.billing_details) > 0 do %> - <%= if @invoice.billing_details["type"] == "company" do %> - {@invoice.billing_details["company_name"]} -
- <%= if @invoice.billing_details["company_vat_number"] do %> - VAT: {@invoice.billing_details["company_vat_number"]}
- <% end %> - <% else %> - - {@invoice.billing_details["first_name"]} {@invoice.billing_details[ - "last_name" - ]} - -
- <% end %> - <%= if @invoice.billing_details["address_line1"] do %> - {@invoice.billing_details["address_line1"]}
- <% end %> - <%= if @invoice.billing_details["address_line2"] do %> - {@invoice.billing_details["address_line2"]}
- <% end %> - <%= if @invoice.billing_details["city"] do %> - {@invoice.billing_details["city"]} - <%= if @invoice.billing_details["postal_code"] do %> - , {@invoice.billing_details["postal_code"]} - <% end %> -
- <% end %> - <%= if @invoice.billing_details["country"] do %> - {@invoice.billing_details["country"]} - <% end %> - <% else %> - <%= if @invoice.user do %> - {@invoice.user.email} - <% else %> - No billing information - <% end %> - <% end %> -

-
- -
-

Credit Note Details

-

- Credit Note #: {@credit_note_number}
- Date: - {Calendar.strftime(@transaction.inserted_at, "%B %d, %Y")}
- Currency: {@transaction.currency} -

-
-
- - <%!-- Refund Confirmation Box --%> -
-

- - - - Refund Details -

-
-
- Refund Amount - - {Decimal.to_string(Decimal.abs(@transaction.amount), :normal)} {@transaction.currency} - -
-
- Refund Date - - {Calendar.strftime(@transaction.inserted_at, "%B %d, %Y at %H:%M")} - -
-
- Payment Method - {String.capitalize(@transaction.payment_method)} -
-
- Transaction # - {@transaction.transaction_number} -
-
-
- - <%!-- Original Invoice Reference --%> -
-

Original Invoice Reference

-
-
- Invoice #: - {@invoice.invoice_number} -
-
- Invoice Date: - - {Calendar.strftime(@invoice.inserted_at, "%B %d, %Y")} - -
-
- Original Total: - - {Decimal.to_string(@invoice.total, :normal)} {@invoice.currency} - -
-
-
- - <%!-- Reason for Refund --%> - <%= if @transaction.description && @transaction.description != "" do %> -
-

- Reason for Refund -

-

{@transaction.description}

-
- <% end %> - - <%!-- Refund Summary Table --%> - - - - - - - - - - - - - -
DescriptionAmount
-
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} -
-
- - <%!-- Bank Details for Refund --%> - <%= if @company.bank_name != "" || @company.bank_iban != "" do %> -
-

Refund Payment Information

-

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. -

-
- <% end %> -
- - -
- - diff --git a/lib/modules/billing/web/currencies.ex b/lib/modules/billing/web/currencies.ex deleted file mode 100644 index a2456d382..000000000 --- a/lib/modules/billing/web/currencies.ex +++ /dev/null @@ -1,335 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.Currencies do - @moduledoc """ - Currencies management LiveView for the billing module. - - Provides currency configuration interface with CRUD operations - and bulk import from the BeamLabCountries library. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Currency - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if Billing.enabled?() do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Currencies") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing/currencies")) - |> assign(:currencies, []) - |> assign(:loading, true) - |> assign(:show_form, false) - |> assign(:editing_currency, nil) - |> assign(:form, nil) - |> assign(:show_import, false) - |> assign(:available_currencies, []) - |> assign(:selected_imports, MapSet.new()) - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, load_currencies(socket)} - end - - defp load_currencies(socket) do - currencies = Billing.list_currencies(order_by: [asc: :sort_order, asc: :code]) - - socket - |> assign(:currencies, currencies) - |> assign(:loading, false) - end - - # --- Toggle / Default / Refresh --- - - @impl true - def handle_event("toggle_enabled", %{"uuid" => uuid}, socket) do - currency = Enum.find(socket.assigns.currencies, &(&1.uuid == uuid)) - - case Billing.update_currency(currency, %{enabled: !currency.enabled}) do - {:ok, _currency} -> - {:noreply, - socket - |> load_currencies() - |> put_flash(:info, "Currency updated")} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, "Failed to update currency")} - end - end - - @impl true - def handle_event("set_default", %{"uuid" => uuid}, socket) do - currency = Enum.find(socket.assigns.currencies, &(&1.uuid == uuid)) - - case Billing.set_default_currency(currency) do - {:ok, _currency} -> - {:noreply, - socket - |> load_currencies() - |> put_flash(:info, "#{currency.code} set as default currency")} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, "Failed to set default currency")} - end - end - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, socket |> assign(:loading, true) |> load_currencies()} - end - - # --- Currency Form (Add / Edit) --- - - @impl true - def handle_event("show_add_form", _params, socket) do - changeset = Currency.changeset(%Currency{}, %{}) - - {:noreply, - socket - |> assign(:show_form, true) - |> assign(:editing_currency, nil) - |> assign(:form, to_form(changeset))} - end - - @impl true - def handle_event("show_edit_form", %{"uuid" => uuid}, socket) do - currency = Enum.find(socket.assigns.currencies, &(&1.uuid == uuid)) - changeset = Currency.changeset(currency, %{}) - - {:noreply, - socket - |> assign(:show_form, true) - |> assign(:editing_currency, currency) - |> assign(:form, to_form(changeset))} - end - - @impl true - def handle_event("close_form", _params, socket) do - {:noreply, - socket - |> assign(:show_form, false) - |> assign(:editing_currency, nil) - |> assign(:form, nil)} - end - - @impl true - def handle_event("validate", %{"currency" => params}, socket) do - changeset = - (socket.assigns.editing_currency || %Currency{}) - |> Currency.changeset(params) - |> Map.put(:action, :validate) - - {:noreply, assign(socket, :form, to_form(changeset))} - end - - @impl true - def handle_event("save", %{"currency" => params}, socket) do - result = - case socket.assigns.editing_currency do - nil -> Billing.create_currency(params) - currency -> Billing.update_currency(currency, params) - end - - case result do - {:ok, _currency} -> - action = if socket.assigns.editing_currency, do: "updated", else: "created" - - {:noreply, - socket - |> load_currencies() - |> assign(:show_form, false) - |> assign(:editing_currency, nil) - |> assign(:form, nil) - |> put_flash(:info, "Currency #{action} successfully")} - - {:error, changeset} -> - {:noreply, assign(socket, :form, to_form(changeset))} - end - end - - # --- Delete --- - - @impl true - def handle_event("delete_currency", %{"uuid" => uuid}, socket) do - currency = Enum.find(socket.assigns.currencies, &(&1.uuid == uuid)) - - case Billing.delete_currency(currency) do - {:ok, _currency} -> - {:noreply, - socket - |> load_currencies() - |> put_flash(:info, "#{currency.code} deleted")} - - {:error, :is_default} -> - {:noreply, put_flash(socket, :error, "Cannot delete the default currency")} - - {:error, :currency_in_use} -> - {:noreply, - put_flash(socket, :error, "Cannot delete currency — it is used by existing orders")} - - {:error, _other} -> - {:noreply, put_flash(socket, :error, "Failed to delete currency")} - end - end - - # --- Import from BeamLabCountries --- - - @impl true - def handle_event("show_import", _params, socket) do - existing_codes = - socket.assigns.currencies - |> Enum.map(& &1.code) - |> MapSet.new() - - # Get company country's currencies to prioritize them (primary + alternative) - company_country_code = Settings.get_setting("billing_company_country", "") - priority_currency_codes = get_country_currency_codes(company_country_code) - - available = - BeamLabCountries.Currencies.all() - |> Enum.reject(&MapSet.member?(existing_codes, &1.code)) - |> sort_currencies_with_priority(priority_currency_codes) - - {:noreply, - socket - |> assign(:show_import, true) - |> assign(:available_currencies, available) - |> assign(:selected_imports, MapSet.new())} - end - - @impl true - def handle_event("close_import", _params, socket) do - {:noreply, - socket - |> assign(:show_import, false) - |> assign(:available_currencies, []) - |> assign(:selected_imports, MapSet.new())} - end - - @impl true - def handle_event("toggle_import_selection", %{"code" => code}, socket) do - selected = socket.assigns.selected_imports - - updated = - if MapSet.member?(selected, code), - do: MapSet.delete(selected, code), - else: MapSet.put(selected, code) - - {:noreply, assign(socket, :selected_imports, updated)} - end - - @impl true - def handle_event("select_all_imports", _params, socket) do - all_codes = MapSet.new(socket.assigns.available_currencies, & &1.code) - {:noreply, assign(socket, :selected_imports, all_codes)} - end - - @impl true - def handle_event("deselect_all_imports", _params, socket) do - {:noreply, assign(socket, :selected_imports, MapSet.new())} - end - - @impl true - def handle_event("import_selected", _params, socket) do - selected = socket.assigns.selected_imports - - to_import = - Enum.filter(socket.assigns.available_currencies, &MapSet.member?(selected, &1.code)) - - {ok_count, fail_count} = - Enum.reduce(to_import, {0, 0}, fn cur, {ok, fail} -> - attrs = %{ - code: cur.code, - name: cur.name, - symbol: cur.symbol_native, - decimal_places: cur.decimal_digits, - exchange_rate: "1.0", - enabled: false - } - - case Billing.create_currency(attrs) do - {:ok, _} -> {ok + 1, fail} - {:error, _} -> {ok, fail + 1} - end - end) - - message = - case {ok_count, fail_count} do - {ok, 0} -> "#{ok} currencies imported" - {0, fail} -> "Import failed for #{fail} currencies" - {ok, fail} -> "#{ok} imported, #{fail} failed" - end - - {:noreply, - socket - |> load_currencies() - |> assign(:show_import, false) - |> assign(:available_currencies, []) - |> assign(:selected_imports, MapSet.new()) - |> put_flash(:info, message)} - end - - # --- Helpers --- - - def error_to_string([]), do: "" - - def error_to_string(errors) when is_list(errors) do - Enum.map_join(errors, ", ", fn - {msg, opts} -> - Enum.reduce(opts, msg, fn {key, value}, acc -> - String.replace(acc, "%{#{key}}", to_string(value)) - end) - - msg when is_binary(msg) -> - msg - end) - end - - # Gets currency codes for a country from BeamLabCountries (primary + alternative) - defp get_country_currency_codes(country_code) - when is_binary(country_code) and country_code != "" do - case BeamLabCountries.get(country_code) do - %{currency_code: primary, alt_currency: alt} -> - [primary, alt] - |> Enum.reject(&(is_nil(&1) or &1 == "")) - - _ -> - [] - end - end - - defp get_country_currency_codes(_), do: [] - - # Sorts currencies with priority currencies first, then alphabetically - defp sort_currencies_with_priority(currencies, []) do - Enum.sort_by(currencies, & &1.code) - end - - defp sort_currencies_with_priority(currencies, priority_codes) when is_list(priority_codes) do - priority_set = MapSet.new(priority_codes) - {priority, rest} = Enum.split_with(currencies, &MapSet.member?(priority_set, &1.code)) - - # Sort priority currencies in the order they appear in priority_codes - sorted_priority = - Enum.sort_by(priority, fn cur -> - Enum.find_index(priority_codes, &(&1 == cur.code)) || 999 - end) - - sorted_priority ++ Enum.sort_by(rest, & &1.code) - end -end diff --git a/lib/modules/billing/web/currencies.html.heex b/lib/modules/billing/web/currencies.html.heex deleted file mode 100644 index 91eea6898..000000000 --- a/lib/modules/billing/web/currencies.html.heex +++ /dev/null @@ -1,413 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin/billing")} - title="Currencies" - subtitle="Manage supported currencies for billing" - > - <:actions> - - - - - - - <%!-- Navigation Tabs --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/billing")} - class="tab" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-2" /> General - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/billing/providers")} - class="tab" - > - <.icon name="hero-credit-card" class="w-4 h-4 mr-2" /> Payment Providers - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/currencies")} - class="tab tab-active" - > - <.icon name="hero-currency-dollar" class="w-4 h-4 mr-2" /> Currencies - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscription-types")} - class="tab" - > - <.icon name="hero-clipboard-document-list" class="w-4 h-4 mr-2" /> Subscription Types - -
- - <%!-- Currencies Table --%> -
-
- <%= if @loading do %> -
- -
- <% else %> - <%= if Enum.empty?(@currencies) do %> -
- <.icon - name="hero-currency-dollar" - class="w-16 h-16 mx-auto mb-4 text-base-content/30" - /> -

No currencies configured

-

- Add currencies manually or import from the ISO 4217 library -

-
- - -
-
- <% else %> -
- - - - - - - - - - - - - - <%= for currency <- @currencies do %> - - - - - - - - - - <% end %> - -
CurrencySymbolDecimal PlacesExchange RateStatusDefaultActions
-
-
{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 %> -
-
-
- <% end %> - <% end %> -
-
- - <%!-- Info Card --%> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> -
-

Currency Configuration

-

- 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. -

-
-
-
- - <%!-- Currency Form Modal --%> - <%= if @show_form do %> - - <% end %> - - <%!-- Import Modal --%> - <%= if @show_import do %> - - <% end %> -
diff --git a/lib/modules/billing/web/index.ex b/lib/modules/billing/web/index.ex deleted file mode 100644 index 7f27cdef8..000000000 --- a/lib/modules/billing/web/index.ex +++ /dev/null @@ -1,70 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.Index do - @moduledoc """ - Billing module dashboard LiveView. - - Provides an overview of billing activity including: - - Key metrics (orders, invoices, revenue) - - Recent orders and invoices - - Quick actions - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if Billing.enabled?() do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Billing Dashboard") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing")) - |> load_dashboard_data() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - defp load_dashboard_data(socket) do - stats = Billing.get_dashboard_stats() - recent_orders = Billing.list_orders(limit: 5, sort_by: :inserted_at, sort_order: :desc) - recent_invoices = Billing.list_invoices(limit: 5, sort_by: :inserted_at, sort_order: :desc) - currencies = Billing.list_currencies(enabled: true) - - socket - |> assign(:stats, stats) - |> assign(:recent_orders, recent_orders) - |> assign(:recent_invoices, recent_invoices) - |> assign(:currencies, currencies) - end - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, load_dashboard_data(socket)} - end - - @impl true - def handle_event("view_order", %{"uuid" => uuid}, socket) do - {:noreply, push_navigate(socket, to: Routes.path("/admin/billing/orders/#{uuid}"))} - end - - @impl true - def handle_event("view_invoice", %{"uuid" => uuid}, socket) do - {:noreply, push_navigate(socket, to: Routes.path("/admin/billing/invoices/#{uuid}"))} - end -end diff --git a/lib/modules/billing/web/index.html.heex b/lib/modules/billing/web/index.html.heex deleted file mode 100644 index 688db8dab..000000000 --- a/lib/modules/billing/web/index.html.heex +++ /dev/null @@ -1,264 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin")} - title="Billing Dashboard" - subtitle="Overview of orders, invoices, and billing activity" - > - <: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 - - - - - <%!-- Stats Cards --%> -
-
-
- <.icon name="hero-clipboard-document-list" class="w-8 h-8" /> -
-
Total Orders
-
{@stats.total_orders}
-
{@stats.orders_this_month} this month
-
- -
-
- <.icon name="hero-document-text" class="w-8 h-8" /> -
-
Total Invoices
-
{@stats.total_invoices}
-
{@stats.invoices_this_month} this month
-
- -
-
- <.icon name="hero-banknotes" class="w-8 h-8" /> -
-
Paid Revenue
-
- <.currency_compact - amount={@stats.total_paid_revenue} - currency={@stats.default_currency} - /> -
-
{@stats.paid_invoices_count} paid invoices
-
- -
-
- <.icon name="hero-clock" class="w-8 h-8" /> -
-
Pending
-
- <.currency_compact amount={@stats.pending_revenue} currency={@stats.default_currency} /> -
-
{@stats.pending_invoices_count} pending invoices
-
-
- -
- <%!-- Recent Orders --%> -
-
-
-

Recent Orders

- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/orders")} - class="btn btn-ghost btn-sm" - > - View All <.icon name="hero-arrow-right" class="w-4 h-4" /> - -
- - <%= if Enum.empty?(@recent_orders) do %> -
- <.icon - name="hero-clipboard-document-list" - class="w-12 h-12 mx-auto mb-2 opacity-50" - /> -

No orders yet

- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/orders/new")} - class="btn btn-primary btn-sm mt-4" - > - Create First Order - -
- <% else %> -
- - - - - - - - - - - <%= for order <- @recent_orders do %> - - - - - - - <% end %> - -
Order #StatusTotalDate
{order.order_number}<.order_status_badge status={order.status} /> - <.currency_compact amount={order.total} currency={order.currency} /> - - <.time_ago datetime={order.inserted_at} /> -
-
- <% end %> -
-
- - <%!-- Recent Invoices --%> -
-
-
-

Recent Invoices

- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/invoices")} - class="btn btn-ghost btn-sm" - > - View All <.icon name="hero-arrow-right" class="w-4 h-4" /> - -
- - <%= if Enum.empty?(@recent_invoices) do %> -
- <.icon name="hero-document-text" class="w-12 h-12 mx-auto mb-2 opacity-50" /> -

No invoices yet

-

Create an order first to generate invoices

-
- <% else %> -
- - - - - - - - - - - <%= for invoice <- @recent_invoices do %> - - - - - - - <% end %> - -
Invoice #StatusTotalDue 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 %> -
-
- <% end %> -
-
-
- - <%!-- Quick Actions --%> -
-
-

Quick Actions

-
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/orders/new")} - class="btn btn-outline whitespace-nowrap" - > - <.icon name="hero-plus" class="w-5 h-5" /> New Order - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscriptions")} - class="btn btn-outline whitespace-nowrap" - > - <.icon name="hero-credit-card" class="w-5 h-5" /> Subscriptions - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscription-types")} - class="btn btn-outline whitespace-nowrap" - > - <.icon name="hero-squares-2x2" class="w-5 h-5" /> Subscription Types - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/profiles")} - class="btn btn-outline whitespace-nowrap" - > - <.icon name="hero-users" class="w-5 h-5" /> Billing Profiles - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/billing")} - class="btn btn-outline whitespace-nowrap" - > - <.icon name="hero-cog-6-tooth" class="w-5 h-5" /> Settings - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/billing/providers")} - class="btn btn-primary whitespace-nowrap" - > - <.icon name="hero-credit-card" class="w-5 h-5" /> Payment Providers - -
-
-
- - <%!-- Active Currencies --%> -
-
-
-

Active Currencies

- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/currencies")} - class="btn btn-ghost btn-sm" - > - Manage <.icon name="hero-arrow-right" class="w-4 h-4" /> - -
-
- <%= for currency <- @currencies do %> - <.currency_badge code={currency.code} name={currency.name} size={:md} /> - <% end %> - <%= if Enum.empty?(@currencies) do %> - No currencies configured - <% end %> -
-
-
-
-
diff --git a/lib/modules/billing/web/invoice_detail.ex b/lib/modules/billing/web/invoice_detail.ex deleted file mode 100644 index df10ab67e..000000000 --- a/lib/modules/billing/web/invoice_detail.ex +++ /dev/null @@ -1,266 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail do - @moduledoc """ - Invoice detail LiveView for the billing module. - - Displays complete invoice information and provides actions for invoice management. - Complex business logic is delegated to `Actions`, and template helpers live in `Helpers`. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Invoice - alias PhoenixKit.Modules.Billing.Providers - alias PhoenixKit.Modules.Billing.Web.InvoiceDetail.Actions - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - import PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers - - @impl true - def mount(%{"id" => id}, _session, socket) do - if Billing.enabled?() do - case Billing.get_invoice(id, preload: [:user, :order, :transactions]) do - nil -> - {:ok, - socket - |> put_flash(:error, "Invoice not found") - |> push_navigate(to: Routes.path("/admin/billing/invoices"))} - - invoice -> - project_title = Settings.get_project_title() - transactions = Billing.list_invoice_transactions(invoice.uuid) - - available_providers = Providers.list_available_providers() - - socket = - socket - |> assign(:page_title, "Invoice #{invoice.invoice_number}") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing/invoices/#{invoice.uuid}")) - |> assign(:invoice, invoice) - |> assign(:transactions, transactions) - |> assign(:available_providers, available_providers) - |> assign(:checkout_loading, nil) - |> assign(:show_payment_modal, false) - |> assign(:show_refund_modal, false) - |> assign(:show_send_modal, false) - |> assign(:show_send_receipt_modal, false) - |> assign(:show_send_credit_note_modal, false) - |> assign(:show_send_payment_confirmation_modal, false) - |> assign(:payment_amount, Invoice.remaining_amount(invoice) |> Decimal.to_string()) - |> assign(:refund_amount, "") - |> assign(:payment_description, "") - |> assign(:refund_description, "") - |> assign(:available_payment_methods, Billing.available_payment_methods()) - |> assign(:selected_payment_method, "bank") - |> assign(:selected_refund_payment_method, "bank") - |> assign(:send_email, get_default_email(invoice)) - |> assign(:send_receipt_email, get_default_email(invoice)) - |> assign(:send_credit_note_email, get_default_email(invoice)) - |> assign(:send_credit_note_transaction_uuid, nil) - |> assign(:send_payment_confirmation_email, get_default_email(invoice)) - |> assign(:send_payment_confirmation_transaction_uuid, nil) - - {:ok, socket} - end - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - # Modal Controls - - @impl true - def handle_event("open_payment_modal", _params, socket) do - remaining = Invoice.remaining_amount(socket.assigns.invoice) - - {:noreply, - socket - |> assign(:show_payment_modal, true) - |> assign(:payment_amount, Decimal.to_string(remaining)) - |> assign(:payment_description, "")} - end - - @impl true - def handle_event("close_payment_modal", _params, socket) do - {:noreply, assign(socket, :show_payment_modal, false)} - end - - @impl true - def handle_event("open_refund_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_refund_modal, true) - |> assign(:refund_amount, "") - |> assign(:refund_description, "")} - end - - @impl true - def handle_event("close_refund_modal", _params, socket) do - {:noreply, assign(socket, :show_refund_modal, false)} - end - - @impl true - def handle_event("open_send_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_send_modal, true) - |> assign(:send_email, get_default_email(socket.assigns.invoice))} - end - - @impl true - def handle_event("close_send_modal", _params, socket) do - {:noreply, assign(socket, :show_send_modal, false)} - end - - @impl true - def handle_event("open_send_receipt_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_send_receipt_modal, true) - |> assign(:send_receipt_email, get_default_email(socket.assigns.invoice))} - end - - @impl true - def handle_event("close_send_receipt_modal", _params, socket) do - {:noreply, assign(socket, :show_send_receipt_modal, false)} - end - - @impl true - def handle_event( - "open_send_credit_note_modal", - %{"transaction-uuid" => transaction_uuid}, - socket - ) do - {:noreply, - socket - |> assign(:show_send_credit_note_modal, true) - |> assign(:send_credit_note_email, get_default_email(socket.assigns.invoice)) - |> assign(:send_credit_note_transaction_uuid, transaction_uuid)} - end - - @impl true - def handle_event("close_send_credit_note_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_send_credit_note_modal, false) - |> assign(:send_credit_note_transaction_uuid, nil)} - end - - @impl true - def handle_event( - "open_send_payment_confirmation_modal", - %{"transaction-uuid" => transaction_uuid}, - socket - ) do - {:noreply, - socket - |> assign(:show_send_payment_confirmation_modal, true) - |> assign(:send_payment_confirmation_email, get_default_email(socket.assigns.invoice)) - |> assign(:send_payment_confirmation_transaction_uuid, transaction_uuid)} - end - - @impl true - def handle_event("close_send_payment_confirmation_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_send_payment_confirmation_modal, false) - |> assign(:send_payment_confirmation_transaction_uuid, nil)} - end - - # Form Updates - - @impl true - def handle_event("update_payment_form", params, socket) do - socket = - socket - |> assign(:payment_amount, params["amount"] || socket.assigns.payment_amount) - |> assign(:payment_description, params["description"] || socket.assigns.payment_description) - - socket = - if params["payment_method"] do - assign(socket, :selected_payment_method, params["payment_method"]) - else - socket - end - - {:noreply, socket} - end - - @impl true - def handle_event("update_refund_form", params, socket) do - socket = - socket - |> assign(:refund_amount, params["amount"] || socket.assigns.refund_amount) - |> assign(:refund_description, params["description"] || socket.assigns.refund_description) - - socket = - if params["payment_method"] do - assign(socket, :selected_refund_payment_method, params["payment_method"]) - else - socket - end - - {:noreply, socket} - end - - @impl true - def handle_event("update_send_form", %{"email" => email}, socket) do - {:noreply, assign(socket, :send_email, email)} - end - - @impl true - def handle_event("update_send_receipt_form", %{"email" => email}, socket) do - {:noreply, assign(socket, :send_receipt_email, email)} - end - - @impl true - def handle_event("update_send_credit_note_form", %{"email" => email}, socket) do - {:noreply, assign(socket, :send_credit_note_email, email)} - end - - @impl true - def handle_event("update_send_payment_confirmation_form", %{"email" => email}, socket) do - {:noreply, assign(socket, :send_payment_confirmation_email, email)} - end - - # Action Delegators - - @impl true - def handle_event("record_payment", _params, socket), do: Actions.record_payment(socket) - - @impl true - def handle_event("pay_with_provider", %{"provider" => provider}, socket), - do: Actions.pay_with_provider(socket, provider) - - @impl true - def handle_event("record_refund", _params, socket), do: Actions.record_refund(socket) - - @impl true - def handle_event("send_invoice", _params, socket), do: Actions.send_invoice(socket) - - @impl true - def handle_event("send_receipt", _params, socket), do: Actions.send_receipt(socket) - - @impl true - def handle_event("send_credit_note", _params, socket), do: Actions.send_credit_note(socket) - - @impl true - def handle_event("send_payment_confirmation", _params, socket), - do: Actions.send_payment_confirmation(socket) - - @impl true - def handle_event("void_invoice", _params, socket), do: Actions.void_invoice(socket) - - @impl true - def handle_event("generate_receipt", _params, socket), do: Actions.generate_receipt(socket) -end diff --git a/lib/modules/billing/web/invoice_detail.html.heex b/lib/modules/billing/web/invoice_detail.html.heex deleted file mode 100644 index fcf426451..000000000 --- a/lib/modules/billing/web/invoice_detail.html.heex +++ /dev/null @@ -1,1017 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/billing/invoices")}> -
-

- {@invoice.invoice_number} -

- <.invoice_status_badge status={@invoice.status} size={:md} /> -
-

- 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 %> - - - -
- <%!-- Main Content --%> -
- <%!-- Invoice Details --%> -
-
-

Invoice Details

- - <%!-- Line Items --%> -
- - - - - - - - - - - <%= for item <- @invoice.line_items || [] do %> - - - - - - - <% end %> - - - - - - - <%= if Decimal.gt?(@invoice.tax_amount || Decimal.new(0), Decimal.new(0)) do %> - - - - - <% end %> - - - - - -
ItemQtyUnit PriceTotal
-
{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} /> -
-
-
-
- - <%!-- Payments & Transactions --%> -
-
-
-

Payments & Transactions

- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/transactions")} - class="btn btn-ghost btn-xs" - > - View All <.icon name="hero-arrow-right" class="w-3 h-3" /> - -
- - <%!-- Payment Summary --%> -
-
-
Total
-
- <.currency_compact amount={@invoice.total} currency={@invoice.currency} /> -
-
-
-
Paid
-
- <.currency_compact amount={@invoice.paid_amount} currency={@invoice.currency} /> -
-
-
-
- Remaining -
-
- <.currency_compact - amount={PhoenixKit.Modules.Billing.Invoice.remaining_amount(@invoice)} - currency={@invoice.currency} - /> -
-
-
- - <%!-- Transactions Table --%> - <%= if Enum.empty?(@transactions) do %> -
- <.icon name="hero-banknotes" class="w-8 h-8 mx-auto mb-2 opacity-50" /> -

No transactions recorded yet

-
- <% else %> -
- - - - - - - - - - - - - <%= for transaction <- @transactions do %> - - - - - - - - - <% end %> - -
DateNumberTypeAmountDescriptionActions
- <.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 %> -
-
-
- <% end %> -
-
- - <%!-- Payment Terms & Notes --%> - <%= if @invoice.payment_terms || @invoice.notes do %> -
-
-

Payment Information

- <%= if @invoice.payment_terms do %> -
-

Payment Terms

-

{@invoice.payment_terms}

-
- <% end %> - <%= if @invoice.bank_details && map_size(@invoice.bank_details) > 0 do %> -
-

Bank Details

-
- <%= if @invoice.bank_details["bank_name"] do %> -
Bank: {@invoice.bank_details["bank_name"]}
- <% end %> - <%= if @invoice.bank_details["account_name"] do %> -
Account: {@invoice.bank_details["account_name"]}
- <% end %> - <%= if @invoice.bank_details["iban"] do %> -
IBAN: {@invoice.bank_details["iban"]}
- <% end %> - <%= if @invoice.bank_details["swift"] do %> -
SWIFT/BIC: {@invoice.bank_details["swift"]}
- <% end %> -
-
- <% end %> - <%= if @invoice.notes do %> -
-

Notes

-

{@invoice.notes}

-
- <% end %> -
-
- <% end %> - - <%!-- Receipt --%> - <%= if @invoice.receipt_number do %> -
-
-
- <.icon name="hero-document-check" class="w-8 h-8 text-success" /> -
-

Receipt Generated

-

{@invoice.receipt_number}

-

- Generated <.time_ago datetime={@invoice.receipt_generated_at} /> -

-
-
-
-
- <% end %> -
- - <%!-- Sidebar --%> -
- <%!-- Related Order --%> - <%= if @invoice.order do %> -
-
-

Related Order

-
- <.link - navigate={ - PhoenixKit.Utils.Routes.path("/admin/billing/orders/#{@invoice.order.uuid}") - } - class="flex items-center gap-3 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors" - > - <.icon name="hero-clipboard-document-list" class="w-6 h-6 text-primary" /> -
-
{@invoice.order.order_number}
-
- <.order_status_badge status={@invoice.order.status} size={:xs} /> -
-
- -
-
-
- <% end %> - - <%!-- Customer Info --%> -
-
-

Customer

- <%= if @invoice.user do %> -
- <.user_avatar user={@invoice.user} size="lg" /> -
-
{@invoice.user.email}
- <.link - navigate={ - PhoenixKit.Utils.Routes.path("/admin/users/edit/#{@invoice.user.uuid}") - } - class="text-sm text-primary hover:underline" - > - View Profile - -
-
- <% else %> -

No customer linked

- <% end %> -
-
- - <%!-- Billing Details --%> -
-
-

Billing Details

- <%= if @invoice.billing_details && map_size(@invoice.billing_details) > 0 do %> -
- <%= if @invoice.billing_details["type"] == "company" do %> -
{@invoice.billing_details["company_name"]}
- <%= if @invoice.billing_details["company_vat_number"] do %> -
- VAT: {@invoice.billing_details["company_vat_number"]} -
- <% end %> - <% else %> -
- {@invoice.billing_details["first_name"]} {@invoice.billing_details[ - "last_name" - ]} -
- <% end %> - <%= if @invoice.billing_details["address_line1"] do %> -
- {@invoice.billing_details["address_line1"]}
- <%= if @invoice.billing_details["address_line2"] do %> - {@invoice.billing_details["address_line2"]}
- <% end %> - {@invoice.billing_details["city"]}, {@invoice.billing_details["postal_code"]}
- {@invoice.billing_details["country"]} -
- <% end %> -
- <% else %> -

No billing details

- <% end %> -
-
- - <%!-- Timeline (sorted by datetime) --%> -
-
-

Timeline

- <% timeline_events = build_timeline_events(@invoice, @transactions) %> -
    - <%= for {event, index} <- Enum.with_index(timeline_events) do %> -
  • - <%= if index > 0 do %> -
    - <% end %> - <%= case event.type do %> - <% :created -> %> -
    - <.time_ago datetime={event.datetime} /> -
    -
    - <.icon name="hero-document-plus" class="w-4 h-4" /> -
    -
    Created
    - <% :invoice_sent -> %> -
    - <.time_ago datetime={event.datetime} /> -
    -
    - <.icon name="hero-paper-airplane" class="w-4 h-4" /> -
    -
    -
    Invoice Sent
    -
    {event.data["email"]}
    -
    - <% :invoice_sent_legacy -> %> -
    - <.time_ago datetime={event.datetime} /> -
    -
    - <.icon name="hero-paper-airplane" class="w-4 h-4" /> -
    -
    Invoice Sent
    - <% :payment -> %> -
    - <.time_ago datetime={event.datetime} /> -
    -
    - <.icon name="hero-banknotes" class="w-4 h-4" /> -
    -
    -
    Payment
    -
    - +<.currency_compact - amount={event.data.amount} - currency={event.data.currency} - /> -
    - <%= if event.data.description do %> -
    {event.data.description}
    - <% end %> -
    - <% :paid -> %> -
    - <.time_ago datetime={event.datetime} /> -
    -
    - <.icon name="hero-check-circle" class="w-4 h-4" /> -
    -
    - Fully Paid -
    - <% :receipt_generated -> %> -
    - <.time_ago datetime={event.datetime} /> -
    -
    - <.icon name="hero-document-check" class="w-4 h-4" /> -
    -
    -
    Receipt Generated
    -
    {event.data}
    -
    - <% :receipt_sent -> %> -
    - <.time_ago datetime={event.datetime} /> -
    -
    - <.icon name="hero-paper-airplane" class="w-4 h-4" /> -
    -
    -
    Receipt Sent
    -
    {event.data["email"]}
    -
    - <% :refund -> %> -
    - <.time_ago datetime={event.datetime} /> -
    -
    - <.icon name="hero-arrow-uturn-left" class="w-4 h-4" /> -
    -
    -
    Refund
    -
    - <.currency_compact - amount={Decimal.abs(event.data.amount)} - currency={event.data.currency} - /> -
    - <%= if event.data.description do %> -
    {event.data.description}
    - <% end %> -
    - <% :credit_note_sent -> %> -
    - <.time_ago datetime={event.datetime} /> -
    -
    - <.icon name="hero-paper-airplane" class="w-4 h-4" /> -
    -
    -
    Credit Note Sent
    -
    {event.data["email"]}
    -
    - {event.data["credit_note_number"]} -
    -
    - <% :voided -> %> -
    - <.time_ago datetime={event.datetime} /> -
    -
    - <.icon name="hero-x-circle" class="w-4 h-4" /> -
    -
    Voided
    - <% _ -> %> -
    - <.time_ago datetime={event.datetime} /> -
    -
    - <.icon name="hero-question-mark-circle" class="w-4 h-4" /> -
    -
    Unknown Event
    - <% end %> - <%= if index < length(timeline_events) - 1 do %> -
    - <% end %> -
  • - <% end %> - <%!-- Full refund indicator (always at the end if applicable) --%> - <%= if fully_refunded?(@invoice, @transactions) do %> -
  • -
    -
    -
    - <.icon name="hero-x-circle" class="w-4 h-4" /> -
    -
    -
    Fully Refunded
    -
    -
  • - <% end %> -
-
-
-
-
-
- - <%!-- Payment Modal --%> - <%= if @show_payment_modal do %> - - <% end %> - - <%!-- Refund Modal --%> - <%= if @show_refund_modal do %> - - <% end %> - - <%!-- Send Invoice Modal --%> - <%= if @show_send_modal do %> - - <% end %> - - <%!-- Send Receipt Modal --%> - <%= if @show_send_receipt_modal do %> - - <% end %> - - <%!-- Send Credit Note Modal --%> - <%= if @show_send_credit_note_modal do %> - - <% end %> - - <%!-- Send Payment Confirmation Modal --%> - <%= if @show_send_payment_confirmation_modal do %> - - <% end %> -
diff --git a/lib/modules/billing/web/invoice_detail/actions.ex b/lib/modules/billing/web/invoice_detail/actions.ex deleted file mode 100644 index a2785892d..000000000 --- a/lib/modules/billing/web/invoice_detail/actions.ex +++ /dev/null @@ -1,301 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Actions do - @moduledoc """ - Action handlers for the invoice detail LiveView. - - Contains business logic for payment recording, refunds, - sending documents, voiding, and receipt generation. - Each function takes a socket and returns `{:noreply, socket}`. - """ - - import Phoenix.LiveView, only: [put_flash: 3, redirect: 2] - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Utils.Routes - - def record_payment(socket) do - %{ - invoice: invoice, - payment_amount: amount, - payment_description: desc, - selected_payment_method: payment_method - } = socket.assigns - - current_scope = socket.assigns[:phoenix_kit_current_scope] - - attrs = %{ - amount: amount, - payment_method: payment_method, - description: if(desc == "", do: nil, else: desc) - } - - case Billing.record_payment(invoice, attrs, current_scope) do - {:ok, _transaction} -> - socket = reload_invoice(socket) - - {:noreply, - socket - |> Phoenix.Component.assign(:show_payment_modal, false) - |> put_flash(:info, "Payment recorded successfully")} - - {:error, :not_payable} -> - {:noreply, put_flash(socket, :error, "Invoice cannot receive payments in current status")} - - {:error, :exceeds_remaining} -> - {:noreply, put_flash(socket, :error, "Payment amount exceeds remaining balance")} - - {:error, :invalid_amount} -> - {:noreply, put_flash(socket, :error, "Invalid payment amount")} - - {:error, changeset} when is_struct(changeset, Ecto.Changeset) -> - {:noreply, put_flash(socket, :error, "Failed to record payment")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to record payment: #{inspect(reason)}")} - end - end - - def pay_with_provider(socket, provider_str) do - provider = String.to_existing_atom(provider_str) - invoice = socket.assigns.invoice - - success_url = Routes.url("/admin/billing/invoices/#{invoice.uuid}?payment=success") - cancel_url = Routes.url("/admin/billing/invoices/#{invoice.uuid}?payment=cancelled") - - opts = [ - success_url: success_url, - cancel_url: cancel_url, - currency: invoice.currency, - metadata: %{ - invoice_uuid: invoice.uuid, - invoice_number: invoice.invoice_number - } - ] - - socket = Phoenix.Component.assign(socket, :checkout_loading, provider) - - case Billing.create_checkout_session(invoice, provider, opts) do - {:ok, checkout_url} when is_binary(checkout_url) -> - {:noreply, redirect(socket, external: checkout_url)} - - {:error, :provider_not_available} -> - {:noreply, - socket - |> Phoenix.Component.assign(:checkout_loading, nil) - |> put_flash(:error, "Payment provider #{provider} is not available")} - - {:error, reason} -> - {:noreply, - socket - |> Phoenix.Component.assign(:checkout_loading, nil) - |> put_flash(:error, "Failed to create checkout session: #{inspect(reason)}")} - end - end - - def record_refund(socket) do - %{ - invoice: invoice, - refund_amount: amount, - refund_description: desc, - selected_refund_payment_method: payment_method - } = socket.assigns - - current_scope = socket.assigns[:phoenix_kit_current_scope] - - if desc == "" do - {:noreply, put_flash(socket, :error, "Refund reason is required")} - else - attrs = %{ - amount: amount, - payment_method: payment_method, - description: desc - } - - case Billing.record_refund(invoice, attrs, current_scope) do - {:ok, _transaction} -> - socket = reload_invoice(socket) - - {:noreply, - socket - |> Phoenix.Component.assign(:show_refund_modal, false) - |> put_flash(:info, "Refund recorded successfully")} - - {:error, :not_refundable} -> - {:noreply, put_flash(socket, :error, "Invoice has no payments to refund")} - - {:error, :exceeds_paid_amount} -> - {:noreply, put_flash(socket, :error, "Refund amount exceeds paid amount")} - - {:error, :invalid_amount} -> - {:noreply, put_flash(socket, :error, "Invalid refund amount")} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, "Failed to record refund")} - end - end - end - - def send_invoice(socket) do - invoice = socket.assigns.invoice - email = socket.assigns.send_email - invoice_url = Routes.url("/admin/billing/invoices/#{invoice.uuid}/print") - - case Billing.send_invoice(invoice, invoice_url: invoice_url, to_email: email) do - {:ok, updated_invoice} -> - {:noreply, - socket - |> Phoenix.Component.assign(:invoice, updated_invoice) - |> Phoenix.Component.assign(:show_send_modal, false) - |> put_flash(:info, "Invoice sent to #{email}")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to send invoice: #{reason}")} - end - end - - def send_receipt(socket) do - invoice = socket.assigns.invoice - email = socket.assigns.send_receipt_email - receipt_url = Routes.url("/admin/billing/invoices/#{invoice.uuid}/receipt") - - case Billing.send_receipt(invoice, receipt_url: receipt_url, to_email: email) do - {:ok, updated_invoice} -> - {:noreply, - socket - |> Phoenix.Component.assign(:invoice, updated_invoice) - |> Phoenix.Component.assign(:show_send_receipt_modal, false) - |> put_flash(:info, "Receipt sent to #{email}")} - - {:error, :invoice_not_paid} -> - {:noreply, put_flash(socket, :error, "Invoice must be paid before sending receipt")} - - {:error, :receipt_not_generated} -> - {:noreply, put_flash(socket, :error, "Receipt has not been generated yet")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to send receipt: #{inspect(reason)}")} - end - end - - def send_credit_note(socket) do - invoice = socket.assigns.invoice - email = socket.assigns.send_credit_note_email - transaction_uuid = socket.assigns.send_credit_note_transaction_uuid - transaction = Enum.find(socket.assigns.transactions, &(&1.uuid == transaction_uuid)) - - credit_note_url = - Routes.url("/admin/billing/invoices/#{invoice.uuid}/credit-note/#{transaction_uuid}") - - with %{} <- transaction, - {:ok, updated_transaction} <- - Billing.send_credit_note(invoice, transaction, - credit_note_url: credit_note_url, - to_email: email - ) do - updated_transactions = - update_transaction_in_list(socket.assigns.transactions, updated_transaction) - - {:noreply, - socket - |> Phoenix.Component.assign(:transactions, updated_transactions) - |> Phoenix.Component.assign(:show_send_credit_note_modal, false) - |> Phoenix.Component.assign(:send_credit_note_transaction_uuid, nil) - |> put_flash(:info, "Credit note sent to #{email}")} - else - nil -> - {:noreply, put_flash(socket, :error, "Transaction not found")} - - {:error, :not_a_refund} -> - {:noreply, put_flash(socket, :error, "Transaction is not a refund")} - - {:error, :no_recipient_email} -> - {:noreply, put_flash(socket, :error, "No recipient email address")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to send credit note: #{inspect(reason)}")} - end - end - - def send_payment_confirmation(socket) do - invoice = socket.assigns.invoice - email = socket.assigns.send_payment_confirmation_email - transaction_uuid = socket.assigns.send_payment_confirmation_transaction_uuid - transaction = Enum.find(socket.assigns.transactions, &(&1.uuid == transaction_uuid)) - - payment_url = - Routes.url("/admin/billing/invoices/#{invoice.uuid}/payment/#{transaction_uuid}") - - with %{} <- transaction, - {:ok, updated_transaction} <- - Billing.send_payment_confirmation(invoice, transaction, - payment_url: payment_url, - to_email: email - ) do - updated_transactions = - update_transaction_in_list(socket.assigns.transactions, updated_transaction) - - {:noreply, - socket - |> Phoenix.Component.assign(:transactions, updated_transactions) - |> Phoenix.Component.assign(:show_send_payment_confirmation_modal, false) - |> Phoenix.Component.assign(:send_payment_confirmation_transaction_uuid, nil) - |> put_flash(:info, "Payment confirmation sent to #{email}")} - else - nil -> - {:noreply, put_flash(socket, :error, "Transaction not found")} - - {:error, :not_a_payment} -> - {:noreply, put_flash(socket, :error, "Transaction is not a payment")} - - {:error, :no_recipient_email} -> - {:noreply, put_flash(socket, :error, "No recipient email address")} - - {:error, reason} -> - {:noreply, - put_flash(socket, :error, "Failed to send payment confirmation: #{inspect(reason)}")} - end - end - - def void_invoice(socket) do - case Billing.void_invoice(socket.assigns.invoice) do - {:ok, invoice} -> - {:noreply, - socket - |> Phoenix.Component.assign(:invoice, invoice) - |> put_flash(:info, "Invoice voided")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to void invoice: #{reason}")} - end - end - - def generate_receipt(socket) do - case Billing.generate_receipt(socket.assigns.invoice) do - {:ok, invoice} -> - {:noreply, - socket - |> Phoenix.Component.assign(:invoice, invoice) - |> put_flash(:info, "Receipt generated: #{invoice.receipt_number}")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to generate receipt: #{reason}")} - end - end - - # Private helpers - - defp reload_invoice(socket) do - invoice = socket.assigns.invoice - updated_invoice = Billing.get_invoice(invoice.uuid, preload: [:user, :order, :transactions]) - transactions = Billing.list_invoice_transactions(invoice.uuid) - - socket - |> Phoenix.Component.assign(:invoice, updated_invoice) - |> Phoenix.Component.assign(:transactions, transactions) - end - - defp update_transaction_in_list(transactions, updated_transaction) do - Enum.map(transactions, fn t -> - if t.uuid == updated_transaction.uuid, do: updated_transaction, else: t - end) - end -end diff --git a/lib/modules/billing/web/invoice_detail/helpers.ex b/lib/modules/billing/web/invoice_detail/helpers.ex deleted file mode 100644 index 00ce9638c..000000000 --- a/lib/modules/billing/web/invoice_detail/helpers.ex +++ /dev/null @@ -1,212 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do - @moduledoc """ - Helper functions for the invoice detail LiveView. - - Contains timeline building, history parsing, formatting, - and other template-callable utilities. - """ - - alias PhoenixKit.Modules.Billing.Web.InvoiceDetail.TimelineEvent - - @doc """ - Gets the default email address from invoice billing details or user. - """ - def get_default_email(invoice) do - cond do - invoice.billing_details["email"] -> invoice.billing_details["email"] - invoice.user -> invoice.user.email - true -> "" - end - end - - @doc """ - Gets send history from invoice metadata. - """ - def get_send_history(invoice) do - case invoice.metadata do - %{"send_history" => history} when is_list(history) -> history - _ -> [] - end - end - - @doc """ - Gets receipt send history from invoice receipt_data. - """ - def get_receipt_send_history(invoice) do - case invoice.receipt_data do - %{"send_history" => history} when is_list(history) -> history - _ -> [] - end - end - - @doc """ - Gets credit note send history from transaction metadata. - """ - def get_credit_note_send_history(transaction) do - case transaction.metadata do - %{"credit_note_send_history" => history} when is_list(history) -> history - _ -> [] - end - end - - @doc """ - Parses ISO8601 datetime string to DateTime. - """ - def parse_datetime(nil), do: nil - - def parse_datetime(datetime_string) when is_binary(datetime_string) do - case DateTime.from_iso8601(datetime_string) do - {:ok, datetime, _offset} -> datetime - _ -> nil - end - end - - def parse_datetime(datetime), do: datetime - - @doc """ - Builds a sorted timeline of all invoice events. - Returns a list of `%TimelineEvent{}` structs sorted by datetime. - """ - def build_timeline_events(invoice, transactions) do - events = [] - - # 1. Created event - events = [%TimelineEvent{type: :created, datetime: invoice.inserted_at} | events] - - # 2. Invoice sent events - invoice_sends = - get_send_history(invoice) - |> Enum.map(fn entry -> - %TimelineEvent{ - type: :invoice_sent, - datetime: parse_datetime(entry["sent_at"]), - data: entry - } - end) - - events = events ++ invoice_sends - - # Fallback for old invoices without send_history - events = - if invoice.sent_at && Enum.empty?(get_send_history(invoice)) do - [%TimelineEvent{type: :invoice_sent_legacy, datetime: invoice.sent_at} | events] - else - events - end - - # 3. Payment transactions (positive amounts) - payment_events = - transactions - |> Enum.filter(&Decimal.positive?(&1.amount)) - |> Enum.map(fn txn -> - %TimelineEvent{type: :payment, datetime: txn.inserted_at, data: txn} - end) - - events = events ++ payment_events - - # 4. Paid event (when fully paid) - events = - if invoice.paid_at do - [%TimelineEvent{type: :paid, datetime: invoice.paid_at} | events] - else - events - end - - # 5. Receipt generated - events = - if invoice.receipt_number do - [ - %TimelineEvent{ - type: :receipt_generated, - datetime: invoice.receipt_generated_at, - data: invoice.receipt_number - } - | events - ] - else - events - end - - # 6. Receipt sent events - receipt_sends = - get_receipt_send_history(invoice) - |> Enum.map(fn entry -> - %TimelineEvent{ - type: :receipt_sent, - datetime: parse_datetime(entry["sent_at"]), - data: entry - } - end) - - events = events ++ receipt_sends - - # 7. Refund transactions and their credit note sends - refund_events = - transactions - |> Enum.filter(&Decimal.negative?(&1.amount)) - |> Enum.flat_map(fn txn -> - # Refund event itself - refund_event = %TimelineEvent{type: :refund, datetime: txn.inserted_at, data: txn} - - # Credit note send events for this refund - credit_note_sends = - get_credit_note_send_history(txn) - |> Enum.map(fn entry -> - %TimelineEvent{ - type: :credit_note_sent, - datetime: parse_datetime(entry["sent_at"]), - data: Map.put(entry, "transaction", txn) - } - end) - - [refund_event | credit_note_sends] - end) - - events = events ++ refund_events - - # 8. Voided event - events = - if invoice.voided_at do - [%TimelineEvent{type: :voided, datetime: invoice.voided_at} | events] - else - events - end - - # Sort by datetime (nil datetimes go to the end) - events - |> Enum.sort_by( - fn event -> - case event.datetime do - nil -> {1, 0} - dt -> {0, DateTime.to_unix(dt, :microsecond)} - end - end, - :asc - ) - end - - @doc """ - Checks if invoice is fully refunded. - """ - def fully_refunded?(invoice, transactions) do - total_refunded = - transactions - |> Enum.filter(&Decimal.negative?(&1.amount)) - |> Enum.map(& &1.amount) - |> Enum.reduce(Decimal.new(0), &Decimal.add/2) - |> Decimal.abs() - - Decimal.gt?(total_refunded, Decimal.new(0)) && - Decimal.gte?(total_refunded, invoice.total) - end - - @doc """ - Formats payment method name for display. - """ - def format_payment_method_name("bank"), do: "Bank Transfer" - def format_payment_method_name("stripe"), do: "Stripe" - def format_payment_method_name("paypal"), do: "PayPal" - def format_payment_method_name("razorpay"), do: "Razorpay" - def format_payment_method_name(other) when is_binary(other), do: String.capitalize(other) - def format_payment_method_name(_), do: "Unknown" -end diff --git a/lib/modules/billing/web/invoice_detail/timeline_event.ex b/lib/modules/billing/web/invoice_detail/timeline_event.ex deleted file mode 100644 index 30513aece..000000000 --- a/lib/modules/billing/web/invoice_detail/timeline_event.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.TimelineEvent do - @moduledoc """ - Struct representing a single event in the invoice timeline. - - ## Fields - - - `type` - Event type atom (`:created`, `:invoice_sent`, `:payment`, `:paid`, - `:receipt_generated`, `:receipt_sent`, `:refund`, `:credit_note_sent`, `:voided`, - `:invoice_sent_legacy`) - - `datetime` - When the event occurred - - `data` - Event-specific payload (transaction, send history entry, receipt number, or nil) - """ - - @enforce_keys [:type] - defstruct [:type, :datetime, :data] - - @type event_type :: - :created - | :invoice_sent - | :invoice_sent_legacy - | :payment - | :paid - | :receipt_generated - | :receipt_sent - | :refund - | :credit_note_sent - | :voided - - @type t :: %__MODULE__{ - type: event_type(), - datetime: DateTime.t() | NaiveDateTime.t() | nil, - data: term() - } -end diff --git a/lib/modules/billing/web/invoice_print.ex b/lib/modules/billing/web/invoice_print.ex deleted file mode 100644 index 6a81eb348..000000000 --- a/lib/modules/billing/web/invoice_print.ex +++ /dev/null @@ -1,94 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.InvoicePrint do - @moduledoc """ - Printable invoice view - displays invoice in a print-friendly format. - - This page is designed to be printed or saved as PDF directly from the browser. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.CountryData - alias PhoenixKit.Modules.Billing.Transaction - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"id" => id}, _session, socket) do - if Billing.enabled?() do - case Billing.get_invoice(id, preload: [:user, :order, :transactions]) do - nil -> - {:ok, - socket - |> put_flash(:error, "Invoice not found") - |> push_navigate(to: Routes.path("/admin/billing/invoices"))} - - invoice -> - project_title = Settings.get_project_title() - company_info = get_company_info() - - # Calculate refund info from transactions - refund_info = calculate_refund_info(invoice.transactions) - - socket = - socket - |> assign(:page_title, "Invoice #{invoice.invoice_number}") - |> assign(:project_title, project_title) - |> assign(:invoice, invoice) - |> assign(:company, company_info) - |> assign(:refund_info, refund_info) - - {:ok, socket, layout: false} - end - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - defp get_company_info do - %{ - name: Settings.get_setting("billing_company_name", ""), - address: CountryData.format_company_address(), - vat: Settings.get_setting("billing_company_vat", ""), - bank_name: Settings.get_setting("billing_bank_name", ""), - bank_iban: Settings.get_setting("billing_bank_iban", ""), - bank_swift: Settings.get_setting("billing_bank_swift", "") - } - end - - defp calculate_refund_info(transactions) when is_list(transactions) do - refund_txns = - transactions - |> Enum.filter(&Transaction.refund?/1) - |> Enum.sort_by(& &1.inserted_at, {:desc, DateTime}) - - if Enum.empty?(refund_txns) do - nil - else - total_refunded = - refund_txns - |> Enum.map(& &1.amount) - |> Enum.reduce(Decimal.new(0), &Decimal.add/2) - |> Decimal.abs() - - latest_refund = List.first(refund_txns) - - %{ - total: total_refunded, - count: length(refund_txns), - latest_date: latest_refund.inserted_at, - transactions: refund_txns - } - end - end - - defp calculate_refund_info(_), do: nil -end diff --git a/lib/modules/billing/web/invoice_print.html.heex b/lib/modules/billing/web/invoice_print.html.heex deleted file mode 100644 index cd51fbd42..000000000 --- a/lib/modules/billing/web/invoice_print.html.heex +++ /dev/null @@ -1,748 +0,0 @@ - - - - - - Invoice {@invoice.invoice_number} - {@project_title} - - - - - -
-
-
-

INVOICE

-
{@invoice.invoice_number}
-
-
- {String.upcase(@invoice.status)} -
-
- -
-
-
-

From

-

- <%!-- Customer/Client billing details (who pays) --%> - <%= if @invoice.billing_details && map_size(@invoice.billing_details) > 0 do %> - <%= if @invoice.billing_details["type"] == "company" do %> - {@invoice.billing_details["company_name"]} -
- <%= if @invoice.billing_details["company_vat_number"] do %> - VAT: {@invoice.billing_details["company_vat_number"]}
- <% end %> - <% else %> - - {@invoice.billing_details["first_name"]} {@invoice.billing_details[ - "last_name" - ]} - -
- <% end %> - <%= if @invoice.billing_details["address_line1"] do %> - {@invoice.billing_details["address_line1"]}
- <% end %> - <%= if @invoice.billing_details["address_line2"] do %> - {@invoice.billing_details["address_line2"]}
- <% end %> - <%= if @invoice.billing_details["city"] do %> - {@invoice.billing_details["city"]} - <%= if @invoice.billing_details["postal_code"] do %> - , {@invoice.billing_details["postal_code"]} - <% end %> -
- <% end %> - <%= if @invoice.billing_details["country"] do %> - {@invoice.billing_details["country"]} - <% end %> - <% else %> - <%= if @invoice.user do %> - {@invoice.user.email} - <% else %> - No billing information - <% end %> - <% end %> -

-
- -
-

Bill To

-

- <%!-- Our company details (who receives payment) --%> - {@company.name} -
- <%= for line <- String.split(@company.address || "", "\n") do %> - {line}
- <% end %> - <%= if @company.vat != "" do %> - VAT: {@company.vat} - <% end %> -

-
- -
-

Invoice Details

-

- Date: {Calendar.strftime(@invoice.inserted_at, "%B %d, %Y")}
- Due Date: {if @invoice.due_date, - do: Calendar.strftime(@invoice.due_date, "%B %d, %Y"), - else: "-"}
- Currency: {@invoice.currency} - <%= if @invoice.order do %> -
Order: {@invoice.order.order_number} - <% end %> -

-
-
- - - - - - - - - - - - <%= for item <- @invoice.line_items || [] do %> - - - - - - - <% end %> - -
DescriptionQtyUnit PriceAmount
-
{item["name"]}
- <%= if item["description"] && item["description"] != "" do %> -
{item["description"]}
- <% end %> -
{item["quantity"]}{item["unit_price"]} {@invoice.currency}{item["total"]} {@invoice.currency}
- -
- - - - - - <%= if Decimal.gt?(@invoice.tax_amount || Decimal.new(0), Decimal.new(0)) do %> - - - - - <% end %> - - - - -
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} -
-
- - <%= if @invoice.status != "paid" do %> -
-
-
Payment Due
-
- {if @invoice.due_date, - do: Calendar.strftime(@invoice.due_date, "%B %d, %Y"), - else: "On receipt"} -
- <%= if @invoice.payment_terms do %> -
{@invoice.payment_terms}
- <% end %> -
- -
-

Bank Transfer Details

- - - - - - - - - - - - - - - - - -
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}
-
-
- <% end %> - - <%= if @invoice.status == "paid" && @invoice.receipt_number do %> -
- - - - PAID - Receipt #{@invoice.receipt_number} - <%= if @invoice.paid_at do %> - - on {Calendar.strftime(@invoice.paid_at, "%B %d, %Y")} - - <% end %> -
- <% end %> - - <%!-- Refund information --%> - <%= if @refund_info do %> -
- - - - - REFUNDED - {Decimal.to_string(@refund_info.total, :normal)} {@invoice.currency} - - on {Calendar.strftime(@refund_info.latest_date, "%B %d, %Y")} - -
- - <%!-- Payment history table when refunds exist --%> -
-

Payment History

- - - - - - - - - - - <%= for txn <- @invoice.transactions do %> - - - - - - - <% end %> - -
DateTypeMethodAmount
{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 %> -
-
- <% end %> - - <%= if @invoice.notes do %> -
-

- Notes -

-

{@invoice.notes}

-
- <% end %> -
- - -
- - diff --git a/lib/modules/billing/web/invoices.ex b/lib/modules/billing/web/invoices.ex deleted file mode 100644 index 1934662f8..000000000 --- a/lib/modules/billing/web/invoices.ex +++ /dev/null @@ -1,175 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.Invoices do - @moduledoc """ - Invoices list LiveView for the billing module. - - Provides invoice management interface with filtering, searching, and pagination. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Events - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @default_per_page 25 - - @impl true - def mount(_params, _session, socket) do - if Billing.enabled?() do - # Subscribe to invoice events for real-time updates - if connected?(socket), do: Events.subscribe_invoices() - - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Invoices") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing/invoices")) - |> assign(:invoices, []) - |> assign(:total_count, 0) - |> assign(:loading, true) - |> assign_filter_defaults() - |> assign_pagination_defaults() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(params, _url, socket) do - socket = - socket - |> apply_params(params) - |> load_invoices() - - {:noreply, socket} - end - - defp assign_filter_defaults(socket) do - socket - |> assign(:search, "") - |> assign(:status_filter, "all") - end - - defp assign_pagination_defaults(socket) do - socket - |> assign(:page, 1) - |> assign(:per_page, @default_per_page) - |> assign(:total_pages, 1) - end - - defp apply_params(socket, params) do - page = parse_page(params["page"]) - per_page = parse_per_page(params["per_page"]) - search = params["search"] || "" - status = params["status"] || "all" - - socket - |> assign(:page, page) - |> assign(:per_page, per_page) - |> assign(:search, search) - |> assign(:status_filter, status) - end - - defp parse_page(nil), do: 1 - defp parse_page(page) when is_binary(page), do: max(1, String.to_integer(page)) - defp parse_page(page) when is_integer(page), do: max(1, page) - - defp parse_per_page(nil), do: @default_per_page - - defp parse_per_page(per_page) when is_binary(per_page), - do: min(100, max(10, String.to_integer(per_page))) - - defp parse_per_page(per_page) when is_integer(per_page), do: min(100, max(10, per_page)) - - defp load_invoices(socket) do - %{ - page: page, - per_page: per_page, - search: search, - status_filter: status - } = socket.assigns - - opts = [ - page: page, - per_page: per_page, - search: search, - status: if(status == "all", do: nil, else: status), - preload: [:user, :order] - ] - - {invoices, total_count} = Billing.list_invoices_with_count(opts) - total_pages = ceil(total_count / per_page) - - socket - |> assign(:invoices, invoices) - |> assign(:total_count, total_count) - |> assign(:total_pages, max(1, total_pages)) - |> assign(:loading, false) - end - - @impl true - def handle_event("filter", params, socket) do - new_params = build_url_params(socket.assigns, params) - {:noreply, push_patch(socket, to: Routes.path("/admin/billing/invoices?#{new_params}"))} - end - - @impl true - def handle_event("clear_filters", _params, socket) do - {:noreply, push_patch(socket, to: Routes.path("/admin/billing/invoices"))} - end - - @impl true - def handle_event("view_invoice", %{"uuid" => uuid}, socket) do - {:noreply, push_navigate(socket, to: Routes.path("/admin/billing/invoices/#{uuid}"))} - end - - @impl true - def handle_event("page_change", %{"page" => page}, socket) do - new_params = build_url_params(socket.assigns, %{"page" => page}) - {:noreply, push_patch(socket, to: Routes.path("/admin/billing/invoices?#{new_params}"))} - end - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, socket |> assign(:loading, true) |> load_invoices()} - end - - # PubSub event handlers for real-time updates - @impl true - def handle_info({event, _invoice}, socket) - when event in [:invoice_created, :invoice_sent, :invoice_paid, :invoice_voided] do - {:noreply, load_invoices(socket)} - end - - # Catch-all for any other messages (ignore them) - @impl true - def handle_info(_msg, socket) do - {:noreply, socket} - end - - defp build_url_params(assigns, new_params) do - params = %{ - "page" => Map.get(new_params, "page", assigns.page), - "per_page" => assigns.per_page, - "search" => Map.get(new_params, "search", assigns.search), - "status" => Map.get(new_params, "status", assigns.status_filter) - } - - params - |> Enum.reject(fn - {_k, v} when v in ["", "all", nil] -> true - {"page", 1} -> true - {"per_page", @default_per_page} -> true - _ -> false - end) - |> URI.encode_query() - end -end diff --git a/lib/modules/billing/web/invoices.html.heex b/lib/modules/billing/web/invoices.html.heex deleted file mode 100644 index a94c9f366..000000000 --- a/lib/modules/billing/web/invoices.html.heex +++ /dev/null @@ -1,183 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/billing")}> -

Invoices

-

{@total_count} total invoices

- <:actions> - - - - - <%!-- Filters --%> -
-
-
-
- - -
- -
- - -
- - -
-
-
- - <%!-- Invoices Table --%> -
-
- <%= if @loading do %> -
- -
- <% else %> - <%= if Enum.empty?(@invoices) do %> -
- <.icon - name="hero-document-text" - class="w-16 h-16 mx-auto mb-4 text-base-content/30" - /> -

No invoices found

-

- <%= 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 - -
- <% else %> -
- - - - - - - - - - - - - - <%= for invoice <- @invoices do %> - - - - - - - - - - <% end %> - -
Invoice #Order #CustomerStatusTotalDue 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" /> -
{invoice.user.email}
-
- <% else %> - - - <% end %> -
<.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")} - -
-
- - <%!-- Pagination --%> - <%= if @total_pages > 1 do %> -
- <.pagination - current_page={@page} - total_pages={@total_pages} - base_path={PhoenixKit.Utils.Routes.path("/admin/billing/invoices")} - params={%{"search" => @search, "status" => @status_filter}} - /> -
- <% end %> - <% end %> - <% end %> -
-
-
-
diff --git a/lib/modules/billing/web/order_detail.ex b/lib/modules/billing/web/order_detail.ex deleted file mode 100644 index 5d985a7cb..000000000 --- a/lib/modules/billing/web/order_detail.ex +++ /dev/null @@ -1,126 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.OrderDetail do - @moduledoc """ - Order detail LiveView for the billing module. - - Displays complete order information and provides actions for order management. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"id" => id}, _session, socket) do - if Billing.enabled?() do - case Billing.get_order(id, preload: [:user, :billing_profile]) do - nil -> - {:ok, - socket - |> put_flash(:error, "Order not found") - |> push_navigate(to: Routes.path("/admin/billing/orders"))} - - order -> - project_title = Settings.get_project_title() - invoices = Billing.list_invoices_for_order(order.uuid) - - socket = - socket - |> assign(:page_title, "Order #{order.order_number}") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing/orders/#{order.uuid}")) - |> assign(:order, order) - |> assign(:invoices, invoices) - |> assign(:show_status_modal, false) - |> assign(:show_invoice_modal, false) - - {:ok, socket} - end - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("confirm_order", _params, socket) do - case Billing.confirm_order(socket.assigns.order) do - {:ok, order} -> - {:noreply, - socket - |> assign(:order, order) - |> put_flash(:info, "Order confirmed successfully")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to confirm order: #{reason}")} - end - end - - @impl true - def handle_event("mark_paid", _params, socket) do - case Billing.mark_order_paid(socket.assigns.order) do - {:ok, order} -> - {:noreply, - socket - |> assign(:order, order) - |> put_flash(:info, "Order marked as paid")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to mark as paid: #{reason}")} - end - end - - @impl true - def handle_event("cancel_order", _params, socket) do - case Billing.cancel_order(socket.assigns.order) do - {:ok, order} -> - {:noreply, - socket - |> assign(:order, order) - |> put_flash(:info, "Order cancelled")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to cancel order: #{reason}")} - end - end - - @impl true - def handle_event("generate_invoice", _params, socket) do - case Billing.create_invoice_from_order(socket.assigns.order) do - {:ok, invoice} -> - invoices = Billing.list_invoices_for_order(socket.assigns.order.uuid) - - {:noreply, - socket - |> assign(:invoices, invoices) - |> put_flash(:info, "Invoice #{invoice.invoice_number} created")} - - {:error, changeset} -> - errors = format_changeset_errors(changeset) - {:noreply, put_flash(socket, :error, "Failed to create invoice: #{errors}")} - end - end - - @impl true - def handle_event("view_invoice", %{"uuid" => uuid}, socket) do - {:noreply, push_navigate(socket, to: Routes.path("/admin/billing/invoices/#{uuid}"))} - end - - defp format_changeset_errors(changeset) do - changeset - |> Ecto.Changeset.traverse_errors(fn {msg, opts} -> - Enum.reduce(opts, msg, fn {key, value}, acc -> - String.replace(acc, "%{#{key}}", to_string(value)) - end) - end) - |> Enum.map_join("; ", fn {k, v} -> "#{k}: #{Enum.join(v, ", ")}" end) - end -end diff --git a/lib/modules/billing/web/order_detail.html.heex b/lib/modules/billing/web/order_detail.html.heex deleted file mode 100644 index 2b92bebd9..000000000 --- a/lib/modules/billing/web/order_detail.html.heex +++ /dev/null @@ -1,363 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/billing/orders")}> -
-

- {@order.order_number} -

- <.order_status_badge status={@order.status} size={:md} /> -
-

- 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 %> - - - -
- <%!-- Main Content --%> -
- <%!-- Order Summary --%> -
-
-

Order Summary

- - <%!-- Line Items --%> -
- - - - - - - - - - - <%= for item <- @order.line_items || [] do %> - - - - - - - <% end %> - - - - - - - <%= if Decimal.gt?(@order.tax_amount || Decimal.new(0), Decimal.new(0)) do %> - - - - - <% end %> - <%= if Decimal.gt?(@order.discount_amount || Decimal.new(0), Decimal.new(0)) do %> - - - - - <% end %> - - - - - -
ItemQtyUnit PriceTotal
-
{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} /> -
-
-
-
- - <%!-- Notes --%> - <%= if @order.notes || @order.internal_notes do %> -
-
-

Notes

- <%= if @order.notes do %> -
-

Customer Notes

-

{@order.notes}

-
- <% end %> - <%= if @order.internal_notes do %> -
-

Internal Notes

-

{@order.internal_notes}

-
- <% end %> -
-
- <% end %> - - <%!-- Invoices --%> -
-
-
-

Invoices

- <%= if @order.status in ["draft", "pending", "confirmed"] do %> - - <% end %> -
- - <%= if Enum.empty?(@invoices) do %> -
- <.icon name="hero-document-text" class="w-12 h-12 mx-auto mb-2 opacity-50" /> -

No invoices generated yet

-
- <% else %> -
- - - - - - - - - - - - <%= for invoice <- @invoices do %> - - - - - - - - <% end %> - -
Invoice #StatusTotalDue 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")} - - -
-
- <% end %> -
-
-
- - <%!-- Sidebar --%> -
- <%!-- Customer Info --%> -
-
-

Customer

- <%= if @order.user do %> -
- <.user_avatar user={@order.user} size="lg" /> -
-
{@order.user.email}
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/users/#{@order.user.uuid}")} - class="text-sm text-primary hover:underline" - > - View Profile - -
-
- <% else %> -

No customer linked

- <% end %> -
-
- - <%!-- Billing Info --%> -
-
-

Billing Information

- <%= if @order.billing_snapshot && map_size(@order.billing_snapshot) > 0 do %> -
- <%= if @order.billing_snapshot["type"] == "company" do %> -
{@order.billing_snapshot["company_name"]}
- <%= if @order.billing_snapshot["company_vat_number"] do %> -
- VAT: {@order.billing_snapshot["company_vat_number"]} -
- <% end %> - <% else %> -
- {@order.billing_snapshot["first_name"]} {@order.billing_snapshot["last_name"]} -
- <% end %> - <%= if @order.billing_snapshot["address_line1"] do %> -
- {@order.billing_snapshot["address_line1"]}
- <%= if @order.billing_snapshot["address_line2"] do %> - {@order.billing_snapshot["address_line2"]}
- <% end %> - {@order.billing_snapshot["city"]}, {@order.billing_snapshot["postal_code"]}
- {@order.billing_snapshot["country"]} -
- <% end %> -
- <% else %> -

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 %> -
-
- - <%!-- Payment Details --%> -
-
-

Payment

-
-
- Method - <%= if @order.payment_method do %> - {String.upcase(@order.payment_method)} - <% else %> - Not specified - <% end %> -
-
- Currency - <.currency_badge code={@order.currency} size={:sm} /> -
- <%= if @order.confirmed_at do %> -
- Confirmed - - <.time_ago datetime={@order.confirmed_at} /> - -
- <% end %> - <%= if @order.paid_at do %> -
- Paid - - <.time_ago datetime={@order.paid_at} /> - -
- <% end %> - <%= if @order.cancelled_at do %> -
- Cancelled - - <.time_ago datetime={@order.cancelled_at} /> - -
- <% end %> -
-
-
-
-
-
-
diff --git a/lib/modules/billing/web/order_form.ex b/lib/modules/billing/web/order_form.ex deleted file mode 100644 index 5c416d160..000000000 --- a/lib/modules/billing/web/order_form.ex +++ /dev/null @@ -1,338 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.OrderForm do - @moduledoc """ - Order form LiveView for creating and editing orders. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.CountryData - alias PhoenixKit.Modules.Billing.Order - alias PhoenixKit.Settings - alias PhoenixKit.Users.Auth - alias PhoenixKit.Utils.Routes - - @impl true - def mount(params, _session, socket) do - if Billing.enabled?() do - project_title = Settings.get_project_title() - %{users: users} = Auth.list_users_paginated(limit: 100) - currencies = Billing.list_currencies(enabled: true) - default_currency = Settings.get_setting("billing_default_currency", "EUR") - - socket = - socket - |> assign(:project_title, project_title) - |> assign(:users, users) - |> assign(:currencies, currencies) - |> assign(:default_currency, default_currency) - |> assign(:billing_profiles, []) - |> load_order(params["id"]) - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - defp load_order(socket, nil) do - # New order - changeset = - Billing.change_order(%Billing.Order{ - currency: socket.assigns.default_currency, - line_items: [%{"name" => "", "quantity" => 1, "unit_price" => "0.00", "total" => "0.00"}] - }) - - socket - |> assign(:page_title, "New Order") - |> assign(:url_path, Routes.path("/admin/billing/orders/new")) - |> assign(:order, nil) - |> assign(:form, to_form(changeset)) - |> assign(:line_items, [%{id: 0, name: "", description: "", quantity: 1, unit_price: "0.00"}]) - |> assign(:selected_user_uuid, nil) - |> assign(:selected_billing_profile_uuid, nil) - |> assign(:country_tax_rate, nil) - |> assign(:country_name, nil) - |> assign(:country_vat_percent, nil) - end - - defp load_order(socket, id) do - case Billing.get_order(id, preload: [:user, :billing_profile]) do - nil -> - socket - |> put_flash(:error, "Order not found") - |> push_navigate(to: Routes.path("/admin/billing/orders")) - - order -> - changeset = Billing.change_order(order) - line_items = parse_line_items(order.line_items) - - billing_profiles = - if order.user_uuid, do: Billing.list_user_billing_profiles(order.user_uuid), else: [] - - # Get country tax info from billing profile - {country_tax_rate, country_name, country_vat_percent} = - if order.billing_profile do - get_country_tax_info(order.billing_profile.country) - else - {nil, nil, nil} - end - - socket - |> assign(:page_title, "Edit Order #{order.order_number}") - |> assign(:url_path, Routes.path("/admin/billing/orders/#{order.uuid}/edit")) - |> assign(:order, order) - |> assign(:form, to_form(changeset)) - |> assign(:line_items, line_items) - |> assign(:selected_user_uuid, order.user_uuid) - |> assign(:billing_profiles, billing_profiles) - |> assign(:selected_billing_profile_uuid, order.billing_profile_uuid) - |> assign(:country_tax_rate, country_tax_rate) - |> assign(:country_name, country_name) - |> assign(:country_vat_percent, country_vat_percent) - end - end - - defp parse_line_items(nil), - do: [%{id: 0, name: "", description: "", quantity: 1, unit_price: "0.00"}] - - defp parse_line_items([]), - do: [%{id: 0, name: "", description: "", quantity: 1, unit_price: "0.00"}] - - defp parse_line_items(items) do - items - |> Enum.with_index() - |> Enum.map(fn {item, idx} -> - %{ - id: idx, - name: item["name"] || "", - description: item["description"] || "", - quantity: item["quantity"] || 1, - unit_price: item["unit_price"] || "0.00" - } - end) - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("select_user", %{"user_uuid" => user_uuid}, socket) do - user_uuid = if user_uuid == "", do: nil, else: user_uuid - billing_profiles = if user_uuid, do: Billing.list_user_billing_profiles(user_uuid), else: [] - - # Auto-select default profile if available, otherwise select first profile - default_profile = Enum.find(billing_profiles, & &1.is_default) - selected_profile = default_profile || List.first(billing_profiles) - selected_profile_uuid = if selected_profile, do: selected_profile.uuid, else: nil - - # Get country tax info for selected profile - {country_tax_rate, country_name, country_vat_percent} = - if selected_profile do - get_country_tax_info(selected_profile.country) - else - {nil, nil, nil} - end - - {:noreply, - socket - |> assign(:selected_user_uuid, user_uuid) - |> assign(:billing_profiles, billing_profiles) - |> assign(:selected_billing_profile_uuid, selected_profile_uuid) - |> assign(:country_tax_rate, country_tax_rate) - |> assign(:country_name, country_name) - |> assign(:country_vat_percent, country_vat_percent)} - end - - @impl true - def handle_event( - "select_billing_profile", - %{"order" => %{"billing_profile_uuid" => profile_uuid}}, - socket - ) do - handle_billing_profile_selection(profile_uuid, socket) - end - - @impl true - def handle_event("select_billing_profile", %{"profile_uuid" => profile_uuid}, socket) do - handle_billing_profile_selection(profile_uuid, socket) - end - - @impl true - def handle_event("add_line_item", _params, socket) do - new_id = length(socket.assigns.line_items) - new_item = %{id: new_id, name: "", description: "", quantity: 1, unit_price: "0.00"} - {:noreply, assign(socket, :line_items, socket.assigns.line_items ++ [new_item])} - end - - @impl true - def handle_event("remove_line_item", %{"id" => id}, socket) do - id = String.to_integer(id) - items = Enum.reject(socket.assigns.line_items, &(&1.id == id)) - - items = - if Enum.empty?(items), - do: [%{id: 0, name: "", description: "", quantity: 1, unit_price: "0.00"}], - else: items - - {:noreply, assign(socket, :line_items, items)} - end - - @impl true - def handle_event("update_line_item", params, socket) do - id = String.to_integer(params["id"]) - field = String.to_existing_atom(params["field"]) - value = params["value"] - - items = - Enum.map(socket.assigns.line_items, fn item -> - if item.id == id do - Map.put(item, field, value) - else - item - end - end) - - {:noreply, assign(socket, :line_items, items)} - end - - @impl true - def handle_event("save", %{"order" => order_params}, socket) do - # Get tax rate - prefer country-based rate from billing profile, fallback to config - tax_rate = - case socket.assigns.country_tax_rate do - %Decimal{} = rate -> - rate - - _ -> - config = Billing.get_config() - get_tax_rate_decimal(config) - end - - line_items = - socket.assigns.line_items - |> Enum.filter(&(&1.name != "")) - |> Enum.map(fn item -> - quantity = parse_number(item.quantity, 1) - unit_price = parse_decimal(item.unit_price) - total = Decimal.mult(unit_price, quantity) - - %{ - "name" => item.name, - "description" => item.description, - "quantity" => quantity, - "unit_price" => Decimal.to_string(unit_price), - "total" => Decimal.to_string(total) - } - end) - - # Calculate totals with tax using Order.calculate_totals - {subtotal, tax_amount, total} = Order.calculate_totals(line_items, tax_rate, Decimal.new("0")) - - order_params = - order_params - |> Map.put("line_items", line_items) - |> Map.put("subtotal", Decimal.to_string(subtotal)) - |> Map.put("tax_rate", Decimal.to_string(tax_rate)) - |> Map.put("tax_amount", Decimal.to_string(tax_amount)) - |> Map.put("total", Decimal.to_string(total)) - |> Map.put("user_uuid", socket.assigns.selected_user_uuid) - |> Map.put("billing_profile_uuid", socket.assigns.selected_billing_profile_uuid) - - save_order(socket, order_params) - end - - defp save_order(socket, params) do - result = - if socket.assigns.order do - Billing.update_order(socket.assigns.order, params) - else - Billing.create_order(params) - end - - case result do - {:ok, order} -> - {:noreply, - socket - |> put_flash(:info, "Order saved successfully") - |> push_navigate(to: Routes.path("/admin/billing/orders/#{order.uuid}"))} - - {:error, changeset} -> - {:noreply, assign(socket, :form, to_form(changeset))} - end - rescue - e -> - require Logger - Logger.error("Order save failed: #{Exception.message(e)}") - {:noreply, put_flash(socket, :error, gettext("Something went wrong. Please try again."))} - end - - defp handle_billing_profile_selection(profile_uuid, socket) do - profile_uuid = if profile_uuid == "", do: nil, else: profile_uuid - - {country_tax_rate, country_name, country_vat_percent} = - if profile_uuid do - case Billing.get_billing_profile(profile_uuid) do - nil -> {nil, nil, nil} - profile -> get_country_tax_info(profile.country) - end - else - {nil, nil, nil} - end - - {:noreply, - socket - |> assign(:selected_billing_profile_uuid, profile_uuid) - |> assign(:country_tax_rate, country_tax_rate) - |> assign(:country_name, country_name) - |> assign(:country_vat_percent, country_vat_percent)} - end - - defp parse_number(value, _default) when is_integer(value), do: value - - defp parse_number(value, default) when is_binary(value) do - case Integer.parse(value) do - {num, _} -> num - :error -> default - end - end - - defp parse_number(_, default), do: default - - defp parse_decimal(value) when is_binary(value) do - case Decimal.parse(value) do - {decimal, _} -> decimal - :error -> Decimal.new(0) - end - end - - defp parse_decimal(_), do: Decimal.new(0) - - defp get_tax_rate_decimal(config) do - if config.tax_enabled do - # Settings stores "20" for 20%, schema needs 0.20 - config.default_tax_rate - |> Decimal.new() - |> Decimal.div(Decimal.new(100)) - else - Decimal.new("0") - end - end - - defp get_country_tax_info(nil), do: {nil, nil, nil} - - defp get_country_tax_info(country_code) when is_binary(country_code) do - tax_rate = CountryData.get_standard_vat_rate(country_code) - vat_percent = CountryData.get_standard_vat_percent(country_code) - country_name = CountryData.get_country_name(country_code) - - {tax_rate, country_name, vat_percent} - end - - defp get_country_tax_info(_), do: {nil, nil, nil} -end diff --git a/lib/modules/billing/web/order_form.html.heex b/lib/modules/billing/web/order_form.html.heex deleted file mode 100644 index c78001cc5..000000000 --- a/lib/modules/billing/web/order_form.html.heex +++ /dev/null @@ -1,288 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/billing/orders")}> -

{@page_title}

-

- {if @order, do: "Modify order details", else: "Create a new order"} -

- - - <.form for={@form} phx-submit="save" class="space-y-6"> - <%!-- Customer Selection --%> -
-
-

Customer

- -
-
- - -
- - <%= if @selected_user_uuid do %> - <%= if length(@billing_profiles) > 0 do %> -
- - -
- <% else %> - <%!-- Warning: No billing profiles - block order creation --%> -
-
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> -
-

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 - -

-
-
-
- <% end %> - <% end %> -
-
-
- - <%!-- Line Items --%> -
-
-
-

Line Items

- -
- -
- - - - - - - - - - - - <%= for item <- @line_items do %> - - - - - - - - <% end %> - -
NameDescriptionQtyUnit Price
- - - - - - - - - -
-
-
-
- - <%!-- Order Settings --%> -
-
-

Order Settings

- -
-
- - -
- -
- - -
-
-
-
- - <%!-- Notes --%> -
-
-

Notes

- -
-
- - -
- -
- - -
-
-
-
- - <%!-- Actions --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/orders")} - class="btn btn-ghost" - > - Cancel - - -
- -
-
diff --git a/lib/modules/billing/web/orders.ex b/lib/modules/billing/web/orders.ex deleted file mode 100644 index 034468acd..000000000 --- a/lib/modules/billing/web/orders.ex +++ /dev/null @@ -1,183 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.Orders do - @moduledoc """ - Orders list LiveView for the billing module. - - Provides order management interface with filtering, searching, and pagination. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Events - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @default_per_page 25 - - @impl true - def mount(_params, _session, socket) do - if Billing.enabled?() do - # Subscribe to order events for real-time updates - if connected?(socket), do: Events.subscribe_orders() - - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Orders") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing/orders")) - |> assign(:orders, []) - |> assign(:total_count, 0) - |> assign(:loading, true) - |> assign_filter_defaults() - |> assign_pagination_defaults() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(params, _url, socket) do - socket = - socket - |> apply_params(params) - |> load_orders() - - {:noreply, socket} - end - - defp assign_filter_defaults(socket) do - socket - |> assign(:search, "") - |> assign(:status_filter, "all") - |> assign(:date_from, nil) - |> assign(:date_to, nil) - end - - defp assign_pagination_defaults(socket) do - socket - |> assign(:page, 1) - |> assign(:per_page, @default_per_page) - |> assign(:total_pages, 1) - end - - defp apply_params(socket, params) do - page = parse_page(params["page"]) - per_page = parse_per_page(params["per_page"]) - search = params["search"] || "" - status = params["status"] || "all" - - socket - |> assign(:page, page) - |> assign(:per_page, per_page) - |> assign(:search, search) - |> assign(:status_filter, status) - end - - defp parse_page(nil), do: 1 - defp parse_page(page) when is_binary(page), do: max(1, String.to_integer(page)) - defp parse_page(page) when is_integer(page), do: max(1, page) - - defp parse_per_page(nil), do: @default_per_page - - defp parse_per_page(per_page) when is_binary(per_page), - do: min(100, max(10, String.to_integer(per_page))) - - defp parse_per_page(per_page) when is_integer(per_page), do: min(100, max(10, per_page)) - - defp load_orders(socket) do - %{ - page: page, - per_page: per_page, - search: search, - status_filter: status - } = socket.assigns - - opts = [ - page: page, - per_page: per_page, - search: search, - status: if(status == "all", do: nil, else: status), - preload: [:user] - ] - - {orders, total_count} = Billing.list_orders_with_count(opts) - total_pages = ceil(total_count / per_page) - - socket - |> assign(:orders, orders) - |> assign(:total_count, total_count) - |> assign(:total_pages, max(1, total_pages)) - |> assign(:loading, false) - end - - @impl true - def handle_event("filter", params, socket) do - new_params = build_url_params(socket.assigns, params) - {:noreply, push_patch(socket, to: Routes.path("/admin/billing/orders?#{new_params}"))} - end - - @impl true - def handle_event("clear_filters", _params, socket) do - {:noreply, push_patch(socket, to: Routes.path("/admin/billing/orders"))} - end - - @impl true - def handle_event("view_order", %{"uuid" => uuid}, socket) do - {:noreply, push_navigate(socket, to: Routes.path("/admin/billing/orders/#{uuid}"))} - end - - @impl true - def handle_event("page_change", %{"page" => page}, socket) do - new_params = build_url_params(socket.assigns, %{"page" => page}) - {:noreply, push_patch(socket, to: Routes.path("/admin/billing/orders?#{new_params}"))} - end - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, socket |> assign(:loading, true) |> load_orders()} - end - - # PubSub event handlers for real-time updates - @impl true - def handle_info({event, _order}, socket) - when event in [ - :order_created, - :order_updated, - :order_confirmed, - :order_paid, - :order_cancelled - ] do - {:noreply, load_orders(socket)} - end - - # Catch-all for any other messages (ignore them) - @impl true - def handle_info(_msg, socket) do - {:noreply, socket} - end - - defp build_url_params(assigns, new_params) do - params = %{ - "page" => Map.get(new_params, "page", assigns.page), - "per_page" => assigns.per_page, - "search" => Map.get(new_params, "search", assigns.search), - "status" => Map.get(new_params, "status", assigns.status_filter) - } - - params - |> Enum.reject(fn - {_k, v} when v in ["", "all", nil] -> true - {"page", 1} -> true - {"per_page", @default_per_page} -> true - _ -> false - end) - |> URI.encode_query() - end -end diff --git a/lib/modules/billing/web/orders.html.heex b/lib/modules/billing/web/orders.html.heex deleted file mode 100644 index 8440a2824..000000000 --- a/lib/modules/billing/web/orders.html.heex +++ /dev/null @@ -1,183 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/billing")}> -

Orders

-

{@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 --%> -
-
-
-
- - -
- -
- - -
- - -
-
-
- - <%!-- Orders Table --%> -
-
- <%= if @loading do %> -
- -
- <% else %> - <%= if Enum.empty?(@orders) do %> -
- <.icon - name="hero-clipboard-document-list" - class="w-16 h-16 mx-auto mb-4 text-base-content/30" - /> -

No orders found

-

- <%= 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 - -
- <% else %> -
- - - - - - - - - - - - - - <%= for order <- @orders do %> - - - - - - - - - - <% end %> - -
Order #CustomerStatusPaymentTotalDate
{order.order_number} - <%= if order.user do %> -
- <.user_avatar user={order.user} size="sm" /> -
-
{order.user.email}
-
-
- <% else %> - - - <% end %> -
<.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")} - -
-
- - <%!-- Pagination --%> - <%= if @total_pages > 1 do %> -
- <.pagination - current_page={@page} - total_pages={@total_pages} - base_path={PhoenixKit.Utils.Routes.path("/admin/billing/orders")} - params={%{"search" => @search, "status" => @status_filter}} - /> -
- <% end %> - <% end %> - <% end %> -
-
-
-
diff --git a/lib/modules/billing/web/payment_confirmation_print.ex b/lib/modules/billing/web/payment_confirmation_print.ex deleted file mode 100644 index 3c047dafb..000000000 --- a/lib/modules/billing/web/payment_confirmation_print.ex +++ /dev/null @@ -1,128 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.PaymentConfirmationPrint do - @moduledoc """ - Printable payment confirmation view - displays individual payment in a print-friendly format. - - This page is designed to be printed or saved as PDF directly from the browser. - Payment confirmations are generated for individual payment transactions. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.CountryData - alias PhoenixKit.Modules.Billing.Transaction - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"id" => invoice_uuid, "transaction_uuid" => transaction_uuid}, _session, socket) do - with true <- Billing.enabled?(), - %{} = invoice <- Billing.get_invoice(invoice_uuid, preload: [:user, :order]), - %Transaction{} = transaction <- Billing.get_transaction(transaction_uuid), - true <- Transaction.payment?(transaction) do - mount_payment_confirmation(socket, invoice, transaction) - else - false -> - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - - nil -> - error_msg = - if Billing.get_invoice(invoice_uuid) == nil, - do: "Invoice not found", - else: "Transaction not found" - - redirect_path = - if Billing.get_invoice(invoice_uuid) == nil, - do: Routes.path("/admin/billing/invoices"), - else: Routes.path("/admin/billing/invoices/#{invoice_uuid}") - - {:ok, - socket - |> put_flash(:error, error_msg) - |> push_navigate(to: redirect_path)} - - %Transaction{} -> - {:ok, - socket - |> put_flash(:error, "Transaction is not a payment") - |> push_navigate(to: Routes.path("/admin/billing/invoices/#{invoice_uuid}"))} - end - end - - defp mount_payment_confirmation(socket, invoice, transaction) do - project_title = Settings.get_project_title() - company_info = get_company_info() - confirmation_number = generate_confirmation_number(transaction) - all_transactions = Billing.list_invoice_transactions(invoice.uuid) - payment_context = calculate_payment_context(invoice, transaction, all_transactions) - - socket = - socket - |> assign(:page_title, "Payment Confirmation #{confirmation_number}") - |> assign(:project_title, project_title) - |> assign(:invoice, invoice) - |> assign(:transaction, transaction) - |> assign(:confirmation_number, confirmation_number) - |> assign(:company, company_info) - |> assign(:payment_context, payment_context) - - {:ok, socket, layout: false} - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - defp get_company_info do - %{ - name: Settings.get_setting("billing_company_name", ""), - address: CountryData.format_company_address(), - vat: Settings.get_setting("billing_company_vat", ""), - bank_name: Settings.get_setting("billing_bank_name", ""), - bank_iban: Settings.get_setting("billing_bank_iban", ""), - bank_swift: Settings.get_setting("billing_bank_swift", "") - } - end - - defp generate_confirmation_number(transaction) do - prefix = Settings.get_setting("billing_payment_confirmation_prefix", "PMT") - suffix = transaction.transaction_number |> String.replace(~r/^TXN-/, "") - "#{prefix}-#{suffix}" - end - - defp calculate_payment_context(invoice, transaction, all_transactions) do - # Payments up to and including this transaction - sorted_payments = - all_transactions - |> Enum.filter(&Decimal.positive?(&1.amount)) - |> Enum.sort_by(& &1.inserted_at, {:asc, DateTime}) - - # Find position of current payment - payment_index = - Enum.find_index(sorted_payments, fn t -> t.uuid == transaction.uuid end) || 0 - - # Total paid up to and including this payment - payments_up_to_now = Enum.take(sorted_payments, payment_index + 1) - - total_paid_so_far = - payments_up_to_now - |> Enum.map(& &1.amount) - |> Enum.reduce(Decimal.new(0), &Decimal.add/2) - - remaining_balance = Decimal.sub(invoice.total, total_paid_so_far) - - is_final_payment = Decimal.lte?(remaining_balance, Decimal.new(0)) - - %{ - payment_number: payment_index + 1, - total_payments: length(sorted_payments), - total_paid_so_far: total_paid_so_far, - remaining_balance: Decimal.max(remaining_balance, Decimal.new(0)), - is_final_payment: is_final_payment - } - end -end diff --git a/lib/modules/billing/web/payment_confirmation_print.html.heex b/lib/modules/billing/web/payment_confirmation_print.html.heex deleted file mode 100644 index 0475d073e..000000000 --- a/lib/modules/billing/web/payment_confirmation_print.html.heex +++ /dev/null @@ -1,597 +0,0 @@ - - - - - - Payment Confirmation {@confirmation_number} - {@project_title} - - - - - -
-
-
-

PAYMENT CONFIRMATION

-
{@confirmation_number}
-
- <%= if @payment_context.is_final_payment do %> -
PAID IN FULL
- <% else %> -
PARTIAL PAYMENT
- <% end %> -
- -
-
-
-

Received From

-

- <%= if @invoice.billing_details && map_size(@invoice.billing_details) > 0 do %> - <%= if @invoice.billing_details["type"] == "company" do %> - {@invoice.billing_details["company_name"]} -
- <%= if @invoice.billing_details["company_vat_number"] do %> - VAT: {@invoice.billing_details["company_vat_number"]}
- <% end %> - <% else %> - - {@invoice.billing_details["first_name"]} {@invoice.billing_details[ - "last_name" - ]} - -
- <% end %> - <%= if @invoice.billing_details["address_line1"] do %> - {@invoice.billing_details["address_line1"]}
- <% end %> - <%= if @invoice.billing_details["city"] do %> - {@invoice.billing_details["city"]} - <%= if @invoice.billing_details["postal_code"] do %> - , {@invoice.billing_details["postal_code"]} - <% end %> -
- <% end %> - <%= if @invoice.billing_details["country"] do %> - {@invoice.billing_details["country"]} - <% end %> - <% else %> - <%= if @invoice.user do %> - {@invoice.user.email} - <% else %> - No billing information - <% end %> - <% end %> -

-
- -
-

Received By

-

- {@company.name} -
- <%= for line <- String.split(@company.address || "", "\n") do %> - {line}
- <% end %> - <%= if @company.vat != "" do %> - VAT: {@company.vat} - <% end %> -

-
- -
-

Payment Details

-

- Confirmation #: {@confirmation_number}
- Payment Date: - {Calendar.strftime(@transaction.inserted_at, "%B %d, %Y")}
- Payment Method: {String.capitalize(@transaction.payment_method)}
- Currency: {@invoice.currency} -

-
-
- - <%!-- Payment Amount Box --%> -
-

- - - - <%= if @payment_context.is_final_payment do %> - Payment Received - Invoice Paid in Full - <% else %> - Partial Payment Received - <% end %> -

-
-
- Amount Received - - {Decimal.to_string(@transaction.amount, :normal)} {@invoice.currency} - -
-
- Payment Date & Time - - {Calendar.strftime(@transaction.inserted_at, "%B %d, %Y at %H:%M")} - -
-
- Transaction Number - {@transaction.transaction_number} -
-
- - Payment #{@payment_context.payment_number} of {@payment_context.total_payments} - - {String.capitalize(@transaction.payment_method)} -
-
-
- - <%!-- Balance Summary --%> -
-
-
Invoice Total
-
- {Decimal.to_string(@invoice.total, :normal)} {@invoice.currency} -
-
- -
-
Remaining Balance
-
- {Decimal.to_string(@payment_context.remaining_balance, :normal)} {@invoice.currency} -
-
-
- - <%!-- Invoice Reference --%> -
-

Invoice Reference

-
-
-
Invoice Number
-
{@invoice.invoice_number}
-
-
-
Invoice Date
-
{Calendar.strftime(@invoice.inserted_at, "%B %d, %Y")}
-
- <%= if @invoice.order do %> -
-
Order Number
-
{@invoice.order.order_number}
-
- <% end %> -
-
- - <%= if @transaction.description do %> -
-

- Payment Notes -

-

{@transaction.description}

-
- <% end %> -
- - -
- - diff --git a/lib/modules/billing/web/provider_settings.ex b/lib/modules/billing/web/provider_settings.ex deleted file mode 100644 index b98c0b9a3..000000000 --- a/lib/modules/billing/web/provider_settings.ex +++ /dev/null @@ -1,179 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.ProviderSettings do - @moduledoc """ - Payment provider settings LiveView for the billing module. - - Provides configuration interface for Stripe, PayPal, and Razorpay payment providers. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Providers - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if Billing.enabled?() do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Payment Providers") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/settings/billing/providers")) - |> load_provider_settings() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin/billing/settings"))} - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - defp load_provider_settings(socket) do - socket - # Stripe settings - |> assign(:stripe_enabled, Settings.get_setting("billing_stripe_enabled", "false") == "true") - |> assign(:stripe_secret_key, Settings.get_setting("billing_stripe_secret_key", "")) - |> assign(:stripe_publishable_key, Settings.get_setting("billing_stripe_publishable_key", "")) - |> assign(:stripe_webhook_secret, Settings.get_setting("billing_stripe_webhook_secret", "")) - |> assign(:stripe_webhook_url, Routes.url("/webhooks/billing/stripe")) - # PayPal settings - |> assign(:paypal_enabled, Settings.get_setting("billing_paypal_enabled", "false") == "true") - |> assign(:paypal_client_id, Settings.get_setting("billing_paypal_client_id", "")) - |> assign(:paypal_client_secret, Settings.get_setting("billing_paypal_client_secret", "")) - |> assign(:paypal_webhook_id, Settings.get_setting("billing_paypal_webhook_id", "")) - |> assign(:paypal_mode, Settings.get_setting("billing_paypal_mode", "sandbox")) - |> assign(:paypal_webhook_url, Routes.url("/webhooks/billing/paypal")) - # Razorpay settings - |> assign( - :razorpay_enabled, - Settings.get_setting("billing_razorpay_enabled", "false") == "true" - ) - |> assign(:razorpay_key_id, Settings.get_setting("billing_razorpay_key_id", "")) - |> assign(:razorpay_key_secret, Settings.get_setting("billing_razorpay_key_secret", "")) - |> assign( - :razorpay_webhook_secret, - Settings.get_setting("billing_razorpay_webhook_secret", "") - ) - |> assign(:razorpay_webhook_url, Routes.url("/webhooks/billing/razorpay")) - # Provider availability - |> assign(:available_providers, Providers.list_available_providers()) - end - - @impl true - def handle_event("toggle_stripe", _params, socket) do - new_enabled = !socket.assigns.stripe_enabled - Settings.update_setting("billing_stripe_enabled", to_string(new_enabled)) - - {:noreply, - socket - |> assign(:stripe_enabled, new_enabled) - |> assign(:available_providers, Providers.list_available_providers()) - |> put_flash(:info, if(new_enabled, do: "Stripe enabled", else: "Stripe disabled"))} - end - - @impl true - def handle_event("save_stripe", params, socket) do - settings = [ - {"billing_stripe_secret_key", params["secret_key"] || ""}, - {"billing_stripe_publishable_key", params["publishable_key"] || ""}, - {"billing_stripe_webhook_secret", params["webhook_secret"] || ""} - ] - - Enum.each(settings, fn {key, value} -> - Settings.update_setting(key, value) - end) - - {:noreply, - socket - |> load_provider_settings() - |> put_flash(:info, "Stripe settings saved")} - end - - @impl true - def handle_event("toggle_paypal", _params, socket) do - new_enabled = !socket.assigns.paypal_enabled - Settings.update_setting("billing_paypal_enabled", to_string(new_enabled)) - - {:noreply, - socket - |> assign(:paypal_enabled, new_enabled) - |> assign(:available_providers, Providers.list_available_providers()) - |> put_flash(:info, if(new_enabled, do: "PayPal enabled", else: "PayPal disabled"))} - end - - @impl true - def handle_event("save_paypal", params, socket) do - settings = [ - {"billing_paypal_client_id", params["client_id"] || ""}, - {"billing_paypal_client_secret", params["client_secret"] || ""}, - {"billing_paypal_webhook_id", params["webhook_id"] || ""}, - {"billing_paypal_mode", params["mode"] || "sandbox"} - ] - - Enum.each(settings, fn {key, value} -> - Settings.update_setting(key, value) - end) - - {:noreply, - socket - |> load_provider_settings() - |> put_flash(:info, "PayPal settings saved")} - end - - @impl true - def handle_event("toggle_razorpay", _params, socket) do - new_enabled = !socket.assigns.razorpay_enabled - Settings.update_setting("billing_razorpay_enabled", to_string(new_enabled)) - - {:noreply, - socket - |> assign(:razorpay_enabled, new_enabled) - |> assign(:available_providers, Providers.list_available_providers()) - |> put_flash(:info, if(new_enabled, do: "Razorpay enabled", else: "Razorpay disabled"))} - end - - @impl true - def handle_event("save_razorpay", params, socket) do - settings = [ - {"billing_razorpay_key_id", params["key_id"] || ""}, - {"billing_razorpay_key_secret", params["key_secret"] || ""}, - {"billing_razorpay_webhook_secret", params["webhook_secret"] || ""} - ] - - Enum.each(settings, fn {key, value} -> - Settings.update_setting(key, value) - end) - - {:noreply, - socket - |> load_provider_settings() - |> put_flash(:info, "Razorpay settings saved")} - end - - # Helper to mask sensitive keys - def mask_key(nil), do: "" - def mask_key(""), do: "" - - def mask_key(key) when is_binary(key) do - len = String.length(key) - - if len > 8 do - String.slice(key, 0, 4) <> String.duplicate("•", len - 8) <> String.slice(key, -4, 4) - else - String.duplicate("•", len) - end - end - - def has_credentials?(key) when is_binary(key), do: key != "" - def has_credentials?(_), do: false -end diff --git a/lib/modules/billing/web/provider_settings.html.heex b/lib/modules/billing/web/provider_settings.html.heex deleted file mode 100644 index 9a8e3fca7..000000000 --- a/lib/modules/billing/web/provider_settings.html.heex +++ /dev/null @@ -1,447 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin/billing")} - title="Payment Providers" - subtitle="Configure online payment integrations" - /> - - <%!-- Navigation Tabs --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/billing")} - class="tab" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-2" /> General - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/billing/providers")} - class="tab tab-active" - > - <.icon name="hero-credit-card" class="w-4 h-4 mr-2" /> Payment Providers - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/currencies")} - class="tab" - > - <.icon name="hero-currency-dollar" class="w-4 h-4 mr-2" /> Currencies - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscription-types")} - class="tab" - > - <.icon name="hero-clipboard-document-list" class="w-4 h-4 mr-2" /> Subscription Types - -
- - <%!-- Active Providers Summary --%> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> -
- Active Providers: - <%= if Enum.empty?(@available_providers) do %> - None configured - <% else %> - <%= for {provider, index} <- Enum.with_index(@available_providers) do %> - - {provider |> Atom.to_string() |> String.capitalize()} - - <%= if index < length(@available_providers) - 1 do %> - - <% end %> - <% end %> - <% end %> -
-
- -
- <%!-- Stripe Provider --%> -
-
-
-
-
- <.icon name="hero-credit-card" class="w-6 h-6 text-primary" /> -
-
-

Stripe

-

Cards, Apple Pay, Google Pay

-
-
- -
- -
- -
-
- - -
- -
- - -
- -
- - -
- -
- -
- - -
-
- -
- -
-
-
-
- - <%!-- PayPal Provider --%> -
-
-
-
-
- <.icon name="hero-currency-dollar" class="w-6 h-6 text-info" /> -
-
-

PayPal

-

PayPal, Venmo, Cards

-
-
- -
- -
- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
- - -
-
- -
- -
-
-
-
- - <%!-- Razorpay Provider --%> -
-
-
-
-
- <.icon name="hero-banknotes" class="w-6 h-6 text-secondary" /> -
-
-

Razorpay

-

India payments (UPI, Cards)

-
-
- -
- -
- -
-
- - -
- -
- - -
- -
- - -
- -
- -
- - -
-
- -
- -
-
-
-
-
- - <%!-- Quick Help --%> -
-
-

Setup Instructions

-
-
-

- <.icon name="hero-credit-card" class="w-5 h-5 text-primary" /> Stripe -

-
    -
  1. Create account at stripe.com
  2. -
  3. Get API keys from Dashboard → Developers
  4. -
  5. Create webhook with events: checkout.session.completed, payment_intent.*
  6. -
  7. Copy webhook signing secret
  8. -
-
-
-

- <.icon name="hero-currency-dollar" class="w-5 h-5 text-info" /> PayPal -

-
    -
  1. Create app at developer.paypal.com
  2. -
  3. Get Client ID and Secret
  4. -
  5. Configure webhook with events: CHECKOUT.ORDER.*, PAYMENT.*
  6. -
  7. Copy Webhook ID
  8. -
-
-
-

- <.icon name="hero-banknotes" class="w-5 h-5 text-secondary" /> Razorpay -

-
    -
  1. Create account at razorpay.com
  2. -
  3. Get Key ID and Secret from Settings → API Keys
  4. -
  5. Create webhook with events: payment.*, order.paid
  6. -
  7. Copy webhook secret
  8. -
-
-
-
-
-
-
diff --git a/lib/modules/billing/web/receipt_print.ex b/lib/modules/billing/web/receipt_print.ex deleted file mode 100644 index ffaf26ba5..000000000 --- a/lib/modules/billing/web/receipt_print.ex +++ /dev/null @@ -1,111 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.ReceiptPrint do - @moduledoc """ - Printable receipt view - displays receipt in a print-friendly format. - - This page is designed to be printed or saved as PDF directly from the browser. - Receipts are generated after invoice payment is confirmed. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.CountryData - alias PhoenixKit.Modules.Billing.Invoice - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"id" => id}, _session, socket) do - if Billing.enabled?() do - case Billing.get_invoice(id, preload: [:user, :order, :transactions]) do - nil -> - {:ok, - socket - |> put_flash(:error, "Invoice not found") - |> push_navigate(to: Routes.path("/admin/billing/invoices"))} - - %Invoice{receipt_number: nil} = _invoice -> - {:ok, - socket - |> put_flash(:error, "Receipt not yet generated for this invoice") - |> push_navigate(to: Routes.path("/admin/billing/invoices/#{id}"))} - - invoice -> - project_title = Settings.get_project_title() - company_info = get_company_info() - transactions = Billing.list_invoice_transactions(invoice.uuid) - - # Calculate receipt status and related data - receipt_status = Billing.calculate_receipt_status(invoice, transactions) - {total_refunded, last_refund_date} = calculate_refund_info(transactions) - last_payment_date = get_last_payment_date(transactions) - - socket = - socket - |> assign(:page_title, "Receipt #{invoice.receipt_number}") - |> assign(:project_title, project_title) - |> assign(:invoice, invoice) - |> assign(:transactions, transactions) - |> assign(:company, company_info) - |> assign(:receipt_status, receipt_status) - |> assign(:total_refunded, total_refunded) - |> assign(:last_refund_date, last_refund_date) - |> assign(:last_payment_date, last_payment_date) - - {:ok, socket, layout: false} - end - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - defp get_company_info do - %{ - name: Settings.get_setting("billing_company_name", ""), - address: CountryData.format_company_address(), - vat: Settings.get_setting("billing_company_vat", ""), - bank_name: Settings.get_setting("billing_bank_name", ""), - bank_iban: Settings.get_setting("billing_bank_iban", ""), - bank_swift: Settings.get_setting("billing_bank_swift", "") - } - end - - defp calculate_refund_info(transactions) do - refunds = - transactions - |> Enum.filter(&Decimal.negative?(&1.amount)) - |> Enum.sort_by(& &1.inserted_at, {:desc, DateTime}) - - total_refunded = - refunds - |> Enum.map(& &1.amount) - |> Enum.reduce(Decimal.new(0), &Decimal.add/2) - |> Decimal.abs() - - last_refund_date = - case refunds do - [first | _] -> first.inserted_at - [] -> nil - end - - {total_refunded, last_refund_date} - end - - defp get_last_payment_date(transactions) do - transactions - |> Enum.filter(&Decimal.positive?(&1.amount)) - |> Enum.sort_by(& &1.inserted_at, {:desc, DateTime}) - |> case do - [first | _] -> first.inserted_at - [] -> nil - end - end -end diff --git a/lib/modules/billing/web/receipt_print.html.heex b/lib/modules/billing/web/receipt_print.html.heex deleted file mode 100644 index 8648db5af..000000000 --- a/lib/modules/billing/web/receipt_print.html.heex +++ /dev/null @@ -1,873 +0,0 @@ - - - - - - Receipt {@invoice.receipt_number} - {@project_title} - - - - - -
-
-
-

RECEIPT

-
{@invoice.receipt_number}
-
- <%= cond do %> - <% @receipt_status == "refunded" -> %> -
REFUNDED
- <% @receipt_status == "partially_paid" -> %> -
PARTIALLY PAID
- <% true -> %> - - <% end %> -
- -
-
-
-

Received From

-

- <%= if @invoice.billing_details && map_size(@invoice.billing_details) > 0 do %> - <%= if @invoice.billing_details["type"] == "company" do %> - {@invoice.billing_details["company_name"]} -
- <%= if @invoice.billing_details["company_vat_number"] do %> - VAT: {@invoice.billing_details["company_vat_number"]}
- <% end %> - <% else %> - - {@invoice.billing_details["first_name"]} {@invoice.billing_details[ - "last_name" - ]} - -
- <% end %> - <%= if @invoice.billing_details["address_line1"] do %> - {@invoice.billing_details["address_line1"]}
- <% end %> - <%= if @invoice.billing_details["address_line2"] do %> - {@invoice.billing_details["address_line2"]}
- <% end %> - <%= if @invoice.billing_details["city"] do %> - {@invoice.billing_details["city"]} - <%= if @invoice.billing_details["postal_code"] do %> - , {@invoice.billing_details["postal_code"]} - <% end %> -
- <% end %> - <%= if @invoice.billing_details["country"] do %> - {@invoice.billing_details["country"]} - <% end %> - <% else %> - <%= if @invoice.user do %> - {@invoice.user.email} - <% else %> - No billing information - <% end %> - <% end %> -

-
- -
-

Received By

-

- {@company.name} -
- <%= for line <- String.split(@company.address || "", "\n") do %> - {line}
- <% end %> - <%= if @company.vat != "" do %> - VAT: {@company.vat} - <% end %> -

-
- -
-

Receipt Details

-

- Receipt #: {@invoice.receipt_number}
- Invoice #: {@invoice.invoice_number}
- Date: - {if @invoice.receipt_generated_at, - do: Calendar.strftime(@invoice.receipt_generated_at, "%B %d, %Y"), - else: Calendar.strftime(@invoice.paid_at || @invoice.updated_at, "%B %d, %Y")}
- Currency: {@invoice.currency} - <%= if @invoice.order do %> -
Order: {@invoice.order.order_number} - <% end %> -

-
-
- - <%!-- Payment/Refund Status Box --%> - <%= cond do %> - <% @receipt_status == "refunded" -> %> - <%!-- Refund Information Box --%> -
-

- - - - Payment Refunded -

-
-
- Original Amount Paid - - {Decimal.to_string(@invoice.paid_amount || @invoice.total, :normal)} {@invoice.currency} - -
-
- Total Refunded - - {Decimal.to_string(@total_refunded, :normal)} {@invoice.currency} - -
-
- Refund Date - - <%= if @last_refund_date do %> - {Calendar.strftime(@last_refund_date, "%B %d, %Y")} - <% else %> - - - <% end %> - -
-
- Reference - {@invoice.invoice_number} -
-
-
- <% @receipt_status == "partially_paid" -> %> - <%!-- Partial Payment Box --%> -
-

- - - - Partial Payment Received -

-
-
- Amount Paid - - {Decimal.to_string(@invoice.paid_amount || Decimal.new(0), :normal)} {@invoice.currency} - -
-
- Last Payment Date - - <%= if @last_payment_date do %> - {Calendar.strftime(@last_payment_date, "%B %d, %Y at %H:%M")} - <% else %> - - - <% end %> - -
-
- Payment Method - Bank Transfer -
-
- Reference - {@invoice.invoice_number} -
-
-
- <%!-- Remaining Balance Box --%> -
- Remaining Balance - - {Decimal.to_string( - Decimal.sub(@invoice.total, @invoice.paid_amount || Decimal.new(0)), - :normal - )} {@invoice.currency} - -
- <% true -> %> - <%!-- Full Payment Confirmed --%> -
-

- - - - Payment Confirmed -

-
-
- Amount Paid - - {Decimal.to_string(@invoice.paid_amount || @invoice.total, :normal)} {@invoice.currency} - -
-
- Payment Date - - {if @invoice.paid_at, - do: Calendar.strftime(@invoice.paid_at, "%B %d, %Y at %H:%M"), - else: "-"} - -
-
- Payment Method - Bank Transfer -
-
- Reference - {@invoice.invoice_number} -
-
-
- <% end %> - - <%!-- Line Items --%> - - - - - - - - - - - <%= for item <- @invoice.line_items || [] do %> - - - - - - - <% end %> - -
DescriptionQtyUnit PriceAmount
-
{item["name"]}
- <%= if item["description"] && item["description"] != "" do %> -
{item["description"]}
- <% end %> -
{item["quantity"]}{item["unit_price"]} {@invoice.currency}{item["total"]} {@invoice.currency}
- -
- - - - - - <%= if Decimal.gt?(@invoice.tax_amount || Decimal.new(0), Decimal.new(0)) do %> - - - - - <% end %> - - - - -
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} -
-
- - <%!-- Transactions History --%> - <%= if length(@transactions) > 0 do %> -
-

Payment Transactions

- - - - - - - - - - - - <%= for txn <- @transactions do %> - - - - - - - - <% end %> - -
DateTransaction #MethodDescriptionAmount
{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} - -
-
- <% end %> - - <%= if @invoice.notes do %> -
-

- Notes -

-

{@invoice.notes}

-
- <% end %> -
- - -
- - diff --git a/lib/modules/billing/web/settings.ex b/lib/modules/billing/web/settings.ex deleted file mode 100644 index bb765d1bb..000000000 --- a/lib/modules/billing/web/settings.ex +++ /dev/null @@ -1,159 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.Settings do - @moduledoc """ - Billing settings LiveView for the billing module. - - Provides configuration interface for billing module settings. - Company and bank information is now managed in Organization Settings. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.CountryData - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - project_title = Settings.get_project_title() - billing_enabled = Billing.enabled?() - - socket = - socket - |> assign(:page_title, "Billing Settings") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing/settings")) - |> assign(:billing_enabled, billing_enabled) - |> load_settings() - - {:ok, socket} - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - defp load_settings(socket) do - # Get company info from consolidated Settings (with fallback to legacy keys) - company_info = CountryData.get_company_info() - bank_details = CountryData.get_bank_details() - company_country = company_info["country"] || "" - - socket - # General settings - |> assign(:default_currency, Settings.get_setting("billing_default_currency", "EUR")) - |> assign(:invoice_prefix, Settings.get_setting("billing_invoice_prefix", "INV")) - |> assign(:order_prefix, Settings.get_setting("billing_order_prefix", "ORD")) - |> assign(:receipt_prefix, Settings.get_setting("billing_receipt_prefix", "RCP")) - |> assign(:invoice_due_days, Settings.get_setting("billing_invoice_due_days", "14")) - |> assign(:tax_enabled, Settings.get_setting("billing_tax_enabled", "false") == "true") - |> assign(:tax_rate, Settings.get_setting("billing_default_tax_rate", "0")) - # Company info (from consolidated source) - |> assign(:company_info, company_info) - |> assign(:company_address_formatted, CountryData.format_company_address()) - |> assign(:company_country_name, get_country_name(company_country)) - |> assign(:company_country, company_country) - # For suggested tax rate - |> assign_suggested_tax_rate() - # Bank details (from consolidated source) - |> assign(:bank_details, bank_details) - end - - # Helper to get country name from code - defp get_country_name(""), do: "" - defp get_country_name(nil), do: "" - - defp get_country_name(country_code) do - case BeamLabCountries.get(country_code) do - nil -> country_code - country -> country.name - end - end - - @impl true - def handle_event("save_general", params, socket) do - # Convert checkbox value to "true"/"false" string - tax_enabled = if params["tax_enabled"] == "true", do: "true", else: "false" - - settings = [ - {"billing_default_currency", params["default_currency"]}, - {"billing_invoice_prefix", params["invoice_prefix"]}, - {"billing_order_prefix", params["order_prefix"]}, - {"billing_receipt_prefix", params["receipt_prefix"]}, - {"billing_invoice_due_days", params["invoice_due_days"]}, - {"billing_tax_enabled", tax_enabled}, - {"billing_default_tax_rate", params["tax_rate"]} - ] - - Enum.each(settings, fn {key, value} -> - Settings.update_setting(key, value) - end) - - {:noreply, - socket - |> load_settings() - |> put_flash(:info, "General settings saved")} - end - - @impl true - def handle_event("tax_rate_changed", %{"tax_rate" => tax_rate}, socket) do - current_rate = parse_tax_rate(tax_rate) - country_code = socket.assigns.company_country - - suggested_rate = - if country_code != "" do - rate = CountryData.get_standard_vat_percent(country_code) - if rate == current_rate, do: nil, else: rate - else - nil - end - - {:noreply, - socket - |> assign(:tax_rate, tax_rate) - |> assign(:suggested_tax_rate, suggested_rate)} - end - - @impl true - def handle_event("apply_suggested_tax", _params, socket) do - case socket.assigns.suggested_tax_rate do - nil -> - {:noreply, socket} - - rate -> - {:noreply, - socket - |> assign(:tax_rate, to_string(rate)) - |> assign(:suggested_tax_rate, nil)} - end - end - - # Suggested tax rate helper - - defp assign_suggested_tax_rate(socket) do - country_code = socket.assigns.company_country - current_rate = parse_tax_rate(socket.assigns.tax_rate) - - suggested_rate = - if country_code != "" do - rate = CountryData.get_standard_vat_percent(country_code) - # Hide suggestion if it matches current rate - if rate == current_rate, do: nil, else: rate - else - nil - end - - assign(socket, :suggested_tax_rate, suggested_rate) - end - - defp parse_tax_rate(rate) when is_binary(rate) do - case Float.parse(rate) do - {value, _} -> if value == trunc(value), do: trunc(value), else: value - :error -> 0 - end - end - - defp parse_tax_rate(rate) when is_number(rate), do: rate - defp parse_tax_rate(_), do: 0 -end diff --git a/lib/modules/billing/web/settings.html.heex b/lib/modules/billing/web/settings.html.heex deleted file mode 100644 index 049fa3867..000000000 --- a/lib/modules/billing/web/settings.html.heex +++ /dev/null @@ -1,333 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin/billing")} - title="Billing Settings" - subtitle="Configure billing module options" - /> - - <%!-- Navigation Tabs --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/billing")} - class="tab tab-active" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-2" /> General - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/billing/providers")} - class="tab" - > - <.icon name="hero-credit-card" class="w-4 h-4 mr-2" /> Payment Providers - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/currencies")} - class="tab" - > - <.icon name="hero-currency-dollar" class="w-4 h-4 mr-2" /> Currencies - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscription-types")} - class="tab" - > - <.icon name="hero-clipboard-document-list" class="w-4 h-4 mr-2" /> Subscription Types - -
- -
- <%!-- General Settings --%> -
-
-

General Settings

-
-
- - -
- -
-
- - -
-
- - -
-
- - -
-
- -
- - -
- -
Tax Settings
- -
-
- -
-
- - -
-
- - <%= if @suggested_tax_rate do %> -
-
- <.icon name="hero-light-bulb" class="w-4 h-4" /> - - Suggested rate for selected country: {@suggested_tax_rate}% - - -
-
- <% end %> - -
- -
-
-
-
- - <%!-- Company & Bank Information (Preview) --%> -
-
-
-
-

Company & Bank Information

-

Shown on invoices and receipts

-
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/organization")} - class="btn btn-primary btn-sm" - > - <.icon name="hero-pencil-square" class="w-4 h-4" /> Edit in Organization - -
- -
- <%!-- Company Info Preview (same style as Legal Settings) --%> -
-

Company

- <%= if @company_info["name"] && @company_info["name"] != "" do %> -
-
- <.icon - name="hero-building-office" - class="w-5 h-5 text-primary shrink-0 mt-0.5" - /> -
-

{@company_info["name"]}

- <%= if @company_info["registration_number"] && @company_info["registration_number"] != "" do %> -

- Reg. No: {@company_info["registration_number"]} -

- <% end %> -
-
- - <%= if @company_info["address_line1"] && @company_info["address_line1"] != "" do %> -
- <.icon name="hero-map-pin" class="w-5 h-5 text-primary shrink-0 mt-0.5" /> -
-

{@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 %> -
-
- <% end %> - - <%= if @company_info["vat_number"] && @company_info["vat_number"] != "" do %> -
- <.icon - name="hero-document-text" - class="w-5 h-5 text-primary shrink-0 mt-0.5" - /> -
- VAT: {@company_info[ - "vat_number" - ]} -
-
- <% end %> -
- <% else %> -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> - Company information not configured -
- <% end %> -
- - <%!-- Bank Details Preview --%> -
-

Bank Details

- <%= if @bank_details["bank_name"] && @bank_details["bank_name"] != "" do %> -
-
- <.icon - name="hero-building-library" - class="w-5 h-5 text-primary shrink-0 mt-0.5" - /> -

{@bank_details["bank_name"]}

-
- <%= if @bank_details["iban"] && @bank_details["iban"] != "" do %> -
- <.icon - name="hero-credit-card" - class="w-5 h-5 text-primary shrink-0 mt-0.5" - /> -
- IBAN: - {@bank_details["iban"]} -
-
- <% end %> - <%= if @bank_details["swift"] && @bank_details["swift"] != "" do %> -
- <.icon - name="hero-globe-americas" - class="w-5 h-5 text-primary shrink-0 mt-0.5" - /> -
- SWIFT: - {@bank_details["swift"]} -
-
- <% end %> -
- <% else %> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> - Bank details not configured (optional) -
- <% end %> -
-
-
-
-
- - <%!-- Quick Links --%> -
-
-

Related Settings

-
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/billing/providers")} - class="btn btn-primary" - > - <.icon name="hero-credit-card" class="w-5 h-5" /> Payment Providers - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/currencies")} - class="btn btn-outline" - > - <.icon name="hero-currency-dollar" class="w-5 h-5" /> Manage Currencies - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscription-types")} - class="btn btn-outline" - > - <.icon name="hero-clipboard-document-list" class="w-5 h-5" /> Subscription Types - - <.link navigate={PhoenixKit.Utils.Routes.path("/admin/modules")} class="btn btn-outline"> - <.icon name="hero-squares-2x2" class="w-5 h-5" /> All Modules - -
-
-
-
-
diff --git a/lib/modules/billing/web/subscription_detail.ex b/lib/modules/billing/web/subscription_detail.ex deleted file mode 100644 index 7b57b5325..000000000 --- a/lib/modules/billing/web/subscription_detail.ex +++ /dev/null @@ -1,201 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.SubscriptionDetail do - @moduledoc """ - Subscription detail LiveView for the billing module. - - Displays complete subscription information and provides management actions. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Subscription - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"id" => id}, _session, socket) do - if Billing.enabled?() do - case Billing.get_subscription(id, preload: [:user, :subscription_type, :payment_method]) do - nil -> - {:ok, - socket - |> put_flash(:error, "Subscription not found") - |> push_navigate(to: Routes.path("/admin/billing/subscriptions"))} - - subscription -> - project_title = Settings.get_project_title() - types = Billing.list_subscription_types(active_only: true) - - socket = - socket - |> assign(:page_title, "Subscription ##{subscription.uuid}") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing/subscriptions/#{subscription.uuid}")) - |> assign(:subscription, subscription) - |> assign(:subscription_types, types) - |> assign(:show_change_subscription_type_modal, false) - |> assign(:selected_new_subscription_type_uuid, nil) - - {:ok, socket} - end - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("cancel_now", _params, socket) do - case Billing.cancel_subscription(socket.assigns.subscription, immediately: true) do - {:ok, subscription} -> - {:noreply, - socket - |> assign(:subscription, reload_subscription(subscription.uuid)) - |> put_flash(:info, "Subscription cancelled immediately")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to cancel: #{inspect(reason)}")} - end - end - - @impl true - def handle_event("cancel_at_period_end", _params, socket) do - case Billing.cancel_subscription(socket.assigns.subscription, immediately: false) do - {:ok, subscription} -> - {:noreply, - socket - |> assign(:subscription, reload_subscription(subscription.uuid)) - |> put_flash(:info, "Subscription will cancel at period end")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to cancel: #{inspect(reason)}")} - end - end - - @impl true - def handle_event("resume", _params, socket) do - case Billing.resume_subscription(socket.assigns.subscription) do - {:ok, subscription} -> - {:noreply, - socket - |> assign(:subscription, reload_subscription(subscription.uuid)) - |> put_flash(:info, "Subscription resumed")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to resume: #{inspect(reason)}")} - end - end - - @impl true - def handle_event("pause", _params, socket) do - case Billing.pause_subscription(socket.assigns.subscription) do - {:ok, subscription} -> - {:noreply, - socket - |> assign(:subscription, reload_subscription(subscription.uuid)) - |> put_flash(:info, "Subscription paused")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to pause: #{inspect(reason)}")} - end - end - - @impl true - def handle_event("open_change_subscription_type_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_change_subscription_type_modal, true) - |> assign(:selected_new_subscription_type_uuid, nil)} - end - - @impl true - def handle_event("close_change_subscription_type_modal", _params, socket) do - {:noreply, assign(socket, :show_change_subscription_type_modal, false)} - end - - @impl true - def handle_event( - "select_new_subscription_type", - %{"subscription_type_uuid" => type_uuid}, - socket - ) do - type_uuid = if type_uuid == "", do: nil, else: type_uuid - {:noreply, assign(socket, :selected_new_subscription_type_uuid, type_uuid)} - end - - @impl true - def handle_event("change_subscription_type", _params, socket) do - %{subscription: subscription, selected_new_subscription_type_uuid: new_type_uuid} = - socket.assigns - - if new_type_uuid && to_string(new_type_uuid) != to_string(subscription.subscription_type_uuid) do - case Billing.change_subscription_type(subscription, new_type_uuid) do - {:ok, updated_subscription} -> - {:noreply, - socket - |> assign(:subscription, reload_subscription(updated_subscription.uuid)) - |> assign(:show_change_subscription_type_modal, false) - |> put_flash(:info, "Subscription type changed successfully")} - - {:error, reason} -> - {:noreply, - put_flash(socket, :error, "Failed to change subscription type: #{inspect(reason)}")} - end - else - {:noreply, put_flash(socket, :error, "Please select a different subscription type")} - end - end - - defp reload_subscription(id) do - Billing.get_subscription(id, preload: [:user, :subscription_type, :payment_method]) - end - - # Helper functions for template - - def status_badge_class(status) do - case status do - "active" -> "badge-success" - "trialing" -> "badge-info" - "past_due" -> "badge-warning" - "paused" -> "badge-neutral" - "cancelled" -> "badge-error" - _ -> "badge-ghost" - end - end - - def format_interval(nil, _), do: "-" - def format_interval(_, nil), do: "-" - - def format_interval(interval, interval_count) do - case {interval, interval_count} do - {"month", 1} -> "Monthly" - {"month", n} -> "Every #{n} months" - {"year", 1} -> "Yearly" - {"year", n} -> "Every #{n} years" - {"week", 1} -> "Weekly" - {"week", n} -> "Every #{n} weeks" - {"day", 1} -> "Daily" - {"day", n} -> "Every #{n} days" - _ -> "#{interval_count} #{interval}(s)" - end - end - - def days_until_renewal(%Subscription{current_period_end: nil}), do: nil - - def days_until_renewal(%Subscription{current_period_end: period_end}) do - Date.diff(DateTime.to_date(period_end), Date.utc_today()) - end - - def grace_period_remaining(%Subscription{grace_period_end: nil}), do: nil - - def grace_period_remaining(%Subscription{grace_period_end: grace_end}) do - Date.diff(DateTime.to_date(grace_end), Date.utc_today()) - end -end diff --git a/lib/modules/billing/web/subscription_detail.html.heex b/lib/modules/billing/web/subscription_detail.html.heex deleted file mode 100644 index 79a798386..000000000 --- a/lib/modules/billing/web/subscription_detail.html.heex +++ /dev/null @@ -1,402 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/billing/subscriptions")}> -
-

- Subscription #{@subscription.uuid} -

- - {@subscription.status} - -
-

- 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 %> - - - -
- <%!-- Main Content --%> -
- <%!-- Subscription Type Details --%> -
-
-

Subscription Type Details

- <%= if @subscription.subscription_type do %> -
-
-
-

{@subscription.subscription_type.name}

-

- {@subscription.subscription_type.description} -

-
-
-
- <.currency_amount - amount={@subscription.subscription_type.price} - currency={@subscription.subscription_type.currency} - /> -
-
- {format_interval( - @subscription.subscription_type.interval, - @subscription.subscription_type.interval_count - )} -
-
-
- - <%!-- Features --%> - <%= if @subscription.subscription_type.features && is_list(@subscription.subscription_type.features) && length(@subscription.subscription_type.features) > 0 do %> -
Features
-
    - <%= for feature <- @subscription.subscription_type.features do %> -
  • - <.icon name="hero-check" class="w-4 h-4 text-success" /> - {feature} -
  • - <% end %> -
- <% end %> -
- <% else %> -
-

No subscription type associated

-
- <% end %> -
-
- - <%!-- Billing Period --%> -
-
-

Billing Period

-
-
-
Period Start
-
- <%= if @subscription.current_period_start do %> - <.time_ago datetime={@subscription.current_period_start} /> - <% else %> - Not set - <% end %> -
-
-
-
Period End
-
- <%= if @subscription.current_period_end do %> - <.time_ago datetime={@subscription.current_period_end} /> - <% days = days_until_renewal(@subscription) %> - <%= if days && days > 0 do %> - - ({days} days remaining) - - <% end %> - <% else %> - Not set - <% end %> -
-
-
- - <%!-- Trial Period --%> - <%= if @subscription.trial_start || @subscription.trial_end do %> -
Trial Period
-
-
-
Trial Start
-
- <%= if @subscription.trial_start do %> - <.time_ago datetime={@subscription.trial_start} /> - <% else %> - - - <% end %> -
-
-
-
Trial End
-
- <%= if @subscription.trial_end do %> - <.time_ago datetime={@subscription.trial_end} /> - <% else %> - - - <% end %> -
-
-
- <% end %> - - <%!-- Grace Period (if past_due) --%> - <%= if @subscription.status == "past_due" && @subscription.grace_period_end do %> -
Grace Period
-
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> -
-
Payment Failed - Grace Period Active
-
- Grace period ends <.time_ago datetime={@subscription.grace_period_end} /> - <% grace_days = grace_period_remaining(@subscription) %> - <%= if grace_days && grace_days > 0 do %> - ({grace_days} days remaining) - <% end %> -
-
- Renewal attempts: {@subscription.renewal_attempts || 0} -
-
-
- <% end %> -
-
- - <%!-- Cancellation Info --%> - <%= if @subscription.cancel_at_period_end || @subscription.cancelled_at do %> -
-
-

- <.icon name="hero-x-circle" class="w-6 h-6" /> Cancellation -

- <%= if @subscription.cancel_at_period_end do %> -

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 %> -
-
- <% end %> -
- - <%!-- Sidebar --%> -
- <%!-- Customer Info --%> -
-
-

Customer

- <%= if @subscription.user do %> -
- <.user_avatar user={@subscription.user} size="lg" /> -
-
{@subscription.user.email}
- <.link - navigate={ - PhoenixKit.Utils.Routes.path("/admin/users/edit/#{@subscription.user.uuid}") - } - class="text-sm text-primary hover:underline" - > - View Profile - -
-
- <% else %> -

No customer linked

- <% end %> -
-
- - <%!-- Payment Method --%> -
-
-

Payment Method

- <%= if @subscription.payment_method do %> -
-
- <.icon name="hero-credit-card" class="w-6 h-6 text-primary" /> -
-
- {PhoenixKit.Modules.Billing.PaymentMethod.display_name( - @subscription.payment_method - )} -
-
- Provider: {@subscription.payment_method.provider} -
-
-
-
- <% else %> -
- <.icon name="hero-credit-card" class="w-8 h-8 mx-auto mb-2 opacity-50" /> -

No payment method

-
- <% end %> -
-
- - <%!-- Quick Info --%> -
-
-

Quick Info

-
-
- Status - - {@subscription.status} - -
-
- Created - <.time_ago datetime={@subscription.inserted_at} /> -
- <%= if @subscription.started_at do %> -
- Started - <.time_ago datetime={@subscription.started_at} /> -
- <% end %> - <%= if @subscription.ended_at do %> -
- Ended - <.time_ago datetime={@subscription.ended_at} /> -
- <% end %> -
-
-
-
-
-
- - <%!-- Change Subscription Type Modal --%> - <%= if @show_change_subscription_type_modal do %> - - <% end %> -
diff --git a/lib/modules/billing/web/subscription_form.ex b/lib/modules/billing/web/subscription_form.ex deleted file mode 100644 index a7c36b64f..000000000 --- a/lib/modules/billing/web/subscription_form.ex +++ /dev/null @@ -1,234 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.SubscriptionForm do - @moduledoc """ - Subscription form LiveView for creating subscriptions manually. - - Allows administrators to: - - Search and select a user by email - - Choose a subscription type - - Optionally assign a payment method - - Configure trial period - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Settings - alias PhoenixKit.Users.Auth - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if Billing.enabled?() do - project_title = Settings.get_project_title() - types = Billing.list_subscription_types(active_only: true) - - socket = - socket - |> assign(:page_title, "Create Subscription") - |> assign(:project_title, project_title) - |> assign(:subscription_types, types) - |> assign(:user_search, "") - |> assign(:user_results, []) - |> assign(:selected_user, nil) - |> assign(:selected_subscription_type_uuid, nil) - |> assign(:payment_methods, []) - |> assign(:selected_payment_method_uuid, nil) - |> assign(:enable_trial, false) - |> assign(:trial_days, "") - |> assign(:error, nil) - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("search_user", %{"query" => query}, socket) do - if String.length(query) >= 2 do - results = search_users(query) - {:noreply, assign(socket, user_search: query, user_results: results)} - else - {:noreply, assign(socket, user_search: query, user_results: [])} - end - end - - @impl true - def handle_event("select_user", %{"id" => user_uuid}, socket) do - case Auth.get_user(user_uuid) do - nil -> - {:noreply, put_flash(socket, :error, "User not found")} - - user -> - payment_methods = Billing.list_payment_methods(user.uuid, status: "active") - - {:noreply, - socket - |> assign(:selected_user, user) - |> assign(:user_search, user.email) - |> assign(:user_results, []) - |> assign(:payment_methods, payment_methods) - |> assign(:selected_payment_method_uuid, nil)} - end - end - - @impl true - def handle_event("clear_user", _params, socket) do - {:noreply, - socket - |> assign(:selected_user, nil) - |> assign(:user_search, "") - |> assign(:user_results, []) - |> assign(:payment_methods, []) - |> assign(:selected_payment_method_uuid, nil)} - end - - @impl true - def handle_event("select_subscription_type", %{"subscription_type_uuid" => type_uuid}, socket) do - type_uuid = if type_uuid == "", do: nil, else: type_uuid - - # Get subscription type's default trial days - trial_days = - if type_uuid do - case Enum.find(socket.assigns.subscription_types, &(to_string(&1.uuid) == type_uuid)) do - %{trial_days: days} when is_integer(days) and days > 0 -> to_string(days) - _ -> "" - end - else - "" - end - - {:noreply, - socket - |> assign(:selected_subscription_type_uuid, type_uuid) - |> assign(:trial_days, trial_days) - |> assign(:enable_trial, trial_days != "")} - end - - @impl true - def handle_event("select_payment_method", %{"payment_method_uuid" => pm_uuid}, socket) do - pm_uuid = if pm_uuid == "", do: nil, else: pm_uuid - {:noreply, assign(socket, :selected_payment_method_uuid, pm_uuid)} - end - - @impl true - def handle_event("toggle_trial", %{"enable" => enable}, socket) do - enable = enable == "true" - {:noreply, assign(socket, :enable_trial, enable)} - end - - @impl true - def handle_event("update_trial_days", %{"days" => days}, socket) do - {:noreply, assign(socket, :trial_days, days)} - end - - @impl true - def handle_event("clear_error", _params, socket) do - {:noreply, assign(socket, :error, nil)} - end - - @impl true - def handle_event("save", _params, socket) do - %{ - selected_user: user, - selected_subscription_type_uuid: type_uuid, - selected_payment_method_uuid: pm_uuid, - enable_trial: enable_trial, - trial_days: trial_days - } = socket.assigns - - cond do - is_nil(user) -> - {:noreply, assign(socket, :error, "Please select a customer")} - - is_nil(type_uuid) -> - {:noreply, assign(socket, :error, "Please select a subscription type")} - - true -> - attrs = %{ - subscription_type_uuid: type_uuid, - payment_method_uuid: pm_uuid, - trial_days: - if(enable_trial && trial_days != "", do: String.to_integer(trial_days), else: 0) - } - - try do - case Billing.create_subscription(user.uuid, attrs) do - {:ok, subscription} -> - {:noreply, - socket - |> put_flash(:info, "Subscription created successfully") - |> push_navigate( - to: Routes.path("/admin/billing/subscriptions/#{subscription.uuid}") - )} - - {:error, %Ecto.Changeset{} = changeset} -> - error_msg = format_changeset_errors(changeset) - {:noreply, assign(socket, :error, error_msg)} - - {:error, reason} -> - {:noreply, - assign(socket, :error, "Failed to create subscription: #{inspect(reason)}")} - end - rescue - e -> - require Logger - Logger.error("Subscription save failed: #{Exception.message(e)}") - {:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")} - end - end - end - - # Private helpers - - defp search_users(query) do - # Use paginated search with small page size - %{users: users} = Auth.list_users_paginated(search: query, page_size: 10) - users - end - - defp format_changeset_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Enum.reduce(opts, msg, fn {key, value}, acc -> - String.replace(acc, "%{#{key}}", to_string(value)) - end) - end) - |> Enum.map_join("; ", fn {field, errors} -> "#{field}: #{Enum.join(errors, ", ")}" end) - end - - # Helper functions for template - - def format_interval(interval, interval_count) do - case {interval, interval_count} do - {"month", 1} -> "Monthly" - {"month", n} -> "Every #{n} months" - {"year", 1} -> "Yearly" - {"year", n} -> "Every #{n} years" - {"week", 1} -> "Weekly" - {"week", n} -> "Every #{n} weeks" - {"day", 1} -> "Daily" - {"day", n} -> "Every #{n} days" - _ -> "#{interval_count} #{interval}(s)" - end - end - - def format_payment_method(pm) do - case pm.type do - "card" -> - brand = pm.brand || "Card" - last4 = pm.last4 || "****" - "#{String.capitalize(brand)} ending in #{last4}" - - type -> - String.capitalize(type) - end - end -end diff --git a/lib/modules/billing/web/subscription_form.html.heex b/lib/modules/billing/web/subscription_form.html.heex deleted file mode 100644 index 04b284b33..000000000 --- a/lib/modules/billing/web/subscription_form.html.heex +++ /dev/null @@ -1,310 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/billing/subscriptions")}> -

{@page_title}

-

- Manually create a subscription for a customer -

- - - <%!-- Error Alert --%> - <%= if @error do %> -
- <.icon name="hero-exclamation-circle" class="w-5 h-5" /> - {@error} - -
- <% end %> - -
- <%!-- Form --%> -
- <%!-- Customer Selection --%> -
-
-

- <.icon name="hero-user" class="w-5 h-5" /> Customer -

- - <%= if @selected_user do %> - <%!-- Selected user display --%> -
-
- <.user_avatar user={@selected_user} size="md" /> -
-
{@selected_user.email}
-
ID: {@selected_user.uuid}
-
-
- -
- <% else %> - <%!-- User search --%> -
- -
- - <%= if @user_search != "" && length(@user_results) > 0 do %> -
- <%= for user <- @user_results do %> - - <% end %> -
- <% end %> - <%= if @user_search != "" && length(@user_results) == 0 do %> -
- No users found matching "{@user_search}" -
- <% end %> -
-
- <% end %> -
-
- - <%!-- Plan Selection --%> -
-
-

- <.icon name="hero-squares-2x2" class="w-5 h-5" /> Subscription Type -

- -
- - -
- - <%!-- Trial Period --%> -
- -
- - <%= if @enable_trial do %> -
- - -
- <% end %> -
-
- - <%!-- Payment Method (if user selected) --%> - <%= if @selected_user && length(@payment_methods) > 0 do %> -
-
-

- <.icon name="hero-credit-card" class="w-5 h-5" /> Payment Method - Optional -

- -
- - - -
-
-
- <% end %> - - <%!-- Actions --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscriptions")} - class="btn btn-ghost" - > - Cancel - - -
-
- - <%!-- Summary Sidebar --%> -
-
-
-

Summary

-
- - <%!-- Customer --%> -
-
Customer
- <%= if @selected_user do %> -
{@selected_user.email}
- <% else %> -
Not selected
- <% end %> -
- -
- - <%!-- Subscription Type --%> -
-
Subscription Type
- <%= if @selected_subscription_type_uuid do %> - <% type = - Enum.find( - @subscription_types, - &(to_string(&1.uuid) == to_string(@selected_subscription_type_uuid)) - ) %> - <%= if type do %> -
{type.name}
-
- <.currency_amount amount={type.price} currency={type.currency} /> -
-
- {format_interval(type.interval, type.interval_count)} -
- <% end %> - <% else %> -
Not selected
- <% end %> -
- - <%= if @enable_trial && @trial_days != "" do %> -
-
-
Trial Period
-
{@trial_days} days
-
- <% end %> - - <%= if @selected_payment_method_uuid do %> -
-
-
Payment Method
- <% pm = - Enum.find( - @payment_methods, - &(to_string(&1.uuid) == to_string(@selected_payment_method_uuid)) - ) %> - <%= if pm do %> -
{format_payment_method(pm)}
- <% end %> -
- <% end %> - -
- - <%!-- Status Preview --%> -
-
Initial Status
- <%= if @enable_trial && @trial_days != "" do %> -
Trialing
- <% else %> -
Active
- <% end %> -
-
-
-
-
-
-
diff --git a/lib/modules/billing/web/subscription_type_form.ex b/lib/modules/billing/web/subscription_type_form.ex deleted file mode 100644 index 87b916534..000000000 --- a/lib/modules/billing/web/subscription_type_form.ex +++ /dev/null @@ -1,162 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.SubscriptionTypeForm do - @moduledoc """ - Subscription type form LiveView for creating and editing subscription types. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.SubscriptionType - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(params, _session, socket) do - if Billing.enabled?() do - project_title = Settings.get_project_title() - default_currency = Settings.get_setting("billing_default_currency", "EUR") - - {type, title, mode} = - case params do - %{"id" => id} -> - case Billing.get_subscription_type(id) do - {:ok, type} -> {type, "Edit Subscription Type", :edit} - {:error, _} -> {nil, "Subscription Type Not Found", :not_found} - end - - _ -> - {%SubscriptionType{ - currency: default_currency, - interval: "month", - interval_count: 1, - active: true - }, "Create Subscription Type", :new} - end - - if type do - changeset = SubscriptionType.changeset(type, %{}) - - url_path = - case mode do - :new -> Routes.path("/admin/billing/subscription-types/new") - :edit -> Routes.path("/admin/billing/subscription-types/#{type.uuid}/edit") - _ -> Routes.path("/admin/billing/subscription-types") - end - - socket = - socket - |> assign(:page_title, title) - |> assign(:project_title, project_title) - |> assign(:url_path, url_path) - |> assign(:mode, mode) - |> assign(:subscription_type, type) - |> assign(:changeset, changeset) - |> assign(:features_input, format_features(type.features)) - |> assign(:form, to_form(changeset)) - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Subscription type not found") - |> push_navigate(to: Routes.path("/admin/billing/subscription-types"))} - end - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("validate", %{"subscription_type" => params}, socket) do - params = process_params(params, socket.assigns.features_input) - - changeset = - socket.assigns.subscription_type - |> SubscriptionType.changeset(params) - |> Map.put(:action, :validate) - - {:noreply, assign(socket, :form, to_form(changeset))} - end - - @impl true - def handle_event("update_features", %{"features" => features}, socket) do - {:noreply, assign(socket, :features_input, features)} - end - - @impl true - def handle_event("save", %{"subscription_type" => params}, socket) do - params = process_params(params, socket.assigns.features_input) - - result = - case socket.assigns.mode do - :new -> Billing.create_subscription_type(params) - :edit -> Billing.update_subscription_type(socket.assigns.subscription_type, params) - end - - case result do - {:ok, _type} -> - {:noreply, - socket - |> put_flash(:info, type_saved_message(socket.assigns.mode)) - |> push_navigate(to: Routes.path("/admin/billing/subscription-types"))} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :form, to_form(changeset))} - - {:error, reason} -> - {:noreply, - put_flash(socket, :error, "Failed to save subscription type: #{inspect(reason)}")} - end - end - - defp process_params(params, features_input) do - # Parse features from textarea (one per line) - features = - features_input - |> String.split("\n") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - - # Parse price from string to decimal - price = - case params["price"] do - "" -> nil - nil -> nil - p when is_binary(p) -> Decimal.new(p) - p -> p - end - - params - |> Map.put("features", features) - |> Map.put("price", price) - end - - defp format_features(nil), do: "" - defp format_features(features) when is_list(features), do: Enum.join(features, "\n") - defp format_features(_), do: "" - - defp type_saved_message(:new), do: "Subscription type created successfully" - defp type_saved_message(:edit), do: "Subscription type updated successfully" - - def error_to_string([]), do: "" - - def error_to_string(errors) when is_list(errors) do - Enum.map_join(errors, ", ", fn - {msg, opts} -> - Enum.reduce(opts, msg, fn {key, value}, acc -> - String.replace(acc, "%{#{key}}", to_string(value)) - end) - - msg when is_binary(msg) -> - msg - end) - end -end diff --git a/lib/modules/billing/web/subscription_type_form.html.heex b/lib/modules/billing/web/subscription_type_form.html.heex deleted file mode 100644 index 31c811166..000000000 --- a/lib/modules/billing/web/subscription_type_form.html.heex +++ /dev/null @@ -1,328 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/billing/subscription-types")}> -

{@page_title}

-

- <%= if @mode == :new do %> - Create a new subscription type - <% else %> - Edit subscription type details and pricing - <% end %> -

- - -
- <%!-- Form --%> -
- <.form for={@form} phx-change="validate" phx-submit="save"> - <%!-- Basic Info --%> -
-
-

Basic Information

- -
-
- - - <%= if @form[:name].errors != [] do %> - - <% end %> -
- -
- - - <%= if @form[:slug].errors != [] do %> - - <% else %> - - <% end %> -
-
- -
- - -
-
-
- - <%!-- Pricing --%> -
-
-

Pricing

- -
-
- - - <%= if @form[:price].errors != [] do %> - - <% end %> -
- -
- - -
- -
- - -
-
- -
-
- - -
- -
- - - -
-
-
-
- - <%!-- Features --%> -
-
-

Features

-

- List the features included in this plan (one per line) -

- -
- -
-
-
- - <%!-- Settings --%> -
-
-

Settings

- -
-
- - - -
- -
- - -
-
-
-
- - <%!-- Actions --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscription-types")} - class="btn btn-ghost" - > - Cancel - - -
- -
- - <%!-- Preview --%> -
-
-
-

Preview

-
- -

{@form[:name].value || "Name"}

-

- {@form[:description].value || "Description"} -

- -
-
- <%= if @form[:price].value do %> - <.currency_amount - amount={@form[:price].value} - currency={@form[:currency].value || "EUR"} - /> - <% else %> - $0.00 - <% end %> -
-
- <% interval = @form[:interval].value || "month" %> - <% count = @form[:interval_count].value || 1 %> per {if count == 1, - do: interval, - else: "#{count} #{interval}s"} -
-
- - <%= if @form[:trial_days].value && @form[:trial_days].value != "" && @form[:trial_days].value != "0" do %> -
- {@form[:trial_days].value} day trial -
- <% end %> - - <% features = - @features_input - |> String.split("\n") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) %> - <%= if length(features) > 0 do %> -
-
    - <%= for feature <- features do %> -
  • - <.icon name="hero-check" class="w-4 h-4 text-success flex-shrink-0" /> - {feature} -
  • - <% end %> -
- <% end %> -
-
-
-
-
-
diff --git a/lib/modules/billing/web/subscription_types.ex b/lib/modules/billing/web/subscription_types.ex deleted file mode 100644 index f605345ac..000000000 --- a/lib/modules/billing/web/subscription_types.ex +++ /dev/null @@ -1,116 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.SubscriptionTypes do - @moduledoc """ - Subscription types list LiveView for the billing module. - - Displays all subscription types with management actions. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if Billing.enabled?() do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Subscription Types") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing/subscription-types")) - |> load_subscription_types() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - defp load_subscription_types(socket) do - types = Billing.list_subscription_types(active_only: false) - assign(socket, :subscription_types, types) - end - - @impl true - def handle_event("toggle_active", %{"uuid" => uuid}, socket) do - type = Enum.find(socket.assigns.subscription_types, &(&1.uuid == uuid)) - - if type do - case Billing.update_subscription_type(type, %{active: !type.active}) do - {:ok, _type} -> - {:noreply, - socket - |> load_subscription_types() - |> put_flash( - :info, - if(type.active, - do: "Subscription type deactivated", - else: "Subscription type activated" - ) - )} - - {:error, reason} -> - {:noreply, - put_flash(socket, :error, "Failed to update subscription type: #{inspect(reason)}")} - end - else - {:noreply, put_flash(socket, :error, "Subscription type not found")} - end - end - - @impl true - def handle_event("delete_subscription_type", %{"uuid" => uuid}, socket) do - type = Enum.find(socket.assigns.subscription_types, &(&1.uuid == uuid)) - - if type do - case Billing.delete_subscription_type(type) do - {:ok, _type} -> - {:noreply, - socket - |> load_subscription_types() - |> put_flash(:info, "Subscription type deleted")} - - {:error, :has_subscriptions} -> - {:noreply, - put_flash( - socket, - :error, - "Cannot delete subscription type with active subscriptions. Deactivate it instead." - )} - - {:error, reason} -> - {:noreply, - put_flash(socket, :error, "Failed to delete subscription type: #{inspect(reason)}")} - end - else - {:noreply, put_flash(socket, :error, "Subscription type not found")} - end - end - - # Helper functions for template - - def format_interval(interval, interval_count) do - case {interval, interval_count} do - {"month", 1} -> "Monthly" - {"month", n} -> "Every #{n} months" - {"year", 1} -> "Yearly" - {"year", n} -> "Every #{n} years" - {"week", 1} -> "Weekly" - {"week", n} -> "Every #{n} weeks" - {"day", 1} -> "Daily" - {"day", n} -> "Every #{n} days" - _ -> "#{interval_count} #{interval}(s)" - end - end -end diff --git a/lib/modules/billing/web/subscription_types.html.heex b/lib/modules/billing/web/subscription_types.html.heex deleted file mode 100644 index 200bd9398..000000000 --- a/lib/modules/billing/web/subscription_types.html.heex +++ /dev/null @@ -1,173 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin/billing")} - title="Subscription Types" - subtitle="Manage pricing and features for subscriptions" - > - <:actions> - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscription-types/new")} - class="btn btn-primary btn-sm" - > - <.icon name="hero-plus" class="w-4 h-4" /> Create Subscription Type - - - - - <%!-- Navigation Tabs --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/billing")} - class="tab" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-2" /> General - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/billing/providers")} - class="tab" - > - <.icon name="hero-credit-card" class="w-4 h-4 mr-2" /> Payment Providers - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/currencies")} - class="tab" - > - <.icon name="hero-currency-dollar" class="w-4 h-4 mr-2" /> Currencies - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscription-types")} - class="tab tab-active" - > - <.icon name="hero-clipboard-document-list" class="w-4 h-4 mr-2" /> Subscription Types - -
- - <%!-- Subscription Types Grid --%> - <%= if Enum.empty?(@subscription_types) do %> -
-
- <.icon name="hero-squares-2x2" class="w-12 h-12 mx-auto mb-4 opacity-50" /> -

No Subscription Types Created

-

- Create your first subscription type to start accepting recurring payments. -

-
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscription-types/new")} - class="btn btn-primary" - > - <.icon name="hero-plus" class="w-4 h-4" /> Create First Subscription Type - -
-
-
- <% else %> -
- <%= for type <- @subscription_types do %> -
-
-
-
-

- {type.name} - <%= if !type.active do %> - Inactive - <% end %> -

-

{type.description}

-
-
- <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/billing/subscription-types/#{type.uuid}/edit" - ) - } - class="btn btn-xs btn-ghost" - title="Edit subscription type" - > - <.icon name="hero-pencil" class="w-4 h-4" /> - - - -
-
- -
-
- <.currency_amount amount={type.price} currency={type.currency} /> -
-
- {format_interval(type.interval, type.interval_count)} -
-
- - <%= if type.trial_days && type.trial_days > 0 do %> -
- {type.trial_days} day trial -
- <% end %> - - <%!-- Features --%> - <%= if type.features && is_list(type.features) && length(type.features) > 0 do %> -
-
    - <%= for feature <- type.features do %> -
  • - <.icon name="hero-check" class="w-4 h-4 text-success flex-shrink-0" /> - {feature} -
  • - <% end %> -
- <% end %> - - <%!-- Meta Info --%> -
-
- Slug: - {type.slug} -
- <%= if type.sort_order do %> -
- Sort Order: - {type.sort_order} -
- <% end %> -
-
-
- <% end %> -
- <% end %> -
-
diff --git a/lib/modules/billing/web/subscriptions.ex b/lib/modules/billing/web/subscriptions.ex deleted file mode 100644 index eb0eefd4c..000000000 --- a/lib/modules/billing/web/subscriptions.ex +++ /dev/null @@ -1,190 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.Subscriptions do - @moduledoc """ - Subscriptions list LiveView for the billing module. - - Displays all subscriptions with filtering and search capabilities. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Events - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if Billing.enabled?() do - if connected?(socket) do - Events.subscribe_subscriptions() - end - - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Subscriptions") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing/subscriptions")) - |> assign(:status_filter, "all") - |> assign(:search, "") - |> load_subscriptions() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(params, _url, socket) do - status = params["status"] || "all" - search = params["search"] || "" - - socket = - socket - |> assign(:status_filter, status) - |> assign(:search, search) - |> load_subscriptions() - - {:noreply, socket} - end - - defp load_subscriptions(socket) do - opts = - [preload: [:user, :subscription_type, :payment_method]] - |> add_status_filter(socket.assigns.status_filter) - |> add_search_filter(socket.assigns.search) - - subscriptions = Billing.list_subscriptions(opts) - stats = calculate_stats(subscriptions) - - socket - |> assign(:subscriptions, subscriptions) - |> assign(:stats, stats) - end - - defp add_status_filter(opts, "all"), do: opts - defp add_status_filter(opts, status), do: Keyword.put(opts, :status, status) - - defp add_search_filter(opts, ""), do: opts - defp add_search_filter(opts, search), do: Keyword.put(opts, :search, search) - - defp calculate_stats(subscriptions) do - %{ - total: length(subscriptions), - active: Enum.count(subscriptions, &(&1.status == "active")), - trialing: Enum.count(subscriptions, &(&1.status == "trialing")), - past_due: Enum.count(subscriptions, &(&1.status == "past_due")), - cancelled: Enum.count(subscriptions, &(&1.status == "cancelled")) - } - end - - @impl true - def handle_event("filter_status", %{"status" => status}, socket) do - {:noreply, - push_patch(socket, - to: - Routes.path("/admin/billing/subscriptions") <> - build_query_string(status, socket.assigns.search) - )} - end - - @impl true - def handle_event("search", %{"search" => search}, socket) do - {:noreply, - push_patch(socket, - to: - Routes.path("/admin/billing/subscriptions") <> - build_query_string(socket.assigns.status_filter, search) - )} - end - - @impl true - def handle_event("cancel_subscription", %{"uuid" => uuid}, socket) do - subscription = Enum.find(socket.assigns.subscriptions, &(&1.uuid == uuid)) - - if subscription do - case Billing.cancel_subscription(subscription, immediately: false) do - {:ok, _subscription} -> - {:noreply, - socket - |> load_subscriptions() - |> put_flash(:info, "Subscription will be cancelled at period end")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to cancel: #{inspect(reason)}")} - end - else - {:noreply, put_flash(socket, :error, "Subscription not found")} - end - end - - # PubSub event handlers - @impl true - def handle_info({:subscription_created, _subscription}, socket) do - {:noreply, load_subscriptions(socket)} - end - - @impl true - def handle_info({:subscription_cancelled, _subscription}, socket) do - {:noreply, load_subscriptions(socket)} - end - - @impl true - def handle_info({:subscription_renewed, _subscription}, socket) do - {:noreply, load_subscriptions(socket)} - end - - @impl true - def handle_info({:subscription_type_changed, _subscription, _old_type, _new_type}, socket) do - {:noreply, load_subscriptions(socket)} - end - - @impl true - def handle_info({:subscription_status_changed, _subscription, _old_status, _new_status}, socket) do - {:noreply, load_subscriptions(socket)} - end - - defp build_query_string(status, search) do - params = - [] - |> then(fn p -> if status != "all", do: [{"status", status} | p], else: p end) - |> then(fn p -> if search != "", do: [{"search", search} | p], else: p end) - - case params do - [] -> "" - _ -> "?" <> URI.encode_query(params) - end - end - - # Helper functions for template - - def status_badge_class(status) do - case status do - "active" -> "badge-success" - "trialing" -> "badge-info" - "past_due" -> "badge-warning" - "paused" -> "badge-neutral" - "cancelled" -> "badge-error" - _ -> "badge-ghost" - end - end - - def format_interval(interval, interval_count) do - case {interval, interval_count} do - {"month", 1} -> "Monthly" - {"month", n} -> "Every #{n} months" - {"year", 1} -> "Yearly" - {"year", n} -> "Every #{n} years" - {"week", 1} -> "Weekly" - {"week", n} -> "Every #{n} weeks" - {"day", 1} -> "Daily" - {"day", n} -> "Every #{n} days" - _ -> "#{interval_count} #{interval}(s)" - end - end -end diff --git a/lib/modules/billing/web/subscriptions.html.heex b/lib/modules/billing/web/subscriptions.html.heex deleted file mode 100644 index 590860747..000000000 --- a/lib/modules/billing/web/subscriptions.html.heex +++ /dev/null @@ -1,246 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin/billing")} - title="Subscriptions" - subtitle="Manage recurring billing subscriptions" - > - <:actions> - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscription-types")} - class="btn btn-outline btn-sm" - > - <.icon name="hero-squares-2x2" class="w-4 h-4" /> Manage Subscription Types - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/billing/subscriptions/new")} - class="btn btn-primary btn-sm" - > - <.icon name="hero-plus" class="w-4 h-4" /> New Subscription - - - - - <%!-- Stats Cards --%> -
-
-
Total
-
{@stats.total}
-
-
-
Active
-
{@stats.active}
-
-
-
Trialing
-
{@stats.trialing}
-
-
-
Past Due
-
{@stats.past_due}
-
-
-
Cancelled
-
{@stats.cancelled}
-
-
- - <%!-- Filters --%> -
-
-
- <%!-- Status Filter --%> -
- - - - - -
- - <%!-- Search --%> -
-
- - -
-
-
-
-
- - <%!-- Subscriptions Table --%> -
-
- <%= if Enum.empty?(@subscriptions) do %> -
- <.icon name="hero-credit-card" class="w-12 h-12 mx-auto mb-4 opacity-50" /> -

No subscriptions found

-

- Subscriptions will appear here when customers subscribe to plans -

-
- <% else %> -
- - - - - - - - - - - - - <%= for subscription <- @subscriptions do %> - - - - - - - - - <% end %> - -
CustomerSubscription TypeStatusCurrent PeriodPriceActions
- <%= if subscription.user do %> -
- <.user_avatar user={subscription.user} size="sm" /> -
-
{subscription.user.email}
-
- ID: {subscription.uuid} -
-
-
- <% else %> - No user - <% end %> -
- <%= if subscription.subscription_type do %> -
-
{subscription.subscription_type.name}
-
- {format_interval( - subscription.subscription_type.interval, - subscription.subscription_type.interval_count - )} -
-
- <% else %> - No subscription type - <% end %> -
- - {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 %> -
-
-
- <% end %> -
-
-
-
diff --git a/lib/modules/billing/web/transactions.ex b/lib/modules/billing/web/transactions.ex deleted file mode 100644 index 586e2e93d..000000000 --- a/lib/modules/billing/web/transactions.ex +++ /dev/null @@ -1,172 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.Transactions do - @moduledoc """ - Transactions list LiveView for the billing module. - - Provides transaction management interface with filtering, searching, and pagination. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Transaction - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @default_per_page 25 - - @impl true - def mount(_params, _session, socket) do - if Billing.enabled?() do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Transactions") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/billing/transactions")) - |> assign(:transactions, []) - |> assign(:total_count, 0) - |> assign(:loading, true) - |> assign_filter_defaults() - |> assign_pagination_defaults() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(params, _url, socket) do - socket = - socket - |> apply_params(params) - |> load_transactions() - - {:noreply, socket} - end - - defp assign_filter_defaults(socket) do - socket - |> assign(:search, "") - |> assign(:type_filter, "all") - |> assign(:payment_method_filter, "all") - end - - defp assign_pagination_defaults(socket) do - socket - |> assign(:page, 1) - |> assign(:per_page, @default_per_page) - |> assign(:total_pages, 1) - end - - defp apply_params(socket, params) do - page = parse_page(params["page"]) - per_page = parse_per_page(params["per_page"]) - search = params["search"] || "" - type = params["type"] || "all" - payment_method = params["payment_method"] || "all" - - socket - |> assign(:page, page) - |> assign(:per_page, per_page) - |> assign(:search, search) - |> assign(:type_filter, type) - |> assign(:payment_method_filter, payment_method) - end - - defp parse_page(nil), do: 1 - defp parse_page(page) when is_binary(page), do: max(1, String.to_integer(page)) - defp parse_page(page) when is_integer(page), do: max(1, page) - - defp parse_per_page(nil), do: @default_per_page - - defp parse_per_page(per_page) when is_binary(per_page), - do: min(100, max(10, String.to_integer(per_page))) - - defp parse_per_page(per_page) when is_integer(per_page), do: min(100, max(10, per_page)) - - defp load_transactions(socket) do - %{ - page: page, - per_page: per_page, - search: search, - type_filter: type, - payment_method_filter: payment_method - } = socket.assigns - - opts = [ - page: page, - per_page: per_page, - search: search, - type: if(type == "all", do: nil, else: type), - payment_method: if(payment_method == "all", do: nil, else: payment_method), - preload: [:invoice, :user] - ] - - {transactions, total_count} = Billing.list_transactions_with_count(opts) - total_pages = ceil(total_count / per_page) - - socket - |> assign(:transactions, transactions) - |> assign(:total_count, total_count) - |> assign(:total_pages, max(1, total_pages)) - |> assign(:loading, false) - end - - @impl true - def handle_event("filter", params, socket) do - new_params = build_url_params(socket.assigns, params) - {:noreply, push_patch(socket, to: Routes.path("/admin/billing/transactions?#{new_params}"))} - end - - @impl true - def handle_event("clear_filters", _params, socket) do - {:noreply, push_patch(socket, to: Routes.path("/admin/billing/transactions"))} - end - - @impl true - def handle_event("view_invoice", %{"uuid" => uuid}, socket) do - {:noreply, push_navigate(socket, to: Routes.path("/admin/billing/invoices/#{uuid}"))} - end - - @impl true - def handle_event("page_change", %{"page" => page}, socket) do - new_params = build_url_params(socket.assigns, %{"page" => page}) - {:noreply, push_patch(socket, to: Routes.path("/admin/billing/transactions?#{new_params}"))} - end - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, socket |> assign(:loading, true) |> load_transactions()} - end - - defp build_url_params(assigns, new_params) do - params = %{ - "page" => Map.get(new_params, "page", assigns.page), - "per_page" => assigns.per_page, - "search" => Map.get(new_params, "search", assigns.search), - "type" => Map.get(new_params, "type", assigns.type_filter), - "payment_method" => Map.get(new_params, "payment_method", assigns.payment_method_filter) - } - - params - |> Enum.reject(fn - {_k, v} when v in ["", "all", nil] -> true - {"page", 1} -> true - {"per_page", @default_per_page} -> true - _ -> false - end) - |> URI.encode_query() - end - - @doc """ - Returns transaction type based on amount sign. - """ - def transaction_type(%Transaction{} = transaction) do - Transaction.type(transaction) - end -end diff --git a/lib/modules/billing/web/transactions.html.heex b/lib/modules/billing/web/transactions.html.heex deleted file mode 100644 index 4b8274d07..000000000 --- a/lib/modules/billing/web/transactions.html.heex +++ /dev/null @@ -1,209 +0,0 @@ - -
- <%!-- Header --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/billing")}> -

Transactions

-

{@total_count} total transactions

- <:actions> - - - - - <%!-- Filters --%> -
-
-
-
- - -
- -
- - -
- -
- - -
- - -
-
-
- - <%!-- Transactions Table --%> -
-
- <%= if @loading do %> -
- -
- <% else %> - <%= if Enum.empty?(@transactions) do %> -
- <.icon - name="hero-banknotes" - class="w-16 h-16 mx-auto mb-4 text-base-content/30" - /> -

No transactions found

-

- <%= 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 %> -

-
- <% else %> -
- - - - - - - - - - - - - - - <%= for transaction <- @transactions do %> - - - - - - - - - - - <% end %> - -
Transaction #InvoiceTypeAmountMethodDescriptionDate
{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")} - - -
-
- - <%!-- Pagination --%> - <%= if @total_pages > 1 do %> -
- <.pagination - current_page={@page} - total_pages={@total_pages} - base_path={PhoenixKit.Utils.Routes.path("/admin/billing/transactions")} - params={ - %{ - "search" => @search, - "type" => @type_filter, - "payment_method" => @payment_method_filter - } - } - /> -
- <% end %> - <% end %> - <% end %> -
-
-
-
diff --git a/lib/modules/billing/web/user_billing_profile_form.ex b/lib/modules/billing/web/user_billing_profile_form.ex deleted file mode 100644 index 6e048bdb7..000000000 --- a/lib/modules/billing/web/user_billing_profile_form.ex +++ /dev/null @@ -1,533 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.UserBillingProfileForm do - @moduledoc """ - User billing profile form LiveView for creating and editing own billing profiles. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.BillingProfile - alias PhoenixKit.Modules.Billing.CountryData - alias PhoenixKit.Utils.Routes - - @impl true - def mount(params, _session, socket) do - user = get_current_user(socket) - - cond do - not Billing.enabled?() -> - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/dashboard"))} - - is_nil(user) -> - {:ok, - socket - |> put_flash(:error, "Please log in to manage billing profiles") - |> push_navigate(to: Routes.path("/phoenix_kit/users/log-in"))} - - true -> - countries = CountryData.countries_for_select() - return_to = params["return_to"] - - socket = - socket - |> assign(:user, user) - |> assign(:countries, countries) - |> assign(:profile_type, "individual") - |> assign(:subdivision_label, "Region") - |> assign(:return_to, return_to) - |> load_profile(params["id"]) - - {:ok, socket} - end - end - - defp load_profile(socket, nil) do - # New profile - changeset = Billing.change_billing_profile(%BillingProfile{type: "individual"}) - - socket - |> assign(:page_title, "New Billing Profile") - |> assign(:profile, nil) - |> assign(:form, to_form(changeset)) - end - - defp load_profile(socket, id) do - case Billing.get_billing_profile(id) do - nil -> - socket - |> put_flash(:error, "Billing profile not found") - |> push_navigate(to: Routes.path("/dashboard/billing-profiles")) - - profile -> - # Verify ownership - if profile.user_uuid != socket.assigns.user.uuid do - socket - |> put_flash(:error, "Access denied") - |> push_navigate(to: Routes.path("/dashboard/billing-profiles")) - else - changeset = Billing.change_billing_profile(profile) - - socket - |> assign(:page_title, "Edit Billing Profile") - |> assign(:profile, profile) - |> assign(:form, to_form(changeset)) - |> assign(:profile_type, profile.type) - |> assign(:subdivision_label, CountryData.get_subdivision_label(profile.country)) - end - end - end - - @impl true - def handle_event("change_type", %{"type" => type}, socket) do - {:noreply, assign(socket, :profile_type, type)} - end - - @impl true - def handle_event("validate", %{"billing_profile" => params}, socket) do - changeset = - (socket.assigns.profile || %BillingProfile{}) - |> Billing.change_billing_profile(params) - |> Map.put(:action, :validate) - - # Update subdivision label when country changes - subdivision_label = CountryData.get_subdivision_label(params["country"]) - - {:noreply, - socket - |> assign(:form, to_form(changeset)) - |> assign(:subdivision_label, subdivision_label)} - end - - @impl true - def handle_event("save", %{"billing_profile" => params}, socket) do - params = - params - |> Map.put("user_uuid", socket.assigns.user.uuid) - |> Map.put("type", socket.assigns.profile_type) - - save_profile(socket, params) - end - - defp save_profile(socket, params) do - result = - if socket.assigns.profile do - Billing.update_billing_profile(socket.assigns.profile, params) - else - Billing.create_billing_profile(socket.assigns.user.uuid, params) - end - - case result do - {:ok, _profile} -> - action = if socket.assigns.profile, do: "updated", else: "created" - redirect_path = socket.assigns.return_to || Routes.path("/dashboard/billing-profiles") - - {:noreply, - socket - |> put_flash(:info, "Billing profile #{action} successfully") - |> push_navigate(to: redirect_path)} - - {:error, changeset} -> - {:noreply, assign(socket, :form, to_form(changeset))} - end - end - - @impl true - def render(assigns) do - ~H""" - -
- <%!-- Header --%> -
- <.link - navigate={@return_to || Routes.path("/dashboard/billing-profiles")} - class="btn btn-ghost btn-sm" - > - <.icon name="hero-arrow-left" class="w-5 h-5" /> - -
-

{@page_title}

-

- <%= if @profile do %> - Update your billing information - <% else %> - Create a new billing profile for orders - <% end %> -

-
-
- -
- <%!-- Profile Type Selection --%> -
-
-

- <.icon name="hero-user-circle" class="w-5 h-5" /> Profile Type -

- -
- - -
-
-
- - <%!-- Individual Fields --%> - <%= if @profile_type == "individual" do %> -
-
-

- <.icon name="hero-user" class="w-5 h-5" /> Personal Information -

- -
-
- - - <.error :for={msg <- @form[:first_name].errors |> Enum.map(&elem(&1, 0))}> - {msg} - -
- -
- - - <.error :for={msg <- @form[:last_name].errors |> Enum.map(&elem(&1, 0))}> - {msg} - -
-
- -
-
- - -
- -
- - -
-
-
-
- <% end %> - - <%!-- Company Fields --%> - <%= if @profile_type == "company" do %> -
-
-

- <.icon name="hero-building-office" class="w-5 h-5" /> Company Information -

- -
- - - <.error :for={msg <- @form[:company_name].errors |> Enum.map(&elem(&1, 0))}> - {msg} - -
- -
-
- - - -
- -
- - -
-
- -
- - -
- -
Contact
- -
-
- - -
- -
- - -
-
-
-
- <% end %> - - <%!-- Billing Address (Country FIRST) --%> -
-
-

- <.icon name="hero-map-pin" class="w-5 h-5" /> Billing Address -

- - <%!-- Country first --%> -
- - -
- -
- - -
- -
- - -
- -
-
- - -
- -
- - -
- -
- - -
-
-
-
- - <%!-- Options --%> -
-
-

- <.icon name="hero-cog-6-tooth" class="w-5 h-5" /> Options -

- -
- -
- -
- - - -
-
-
- - <%!-- Actions --%> -
- <.link - navigate={@return_to || Routes.path("/dashboard/billing-profiles")} - class="btn btn-ghost" - > - Cancel - - -
-
-
-
- """ - end - - # Private helpers - - defp get_current_user(socket) do - case socket.assigns[:phoenix_kit_current_scope] do - %{user: %{uuid: _} = user} -> user - _ -> nil - end - end -end diff --git a/lib/modules/billing/web/user_billing_profiles.ex b/lib/modules/billing/web/user_billing_profiles.ex deleted file mode 100644 index 5675eb7ab..000000000 --- a/lib/modules/billing/web/user_billing_profiles.ex +++ /dev/null @@ -1,237 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.UserBillingProfiles do - @moduledoc """ - User billing profiles list LiveView. - - Allows users to manage their own billing profiles. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Events - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - user = get_current_user(socket) - - cond do - not Billing.enabled?() -> - {:ok, - socket - |> put_flash(:error, "Billing module is not enabled") - |> push_navigate(to: Routes.path("/dashboard"))} - - is_nil(user) -> - {:ok, - socket - |> put_flash(:error, "Please log in to view your billing profiles") - |> push_navigate(to: Routes.path("/phoenix_kit/users/log-in"))} - - true -> - # Subscribe to billing profile events for real-time updates - if connected?(socket), do: Events.subscribe_profiles() - - profiles = Billing.list_user_billing_profiles(user.uuid) - - socket = - socket - |> assign(:page_title, "My Billing Profiles") - |> assign(:profiles, profiles) - |> assign(:user, user) - - {:ok, socket} - end - end - - @impl true - def handle_event("set_default", %{"uuid" => uuid}, socket) do - profile = Enum.find(socket.assigns.profiles, &(&1.uuid == uuid)) - - if profile && profile.user_uuid == socket.assigns.user.uuid do - case Billing.set_default_billing_profile(profile) do - {:ok, _profile} -> - profiles = Billing.list_user_billing_profiles(socket.assigns.user.uuid) - - {:noreply, - socket - |> assign(:profiles, profiles) - |> put_flash(:info, "Default profile updated")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to set default profile")} - end - else - {:noreply, put_flash(socket, :error, "Profile not found")} - end - end - - @impl true - def handle_event("delete", %{"uuid" => uuid}, socket) do - profile = Enum.find(socket.assigns.profiles, &(&1.uuid == uuid)) - - if profile && profile.user_uuid == socket.assigns.user.uuid do - case Billing.delete_billing_profile(profile) do - {:ok, _} -> - profiles = Billing.list_user_billing_profiles(socket.assigns.user.uuid) - - {:noreply, - socket - |> assign(:profiles, profiles) - |> put_flash(:info, "Profile deleted successfully")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to delete profile")} - end - else - {:noreply, put_flash(socket, :error, "Profile not found")} - end - end - - # PubSub event handlers for real-time updates - @impl true - def handle_info({event, _profile}, socket) - when event in [:profile_created, :profile_updated, :profile_deleted] do - # Only refresh if we have a user assigned - if socket.assigns[:user] do - profiles = Billing.list_user_billing_profiles(socket.assigns.user.uuid) - {:noreply, assign(socket, :profiles, profiles)} - else - {:noreply, socket} - end - end - - @impl true - def handle_info(_msg, socket) do - {:noreply, socket} - end - - @impl true - def render(assigns) do - ~H""" - -
- <%!-- Header --%> -
-
-

My Billing Profiles

-

- Manage your billing information for orders and invoices -

-
- <.link - navigate={Routes.path("/dashboard/billing-profiles/new")} - class="btn btn-primary" - > - <.icon name="hero-plus" class="w-5 h-5 mr-2" /> New Profile - -
- - <%!-- Profiles List --%> - <%= if Enum.empty?(@profiles) do %> -
-
- <.icon name="hero-identification" class="w-16 h-16 mx-auto mb-4 opacity-30" /> -

No billing profiles yet

-

- 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 - -
-
- <% else %> -
- <%= for profile <- @profiles do %> -
-
-
- <%!-- Profile Info --%> -
-
- - {String.capitalize(profile.type)} - - <%= if profile.is_default do %> - Default - <% end %> -
- - <%= 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.address_line1 do %> -

- {profile.address_line1} - <%= if profile.city do %> - , {profile.city} - <% end %> - <%= if profile.country do %> - , {profile.country} - <% end %> -

- <% end %> -
- - <%!-- Actions --%> -
- <.link - navigate={Routes.path("/dashboard/billing-profiles/#{profile.uuid}/edit")} - class="btn btn-outline btn-sm" - > - <.icon name="hero-pencil" class="w-4 h-4" /> Edit - - - <%= if not profile.is_default do %> - - <% end %> - - -
-
-
-
- <% end %> -
- <% end %> -
-
- """ - end - - # Private helpers - - defp get_current_user(socket) do - case socket.assigns[:phoenix_kit_current_scope] do - %{user: %{uuid: _} = user} -> user - _ -> nil - end - end -end diff --git a/lib/modules/billing/web/webhook_controller.ex b/lib/modules/billing/web/webhook_controller.ex deleted file mode 100644 index 0c264a1b0..000000000 --- a/lib/modules/billing/web/webhook_controller.ex +++ /dev/null @@ -1,160 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Web.WebhookController do - @moduledoc """ - Handles webhooks from payment providers (Stripe, PayPal, Razorpay). - - This controller receives webhook events from payment providers, - verifies their signatures, and processes them through the WebhookProcessor. - - ## Webhook URLs - - Configure these URLs in your payment provider dashboards: - - - Stripe: `https://yourdomain.com/phoenix_kit/webhooks/billing/stripe` - - PayPal: `https://yourdomain.com/phoenix_kit/webhooks/billing/paypal` - - Razorpay: `https://yourdomain.com/phoenix_kit/webhooks/billing/razorpay` - - ## Security - - All webhooks verify signatures to ensure they come from legitimate sources. - Invalid signatures result in 401 Unauthorized responses. - - ## Idempotency - - Events are logged in the `phoenix_kit_webhook_events` table with their - event IDs. Duplicate events are detected and ignored to prevent - double-processing. - """ - - use Phoenix.Controller, - formats: [:json] - - alias PhoenixKit.Modules.Billing.Providers - alias PhoenixKit.Modules.Billing.WebhookProcessor - alias PhoenixKit.Settings - - require Logger - - @doc """ - Handles Stripe webhooks. - - Expects the raw body in `conn.assigns.raw_body` (set by a custom Plug). - Signature is read from the `stripe-signature` header. - """ - def stripe(conn, _params) do - handle_webhook(conn, :stripe, "stripe-signature") - end - - @doc """ - Handles PayPal webhooks. - - PayPal verification requires multiple headers for signature verification. - """ - def paypal(conn, _params) do - handle_webhook(conn, :paypal, "paypal-transmission-sig") - end - - @doc """ - Handles Razorpay webhooks. - - Signature is read from the `x-razorpay-signature` header. - """ - def razorpay(conn, _params) do - handle_webhook(conn, :razorpay, "x-razorpay-signature") - end - - # =========================================== - # Private Implementation - # =========================================== - - defp handle_webhook(conn, provider, signature_header) do - with {:ok, raw_body} <- get_raw_body(conn), - {:ok, signature} <- get_signature(conn, signature_header), - {:ok, secret} <- get_webhook_secret(provider), - :ok <- verify_signature(provider, raw_body, signature, secret), - {:ok, payload} <- decode_payload(raw_body), - {:ok, event} <- Providers.handle_webhook_event(provider, payload), - {:ok, _result} <- WebhookProcessor.process(event) do - Logger.info("Webhook processed successfully: #{provider} - #{event.type}") - - conn - |> put_status(200) - |> json(%{status: "ok"}) - else - {:error, :invalid_signature} -> - Logger.warning("Invalid webhook signature from #{provider}") - - conn - |> put_status(401) - |> json(%{error: "Invalid signature"}) - - {:error, :duplicate_event} -> - # Duplicate events are OK - return 200 to prevent retries - Logger.debug("Duplicate webhook event from #{provider}") - - conn - |> put_status(200) - |> json(%{status: "duplicate"}) - - {:error, :unknown_event} -> - # Unknown events are OK - return 200 to prevent retries - Logger.debug("Unknown webhook event type from #{provider}") - - conn - |> put_status(200) - |> json(%{status: "ignored"}) - - {:error, :not_configured} -> - Logger.warning("Webhook received for unconfigured provider: #{provider}") - - conn - |> put_status(400) - |> json(%{error: "Provider not configured"}) - - {:error, reason} -> - Logger.error("Webhook processing failed for #{provider}: #{inspect(reason)}") - - conn - |> put_status(400) - |> json(%{error: "Processing failed"}) - end - end - - defp get_raw_body(conn) do - case conn.assigns[:raw_body] do - nil -> - # Try to read from body_params if raw_body not set - # This is a fallback - ideally raw_body should be set by a Plug - {:error, :no_raw_body} - - raw_body when is_binary(raw_body) -> - {:ok, raw_body} - end - end - - defp get_signature(conn, header_name) do - case get_req_header(conn, header_name) do - [signature | _] -> {:ok, signature} - [] -> {:error, :no_signature} - end - end - - defp get_webhook_secret(provider) do - key = "billing_#{provider}_webhook_secret" - - case Settings.get_setting(key, "") do - "" -> {:error, :not_configured} - secret -> {:ok, secret} - end - end - - defp verify_signature(provider, raw_body, signature, secret) do - Providers.verify_webhook_signature(provider, raw_body, signature, secret) - end - - defp decode_payload(raw_body) do - case Jason.decode(raw_body) do - {:ok, payload} -> {:ok, payload} - {:error, _} -> {:error, :invalid_json} - end - end -end diff --git a/lib/modules/billing/workers/subscription_dunning_worker.ex b/lib/modules/billing/workers/subscription_dunning_worker.ex deleted file mode 100644 index 990b26ea7..000000000 --- a/lib/modules/billing/workers/subscription_dunning_worker.ex +++ /dev/null @@ -1,212 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Workers.SubscriptionDunningWorker do - @moduledoc """ - Oban worker for dunning (failed payment recovery). - - When a subscription payment fails, the subscription enters `past_due` status - and this worker handles retry attempts during the grace period. - - ## Dunning Process - - 1. Initial payment fails → subscription status = `past_due` - 2. Grace period starts (configurable, default 3 days) - 3. This worker retries payment at intervals - 4. If payment succeeds → status = `active` - 5. If max attempts reached or grace period ends → status = `cancelled` - - ## Retry Schedule - - Default retry schedule (can be configured): - - Attempt 1: Immediate (handled by RenewalWorker) - - Attempt 2: 24 hours later - - Attempt 3: 48 hours later (2 days) - - Attempt 4: 72 hours later (3 days, grace period ends) - - ## Configuration - - ```elixir - # Settings (stored in database) - billing_subscription_grace_days: 3 - billing_dunning_max_attempts: 3 - ``` - - ## Manual Trigger - - ```elixir - %{subscription_uuid: "019145a1-0000-7000-8000-000000000001"} - |> SubscriptionDunningWorker.new() - |> Oban.insert() - ``` - """ - - use Oban.Worker, - queue: :billing, - max_attempts: 5, - unique: [period: 3600, keys: [:subscription_uuid]] - - alias PhoenixKit.Modules.Billing.{PaymentMethod, Providers, Subscription, SubscriptionType} - alias PhoenixKit.RepoHelper - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{args: args}) do - subscription_uuid = Map.get(args, "subscription_uuid") || Map.get(args, "subscription_id") - - case get_subscription_with_preloads(subscription_uuid) do - nil -> - Logger.warning("Subscription #{subscription_uuid} not found for dunning") - :ok - - subscription -> - process_dunning(subscription) - end - end - - # ============================================ - # Dunning Processing - # ============================================ - - defp process_dunning(%Subscription{status: "cancelled"}) do - # Already cancelled, nothing to do - :ok - end - - defp process_dunning(%Subscription{status: status}) when status not in ["past_due"] do - # Not in past_due status, skip - :ok - end - - defp process_dunning(%Subscription{} = subscription) do - max_attempts = get_max_attempts() - - cond do - subscription.renewal_attempts >= max_attempts -> - Logger.info("Subscription #{subscription.uuid} exceeded max dunning attempts, cancelling") - cancel_subscription(subscription, "Max payment retry attempts exceeded") - - Subscription.grace_period_expired?(subscription) -> - Logger.info("Subscription #{subscription.uuid} grace period expired, cancelling") - cancel_subscription(subscription, "Grace period expired") - - true -> - attempt_payment_retry(subscription) - end - end - - defp attempt_payment_retry(%Subscription{payment_method: nil} = subscription) do - Logger.warning("Subscription #{subscription.uuid} has no payment method for retry") - # Still schedule next retry in case user adds payment method - schedule_next_retry(subscription) - {:ok, :no_payment_method} - end - - defp attempt_payment_retry(%Subscription{} = subscription) do - pm = subscription.payment_method - - if PaymentMethod.usable?(pm) do - Logger.info( - "Attempting payment retry ##{subscription.renewal_attempts + 1} for subscription #{subscription.uuid}" - ) - - case charge_subscription(subscription) do - {:ok, _result} -> - Logger.info("Dunning payment successful for subscription #{subscription.uuid}") - reactivate_subscription(subscription) - - {:error, reason} -> - Logger.warning( - "Dunning payment failed for subscription #{subscription.uuid}: #{inspect(reason)}" - ) - - update_retry_count(subscription) - schedule_next_retry(subscription) - {:error, reason} - end - else - Logger.warning( - "Payment method not usable for subscription #{subscription.uuid}: #{inspect(pm.status)}" - ) - - schedule_next_retry(subscription) - {:error, :payment_method_not_usable} - end - end - - defp charge_subscription(%Subscription{} = subscription) do - plan = subscription.subscription_type - pm = subscription.payment_method - - # Providers.charge_payment_method expects the payment_method map with :provider key - Providers.charge_payment_method(pm, plan.price, - currency: plan.currency, - description: "Subscription renewal (dunning retry)", - metadata: %{ - subscription_uuid: subscription.uuid, - retry_attempt: subscription.renewal_attempts + 1 - } - ) - end - - defp reactivate_subscription(%Subscription{} = subscription) do - plan = subscription.subscription_type - new_period_start = subscription.current_period_end - new_period_end = SubscriptionType.next_billing_date(plan, DateTime.to_date(new_period_start)) - - subscription - |> Subscription.activate_changeset(datetime_from_date(new_period_end)) - |> RepoHelper.repo().update() - end - - defp update_retry_count(%Subscription{} = subscription) do - subscription - |> Ecto.Changeset.change(%{ - renewal_attempts: subscription.renewal_attempts + 1, - last_renewal_attempt_at: UtilsDate.utc_now() - }) - |> RepoHelper.repo().update() - end - - defp cancel_subscription(%Subscription{} = subscription, reason) do - Logger.info("Cancelling subscription #{subscription.uuid}: #{reason}") - - subscription - |> Subscription.cancel_changeset(true) - |> RepoHelper.repo().update() - end - - defp schedule_next_retry(%Subscription{} = subscription) do - max_attempts = get_max_attempts() - - if subscription.renewal_attempts < max_attempts do - # Schedule next retry in 24 hours - %{subscription_uuid: subscription.uuid} - |> __MODULE__.new(schedule_in: 86_400) - |> Oban.insert() - end - end - - # ============================================ - # Queries & Helpers - # ============================================ - - defp get_subscription_with_preloads(uuid) when is_binary(uuid) do - import Ecto.Query - - from(s in Subscription, - where: s.uuid == ^uuid, - preload: [:subscription_type, :payment_method] - ) - |> RepoHelper.repo().one() - end - - defp get_max_attempts do - Settings.get_setting("billing_dunning_max_attempts", "3") - |> String.to_integer() - end - - defp datetime_from_date(date) do - DateTime.new!(date, ~T[00:00:00], "Etc/UTC") - end -end diff --git a/lib/modules/billing/workers/subscription_renewal_worker.ex b/lib/modules/billing/workers/subscription_renewal_worker.ex deleted file mode 100644 index 6f654ad74..000000000 --- a/lib/modules/billing/workers/subscription_renewal_worker.ex +++ /dev/null @@ -1,269 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.Workers.SubscriptionRenewalWorker do - @moduledoc """ - Oban worker for processing subscription renewals. - - This worker runs daily and handles: - - Finding subscriptions due for renewal (within 24 hours of period end) - - Creating invoices for the renewal - - Charging saved payment methods via providers - - Updating subscription periods on success - - Moving to past_due status on failure - - ## Scheduling - - The worker should be scheduled to run daily via Oban crontab: - - ```elixir - config :my_app, Oban, - queues: [default: 10, billing: 5], - plugins: [ - {Oban.Plugins.Cron, - crontab: [ - {"0 6 * * *", PhoenixKit.Modules.Billing.Workers.SubscriptionRenewalWorker} - ]} - ] - ``` - - ## Process Flow - - 1. Query subscriptions where `current_period_end` is within 24 hours - 2. For each subscription: - a. Skip if cancel_at_period_end is true - b. Create renewal invoice - c. Charge saved payment method - d. On success: extend period_end, update invoice as paid - e. On failure: set past_due, schedule dunning - - ## Manual Trigger - - Can be triggered manually for a specific subscription: - - ```elixir - %{subscription_uuid: "019145a1-0000-7000-8000-000000000001"} - |> SubscriptionRenewalWorker.new() - |> Oban.insert() - ``` - """ - - use Oban.Worker, - queue: :billing, - max_attempts: 3, - unique: [period: 3600] - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.{PaymentMethod, Providers, Subscription, SubscriptionType} - alias PhoenixKit.Modules.Billing.Workers.SubscriptionDunningWorker - alias PhoenixKit.RepoHelper - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"subscription_uuid" => subscription_uuid}}) do - # Process single subscription - case get_subscription(subscription_uuid) do - nil -> - Logger.warning("Subscription #{subscription_uuid} not found for renewal") - :ok - - subscription -> - process_subscription_renewal(subscription) - end - end - - def perform(%Oban.Job{args: %{"subscription_id" => subscription_uuid}}) do - # Backward compat for in-flight jobs - case get_subscription(subscription_uuid) do - nil -> - Logger.warning("Subscription #{subscription_uuid} not found for renewal") - :ok - - subscription -> - process_subscription_renewal(subscription) - end - end - - def perform(%Oban.Job{args: _args}) do - # Process all due subscriptions (daily batch) - subscriptions = find_subscriptions_due_for_renewal() - Logger.info("Found #{length(subscriptions)} subscriptions due for renewal") - - Enum.each(subscriptions, fn subscription -> - case process_subscription_renewal(subscription) do - {:ok, _} -> - Logger.info("Renewed subscription #{subscription.uuid}") - - {:error, reason} -> - Logger.warning("Failed to renew subscription #{subscription.uuid}: #{inspect(reason)}") - end - end) - - :ok - end - - # ============================================ - # Renewal Processing - # ============================================ - - defp process_subscription_renewal(%Subscription{cancel_at_period_end: true} = subscription) do - # Subscription marked for cancellation - don't renew, cancel now - Logger.info("Subscription #{subscription.uuid} marked for cancellation, cancelling now") - - subscription - |> Subscription.cancel_changeset(true) - |> RepoHelper.repo().update() - end - - defp process_subscription_renewal(%Subscription{} = subscription) do - repo = RepoHelper.repo() - - with {:ok, subscription} <- repo.preload(subscription, [:subscription_type, :payment_method]), - {:ok, invoice} <- create_renewal_invoice(subscription), - {:ok, _} <- charge_payment_method(subscription, invoice) do - # Payment successful - extend period - plan = subscription.subscription_type - new_period_start = subscription.current_period_end - - new_period_end = - SubscriptionType.next_billing_date(plan, DateTime.to_date(new_period_start)) - - subscription - |> Subscription.activate_changeset(datetime_from_date(new_period_end)) - |> repo.update() - else - {:error, :no_payment_method} -> - Logger.warning("Subscription #{subscription.uuid} has no payment method") - handle_payment_failure(subscription, "No payment method configured") - - {:error, reason} -> - handle_payment_failure(subscription, inspect(reason)) - end - end - - defp create_renewal_invoice(%Subscription{subscription_type: nil}) do - {:error, :no_plan} - end - - defp create_renewal_invoice(%Subscription{} = subscription) do - plan = subscription.subscription_type - - line_items = [ - %{ - "name" => "#{plan.name} subscription", - "description" => "#{SubscriptionType.interval_description(plan)}", - "quantity" => 1, - "unit_price" => plan.price, - "total" => plan.price - } - ] - - invoice_attrs = %{ - billing_profile_uuid: subscription.billing_profile_uuid, - currency: plan.currency, - status: "sent", - due_date: Date.utc_today(), - notes: "Subscription renewal: #{plan.name}", - line_items: line_items, - subtotal: plan.price, - total: plan.price - } - - case Billing.create_invoice(subscription.user_uuid, invoice_attrs) do - {:ok, invoice} -> {:ok, invoice} - error -> error - end - end - - defp charge_payment_method(%Subscription{payment_method: nil}, _invoice) do - {:error, :no_payment_method} - end - - defp charge_payment_method(%Subscription{payment_method: pm} = subscription, invoice) do - if PaymentMethod.usable?(pm) do - # Providers.charge_payment_method expects the payment_method map with :provider key - case Providers.charge_payment_method(pm, invoice.total, - currency: invoice.currency, - description: "Subscription renewal", - metadata: %{ - invoice_uuid: invoice.uuid, - subscription_uuid: subscription.uuid - } - ) do - {:ok, charge_result} -> - # Record payment on invoice - payment_attrs = %{ - amount: invoice.total, - payment_method: pm.provider, - description: "Subscription renewal payment", - provider_transaction_id: charge_result.provider_transaction_id, - provider_data: charge_result - } - - Billing.record_payment(invoice, payment_attrs, nil) - - {:error, reason} -> - {:error, reason} - end - else - {:error, :payment_method_not_usable} - end - end - - defp handle_payment_failure(%Subscription{} = subscription, error_message) do - grace_days = - Settings.get_setting("billing_subscription_grace_days", "3") |> String.to_integer() - - grace_period_end = DateTime.add(UtilsDate.utc_now(), grace_days, :day) - - Logger.warning( - "Subscription #{subscription.uuid} renewal failed: #{error_message}. Grace period until #{grace_period_end}" - ) - - result = - subscription - |> Subscription.past_due_changeset(grace_period_end) - |> RepoHelper.repo().update() - - # Schedule dunning job - schedule_dunning(subscription.uuid) - - result - end - - defp schedule_dunning(subscription_uuid) do - # Schedule dunning worker to retry in 24 hours - %{subscription_uuid: subscription_uuid} - |> SubscriptionDunningWorker.new(schedule_in: 86_400) - |> Oban.insert() - end - - # ============================================ - # Queries - # ============================================ - - defp find_subscriptions_due_for_renewal do - import Ecto.Query - - # Find subscriptions where: - # - Status is active or trialing - # - Period end is within next 24 hours - # - Not already marked for cancellation - cutoff = DateTime.add(UtilsDate.utc_now(), 24, :hour) - - from(s in Subscription, - where: s.status in ["active", "trialing"], - where: s.current_period_end <= ^cutoff, - where: s.cancel_at_period_end == false - ) - |> RepoHelper.repo().all() - end - - defp get_subscription(uuid) when is_binary(uuid) do - RepoHelper.repo().get_by(Subscription, uuid: uuid) - end - - defp datetime_from_date(date) do - DateTime.new!(date, ~T[00:00:00], "Etc/UTC") - end -end diff --git a/lib/modules/languages/languages.ex b/lib/modules/languages/languages.ex index 269f7268b..9831b490a 100644 --- a/lib/modules/languages/languages.ex +++ b/lib/modules/languages/languages.ex @@ -132,8 +132,9 @@ defmodule PhoenixKit.Modules.Languages do %Language{code: "ko", name: "Korean", is_default: false, is_enabled: true}, %Language{code: "ru", name: "Russian", is_default: false, is_enabled: true}, %Language{code: "nl", name: "Dutch", is_default: false, is_enabled: true}, - %Language{code: "zh-CN", name: "Chinese (Mandarin)", is_default: false, is_enabled: true}, - %Language{code: "ar", name: "Arabic", is_default: false, is_enabled: true} + %Language{code: "zh", name: "Chinese", is_default: false, is_enabled: true}, + %Language{code: "ar", name: "Arabic", is_default: false, is_enabled: true}, + %Language{code: "et", name: "Estonian", is_default: false, is_enabled: true} ] ## --- System Management Functions --- @@ -514,7 +515,7 @@ defmodule PhoenixKit.Modules.Languages do ## Examples iex> PhoenixKit.Modules.Languages.get_default_language_codes() - ["en-US", "es-ES", "fr-FR", "de-DE", "pt-BR", "it", "nl", "ru", "ja", "ko", "zh-CN", "ar"] + ["en-US", "es-ES", "fr-FR", "de-DE", "pt-BR", "it", "nl", "ru", "ja", "ko", "zh", "ar", "et"] """ def get_default_language_codes do @top_10_languages diff --git a/lib/modules/legal/legal.ex b/lib/modules/legal/legal.ex index 7f7d209f4..0ac5be898 100644 --- a/lib/modules/legal/legal.ex +++ b/lib/modules/legal/legal.ex @@ -569,6 +569,7 @@ defmodule PhoenixKit.Modules.Legal do - cookie_policy_url: string (backward compat, derived from published pages) - privacy_policy_url: string (backward compat, derived from published pages) - legal_links: list of %{title: string, url: string} for all published legal pages + - legal_index_url: string """ @spec get_consent_widget_config() :: map() def get_consent_widget_config do diff --git a/lib/modules/shop/events.ex b/lib/modules/shop/events.ex deleted file mode 100644 index 15bad15c1..000000000 --- a/lib/modules/shop/events.ex +++ /dev/null @@ -1,381 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Events do - @moduledoc """ - PubSub event broadcasting for Shop module. - - This module provides functions to broadcast cart changes across - multiple browser tabs and devices for the same user/session, as well - as product, category, and inventory updates for real-time admin dashboards. - - ## Topics - - - `shop:cart:user:{user_uuid}` - Cart events for authenticated users - - `shop:cart:session:{session_id}` - Cart events for guest sessions - - `shop:products` - Product events (created, updated, deleted) - - `shop:categories` - Category events (created, updated, deleted) - - `shop:inventory` - Inventory events (stock changes) - - `shop:products:{product_uuid}` - Individual product events - - ## Events - - ### Cart Events - - `{:cart_updated, cart}` - Cart totals changed (generic update) - - `{:item_added, cart, item}` - Item added to cart - - `{:item_removed, cart, item_uuid}` - Item removed from cart - - `{:quantity_updated, cart, item}` - Item quantity changed - - `{:shipping_selected, cart}` - Shipping method selected/changed - - `{:payment_selected, cart}` - Payment option selected/changed - - `{:cart_cleared, cart}` - All items removed from cart - - ### Product Events - - `{:product_created, product}` - New product created - - `{:product_updated, product}` - Product updated - - `{:product_deleted, product_uuid}` - Product deleted - - `{:products_bulk_status_changed, product_uuids, status}` - Bulk status update - - ### Category Events - - `{:category_created, category}` - New category created - - `{:category_updated, category}` - Category updated - - `{:category_deleted, category_uuid}` - Category deleted - - ### Inventory Events - - `{:inventory_updated, product_uuid, stock_change}` - Stock level changed - - ## Examples - - # Subscribe to cart updates for authenticated user - Events.subscribe_to_user_cart(user_uuid) - - # Subscribe to cart updates for guest session - Events.subscribe_to_session_cart(session_id) - - # Subscribe to product updates (admin dashboard) - Events.subscribe_products() - - # Broadcast item added - Events.broadcast_item_added(cart, item) - - # Broadcast product created - Events.broadcast_product_created(product) - - # Handle in LiveView - def handle_info({:item_added, cart, _item}, socket) do - {:noreply, assign(socket, :cart, cart)} - end - """ - - alias PhoenixKit.Modules.Shop.Cart - alias PhoenixKit.PubSub.Manager - - # ============================================ - # TOPIC CONSTANTS - # ============================================ - - @products_topic "shop:products" - @categories_topic "shop:categories" - @inventory_topic "shop:inventory" - - # ============================================ - # TOPIC GETTERS - # ============================================ - - @doc """ - Returns the PubSub topic for all products. - """ - def products_topic, do: @products_topic - - @doc """ - Returns the PubSub topic for all categories. - """ - def categories_topic, do: @categories_topic - - @doc """ - Returns the PubSub topic for inventory events. - """ - def inventory_topic, do: @inventory_topic - - # ============================================ - # TOPIC BUILDERS - # ============================================ - - @doc """ - Returns the PubSub topic for a user's cart. - """ - def user_cart_topic(user_uuid) when not is_nil(user_uuid) do - "shop:cart:user:#{user_uuid}" - end - - @doc """ - Returns the PubSub topic for a session's cart. - """ - def session_cart_topic(session_id) when not is_nil(session_id) do - "shop:cart:session:#{session_id}" - end - - @doc """ - Returns the appropriate topic(s) for a cart. - """ - def cart_topics(%Cart{user_uuid: user_uuid, session_id: session_id}) do - topics = [] - topics = if user_uuid, do: [user_cart_topic(user_uuid) | topics], else: topics - topics = if session_id, do: [session_cart_topic(session_id) | topics], else: topics - topics - end - - @doc """ - Returns the PubSub topic for a specific product. - """ - def product_topic(product_uuid) when not is_nil(product_uuid) do - "#{@products_topic}:#{product_uuid}" - end - - # ============================================ - # SUBSCRIPTION FUNCTIONS - # ============================================ - - # -------------------------------------------- - # Product Subscriptions - # -------------------------------------------- - - @doc """ - Subscribes to product events. - """ - def subscribe_products do - Manager.subscribe(@products_topic) - end - - @doc """ - Subscribes to events for a specific product. - """ - def subscribe_product(product_uuid) when not is_nil(product_uuid) do - Manager.subscribe(product_topic(product_uuid)) - end - - # -------------------------------------------- - # Category Subscriptions - # -------------------------------------------- - - @doc """ - Subscribes to category events. - """ - def subscribe_categories do - Manager.subscribe(@categories_topic) - end - - # -------------------------------------------- - # Inventory Subscriptions - # -------------------------------------------- - - @doc """ - Subscribes to inventory events. - """ - def subscribe_inventory do - Manager.subscribe(@inventory_topic) - end - - @doc """ - Subscribes to cart events for a specific cart. - Subscribes to all relevant topics (user and/or session). - """ - def subscribe_to_cart(%Cart{} = cart) do - cart - |> cart_topics() - |> Enum.each(&Manager.subscribe/1) - end - - @doc """ - Subscribes to cart events for an authenticated user. - """ - def subscribe_to_user_cart(user_uuid) when not is_nil(user_uuid) do - Manager.subscribe(user_cart_topic(user_uuid)) - end - - @doc """ - Subscribes to cart events for a guest session. - """ - def subscribe_to_session_cart(session_id) when not is_nil(session_id) do - Manager.subscribe(session_cart_topic(session_id)) - end - - @doc """ - Unsubscribes from cart events for a specific cart. - """ - def unsubscribe_from_cart(%Cart{} = cart) do - cart - |> cart_topics() - |> Enum.each(&Manager.unsubscribe/1) - end - - @doc """ - Unsubscribes from cart events for an authenticated user. - """ - def unsubscribe_from_user_cart(user_uuid) when not is_nil(user_uuid) do - Manager.unsubscribe(user_cart_topic(user_uuid)) - end - - @doc """ - Unsubscribes from cart events for a guest session. - """ - def unsubscribe_from_session_cart(session_id) when not is_nil(session_id) do - Manager.unsubscribe(session_cart_topic(session_id)) - end - - # ============================================ - # PRODUCT BROADCAST FUNCTIONS - # ============================================ - - @doc """ - Broadcasts product created event. - """ - def broadcast_product_created(product) do - broadcast(@products_topic, {:product_created, product}) - end - - @doc """ - Broadcasts product updated event. - """ - def broadcast_product_updated(product) do - broadcast(@products_topic, {:product_updated, product}) - broadcast(product_topic(product.uuid), {:product_updated, product}) - end - - @doc """ - Broadcasts product deleted event. - """ - def broadcast_product_deleted(product_uuid) do - broadcast(@products_topic, {:product_deleted, product_uuid}) - end - - @doc """ - Broadcasts bulk product status changed event. - """ - def broadcast_products_bulk_status_changed(product_uuids, status) do - broadcast(@products_topic, {:products_bulk_status_changed, product_uuids, status}) - end - - # ============================================ - # CATEGORY BROADCAST FUNCTIONS - # ============================================ - - @doc """ - Broadcasts category created event. - """ - def broadcast_category_created(category) do - broadcast(@categories_topic, {:category_created, category}) - end - - @doc """ - Broadcasts category updated event. - """ - def broadcast_category_updated(category) do - broadcast(@categories_topic, {:category_updated, category}) - end - - @doc """ - Broadcasts category deleted event. - """ - def broadcast_category_deleted(category_uuid) do - broadcast(@categories_topic, {:category_deleted, category_uuid}) - end - - @doc """ - Broadcasts bulk category status changed event. - """ - def broadcast_categories_bulk_status_changed(category_ids, status) do - broadcast(@categories_topic, {:categories_bulk_status_changed, category_ids, status}) - end - - @doc """ - Broadcasts bulk category parent changed event. - """ - def broadcast_categories_bulk_parent_changed(category_ids, parent_uuid) do - broadcast(@categories_topic, {:categories_bulk_parent_changed, category_ids, parent_uuid}) - end - - @doc """ - Broadcasts bulk category deleted event. - """ - def broadcast_categories_bulk_deleted(category_ids) do - broadcast(@categories_topic, {:categories_bulk_deleted, category_ids}) - end - - # ============================================ - # INVENTORY BROADCAST FUNCTIONS - # ============================================ - - @doc """ - Broadcasts inventory updated event. - """ - def broadcast_inventory_updated(product_uuid, stock_change) do - broadcast(@inventory_topic, {:inventory_updated, product_uuid, stock_change}) - broadcast(product_topic(product_uuid), {:inventory_updated, product_uuid, stock_change}) - end - - # ============================================ - # CART BROADCAST FUNCTIONS - # ============================================ - - @doc """ - Broadcasts a generic cart update event. - """ - def broadcast_cart_updated(%Cart{} = cart) do - broadcast_to_cart(cart, {:cart_updated, cart}) - end - - @doc """ - Broadcasts item added event. - """ - def broadcast_item_added(%Cart{} = cart, item) do - broadcast_to_cart(cart, {:item_added, cart, item}) - end - - @doc """ - Broadcasts item removed event. - """ - def broadcast_item_removed(%Cart{} = cart, item_uuid) do - broadcast_to_cart(cart, {:item_removed, cart, item_uuid}) - end - - @doc """ - Broadcasts quantity updated event. - """ - def broadcast_quantity_updated(%Cart{} = cart, item) do - broadcast_to_cart(cart, {:quantity_updated, cart, item}) - end - - @doc """ - Broadcasts shipping method selected event. - """ - def broadcast_shipping_selected(%Cart{} = cart) do - broadcast_to_cart(cart, {:shipping_selected, cart}) - end - - @doc """ - Broadcasts payment option selected event. - """ - def broadcast_payment_selected(%Cart{} = cart) do - broadcast_to_cart(cart, {:payment_selected, cart}) - end - - @doc """ - Broadcasts cart cleared event. - """ - def broadcast_cart_cleared(%Cart{} = cart) do - broadcast_to_cart(cart, {:cart_cleared, cart}) - end - - # ============================================ - # PRIVATE FUNCTIONS - # ============================================ - - defp broadcast_to_cart(%Cart{} = cart, message) do - cart - |> cart_topics() - |> Enum.each(fn topic -> - Manager.broadcast(topic, message) - end) - end - - defp broadcast(topic, message) do - Manager.broadcast(topic, message) - end -end diff --git a/lib/modules/shop/import/csv_analyzer.ex b/lib/modules/shop/import/csv_analyzer.ex deleted file mode 100644 index 985f0d072..000000000 --- a/lib/modules/shop/import/csv_analyzer.ex +++ /dev/null @@ -1,216 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Import.CSVAnalyzer do - @moduledoc """ - Analyze Shopify CSV files to extract option metadata. - - Extracts all Option1..Option10 names and unique values from CSV - for use in the import mapping UI. - - ## Usage - - CSVAnalyzer.analyze_options("/path/to/products.csv") - # => %{ - # options: [ - # %{name: "Size", position: 1, values: ["Small", "Medium", "Large"]}, - # %{name: "Color", position: 2, values: ["Red", "Blue", "Green"]} - # ], - # total_products: 150, - # total_variants: 450 - # } - """ - - alias PhoenixKit.Modules.Shop.Import.CSVParser - alias PhoenixKit.Modules.Shop.Import.Filter - - @max_options 10 - - @doc """ - Analyzes a CSV file and extracts option metadata. - - Returns a map with: - - `options` - List of option definitions with name, position, and unique values - - `total_products` - Number of unique product handles - - `total_variants` - Total number of variant rows - - ## Examples - - CSVAnalyzer.analyze_options("/tmp/products.csv") - # => %{ - # options: [ - # %{name: "Size", position: 1, values: ["S", "M", "L", "XL"]}, - # %{name: "Cup Color", position: 2, values: ["Red", "Blue"]}, - # %{name: "Liquid Color", position: 3, values: ["Clear", "Amber"]} - # ], - # total_products: 50, - # total_variants: 200 - # } - """ - def analyze_options(file_path, config \\ nil) do - grouped = CSVParser.parse_and_group(file_path) - - # Apply import config filter if provided and not skipped - {filtered, skipped_count} = - if config && !config.skip_filter do - filtered = - grouped - |> Enum.filter(fn {_handle, rows} -> Filter.should_include?(rows, config) end) - |> Map.new() - - {filtered, map_size(grouped) - map_size(filtered)} - else - {grouped, 0} - end - - # Group options by NAME instead of position - # This handles cases where different products use Option1 for different purposes - {option_data, total_variants} = - Enum.reduce(filtered, {%{}, 0}, fn {_handle, rows}, {acc, variant_count} -> - # Get option names and values from all rows - first_row = List.first(rows) - variant_rows = Enum.filter(rows, &has_price?/1) - - # Collect options by name - acc = collect_options_by_name(acc, first_row, variant_rows) - - {acc, variant_count + length(variant_rows)} - end) - - # Convert to output format, sorted by name - options = - option_data - |> Enum.sort_by(fn {name, _} -> String.downcase(name) end) - |> Enum.with_index(1) - |> Enum.map(fn {{name, values}, index} -> - %{ - name: name, - position: index, - values: MapSet.to_list(values) |> Enum.sort() - } - end) - - %{ - options: options, - total_products: map_size(filtered), - total_variants: total_variants, - total_skipped: skipped_count - } - end - - @doc """ - Quick analysis - only extracts option names without values. - - Faster than full analysis, useful for initial UI display. - """ - def analyze_option_names(file_path) do - # Read just the first few rows to get option names - grouped = CSVParser.parse_and_group(file_path) - - # Get first product's first row - first_product_rows = grouped |> Map.values() |> List.first() || [] - first_row = List.first(first_product_rows) || %{} - - # Extract option names - for i <- 1..@max_options, - name = get_option_name(first_row, i), - name != nil do - %{name: name, position: i} - end - end - - @doc """ - Compares CSV option values with global option values. - - Returns a map showing which values are new (not in global option). - - ## Examples - - CSVAnalyzer.compare_with_global_option(csv_values, global_option) - # => %{ - # existing: ["Red", "Blue"], - # new: ["Yellow", "Purple"] - # } - """ - def compare_with_global_option(csv_values, global_option) when is_list(csv_values) do - global_values = extract_global_option_values(global_option) - global_set = MapSet.new(global_values) - - csv_set = MapSet.new(csv_values) - - existing = MapSet.intersection(csv_set, global_set) |> MapSet.to_list() - new_values = MapSet.difference(csv_set, global_set) |> MapSet.to_list() - - %{ - existing: Enum.sort(existing), - new: Enum.sort(new_values) - } - end - - def compare_with_global_option(_, _), do: %{existing: [], new: []} - - # Extract values from global option (handles both simple and enhanced format) - defp extract_global_option_values(nil), do: [] - - defp extract_global_option_values(%{"options" => options}) when is_list(options) do - Enum.map(options, fn - opt when is_binary(opt) -> opt - %{"value" => value} -> value - _ -> nil - end) - |> Enum.reject(&is_nil/1) - end - - defp extract_global_option_values(_), do: [] - - # Private helpers - - # Collect options grouped by name (not position) - defp collect_options_by_name(acc, first_row, variant_rows) do - # Get option names from first row - option_names = - for i <- 1..@max_options, - name = get_option_name(first_row, i), - name != nil, - do: {i, name} - - # Collect values for each option name - Enum.reduce(option_names, acc, fn {position, name}, acc -> - # Get all values for this option from variant rows - values = - Enum.reduce(variant_rows, MapSet.new(), fn row, values_acc -> - case get_option_value(row, position) do - nil -> values_acc - "" -> values_acc - value -> MapSet.put(values_acc, value) - end - end) - - # Merge with existing values for this option name - existing = Map.get(acc, name, MapSet.new()) - Map.put(acc, name, MapSet.union(existing, values)) - end) - end - - defp get_option_name(row, position) do - key = "Option#{position} Name" - - case row[key] do - nil -> nil - "" -> nil - name -> String.trim(name) - end - end - - defp get_option_value(row, position) do - key = "Option#{position} Value" - - case row[key] do - nil -> nil - "" -> nil - value -> String.trim(value) - end - end - - defp has_price?(row) do - price = row["Variant Price"] - price != nil and price != "" - end -end diff --git a/lib/modules/shop/import/csv_parser.ex b/lib/modules/shop/import/csv_parser.ex deleted file mode 100644 index de5a5b891..000000000 --- a/lib/modules/shop/import/csv_parser.ex +++ /dev/null @@ -1,72 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Import.CSVParser do - @moduledoc """ - Parse Shopify CSV and group rows by Handle. - - Shopify CSV structure: - - First row with product data contains title, description, etc. - - Subsequent rows for same Handle contain variant data only (empty title/description) - - Each variant row has Option1/Option2 values and prices - """ - - NimbleCSV.define(ShopifyCSV, separator: ",", escape: "\"") - - @doc """ - Parse CSV file and group rows by Handle (product identifier). - - Returns a map where keys are handles and values are lists of row maps. - - ## Examples - - CSVParser.parse_and_group("/path/to/products.csv") - # => %{ - # "product-handle" => [ - # %{"Handle" => "product-handle", "Title" => "Product", ...}, - # %{"Handle" => "product-handle", "Option1 Value" => "Small", ...}, - # ... - # ], - # ... - # } - """ - def parse_and_group(file_path) do - {_headers, rows} = - file_path - |> File.stream!([:utf8]) - |> ShopifyCSV.parse_stream(skip_headers: false) - |> Enum.reduce({nil, []}, fn - row, {nil, []} -> - # First row is headers - {row, []} - - row, {headers, rows} -> - # Convert row to map using headers - row_map = - Enum.zip(headers, row) - |> Map.new() - - {headers, [row_map | rows]} - end) - - # Group by Handle and reverse to maintain order - rows - |> Enum.reverse() - |> Enum.group_by(& &1["Handle"]) - end - - @doc """ - Get the first (main) row for a product group. - Contains title, description, and other product-level data. - """ - def main_row(rows) when is_list(rows) do - List.first(rows) - end - - @doc """ - Get all variant rows (rows with price data). - """ - def variant_rows(rows) when is_list(rows) do - Enum.filter(rows, fn row -> - price = row["Variant Price"] - price != nil and price != "" - end) - end -end diff --git a/lib/modules/shop/import/csv_validator.ex b/lib/modules/shop/import/csv_validator.ex deleted file mode 100644 index 8440aacb0..000000000 --- a/lib/modules/shop/import/csv_validator.ex +++ /dev/null @@ -1,295 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Import.CSVValidator do - @moduledoc """ - Validates CSV files before import processing. - - Performs early validation to fail fast with meaningful errors - rather than discovering issues mid-import. - - ## Validation Checks - - 1. **File exists and readable** - Basic filesystem check - 2. **CSV parseable** - File is valid CSV format - 3. **Required columns present** - Checks for Handle, Title, Variant Price by default - 4. **Warning detection** - Non-blocking issues like empty rows - - ## Examples - - # Basic validation with default required columns - case CSVValidator.validate_file("/path/to/products.csv") do - :ok -> IO.puts("File is valid") - {:error, reason} -> IO.puts("Validation failed: \#{reason}") - end - - # Validation with custom required columns - CSVValidator.validate_headers("/path/to/products.csv", ["Handle", "Title", "Price"]) - - # Full validation report - report = CSVValidator.get_validation_report("/path/to/products.csv") - # => %{ - # valid: true, - # file_path: "/path/to/products.csv", - # headers: ["Handle", "Title", ...], - # row_count: 1234, - # warnings: ["Some rows have empty Handle values"] - # } - """ - - alias PhoenixKit.Modules.Shop.ImportConfig - - NimbleCSV.define(ValidatorCSV, separator: ",", escape: "\"") - - @default_required_columns ImportConfig.default_required_columns() - - @doc """ - Validates that a file exists, is readable, and has valid CSV format. - - Returns `:ok` or `{:error, reason}`. - """ - def validate_file(file_path) do - with :ok <- check_file_exists(file_path), - :ok <- check_file_readable(file_path) do - check_csv_parseable(file_path) - end - end - - @doc """ - Extracts and validates CSV headers against required columns. - - Uses default required columns: #{inspect(@default_required_columns)} - - Returns `{:ok, headers}` or `{:error, reason}`. - """ - def validate_headers(file_path) do - validate_headers(file_path, @default_required_columns) - end - - @doc """ - Extracts and validates CSV headers against custom required columns. - - Returns `{:ok, headers}` or `{:error, {:missing_columns, missing}}`. - """ - def validate_headers(file_path, required_columns) when is_list(required_columns) do - with :ok <- validate_file(file_path), - {:ok, headers} <- extract_headers(file_path) do - missing = find_missing_columns(headers, required_columns) - - if missing == [] do - {:ok, headers} - else - {:error, {:missing_columns, missing}} - end - end - end - - @doc """ - Returns a comprehensive validation report. - - ## Report Structure - - %{ - valid: boolean, - file_path: string, - file_size: integer, - headers: list | nil, - row_count: integer | nil, - missing_columns: list, - warnings: list, - error: string | nil - } - """ - def get_validation_report(file_path, opts \\ []) do - required_columns = Keyword.get(opts, :required_columns, @default_required_columns) - - report = %{ - valid: false, - file_path: file_path, - file_size: nil, - headers: nil, - row_count: nil, - missing_columns: [], - warnings: [], - error: nil - } - - with :ok <- check_file_exists(file_path), - {:ok, file_size} <- get_file_size(file_path), - :ok <- check_file_readable(file_path), - {:ok, headers} <- extract_headers(file_path), - {:ok, row_count} <- count_data_rows(file_path) do - missing = find_missing_columns(headers, required_columns) - warnings = detect_warnings(file_path, headers) - - %{ - report - | valid: missing == [], - file_size: file_size, - headers: headers, - row_count: row_count, - missing_columns: missing, - warnings: warnings - } - else - {:error, reason} -> - %{report | error: format_error(reason)} - end - end - - @doc """ - Extracts headers from a CSV file. - - Returns `{:ok, headers}` or `{:error, reason}`. - """ - def extract_headers(file_path) do - file_path - |> File.stream!([:utf8]) - |> ValidatorCSV.parse_stream(skip_headers: false) - |> Enum.take(1) - |> case do - [headers] when is_list(headers) -> - {:ok, headers} - - [] -> - {:error, :empty_file} - - _ -> - {:error, :invalid_csv_format} - end - rescue - e in NimbleCSV.ParseError -> - {:error, {:parse_error, Exception.message(e)}} - - e -> - {:error, {:unexpected_error, Exception.message(e)}} - end - - # ============================================ - # PRIVATE FUNCTIONS - # ============================================ - - defp check_file_exists(file_path) do - if File.exists?(file_path) do - :ok - else - {:error, :file_not_found} - end - end - - defp check_file_readable(file_path) do - # Try to open and read first few bytes to check readability - case File.open(file_path, [:read, :utf8]) do - {:ok, file} -> - result = IO.read(file, 1024) - File.close(file) - - case result do - {:error, reason} -> {:error, {:file_not_readable, reason}} - _ -> :ok - end - - {:error, reason} -> - {:error, {:file_not_readable, reason}} - end - end - - defp check_csv_parseable(file_path) do - file_path - |> File.stream!([:utf8]) - |> ValidatorCSV.parse_stream(skip_headers: false) - |> Enum.take(2) - |> case do - [_ | _] -> :ok - [] -> {:error, :empty_file} - end - rescue - e in NimbleCSV.ParseError -> - {:error, {:parse_error, Exception.message(e)}} - - _ -> - {:error, :invalid_csv_format} - end - - defp get_file_size(file_path) do - case File.stat(file_path) do - {:ok, %{size: size}} -> {:ok, size} - {:error, reason} -> {:error, {:stat_error, reason}} - end - end - - defp count_data_rows(file_path) do - count = - file_path - |> File.stream!([:utf8]) - |> ValidatorCSV.parse_stream(skip_headers: true) - |> Enum.count() - - {:ok, count} - rescue - _ -> {:ok, nil} - end - - defp find_missing_columns(headers, required_columns) do - headers_set = MapSet.new(headers) - - required_columns - |> Enum.reject(fn col -> MapSet.member?(headers_set, col) end) - end - - defp detect_warnings(file_path, headers) do - warnings = [] - - # Check for Handle column index - handle_index = Enum.find_index(headers, &(&1 == "Handle")) - - warnings = - if handle_index do - empty_handles = count_empty_handles(file_path, handle_index) - - if empty_handles > 0 do - ["#{empty_handles} rows have empty Handle values" | warnings] - else - warnings - end - else - warnings - end - - # Check for duplicate headers - duplicate_headers = find_duplicate_headers(headers) - - warnings = - if duplicate_headers != [] do - ["Duplicate headers found: #{Enum.join(duplicate_headers, ", ")}" | warnings] - else - warnings - end - - Enum.reverse(warnings) - end - - defp count_empty_handles(file_path, handle_index) do - file_path - |> File.stream!([:utf8]) - |> ValidatorCSV.parse_stream(skip_headers: true) - |> Enum.count(fn row -> - handle = Enum.at(row, handle_index, "") - handle == nil or String.trim(handle) == "" - end) - rescue - _ -> 0 - end - - defp find_duplicate_headers(headers) do - headers - |> Enum.frequencies() - |> Enum.filter(fn {_header, count} -> count > 1 end) - |> Enum.map(fn {header, _count} -> header end) - end - - defp format_error(:file_not_found), do: "File not found" - defp format_error(:empty_file), do: "File is empty" - defp format_error(:invalid_csv_format), do: "Invalid CSV format" - defp format_error({:file_not_readable, reason}), do: "File not readable: #{reason}" - defp format_error({:stat_error, reason}), do: "Cannot read file stats: #{reason}" - defp format_error({:parse_error, msg}), do: "CSV parse error: #{msg}" - defp format_error({:unexpected_error, msg}), do: "Unexpected error: #{msg}" -end diff --git a/lib/modules/shop/import/filter.ex b/lib/modules/shop/import/filter.ex deleted file mode 100644 index f4473fe67..000000000 --- a/lib/modules/shop/import/filter.ex +++ /dev/null @@ -1,201 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Import.Filter do - @moduledoc """ - Filter products for import based on configurable rules. - - Supports both legacy hardcoded keywords (for backward compatibility) - and configurable ImportConfig-based filtering. - - ## Configuration-based filtering - - config = Shop.get_default_import_config() - Filter.should_include?(rows, config) - Filter.categorize(title, config) - - ## Legacy filtering (backward compatible) - - Filter.should_include?(rows) # Uses hardcoded defaults - Filter.categorize(title) # Uses hardcoded defaults - """ - - alias PhoenixKit.Modules.Shop.ImportConfig - - # Legacy hardcoded defaults for backward compatibility - @default_include_keywords ~w(3d printed shelf mask vase planter holder stand lamp light figurine sculpture statue) - @default_exclude_keywords ~w(decal sticker mural wallpaper poster tapestry canvas) - @default_exclude_phrases ["wall art"] - - @default_category_rules [ - {["shelf"], "shelves"}, - {["mask"], "masks"}, - {["vase", "planter"], "vases-planters"}, - {["holder", "stand"], "holders-stands"}, - {["lamp", "light"], "lamps"}, - {["figurine", "sculpture", "statue"], "figurines"} - ] - - @default_category_slug "other-3d" - - # ============================================ - # SHOULD_INCLUDE? FUNCTIONS - # ============================================ - - @doc """ - Check if product should be included in import. - - ## With config - - Filter.should_include?(rows, config) - - Returns true if: - - Config has `skip_filter: true`, OR - - Title matches at least one include keyword AND - - Title does NOT match any exclude keyword/phrase - - ## Without config (legacy) - - Filter.should_include?(rows) - - Uses hardcoded default keywords for backward compatibility. - """ - def should_include?(rows, config \\ nil) - - def should_include?(rows, %ImportConfig{skip_filter: true}) when is_list(rows), do: true - - def should_include?(rows, %ImportConfig{} = config) when is_list(rows) do - first_row = List.first(rows) - title = first_row["Title"] || "" - handle = first_row["Handle"] || "" - - if skip_handle?(handle) do - false - else - has_include_match?(title, config) and not has_exclude_match?(title, config) - end - end - - def should_include?(rows, nil) when is_list(rows) do - # Legacy behavior: use hardcoded defaults - first_row = List.first(rows) - title = first_row["Title"] || "" - handle = first_row["Handle"] || "" - - if skip_handle?(handle) do - false - else - has_include_match_legacy?(title) and not has_exclude_match_legacy?(title) - end - end - - # ============================================ - # CATEGORIZE FUNCTIONS - # ============================================ - - @doc """ - Categorize product based on title keywords. - - ## With config - - Filter.categorize(title, config) - - Uses category_rules from config. Returns default_category_slug if no match. - - ## Without config (legacy) - - Filter.categorize(title) - - Uses hardcoded category rules. Returns "other-3d" if no match. - """ - def categorize(title, config \\ nil) - - def categorize(title, %ImportConfig{} = config) when is_binary(title) do - title_lower = String.downcase(title) - - find_category_from_config(title_lower, config) || config.default_category_slug || - @default_category_slug - end - - def categorize(title, nil) when is_binary(title) do - # Legacy behavior - title_lower = String.downcase(title) - find_category_legacy(title_lower) || @default_category_slug - end - - # ============================================ - # CONFIG-BASED HELPERS - # ============================================ - - defp has_include_match?(title, %ImportConfig{include_keywords: keywords}) do - if keywords == [] do - # No include keywords = include everything - true - else - title_lower = String.downcase(title) - Enum.any?(keywords, &String.contains?(title_lower, String.downcase(&1))) - end - end - - defp has_exclude_match?(title, %ImportConfig{ - exclude_keywords: keywords, - exclude_phrases: phrases - }) do - title_lower = String.downcase(title) - - has_keyword = Enum.any?(keywords || [], &String.contains?(title_lower, String.downcase(&1))) - has_phrase = Enum.any?(phrases || [], &String.contains?(title_lower, String.downcase(&1))) - - has_keyword or has_phrase - end - - defp find_category_from_config(title_lower, %ImportConfig{category_rules: rules}) - when is_list(rules) do - Enum.find_value(rules, fn rule -> - keywords = rule["keywords"] || rule[:keywords] || [] - slug = rule["slug"] || rule[:slug] - - if Enum.any?(keywords, fn kw -> String.contains?(title_lower, String.downcase(kw)) end) do - slug - end - end) - end - - defp find_category_from_config(_title_lower, _config), do: nil - - # ============================================ - # LEGACY HELPERS (backward compatibility) - # ============================================ - - defp has_include_match_legacy?(title) do - title_lower = String.downcase(title) - Enum.any?(@default_include_keywords, &String.contains?(title_lower, &1)) - end - - defp has_exclude_match_legacy?(title) do - title_lower = String.downcase(title) - - has_keyword = Enum.any?(@default_exclude_keywords, &String.contains?(title_lower, &1)) - has_phrase = Enum.any?(@default_exclude_phrases, &String.contains?(title_lower, &1)) - - has_keyword or has_phrase - end - - defp find_category_legacy(title_lower) do - Enum.find_value(@default_category_rules, fn {keywords, category} -> - if Enum.any?(keywords, &String.contains?(title_lower, &1)) do - category - end - end) - end - - # ============================================ - # SHARED HELPERS - # ============================================ - - defp skip_handle?(handle) do - handle_lower = String.downcase(handle) - - String.contains?(handle_lower, "shipping") or - String.contains?(handle_lower, "payment") or - String.contains?(handle_lower, "gift-card") or - String.contains?(handle_lower, "custom-order") - end -end diff --git a/lib/modules/shop/import/format_detector.ex b/lib/modules/shop/import/format_detector.ex deleted file mode 100644 index d64bd783f..000000000 --- a/lib/modules/shop/import/format_detector.ex +++ /dev/null @@ -1,39 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Import.FormatDetector do - @moduledoc """ - Auto-detect CSV format from file headers. - - Iterates known format modules and calls `detect?/1` on each. - First match wins, so order matters. - """ - - alias PhoenixKit.Modules.Shop.Import.CSVValidator - alias PhoenixKit.Modules.Shop.Import.{PromUaFormat, ShopifyFormat} - - # Ordered list — first match wins. - # PromUaFormat first because its markers are more specific (Ukrainian columns). - @formats [PromUaFormat, ShopifyFormat] - - @doc """ - Detect format module from CSV file path. - Returns `{:ok, format_module}` or `{:error, :unknown_format}`. - """ - def detect(path) do - case CSVValidator.extract_headers(path) do - {:ok, headers} -> detect_from_headers(headers) - {:error, _} = error -> error - end - end - - @doc "Detect format module from pre-extracted headers." - def detect_from_headers(headers) do - case Enum.find(@formats, & &1.detect?(headers)) do - nil -> {:error, :unknown_format} - mod -> {:ok, mod} - end - end - - @doc "Returns human-readable format name." - def format_name(ShopifyFormat), do: "Shopify" - def format_name(PromUaFormat), do: "Prom.ua" - def format_name(_), do: "Unknown" -end diff --git a/lib/modules/shop/import/import_format.ex b/lib/modules/shop/import/import_format.ex deleted file mode 100644 index 57deb2b40..000000000 --- a/lib/modules/shop/import/import_format.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Import.ImportFormat do - @moduledoc """ - Behaviour for CSV import format adapters. - - All CSV format modules (Shopify, Prom.ua, etc.) implement this behaviour - to provide a uniform interface for the import pipeline. - """ - - alias PhoenixKit.Modules.Shop.ImportConfig - - @doc "Returns true if the given CSV headers match this format." - @callback detect?(headers :: [String.t()]) :: boolean() - - @doc "Counts the number of products that will be imported from the file." - @callback count(path :: String.t(), config :: ImportConfig.t() | nil) :: non_neg_integer() - - @doc "Whether the :configure wizard step (option mapping UI) should be shown." - @callback requires_option_mapping?() :: boolean() - - @doc """ - Parses CSV and returns a list/stream of product attrs maps ready for `Shop.upsert_product/1`. - """ - @callback parse_and_transform( - path :: String.t(), - categories_map :: map(), - config :: ImportConfig.t() | nil, - opts :: keyword() - ) :: Enumerable.t() - - @doc "Returns default attrs for seeding an ImportConfig for this format." - @callback default_config_attrs() :: map() -end diff --git a/lib/modules/shop/import/option_builder.ex b/lib/modules/shop/import/option_builder.ex deleted file mode 100644 index fc77064df..000000000 --- a/lib/modules/shop/import/option_builder.ex +++ /dev/null @@ -1,280 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Import.OptionBuilder do - @moduledoc """ - Build option values and price modifiers from Shopify variant rows. - - Extracts Option1..Option10 names and values from CSV rows, - calculates base price (minimum) and price modifiers (deltas from base). - - ## Extended Support - - - Supports Option1 through Option10 (Shopify standard) - - Accepts option_mappings for slot-based options - - Builds _option_slots structure for products using global options - """ - - @max_options 10 - - @doc """ - Build options data from variant rows (legacy format). - - Returns a map with: - - base_price: minimum variant price (Decimal) - - option1_name: name of first option (e.g., "Size") - - option1_values: list of unique values for option1 - - option1_modifiers: map of value => price delta from base - - option2_name: name of second option (e.g., "Color") - - option2_values: list of unique values for option2 - - ## Examples - - OptionBuilder.build_from_variants(rows) - # => %{ - # base_price: Decimal.new("22.80"), - # option1_name: "Size", - # option1_values: ["4 inches (10 cm)", "5 inches (13 cm)", ...], - # option1_modifiers: %{"4 inches (10 cm)" => "0", "5 inches (13 cm)" => "5.00", ...}, - # option2_name: "Color", - # option2_values: ["Black", "White", ...] - # } - """ - def build_from_variants(rows) when is_list(rows) do - # Get option names from first row - first_row = List.first(rows) - option1_name = get_non_empty(first_row, "Option1 Name") - option2_name = get_non_empty(first_row, "Option2 Name") - - # Extract variants with prices - variants = - rows - |> Enum.map(fn row -> - %{ - option1_value: get_non_empty(row, "Option1 Value"), - option2_value: get_non_empty(row, "Option2 Value"), - price: parse_price(row["Variant Price"]) - } - end) - |> Enum.filter(& &1.price) - - # Calculate base price (minimum) - base_price = - variants - |> Enum.map(& &1.price) - |> Enum.min(fn -> Decimal.new("0") end) - - # Build option1 data (typically Size - affects price) - {option1_values, option1_modifiers} = build_option_data(variants, :option1_value, base_price) - - # Build option2 values (typically Color - no price impact, just values) - option2_values = get_unique_values(variants, :option2_value) - - %{ - base_price: base_price, - option1_name: option1_name, - option1_values: option1_values, - option1_modifiers: option1_modifiers, - option2_name: option2_name, - option2_values: option2_values - } - end - - @doc """ - Build extended options data from variant rows. - - Supports Option1 through Option10 and optional slot mappings. - - ## Arguments - - - `rows` - List of CSV row maps for a single product - - `opts` - Keyword options: - - `:option_mappings` - List of mapping configs from ImportConfig - - ## Returns - - Map with: - - `base_price` - Minimum variant price - - `options` - List of option data for each option found - - `option_slots` - Slot definitions if mappings provided - - ## Examples - - # Without mappings (standard import) - OptionBuilder.build_extended(rows) - # => %{ - # base_price: Decimal.new("22.80"), - # options: [ - # %{position: 1, name: "Size", values: [...], modifiers: %{...}}, - # %{position: 2, name: "Cup Color", values: [...]}, - # %{position: 3, name: "Liquid Color", values: [...]} - # ], - # option_slots: [] - # } - - # With mappings (slot-based import) - mappings = [ - %{"csv_name" => "Cup Color", "slot_key" => "cup_color", "source_key" => "color"}, - %{"csv_name" => "Liquid Color", "slot_key" => "liquid_color", "source_key" => "color"} - ] - OptionBuilder.build_extended(rows, option_mappings: mappings) - # => %{ - # base_price: Decimal.new("22.80"), - # options: [...], - # option_slots: [ - # %{slot: "cup_color", source_key: "color", label: "Cup Color", values: [...]}, - # %{slot: "liquid_color", source_key: "color", label: "Liquid Color", values: [...]} - # ] - # } - """ - def build_extended(rows, opts \\ []) when is_list(rows) do - option_mappings = Keyword.get(opts, :option_mappings, []) - first_row = List.first(rows) - - # Extract variants with prices and all option values - variants = extract_all_variants(rows) - - # Calculate base price (minimum) - base_price = - variants - |> Enum.map(& &1.price) - |> Enum.filter(& &1) - |> Enum.min(fn -> Decimal.new("0") end) - - # Build option data for each option position - options = - for i <- 1..@max_options, - name = get_option_name(first_row, i), - name != nil do - field = String.to_atom("option#{i}_value") - {values, modifiers} = build_option_data(variants, field, base_price) - - # Only include modifiers if they have non-zero values - has_price_impact = Enum.any?(modifiers, fn {_k, v} -> v != "0" end) - - %{ - position: i, - name: name, - values: values, - modifiers: if(has_price_impact, do: modifiers, else: %{}) - } - end - - # Build option slots from mappings - option_slots = build_option_slots_from_mappings(options, option_mappings) - - %{ - base_price: base_price, - options: options, - option_slots: option_slots - } - end - - # Extract all option values (Option1..Option10) from variant rows - defp extract_all_variants(rows) do - Enum.map(rows, fn row -> - base = %{price: parse_price(row["Variant Price"])} - - # Add option values for each position - Enum.reduce(1..@max_options, base, fn i, acc -> - key = String.to_atom("option#{i}_value") - value = get_non_empty(row, "Option#{i} Value") - Map.put(acc, key, value) - end) - end) - |> Enum.filter(& &1.price) - end - - # Get option name for a position - defp get_option_name(row, position) do - get_non_empty(row, "Option#{position} Name") - end - - # Build option slots from mappings - defp build_option_slots_from_mappings(options, mappings) when is_list(mappings) do - mappings - |> Enum.map(fn mapping -> - csv_name = mapping["csv_name"] - slot_key = mapping["slot_key"] - source_key = mapping["source_key"] - label = mapping["label"] || csv_name - - # Find the option with matching name - option = Enum.find(options, fn opt -> opt.name == csv_name end) - - if option && slot_key do - %{ - slot: slot_key, - source_key: source_key, - label: label, - values: option.values, - position: option.position - } - else - nil - end - end) - |> Enum.reject(&is_nil/1) - end - - defp build_option_slots_from_mappings(_, _), do: [] - - # Private helpers - - defp get_non_empty(row, key) do - case row[key] do - nil -> nil - "" -> nil - value -> String.trim(value) - end - end - - defp parse_price(nil), do: nil - defp parse_price(""), do: nil - - defp parse_price(str) when is_binary(str) do - str = String.trim(str) - - case Decimal.parse(str) do - {decimal, _} -> decimal - :error -> nil - end - end - - defp build_option_data(variants, field, base_price) do - # Get unique values preserving order of first appearance - values = - variants - |> Enum.map(&Map.get(&1, field)) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - - # Build price modifiers for each value - # Group by value and take the first price for each - price_by_value = - variants - |> Enum.reduce(%{}, fn v, acc -> - value = Map.get(v, field) - - if value && !Map.has_key?(acc, value) do - Map.put(acc, value, v.price) - else - acc - end - end) - - # Calculate modifiers as delta from base price - modifiers = - price_by_value - |> Enum.reduce(%{}, fn {value, price}, acc -> - modifier = Decimal.sub(price, base_price) - Map.put(acc, value, Decimal.to_string(modifier)) - end) - - {values, modifiers} - end - - defp get_unique_values(variants, field) do - variants - |> Enum.map(&Map.get(&1, field)) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - end -end diff --git a/lib/modules/shop/import/product_transformer.ex b/lib/modules/shop/import/product_transformer.ex deleted file mode 100644 index 933b28061..000000000 --- a/lib/modules/shop/import/product_transformer.ex +++ /dev/null @@ -1,473 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Import.ProductTransformer do - @moduledoc """ - Transform Shopify CSV rows into PhoenixKit Product format. - - Handles: - - Basic product fields (title, description, price, etc.) - - Option values and price modifiers in metadata - - Slot-based options with global option mapping - - Category assignment based on title keywords (configurable) - - Image collection - - Auto-creation of missing categories - - ## Extended Transform - - Use `transform_extended/5` with `option_mappings` to enable slot-based - options that reference global options. This allows multiple uses of the - same global option in a product (e.g., cup_color and liquid_color both - referencing the "color" global option). - """ - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Import.{Filter, OptionBuilder} - alias PhoenixKit.Modules.Shop.SlugResolver - alias PhoenixKit.Modules.Shop.Translations - - require Logger - - @doc """ - Transform a group of CSV rows (one product) into Product attrs. - - ## Arguments - - - handle: Product handle (slug) - - rows: List of CSV row maps for this product - - categories_map: Map of slug => category_uuid - - config: Optional ImportConfig for category rules (nil = legacy defaults) - - opts: Keyword options: - - `:language` - Target language for imported content (default: system default language) - - ## Returns - - Map suitable for `Shop.create_product/1` - """ - def transform(handle, rows, categories_map \\ %{}, config \\ nil, opts \\ []) do - first_row = List.first(rows) - options = OptionBuilder.build_from_variants(rows) - - # Get target language for localized fields - language = Keyword.get(opts, :language, Translations.default_language()) - - # Determine category using config or legacy defaults - title = first_row["Title"] || "" - category_slug = Filter.categorize(title, config) - - # Get category_uuid, auto-creating if necessary (with localized name/slug) - category_uuid = resolve_category_uuid(category_slug, categories_map, language) - - # Build metadata with option values and price modifiers - metadata = build_metadata(options) - - # Extract non-localized values - body_html_raw = first_row["Body (HTML)"] - description_raw = extract_description(body_html_raw) - seo_title_raw = get_non_empty(first_row, "SEO Title") - seo_description_raw = get_non_empty(first_row, "SEO Description") - - %{ - # Localized fields - stored as maps with language key - slug: localized_map(handle, language), - title: localized_map(title, language), - body_html: localized_map(body_html_raw, language), - description: localized_map(description_raw, language), - seo_title: localized_map(seo_title_raw, language), - seo_description: localized_map(seo_description_raw, language), - # Non-localized fields - vendor: get_non_empty(first_row, "Vendor"), - tags: parse_tags(first_row["Tags"]), - status: parse_status(first_row["Published"]), - price: options.base_price, - product_type: "physical", - requires_shipping: true, - taxable: true, - featured_image: find_featured_image(rows), - images: collect_images(rows), - category_uuid: category_uuid, - metadata: metadata - } - end - - # Build a localized field map for a single value - # Idempotent: if value is already a map with string keys, return as-is - defp localized_map(nil, _language), do: %{} - defp localized_map("", _language), do: %{} - - defp localized_map(value, _language) when is_map(value) do - # Already a localized map - return as-is to prevent double-wrapping - value - end - - defp localized_map(value, language) when is_binary(value), do: %{language => value} - - @doc """ - Resolves category UUID from slug, auto-creating if necessary. - - If category doesn't exist, creates it with: - - name: Generated from slug (capitalize, replace hyphens with spaces) - localized map - - status: "active" - - slug: The original slug - localized map - - ## Arguments - - - category_slug: The slug string to look up - - categories_map: Map of slug => category_uuid - - language: Target language for localized fields (default: system default) - """ - def resolve_category_uuid(category_slug, categories_map, language \\ nil) - - def resolve_category_uuid(category_slug, categories_map, language) - when is_binary(category_slug) do - lang = language || Translations.default_language() - - case Map.get(categories_map, category_slug) do - nil -> - # Category doesn't exist - try to create it - maybe_create_category(category_slug, lang) - - category_uuid -> - category_uuid - end - end - - def resolve_category_uuid(_, _, _), do: nil - - defp maybe_create_category(slug, language) when is_binary(slug) and slug != "" do - # First check if category already exists (using localized slug search) - case Shop.get_category_by_slug_localized(slug, language) do - {:ok, %{uuid: uuid}} -> - # Category exists, return its uuid - uuid - - {:error, :not_found} -> - # Category doesn't exist - create it - create_new_category(slug, language) - end - end - - defp create_new_category(slug, language) do - # Normalize language to dialect format (e.g., "en" -> "en-US") - # to match how SlugResolver queries slug JSONB fields - normalized_lang = SlugResolver.normalize_language_public(language) - - # Generate name from slug: "vases-planters" -> "Vases Planters" - name = - slug - |> String.replace("-", " ") - |> String.split(" ") - |> Enum.map_join(" ", &String.capitalize/1) - - # Create localized attributes with normalized language key - attrs = %{ - name: %{normalized_lang => name}, - slug: %{normalized_lang => slug}, - status: "active" - } - - case Shop.create_category(attrs) do - {:ok, category} -> - Logger.info( - "Auto-created category: #{slug} (uuid: #{category.uuid}) with language: #{normalized_lang}" - ) - - category.uuid - - {:error, _changeset} -> - # Unique constraint hit - category was created by concurrent process, fetch it - case Shop.get_category_by_slug_localized(slug, language) do - {:ok, %{uuid: uuid}} -> - Logger.info("Category #{slug} already exists (uuid: #{uuid}), using existing") - uuid - - {:error, :not_found} -> - Logger.warning("Failed to create or find category: #{slug}") - nil - end - end - end - - @doc """ - Build an updated categories_map including any auto-created categories. - - Call this after transform() to update the map for subsequent products. - """ - def update_categories_map(categories_map, category_slug) when is_binary(category_slug) do - if Map.has_key?(categories_map, category_slug) do - categories_map - else - case Shop.get_category_by_slug(category_slug) do - nil -> categories_map - category -> Map.put(categories_map, category_slug, category.uuid) - end - end - end - - def update_categories_map(categories_map, _), do: categories_map - - @doc """ - Transform with extended options support (Option3..N and slot mappings). - - ## Arguments - - - handle: Product handle (slug) - - rows: List of CSV row maps for this product - - categories_map: Map of slug => category_uuid - - config: Optional ImportConfig for category rules and option mappings - - opts: Keyword options: - - `:language` - Target language for imported content - - `:option_mappings` - Explicit option mappings (overrides config) - - ## Returns - - Map suitable for `Shop.create_product/1` with slot-based metadata if mappings provided. - """ - def transform_extended(handle, rows, categories_map \\ %{}, config \\ nil, opts \\ []) do - first_row = List.first(rows) - - # Get option mappings from opts or config - option_mappings = get_option_mappings(config, opts) - - # Build extended options with mappings support - options = OptionBuilder.build_extended(rows, option_mappings: option_mappings) - - # Get target language for localized fields - language = Keyword.get(opts, :language, Translations.default_language()) - - # Determine category using config or legacy defaults - title = first_row["Title"] || "" - category_slug = Filter.categorize(title, config) - - # Get category_uuid, auto-creating if necessary (with localized name/slug) - category_uuid = resolve_category_uuid(category_slug, categories_map, language) - - # Build metadata with slot-based option structure - metadata = build_metadata_extended(options) - - # Extract non-localized values - body_html_raw = first_row["Body (HTML)"] - description_raw = extract_description(body_html_raw) - seo_title_raw = get_non_empty(first_row, "SEO Title") - seo_description_raw = get_non_empty(first_row, "SEO Description") - - %{ - # Localized fields - stored as maps with language key - slug: localized_map(handle, language), - title: localized_map(title, language), - body_html: localized_map(body_html_raw, language), - description: localized_map(description_raw, language), - seo_title: localized_map(seo_title_raw, language), - seo_description: localized_map(seo_description_raw, language), - # Non-localized fields - vendor: get_non_empty(first_row, "Vendor"), - tags: parse_tags(first_row["Tags"]), - status: parse_status(first_row["Published"]), - price: options.base_price, - product_type: "physical", - requires_shipping: true, - taxable: true, - featured_image: find_featured_image(rows), - images: collect_images(rows), - category_uuid: category_uuid, - metadata: metadata - } - end - - # Get option mappings from config or opts - defp get_option_mappings(config, opts) do - explicit_mappings = Keyword.get(opts, :option_mappings) - - cond do - is_list(explicit_mappings) and explicit_mappings != [] -> - explicit_mappings - - config != nil and is_list(config.option_mappings) -> - config.option_mappings - - true -> - [] - end - end - - # Build metadata from extended options data - defp build_metadata_extended(%{options: options, option_slots: option_slots}) do - result = %{} - - # Build _option_values from all options - option_values = - options - |> Enum.filter(fn opt -> opt.values != [] end) - |> Enum.reduce(%{}, fn opt, acc -> - key = normalize_key(opt.name) - Map.put(acc, key, opt.values) - end) - - result = - if option_values != %{} do - Map.put(result, "_option_values", option_values) - else - result - end - - # Build _price_modifiers from options that have modifiers - price_modifiers = - options - |> Enum.filter(fn opt -> opt.modifiers != %{} end) - |> Enum.reduce(%{}, fn opt, acc -> - key = normalize_key(opt.name) - Map.put(acc, key, opt.modifiers) - end) - - result = - if price_modifiers != %{} do - Map.put(result, "_price_modifiers", price_modifiers) - else - result - end - - # Build _option_slots from slot mappings - slots = - option_slots - |> Enum.map(fn slot -> - %{ - "slot" => slot.slot, - "source_key" => slot.source_key, - "label" => slot.label - } - end) - - result = - if slots != [] do - # Also update _option_values to use slot keys instead of CSV names - slot_option_values = - option_slots - |> Enum.filter(fn slot -> slot.values != [] end) - |> Enum.reduce(%{}, fn slot, acc -> - Map.put(acc, slot.slot, slot.values) - end) - - result - |> Map.put("_option_slots", slots) - |> Map.update("_option_values", slot_option_values, fn existing -> - Map.merge(existing, slot_option_values) - end) - else - result - end - - result - end - - # Private helpers - - defp build_metadata(options) do - option_values = %{} - price_modifiers = %{} - - # Option1 (typically Size) - affects price - {option_values, price_modifiers} = - if options.option1_name && options.option1_values != [] do - key = normalize_key(options.option1_name) - - ov = Map.put(option_values, key, options.option1_values) - - pm = - if options.option1_modifiers != %{} do - Map.put(price_modifiers, key, options.option1_modifiers) - else - price_modifiers - end - - {ov, pm} - else - {option_values, price_modifiers} - end - - # Option2 (typically Color) - no price impact, just values - option_values = - if options.option2_name && options.option2_values != [] do - key = normalize_key(options.option2_name) - Map.put(option_values, key, options.option2_values) - else - option_values - end - - result = %{} - - result = - if option_values != %{} do - Map.put(result, "_option_values", option_values) - else - result - end - - result = - if price_modifiers != %{} do - Map.put(result, "_price_modifiers", price_modifiers) - else - result - end - - result - end - - defp normalize_key(name) do - name - |> String.downcase() - |> String.replace(~r/\s+/, "_") - |> String.replace(~r/[^a-z0-9_]/, "") - end - - defp get_non_empty(row, key) do - case row[key] do - nil -> nil - "" -> nil - value -> String.trim(value) - end - end - - defp parse_tags(nil), do: [] - defp parse_tags(""), do: [] - - defp parse_tags(tags) do - tags - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - end - - defp parse_status("true"), do: "active" - defp parse_status("TRUE"), do: "active" - defp parse_status(_), do: "draft" - - defp extract_description(nil), do: nil - defp extract_description(""), do: nil - - defp extract_description(html) do - # Extract first paragraph as description (strip HTML tags) - html - |> String.replace(~r/<[^>]+>/, " ") - |> String.replace(~r/\s+/, " ") - |> String.trim() - |> String.slice(0, 500) - end - - defp find_featured_image(rows) do - # Find image with position 1, or first image - featured = - Enum.find(rows, fn row -> - row["Image Position"] == "1" - end) - - case featured do - nil -> get_non_empty(List.first(rows), "Image Src") - row -> get_non_empty(row, "Image Src") - end - end - - defp collect_images(rows) do - rows - |> Enum.map(fn row -> get_non_empty(row, "Image Src") end) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - |> Enum.map(fn url -> %{"src" => url} end) - end -end diff --git a/lib/modules/shop/import/prom_ua_format.ex b/lib/modules/shop/import/prom_ua_format.ex deleted file mode 100644 index c98a6e0ce..000000000 --- a/lib/modules/shop/import/prom_ua_format.ex +++ /dev/null @@ -1,417 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Import.PromUaFormat do - @moduledoc """ - Prom.ua CSV format adapter implementing `ImportFormat` behaviour. - - Handles the Prom.ua export format: - - One row = one product (no variant grouping) - - Bilingual: Russian + Ukrainian titles/descriptions - - Multiple images comma-separated in a single column - - Ukrainian column names - - Category by name (`Назва_групи`) - - Prices in UAH - """ - - @behaviour PhoenixKit.Modules.Shop.Import.ImportFormat - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Translations - - require Logger - - # Prom.ua CSVs use comma separator (standard CSV) - NimbleCSV.define(PromUaCSV, separator: ",", escape: "\"") - - @marker_columns ["Назва_позиції", "Ціна", "Номер_групи"] - - @impl true - def detect?(headers) do - header_set = MapSet.new(headers) - Enum.all?(@marker_columns, &MapSet.member?(header_set, &1)) - end - - @impl true - def requires_option_mapping?, do: false - - @impl true - def count(path, _config) do - parse_rows(path) |> length() - end - - @impl true - def parse_and_transform(path, categories_map, _config, _opts) do - parse_rows(path) - |> Enum.map(fn row -> transform_row(row, categories_map) end) - end - - @impl true - def default_config_attrs do - %{ - name: "prom_ua_default", - skip_filter: true, - category_rules: [], - required_columns: ["Назва_позиції", "Ціна"], - is_default: false, - active: true, - download_images: true, - include_keywords: [], - exclude_keywords: [], - exclude_phrases: [], - option_mappings: [] - } - end - - # ============================================ - # PARSING - # ============================================ - - defp parse_rows(path) do - {headers, rows} = - path - |> File.stream!([:utf8]) - |> PromUaCSV.parse_stream(skip_headers: false) - |> Enum.reduce({nil, []}, fn - row, {nil, []} -> - {row, []} - - row, {headers, acc} -> - # Pad row to match header length (handles short rows) - padded = pad_row(row, length(headers)) - row_map = Enum.zip(headers, padded) |> Map.new() - {headers, [row_map | acc]} - end) - - if headers == nil do - [] - else - rows - |> Enum.reverse() - |> Enum.filter(fn row -> - name = row["Назва_позиції"] || "" - String.trim(name) != "" - end) - end - end - - defp pad_row(row, target_length) when length(row) >= target_length, do: row - - defp pad_row(row, target_length) do - row ++ List.duplicate("", target_length - length(row)) - end - - # ============================================ - # TRANSFORMATION - # ============================================ - - defp transform_row(row, categories_map) do - slug = extract_slug(row) - category_uuid = resolve_category(row, categories_map) - {price, compare_at_price} = parse_price_and_discount(row) - image_urls = parse_image_urls(row["Посилання_зображення"]) - images = Enum.map(image_urls, fn url -> %{"src" => url} end) - - %{ - slug: bilingual_map(slug), - title: - localized_map( - non_empty(row["Назва_позиції"]) || "", - non_empty(row["Назва_позиції_укр"]) || "" - ), - body_html: localized_map(row["Опис"] || "", row["Опис_укр"] || ""), - description: - localized_map(extract_description(row["Опис"]), extract_description(row["Опис_укр"])), - seo_title: - localized_map( - non_empty(row["HTML_заголовок"]) || "", - non_empty(row["HTML_заголовок_укр"]) || "" - ), - seo_description: - localized_map(non_empty(row["HTML_опис"]) || "", non_empty(row["HTML_опис_укр"]) || ""), - vendor: non_empty(row["Виробник"]), - tags: parse_tags(row["Пошукові_запити"]), - status: parse_availability(row["Наявність"]), - price: price, - compare_at_price: compare_at_price, - product_type: "physical", - requires_shipping: true, - taxable: true, - featured_image: List.first(image_urls), - images: images, - category_uuid: category_uuid, - weight_grams: parse_weight(row["Вага,кг"]), - metadata: build_metadata(row) - } - end - - # ============================================ - # SLUG - # ============================================ - - defp extract_slug(row) do - url = row["Продукт_на_сайті"] || "" - - slug = - case Regex.run(~r|/p\d+-(.+?)\.html|, url) do - [_, slug_part] -> slug_part - _ -> nil - end - - slug = slug || fallback_slug(row) - slug - end - - defp fallback_slug(row) do - uid = non_empty(row["Унікальний_ідентифікатор"]) - - if uid do - "prom-#{uid}" - else - # Last resort: generate from product name - name = row["Назва_позиції"] || "product" - - name - |> String.downcase() - |> String.replace(~r/[^a-z0-9\s-]/, "") - |> String.replace(~r/\s+/, "-") - |> String.slice(0, 60) - end - end - - defp bilingual_map(value) do - default_lang = Translations.default_language() - - %{"ru" => value, "uk" => value} - |> maybe_put_default_lang(default_lang, value) - end - - # Ensure the system's default language key is always present in localized maps. - # Uses the Russian value as fallback for the default language. - defp localized_map(ru_value, uk_value) do - default_lang = Translations.default_language() - - %{"ru" => ru_value, "uk" => uk_value} - |> maybe_put_default_lang(default_lang, ru_value) - end - - defp maybe_put_default_lang(map, lang, _fallback) when lang in ["ru", "uk"], do: map - defp maybe_put_default_lang(map, lang, fallback), do: Map.put_new(map, lang, fallback) - - # ============================================ - # CATEGORY - # ============================================ - - defp resolve_category(row, categories_map) do - group_name = non_empty(row["Назва_групи"]) - group_number = non_empty(row["Номер_групи"]) - - if group_name do - # Build a slug from the group number for lookup - category_slug = if group_number, do: "group-#{group_number}", else: slugify(group_name) - - case Map.get(categories_map, category_slug) do - nil -> - # Auto-create category with ru name and generated slug - maybe_create_prom_category(group_name, category_slug) - - category_uuid -> - category_uuid - end - else - nil - end - end - - defp maybe_create_prom_category(group_name, slug) do - lang = Translations.default_language() - - case Shop.get_category_by_slug_localized(slug, lang) do - {:ok, %{uuid: uuid}} -> - uuid - - {:error, :not_found} -> - attrs = %{ - name: localized_map(group_name, group_name), - slug: localized_map(slug, slug), - status: "active" - } - - case Shop.create_category(attrs) do - {:ok, category} -> - Logger.info("Auto-created Prom.ua category: #{slug} (#{group_name})") - category.uuid - - {:error, changeset} -> - Logger.warning("Failed to create category #{slug}: #{inspect(changeset.errors)}") - nil - end - end - end - - defp slugify(name) do - name - |> String.downcase() - |> String.replace(~r/[^a-zа-яёіїєґ0-9\s-]/u, "") - |> String.replace(~r/\s+/, "-") - |> String.slice(0, 80) - end - - # ============================================ - # PRICE & DISCOUNT - # ============================================ - - defp parse_price_and_discount(row) do - price_str = row["Ціна"] || "0" - discount_str = row["Знижка"] || "" - - price = - case Decimal.parse(String.trim(price_str)) do - {decimal, _} -> decimal - :error -> Decimal.new(0) - end - - compare_at_price = calculate_compare_at_price(price, String.trim(discount_str)) - - {price, compare_at_price} - end - - defp calculate_compare_at_price(_price, ""), do: nil - - defp calculate_compare_at_price(price, discount_str) do - if String.ends_with?(discount_str, "%") do - # Percentage discount: "10%", "15%", "20%" - percent_str = String.trim_trailing(discount_str, "%") - - case Decimal.parse(percent_str) do - {percent, _} -> - divisor = Decimal.sub(Decimal.new(1), Decimal.div(percent, Decimal.new(100))) - - if Decimal.gt?(divisor, Decimal.new(0)) do - Decimal.div(price, divisor) |> Decimal.round(2) - else - nil - end - - :error -> - nil - end - else - # Absolute discount: "1550.00", "360.00" - case Decimal.parse(discount_str) do - {absolute_discount, _} -> - if Decimal.gt?(absolute_discount, Decimal.new(0)) do - Decimal.add(price, absolute_discount) - else - nil - end - - :error -> - nil - end - end - end - - # ============================================ - # IMAGES - # ============================================ - - defp parse_image_urls(nil), do: [] - defp parse_image_urls(""), do: [] - - defp parse_image_urls(urls_string) do - urls_string - |> String.split(", ") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - end - - # ============================================ - # AVAILABILITY - # ============================================ - - defp parse_availability(nil), do: "draft" - defp parse_availability(""), do: "draft" - - defp parse_availability(value) do - trimmed = String.trim(value) - - cond do - trimmed in ["+", "!", "@"] -> "active" - trimmed == "-" || trimmed == "0" -> "draft" - # Numeric values > 0 mean in stock - match?({n, ""} when n > 0, Integer.parse(trimmed)) -> "active" - true -> "draft" - end - end - - # ============================================ - # WEIGHT - # ============================================ - - defp parse_weight(nil), do: nil - defp parse_weight(""), do: nil - - defp parse_weight(kg_str) do - case Float.parse(String.trim(kg_str)) do - {kg, _} -> round(kg * 1000) - :error -> nil - end - end - - # ============================================ - # TAGS - # ============================================ - - defp parse_tags(nil), do: [] - defp parse_tags(""), do: [] - - defp parse_tags(tags_str) do - tags_str - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - end - - # ============================================ - # DESCRIPTION - # ============================================ - - defp extract_description(nil), do: "" - defp extract_description(""), do: "" - - defp extract_description(html) do - html - |> String.replace(~r/<[^>]+>/, " ") - |> String.replace(~r/&[a-z]+;/, " ") - |> String.replace(~r/\s+/, " ") - |> String.trim() - |> String.slice(0, 500) - end - - # ============================================ - # METADATA - # ============================================ - - defp build_metadata(row) do - metadata = %{} - - metadata = put_if_present(metadata, "sku", row["Код_товару"]) - metadata = put_if_present(metadata, "prom_id", row["Ідентифікатор_товару"]) - metadata = put_if_present(metadata, "prom_uid", row["Унікальний_ідентифікатор"]) - metadata = put_if_present(metadata, "country", row["Країна_виробник"]) - metadata = put_if_present(metadata, "currency", row["Валюта"]) - metadata = put_if_present(metadata, "group_id", row["Номер_групи"]) - - metadata - end - - defp put_if_present(map, _key, nil), do: map - defp put_if_present(map, _key, ""), do: map - defp put_if_present(map, key, value), do: Map.put(map, key, String.trim(value)) - - # ============================================ - # HELPERS - # ============================================ - - defp non_empty(nil), do: nil - defp non_empty(""), do: nil - defp non_empty(value), do: String.trim(value) -end diff --git a/lib/modules/shop/import/shopify_csv.ex b/lib/modules/shop/import/shopify_csv.ex deleted file mode 100644 index 9f09f1c7d..000000000 --- a/lib/modules/shop/import/shopify_csv.ex +++ /dev/null @@ -1,329 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Import.ShopifyCSV do - @moduledoc """ - Main orchestrator for Shopify CSV import. - - Coordinates CSV parsing, validation, filtering, transformation, and product creation. - - ## Usage - - # Dry run - see what would be imported - ShopifyCSV.import("/path/to/products.csv", dry_run: true) - - # Full import - ShopifyCSV.import("/path/to/products.csv") - - # Import with custom config - config = Shop.get_import_config!(config_uuid) - ShopifyCSV.import("/path/to/products.csv", config: config) - - # Import to specific category - category = Shop.get_category_by_slug("shelves") - ShopifyCSV.import("/path/to/products.csv", category_uuid: category.uuid) - """ - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Import.{CSVParser, CSVValidator, Filter, ProductTransformer} - alias PhoenixKit.Modules.Shop.ImportConfig - alias PhoenixKit.Modules.Shop.Translations - - require Logger - - @doc """ - Import products from Shopify CSV file. - - ## Options - - - `:dry_run` - If true, don't create products, just return what would be created - - `:category_uuid` - Override category for all products - - `:skip_existing` - If true, skip products with existing slugs (default: true) - - `:update_existing` - If true, update existing products instead of skipping (default: false) - - `:config` - ImportConfig struct for filtering/categorization (nil = use defaults) - - `:validate` - If true, validate CSV before import (default: true) - - Note: When `update_existing: true`, `skip_existing` is ignored. - - ## Returns - - Summary map with: - - `:imported` - count of newly created products - - `:updated` - count of updated existing products - - `:skipped` - count of skipped (existing or filtered out) - - `:errors` - count of failed imports - - `:dry_run` - count of products in dry run - - `:error_details` - list of error tuples - - `:validation_report` - CSV validation report (if validate: true) - """ - def import(file_path, opts \\ []) do - dry_run = Keyword.get(opts, :dry_run, false) - category_uuid = Keyword.get(opts, :category_uuid) - skip_existing = Keyword.get(opts, :skip_existing, true) - update_existing = Keyword.get(opts, :update_existing, false) - config = Keyword.get(opts, :config) - validate = Keyword.get(opts, :validate, true) - language = Keyword.get(opts, :language) - - # Get required columns from config if provided - required_columns = get_required_columns(config) - - # Validate CSV first if requested - validation_result = - if validate do - case CSVValidator.validate_headers(file_path, required_columns) do - {:ok, _headers} -> - {:ok, - CSVValidator.get_validation_report(file_path, required_columns: required_columns)} - - {:error, reason} -> - {:error, reason} - end - else - {:ok, nil} - end - - case validation_result do - {:error, reason} -> - %{ - imported: 0, - updated: 0, - dry_run: 0, - skipped: 0, - errors: 1, - error_details: [{:validation_failed, format_validation_error(reason)}], - validation_report: nil - } - - {:ok, validation_report} -> - do_import(file_path, %{ - dry_run: dry_run, - category_uuid: category_uuid, - skip_existing: skip_existing, - update_existing: update_existing, - config: config, - validation_report: validation_report, - language: language - }) - end - end - - defp get_required_columns(%ImportConfig{required_columns: cols}) when is_list(cols), do: cols - defp get_required_columns(_), do: ImportConfig.default_required_columns() - - defp format_validation_error({:missing_columns, cols}), - do: "Missing columns: #{Enum.join(cols, ", ")}" - - defp format_validation_error(:file_not_found), do: "File not found" - defp format_validation_error(:empty_file), do: "File is empty" - defp format_validation_error({:parse_error, msg}), do: "CSV parse error: #{msg}" - defp format_validation_error(other), do: inspect(other) - - defp do_import(file_path, opts) do - %{ - dry_run: dry_run, - category_uuid: category_uuid, - skip_existing: skip_existing, - update_existing: update_existing, - config: config, - validation_report: validation_report, - language: language - } = opts - - # Build categories map for auto-assignment - categories_map = build_categories_map() - - # Parse and group CSV - Logger.info("Parsing CSV: #{file_path}") - grouped = CSVParser.parse_and_group(file_path) - Logger.info("Found #{map_size(grouped)} unique handles") - - # Filter and import - results = - grouped - |> Enum.map(fn {handle, rows} -> - process_product(handle, rows, %{ - dry_run: dry_run, - category_uuid: category_uuid, - categories_map: categories_map, - skip_existing: skip_existing, - update_existing: update_existing, - config: config, - language: language - }) - end) - - summary = summarize(results) - Map.put(summary, :validation_report, validation_report) - end - - @doc """ - Quick dry run - just parse and filter, show what would be imported. - """ - def preview(file_path, opts \\ []) do - config = Keyword.get(opts, :config) - - grouped = CSVParser.parse_and_group(file_path) - - filtered = - grouped - |> Enum.filter(fn {_handle, rows} -> Filter.should_include?(rows, config) end) - - Logger.info("Total products in CSV: #{map_size(grouped)}") - Logger.info("Would import (matching filter): #{length(filtered)}") - Logger.info("Would skip (filtered out): #{map_size(grouped) - length(filtered)}") - - # Show sample - filtered - |> Enum.take(5) - |> Enum.each(fn {handle, rows} -> - first = List.first(rows) - category = Filter.categorize(first["Title"] || "", config) - Logger.info(" #{handle} -> #{category}") - end) - - %{ - total: map_size(grouped), - would_import: length(filtered), - would_skip: map_size(grouped) - length(filtered) - } - end - - @doc """ - Validates CSV file without importing. - - Returns validation report with headers, row count, and any warnings. - """ - def validate(file_path, opts \\ []) do - config = Keyword.get(opts, :config) - required_columns = get_required_columns(config) - - CSVValidator.get_validation_report(file_path, required_columns: required_columns) - end - - # Private helpers - - defp build_categories_map do - lang = Translations.default_language() - - Shop.list_categories() - |> Enum.reduce(%{}, fn cat, acc -> - # Extract string slug from JSONB map for map key - slug = Translations.get(cat, :slug, lang) - - if slug && slug != "" do - Map.put(acc, slug, cat.uuid) - else - acc - end - end) - end - - defp process_product(handle, rows, opts) do - config = opts.config - - if Filter.should_include?(rows, config) do - do_process_product(handle, rows, opts) - else - {:skipped, handle, :filtered} - end - end - - defp do_process_product(handle, rows, opts) do - %{ - dry_run: dry_run, - category_uuid: override_category_uuid, - categories_map: categories_map, - config: config, - language: language - } = opts - - # Transform with config and language - transform_opts = if language, do: [language: language], else: [] - attrs = ProductTransformer.transform(handle, rows, categories_map, config, transform_opts) - - # Override category if specified - attrs = - if override_category_uuid do - Map.put(attrs, :category_uuid, override_category_uuid) - else - attrs - end - - if dry_run do - {:dry_run, handle, attrs} - else - save_product(handle, attrs, opts) - end - end - - defp save_product(handle, attrs, opts) do - %{skip_existing: skip_existing, update_existing: update_existing} = opts - - cond do - update_existing -> - upsert_product(handle, attrs) - - skip_existing && product_exists?(handle) -> - {:skipped, handle, :exists} - - true -> - create_product(handle, attrs) - end - end - - defp product_exists?(slug) do - case Shop.get_product_by_slug(slug) do - nil -> false - _ -> true - end - end - - defp create_product(handle, attrs) do - case Shop.create_product(attrs) do - {:ok, product} -> - Logger.debug("Created: #{handle}") - {:ok, product} - - {:error, changeset} -> - Logger.warning("Failed: #{handle} - #{inspect(changeset.errors)}") - {:error, handle, changeset} - end - end - - defp upsert_product(handle, attrs) do - case Shop.upsert_product(attrs) do - {:ok, product, :inserted} -> - Logger.debug("Created: #{handle}") - {:ok, product} - - {:ok, product, :updated} -> - Logger.debug("Updated: #{handle}") - {:updated, product} - - {:error, changeset} -> - Logger.warning("Failed: #{handle} - #{inspect(changeset.errors)}") - {:error, handle, changeset} - end - end - - defp summarize(results) do - ok_count = Enum.count(results, &match?({:ok, _}, &1)) - updated_count = Enum.count(results, &match?({:updated, _}, &1)) - dry_count = Enum.count(results, &match?({:dry_run, _, _}, &1)) - skipped_count = Enum.count(results, &match?({:skipped, _, _}, &1)) - errors = Enum.filter(results, &match?({:error, _, _}, &1)) - - summary = %{ - imported: ok_count, - updated: updated_count, - dry_run: dry_count, - skipped: skipped_count, - errors: length(errors), - error_details: errors - } - - Logger.info( - "Import complete: #{ok_count} imported, #{updated_count} updated, #{dry_count} dry run, #{skipped_count} skipped, #{length(errors)} errors" - ) - - summary - end -end diff --git a/lib/modules/shop/import/shopify_format.ex b/lib/modules/shop/import/shopify_format.ex deleted file mode 100644 index 8dffc9b05..000000000 --- a/lib/modules/shop/import/shopify_format.ex +++ /dev/null @@ -1,63 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Import.ShopifyFormat do - @moduledoc """ - Shopify CSV format adapter implementing `ImportFormat` behaviour. - - Wraps existing CSVParser, Filter, and ProductTransformer modules - behind the uniform import format interface. No logic changes — pure delegation. - """ - - @behaviour PhoenixKit.Modules.Shop.Import.ImportFormat - - alias PhoenixKit.Modules.Shop.Import.{CSVParser, Filter, ProductTransformer} - alias PhoenixKit.Modules.Shop.ImportConfig - - @shopify_markers ["Handle", "Title", "Variant Price"] - - @impl true - def detect?(headers) do - header_set = MapSet.new(headers) - Enum.all?(@shopify_markers, &MapSet.member?(header_set, &1)) - end - - @impl true - def requires_option_mapping?, do: true - - @impl true - def count(path, config) do - CSVParser.parse_and_group(path) - |> Enum.count(fn {_handle, rows} -> Filter.should_include?(rows, config) end) - end - - @impl true - def parse_and_transform(path, categories_map, config, opts) do - language = Keyword.get(opts, :language) - option_mappings = Keyword.get(opts, :option_mappings, []) - - CSVParser.parse_and_group(path) - |> Enum.filter(fn {_handle, rows} -> Filter.should_include?(rows, config) end) - |> Enum.map(fn {handle, rows} -> - transform_opts = [language: language, option_mappings: option_mappings] - - if option_mappings != [] do - ProductTransformer.transform_extended( - handle, - rows, - categories_map, - config, - transform_opts - ) - else - ProductTransformer.transform(handle, rows, categories_map, config, transform_opts) - end - end) - end - - @impl true - def default_config_attrs do - config = ImportConfig.from_legacy_defaults() - - config - |> Map.from_struct() - |> Map.drop([:__meta__, :id, :uuid, :inserted_at, :updated_at]) - end -end diff --git a/lib/modules/shop/options/metadata_validator.ex b/lib/modules/shop/options/metadata_validator.ex deleted file mode 100644 index 315afd16c..000000000 --- a/lib/modules/shop/options/metadata_validator.ex +++ /dev/null @@ -1,340 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Options.MetadataValidator do - @moduledoc """ - Validates and normalizes product metadata for options and pricing. - - This module handles: - - Format normalization (object -> string for price modifiers) - - Consistency validation between _option_values and _price_modifiers - - Cleanup of orphaned modifiers for removed values - - ## Price Modifier Formats - - The canonical format is a simple string representing the price delta: - - %{"_price_modifiers" => %{ - "size" => %{"M" => "5.00", "L" => "10.00"}, - "color" => %{"Gold" => "8.00"} - }} - - Legacy object format is also supported for backward compatibility: - - %{"_price_modifiers" => %{ - "size" => %{"M" => %{"type" => "fixed", "value" => "5.00"}} - }} - - Both formats are normalized to string format when saving. - """ - - @doc """ - Validates metadata structure against option schema. - - Returns `:ok` or `{:error, errors}` where errors is a list of error tuples. - - ## Examples - - schema = [%{"key" => "size", "type" => "select", "options" => ["S", "M", "L"]}] - - MetadataValidator.validate(%{"size" => "M"}, schema) - # => :ok - - MetadataValidator.validate(%{"size" => "XL"}, schema) - # => {:error, [{"size", "must be one of: S, M, L"}]} - """ - def validate(metadata, option_schema) when is_map(metadata) and is_list(option_schema) do - errors = validate_values(metadata, option_schema) ++ validate_consistency(metadata) - - case errors do - [] -> :ok - _ -> {:error, errors} - end - end - - def validate(_, _), do: :ok - - @doc """ - Validates consistency between _option_values and _price_modifiers. - - Ensures that: - - All keys in _price_modifiers have corresponding entries in _option_values (or are schema options) - - All values in _price_modifiers exist in their respective option values - - Returns a list of error tuples (empty if valid). - """ - def validate_consistency(metadata) when is_map(metadata) do - option_values = Map.get(metadata, "_option_values", %{}) - price_modifiers = Map.get(metadata, "_price_modifiers", %{}) - - # Guard against non-map price_modifiers - if is_map(price_modifiers) do - Enum.flat_map(price_modifiers, fn - {option_key, values} when is_map(values) -> - available_values = Map.get(option_values, option_key, []) - validate_option_modifiers(option_key, values, available_values) - - {option_key, _invalid} -> - # Skip non-map values but could log warning - [{option_key, "price_modifiers values must be a map"}] - end) - else - [{"_price_modifiers", "must be a map"}] - end - end - - def validate_consistency(_), do: [] - - defp validate_option_modifiers(_option_key, _values, []), do: [] - - defp validate_option_modifiers(option_key, values, available_values) do - Enum.flat_map(values, fn {value, _modifier} -> - validate_single_modifier(option_key, value, available_values) - end) - end - - defp validate_single_modifier(option_key, value, available_values) do - if value in available_values do - [] - else - [{option_key, "modifier for '#{value}' has no corresponding option value"}] - end - end - - @doc """ - Removes orphaned modifiers for values not in _option_values. - - This cleans up price modifiers when option values are removed. - - ## Examples - - metadata = %{ - "_option_values" => %{"size" => ["M", "L"]}, - "_price_modifiers" => %{"size" => %{"S" => "0", "M" => "5.00", "L" => "10.00"}} - } - - MetadataValidator.clean_orphaned_modifiers(metadata) - # => %{ - # "_option_values" => %{"size" => ["M", "L"]}, - # "_price_modifiers" => %{"size" => %{"M" => "5.00", "L" => "10.00"}} - # } - """ - def clean_orphaned_modifiers(metadata) when is_map(metadata) do - option_values = Map.get(metadata, "_option_values", %{}) - price_modifiers = Map.get(metadata, "_price_modifiers", %{}) - - if price_modifiers == %{} do - metadata - else - cleaned_modifiers = clean_all_modifiers(price_modifiers, option_values) - apply_cleaned_modifiers(metadata, cleaned_modifiers) - end - end - - def clean_orphaned_modifiers(metadata), do: metadata - - defp clean_all_modifiers(price_modifiers, option_values) when is_map(price_modifiers) do - price_modifiers - |> Enum.flat_map(fn - {option_key, values} when is_map(values) -> - available_values = Map.get(option_values, option_key, []) - [{option_key, clean_option_modifiers(values, available_values)}] - - {_option_key, _invalid} -> - # Skip non-map values - [] - end) - |> Enum.reject(fn {_k, v} -> v == %{} end) - |> Map.new() - end - - defp clean_all_modifiers(_invalid, _option_values), do: %{} - - # If no option_values for this key, keep all modifiers (schema-based) - defp clean_option_modifiers(values, []), do: values - - defp clean_option_modifiers(values, available_values) do - Map.filter(values, fn {value, _} -> value in available_values end) - end - - defp apply_cleaned_modifiers(metadata, cleaned) when cleaned == %{}, - do: Map.delete(metadata, "_price_modifiers") - - defp apply_cleaned_modifiers(metadata, cleaned), - do: Map.put(metadata, "_price_modifiers", cleaned) - - @doc """ - Normalizes all price modifiers to string format. - - Converts object format to string format: - - `%{"type" => "fixed", "value" => "10.00"}` -> `"10.00"` - - `%{"value" => "10.00"}` -> `"10.00"` - - `%{"final_price" => "30.00"}` with base_price 20 -> `"10.00"` - - Already-string values are passed through unchanged. - - ## Examples - - metadata = %{ - "_price_modifiers" => %{ - "size" => %{ - "M" => %{"type" => "fixed", "value" => "5.00"}, - "L" => "10.00" - } - } - } - - MetadataValidator.normalize_price_modifiers(metadata) - # => %{ - # "_price_modifiers" => %{ - # "size" => %{"M" => "5.00", "L" => "10.00"} - # } - # } - """ - def normalize_price_modifiers(metadata, base_price \\ nil) - - def normalize_price_modifiers(metadata, base_price) when is_map(metadata) do - case Map.get(metadata, "_price_modifiers") do - nil -> - metadata - - price_modifiers when is_map(price_modifiers) -> - normalized = - Enum.map(price_modifiers, fn {option_key, values} -> - normalized_values = - Enum.map(values, fn {value, modifier} -> - {value, normalize_modifier_value(modifier, base_price)} - end) - |> Enum.reject(fn {_k, v} -> is_nil(v) end) - |> Map.new() - - {option_key, normalized_values} - end) - |> Enum.reject(fn {_k, v} -> v == %{} end) - |> Map.new() - - if normalized == %{} do - Map.delete(metadata, "_price_modifiers") - else - Map.put(metadata, "_price_modifiers", normalized) - end - - _ -> - metadata - end - end - - def normalize_price_modifiers(metadata, _base_price), do: metadata - - @doc """ - Normalizes a complete set of product attributes before saving. - - This function: - 1. Normalizes price modifiers to string format - 2. Cleans orphaned modifiers - 3. Removes empty _option_values and _price_modifiers maps - - ## Examples - - attrs = %{ - "title" => "My Product", - "metadata" => %{ - "_option_values" => %{"size" => ["M", "L"]}, - "_price_modifiers" => %{ - "size" => %{ - "M" => %{"type" => "fixed", "value" => "5.00"}, - "S" => "orphaned" - } - } - } - } - - MetadataValidator.normalize_product_attrs(attrs) - # Normalizes modifiers and removes orphaned "S" entry - """ - def normalize_product_attrs(attrs) when is_map(attrs) do - case attrs do - %{"metadata" => metadata, "price" => price} when is_map(metadata) -> - base_price = parse_decimal(price) - normalized = normalize_and_clean(metadata, base_price) - Map.put(attrs, "metadata", normalized) - - %{"metadata" => metadata} when is_map(metadata) -> - normalized = normalize_and_clean(metadata, nil) - Map.put(attrs, "metadata", normalized) - - _ -> - attrs - end - end - - def normalize_product_attrs(attrs), do: attrs - - # Private helpers - - defp normalize_and_clean(metadata, base_price) do - metadata - |> normalize_price_modifiers(base_price) - |> clean_orphaned_modifiers() - |> clean_empty_maps() - end - - defp clean_empty_maps(metadata) do - metadata - |> maybe_remove_empty_key("_option_values") - |> maybe_remove_empty_key("_price_modifiers") - end - - defp maybe_remove_empty_key(metadata, key) do - case Map.get(metadata, key) do - val when val == %{} or val == nil -> Map.delete(metadata, key) - _ -> metadata - end - end - - defp normalize_modifier_value(modifier, base_price) when is_map(modifier) do - cond do - # Object with value key - Map.has_key?(modifier, "value") and modifier["value"] != "" -> - modifier["value"] - - # Object with final_price key (needs conversion) - Map.has_key?(modifier, "final_price") and modifier["final_price"] != "" and - not is_nil(base_price) -> - final_price = parse_decimal(modifier["final_price"]) - delta = Decimal.sub(final_price, base_price) - Decimal.to_string(Decimal.round(delta, 2)) - - # Empty or invalid object - true -> - nil - end - end - - defp normalize_modifier_value(modifier, _base_price) when is_binary(modifier) do - # Already in string format - if modifier == "" do - nil - else - modifier - end - end - - defp normalize_modifier_value(_, _), do: nil - - defp validate_values(_metadata, _schema) do - # Delegate to Options.validate_metadata for value validation - # This avoids duplicating the validation logic - [] - end - - defp parse_decimal(nil), do: Decimal.new("0") - defp parse_decimal(""), do: Decimal.new("0") - - defp parse_decimal(value) when is_binary(value) do - case Decimal.parse(value) do - {decimal, _} -> decimal - :error -> Decimal.new("0") - end - end - - defp parse_decimal(%Decimal{} = value), do: value - defp parse_decimal(_), do: Decimal.new("0") -end diff --git a/lib/modules/shop/options/option_types.ex b/lib/modules/shop/options/option_types.ex deleted file mode 100644 index b23461644..000000000 --- a/lib/modules/shop/options/option_types.ex +++ /dev/null @@ -1,434 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.OptionTypes do - @moduledoc """ - Supported option types for product options. - - ## Supported Types - - - `text` - Free-form text input - - `number` - Numeric input (optional min/max/step validation) - - `boolean` - Checkbox/toggle - - `select` - Single choice dropdown (requires options) - - `multiselect` - Multiple choice selection (requires options) - - ## Option Schema Format (Simple) - - %{ - "key" => "material", - "label" => "Material", - "type" => "select", - "options" => ["PLA", "ABS", "PETG"], - "default" => "PLA", - "required" => false, - "unit" => nil, - "position" => 0, - "affects_price" => true, - "modifier_type" => "fixed", - "price_modifiers" => %{ - "PLA" => "0", - "ABS" => "5.00", - "PETG" => "10.00" - } - } - - ## Option Schema Format (Enhanced with Localization) - - %{ - "key" => "color", - "label" => %{"en" => "Color", "ru" => "Цвет"}, - "type" => "select", - "allow_multiple_slots" => true, - "options" => [ - %{"value" => "red", "label" => %{"en" => "Red", "ru" => "Красный"}, "hex" => "#FF0000"}, - %{"value" => "blue", "label" => %{"en" => "Blue", "ru" => "Синий"}, "hex" => "#0000FF"} - ] - } - - ## Multiple Slots - - When `allow_multiple_slots: true`, the same global option can be used - multiple times in a product with different slot names. For example: - - - Global option "color" can be used as "cup_color" and "liquid_color" - - Slots are stored in product metadata["_option_slots"] - - Each slot references the source global option key - - ## Price Modifiers - - For `select` and `multiselect` types, you can enable price modifiers: - - `affects_price` - Boolean indicating if this option affects product price - - `modifier_type` - "fixed" or "percent" - - `price_modifiers` - Map of option value to price delta (as string decimal) - - `allow_override` - Boolean, allows overriding price modifiers per-product - - ## Modifier Types - - - `fixed` - Add exact amount to base price (e.g., +$10) - - `percent` - Add percentage of base price (e.g., +20% of $20 = +$4) - - ## Allow Override - - When `allow_override: true`, the price modifier values can be customized - for each individual product. The global values serve as defaults. - Overridden values are stored in product's metadata["_price_modifiers"]. - - ## Price Calculation Order - - 1. Sum all fixed modifiers - 2. Add to base price (intermediate price) - 3. Sum all percent modifiers - 4. Apply percent to intermediate price - - Example: - - Base price: $20 - - Material: PETG (+$10 fixed) - - Finish: Premium (+20% percent) - - Final: ($20 + $10) * 1.20 = $36 - """ - - @supported_types ["text", "number", "boolean", "select", "multiselect"] - @modifier_types ["fixed", "percent"] - - @doc """ - Returns list of supported option types. - """ - def supported_types, do: @supported_types - - @doc """ - Returns list of supported modifier types. - """ - def modifier_types, do: @modifier_types - - @doc """ - Checks if a type is valid. - """ - def valid_type?(type) when is_binary(type), do: type in @supported_types - def valid_type?(_), do: false - - @doc """ - Checks if a modifier type is valid. - """ - def valid_modifier_type?(type) when is_binary(type), do: type in @modifier_types - def valid_modifier_type?(_), do: false - - @doc """ - Extracts option values from options list. - - Works with both simple string format and enhanced map format: - - Simple: ["Red", "Blue"] -> ["Red", "Blue"] - - Enhanced: [%{"value" => "red", "label" => ...}] -> ["red"] - """ - def get_option_values(options) when is_list(options) do - Enum.map(options, &extract_option_value/1) - end - - def get_option_values(_), do: [] - - defp extract_option_value(opt) when is_binary(opt), do: opt - defp extract_option_value(%{"value" => value}) when is_binary(value), do: value - defp extract_option_value(_), do: nil - - @doc """ - Gets localized label for an option or option value. - - Handles both string labels and localized map labels. - Falls back to default language or first available. - """ - def get_label(label, language \\ "en") - - def get_label(label, _language) when is_binary(label), do: label - - def get_label(label, language) when is_map(label) do - # Try exact language match - case Map.get(label, language) do - nil -> - # Try "en" as fallback - case Map.get(label, "en") do - nil -> - # Use first available value - case Map.values(label) do - [first | _] -> first - [] -> "" - end - - en_label -> - en_label - end - - lang_label -> - lang_label - end - end - - def get_label(_, _), do: "" - - @doc """ - Checks if option allows multiple slots. - """ - def allows_multiple_slots?(%{"allow_multiple_slots" => true}), do: true - def allows_multiple_slots?(_), do: false - - @doc """ - Checks if a type requires options array. - """ - def requires_options?("select"), do: true - def requires_options?("multiselect"), do: true - def requires_options?(_), do: false - - @doc """ - Checks if a type supports price modifiers. - """ - def supports_price_modifiers?("select"), do: true - def supports_price_modifiers?("multiselect"), do: true - def supports_price_modifiers?(_), do: false - - @doc """ - Validates an option definition map. - - ## Required Keys - - - `key` - Unique identifier (string) - - `label` - Display label (string) - - `type` - One of supported types - - ## Optional Keys - - - `options` - Required for select/multiselect types - - `default` - Default value - - `required` - Whether field is required (boolean) - - `unit` - Unit label (e.g., "cm", "kg") - - `position` - Sort order (integer) - - `affects_price` - Whether this option affects price (boolean) - - `modifier_type` - "fixed" or "percent" (defaults to "fixed") - - `price_modifiers` - Map of option value to price modifier - - ## Examples - - iex> OptionTypes.validate_option(%{"key" => "material", "label" => "Material", "type" => "text"}) - {:ok, %{"key" => "material", "label" => "Material", "type" => "text"}} - - iex> OptionTypes.validate_option(%{"key" => "color", "label" => "Color", "type" => "select", "options" => ["Red", "Blue"]}) - {:ok, %{"key" => "color", "label" => "Color", "type" => "select", "options" => ["Red", "Blue"]}} - - iex> OptionTypes.validate_option(%{"key" => "test"}) - {:error, "Missing required keys: label, type"} - """ - def validate_option(opt) when is_map(opt) do - with :ok <- validate_required_keys(opt), - :ok <- validate_key_format(opt), - :ok <- validate_label_format(opt), - :ok <- validate_type(opt), - :ok <- validate_allow_multiple_slots(opt), - :ok <- validate_select_options(opt), - :ok <- validate_price_modifiers(opt) do - {:ok, normalize_option(opt)} - end - end - - def validate_option(_), do: {:error, "Option must be a map"} - - @doc """ - Validates a list of option definitions. - Returns {:ok, options} or {:error, reason} on first failure. - """ - def validate_options(options) when is_list(options) do - results = Enum.map(options, &validate_option/1) - errors = Enum.filter(results, &match?({:error, _}, &1)) - - case errors do - [] -> {:ok, Enum.map(results, fn {:ok, opt} -> opt end)} - [{:error, reason} | _] -> {:error, reason} - end - end - - def validate_options(_), do: {:error, "Options must be a list"} - - # Private functions - - defp validate_required_keys(opt) do - required = ["key", "label", "type"] - missing = Enum.reject(required, &Map.has_key?(opt, &1)) - - case missing do - [] -> :ok - keys -> {:error, "Missing required keys: #{Enum.join(keys, ", ")}"} - end - end - - defp validate_key_format(%{"key" => key}) when is_binary(key) do - if String.match?(key, ~r/^[a-z][a-z0-9_]*$/) do - :ok - else - {:error, "Key must be lowercase alphanumeric with underscores, starting with a letter"} - end - end - - defp validate_key_format(_), do: {:error, "Key must be a string"} - - # Label can be a string or a localized map - defp validate_label_format(%{"label" => label}) when is_binary(label), do: :ok - - defp validate_label_format(%{"label" => label}) when is_map(label) do - # Localized format: %{"en" => "Color", "ru" => "Цвет"} - if Enum.all?(label, fn {k, v} -> is_binary(k) and is_binary(v) end) do - :ok - else - {:error, "Localized label must be a map of language code => string"} - end - end - - defp validate_label_format(_), do: {:error, "Label must be a string or localized map"} - - # allow_multiple_slots is optional boolean - defp validate_allow_multiple_slots(%{"allow_multiple_slots" => value}) when is_boolean(value), - do: :ok - - defp validate_allow_multiple_slots(%{"allow_multiple_slots" => _}), - do: {:error, "allow_multiple_slots must be a boolean"} - - defp validate_allow_multiple_slots(_), do: :ok - - defp validate_type(%{"type" => type}) do - if valid_type?(type) do - :ok - else - {:error, "Invalid type '#{type}'. Must be one of: #{Enum.join(@supported_types, ", ")}"} - end - end - - defp validate_select_options(%{"type" => type, "options" => options}) - when type in ["select", "multiselect"] do - cond do - not is_list(options) -> - {:error, "Options must be a list for #{type} type"} - - Enum.empty?(options) -> - {:error, "Options cannot be empty for #{type} type"} - - Enum.all?(options, &is_binary/1) -> - # Simple string format - valid - :ok - - Enum.all?(options, &valid_option_map?/1) -> - # Enhanced map format - valid - :ok - - true -> - {:error, "Options must be strings or maps with 'value' key"} - end - end - - defp validate_select_options(%{"type" => type}) when type in ["select", "multiselect"] do - {:error, "Options are required for #{type} type"} - end - - defp validate_select_options(_), do: :ok - - # Validates an option map has required 'value' key - defp valid_option_map?(opt) when is_map(opt) do - value = opt["value"] - is_binary(value) and value != "" - end - - defp valid_option_map?(_), do: false - - # Validate price modifiers for select/multiselect types - defp validate_price_modifiers(%{"type" => type, "affects_price" => true} = opt) - when type in ["select", "multiselect"] do - modifier_type = Map.get(opt, "modifier_type", "fixed") - - if modifier_type in @modifier_types do - validate_price_modifiers_map(opt) - else - {:error, "modifier_type must be one of: #{Enum.join(@modifier_types, ", ")}"} - end - end - - defp validate_price_modifiers(%{"type" => type, "affects_price" => true}) - when type not in ["select", "multiselect"] do - {:error, "Price modifiers are only supported for select and multiselect types"} - end - - defp validate_price_modifiers(_), do: :ok - - defp validate_price_modifiers_map(opt) do - case opt do - %{"price_modifiers" => modifiers} when is_map(modifiers) -> - # Extract option values using helper that handles both formats - option_values = get_option_values(opt["options"] || []) - - # Check that all options have modifiers - missing = Enum.filter(option_values, fn o -> !Map.has_key?(modifiers, o) end) - - cond do - missing != [] -> - {:error, "Missing price modifiers for options: #{Enum.join(missing, ", ")}"} - - not valid_modifiers?(modifiers) -> - {:error, "Price modifiers must be valid decimal strings (e.g., \"5.00\")"} - - true -> - :ok - end - - %{"price_modifiers" => _} -> - {:error, "Price modifiers must be a map"} - - _ -> - {:error, "Price modifiers are required when affects_price is true"} - end - end - - # Check if all modifier values are valid decimal strings - defp valid_modifiers?(modifiers) when is_map(modifiers) do - Enum.all?(modifiers, fn {_key, value} -> - is_binary(value) and valid_decimal_string?(value) - end) - end - - defp valid_decimal_string?(str) do - case Decimal.parse(str) do - {_decimal, ""} -> true - _ -> false - end - end - - defp normalize_option(opt) do - opt - |> Map.put_new("required", false) - |> Map.put_new("position", 0) - |> Map.put_new("enabled", true) - |> normalize_affects_price() - end - - # Ensure affects_price is false for non-select types - defp normalize_affects_price(%{"type" => type} = opt) - when type not in ["select", "multiselect"] do - opt - |> Map.delete("affects_price") - |> Map.delete("modifier_type") - |> Map.delete("price_modifiers") - end - - defp normalize_affects_price(%{"affects_price" => true} = opt) do - # Ensure price_modifiers has "0" as default for missing options - # Use helper that handles both simple and enhanced formats - option_values = get_option_values(opt["options"] || []) - modifiers = opt["price_modifiers"] || %{} - - normalized_modifiers = - Enum.reduce(option_values, modifiers, fn o, acc -> - Map.put_new(acc, o, "0") - end) - - opt - |> Map.put("price_modifiers", normalized_modifiers) - |> Map.put_new("modifier_type", "fixed") - |> Map.put_new("allow_override", false) - end - - defp normalize_affects_price(opt) do - opt - |> Map.put_new("affects_price", false) - |> Map.delete("allow_override") - end -end diff --git a/lib/modules/shop/options/options.ex b/lib/modules/shop/options/options.ex deleted file mode 100644 index 9363a4e3b..000000000 --- a/lib/modules/shop/options/options.ex +++ /dev/null @@ -1,1361 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Options do - @moduledoc """ - Context for managing product options. - - Provides a two-level option system: - - **Global options** - Apply to all products (stored in shop_config) - - **Category options** - Apply to products in specific category (stored in category.option_schema) - - When retrieving options for a product, the system merges global and category options, - with category options overriding global ones by key. - - ## Localization Note - - Option labels and values are currently stored as plain strings, not localized JSONB maps. - This means options display the same in all languages. Future enhancement: convert - option schema to support localized labels like `"label" => %{"en" => "Material", "ru" => "Материал"}`. - - ## Usage - - # Get/set global options - Options.get_global_options() - Options.update_global_options([%{"key" => "material", "label" => "Material", "type" => "text"}]) - - # Get/set category options - Options.get_category_options(category) - Options.update_category_options(category, [%{"key" => "mounting_type", ...}]) - - # Get merged schema for a product - Options.get_option_schema_for_product(product) - - # Validate product metadata against schema - Options.validate_metadata(product.metadata, schema) - - ## Price Calculation - - Options with `affects_price: true` can modify the final product price. - Two modifier types are supported: - - - `fixed` - Add exact amount (e.g., +$10) - - `percent` - Add percentage of base price (e.g., +20%) - - ## Allow Override - - Options with `allow_override: true` can have their price modifiers customized - per-product. Override values are stored in product metadata under `_price_modifiers`. - When calculating price, the system checks for overrides first, then falls back - to the default values from the option schema. - - Calculation order: - 1. Sum all fixed modifiers (checking for overrides) - 2. Add to base price (intermediate price) - 3. Sum all percent modifiers (checking for overrides) - 4. Apply percent to intermediate price - - Example: - - Base price: $20 - - Material: PETG (+$10 fixed) - - Finish: Premium (+20% percent) - - Final: ($20 + $10) * 1.20 = $36 - """ - - alias PhoenixKit.Modules.Shop.Category - alias PhoenixKit.Modules.Shop.OptionTypes - alias PhoenixKit.Modules.Shop.ShopConfig - - @global_schema_key "global_option_schema" - - # ============================================ - # GLOBAL OPTIONS - # ============================================ - - @doc """ - Gets global option schema. - - Returns a list of option definitions that apply to all products. - """ - def get_global_options do - case repo().get(ShopConfig, @global_schema_key) do - nil -> [] - %ShopConfig{value: %{"options" => opts}} when is_list(opts) -> opts - %ShopConfig{value: _} -> [] - end - end - - @doc """ - Gets enabled global options only. - - Filters out options where `enabled` is explicitly set to `false`. - Options without the `enabled` key default to enabled (backward compatible). - """ - def get_enabled_global_options do - get_global_options() - |> Enum.filter(fn opt -> Map.get(opt, "enabled", true) != false end) - end - - @doc """ - Updates global option schema. - - ## Examples - - Options.update_global_options([ - %{"key" => "material", "label" => "Material", "type" => "select", - "options" => ["PLA", "ABS", "PETG"], "default" => "PLA"} - ]) - """ - def update_global_options(options) when is_list(options) do - with {:ok, validated} <- OptionTypes.validate_options(options) do - wrapped_value = %{"options" => validated} - - case repo().get(ShopConfig, @global_schema_key) do - nil -> - %ShopConfig{} - |> ShopConfig.changeset(%{key: @global_schema_key, value: wrapped_value}) - |> repo().insert() - - config -> - config - |> ShopConfig.changeset(%{value: wrapped_value}) - |> repo().update() - end - end - end - - @doc """ - Adds a single option to global schema. - """ - def add_global_option(opt) when is_map(opt) do - with {:ok, validated} <- OptionTypes.validate_option(opt) do - current = get_global_options() - - # Check for duplicate key - if Enum.any?(current, &(&1["key"] == validated["key"])) do - {:error, "Option with key '#{validated["key"]}' already exists"} - else - update_global_options(current ++ [validated]) - end - end - end - - @doc """ - Removes an option from global schema by key. - """ - def remove_global_option(key) when is_binary(key) do - current = get_global_options() - updated = Enum.reject(current, &(&1["key"] == key)) - update_global_options(updated) - end - - @doc """ - Gets a single global option by key. - - Returns the option definition map or nil if not found. - - ## Examples - - Options.get_global_option_by_key("color") - # => %{"key" => "color", "label" => "Color", "type" => "select", ...} - """ - def get_global_option_by_key(key) when is_binary(key) do - get_global_options() - |> Enum.find(&(&1["key"] == key)) - end - - def get_global_option_by_key(_), do: nil - - @doc """ - Adds a new value to an existing global option. - - Works with both simple string options and enhanced map options. - For enhanced format, value_map should be a map with at least "value" key. - - ## Examples - - # Simple format - adds "yellow" to options list - Options.add_value_to_global_option("color", "yellow") - - # Enhanced format - adds map to options list - Options.add_value_to_global_option("color", %{ - "value" => "yellow", - "label" => %{"en" => "Yellow", "ru" => "Жёлтый"}, - "hex" => "#FFFF00" - }) - """ - def add_value_to_global_option(key, value_or_map) when is_binary(key) do - case get_global_option_by_key(key) do - nil -> - {:error, "Global option '#{key}' not found"} - - option -> - do_add_value_to_option(key, option, value_or_map) - end - end - - defp do_add_value_to_option(key, option, value_or_map) do - current_options = option["options"] || [] - new_value = normalize_option_value(value_or_map, current_options) - - if value_exists?(current_options, new_value) do - {:ok, option} - else - updated_option = Map.put(option, "options", current_options ++ [new_value]) - replace_global_option(key, updated_option) - end - end - - defp replace_global_option(key, updated_option) do - all_options = get_global_options() - - updated_all = - Enum.map(all_options, fn opt -> - if opt["key"] == key, do: updated_option, else: opt - end) - - update_global_options(updated_all) - end - - # Normalize value to match existing format (string or map) - defp normalize_option_value(value, current_options) when is_binary(value) do - # Check if current options are in enhanced format - if Enum.any?(current_options, &is_map/1) do - %{"value" => value, "label" => value} - else - value - end - end - - defp normalize_option_value(value_map, _current_options) when is_map(value_map) do - value_map - end - - defp normalize_option_value(value, _), do: to_string(value) - - # Check if value already exists in options list - defp value_exists?(options, new_value) when is_binary(new_value) do - Enum.any?(options, fn opt -> - case opt do - ^new_value -> true - %{"value" => ^new_value} -> true - _ -> false - end - end) - end - - defp value_exists?(options, %{"value" => value}) do - value_exists?(options, value) - end - - defp value_exists?(_, _), do: false - - # ============================================ - # CATEGORY OPTIONS - # ============================================ - - @doc """ - Gets category-specific option schema. - """ - def get_category_options(%Category{option_schema: schema}) when is_list(schema) do - schema - end - - def get_category_options(%Category{}) do - [] - end - - def get_category_options(category_uuid) when is_binary(category_uuid) do - result = - if uuid_string?(category_uuid) do - repo().get_by(Category, uuid: category_uuid) - else - nil - end - - case result do - nil -> [] - category -> get_category_options(category) - end - end - - def get_category_options(_), do: [] - - @doc """ - Updates category option schema. - """ - def update_category_options(%Category{} = category, options) when is_list(options) do - with {:ok, validated} <- OptionTypes.validate_options(options) do - category - |> Category.changeset(%{option_schema: validated}) - |> repo().update() - end - end - - @doc """ - Adds a single option to category schema. - """ - def add_category_option(%Category{} = category, opt) when is_map(opt) do - with {:ok, validated} <- OptionTypes.validate_option(opt) do - current = get_category_options(category) - - if Enum.any?(current, &(&1["key"] == validated["key"])) do - {:error, "Option with key '#{validated["key"]}' already exists in this category"} - else - update_category_options(category, current ++ [validated]) - end - end - end - - @doc """ - Removes an option from category schema by key. - """ - def remove_category_option(%Category{} = category, key) when is_binary(key) do - current = get_category_options(category) - updated = Enum.reject(current, &(&1["key"] == key)) - update_category_options(category, updated) - end - - # ============================================ - # MERGED SCHEMA (Global + Category) - # ============================================ - - @doc """ - Gets merged option schema for a product. - - Combines global options with category-specific options. - Category options override global ones with the same key. - - ## Examples - - # Product with category - schema = Options.get_option_schema_for_product(product) - - # Product without category (global only) - schema = Options.get_option_schema_for_product(product_without_category) - """ - def get_option_schema_for_product(product) do - global = get_enabled_global_options() - - category_opts = - case product do - %{category: %Category{} = cat} -> get_category_options(cat) - %{category_uuid: nil} -> [] - %{category_uuid: uuid} when is_binary(uuid) -> get_category_options(uuid) - _ -> [] - end - - merge_schemas(global, category_opts) - end - - @doc """ - Merges two option schemas, with the second overriding the first by key. - """ - def merge_schemas(base, override) when is_list(base) and is_list(override) do - override_keys = Enum.map(override, & &1["key"]) - - filtered_base = - Enum.reject(base, fn opt -> - opt["key"] in override_keys - end) - - # Sort by position - (filtered_base ++ override) - |> Enum.sort_by(& &1["position"], :asc) - end - - # ============================================ - # SLOT-BASED OPTIONS - # ============================================ - - @doc """ - Gets slot-based options for a product. - - Resolves `_option_slots` from product metadata to full option specs. - Each slot references a global option via `source_key` and creates a - customized option spec with the slot's key and label. - - ## Examples - - product.metadata = %{ - "_option_slots" => [ - %{"slot" => "cup_color", "label" => %{"en" => "Cup Color"}, "source_key" => "color"}, - %{"slot" => "liquid_color", "label" => %{"en" => "Liquid"}, "source_key" => "color"} - ] - } - - Options.get_slot_options_for_product(product) - # => [ - # %{"key" => "cup_color", "label" => %{"en" => "Cup Color"}, "type" => "select", ...}, - # %{"key" => "liquid_color", "label" => %{"en" => "Liquid"}, "type" => "select", ...} - # ] - """ - def get_slot_options_for_product(product) do - metadata = product.metadata || %{} - slots = Map.get(metadata, "_option_slots", []) - - Enum.flat_map(slots, fn slot -> - case resolve_slot_to_option(slot) do - nil -> [] - option -> [option] - end - end) - end - - @doc """ - Resolves a single slot definition to a full option spec. - - Takes a slot map with "slot", "label", and "source_key", - finds the referenced global option, and creates a new spec - with the slot's key and label but the source's type and values. - """ - def resolve_slot_to_option(%{"slot" => slot_key, "source_key" => source_key} = slot) do - case get_global_option_by_key(source_key) do - nil -> - nil - - source_option -> - if Map.get(source_option, "enabled", true) == false do - nil - else - # Create new option spec using slot key/label but source's type/options - %{ - "key" => slot_key, - "label" => slot["label"] || slot_key, - "type" => source_option["type"], - "options" => source_option["options"], - "source_key" => source_key, - "required" => Map.get(slot, "required", false), - "position" => Map.get(slot, "position", 0) - } - |> maybe_add_price_modifiers(source_option) - end - end - end - - def resolve_slot_to_option(_), do: nil - - # Copy price modifier settings from source option if present - defp maybe_add_price_modifiers(slot_option, source_option) do - if source_option["affects_price"] do - slot_option - |> Map.put("affects_price", true) - |> Map.put("modifier_type", source_option["modifier_type"] || "fixed") - |> Map.put("price_modifiers", source_option["price_modifiers"] || %{}) - |> Map.put("allow_override", source_option["allow_override"] || false) - else - slot_option - end - end - - @doc """ - Gets complete option schema for a product including slot-based options. - - This combines: - 1. Global options (excluding those used as slot sources) - 2. Category options - 3. Slot-based options from product metadata - - ## Examples - - Options.get_complete_option_schema_for_product(product) - """ - def get_complete_option_schema_for_product(product) do - base_schema = get_option_schema_for_product(product) - slot_options = get_slot_options_for_product(product) - - # Get source keys used by slots to exclude from base schema - source_keys = - slot_options - |> Enum.map(& &1["source_key"]) - |> Enum.reject(&is_nil/1) - |> MapSet.new() - - # Filter out global options that are used as slot sources - # (but keep if allow_multiple_slots is false) - filtered_base = - Enum.reject(base_schema, fn opt -> - key = opt["key"] - MapSet.member?(source_keys, key) and OptionTypes.allows_multiple_slots?(opt) - end) - - # Merge and sort by position - (filtered_base ++ slot_options) - |> Enum.sort_by(& &1["position"], :asc) - end - - @doc """ - Builds option slots structure for product metadata. - - Creates the `_option_slots` array from a list of slot definitions. - - ## Examples - - Options.build_option_slots([ - %{slot: "cup_color", source_key: "color", label: %{"en" => "Cup Color"}}, - %{slot: "liquid_color", source_key: "color", label: %{"en" => "Liquid"}} - ]) - # => [ - # %{"slot" => "cup_color", "source_key" => "color", "label" => %{"en" => "Cup Color"}}, - # %{"slot" => "liquid_color", "source_key" => "color", "label" => %{"en" => "Liquid"}} - # ] - """ - def build_option_slots(slots) when is_list(slots) do - Enum.map(slots, fn slot -> - %{ - "slot" => to_string(slot[:slot] || slot["slot"]), - "source_key" => to_string(slot[:source_key] || slot["source_key"]), - "label" => slot[:label] || slot["label"] || slot[:slot] || slot["slot"] - } - |> maybe_add_position(slot) - end) - end - - def build_option_slots(_), do: [] - - defp maybe_add_position(slot_map, source) do - position = source[:position] || source["position"] - if position, do: Map.put(slot_map, "position", position), else: slot_map - end - - # ============================================ - # VALUE VALIDATION - # ============================================ - - @doc """ - Validates product metadata against option schema. - - Returns `:ok` or `{:error, errors}` where errors is a list of `{key, message}` tuples. - - ## Examples - - schema = [%{"key" => "material", "type" => "select", "options" => ["PLA", "ABS"], "required" => true}] - - Options.validate_metadata(%{"material" => "PLA"}, schema) - # => :ok - - Options.validate_metadata(%{}, schema) - # => {:error, [{"material", "is required"}]} - - Options.validate_metadata(%{"material" => "Invalid"}, schema) - # => {:error, [{"material", "must be one of: PLA, ABS"}]} - """ - def validate_metadata(metadata, schema) when is_map(metadata) and is_list(schema) do - required_errors = - schema - |> Enum.filter(& &1["required"]) - |> Enum.reject(fn opt -> - value = Map.get(metadata, opt["key"]) - value != nil and value != "" - end) - |> Enum.map(fn opt -> {opt["key"], "is required"} end) - - type_errors = - Enum.flat_map(schema, fn opt -> - value = Map.get(metadata, opt["key"]) - validate_value_type(opt, value) - end) - - case required_errors ++ type_errors do - [] -> :ok - errors -> {:error, errors} - end - end - - def validate_metadata(_, _), do: :ok - - # Skip validation for nil/empty values (handled by required check) - defp validate_value_type(_opt, nil), do: [] - defp validate_value_type(_opt, ""), do: [] - - defp validate_value_type(%{"key" => key, "type" => "number"}, value) do - cond do - is_number(value) -> [] - is_binary(value) and String.match?(value, ~r/^-?\d+\.?\d*$/) -> [] - true -> [{key, "must be a number"}] - end - end - - defp validate_value_type(%{"key" => key, "type" => "boolean"}, value) do - if is_boolean(value) or value in ["true", "false"] do - [] - else - [{key, "must be a boolean"}] - end - end - - defp validate_value_type(%{"key" => key, "type" => "select", "options" => opts}, value) do - if value in opts do - [] - else - [{key, "must be one of: #{Enum.join(opts, ", ")}"}] - end - end - - defp validate_value_type(%{"key" => key, "type" => "multiselect", "options" => opts}, value) do - values = if is_list(value), do: value, else: [value] - - if Enum.all?(values, &(&1 in opts)) do - [] - else - [{key, "must be a list of: #{Enum.join(opts, ", ")}"}] - end - end - - # text type accepts any string - defp validate_value_type(%{"type" => "text"}, _value), do: [] - - # Unknown type - skip validation - defp validate_value_type(_, _), do: [] - - # ============================================ - # HELPER FUNCTIONS - # ============================================ - - @doc """ - Returns option by key from a schema. - """ - def get_option_by_key(schema, key) when is_list(schema) and is_binary(key) do - Enum.find(schema, &(&1["key"] == key)) - end - - @doc """ - Checks if an option key exists in schema. - """ - def has_option?(schema, key) when is_list(schema) and is_binary(key) do - Enum.any?(schema, &(&1["key"] == key)) - end - - # ============================================ - # PRICE MODIFIER FUNCTIONS - # ============================================ - - @doc """ - Gets price-affecting options from a schema. - - Returns only options that have `affects_price: true` and are - of type `select` or `multiselect`. - - ## Examples - - schema = [ - %{"key" => "material", "type" => "select", "affects_price" => true, ...}, - %{"key" => "notes", "type" => "text", ...} - ] - - Options.get_price_affecting_specs(schema) - # => [%{"key" => "material", ...}] - """ - def get_price_affecting_specs(schema) when is_list(schema) do - Enum.filter(schema, fn opt -> - opt["affects_price"] == true and opt["type"] in ["select", "multiselect"] - end) - end - - def get_price_affecting_specs(_), do: [] - - @doc """ - Gets all selectable options from a schema. - - Returns options that are of type `select` or `multiselect` and not hidden. - Unlike `get_price_affecting_specs/1`, this includes options regardless of - whether they affect price. Use this for UI display of all selectable options. - - ## Examples - - schema = [ - %{"key" => "color", "type" => "select", "options" => ["Red", "Blue"]}, - %{"key" => "material", "type" => "select", "affects_price" => true, ...}, - %{"key" => "notes", "type" => "text", ...} - ] - - Options.get_selectable_specs(schema) - # => [%{"key" => "color", ...}, %{"key" => "material", ...}] - """ - def get_selectable_specs(schema) when is_list(schema) do - Enum.filter(schema, fn opt -> - opt["type"] in ["select", "multiselect"] and - Map.get(opt, "hidden", false) != true - end) - end - - def get_selectable_specs(_), do: [] - - @doc """ - Gets all selectable options for a specific product. - - Combines global and category options, then filters for select/multiselect types. - Also discovers options from product metadata that have values defined. - Unlike `get_price_affecting_specs_for_product/1`, this includes all selectable - options regardless of whether they affect price. - - Use this for displaying option selectors in the product UI. - """ - def get_selectable_specs_for_product(product) do - schema_specs = - product - |> get_option_schema_for_product() - |> get_selectable_specs() - |> filter_by_product_option_values(product) - - # Discover additional options from product metadata (without price requirement) - discovered_specs = discover_selectable_options_from_metadata(product) - - # Merge: schema specs take priority over discovered - merge_discovered_specs(schema_specs, discovered_specs) - end - - @doc """ - Gets all selectable options for admin product detail view. - - Unlike `get_selectable_specs_for_product/1`, this does NOT filter schema options - by product's `_option_values`. Shows all schema options (global + category) plus - discovered options from metadata, giving admins the full picture. - """ - def get_all_selectable_specs_for_admin(product) do - # All schema selectable specs WITHOUT filtering by _option_values - schema_specs = - product - |> get_option_schema_for_product() - |> get_selectable_specs() - - # Discover additional options from product metadata - discovered_specs = discover_selectable_options_from_metadata(product) - - # Merge: schema specs take priority over discovered - merge_discovered_specs(schema_specs, discovered_specs) - end - - # Discovers selectable options from product metadata. - # Creates "virtual" option specs for keys found in _option_values. - # Unlike discover_options_from_metadata/1, this doesn't require _price_modifiers. - defp discover_selectable_options_from_metadata(product) do - metadata = product.metadata || %{} - option_values = Map.get(metadata, "_option_values", %{}) - price_modifiers = Map.get(metadata, "_price_modifiers", %{}) - - # For each key in _option_values that has values - option_values - |> Enum.filter(fn {_key, values} -> - is_list(values) and values != [] - end) - |> Enum.map(fn {key, values} -> - # Check if this option has price modifiers with at least one non-zero value - key_modifiers = Map.get(price_modifiers, key, %{}) - has_price = key_modifiers != %{} and has_nonzero_modifiers?(key_modifiers) - - base_spec = %{ - "key" => key, - "label" => humanize_key(key), - "type" => "select", - "options" => values, - "_discovered" => true - } - - if has_price do - base_spec - |> Map.put("affects_price", true) - |> Map.put("modifier_type", "fixed") - |> Map.put("allow_override", true) - |> Map.put("price_modifiers", key_modifiers) - else - base_spec - end - end) - end - - @doc """ - Gets price-affecting options for a specific product. - - Combines global and category options, then filters for price-affecting ones. - - If the product has `_option_values` in metadata, only returns options - for which the product has values. This allows products without certain - options (e.g., Size) to skip required validation for those options. - - Additionally, discovers options from product metadata that have price modifiers - but are not defined in the schema (e.g., imported products with custom options). - """ - def get_price_affecting_specs_for_product(product) do - schema_specs = - product - |> get_option_schema_for_product() - |> get_price_affecting_specs() - |> filter_by_product_option_values(product) - - # Discover additional options from product metadata - discovered_specs = discover_options_from_metadata(product) - - # Merge: schema specs take priority over discovered - merge_discovered_specs(schema_specs, discovered_specs) - end - - # Filters options - keeps only those for which product has values in metadata. - # If product has no _option_values, returns all options (backward compatibility). - # Also keeps schema specs that have image mappings AND their own defined options. - defp filter_by_product_option_values(specs, product) do - metadata = product.metadata || %{} - option_values = Map.get(metadata, "_option_values", %{}) - - # Only filter if product has _option_values (imported products) - if option_values != %{} do - image_mappings = Map.get(metadata, "_image_mappings", %{}) - - Enum.filter(specs, fn spec -> - key = spec["key"] - - case Map.get(option_values, key) do - values when is_list(values) and values != [] -> - true - - _ -> - # Keep schema specs that have their own defined options or image mappings - has_image_mappings = is_map(image_mappings[key]) and image_mappings[key] != %{} - has_own_options = is_list(spec["options"]) and spec["options"] != [] - has_own_options or has_image_mappings - end - end) - else - # No _option_values - return all options (backward compatibility) - specs - end - end - - # Discovers options from product metadata that have price modifiers with non-zero values. - # Creates "virtual" option specs for keys found in _option_values that also - # have corresponding _price_modifiers entries with at least one non-zero modifier. - defp discover_options_from_metadata(product) do - metadata = product.metadata || %{} - option_values = Map.get(metadata, "_option_values", %{}) - price_modifiers = Map.get(metadata, "_price_modifiers", %{}) - - # For each key in _option_values that has _price_modifiers with non-zero values - option_values - |> Enum.filter(fn {key, values} -> - key_modifiers = Map.get(price_modifiers, key, %{}) - - is_list(values) and values != [] and - key_modifiers != %{} and has_nonzero_modifiers?(key_modifiers) - end) - |> Enum.map(fn {key, values} -> - %{ - "key" => key, - "label" => humanize_key(key), - "type" => "select", - "options" => values, - "affects_price" => true, - "modifier_type" => "fixed", - "allow_override" => true, - "price_modifiers" => Map.get(price_modifiers, key, %{}), - "_discovered" => true - } - end) - end - - # Checks if a price modifiers map has at least one non-zero value. - # Used to determine if an option group actually affects pricing. - defp has_nonzero_modifiers?(modifiers) when is_map(modifiers) do - Enum.any?(modifiers, fn {_key, value} -> - decimal = - case value do - %{"value" => v} when is_binary(v) -> safe_parse_decimal(v) - v when is_binary(v) -> safe_parse_decimal(v) - _ -> nil - end - - decimal != nil and Decimal.compare(decimal, Decimal.new("0")) != :eq - end) - end - - defp has_nonzero_modifiers?(_), do: false - - defp safe_parse_decimal(value) when is_binary(value) do - case Decimal.parse(value) do - {decimal, ""} -> decimal - _ -> nil - end - end - - # Converts snake_case key to human-readable label. - # Example: "main_color" -> "Main Color" - defp humanize_key(key) do - key - |> String.replace("_", " ") - |> String.split(" ") - |> Enum.map_join(" ", &String.capitalize/1) - end - - # Merges schema specs with discovered specs. - # Schema specs take priority - discovered specs are only added if their key - # is not already in the schema. - defp merge_discovered_specs(schema_specs, discovered_specs) do - schema_keys = Enum.map(schema_specs, & &1["key"]) |> MapSet.new() - - # Only add discovered specs not already in schema - new_specs = - Enum.reject(discovered_specs, fn spec -> - MapSet.member?(schema_keys, spec["key"]) - end) - - schema_specs ++ new_specs - end - - @doc """ - Gets the price modifier for a specific option value. - - Returns a Decimal value representing the price delta for the selected option. - Returns Decimal.new("0") if the option has no modifier or option doesn't affect price. - - For "custom" modifier type, the modifiers come from product metadata. - - ## Examples - - opt = %{ - "key" => "material", - "affects_price" => true, - "price_modifiers" => %{"PLA" => "0", "PETG" => "10.00"} - } - - Options.get_price_modifier(opt, "PETG") - # => Decimal.new("10.00") - - Options.get_price_modifier(opt, "PLA") - # => Decimal.new("0") - """ - def get_price_modifier(%{"affects_price" => true, "price_modifiers" => modifiers}, value) - when is_map(modifiers) and is_binary(value) do - case Map.get(modifiers, value) do - nil -> - Decimal.new("0") - - modifier when is_binary(modifier) -> - parse_decimal(modifier) - end - end - - def get_price_modifier(_, _), do: Decimal.new("0") - - @doc """ - Gets the effective modifier info (type and value) for an option, checking for product overrides. - - Returns `{modifier_type, modifier_value}` tuple. - - If the option has `allow_override: true` and the product has an override in metadata, - uses the override type and value. Otherwise uses defaults from option's schema. - - ## Override Structure - - Overrides in metadata can be: - - New format: `%{"type" => "fixed", "value" => "10.00"}` - custom type and value - - Legacy format: `"10.00"` - just value, inherits option's default type - - ## Examples - - # Option with custom override (type + value) - opt = %{"key" => "material", "allow_override" => true, "modifier_type" => "fixed", ...} - metadata = %{"_price_modifiers" => %{"material" => %{"PETG" => %{"type" => "percent", "value" => "15"}}}} - get_effective_modifier_info(opt, "PETG", metadata) - # => {"percent", Decimal.new("15")} - - # Option with legacy override (just value) - metadata = %{"_price_modifiers" => %{"material" => %{"PETG" => "15.00"}}} - get_effective_modifier_info(opt, "PETG", metadata) - # => {"fixed", Decimal.new("15.00")} # Uses option's default type - """ - def get_effective_modifier_info(opt, selected_value, metadata) - - def get_effective_modifier_info( - %{"key" => key, "allow_override" => true, "modifier_type" => default_type} = opt, - selected_value, - metadata - ) - when is_binary(selected_value) and is_map(metadata) do - case get_override_info(metadata, key, selected_value) do - {:ok, type, value} -> - {type || default_type, Decimal.new(value)} - - :not_found -> - default_value = get_price_modifier(opt, selected_value) - {default_type, default_value} - end - end - - def get_effective_modifier_info( - %{"modifier_type" => default_type} = opt, - selected_value, - _metadata - ) do - # No allow_override - use defaults - {default_type, get_price_modifier(opt, selected_value)} - end - - def get_effective_modifier_info(opt, selected_value, _metadata) do - # Fallback: fixed type - {"fixed", get_price_modifier(opt, selected_value)} - end - - # Helper to get override info from metadata (type + value) - defp get_override_info(metadata, option_key, option_value) do - case metadata do - %{"_price_modifiers" => %{^option_key => modifiers}} when is_map(modifiers) -> - case Map.get(modifiers, option_value) do - # New format: %{"type" => "percent", "value" => "15"} - %{"type" => type, "value" => value} when is_binary(value) and value != "" -> - {:ok, type, value} - - %{"value" => value} when is_binary(value) and value != "" -> - {:ok, nil, value} - - # Legacy format: just a string value - value when is_binary(value) and value != "" -> - {:ok, nil, value} - - _ -> - :not_found - end - - _ -> - :not_found - end - end - - # Legacy function - kept for backward compatibility - def get_effective_modifier(opt, selected_value, metadata) do - {_type, value} = get_effective_modifier_info(opt, selected_value, metadata) - value - end - - @doc """ - Gets the price modifier for overridden values from product metadata. - - Used when option has `allow_override: true` and the product has custom values - stored in metadata under `_price_modifiers` key. - - ## Examples - - product_metadata = %{ - "_price_modifiers" => %{ - "material" => %{"PLA" => "0", "PETG" => "15.00"} - } - } - - Options.get_custom_price_modifier(product_metadata, "material", "PETG") - # => Decimal.new("15.00") - """ - def get_custom_price_modifier(metadata, option_key, option_value) - when is_map(metadata) and is_binary(option_key) and is_binary(option_value) do - case metadata do - %{"_price_modifiers" => %{^option_key => modifiers}} when is_map(modifiers) -> - case Map.get(modifiers, option_value) do - nil -> Decimal.new("0") - "" -> Decimal.new("0") - modifier when is_binary(modifier) -> Decimal.new(modifier) - _ -> Decimal.new("0") - end - - _ -> - Decimal.new("0") - end - end - - def get_custom_price_modifier(_, _, _), do: Decimal.new("0") - - @doc """ - Calculates total price modifier for selected specifications. - - Takes a list of price-affecting options, a map of selected values, and the base price. - Returns the final price after applying all modifiers. - - ## Options - - - `product_metadata` - Optional product metadata for custom modifier values. - When provided, options with `modifier_type: "custom"` will use price values - from `metadata["_price_modifiers"][option_key][option_value]`. - - ## Calculation Order - - 1. Sum all fixed modifiers (from schema price_modifiers) - 2. Sum all custom modifiers (from product metadata) - 3. Add to base price (intermediate price) - 4. Sum all percent modifiers - 5. Apply percent to intermediate price: intermediate * (1 + percent_sum/100) - - ## Examples - - specs = [ - %{"key" => "material", "affects_price" => true, "modifier_type" => "fixed", - "price_modifiers" => %{"PLA" => "0", "PETG" => "10.00"}}, - %{"key" => "finish", "affects_price" => true, "modifier_type" => "percent", - "price_modifiers" => %{"Standard" => "0", "Premium" => "20"}} - ] - - selected = %{"material" => "PETG", "finish" => "Premium"} - base_price = Decimal.new("20.00") - - Options.calculate_final_price(specs, selected, base_price) - # => Decimal.new("36.00") # ($20 + $10) * 1.20 - """ - def calculate_final_price(specs, selected_specs, base_price, product_metadata \\ %{}) - - def calculate_final_price(specs, selected_specs, base_price, product_metadata) - when is_list(specs) and is_map(selected_specs) do - base = if is_nil(base_price), do: Decimal.new("0"), else: base_price - metadata = product_metadata || %{} - - # For each option, get the effective type and value (considering overrides) - # Then group by effective type - modifiers = - Enum.map(specs, fn opt -> - selected_value = Map.get(selected_specs, opt["key"]) - - if selected_value do - {type, value} = get_effective_modifier_info(opt, selected_value, metadata) - {type, value} - else - nil - end - end) - |> Enum.reject(&is_nil/1) - - # Split into fixed and percent based on effective type - {fixed_modifiers, percent_modifiers} = - Enum.split_with(modifiers, fn {type, _value} -> type == "fixed" end) - - # Sum fixed modifiers - fixed_sum = - Enum.reduce(fixed_modifiers, Decimal.new("0"), fn {_type, value}, acc -> - Decimal.add(acc, value) - end) - - # Calculate intermediate price (base + fixed) - intermediate = Decimal.add(base, fixed_sum) - - # Sum percent modifiers - percent_sum = - Enum.reduce(percent_modifiers, Decimal.new("0"), fn {_type, value}, acc -> - Decimal.add(acc, value) - end) - - # Apply percent modifier: intermediate * (1 + percent_sum/100) - if Decimal.compare(percent_sum, Decimal.new("0")) == :gt do - multiplier = Decimal.add(Decimal.new("1"), Decimal.div(percent_sum, Decimal.new("100"))) - Decimal.mult(intermediate, multiplier) |> Decimal.round(2) - else - intermediate - end - end - - def calculate_final_price(_, _, base_price, _), do: base_price || Decimal.new("0") - - @doc """ - Calculates total modifier amount (for backward compatibility). - - This function returns just the sum of fixed modifiers. - For full calculation with percent modifiers, use `calculate_final_price/3`. - - ## Examples - - specs = [ - %{"key" => "material", "affects_price" => true, "price_modifiers" => %{"PETG" => "10.00"}}, - %{"key" => "color", "affects_price" => true, "price_modifiers" => %{"Gold" => "8.00"}} - ] - - selected = %{"material" => "PETG", "color" => "Gold"} - - Options.calculate_total_modifier(specs, selected) - # => Decimal.new("18.00") - """ - def calculate_total_modifier(specs, selected_specs) - when is_list(specs) and is_map(selected_specs) do - # Only sum fixed modifiers for backward compatibility - fixed_specs = - Enum.filter(specs, fn opt -> - Map.get(opt, "modifier_type", "fixed") == "fixed" - end) - - Enum.reduce(fixed_specs, Decimal.new("0"), fn opt, acc -> - selected_value = Map.get(selected_specs, opt["key"]) - modifier = get_price_modifier(opt, selected_value) - Decimal.add(acc, modifier) - end) - end - - def calculate_total_modifier(_, _), do: Decimal.new("0") - - @doc """ - Gets the min/max price range for a list of options. - - For each option, finds the minimum and maximum modifier values, - then calculates the final price range considering both fixed and percent modifiers. - - Returns `{min_price, max_price}` as Decimals. - - ## Examples - - specs = [ - %{"key" => "material", "modifier_type" => "fixed", - "price_modifiers" => %{"PLA" => "0", "PETG" => "10.00"}}, - %{"key" => "finish", "modifier_type" => "percent", - "price_modifiers" => %{"Standard" => "0", "Premium" => "20"}} - ] - base_price = Decimal.new("20.00") - - Options.get_price_range(specs, base_price) - # => {Decimal.new("20.00"), Decimal.new("36.00")} - """ - def get_price_range(specs, base_price, product_metadata \\ %{}) - - def get_price_range(specs, base_price, product_metadata) when is_list(specs) do - base = if is_nil(base_price), do: Decimal.new("0"), else: base_price - metadata = product_metadata || %{} - - if Enum.empty?(specs) do - {base, base} - else - # Separate by modifier type: fixed vs percent - {fixed_specs, percent_specs} = - Enum.split_with(specs, fn opt -> - Map.get(opt, "modifier_type", "fixed") == "fixed" - end) - - # Calculate min/max for fixed modifiers (considering overrides) - {fixed_min, fixed_max} = get_effective_modifier_range(fixed_specs, metadata) - - # Calculate min/max for percent modifiers (considering overrides) - {percent_min, percent_max} = get_effective_modifier_range(percent_specs, metadata) - - # Calculate min price: (base + fixed_min) * (1 + percent_min/100) - min_intermediate = Decimal.add(base, fixed_min) - - min_price = - if Decimal.compare(percent_min, Decimal.new("0")) == :gt do - multiplier = - Decimal.add(Decimal.new("1"), Decimal.div(percent_min, Decimal.new("100"))) - - Decimal.mult(min_intermediate, multiplier) |> Decimal.round(2) - else - min_intermediate - end - - # Calculate max price: (base + fixed_max) * (1 + percent_max/100) - max_intermediate = Decimal.add(base, fixed_max) - - max_price = - if Decimal.compare(percent_max, Decimal.new("0")) == :gt do - multiplier = - Decimal.add(Decimal.new("1"), Decimal.div(percent_max, Decimal.new("100"))) - - Decimal.mult(max_intermediate, multiplier) |> Decimal.round(2) - else - max_intermediate - end - - {min_price, max_price} - end - end - - def get_price_range(_, base_price, _) do - base = if is_nil(base_price), do: Decimal.new("0"), else: base_price - {base, base} - end - - @doc """ - Gets the min/max modifier range for a list of options. - - Returns `{min_total, max_total}` as Decimals. - """ - def get_modifier_range(specs) when is_list(specs) do - Enum.reduce(specs, {Decimal.new("0"), Decimal.new("0")}, fn opt, {min_acc, max_acc} -> - modifiers = opt["price_modifiers"] || %{} - values = Map.values(modifiers) |> Enum.map(&parse_decimal/1) - - if Enum.empty?(values) do - {min_acc, max_acc} - else - { - Decimal.add(min_acc, Enum.min(values)), - Decimal.add(max_acc, Enum.max(values)) - } - end - end) - end - - def get_modifier_range(_), do: {Decimal.new("0"), Decimal.new("0")} - - @doc """ - Gets the min/max modifier range for options, considering product overrides. - - For options with `allow_override: true`, checks if product has override values - in metadata and uses those instead of defaults. - - Returns `{min_total, max_total}` as Decimals. - """ - def get_effective_modifier_range(specs, metadata) when is_list(specs) and is_map(metadata) do - Enum.reduce(specs, {Decimal.new("0"), Decimal.new("0")}, fn opt, {min_acc, max_acc} -> - option_key = opt["key"] - allow_override = opt["allow_override"] == true - default_modifiers = opt["price_modifiers"] || %{} - - # Get modifiers: check for overrides first, then fall back to defaults - modifiers = - if allow_override do - case metadata do - %{"_price_modifiers" => %{^option_key => mods}} when is_map(mods) -> - # Merge: override values take precedence - Map.merge(default_modifiers, mods) - - _ -> - default_modifiers - end - else - default_modifiers - end - - values = Map.values(modifiers) |> Enum.map(&parse_decimal/1) - - if Enum.empty?(values) do - {min_acc, max_acc} - else - { - Decimal.add(min_acc, Enum.min(values)), - Decimal.add(max_acc, Enum.max(values)) - } - end - end) - end - - def get_effective_modifier_range(specs, _metadata) when is_list(specs) do - get_modifier_range(specs) - end - - def get_effective_modifier_range(_, _), do: {Decimal.new("0"), Decimal.new("0")} - - defp parse_decimal(value) when is_binary(value) do - case Decimal.parse(value) do - {decimal, ""} -> - decimal - - _ -> - require Logger - Logger.warning("[Shop.Options] Invalid price modifier value: #{inspect(value)}") - Decimal.new("0") - end - end - - defp parse_decimal(value) do - require Logger - - if value not in [nil, ""] do - Logger.warning("[Shop.Options] Unexpected price modifier type: #{inspect(value)}") - end - - Decimal.new("0") - end - - # ============================================ - # PRIVATE - # ============================================ - - defp repo, do: PhoenixKit.RepoHelper.repo() - - defp uuid_string?(string) when is_binary(string) do - match?({:ok, _}, Ecto.UUID.cast(string)) - end -end diff --git a/lib/modules/shop/schemas/cart.ex b/lib/modules/shop/schemas/cart.ex deleted file mode 100644 index b77b0de44..000000000 --- a/lib/modules/shop/schemas/cart.ex +++ /dev/null @@ -1,248 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Cart do - @moduledoc """ - Shopping cart schema with support for guest and authenticated users. - - ## Status Lifecycle - - - `active` - Cart is active and can be modified - - `merged` - Guest cart was merged into user cart after login - - `converted` - Cart was converted to an order - - `abandoned` - Cart was marked as abandoned (no activity) - - `expired` - Cart expired (past expires_at) - - ## Identity - - Each cart has either `user_uuid` (for authenticated users) or `session_id` (for guests). - Guest carts have an `expires_at` timestamp (30 days by default). - When a guest logs in, their cart is either converted to a user cart or merged - with an existing user cart. - """ - - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Modules.Billing.PaymentOption - alias PhoenixKit.Modules.Shop.CartItem - alias PhoenixKit.Modules.Shop.ShippingMethod - alias PhoenixKit.Users.Auth.User - alias PhoenixKit.Utils.Date, as: UtilsDate - - @statuses ~w(active merged converted abandoned expired) - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_shop_carts" do - # Identity - belongs_to :user, User, foreign_key: :user_uuid, references: :uuid, type: UUIDv7 - field :session_id, :string - - # Status - field :status, :string, default: "active" - - # Shipping - belongs_to :shipping_method, ShippingMethod, - foreign_key: :shipping_method_uuid, - references: :uuid, - type: UUIDv7 - - field :shipping_country, :string - - # Payment - belongs_to :payment_option, PaymentOption, - foreign_key: :payment_option_uuid, - references: :uuid, - type: UUIDv7 - - # Totals (cached) - field :subtotal, :decimal, default: Decimal.new("0") - field :shipping_amount, :decimal, default: Decimal.new("0") - field :tax_amount, :decimal, default: Decimal.new("0") - field :discount_amount, :decimal, default: Decimal.new("0") - field :total, :decimal, default: Decimal.new("0") - field :currency, :string, default: "USD" - - # Discount - field :discount_code, :string - - # Calculated - field :total_weight_grams, :integer, default: 0 - field :items_count, :integer, default: 0 - - # Metadata - field :metadata, :map, default: %{} - - # Tracking - field :expires_at, :utc_datetime - field :converted_at, :utc_datetime - field :merged_into_cart_uuid, UUIDv7 - - has_many :items, CartItem, foreign_key: :cart_uuid, references: :uuid - - timestamps(type: :utc_datetime) - end - - @doc """ - Changeset for cart creation and updates. - """ - def changeset(cart, attrs) do - cart - |> cast(attrs, [ - :user_uuid, - :session_id, - :status, - :shipping_method_uuid, - :shipping_country, - :payment_option_uuid, - :subtotal, - :shipping_amount, - :tax_amount, - :discount_amount, - :total, - :currency, - :discount_code, - :total_weight_grams, - :items_count, - :metadata, - :expires_at, - :converted_at, - :merged_into_cart_uuid - ]) - |> validate_inclusion(:status, @statuses) - |> validate_length(:currency, is: 3) - |> validate_length(:shipping_country, max: 2) - |> validate_identity() - |> maybe_set_expires_at() - end - - @doc """ - Changeset for updating cart totals. - """ - def totals_changeset(cart, attrs) do - cart - |> cast(attrs, [ - :subtotal, - :shipping_amount, - :tax_amount, - :discount_amount, - :total, - :total_weight_grams, - :items_count - ]) - end - - @doc """ - Changeset for setting shipping. - """ - def shipping_changeset(cart, attrs) do - cart - |> cast(attrs, [ - :shipping_method_uuid, - :shipping_country, - :shipping_amount - ]) - end - - @doc """ - Changeset for setting payment option. - """ - def payment_changeset(cart, attrs) do - cart - |> cast(attrs, [:payment_option_uuid]) - end - - @doc """ - Changeset for status transitions. - """ - def status_changeset(cart, new_status, extra_attrs \\ %{}) do - attrs = Map.merge(%{status: new_status}, extra_attrs) - - cart - |> cast(attrs, [:status, :converted_at, :merged_into_cart_uuid]) - |> validate_status_transition(cart.status, new_status) - end - - @doc """ - Returns true if cart is active. - """ - def active?(%__MODULE__{status: "active"}), do: true - def active?(_), do: false - - @doc """ - Returns true if cart is a guest cart (no user_uuid). - """ - def guest?(%__MODULE__{user_uuid: nil}), do: true - def guest?(_), do: false - - @doc """ - Returns true if cart is empty. - """ - def empty?(%__MODULE__{items_count: 0}), do: true - def empty?(%__MODULE__{items_count: nil}), do: true - def empty?(_), do: false - - @doc """ - Returns true if cart can be converted to order. - """ - def convertible?(%__MODULE__{status: "active", items_count: count}) when count > 0, do: true - def convertible?(_), do: false - - @doc """ - Returns true if cart has expired. - """ - def expired?(%__MODULE__{expires_at: nil}), do: false - - def expired?(%__MODULE__{expires_at: expires_at}) do - DateTime.compare(UtilsDate.utc_now(), expires_at) == :gt - end - - @doc """ - Returns list of valid status values. - """ - def statuses, do: @statuses - - # Private helpers - - defp validate_identity(changeset) do - user_uuid = get_field(changeset, :user_uuid) - session_id = get_field(changeset, :session_id) - - if is_nil(user_uuid) and is_nil(session_id) do - add_error(changeset, :base, "Either user_uuid or session_id must be set") - else - changeset - end - end - - defp validate_status_transition(changeset, from, to) do - valid_transitions = %{ - "active" => ~w(converting merged converted abandoned expired), - "converting" => ~w(converted active), - "merged" => [], - "converted" => [], - "abandoned" => ~w(active), - "expired" => [] - } - - allowed = Map.get(valid_transitions, from, []) - - if to in allowed or from == to do - changeset - else - add_error(changeset, :status, "cannot transition from #{from} to #{to}") - end - end - - defp maybe_set_expires_at(changeset) do - user_uuid = get_field(changeset, :user_uuid) - session_id = get_field(changeset, :session_id) - expires_at = get_field(changeset, :expires_at) - - # Guest carts expire in 30 days - if is_nil(user_uuid) and not is_nil(session_id) and is_nil(expires_at) do - expires = UtilsDate.utc_now() |> DateTime.add(30, :day) |> DateTime.truncate(:second) - put_change(changeset, :expires_at, expires) - else - changeset - end - end -end diff --git a/lib/modules/shop/schemas/cart_item.ex b/lib/modules/shop/schemas/cart_item.ex deleted file mode 100644 index 03d6560cd..000000000 --- a/lib/modules/shop/schemas/cart_item.ex +++ /dev/null @@ -1,234 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.CartItem do - @moduledoc """ - Cart item schema with price snapshot for consistency. - - When a product is added to the cart, we snapshot the current price and product - details. This ensures that: - - 1. Price changes after adding don't affect the cart total unexpectedly - 2. If the product is deleted, we still have the title and other info - 3. We can show users when prices have changed since they added items - - ## Fields - - - `cart_uuid` - Reference to the cart (required) - - `product_uuid` - Reference to the product (nullable, ON DELETE SET NULL) - - `product_title` - Product title snapshot (required) - - `product_slug` - Product slug snapshot - - `product_sku` - Product SKU snapshot - - `product_image` - Product image URL snapshot - - `unit_price` - Price per unit at time of adding (required) - - `compare_at_price` - Original price for showing discounts - - `quantity` - Number of items (required, > 0) - - `line_total` - Calculated: unit_price * quantity - - `weight_grams` - Weight for shipping calculation - - `taxable` - Whether item is taxable - - `selected_specs` - JSON object for specification-based pricing (e.g., {"material": "PETG", "color": "Gold"}) - """ - - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Modules.Shop.Cart - alias PhoenixKit.Modules.Shop.Product - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_shop_cart_items" do - belongs_to :cart, Cart, foreign_key: :cart_uuid, references: :uuid, type: UUIDv7 - belongs_to :product, Product, foreign_key: :product_uuid, references: :uuid, type: UUIDv7 - field :variant_uuid, UUIDv7 - - # Snapshot - field :product_title, :string - field :product_slug, :string - field :product_sku, :string - field :product_image, :string - - # Pricing (snapshot) - field :unit_price, :decimal - field :compare_at_price, :decimal - field :currency, :string, default: "USD" - - # Quantity - field :quantity, :integer, default: 1 - - # Calculated - field :line_total, :decimal - - # Physical - field :weight_grams, :integer, default: 0 - field :taxable, :boolean, default: true - - # Specification-based pricing - field :selected_specs, :map, default: %{} - - field :metadata, :map, default: %{} - - timestamps(type: :utc_datetime) - end - - @doc """ - Changeset for cart item creation and updates. - """ - def changeset(item, attrs) do - item - |> cast(attrs, [ - :cart_uuid, - :product_uuid, - :variant_uuid, - :product_title, - :product_slug, - :product_sku, - :product_image, - :unit_price, - :compare_at_price, - :currency, - :quantity, - :line_total, - :weight_grams, - :taxable, - :selected_specs, - :metadata - ]) - |> validate_required([:cart_uuid, :product_title, :unit_price, :quantity]) - |> validate_number(:quantity, greater_than: 0) - |> validate_number(:unit_price, greater_than_or_equal_to: 0) - |> validate_length(:currency, is: 3) - |> calculate_line_total() - |> foreign_key_constraint(:cart_uuid) - |> foreign_key_constraint(:product_uuid) - end - - @doc """ - Creates changeset attributes from a product. - - ## Parameters - - - `product` - The Product struct - - `quantity` - Number of items (default: 1) - - `language` - Language code for localized fields (default: system default) - - ## Examples - - iex> from_product(product, 2) - %{ - product_uuid: "01234567-...", - product_title: "Widget", - product_slug: "widget", - unit_price: Decimal.new("19.99"), - quantity: 2, - ... - } - - iex> from_product(product, 1, "ru") - %{product_title: "Виджет", product_slug: "vidzhet", ...} - """ - def from_product(%Product{} = product, quantity \\ 1, language \\ nil) do - lang = language || default_language() - - %{ - product_uuid: product.uuid, - product_title: get_localized_string(product.title, lang), - product_slug: get_localized_string(product.slug, lang), - product_image: get_product_image_url(product), - unit_price: product.price, - compare_at_price: product.compare_at_price, - currency: product.currency, - quantity: quantity, - weight_grams: product.weight_grams || 0, - taxable: product.taxable - } - end - - # Get product image URL, preferring new Storage system over legacy - defp get_product_image_url(%Product{featured_image_uuid: id}) when is_binary(id) do - alias PhoenixKit.Modules.Storage.URLSigner - - try do - URLSigner.signed_url(id, "medium") - rescue - _ -> nil - end - end - - defp get_product_image_url(%Product{featured_image: url}) when is_binary(url), do: url - defp get_product_image_url(_), do: nil - - # Extract string from localized JSONB map field - defp get_localized_string(nil, _lang), do: nil - defp get_localized_string(value, _lang) when is_binary(value), do: value - - defp get_localized_string(map, lang) when is_map(map) do - map[lang] || map[default_language()] || first_value(map) - end - - defp get_localized_string(_value, _lang), do: nil - - defp first_value(map) when map == %{}, do: nil - defp first_value(map), do: map |> Map.values() |> List.first() - - defp default_language do - alias PhoenixKit.Modules.Shop.Translations - Translations.default_language() - end - - @doc """ - Returns true if product data has changed since the item was added. - Useful for showing price change warnings. - """ - def product_changed?(%__MODULE__{product_uuid: nil}, _product), do: true - - def product_changed?(%__MODULE__{} = item, %Product{} = product) do - Decimal.compare(item.unit_price, product.price) != :eq - end - - @doc """ - Returns the price difference if the product price has changed. - Positive = price increased, Negative = price decreased. - """ - def price_difference(%__MODULE__{} = item, %Product{} = product) do - Decimal.sub(product.price, item.unit_price) - end - - @doc """ - Returns true if this item is on sale (has compare_at_price > unit_price). - """ - def on_sale?(%__MODULE__{compare_at_price: nil}), do: false - - def on_sale?(%__MODULE__{compare_at_price: compare, unit_price: price}) do - Decimal.compare(compare, price) == :gt - end - - @doc """ - Returns discount percentage for sale items. - """ - def discount_percentage(%__MODULE__{} = item) do - if on_sale?(item) do - diff = Decimal.sub(item.compare_at_price, item.unit_price) - - diff - |> Decimal.div(item.compare_at_price) - |> Decimal.mult(100) - |> Decimal.round(0) - |> Decimal.to_integer() - else - 0 - end - end - - @doc """ - Returns true if the product has been deleted (product_uuid is nil after SET NULL). - """ - def product_deleted?(%__MODULE__{product_uuid: nil}), do: true - def product_deleted?(_), do: false - - # Private helpers - - defp calculate_line_total(changeset) do - quantity = get_field(changeset, :quantity) || 1 - unit_price = get_field(changeset, :unit_price) || Decimal.new("0") - line_total = Decimal.mult(unit_price, quantity) - put_change(changeset, :line_total, line_total) - end -end diff --git a/lib/modules/shop/schemas/category.ex b/lib/modules/shop/schemas/category.ex deleted file mode 100644 index 10f754fbd..000000000 --- a/lib/modules/shop/schemas/category.ex +++ /dev/null @@ -1,358 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Category do - @moduledoc """ - Category schema for product organization. - - Supports hierarchical nesting via parent_uuid. - - ## Fields - - - `name` - Category name (required) - - `slug` - URL-friendly identifier (unique) - - `description` - Category description - - `featured_product_uuid` - Featured product for fallback image - - `parent_uuid` - Parent category for nesting - - `position` - Sort order - - `status` - Category status: "active", "hidden", "archived" - - `metadata` - JSONB for custom fields - - `option_schema` - Category-specific product option definitions (JSONB array) - - ## Status Values - - - `active` - Category and products visible in storefront - - `unlisted` - Category hidden from menu, but products still visible - - `hidden` - Category and all products hidden from storefront - """ - - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Modules.Storage.URLSigner - - @type t :: %__MODULE__{} - - @statuses ~w(active unlisted hidden) - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_shop_categories" do - # Localized fields (JSONB maps: %{"en" => "value", "ru" => "значение"}) - field :name, :map, default: %{} - field :slug, :map, default: %{} - field :description, :map, default: %{} - - # Non-localized fields - field :image_uuid, Ecto.UUID - field :position, :integer, default: 0 - field :status, :string, default: "active" - field :metadata, :map, default: %{} - field :option_schema, {:array, :map}, default: [] - - # Self-referential for nesting - belongs_to :parent, __MODULE__, foreign_key: :parent_uuid, references: :uuid, type: UUIDv7 - has_many :children, __MODULE__, foreign_key: :parent_uuid, references: :uuid - - # Products in this category - has_many :products, PhoenixKit.Modules.Shop.Product, - foreign_key: :category_uuid, - references: :uuid - - # Featured product for fallback image - belongs_to :featured_product, PhoenixKit.Modules.Shop.Product, - foreign_key: :featured_product_uuid, - references: :uuid, - type: UUIDv7 - - timestamps(type: :utc_datetime) - end - - @doc "Returns list of valid category statuses" - def statuses, do: @statuses - - @localized_fields [:name, :slug, :description] - - @doc """ - Changeset for category creation and updates. - """ - def changeset(category, attrs) do - category - |> cast(attrs, [ - :name, - :slug, - :description, - :image_uuid, - :featured_product_uuid, - :parent_uuid, - :position, - :status, - :metadata, - :option_schema - ]) - |> normalize_map_fields(@localized_fields) - |> validate_localized_required(:name) - |> validate_number(:position, greater_than_or_equal_to: 0) - |> validate_inclusion(:status, @statuses) - |> maybe_generate_slug() - |> validate_no_circular_parent() - |> unique_constraint(:slug, name: "idx_shop_categories_slug_primary") - end - - @doc """ - Returns the list of localized field names. - """ - def localized_fields, do: @localized_fields - - @doc """ - Returns the image URL for a category. - - Priority: - 1. Storage media (image_uuid) if available - 2. Featured product's featured_image_uuid (requires :featured_product preloaded) - 3. Featured product's legacy featured_image URL (requires :featured_product preloaded) - 4. nil if no image - - ## Options - - `:size` - Storage dimension to use (default: "large") - """ - def get_image_url(category, opts \\ []) - - # Priority 1: direct Storage image - def get_image_url(%__MODULE__{image_uuid: image_uuid}, opts) - when is_binary(image_uuid) and image_uuid != "" do - size = Keyword.get(opts, :size, "large") - URLSigner.signed_url(image_uuid, size) - end - - # Priority 2: featured product's Storage image (preloaded) - def get_image_url( - %__MODULE__{featured_product: %{featured_image_uuid: fid}}, - opts - ) - when is_binary(fid) and fid != "" do - size = Keyword.get(opts, :size, "large") - URLSigner.signed_url(fid, size) - end - - # Priority 3: featured product's legacy image URL (preloaded) - def get_image_url( - %__MODULE__{featured_product: %{featured_image: url}}, - _opts - ) - when is_binary(url) and url != "" do - url - end - - # No image available - def get_image_url(_category, _opts), do: nil - - @doc """ - Returns true if category is a root category (no parent). - """ - def root?(%__MODULE__{parent_uuid: nil}), do: true - def root?(%__MODULE__{}), do: false - - @doc """ - Returns true if category has children. - """ - def has_children?(%__MODULE__{children: children}) when is_list(children) do - children != [] - end - - def has_children?(_), do: false - - @doc """ - Returns true if category is active (visible in storefront). - """ - def active?(%__MODULE__{status: "active"}), do: true - def active?(_), do: false - - @doc """ - Returns true if category is unlisted (not in menu, but products visible). - """ - def unlisted?(%__MODULE__{status: "unlisted"}), do: true - def unlisted?(_), do: false - - @doc """ - Returns true if category is hidden (category and products not visible). - """ - def hidden?(%__MODULE__{status: "hidden"}), do: true - def hidden?(_), do: false - - @doc """ - Returns true if products in this category should be visible in storefront. - Products are visible when category is active or unlisted. - """ - def products_visible?(%__MODULE__{status: status}) when status in ["active", "unlisted"], - do: true - - def products_visible?(_), do: false - - @doc """ - Returns true if category should appear in category menu/list. - Only active categories appear in the menu. - """ - def show_in_menu?(%__MODULE__{status: "active"}), do: true - def show_in_menu?(_), do: false - - @doc """ - Returns the full path of category names from root to this category. - Requires parent to be preloaded. - - ## Parameters - - - `category` - Category struct with parent preloaded - - `language` - Language code for localized names (default: system default) - - ## Examples - - iex> breadcrumb_path(category, "en") - ["Home", "Electronics", "Phones"] - """ - def breadcrumb_path(category, language \\ nil) - - def breadcrumb_path(%__MODULE__{parent: nil} = category, language) do - [get_localized_name(category, language)] - end - - def breadcrumb_path(%__MODULE__{parent: %__MODULE__{} = parent} = category, language) do - breadcrumb_path(parent, language) ++ [get_localized_name(category, language)] - end - - def breadcrumb_path(%__MODULE__{} = category, language) do - [get_localized_name(category, language)] - end - - # Extract localized name from JSONB map - defp get_localized_name(%__MODULE__{name: name}, language) do - lang = language || default_language() - - case name do - nil -> nil - map when is_map(map) -> map[lang] || first_value(map) - value when is_binary(value) -> value - _ -> nil - end - end - - defp first_value(map) when map == %{}, do: nil - defp first_value(map), do: map |> Map.values() |> List.first() - - defp default_language do - alias PhoenixKit.Modules.Shop.Translations - Translations.default_language() - end - - # Remove empty string values from map fields - defp normalize_map_fields(changeset, fields) do - Enum.reduce(fields, changeset, fn field, acc -> - case get_change(acc, field) do - nil -> - acc - - map when is_map(map) -> - cleaned = - map - |> Enum.reject(fn {_k, v} -> v in [nil, ""] end) - |> Map.new() - - put_change(acc, field, cleaned) - - _ -> - acc - end - end) - end - - # Validate that localized field has value for default language - defp validate_localized_required(changeset, field) do - value = get_field(changeset, field) || %{} - default_lang = default_language() - - if Map.get(value, default_lang) in [nil, ""] do - add_error(changeset, field, "#{default_lang} translation is required") - else - changeset - end - end - - # Generate slug from name for each language - defp maybe_generate_slug(changeset) do - name_map = get_field(changeset, :name) || %{} - slug_map = get_field(changeset, :slug) || %{} - - # For each language with a name but no slug, generate one - updated_slugs = - Enum.reduce(name_map, slug_map, fn {lang, name}, acc -> - if Map.get(acc, lang) in [nil, ""] and name not in [nil, ""] do - generated = slugify(name) - Map.put(acc, lang, generated) - else - acc - end - end) - - if updated_slugs != slug_map do - put_change(changeset, :slug, updated_slugs) - else - changeset - end - end - - # Prevent category from being its own parent or creating circular references - defp validate_no_circular_parent(changeset) do - parent_uuid = get_change(changeset, :parent_uuid) - category_uuid = changeset.data.uuid - - cond do - is_nil(parent_uuid) -> - changeset - - parent_uuid == category_uuid -> - add_error(changeset, :parent_uuid, "cannot be self") - - true -> - check_ancestor_cycle(changeset, category_uuid, parent_uuid) - end - end - - defp check_ancestor_cycle(changeset, target_uuid, current_uuid) do - check_ancestor_cycle(changeset, target_uuid, current_uuid, %{}) - end - - defp check_ancestor_cycle(changeset, target_uuid, current_uuid, visited) do - if Map.has_key?(visited, current_uuid) do - changeset - else - repo = PhoenixKit.RepoHelper.repo() - - case repo.get_by(__MODULE__, uuid: current_uuid) do - nil -> - changeset - - %{parent_uuid: nil} -> - changeset - - %{parent_uuid: ^target_uuid} -> - add_error(changeset, :parent_uuid, "would create a circular reference") - - %{parent_uuid: next_uuid} -> - check_ancestor_cycle( - changeset, - target_uuid, - next_uuid, - Map.put(visited, current_uuid, true) - ) - end - end - end - - defp slugify(text) when is_binary(text) do - text - |> String.downcase() - |> String.replace(~r/[^\w\s-]/, "") - |> String.replace(~r/\s+/, "-") - |> String.replace(~r/-+/, "-") - |> String.trim("-") - end - - defp slugify(_), do: "" -end diff --git a/lib/modules/shop/schemas/import_config.ex b/lib/modules/shop/schemas/import_config.ex deleted file mode 100644 index c50b1068f..000000000 --- a/lib/modules/shop/schemas/import_config.ex +++ /dev/null @@ -1,236 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.ImportConfig do - @moduledoc """ - ImportConfig schema for configurable CSV import filtering. - - Allows defining custom filtering rules per import type instead of - using hardcoded keywords. - - ## Fields - - - `name` - Config name (e.g., "decor_3d", "general") - - `include_keywords` - Keywords that must be present for inclusion - - `exclude_keywords` - Keywords that cause exclusion - - `exclude_phrases` - Phrases that cause exclusion - - `skip_filter` - If true, skip all filtering (import everything) - - `category_rules` - List of maps: `[%{keywords: [...], slug: "category-slug"}]` - - `default_category_slug` - Fallback category when no rules match - - `required_columns` - CSV columns that must be present - - `is_default` - Use this config when none specified - - `active` - Config is available for use - - `option_mappings` - Mappings from CSV option columns to global options - - ## Example Category Rules - - [ - %{"keywords" => ["shelf"], "slug" => "shelves"}, - %{"keywords" => ["mask"], "slug" => "masks"}, - %{"keywords" => ["vase", "planter"], "slug" => "vases-planters"} - ] - - ## Example Option Mappings - - [ - %{ - "csv_name" => "Cup Color", - "slot_key" => "cup_color", - "source_key" => "color", - "auto_add" => true, - "label" => %{"en" => "Cup Color", "ru" => "Цвет чашки"} - }, - %{ - "csv_name" => "Liquid Color", - "slot_key" => "liquid_color", - "source_key" => "color", - "auto_add" => true - } - ] - """ - - use Ecto.Schema - import Ecto.Changeset - - @type t :: %__MODULE__{} - - @default_required_columns ["Handle", "Title", "Variant Price"] - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_shop_import_configs" do - field :name, :string - - # Filtering keywords (PostgreSQL TEXT[] arrays) - field :include_keywords, {:array, :string}, default: [] - field :exclude_keywords, {:array, :string}, default: [] - field :exclude_phrases, {:array, :string}, default: [] - field :skip_filter, :boolean, default: false - - # Category assignment rules (JSONB) - field :category_rules, {:array, :map}, default: [] - field :default_category_slug, :string - - # CSV validation - field :required_columns, {:array, :string}, default: @default_required_columns - - # Status flags - field :is_default, :boolean, default: false - field :active, :boolean, default: true - - # Image migration options - field :download_images, :boolean, default: false - - # Option mappings for CSV import (JSONB) - field :option_mappings, {:array, :map}, default: [] - - timestamps(type: :utc_datetime) - end - - @doc """ - Changeset for creating/updating an import config. - """ - def changeset(config \\ %__MODULE__{}, attrs) do - config - |> cast(attrs, [ - :name, - :include_keywords, - :exclude_keywords, - :exclude_phrases, - :skip_filter, - :category_rules, - :default_category_slug, - :required_columns, - :is_default, - :active, - :download_images, - :option_mappings - ]) - |> validate_required([:name]) - |> unique_constraint(:name) - |> unique_constraint(:uuid) - |> validate_category_rules() - |> validate_option_mappings() - end - - defp validate_category_rules(changeset) do - case get_field(changeset, :category_rules) do - nil -> - changeset - - rules when is_list(rules) -> - if Enum.all?(rules, &valid_category_rule?/1) do - changeset - else - add_error( - changeset, - :category_rules, - "each rule must have 'keywords' (list) and 'slug' (string)" - ) - end - - _ -> - add_error(changeset, :category_rules, "must be a list of rule objects") - end - end - - defp valid_category_rule?(rule) when is_map(rule) do - keywords = rule["keywords"] || rule[:keywords] - slug = rule["slug"] || rule[:slug] - - is_list(keywords) and is_binary(slug) and slug != "" - end - - defp valid_category_rule?(_), do: false - - defp validate_option_mappings(changeset) do - case get_field(changeset, :option_mappings) do - nil -> - changeset - - mappings when is_list(mappings) -> - if Enum.all?(mappings, &valid_option_mapping?/1) do - changeset - else - add_error( - changeset, - :option_mappings, - "each mapping must have 'csv_name' (string) and 'slot_key' (string)" - ) - end - - _ -> - add_error(changeset, :option_mappings, "must be a list of mapping objects") - end - end - - defp valid_option_mapping?(mapping) when is_map(mapping) do - csv_name = mapping["csv_name"] || mapping[:csv_name] - slot_key = mapping["slot_key"] || mapping[:slot_key] - - is_binary(csv_name) and csv_name != "" and - is_binary(slot_key) and slot_key != "" - end - - defp valid_option_mapping?(_), do: false - - @doc """ - Returns default required columns for CSV validation. - """ - def default_required_columns, do: @default_required_columns - - @doc """ - Builds a config struct from legacy hardcoded values (for backward compatibility). - """ - def from_legacy_defaults do - %__MODULE__{ - name: "legacy_default", - include_keywords: - ~w(3d printed shelf mask vase planter holder stand lamp light figurine sculpture statue), - exclude_keywords: ~w(decal sticker mural wallpaper poster tapestry canvas), - exclude_phrases: ["wall art"], - skip_filter: false, - category_rules: [ - %{"keywords" => ["shelf"], "slug" => "shelves"}, - %{"keywords" => ["mask"], "slug" => "masks"}, - %{"keywords" => ["vase", "planter"], "slug" => "vases-planters"}, - %{"keywords" => ["holder", "stand"], "slug" => "holders-stands"}, - %{"keywords" => ["lamp", "light"], "slug" => "lamps"}, - %{"keywords" => ["figurine", "sculpture", "statue"], "slug" => "figurines"} - ], - default_category_slug: "other-3d", - required_columns: @default_required_columns, - is_default: true, - active: true - } - end - - @doc """ - Builds a default config for Prom.ua imports (no filtering, import everything). - """ - def from_prom_ua_defaults do - %__MODULE__{ - name: "prom_ua_default", - skip_filter: true, - category_rules: [], - required_columns: ["Назва_позиції", "Ціна"], - is_default: false, - active: true, - download_images: true, - include_keywords: [], - exclude_keywords: [], - exclude_phrases: [] - } - end - - @doc """ - Builds a "no filter" config that imports everything. - """ - def no_filter_config do - %__MODULE__{ - name: "no_filter", - skip_filter: true, - category_rules: [], - required_columns: @default_required_columns, - is_default: false, - active: true - } - end -end diff --git a/lib/modules/shop/schemas/import_log.ex b/lib/modules/shop/schemas/import_log.ex deleted file mode 100644 index e5b93a61a..000000000 --- a/lib/modules/shop/schemas/import_log.ex +++ /dev/null @@ -1,170 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.ImportLog do - @moduledoc """ - ImportLog schema for tracking CSV import history. - - ## Fields - - - `filename` - Original filename (required) - - `file_path` - Server path to uploaded file - - `status` - pending | processing | completed | failed - - `total_rows` - Total rows in CSV - - `processed_rows` - Rows processed so far - - `imported_count` - New products created - - `updated_count` - Existing products updated - - `skipped_count` - Products skipped (filtered) - - `error_count` - Products with errors - - `options` - Import options (JSONB) - - `error_details` - List of error objects - - `started_at` - Processing start time - - `completed_at` - Processing end time - - `user_uuid` - User who initiated import - """ - - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Users.Auth.User - alias PhoenixKit.Utils.Date, as: UtilsDate - - @statuses ["pending", "processing", "completed", "failed"] - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_shop_import_logs" do - field :filename, :string - field :file_path, :string - field :status, :string, default: "pending" - - # Statistics - field :total_rows, :integer, default: 0 - field :processed_rows, :integer, default: 0 - field :imported_count, :integer, default: 0 - field :updated_count, :integer, default: 0 - field :skipped_count, :integer, default: 0 - field :error_count, :integer, default: 0 - - # Metadata - field :options, :map, default: %{} - field :error_details, {:array, :map}, default: [] - field :product_uuids, {:array, Ecto.UUID}, default: [] - - # Timing - field :started_at, :utc_datetime - field :completed_at, :utc_datetime - - # Associations - belongs_to :user, User, foreign_key: :user_uuid, references: :uuid, type: UUIDv7 - - timestamps(type: :utc_datetime) - end - - @doc """ - Changeset for creating a new import log. - """ - def create_changeset(import_log \\ %__MODULE__{}, attrs) do - import_log - |> cast(attrs, [:filename, :file_path, :options, :user_uuid]) - |> validate_required([:filename]) - end - - @doc """ - Changeset for updating import log status and stats. - """ - def update_changeset(import_log, attrs) do - import_log - |> cast(attrs, [ - :status, - :total_rows, - :processed_rows, - :imported_count, - :updated_count, - :skipped_count, - :error_count, - :error_details, - :started_at, - :completed_at - ]) - |> validate_inclusion(:status, @statuses) - end - - @doc """ - Mark import as started. - """ - def start_changeset(import_log, total_rows) do - import_log - |> change(%{ - status: "processing", - total_rows: total_rows, - started_at: UtilsDate.utc_now() - }) - end - - @doc """ - Update progress during import. - """ - def progress_changeset(import_log, attrs) do - import_log - |> cast(attrs, [ - :processed_rows, - :imported_count, - :updated_count, - :skipped_count, - :error_count - ]) - end - - @doc """ - Mark import as completed. - """ - def complete_changeset(import_log, stats) do - import_log - |> cast(stats, [ - :imported_count, - :updated_count, - :skipped_count, - :error_count, - :error_details, - :product_uuids - ]) - |> change(%{ - status: "completed", - processed_rows: import_log.total_rows, - completed_at: UtilsDate.utc_now() - }) - end - - @doc """ - Mark import as failed. - """ - def fail_changeset(import_log, error) do - error_details = [%{"error" => inspect(error), "timestamp" => UtilsDate.utc_now()}] - - import_log - |> change(%{ - status: "failed", - error_details: error_details, - completed_at: UtilsDate.utc_now() - }) - end - - @doc """ - Returns the percentage of completion. - """ - def progress_percent(%__MODULE__{total_rows: 0}), do: 0 - - def progress_percent(%__MODULE__{processed_rows: processed, total_rows: total}) do - trunc(processed / total * 100) - end - - @doc """ - Check if import is in progress. - """ - def in_progress?(%__MODULE__{status: "processing"}), do: true - def in_progress?(_), do: false - - @doc """ - Check if import is finished (completed or failed). - """ - def finished?(%__MODULE__{status: status}) when status in ["completed", "failed"], do: true - def finished?(_), do: false -end diff --git a/lib/modules/shop/schemas/product.ex b/lib/modules/shop/schemas/product.ex deleted file mode 100644 index d16b9995e..000000000 --- a/lib/modules/shop/schemas/product.ex +++ /dev/null @@ -1,297 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Product do - @moduledoc """ - Product schema for e-commerce shop. - - Supports both physical and digital products with JSONB flexibility. - - ## Fields - - - `title` - Product title (required) - - `slug` - URL-friendly identifier (unique) - - `description` - Short description - - `body_html` - Full rich text description - - `status` - draft | active | archived - - `product_type` - physical | digital - - `vendor` - Brand/manufacturer - - `tags` - JSONB array of tags - - `price` - Base price (required) - - `compare_at_price` - Original price for discounts - - `cost_per_item` - Cost for profit calculation - - `currency` - ISO currency code (default: USD) - - `taxable` - Subject to tax - - `weight_grams` - Weight for shipping - - `requires_shipping` - Needs physical delivery - - `made_to_order` - Always available regardless of inventory - - `images` - JSONB array of image objects - - `featured_image` - Main image URL - - `seo_title` - SEO title - - `seo_description` - SEO description - - `file_uuid` - Storage file reference (digital products) - - `download_limit` - Max downloads (digital) - - `download_expiry_days` - Days until download expires - - `metadata` - JSONB for custom fields - """ - - use Ecto.Schema - import Ecto.Changeset - - @type t :: %__MODULE__{} - - @statuses ["draft", "active", "archived"] - @product_types ["physical", "digital"] - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_shop_products" do - # Localized fields (JSONB maps: %{"en" => "value", "ru" => "значение"}) - field :title, :map, default: %{} - field :slug, :map, default: %{} - field :description, :map, default: %{} - field :body_html, :map, default: %{} - - # Status (non-localized) - field :status, :string, default: "draft" - - # Type - field :product_type, :string, default: "physical" - field :vendor, :string - field :tags, {:array, :string}, default: [] - - # Pricing - field :price, :decimal - field :compare_at_price, :decimal - field :cost_per_item, :decimal - field :currency, :string, default: "USD" - field :taxable, :boolean, default: true - - # Physical properties - field :weight_grams, :integer, default: 0 - field :requires_shipping, :boolean, default: true - - # Availability - field :made_to_order, :boolean, default: false - - # Media (legacy URL-based) - field :images, {:array, :map}, default: [] - field :featured_image, :string - - # Media (Storage integration) - field :featured_image_uuid, Ecto.UUID - field :image_uuids, {:array, Ecto.UUID}, default: [] - - # SEO (localized JSONB maps) - field :seo_title, :map, default: %{} - field :seo_description, :map, default: %{} - - # Digital products - field :file_uuid, Ecto.UUID - field :download_limit, :integer - field :download_expiry_days, :integer - - # Extensibility - field :metadata, :map, default: %{} - - # Relations - belongs_to :category, PhoenixKit.Modules.Shop.Category, - foreign_key: :category_uuid, - references: :uuid, - type: UUIDv7 - - belongs_to :created_by_user, PhoenixKit.Users.Auth.User, - foreign_key: :created_by_uuid, - references: :uuid, - type: UUIDv7 - - timestamps(type: :utc_datetime) - end - - @doc """ - Changeset for product creation and updates. - """ - @localized_fields [:title, :slug, :description, :body_html, :seo_title, :seo_description] - - def changeset(product, attrs) do - product - |> cast(attrs, [ - :title, - :slug, - :description, - :body_html, - :status, - :product_type, - :vendor, - :tags, - :price, - :compare_at_price, - :cost_per_item, - :currency, - :taxable, - :weight_grams, - :requires_shipping, - :made_to_order, - :images, - :featured_image, - :featured_image_uuid, - :image_uuids, - :seo_title, - :seo_description, - :file_uuid, - :download_limit, - :download_expiry_days, - :metadata, - :category_uuid, - :created_by_uuid - ]) - |> normalize_map_fields(@localized_fields) - |> validate_required([:price]) - |> validate_localized_required(:title) - |> validate_inclusion(:status, @statuses) - |> validate_inclusion(:product_type, @product_types) - |> validate_number(:price, greater_than_or_equal_to: 0) - |> validate_number(:compare_at_price, greater_than_or_equal_to: 0) - |> validate_number(:cost_per_item, greater_than_or_equal_to: 0) - |> validate_number(:weight_grams, greater_than_or_equal_to: 0) - |> validate_number(:download_limit, greater_than: 0) - |> validate_number(:download_expiry_days, greater_than: 0) - |> validate_length(:currency, is: 3) - |> maybe_generate_slug() - end - - @doc """ - Returns the list of localized field names. - """ - def localized_fields, do: @localized_fields - - @doc """ - Returns true if product is active. - """ - def active?(%__MODULE__{status: "active"}), do: true - def active?(_), do: false - - @doc """ - Returns true if product is physical. - """ - def physical?(%__MODULE__{product_type: "physical"}), do: true - def physical?(_), do: false - - @doc """ - Returns true if product is digital. - """ - def digital?(%__MODULE__{product_type: "digital"}), do: true - def digital?(_), do: false - - @doc """ - Returns true if product requires shipping. - """ - def requires_shipping?(%__MODULE__{product_type: "digital"}), do: false - def requires_shipping?(%__MODULE__{requires_shipping: requires}), do: requires - - @doc """ - Returns the display price (compare_at_price if set, otherwise price). - """ - def display_price(%__MODULE__{compare_at_price: nil, price: price}), do: price - def display_price(%__MODULE__{compare_at_price: compare}), do: compare - - @doc """ - Returns true if product has a discount (compare_at_price > price). - """ - def on_sale?(%__MODULE__{compare_at_price: nil}), do: false - - def on_sale?(%__MODULE__{compare_at_price: compare, price: price}) do - Decimal.compare(compare, price) == :gt - end - - @doc """ - Calculates discount percentage. - """ - def discount_percentage(%__MODULE__{} = product) do - if on_sale?(product) do - diff = Decimal.sub(product.compare_at_price, product.price) - percentage = Decimal.div(diff, product.compare_at_price) - Decimal.mult(percentage, 100) |> Decimal.round(0) |> Decimal.to_integer() - else - 0 - end - end - - # Remove empty string values from map fields - defp normalize_map_fields(changeset, fields) do - Enum.reduce(fields, changeset, fn field, acc -> - case get_change(acc, field) do - nil -> - acc - - map when is_map(map) -> - cleaned = - map - |> Enum.reject(fn {_k, v} -> v in [nil, ""] end) - |> Map.new() - - put_change(acc, field, cleaned) - - _ -> - acc - end - end) - end - - # Validate that localized field has value for default language - defp validate_localized_required(changeset, field) do - value = get_field(changeset, field) || %{} - default_lang = default_language() - - if Map.get(value, default_lang) in [nil, ""] do - add_error(changeset, field, "#{default_lang} translation is required") - else - changeset - end - end - - # Generate slug from title for each language - defp maybe_generate_slug(changeset) do - title_map = get_field(changeset, :title) || %{} - slug_map = get_field(changeset, :slug) || %{} - - # For each language with a title but no slug, generate one - updated_slugs = - Enum.reduce(title_map, slug_map, fn {lang, title}, acc -> - if Map.get(acc, lang) in [nil, ""] and title not in [nil, ""] do - generated = slugify(title) - Map.put(acc, lang, generated) - else - acc - end - end) - - if updated_slugs != slug_map do - put_change(changeset, :slug, updated_slugs) - else - changeset - end - end - - defp slugify(text) when is_binary(text) do - text - |> String.downcase() - |> String.replace(~r/[^\w\s-]/, "") - |> String.replace(~r/\s+/, "-") - |> String.replace(~r/-+/, "-") - |> String.trim("-") - end - - defp slugify(_), do: "" - - defp default_language do - alias PhoenixKit.Modules.Languages - - if Code.ensure_loaded?(Languages) and function_exported?(Languages, :enabled?, 0) and - Languages.enabled?() do - case Languages.get_default_language() do - %{code: code} -> code - _ -> "en" - end - else - "en" - end - end -end diff --git a/lib/modules/shop/schemas/shipping_method.ex b/lib/modules/shop/schemas/shipping_method.ex deleted file mode 100644 index ea46bd407..000000000 --- a/lib/modules/shop/schemas/shipping_method.ex +++ /dev/null @@ -1,271 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.ShippingMethod do - @moduledoc """ - Shipping method schema for E-Commerce module. - - Supports weight-based, price-based, and geographic restrictions. - - ## Fields - - - `name` - Method name (required) - - `slug` - URL-friendly identifier (unique, auto-generated) - - `description` - Method description - - `price` - Shipping cost - - `free_above_amount` - Free shipping threshold - - `min_weight_grams`, `max_weight_grams` - Weight limits - - `min_order_amount`, `max_order_amount` - Order amount limits - - `countries` - Allowed countries (empty = all) - - `excluded_countries` - Excluded countries - - `active` - Enabled/disabled - - `position` - Sort order - - `estimated_days_min`, `estimated_days_max` - Delivery estimate - - `tracking_supported` - Tracking available - """ - - use Ecto.Schema - import Ecto.Changeset - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - schema "phoenix_kit_shop_shipping_methods" do - field :name, :string - field :slug, :string - field :description, :string - - # Pricing - field :price, :decimal, default: Decimal.new("0") - field :currency, :string, default: "USD" - field :free_above_amount, :decimal - - # Constraints - field :min_weight_grams, :integer, default: 0 - field :max_weight_grams, :integer - field :min_order_amount, :decimal - field :max_order_amount, :decimal - - # Geographic - field :countries, {:array, :string}, default: [] - field :excluded_countries, {:array, :string}, default: [] - - # Status - field :active, :boolean, default: true - field :position, :integer, default: 0 - - # Delivery info - field :estimated_days_min, :integer - field :estimated_days_max, :integer - field :tracking_supported, :boolean, default: false - - field :metadata, :map, default: %{} - - timestamps(type: :utc_datetime) - end - - @required_fields [:name, :price] - @optional_fields [ - :slug, - :description, - :currency, - :free_above_amount, - :min_weight_grams, - :max_weight_grams, - :min_order_amount, - :max_order_amount, - :countries, - :excluded_countries, - :active, - :position, - :estimated_days_min, - :estimated_days_max, - :tracking_supported, - :metadata - ] - - @doc """ - Changeset for shipping method creation and updates. - """ - def changeset(method, attrs) do - attrs = normalize_booleans(attrs, [:active, :tracking_supported]) - - method - |> cast(attrs, @required_fields ++ @optional_fields) - |> validate_required(@required_fields) - |> validate_length(:name, max: 255) - |> validate_length(:slug, max: 100) - |> validate_length(:currency, is: 3) - |> validate_number(:price, greater_than_or_equal_to: 0) - |> validate_number(:free_above_amount, greater_than: 0) - |> validate_number(:min_weight_grams, greater_than_or_equal_to: 0) - |> validate_number(:max_weight_grams, greater_than: 0) - |> validate_number(:min_order_amount, greater_than: 0) - |> validate_number(:max_order_amount, greater_than: 0) - |> validate_number(:position, greater_than_or_equal_to: 0) - |> validate_number(:estimated_days_min, greater_than_or_equal_to: 0) - |> validate_number(:estimated_days_max, greater_than: 0) - |> maybe_generate_slug() - |> unique_constraint(:slug) - end - - @doc """ - Checks if this method is available for given cart parameters. - - ## Examples - - iex> available_for?(method, %{weight_grams: 500, subtotal: Decimal.new("50"), country: "EE"}) - true - """ - def available_for?(%__MODULE__{active: false}, _params), do: false - - def available_for?(%__MODULE__{} = method, %{ - weight_grams: weight, - subtotal: subtotal, - country: country - }) do - weight_ok?(method, weight) && - amount_ok?(method, subtotal) && - country_ok?(method, country) - end - - def available_for?(%__MODULE__{} = method, params) when is_map(params) do - weight = Map.get(params, :weight_grams, 0) - subtotal = Map.get(params, :subtotal, Decimal.new("0")) - country = Map.get(params, :country) - - available_for?(method, %{weight_grams: weight, subtotal: subtotal, country: country}) - end - - @doc """ - Calculates shipping cost for given subtotal. - Returns 0 if free shipping threshold is met. - """ - def calculate_cost(%__MODULE__{free_above_amount: nil, price: price}, _subtotal) do - price - end - - def calculate_cost(%__MODULE__{free_above_amount: threshold, price: price}, subtotal) do - if Decimal.compare(subtotal, threshold) != :lt do - Decimal.new("0") - else - price - end - end - - @doc """ - Returns estimated delivery string. - - ## Examples - - iex> delivery_estimate(%ShippingMethod{estimated_days_min: 3, estimated_days_max: 5}) - "3-5 days" - - iex> delivery_estimate(%ShippingMethod{estimated_days_min: 1, estimated_days_max: 1}) - "1 day" - """ - def delivery_estimate(%__MODULE__{estimated_days_min: nil}), do: nil - - def delivery_estimate(%__MODULE__{estimated_days_min: min, estimated_days_max: nil}) do - "#{min}+ days" - end - - def delivery_estimate(%__MODULE__{estimated_days_min: 1, estimated_days_max: 1}) do - "1 day" - end - - def delivery_estimate(%__MODULE__{estimated_days_min: min, estimated_days_max: max}) - when min == max do - "#{min} days" - end - - def delivery_estimate(%__MODULE__{estimated_days_min: min, estimated_days_max: max}) do - "#{min}-#{max} days" - end - - @doc """ - Returns true if method is active. - """ - def active?(%__MODULE__{active: true}), do: true - def active?(_), do: false - - @doc """ - Checks if shipping is free for the given subtotal. - """ - def free_for?(%__MODULE__{free_above_amount: nil}, _subtotal), do: false - - def free_for?(%__MODULE__{free_above_amount: threshold}, subtotal) do - Decimal.compare(subtotal, threshold) != :lt - end - - # Private helpers - - defp weight_ok?(%{min_weight_grams: min, max_weight_grams: max}, weight) do - min_ok = is_nil(min) or weight >= min - max_ok = is_nil(max) or weight <= max - min_ok and max_ok - end - - defp amount_ok?(%{min_order_amount: min, max_order_amount: max}, amount) do - min_ok = is_nil(min) or Decimal.compare(amount, min) != :lt - max_ok = is_nil(max) or Decimal.compare(amount, max) != :gt - min_ok and max_ok - end - - defp country_ok?(%{countries: [], excluded_countries: []}, _country), do: true - - defp country_ok?(%{countries: [], excluded_countries: excluded}, country) do - is_nil(country) or country not in excluded - end - - defp country_ok?(%{countries: allowed, excluded_countries: excluded}, country) do - (is_nil(country) or country in allowed) and - (is_nil(country) or country not in excluded) - end - - defp maybe_generate_slug(changeset) do - case get_change(changeset, :slug) do - nil -> - case get_change(changeset, :name) do - nil -> changeset - name -> put_change(changeset, :slug, slugify(name)) - end - - _ -> - changeset - end - end - - defp slugify(text) do - text - |> String.downcase() - |> String.replace(~r/[^\w\s-]/, "") - |> String.replace(~r/\s+/, "-") - |> String.replace(~r/-+/, "-") - |> String.trim("-") - end - - defp normalize_booleans(attrs, fields) when is_map(attrs) do - Enum.reduce(fields, attrs, fn field, acc -> - str_key = to_string(field) - - cond do - Map.has_key?(acc, field) -> - Map.update!(acc, field, &to_boolean/1) - - Map.has_key?(acc, str_key) -> - Map.update!(acc, str_key, &to_boolean/1) - - true -> - acc - end - end) - end - - defp to_boolean(true), do: true - defp to_boolean(false), do: false - defp to_boolean("true"), do: true - defp to_boolean("false"), do: false - defp to_boolean(1), do: true - defp to_boolean(0), do: false - defp to_boolean("1"), do: true - defp to_boolean("0"), do: false - defp to_boolean(nil), do: nil - defp to_boolean(other), do: other -end diff --git a/lib/modules/shop/schemas/shop_config.ex b/lib/modules/shop/schemas/shop_config.ex deleted file mode 100644 index 291623de6..000000000 --- a/lib/modules/shop/schemas/shop_config.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.ShopConfig do - @moduledoc """ - Shop configuration storage schema (key-value JSONB). - - Used for storing global Shop module settings like: - - `global_attribute_schema` - Global product attribute definitions - - ## Attribute Schema Format - - %{ - "key" => "material", - "label" => "Material", - "type" => "select", - "options" => ["PLA", "ABS", "PETG"], - "default" => "PLA", - "required" => false, - "unit" => nil, - "position" => 0 - } - - Supported types: `text`, `number`, `boolean`, `select`, `multiselect` - """ - - use Ecto.Schema - import Ecto.Changeset - - @primary_key {:key, :string, autogenerate: false} - @timestamps_opts [type: :utc_datetime] - - schema "phoenix_kit_shop_config" do - field :value, :map - - timestamps() - end - - @doc """ - Changeset for shop configuration. - """ - def changeset(config, attrs) do - config - |> cast(attrs, [:key, :value]) - |> validate_required([:key, :value]) - |> validate_length(:key, max: 100) - end -end diff --git a/lib/modules/shop/services/image_downloader.ex b/lib/modules/shop/services/image_downloader.ex deleted file mode 100644 index 80bb01194..000000000 --- a/lib/modules/shop/services/image_downloader.ex +++ /dev/null @@ -1,484 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Services.ImageDownloader do - @moduledoc """ - Service for downloading images from external URLs and storing them in the Storage module. - - Handles HTTP download with proper error handling, content type detection, - and integration with PhoenixKit.Modules.Storage for persistent storage. - - ## Usage - - # Download and store a single image - {:ok, file_uuid} = ImageDownloader.download_and_store(url, user_uuid) - - # Download with options - {:ok, file_uuid} = ImageDownloader.download_and_store(url, user_uuid, timeout: 30_000) - - # Batch download multiple images - results = ImageDownloader.download_batch(urls, user_uuid) - # => [{url, {:ok, file_uuid}}, {url, {:error, reason}}, ...] - - """ - - require Logger - - alias PhoenixKit.Modules.Storage - - @default_timeout 30_000 - # 50 MB max - @max_file_size 50 * 1024 * 1024 - @allowed_content_types ~w(image/jpeg image/png image/gif image/webp image/svg+xml) - - @doc """ - Downloads an image from a URL to a temporary file. - - Returns `{:ok, temp_path, content_type, size}` on success. - - ## Options - - * `:timeout` - HTTP request timeout in milliseconds (default: 30_000) - - ## Examples - - iex> download_image("https://example.com/image.jpg") - {:ok, "/tmp/phx_img_abc123", "image/jpeg", 12345} - - iex> download_image("https://example.com/404.jpg") - {:error, :not_found} - - """ - @spec download_image(String.t(), keyword()) :: - {:ok, String.t(), String.t(), non_neg_integer()} | {:error, atom() | String.t()} - def download_image(url, opts \\ []) when is_binary(url) do - timeout = Keyword.get(opts, :timeout, @default_timeout) - - with {:ok, url} <- validate_url(url), - {:ok, response} <- do_http_request(url, timeout), - {:ok, content_type} <- extract_content_type(response), - :ok <- validate_content_type(content_type), - :ok <- validate_size(response.body), - {:ok, temp_path} <- write_temp_file(response.body, content_type) do - {:ok, temp_path, content_type, byte_size(response.body)} - end - end - - @doc """ - Downloads an image from a URL and stores it in the Storage module. - - Returns `{:ok, file_uuid}` where file_uuid is a UUID that can be used to reference - the stored file. - - ## Options - - * `:timeout` - HTTP request timeout in milliseconds (default: 30_000) - * `:metadata` - Additional metadata to store with the file - - ## Examples - - iex> download_and_store("https://cdn.shopify.com/image.jpg", user_uuid) - {:ok, "018f1234-5678-7890-abcd-ef1234567890"} - - iex> download_and_store("https://example.com/404.jpg", user_uuid) - {:error, :not_found} - - """ - @spec download_and_store(String.t(), String.t() | nil, keyword()) :: - {:ok, String.t()} | {:error, atom() | String.t()} - def download_and_store(url, user_uuid, opts \\ []) when is_binary(url) do - metadata = Keyword.get(opts, :metadata, %{}) - - with {:ok, temp_path, content_type, size} <- download_image(url, opts) do - # Check for global deduplication by file hash AND original filename - file_checksum = calculate_file_hash(temp_path) - filename = extract_filename_from_url(url, content_type) - - case find_existing_file(file_checksum, filename) do - %{uuid: existing_uuid} = _existing_file -> - # File with same content and name already exists - reuse it - Logger.info( - "[ImageDownloader] Reusing existing file #{existing_uuid} for URL #{url} (checksum: #{file_checksum}, filename: #{filename})" - ) - - cleanup_temp_file(temp_path) - {:ok, existing_uuid} - - nil -> - # No existing file matches - store new file - Logger.info( - "[ImageDownloader] Storing new file from URL #{url}, temp_path=#{temp_path}, size=#{size}" - ) - - store_new_file(temp_path, filename, content_type, size, user_uuid, url, metadata) - end - end - end - - # Store a new file after verifying it exists - defp store_new_file(temp_path, filename, content_type, size, user_uuid, url, metadata) do - if File.exists?(temp_path) do - result = - Storage.store_file(temp_path, - filename: filename, - content_type: content_type, - size_bytes: size, - user_uuid: user_uuid, - metadata: Map.merge(metadata, %{"source_url" => url}) - ) - - Logger.info("[ImageDownloader] Storage result: #{inspect(result)}") - cleanup_temp_file(temp_path) - handle_storage_result(result) - else - Logger.error("[ImageDownloader] Temp file disappeared before storage: #{temp_path}") - {:error, :temp_file_missing} - end - end - - defp handle_storage_result({:ok, file}) do - Logger.info("[ImageDownloader] Successfully stored file with ID: #{file.uuid}") - {:ok, file.uuid} - end - - defp handle_storage_result({:error, reason}) do - Logger.error("[ImageDownloader] Storage failed: #{inspect(reason)}") - {:error, reason} - end - - # Find existing file by checksum AND original filename - defp find_existing_file(file_checksum, filename) do - import Ecto.Query - - repo = PhoenixKit.Config.get_repo() - - query = - from(f in PhoenixKit.Modules.Storage.File, - where: f.file_checksum == ^file_checksum and f.original_file_name == ^filename, - limit: 1 - ) - - repo.one(query) - end - - # Calculate SHA256 hash of file content - defp calculate_file_hash(file_path) do - Elixir.File.stream!(file_path, 2048) - |> Enum.reduce(:crypto.hash_init(:sha256), fn chunk, acc -> - :crypto.hash_update(acc, chunk) - end) - |> :crypto.hash_final() - |> Base.encode16(case: :lower) - end - - @doc """ - Downloads and stores multiple images in batch. - - Returns a list of tuples `{url, result}` where result is either - `{:ok, file_uuid}` or `{:error, reason}`. - - ## Options - - * `:timeout` - HTTP request timeout for each image (default: 30_000) - * `:concurrency` - Number of concurrent downloads (default: 5) - * `:on_progress` - Callback function called after each download: `fn(url, result, index, total) -> :ok end` - - ## Examples - - iex> download_batch(["url1", "url2", "url3"], user_uuid) - [{"url1", {:ok, "uuid-1"}}, {"url2", {:ok, "uuid-2"}}, {"url3", {:error, :timeout}}] - - """ - @spec download_batch([String.t()], String.t() | nil, keyword()) :: - [{String.t(), {:ok, String.t()} | {:error, atom() | String.t()}}] - def download_batch(urls, user_uuid, opts \\ []) when is_list(urls) do - concurrency = Keyword.get(opts, :concurrency, 5) - on_progress = Keyword.get(opts, :on_progress) - total = length(urls) - - # Create indexed list to preserve URL even on task crash - indexed_urls = Enum.with_index(urls, 1) - - indexed_urls - |> Task.async_stream( - fn {url, index} -> - result = download_and_store(url, user_uuid, opts) - - if on_progress do - on_progress.(url, result, index, total) - end - - {index, url, result} - end, - max_concurrency: concurrency, - timeout: Keyword.get(opts, :timeout, @default_timeout) + 5_000, - on_timeout: :kill_task, - ordered: true - ) - |> Enum.zip(indexed_urls) - |> Enum.map(fn - {{:ok, {_index, url, result}}, _original} -> - {url, result} - - {{:exit, reason}, {url, _index}} -> - # Recover URL from original indexed list when task exits - Logger.warning("Task exited for URL #{url}: #{inspect(reason)}") - {url, {:error, {:task_exit, reason}}} - end) - end - - @doc """ - Validates URLs are accessible before batch download. - - Performs HEAD requests to verify URLs are accessible and return valid - image content types. Returns a tuple of `{valid_urls, invalid_urls}`. - - ## Options - - * `:timeout` - HTTP request timeout in milliseconds (default: 5_000) - * `:concurrency` - Number of concurrent validations (default: 10) - - ## Examples - - iex> validate_urls(["https://example.com/image.jpg", "https://example.com/404.jpg"]) - {["https://example.com/image.jpg"], ["https://example.com/404.jpg"]} - - """ - @spec validate_urls([String.t()], keyword()) :: {[String.t()], [String.t()]} - def validate_urls(urls, opts \\ []) when is_list(urls) do - timeout = Keyword.get(opts, :timeout, 5_000) - concurrency = Keyword.get(opts, :concurrency, 10) - - results = - urls - |> Task.async_stream( - fn url -> {url, valid_image_url?(url, timeout)} end, - max_concurrency: concurrency, - timeout: timeout + 2_000, - on_timeout: :kill_task - ) - |> Enum.map(fn - {:ok, {url, true}} -> {:valid, url} - {:ok, {url, false}} -> {:invalid, url} - {:exit, _reason} -> {:timeout, nil} - end) - |> Enum.reject(fn {_status, url} -> is_nil(url) end) - - valid = for {:valid, url} <- results, do: url - invalid = for {:invalid, url} <- results, do: url - - {valid, invalid} - end - - @doc """ - Checks if a URL points to a valid image that can be downloaded. - - Performs a HEAD request to verify the URL is accessible and returns - an image content type. - - ## Examples - - iex> valid_image_url?("https://example.com/image.jpg") - true - - iex> valid_image_url?("https://example.com/document.pdf") - false - - """ - @spec valid_image_url?(String.t()) :: boolean() - def valid_image_url?(url) when is_binary(url) do - valid_image_url?(url, 5_000) - end - - @spec valid_image_url?(String.t(), non_neg_integer()) :: boolean() - defp valid_image_url?(url, timeout) when is_binary(url) do - case validate_url(url) do - {:ok, url} -> - case Req.head(url, receive_timeout: timeout) do - {:ok, %{status: status, headers: headers}} when status in 200..299 -> - content_type = get_header_value(headers, "content-type") - validate_content_type(content_type) == :ok - - _ -> - false - end - - _ -> - false - end - end - - # Private functions - - defp validate_url(url) do - uri = URI.parse(url) - - cond do - uri.scheme not in ["http", "https"] -> - {:error, :invalid_scheme} - - is_nil(uri.host) or uri.host == "" -> - {:error, :invalid_host} - - true -> - # Upgrade HTTP to HTTPS for security - url = - if uri.scheme == "http", - do: String.replace_prefix(url, "http://", "https://"), - else: url - - {:ok, url} - end - end - - defp do_http_request(url, timeout) do - opts = [ - receive_timeout: timeout, - max_redirects: 5, - headers: [ - {"user-agent", "PhoenixKit/1.0 (Image Downloader)"}, - {"accept", "image/*"} - ] - ] - - case Req.get(url, opts) do - {:ok, %{status: 200} = response} -> - {:ok, response} - - {:ok, %{status: 301}} -> - {:error, :redirect_loop} - - {:ok, %{status: 302}} -> - {:error, :redirect_loop} - - {:ok, %{status: 404}} -> - {:error, :not_found} - - {:ok, %{status: 403}} -> - {:error, :forbidden} - - {:ok, %{status: 429}} -> - {:error, :rate_limited} - - {:ok, %{status: status}} when status >= 500 -> - {:error, :server_error} - - {:ok, %{status: status}} -> - {:error, {:http_error, status}} - - {:error, %Req.TransportError{reason: :timeout}} -> - {:error, :timeout} - - {:error, %Req.TransportError{reason: reason}} -> - {:error, {:transport_error, reason}} - - {:error, reason} -> - {:error, {:request_failed, reason}} - end - end - - defp extract_content_type(%{headers: headers}) do - case get_header_value(headers, "content-type") do - nil -> - {:error, :missing_content_type} - - content_type -> - # Extract just the MIME type, ignoring charset or other parameters - mime_type = - content_type - |> String.split(";") - |> List.first() - |> String.trim() - |> String.downcase() - - {:ok, mime_type} - end - end - - defp get_header_value(headers, key) do - key_lower = String.downcase(key) - - headers - |> Enum.find(fn {k, _v} -> String.downcase(k) == key_lower end) - |> case do - {_, value} when is_list(value) -> List.first(value) - {_, value} -> value - nil -> nil - end - end - - defp validate_content_type(content_type) when content_type in @allowed_content_types, do: :ok - - defp validate_content_type(content_type) do - Logger.warning("Invalid content type for image download: #{content_type}") - {:error, {:invalid_content_type, content_type}} - end - - defp validate_size(body) when byte_size(body) <= @max_file_size, do: :ok - - defp validate_size(body) do - size_mb = Float.round(byte_size(body) / 1024 / 1024, 2) - - {:error, - {:file_too_large, "#{size_mb} MB exceeds limit of #{@max_file_size / 1024 / 1024} MB"}} - end - - defp write_temp_file(body, content_type) do - ext = content_type_to_extension(content_type) - temp_path = generate_temp_path(ext) - - case File.write(temp_path, body) do - :ok -> {:ok, temp_path} - {:error, reason} -> {:error, {:write_failed, reason}} - end - end - - defp generate_temp_path(ext) do - random = :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower) - Path.join(System.tmp_dir!(), "phx_img_#{random}.#{ext}") - end - - defp extract_filename_from_url(url, content_type) do - uri = URI.parse(url) - - # Try to get filename from path - base_name = - case uri.path do - nil -> - "image" - - path -> - path - |> Path.basename() - |> String.split("?") - |> List.first() - |> case do - "" -> "image" - name -> Path.rootname(name) - end - end - - # Ensure proper extension - ext = content_type_to_extension(content_type) - "#{base_name}.#{ext}" - end - - defp content_type_to_extension(content_type) do - case content_type do - "image/jpeg" -> "jpg" - "image/png" -> "png" - "image/gif" -> "gif" - "image/webp" -> "webp" - "image/svg+xml" -> "svg" - _ -> "jpg" - end - end - - defp cleanup_temp_file(temp_path) do - case File.rm(temp_path) do - :ok -> - :ok - - {:error, reason} -> - Logger.warning("Failed to cleanup temp file #{temp_path}: #{inspect(reason)}") - :ok - end - end -end diff --git a/lib/modules/shop/services/image_migration.ex b/lib/modules/shop/services/image_migration.ex deleted file mode 100644 index ceca6178e..000000000 --- a/lib/modules/shop/services/image_migration.ex +++ /dev/null @@ -1,482 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Services.ImageMigration do - @moduledoc """ - Orchestrates batch migration of product images from external URLs to Storage module. - - Provides functions to query migration status, queue migration jobs, - and migrate individual products. - - ## Usage - - # Get migration statistics - stats = ImageMigration.migration_stats() - # => %{total: 100, migrated: 25, pending: 75, failed: 0} - - # Queue all pending products for migration - {:ok, count} = ImageMigration.queue_all_migrations(user_uuid) - # => {:ok, 75} - - # Migrate a single product synchronously - {:ok, product} = ImageMigration.migrate_product(product_uuid, user_uuid) - - """ - - require Logger - - import Ecto.Query - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Product - alias PhoenixKit.Modules.Shop.Services.ImageDownloader - alias PhoenixKit.Modules.Shop.Workers.ImageMigrationWorker - - @doc """ - Returns products that need image migration. - - A product needs migration if it has legacy image URLs but no Storage UUIDs. - - ## Options - - * `:limit` - Maximum number of products to return (default: all) - * `:offset` - Number of products to skip (default: 0) - - ## Examples - - iex> products_needing_migration() - [%Product{}, %Product{}, ...] - - iex> products_needing_migration(limit: 10) - [%Product{}, ...] - - """ - @spec products_needing_migration(keyword()) :: [Product.t()] - def products_needing_migration(opts \\ []) do - limit = Keyword.get(opts, :limit) - offset = Keyword.get(opts, :offset, 0) - - query = - from(p in Product, - # Has legacy images (JSONB array) or featured_image URL - # No Storage-based images yet - where: - (fragment("jsonb_array_length(?) > 0", p.images) or - (not is_nil(p.featured_image) and p.featured_image != "")) and - is_nil(p.featured_image_uuid) and - fragment("COALESCE(array_length(?, 1), 0) = 0", p.image_uuids), - order_by: [asc: p.inserted_at] - ) - - query = if offset > 0, do: offset(query, ^offset), else: query - query = if limit, do: limit(query, ^limit), else: query - - repo().all(query) - end - - @doc """ - Returns the count of products needing migration. - - ## Examples - - iex> products_needing_migration_count() - 75 - - """ - @spec products_needing_migration_count() :: non_neg_integer() - def products_needing_migration_count do - query = - from(p in Product, - where: - (fragment("jsonb_array_length(?) > 0", p.images) or - (not is_nil(p.featured_image) and p.featured_image != "")) and - is_nil(p.featured_image_uuid) and - fragment("COALESCE(array_length(?, 1), 0) = 0", p.image_uuids), - select: count(p.uuid) - ) - - repo().one(query) || 0 - end - - @doc """ - Returns the count of products that have been migrated. - - ## Examples - - iex> products_migrated_count() - 25 - - """ - @spec products_migrated_count() :: non_neg_integer() - def products_migrated_count do - query = - from(p in Product, - where: - not is_nil(p.featured_image_uuid) or - fragment("array_length(?, 1) > 0", p.image_uuids), - select: count(p.uuid) - ) - - repo().one(query) || 0 - end - - @doc """ - Returns migration statistics. - - ## Returns - - A map with the following keys: - * `:total` - Total products with any images (legacy or storage) - * `:migrated` - Products that have storage-based images - * `:pending` - Products with legacy images but no storage images - * `:failed` - Count of failed migration jobs (from Oban) - * `:in_progress` - Count of currently running migration jobs - - ## Examples - - iex> migration_stats() - %{total: 100, migrated: 25, pending: 75, failed: 0, in_progress: 5} - - """ - @spec migration_stats() :: map() - def migration_stats do - pending = products_needing_migration_count() - migrated = products_migrated_count() - total = pending + migrated - - # Get job stats from Oban - {in_progress, failed} = get_oban_job_stats() - - %{ - total: total, - migrated: migrated, - pending: pending, - failed: failed, - in_progress: in_progress - } - end - - defp get_oban_job_stats do - # Count executing and available jobs - in_progress_query = - from(j in Oban.Job, - where: - j.worker == "PhoenixKit.Modules.Shop.Workers.ImageMigrationWorker" and - j.state in ["executing", "available", "scheduled"], - select: count(j.id) - ) - - # Count failed jobs (not retrying) - failed_query = - from(j in Oban.Job, - where: - j.worker == "PhoenixKit.Modules.Shop.Workers.ImageMigrationWorker" and - j.state == "discarded", - select: count(j.id) - ) - - in_progress = repo().one(in_progress_query) || 0 - failed = repo().one(failed_query) || 0 - - {in_progress, failed} - end - - @doc """ - Queues migration jobs for all products needing migration. - - Creates an Oban job for each product that has legacy images but no storage images. - - ## Options - - * `:limit` - Maximum number of products to queue (default: all) - * `:priority` - Oban job priority (default: 3) - - ## Returns - - * `{:ok, count}` - Number of jobs queued - * `{:error, reason}` - If queuing failed - - ## Examples - - iex> queue_all_migrations(user_uuid) - {:ok, 75} - - iex> queue_all_migrations(user_uuid, limit: 10) - {:ok, 10} - - """ - @spec queue_all_migrations(String.t() | integer(), keyword()) :: - {:ok, non_neg_integer()} | {:error, term()} - def queue_all_migrations(user_uuid, opts \\ []) do - limit = Keyword.get(opts, :limit) - priority = Keyword.get(opts, :priority, 3) - - products = products_needing_migration(limit: limit) - count = length(products) - - Logger.info("Queuing image migration for #{count} products") - - jobs = - Enum.map(products, fn product -> - ImageMigrationWorker.new( - %{product_uuid: product.uuid, user_uuid: user_uuid}, - priority: priority - ) - end) - - inserted = Oban.insert_all(jobs) - broadcast_migration_started(count) - {:ok, length(inserted)} - end - - @doc """ - Cancels all pending migration jobs. - - ## Returns - - * `{:ok, count}` - Number of jobs cancelled - - """ - @spec cancel_pending_migrations() :: {:ok, non_neg_integer()} - def cancel_pending_migrations do - query = - from(j in Oban.Job, - where: - j.worker == "PhoenixKit.Modules.Shop.Workers.ImageMigrationWorker" and - j.state in ["available", "scheduled"] - ) - - {count, _} = repo().delete_all(query) - - Logger.info("Cancelled #{count} pending migration jobs") - broadcast_migration_cancelled(count) - - {:ok, count} - end - - @doc """ - Migrates a single product synchronously. - - Downloads all legacy images and updates the product with storage UUIDs. - - ## Returns - - * `{:ok, product}` - Updated product with storage image IDs - * `{:error, :already_migrated}` - Product already has storage images - * `{:error, :no_images}` - Product has no legacy images to migrate - * `{:error, reason}` - Migration failed - - ## Examples - - iex> migrate_product(product_uuid, user_uuid) - {:ok, %Product{featured_image_uuid: "uuid-1", image_uuids: ["uuid-1", "uuid-2"]}} - - """ - @spec migrate_product(String.t(), String.t() | integer()) :: - {:ok, Product.t()} | {:error, term()} - def migrate_product(product_uuid, user_uuid) do - case Shop.get_product(product_uuid) do - nil -> - {:error, :product_not_found} - - product -> - do_migrate_product(product, user_uuid) - end - end - - defp do_migrate_product(product, user_uuid) do - # Check if already migrated - if has_storage_images?(product) do - {:error, :already_migrated} - else - # Validate product has required fields - with :ok <- validate_product_for_migration(product) do - # Collect image URLs - image_urls = collect_image_urls(product) - - if Enum.empty?(image_urls) do - {:error, :no_images} - else - migrate_images_for_product(product, image_urls, user_uuid) - end - end - end - end - - defp validate_product_for_migration(product) do - cond do - is_nil(product.title) or product.title == %{} -> - Logger.warning("Product #{product.uuid} missing title, skipping migration") - {:error, :missing_title} - - is_nil(product.slug) or product.slug == %{} -> - Logger.warning("Product #{product.uuid} missing slug, skipping migration") - {:error, :missing_slug} - - true -> - :ok - end - end - - defp has_storage_images?(product) do - not is_nil(product.featured_image_uuid) or - (is_list(product.image_uuids) and product.image_uuids != []) - end - - defp collect_image_urls(product) do - urls = [] - - # Add featured_image URL if present - urls = - if is_binary(product.featured_image) and String.starts_with?(product.featured_image, "http") do - [product.featured_image | urls] - else - urls - end - - # Add all images from the legacy images array - legacy_image_urls = - (product.images || []) - |> Enum.flat_map(fn - %{"src" => src} when is_binary(src) -> [src] - src when is_binary(src) -> [src] - _ -> [] - end) - |> Enum.filter(&String.starts_with?(&1, "http")) - - (urls ++ legacy_image_urls) |> Enum.uniq() - end - - defp migrate_images_for_product(product, image_urls, user_uuid) do - # Validate URLs first to skip unavailable images - {valid_urls, invalid_urls} = ImageDownloader.validate_urls(image_urls) - - if invalid_urls != [] do - Logger.warning( - "Product #{product.uuid}: #{length(invalid_urls)} invalid URLs skipped: #{inspect(invalid_urls)}" - ) - end - - if valid_urls == [] do - Logger.warning("Product #{product.uuid}: All image URLs invalid") - {:error, :all_urls_invalid} - else - Logger.info("Migrating #{length(valid_urls)} valid images for product #{product.uuid}") - - # Download all images - results = - ImageDownloader.download_batch(valid_urls, user_uuid, concurrency: 3, timeout: 60_000) - - # Build URL -> file_uuid mapping - url_to_file_uuid = - Enum.reduce(results, %{}, fn - {url, {:ok, file_uuid}}, acc -> - Map.put(acc, url, file_uuid) - - {url, {:error, reason}}, acc -> - Logger.warning("Failed to download #{url}: #{inspect(reason)}") - acc - end) - - if map_size(url_to_file_uuid) == 0 do - {:error, :all_downloads_failed} - else - update_product_images(product, url_to_file_uuid) - end - end - end - - defp update_product_images(product, url_to_file_uuid) do - # Map featured_image to featured_image_uuid - featured_image_uuid = Map.get(url_to_file_uuid, product.featured_image) - - # Map legacy images to image_uuids, preserving order from original images array - image_uuids = - (product.images || []) - |> Enum.flat_map(fn - %{"src" => src} -> [src] - src when is_binary(src) -> [src] - _ -> [] - end) - |> Enum.map(&Map.get(url_to_file_uuid, &1)) - |> Enum.reject(&is_nil/1) - - # Use first image_id as featured if not set - featured_image_uuid = featured_image_uuid || List.first(image_uuids) - - # Ensure featured image is first in image_uuids (no duplicates) - image_uuids = - if featured_image_uuid && featured_image_uuid in image_uuids do - [featured_image_uuid | Enum.reject(image_uuids, &(&1 == featured_image_uuid))] - else - image_uuids - end - - # Update image mappings in metadata - metadata = update_image_mappings(product.metadata, url_to_file_uuid) - - attrs = %{ - featured_image_uuid: featured_image_uuid, - image_uuids: image_uuids, - metadata: metadata, - # Clear legacy fields after successful migration - images: [], - featured_image: nil - } - - Shop.update_product(product, attrs) - end - - defp update_image_mappings(nil, _url_to_file_uuid), do: nil - - defp update_image_mappings(metadata, url_to_file_uuid) when is_map(metadata) do - case Map.get(metadata, "_image_mappings") do - nil -> - metadata - - mappings when is_map(mappings) -> - updated_mappings = - Enum.reduce(mappings, %{}, fn {option_key, value_map}, acc -> - updated_value_map = - Enum.reduce(value_map, %{}, fn {value, image_ref}, inner_acc -> - new_ref = convert_url_to_file_uuid(image_ref, url_to_file_uuid) - Map.put(inner_acc, value, new_ref) - end) - - Map.put(acc, option_key, updated_value_map) - end) - - Map.put(metadata, "_image_mappings", updated_mappings) - end - end - - defp update_image_mappings(metadata, _url_to_file_uuid), do: metadata - - defp convert_url_to_file_uuid(image_ref, url_to_file_uuid) - when is_binary(image_ref) do - if String.starts_with?(image_ref, "http") do - Map.get(url_to_file_uuid, image_ref, image_ref) - else - image_ref - end - end - - defp convert_url_to_file_uuid(image_ref, _url_to_file_uuid), do: image_ref - - # PubSub broadcasts - - defp broadcast_migration_started(count) do - PhoenixKit.PubSubHelper.broadcast( - "shop:image_migration:batch", - {:migration_started, %{total: count}} - ) - end - - defp broadcast_migration_cancelled(count) do - PhoenixKit.PubSubHelper.broadcast( - "shop:image_migration:batch", - {:migration_cancelled, %{cancelled: count}} - ) - end - - defp repo do - PhoenixKit.Config.get_repo() - end -end diff --git a/lib/modules/shop/shop.ex b/lib/modules/shop/shop.ex deleted file mode 100644 index 3b5b6cea7..000000000 --- a/lib/modules/shop/shop.ex +++ /dev/null @@ -1,3530 +0,0 @@ -defmodule PhoenixKit.Modules.Shop do - @moduledoc """ - E-commerce Shop Module for PhoenixKit. - - Provides comprehensive e-commerce functionality including products, categories, - options-based pricing, and cart management. - - ## Features - - - **Products**: Physical and digital products with JSONB flexibility - - **Categories**: Hierarchical product categories - - **Options**: Product options with dynamic pricing (fixed or percent modifiers) - - **Inventory**: Stock tracking with reservation system - - **Cart**: Persistent shopping cart (DB-backed for cross-device support) - - ## System Enable/Disable - - # Check if shop is enabled - PhoenixKit.Modules.Shop.enabled?() - - # Enable/disable shop system - PhoenixKit.Modules.Shop.enable_system() - PhoenixKit.Modules.Shop.disable_system() - - ## Integration with Billing - - Shop integrates with the Billing module for orders and payments. - Order line_items include shop metadata for product tracking. - """ - - use PhoenixKit.Module - - import Ecto.Query, warn: false - require Logger - - alias PhoenixKit.Dashboard.Tab - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Currency - alias PhoenixKit.Modules.Billing.PaymentOption - alias PhoenixKit.Modules.Languages - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Modules.Shop.Cart - alias PhoenixKit.Modules.Shop.CartItem - alias PhoenixKit.Modules.Shop.Category - alias PhoenixKit.Modules.Shop.Events - alias PhoenixKit.Modules.Shop.ImportConfig - alias PhoenixKit.Modules.Shop.Options - alias PhoenixKit.Modules.Shop.Options.MetadataValidator - alias PhoenixKit.Modules.Shop.Product - alias PhoenixKit.Modules.Shop.ShippingMethod - alias PhoenixKit.Modules.Shop.ShopConfig - alias PhoenixKit.Modules.Shop.SlugResolver - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Settings - alias PhoenixKit.Users.Auth - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.Routes - alias PhoenixKit.Utils.UUID, as: UUIDUtils - - # ============================================ - # SYSTEM ENABLE/DISABLE - # ============================================ - - @impl PhoenixKit.Module - @doc """ - Checks if the shop system is enabled. - """ - def enabled? do - Settings.get_boolean_setting("shop_enabled", false) - end - - @impl PhoenixKit.Module - @doc """ - Enables the shop system. - """ - def enable_system do - result = Settings.update_boolean_setting_with_module("shop_enabled", true, "shop") - refresh_dashboard_tabs() - result - end - - @impl PhoenixKit.Module - @doc """ - Disables the shop system. - """ - def disable_system do - result = Settings.update_boolean_setting_with_module("shop_enabled", false, "shop") - 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 - - @impl PhoenixKit.Module - @doc """ - Returns the current shop configuration. - """ - def get_config do - %{ - enabled: enabled?(), - currency: get_default_currency_code(), - tax_enabled: Settings.get_setting_cached("shop_tax_enabled", "true") == "true", - tax_rate: Settings.get_setting_cached("shop_tax_rate", "20"), - inventory_tracking: - Settings.get_setting_cached("shop_inventory_tracking", "true") == "true", - allow_price_override: - Settings.get_setting_cached("shop_allow_price_override", "false") == "true", - products_count: count_products(), - categories_count: count_categories() - } - end - - @doc """ - Returns dashboard statistics for the shop. - """ - def get_dashboard_stats do - %{ - total_products: count_products(), - active_products: count_products_by_status("active"), - draft_products: count_products_by_status("draft"), - archived_products: count_products_by_status("archived"), - total_categories: count_categories(), - physical_products: count_products_by_type("physical"), - digital_products: count_products_by_type("digital"), - default_currency: get_default_currency_code() - } - end - - @doc """ - Gets the default currency code from Billing module. - Falls back to "USD" if Billing has no default currency configured. - """ - def get_default_currency_code do - case Billing.get_default_currency() do - %{code: code} -> code - nil -> "USD" - end - end - - @doc """ - Gets the default currency struct from Billing module. - """ - def get_default_currency do - Billing.get_default_currency() - end - - # ============================================ - # MODULE BEHAVIOUR CALLBACKS - # ============================================ - - @impl PhoenixKit.Module - def module_key, do: "shop" - - @impl PhoenixKit.Module - def module_name, do: "E-Commerce" - - @impl PhoenixKit.Module - def permission_metadata do - %{ - key: "shop", - label: "E-Commerce", - icon: "hero-shopping-cart", - description: "Product catalog, orders, and e-commerce management" - } - end - - @impl PhoenixKit.Module - def admin_tabs do - [ - Tab.new!( - id: :admin_shop, - label: "E-Commerce", - icon: "hero-shopping-bag", - path: "shop", - priority: 530, - level: :admin, - permission: "shop", - match: :exact, - group: :admin_modules, - subtab_display: :when_active, - highlight_with_subtabs: false - ), - Tab.new!( - id: :admin_shop_dashboard, - label: "Dashboard", - icon: "hero-home", - path: "shop", - priority: 531, - level: :admin, - permission: "shop", - parent: :admin_shop, - match: :exact - ), - Tab.new!( - id: :admin_shop_products, - label: "Products", - icon: "hero-cube", - path: "shop/products", - priority: 532, - level: :admin, - permission: "shop", - parent: :admin_shop - ), - Tab.new!( - id: :admin_shop_categories, - label: "Categories", - icon: "hero-folder", - path: "shop/categories", - priority: 533, - level: :admin, - permission: "shop", - parent: :admin_shop - ), - Tab.new!( - id: :admin_shop_shipping, - label: "Shipping", - icon: "hero-truck", - path: "shop/shipping", - priority: 534, - level: :admin, - permission: "shop", - parent: :admin_shop - ), - Tab.new!( - id: :admin_shop_carts, - label: "Carts", - icon: "hero-shopping-cart", - path: "shop/carts", - priority: 535, - level: :admin, - permission: "shop", - parent: :admin_shop - ), - Tab.new!( - id: :admin_shop_imports, - label: "CSV Import", - icon: "hero-cloud-arrow-up", - path: "shop/imports", - priority: 536, - level: :admin, - permission: "shop", - parent: :admin_shop - ) - ] - end - - @impl PhoenixKit.Module - def settings_tabs do - [ - Tab.new!( - id: :admin_settings_shop, - label: "E-Commerce", - icon: "hero-shopping-bag", - path: "/admin/shop/settings", - priority: 927, - level: :admin, - parent: :admin_settings, - permission: "shop" - ) - ] - end - - @impl PhoenixKit.Module - def user_dashboard_tabs do - [ - Tab.new!( - id: :dashboard_shop, - label: "Shop", - icon: "hero-building-storefront", - path: "/shop", - priority: 300, - match: :prefix, - group: :shop - ), - Tab.new!( - id: :dashboard_cart, - label: "My Cart", - icon: "hero-shopping-cart", - path: "/cart", - priority: 310, - match: :prefix, - group: :shop - ) - ] - end - - @impl PhoenixKit.Module - def route_module, do: PhoenixKitWeb.Routes.ShopRoutes - - # ============================================ - # PRODUCTS - # ============================================ - - @doc """ - Lists all products with optional filters. - - ## Options - - `:status` - Filter by status (draft, active, archived) - - `:product_type` - Filter by type (physical, digital) - - `:category_uuid` - Filter by category - - `:search` - Search in title and description - - `:page` - Page number - - `:per_page` - Items per page - - `:preload` - Associations to preload - """ - def list_products(opts \\ []) do - Product - |> apply_product_filters(opts) - |> order_by([p], desc: p.inserted_at) - |> maybe_preload(Keyword.get(opts, :preload)) - |> repo().all() - end - - @doc """ - Lists products with count for pagination. - """ - def list_products_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 = - Product - |> apply_product_filters(opts) - - total = repo().aggregate(base_query, :count) - - products = - base_query - |> order_by([p], desc: p.inserted_at) - |> limit(^per_page) - |> offset(^offset) - |> maybe_preload(Keyword.get(opts, :preload, [:category])) - |> repo().all() - - {products, total} - end - - @doc """ - Lists products by their IDs. - - Returns products in the order of the provided IDs. - """ - def list_products_by_ids([]), do: [] - - def list_products_by_ids(ids) when is_list(ids) do - Product |> where([p], p.uuid in ^ids) |> repo().all() - end - - # ============================================ - # STOREFRONT FILTERS - # ============================================ - - @storefront_filters_key "storefront_filters" - - @doc """ - Gets storefront filter configuration from shop_config. - - Returns a list of filter definition maps with keys: - key, type, label, enabled, position. - - Default: price filter only. - """ - def get_storefront_filters do - case repo().get(ShopConfig, @storefront_filters_key) do - %ShopConfig{value: %{"filters" => filters}} when is_list(filters) -> - filters - - _ -> - default_storefront_filters() - end - end - - @doc """ - Returns only enabled storefront filters, sorted by position. - """ - def get_enabled_storefront_filters do - get_storefront_filters() - |> Enum.filter(& &1["enabled"]) - |> Enum.sort_by(& &1["position"]) - end - - @doc """ - Saves storefront filter configuration. - """ - def update_storefront_filters(filters) when is_list(filters) do - value = %{"filters" => filters} - - case repo().get(ShopConfig, @storefront_filters_key) do - nil -> - %ShopConfig{} - |> ShopConfig.changeset(%{key: @storefront_filters_key, value: value}) - |> repo().insert() - - config -> - config - |> ShopConfig.changeset(%{value: value}) - |> repo().update() - end - end - - @doc """ - Aggregates filter values for sidebar display. - - Returns a map of filter_key => aggregated data. - For price_range: %{min: Decimal, max: Decimal} - For vendor: [%{value: "Vendor", count: 5}, ...] - For metadata_option: [%{value: "8 inches", count: 3}, ...] - - Options: - - `:category_uuid` - Scope aggregation to a specific category by UUID - """ - def aggregate_filter_values(opts \\ []) do - filters = get_enabled_storefront_filters() - category_uuid = Keyword.get(opts, :category_uuid) - - Enum.reduce(filters, %{}, fn filter, acc -> - Map.put(acc, filter["key"], aggregate_single_filter(filter, category_uuid)) - end) - end - - defp aggregate_single_filter(%{"type" => "price_range"}, category_uuid) do - query = - Product - |> where([p], p.status == "active") - |> maybe_filter_category(category_uuid) - - min_price = repo().aggregate(query, :min, :price) - max_price = repo().aggregate(query, :max, :price) - %{min: min_price, max: max_price} - rescue - _ -> %{min: nil, max: nil} - end - - defp aggregate_single_filter(%{"type" => "vendor"}, category_uuid) do - query = - Product - |> where([p], p.status == "active" and not is_nil(p.vendor) and p.vendor != "") - |> maybe_filter_category(category_uuid) - |> group_by([p], p.vendor) - |> select([p], %{value: p.vendor, count: count(p.uuid)}) - |> order_by([p], desc: count(p.uuid)) - - repo().all(query) - rescue - _ -> [] - end - - defp aggregate_single_filter(%{"type" => "metadata_option", "option_key" => key}, category_uuid) - when is_binary(key) do - # Query distinct option values from metadata->'_option_values'->key JSONB array - sql = """ - SELECT val AS value, COUNT(DISTINCT p.uuid) AS count - FROM phoenix_kit_shop_products p, - jsonb_array_elements_text(COALESCE(p.metadata->'_option_values'->$1, '[]'::jsonb)) AS val - WHERE p.status = 'active' - #{if category_uuid, do: "AND p.category_uuid = $2", else: ""} - GROUP BY val - ORDER BY count DESC - """ - - params = - if category_uuid do - {:ok, uuid_bin} = Ecto.UUID.dump(category_uuid) - [key, uuid_bin] - else - [key] - end - - case repo().query(sql, params) do - {:ok, %{rows: rows}} -> - Enum.map(rows, fn [value, count] -> %{value: value, count: count} end) - - _ -> - [] - end - rescue - _ -> [] - end - - defp aggregate_single_filter(_filter, _category_uuid), do: [] - - defp maybe_filter_category(query, nil), do: query - defp maybe_filter_category(query, uuid), do: where(query, [p], p.category_uuid == ^uuid) - - @doc """ - Discovers filterable option keys from product metadata. - - Returns a list of {key, product_count} tuples sorted by count descending. - Used by admin UI to auto-suggest available filters. - """ - def discover_filterable_options do - sql = """ - SELECT key, COUNT(DISTINCT p.uuid) AS product_count - FROM phoenix_kit_shop_products p, - jsonb_object_keys(COALESCE(p.metadata->'_option_values', '{}'::jsonb)) AS key - WHERE p.status = 'active' - GROUP BY key - ORDER BY product_count DESC - """ - - case repo().query(sql, []) do - {:ok, %{rows: rows}} -> - Enum.map(rows, fn [key, count] -> %{key: key, count: count} end) - - _ -> - [] - end - rescue - _ -> [] - end - - @doc """ - Returns the default storefront filter configuration. - """ - def default_storefront_filters do - [ - %{ - "key" => "price", - "type" => "price_range", - "label" => "Price", - "enabled" => true, - "position" => 0 - }, - %{ - "key" => "vendor", - "type" => "vendor", - "label" => "Vendor", - "enabled" => false, - "position" => 1 - } - ] - end - - @doc """ - Gets a product by ID or UUID. - """ - def get_product(id, opts \\ []) - - def get_product(id, opts) when is_binary(id) do - if UUIDUtils.valid?(id) do - Product - |> where([p], p.uuid == ^id) - |> maybe_preload(Keyword.get(opts, :preload)) - |> repo().one() - else - nil - end - end - - def get_product(_, _opts), do: nil - - @doc """ - Gets a product by ID or UUID, raises if not found. - """ - def get_product!(id, opts \\ []) do - case get_product(id, opts) do - nil -> raise Ecto.NoResultsError, queryable: Product - product -> product - end - end - - @doc """ - Gets a product by slug. - - Supports localized slugs stored as JSONB maps. - - ## Options - - - `:language` - Language code for slug lookup (default: system default) - - `:preload` - Associations to preload - - ## Examples - - iex> get_product_by_slug("planter") - %Product{} - - iex> get_product_by_slug("kashpo", language: "ru") - %Product{} - """ - def get_product_by_slug(slug, opts \\ []) do - language = Keyword.get(opts, :language, Translations.default_language()) - preload = Keyword.get(opts, :preload, []) - - case SlugResolver.find_product_by_slug(slug, language, preload: preload) do - {:ok, product} -> product - {:error, :not_found} -> nil - end - end - - @doc """ - Creates a new product. - - Automatically normalizes metadata (price modifiers, option values) - before saving to ensure consistent storage format. - """ - def create_product(attrs) do - attrs = MetadataValidator.normalize_product_attrs(attrs) - - result = - %Product{} - |> Product.changeset(attrs) - |> repo().insert() - - case result do - {:ok, product} -> - Events.broadcast_product_created(product) - {:ok, product} - - error -> - error - end - end - - @doc """ - Updates a product. - - Automatically normalizes metadata (price modifiers, option values) - before saving to ensure consistent storage format. - """ - def update_product(%Product{} = product, attrs) do - attrs = MetadataValidator.normalize_product_attrs(attrs) - - result = - product - |> Product.changeset(attrs) - |> repo().update() - - case result do - {:ok, updated_product} -> - Events.broadcast_product_updated(updated_product) - {:ok, updated_product} - - error -> - error - end - end - - @doc """ - Deletes a product. - """ - def delete_product(%Product{} = product) do - product_uuid = product.uuid - - case repo().delete(product) do - {:ok, _} = result -> - Events.broadcast_product_deleted(product_uuid) - result - - error -> - error - end - end - - @doc """ - Returns a changeset for product form. - """ - def change_product(%Product{} = product, attrs \\ %{}) do - Product.changeset(product, attrs) - end - - @doc """ - Bulk update product status. - Returns count of updated products. - """ - def bulk_update_product_status(ids, status) when is_list(ids) and is_binary(status) do - query = Product |> where([p], p.uuid in ^ids) - - {count, _} = - query - |> repo().update_all(set: [status: status, updated_at: UtilsDate.utc_now()]) - - if count > 0 do - Events.broadcast_products_bulk_status_changed(ids, status) - end - - count - end - - @doc """ - Bulk update product category. - Returns count of updated products. - """ - def bulk_update_product_category(uuids, category_uuid) when is_list(uuids) do - cat_uuid = - if category_uuid do - case repo().get_by(Category, uuid: category_uuid) do - nil -> nil - cat -> cat.uuid - end - else - nil - end - - # Don't unassign category if a specific category was requested but not found - if category_uuid && is_nil(cat_uuid) do - 0 - else - query = Product |> where([p], p.uuid in ^uuids) - - {count, _} = - query - |> repo().update_all( - set: [ - category_uuid: cat_uuid, - updated_at: UtilsDate.utc_now() - ] - ) - - count - end - end - - @doc """ - Bulk delete products. - Returns count of deleted products. - """ - def bulk_delete_products(ids) when is_list(ids) do - query = Product |> where([p], p.uuid in ^ids) - - {count, _} = repo().delete_all(query) - - count - end - - @doc """ - Collects all storage file UUIDs associated with a single product. - """ - def collect_product_file_uuids(%Product{} = product) do - [product.featured_image_uuid, product.file_uuid | product.image_uuids || []] - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - end - - @doc """ - Collects all storage file UUIDs for a list of product UUIDs. - """ - def collect_products_file_uuids(product_uuids) when is_list(product_uuids) do - from(p in Product, - where: p.uuid in ^product_uuids, - select: %{ - featured_image_uuid: p.featured_image_uuid, - file_uuid: p.file_uuid, - image_uuids: p.image_uuids - } - ) - |> repo().all() - |> Enum.flat_map(fn p -> - [p.featured_image_uuid, p.file_uuid | p.image_uuids || []] - end) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - end - - # ============================================ - # OPTIONS-BASED PRICING - # ============================================ - - @doc """ - Calculates the final price for a product based on selected specifications. - - Applies option price modifiers (fixed and percent) to the base price. - Fixed modifiers are applied first, then percent modifiers. - - ## Example - - product = %Product{price: Decimal.new("20.00")} - selected_specs = %{"material" => "PETG", "finish" => "Premium"} - - # If PETG has +$10 fixed and Premium has +20% percent: - calculate_product_price(product, selected_specs) - # => Decimal.new("36.00") # ($20 + $10) * 1.20 - """ - def calculate_product_price(%Product{} = product, selected_specs) when is_map(selected_specs) do - base_price = product.price || Decimal.new("0") - metadata = product.metadata || %{} - - # Get price-affecting options for this product - price_affecting_specs = Options.get_price_affecting_specs_for_product(product) - - # Calculate final price with fixed and percent modifiers - # Pass metadata to apply custom per-product price overrides - Options.calculate_final_price(price_affecting_specs, selected_specs, base_price, metadata) - end - - def calculate_product_price(%Product{} = product, _) do - product.price || Decimal.new("0") - end - - @doc """ - Gets the price range for a product based on option modifiers. - - Returns `{min_price, max_price}` where: - - min_price = minimum possible price (base + min modifiers) - - max_price = maximum possible price (base + max modifiers) - - ## Example - - # Product with base $20, material options (0, +5, +10), finish options (0%, +20%) - get_price_range(product) - # => {Decimal.new("20.00"), Decimal.new("36.00")} - """ - def get_price_range(%Product{} = product) do - base_price = product.price || Decimal.new("0") - metadata = product.metadata || %{} - - # Get price-affecting options - price_affecting_specs = Options.get_price_affecting_specs_for_product(product) - - if Enum.empty?(price_affecting_specs) do - {base_price, base_price} - else - # Pass metadata to apply custom per-product price overrides - Options.get_price_range(price_affecting_specs, base_price, metadata) - end - end - - @doc """ - Formats the product price for catalog display. - - Returns: - - "$19.99" for products without price-affecting options - - "From $19.99" if options have different price modifiers - - "$19.99 - $38.00" for range display - """ - def format_product_price(%Product{} = product, currency, style \\ :from) do - {min_price, max_price} = get_price_range(product) - - format_fn = fn price -> - case currency do - %{} = c -> Currency.format_amount(price, c) - nil -> "$#{Decimal.round(price, 2)}" - end - end - - if Decimal.compare(min_price, max_price) == :eq do - format_fn.(min_price) - else - case style do - :from -> "From #{format_fn.(min_price)}" - :range -> "#{format_fn.(min_price)} - #{format_fn.(max_price)}" - end - end - end - - @doc """ - Gets price-affecting options for a product. - - Convenience wrapper around `Options.get_price_affecting_specs_for_product/1`. - """ - def get_price_affecting_specs(%Product{} = product) do - Options.get_price_affecting_specs_for_product(product) - end - - @doc """ - Gets all selectable options for a product (for UI display). - - Returns all select/multiselect options regardless of whether they affect price. - This includes options like Color that may not have price modifiers but should - still be selectable in the UI. - - Convenience wrapper around `Options.get_selectable_specs_for_product/1`. - """ - def get_selectable_specs(%Product{} = product) do - Options.get_selectable_specs_for_product(product) - end - - # ============================================ - # CATEGORIES - # ============================================ - - @doc """ - Lists all categories. - - ## Options - - `:parent_uuid` - Filter by parent UUID (nil for root categories) - - `:status` - Filter by status: "active", "hidden", "archived", or list of statuses - - `:search` - Search in name - - `:preload` - Associations to preload - """ - def list_categories(opts \\ []) do - Category - |> apply_category_filters(opts) - |> order_by([c], [c.position, c.name]) - |> maybe_preload(Keyword.get(opts, :preload)) - |> repo().all() - end - - @doc """ - Returns a map of category_uuid => product_count for all categories. - """ - def product_counts_by_category do - Product - |> where([p], not is_nil(p.category_uuid)) - |> group_by([p], p.category_uuid) - |> select([p], {p.category_uuid, count(p.uuid)}) - |> repo().all() - |> Map.new() - rescue - e -> - Logger.warning("Failed to load product counts by category: #{inspect(e)}") - %{} - end - - @doc """ - Lists root categories (no parent). - """ - def list_root_categories(opts \\ []) do - list_categories(Keyword.put(opts, :parent_uuid, nil)) - end - - @doc """ - Lists active categories only (for storefront display). - """ - def list_active_categories(opts \\ []) do - list_categories(Keyword.put(opts, :status, "active")) - end - - @doc """ - Lists categories visible in storefront navigation/menu. - Only active categories appear in menus. - Semantic alias for list_active_categories/1. - """ - def list_menu_categories(opts \\ []) do - list_active_categories(opts) - end - - @doc """ - Lists categories whose products are visible in storefront. - Includes both active and unlisted categories. - Use for product filtering, not for navigation menus. - """ - def list_visible_categories(opts \\ []) do - list_categories(Keyword.put(opts, :status, ["active", "unlisted"])) - end - - @doc """ - Lists categories with count for pagination. - """ - def list_categories_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 = - Category - |> apply_category_filters(opts) - - total = repo().aggregate(base_query, :count) - - categories = - base_query - |> order_by([c], [c.position, c.name]) - |> limit(^per_page) - |> offset(^offset) - |> maybe_preload(Keyword.get(opts, :preload)) - |> repo().all() - - {categories, total} - end - - @doc """ - Gets a category by ID or UUID. - """ - def get_category(id, opts \\ []) - - def get_category(id, opts) when is_binary(id) do - if UUIDUtils.valid?(id) do - Category - |> where([c], c.uuid == ^id) - |> maybe_preload(Keyword.get(opts, :preload)) - |> repo().one() - else - nil - end - end - - def get_category(_, _opts), do: nil - - @doc """ - Gets a category by ID or UUID, raises if not found. - """ - def get_category!(id, opts \\ []) do - case get_category(id, opts) do - nil -> raise Ecto.NoResultsError, queryable: Category - category -> category - end - end - - @doc """ - Gets a category by slug. - - Supports localized slugs stored as JSONB maps. - - ## Options - - - `:language` - Language code for slug lookup (default: system default) - - `:preload` - Associations to preload - - ## Examples - - iex> get_category_by_slug("planters") - %Category{} - - iex> get_category_by_slug("kashpo", language: "ru") - %Category{} - """ - def get_category_by_slug(slug, opts \\ []) do - language = Keyword.get(opts, :language, Translations.default_language()) - preload = Keyword.get(opts, :preload, []) - - case SlugResolver.find_category_by_slug(slug, language, preload: preload) do - {:ok, category} -> category - {:error, :not_found} -> nil - end - end - - @doc """ - Creates a new category. - """ - def create_category(attrs) do - result = - %Category{} - |> Category.changeset(attrs) - |> repo().insert() - - case result do - {:ok, category} -> - Events.broadcast_category_created(category) - {:ok, category} - - error -> - error - end - end - - @doc """ - Updates a category. - """ - def update_category(%Category{} = category, attrs) do - result = - category - |> Category.changeset(attrs) - |> repo().update() - - case result do - {:ok, updated_category} -> - Events.broadcast_category_updated(updated_category) - {:ok, updated_category} - - error -> - error - end - end - - @doc """ - Lists categories that have no products assigned. - """ - def list_empty_categories do - subquery = from(p in Product, select: p.category_uuid, where: not is_nil(p.category_uuid)) - - from(c in Category, where: c.uuid not in subquery(subquery)) - |> repo().all() - end - - @doc """ - Deletes a category. - """ - def delete_category(%Category{} = category) do - category_uuid = category.uuid - - case repo().delete(category) do - {:ok, _} = result -> - Events.broadcast_category_deleted(category_uuid) - result - - error -> - error - end - end - - @doc """ - Returns a changeset for category form. - """ - def change_category(%Category{} = category, attrs \\ %{}) do - Category.changeset(category, attrs) - end - - @doc """ - Bulk update category status. - Returns count of updated categories. - """ - def bulk_update_category_status(ids, status) when is_list(ids) and is_binary(status) do - query = Category |> where([c], c.uuid in ^ids) - - {count, _} = - query - |> repo().update_all(set: [status: status, updated_at: UtilsDate.utc_now()]) - - if count > 0 do - Events.broadcast_categories_bulk_status_changed(ids, status) - end - - count - end - - @doc """ - Bulk update category parent. - Returns count of updated categories. Excludes the target parent from the update set - to prevent self-reference. Uses a single UPDATE with subquery to resolve parent_uuid. - """ - def bulk_update_category_parent(ids, parent_uuid) when is_list(ids) do - # Exclude the target parent and its ancestors from update set to prevent cycles - ids_to_update = - if parent_uuid do - ancestors = collect_ancestor_uuids(parent_uuid, %{}) - - Enum.reject(ids, &(&1 == parent_uuid or Map.has_key?(ancestors, &1))) - else - ids - end - - if ids_to_update == [] do - 0 - else - now = UtilsDate.utc_now() - - {count, _} = - if is_nil(parent_uuid) do - # Make root — set parent to nil - Category - |> where([c], c.uuid in ^ids_to_update) - |> repo().update_all(set: [parent_uuid: nil, updated_at: now]) - else - # Set parent_uuid directly - Category - |> where([c], c.uuid in ^ids_to_update) - |> repo().update_all(set: [parent_uuid: parent_uuid, updated_at: now]) - end - - if count > 0 do - Events.broadcast_categories_bulk_parent_changed(ids_to_update, parent_uuid) - end - - count - end - end - - defp collect_ancestor_uuids(nil, acc), do: acc - - defp collect_ancestor_uuids(uuid, acc) do - if Map.has_key?(acc, uuid) do - acc - else - case repo().get_by(Category, uuid: uuid) do - nil -> acc - %{parent_uuid: parent} -> collect_ancestor_uuids(parent, Map.put(acc, uuid, true)) - end - end - end - - @doc """ - Bulk delete categories. - Returns count of deleted categories. Nullifies category references on orphaned products. - """ - def bulk_delete_categories(ids) when is_list(ids) do - # Nullify category references on products to prevent orphans - orphan_query = Product |> where([p], p.category_uuid in ^ids) - - repo().update_all(orphan_query, - set: [category_uuid: nil, updated_at: UtilsDate.utc_now()] - ) - - # Delete categories - category_query = Category |> where([c], c.uuid in ^ids) - - {count, _} = repo().delete_all(category_query) - - if count > 0 do - Events.broadcast_categories_bulk_deleted(ids) - end - - count - end - - @doc """ - Returns categories as options for select input. - Returns list of {localized_name, id} tuples. - """ - def category_options do - default_lang = Translations.default_language() - - Category - |> order_by([c], [c.position, c.name]) - |> repo().all() - |> Enum.map(fn cat -> - {Translations.get(cat, :name, default_lang), cat.uuid} - end) - end - - @doc """ - Ensures a category has a featured_product_uuid set. - - If the category has no image_uuid and no featured_product_uuid, auto-detects the - first active product with an image and saves it. Returns the (possibly updated) - category with :featured_product preloaded. - """ - def ensure_featured_product( - %Category{featured_product_uuid: nil, image_uuid: nil, uuid: cat_uuid} = cat - ) do - case find_default_featured_product(cat_uuid) do - nil -> - cat - - product_uuid -> - {:ok, updated} = - update_category(cat, %{ - featured_product_uuid: product_uuid - }) - - repo().preload(updated, :featured_product) - end - end - - def ensure_featured_product(cat), do: cat - - defp find_default_featured_product(category_uuid) do - from(p in Product, - where: p.category_uuid == ^category_uuid, - where: p.status == "active", - where: - not is_nil(p.featured_image_uuid) or - (not is_nil(p.featured_image) and p.featured_image != ""), - order_by: [asc: p.inserted_at], - limit: 1, - select: p.uuid - ) - |> repo().one() - end - - @doc """ - Returns a list of {name, id} tuples for products in a category that have images. - Used for the featured product dropdown in the admin category form. - """ - def list_category_product_options(category_uuid) do - default_lang = Translations.default_language() - - query = category_product_options_query(category_uuid) - - if query do - query - |> repo().all() - |> Enum.map(fn {title_map, uuid} -> - name = - case title_map do - %{} = map -> map[default_lang] || map |> Map.values() |> List.first() - _ -> "Product #{uuid}" - end - - {name, uuid} - end) - else - [] - end - end - - defp category_product_options_query(category_uuid) when is_binary(category_uuid) do - if match?({:ok, _}, Ecto.UUID.cast(category_uuid)) do - from(p in Product, - where: p.category_uuid == ^category_uuid, - where: p.status == "active", - where: - not is_nil(p.featured_image_uuid) or - (not is_nil(p.featured_image) and p.featured_image != ""), - order_by: [asc: p.uuid], - select: {p.title, p.uuid} - ) - end - end - - defp category_product_options_query(_), do: nil - - # ============================================ - # SHIPPING METHODS - # ============================================ - - @doc """ - Lists all shipping methods. - - ## Options - - `:active` - Filter by active status - - `:country` - Filter by country availability - """ - def list_shipping_methods(opts \\ []) do - ShippingMethod - |> filter_shipping_by_active(Keyword.get(opts, :active)) - |> order_by([s], [s.position, s.name]) - |> repo().all() - end - - @doc """ - Gets available shipping methods for a cart. - Filters by weight, subtotal, and country. - """ - def get_available_shipping_methods(%Cart{} = cart) do - ShippingMethod - |> where([s], s.active == true) - |> order_by([s], [s.position, s.name]) - |> repo().all() - |> Enum.filter(fn method -> - ShippingMethod.available_for?(method, %{ - weight_grams: cart.total_weight_grams || 0, - subtotal: cart.subtotal || Decimal.new("0"), - country: cart.shipping_country - }) - end) - end - - @doc """ - Gets a shipping method by ID or UUID. - """ - def get_shipping_method(id) when is_binary(id) do - if UUIDUtils.valid?(id) do - repo().get_by(ShippingMethod, uuid: id) - else - nil - end - end - - def get_shipping_method(_), do: nil - - @doc """ - Gets a shipping method by ID or UUID, raises if not found. - """ - def get_shipping_method!(id) do - case get_shipping_method(id) do - nil -> raise Ecto.NoResultsError, queryable: ShippingMethod - method -> method - end - end - - @doc """ - Gets a shipping method by slug. - """ - def get_shipping_method_by_slug(slug) do - ShippingMethod - |> where([s], s.slug == ^slug) - |> repo().one() - end - - @doc """ - Creates a new shipping method. - """ - def create_shipping_method(attrs) do - %ShippingMethod{} - |> ShippingMethod.changeset(attrs) - |> repo().insert() - end - - @doc """ - Updates a shipping method. - """ - def update_shipping_method(%ShippingMethod{} = method, attrs) do - method - |> ShippingMethod.changeset(attrs) - |> repo().update() - end - - @doc """ - Deletes a shipping method. - """ - def delete_shipping_method(%ShippingMethod{} = method) do - repo().delete(method) - end - - @doc """ - Returns a changeset for shipping method form. - """ - def change_shipping_method(%ShippingMethod{} = method, attrs \\ %{}) do - ShippingMethod.changeset(method, attrs) - end - - # ============================================ - # CARTS - # ============================================ - - @doc """ - Gets or creates a cart for the current user/session. - - ## Options - - `:user_uuid` - User UUID (for authenticated users) - - `:session_id` - Session ID (for guests) - """ - def get_or_create_cart(opts) do - user_uuid = Keyword.get(opts, :user_uuid) - session_id = Keyword.get(opts, :session_id) - - case find_active_cart(user_uuid: user_uuid, session_id: session_id) do - nil -> create_cart(user_uuid: user_uuid, session_id: session_id) - cart -> {:ok, cart} - end - end - - @doc """ - Finds active cart by user_uuid or session_id. - - Search priority: - 1. If user_uuid is provided, search by user_uuid first - 2. If not found and session_id is provided, search by session_id (handles guest->login transition) - 3. If only session_id is provided, search by session_id with no user_uuid - """ - def find_active_cart(opts) do - user_uuid = Keyword.get(opts, :user_uuid) - session_id = Keyword.get(opts, :session_id) - - base_query = - Cart - |> where([c], c.status == "active") - |> preload([:items, :shipping_method, :payment_option]) - - cond do - not is_nil(user_uuid) -> - # First try to find by user_uuid - case base_query |> where([c], c.user_uuid == ^user_uuid) |> repo().one() do - nil when not is_nil(session_id) -> - # Fallback: try session_id (cart created before login) - base_query |> where([c], c.session_id == ^session_id) |> repo().one() - - result -> - result - end - - not is_nil(session_id) -> - # Guest user - search by session_id only - base_query - |> where([c], c.session_id == ^session_id and is_nil(c.user_uuid)) - |> repo().one() - - true -> - # No identity provided - nil - end - end - - @doc """ - Creates a new cart. - """ - def create_cart(opts) do - attrs = %{ - user_uuid: Keyword.get(opts, :user_uuid), - session_id: Keyword.get(opts, :session_id), - currency: get_default_currency_code() - } - - case %Cart{} |> Cart.changeset(attrs) |> repo().insert() do - {:ok, cart} -> {:ok, repo().preload(cart, [:items, :shipping_method])} - error -> error - end - end - - @doc """ - Gets a cart by ID or UUID with items preloaded. - """ - def get_cart(uuid) when is_binary(uuid) do - if UUIDUtils.valid?(uuid) do - Cart - |> where([c], c.uuid == ^uuid) - |> preload([:items, :shipping_method, :payment_option]) - |> repo().one() - else - nil - end - end - - def get_cart(_), do: nil - - @doc """ - Gets a cart by ID or UUID, raises if not found. - """ - def get_cart!(id) do - case get_cart(id) do - nil -> raise Ecto.NoResultsError, queryable: Cart - cart -> cart - end - end - - @doc """ - Adds item to cart. - - ## Options - - `:selected_specs` - Map of selected specifications (for dynamic pricing) - - ## Examples - - # Add simple product - add_to_cart(cart, product, 2) - - # Add product with specification-based pricing - add_to_cart(cart, product, 1, selected_specs: %{"material" => "PETG", "color" => "Gold"}) - """ - def add_to_cart(cart, product, quantity \\ 1, opts \\ []) - - def add_to_cart(%Cart{} = cart, %Product{} = product, quantity, opts) when is_list(opts) do - selected_specs = Keyword.get(opts, :selected_specs, %{}) - skip_validation = Keyword.get(opts, :skip_spec_validation, false) - - # Validate selected_specs against product's option schema - with :ok <- maybe_validate_specs(product, selected_specs, skip_validation) do - if map_size(selected_specs) > 0 do - add_product_with_specs_to_cart(cart, product, quantity, selected_specs) - else - add_simple_product_to_cart(cart, product, quantity) - end - end - end - - def add_to_cart(%Cart{} = cart, %Product{} = product, quantity, _opts) - when is_integer(quantity) do - add_simple_product_to_cart(cart, product, quantity) - end - - defp add_simple_product_to_cart(cart, product, quantity) do - result = - repo().transaction(fn -> - # Lock product row to prevent price changes during cart update - # This ensures price snapshot is consistent with current product state - locked_product = - Product - |> where([p], p.uuid == ^product.uuid) - |> lock("FOR UPDATE") - |> repo().one!() - - # Use unified price calculation path (same as add_product_with_specs_to_cart) - # With empty specs this returns base_price, but allows future extensibility - calculated_price = calculate_product_price(locked_product, %{}) - - # Check if product already in cart (without specs) - existing = find_cart_item_by_specs(cart.uuid, product.uuid, %{}) - - item = - case existing do - nil -> - # Create new item with calculated price - attrs = - CartItem.from_product(locked_product, quantity) - |> Map.put(:cart_uuid, cart.uuid) - |> Map.put(:unit_price, calculated_price) - - %CartItem{} |> CartItem.changeset(attrs) |> repo().insert!() - - item -> - # Update quantity - new_qty = item.quantity + quantity - item |> CartItem.changeset(%{quantity: new_qty}) |> repo().update!() - end - - # Recalculate totals - updated_cart = recalculate_cart_totals!(cart) - {updated_cart, item} - end) - - case result do - {:ok, {updated_cart, item}} -> - Events.broadcast_item_added(updated_cart, item) - {:ok, updated_cart} - - error -> - error - end - end - - defp add_product_with_specs_to_cart(cart, product, quantity, selected_specs) do - result = - repo().transaction(fn -> - # Lock product row to prevent price/metadata changes during cart update - locked_product = - Product - |> where([p], p.uuid == ^product.uuid) - |> lock("FOR UPDATE") - |> repo().one!() - - # Calculate price with spec modifiers using locked product state - calculated_price = calculate_product_price(locked_product, selected_specs) - - # Check if same product with same specs already in cart - existing = find_cart_item_by_specs(cart.uuid, product.uuid, selected_specs) - - item = - case existing do - nil -> - # Create new item with specs and calculated price - attrs = - CartItem.from_product(locked_product, quantity) - |> Map.put(:cart_uuid, cart.uuid) - |> Map.put(:unit_price, calculated_price) - |> Map.put(:selected_specs, selected_specs) - - %CartItem{} |> CartItem.changeset(attrs) |> repo().insert!() - - item -> - # Update quantity (price already frozen from first add) - new_qty = item.quantity + quantity - item |> CartItem.changeset(%{quantity: new_qty}) |> repo().update!() - end - - # Recalculate totals - updated_cart = recalculate_cart_totals!(cart) - {updated_cart, item} - end) - - case result do - {:ok, {updated_cart, item}} -> - Events.broadcast_item_added(updated_cart, item) - {:ok, updated_cart} - - error -> - error - end - end - - # ============================================ - # SELECTED SPECS VALIDATION - # ============================================ - - defp maybe_validate_specs(_product, _specs, true), do: :ok - defp maybe_validate_specs(_product, specs, _skip) when specs == %{}, do: :ok - - defp maybe_validate_specs(product, selected_specs, _skip) do - validate_selected_specs(product, selected_specs) - end - - @doc """ - Validates selected_specs against product's option schema. - - Checks: - - All spec keys exist in the option schema - - All spec values are in allowed values list (if defined) - - All required options have values - - ## Returns - - - `:ok` - All specs are valid - - `{:error, :unknown_option_key, key}` - Key not in schema - - `{:error, :invalid_option_value, %{key: key, value: value, allowed: list}}` - Value not allowed - - `{:error, :missing_required_option, key}` - Required option not provided - - ## Examples - - iex> validate_selected_specs(product, %{"material" => "PETG"}) - :ok - - iex> validate_selected_specs(product, %{"material" => "Unobtainium"}) - {:error, :invalid_option_value, %{key: "material", value: "Unobtainium", allowed: ["PLA", "PETG"]}} - """ - def validate_selected_specs(%Product{} = product, selected_specs) when is_map(selected_specs) do - # Use full selectable specs (includes discovered options from metadata) - # to match what the UI actually shows to users - schema = Options.get_selectable_specs_for_product(product) - - # Build lookup map: key => option definition - schema_map = Map.new(schema, fn opt -> {opt["key"], opt} end) - - # Check all provided keys exist and values are valid - with :ok <- validate_spec_keys(selected_specs, schema_map), - :ok <- validate_spec_values(selected_specs, schema_map) do - validate_required_options(selected_specs, schema) - end - end - - def validate_selected_specs(_product, _specs), do: :ok - - # Validate that all provided keys exist in schema - defp validate_spec_keys(selected_specs, schema_map) do - invalid_key = - Enum.find(Map.keys(selected_specs), fn key -> - not Map.has_key?(schema_map, key) - end) - - if invalid_key do - {:error, :unknown_option_key, invalid_key} - else - :ok - end - end - - # Validate that all values are in allowed list (if options defined) - defp validate_spec_values(selected_specs, schema_map) do - invalid = - Enum.find(selected_specs, fn {key, value} -> - opt = Map.get(schema_map, key) - allowed_values = opt["options"] - - # Only validate if options list is defined and non-empty - if is_list(allowed_values) and allowed_values != [] do - value not in allowed_values - else - false - end - end) - - case invalid do - nil -> - :ok - - {key, value} -> - opt = Map.get(schema_map, key) - {:error, :invalid_option_value, %{key: key, value: value, allowed: opt["options"]}} - end - end - - # Validate that all required options have values - defp validate_required_options(selected_specs, schema) do - missing = - Enum.find(schema, fn opt -> - required = opt["required"] == true - key = opt["key"] - - required and not Map.has_key?(selected_specs, key) - end) - - if missing do - {:error, :missing_required_option, missing["key"]} - else - :ok - end - end - - @doc """ - Updates item quantity in cart. - """ - def update_cart_item(%CartItem{} = item, quantity) when quantity > 0 do - result = - repo().transaction(fn -> - updated_item = - item - |> CartItem.changeset(%{quantity: quantity}) - |> repo().update!() - - cart = repo().get_by!(Cart, uuid: item.cart_uuid) - - updated_cart = recalculate_cart_totals!(cart) - {updated_cart, updated_item} - end) - - case result do - {:ok, {updated_cart, updated_item}} -> - Events.broadcast_quantity_updated(updated_cart, updated_item) - {:ok, updated_cart} - - error -> - error - end - end - - def update_cart_item(%CartItem{} = item, 0), do: remove_from_cart(item) - - @doc """ - Removes item from cart. - """ - def remove_from_cart(%CartItem{} = item) do - item_uuid = item.uuid - - result = - repo().transaction(fn -> - cart_uuid = item.cart_uuid - repo().delete!(item) - - cart = repo().get_by!(Cart, uuid: cart_uuid) - - recalculate_cart_totals!(cart) - end) - - case result do - {:ok, updated_cart} -> - Events.broadcast_item_removed(updated_cart, item_uuid) - {:ok, updated_cart} - - error -> - error - end - end - - @doc """ - Clears all items from cart. - """ - def clear_cart(%Cart{} = cart) do - result = - repo().transaction(fn -> - CartItem - |> where([i], i.cart_uuid == ^cart.uuid) - |> repo().delete_all() - - recalculate_cart_totals!(cart) - end) - - case result do - {:ok, updated_cart} -> - Events.broadcast_cart_cleared(updated_cart) - {:ok, updated_cart} - - error -> - error - end - end - - @doc """ - Sets the shipping country for the cart. - """ - def set_cart_shipping_country(%Cart{} = cart, country) do - cart - |> Cart.shipping_changeset(%{shipping_country: country}) - |> repo().update() - end - - @doc """ - Sets shipping method for cart. - """ - def set_cart_shipping(%Cart{} = cart, %ShippingMethod{} = method, country) do - shipping_cost = ShippingMethod.calculate_cost(method, cart.subtotal || Decimal.new("0")) - - result = - repo().transaction(fn -> - updated_cart = - cart - |> Cart.shipping_changeset(%{ - shipping_method_uuid: method.uuid, - shipping_country: country, - shipping_amount: shipping_cost - }) - |> repo().update!() - - recalculate_cart_totals!(updated_cart) - end) - - case result do - {:ok, updated_cart} -> - Events.broadcast_shipping_selected(updated_cart) - {:ok, updated_cart} - - error -> - error - end - end - - @doc """ - Sets payment option for cart. - """ - def set_cart_payment_option(%Cart{} = cart, %PaymentOption{} = option) do - result = - cart - |> Cart.payment_changeset(%{ - payment_option_uuid: option.uuid - }) - |> repo().update() - - case result do - {:ok, updated_cart} -> - Events.broadcast_payment_selected(updated_cart) - {:ok, updated_cart} - - error -> - error - end - end - - def set_cart_payment_option(%Cart{} = cart, payment_option_uuid) - when is_binary(payment_option_uuid) do - case Billing.get_payment_option(payment_option_uuid) do - nil -> - {:error, :payment_option_not_found} - - option -> - set_cart_payment_option(cart, option) - end - end - - def set_cart_payment_option(%Cart{} = cart, nil) do - result = - cart - |> Cart.payment_changeset(%{payment_option_uuid: nil}) - |> repo().update() - - case result do - {:ok, updated_cart} -> - Events.broadcast_payment_selected(updated_cart) - {:ok, updated_cart} - - error -> - error - end - end - - @doc """ - Auto-selects payment option if only one is available. - - If cart already has a payment option selected, does nothing. - If only one option is available, selects it. - """ - def auto_select_payment_option(%Cart{} = cart, payment_options) do - cond do - # Already has payment option selected - not is_nil(cart.payment_option_uuid) -> - {:ok, cart} - - # No options available - payment_options == [] -> - {:ok, cart} - - # Only one option available - auto-select it - length(payment_options) == 1 -> - option = hd(payment_options) - set_cart_payment_option(cart, option) - - # Multiple options - user must choose - true -> - {:ok, cart} - end - end - - @doc """ - Auto-selects the cheapest available shipping method for a cart. - - If cart already has a shipping method selected, does nothing. - If only one method is available, selects it. - If multiple methods are available, selects the cheapest one. - """ - def auto_select_shipping_method(%Cart{} = cart, shipping_methods) do - cond do - # Already has shipping method selected - not is_nil(cart.shipping_method_uuid) -> - {:ok, cart} - - # No items in cart - cart.items == [] or is_nil(cart.items) -> - {:ok, cart} - - # No shipping methods available - shipping_methods == [] -> - {:ok, cart} - - # One or more methods available - select cheapest - true -> - cheapest = find_cheapest_shipping_method(shipping_methods, cart.subtotal) - set_cart_shipping(cart, cheapest, nil) - end - end - - defp find_cheapest_shipping_method(methods, subtotal) do - subtotal = subtotal || Decimal.new("0") - - methods - |> Enum.min_by(fn method -> - if ShippingMethod.free_for?(method, subtotal) do - Decimal.new("0") - else - method.price || Decimal.new("999999") - end - end) - end - - @doc """ - Merges guest cart into user cart after login. - Accepts a user struct or user_uuid (string). - """ - def merge_guest_cart(session_id, %{uuid: user_uuid}) do - do_merge_guest_cart(session_id, user_uuid) - end - - def merge_guest_cart(session_id, user_uuid) when is_binary(user_uuid) do - do_merge_guest_cart(session_id, user_uuid) - end - - defp do_merge_guest_cart(session_id, user_uuid) do - guest_cart = find_active_cart(session_id: session_id) - user_cart = find_active_cart(user_uuid: user_uuid) - - case {guest_cart, user_cart} do - {nil, _} -> - {:ok, user_cart} - - {guest, nil} -> - # Convert guest cart to user cart - guest - |> Cart.changeset(%{ - user_uuid: user_uuid, - session_id: nil, - expires_at: nil - }) - |> repo().update() - - {guest, user} -> - # Merge items into user cart - do_merge_guest_cart_items(guest, user) - end - end - - defp do_merge_guest_cart_items(guest, user) do - repo().transaction(fn -> - # Move items from guest to user cart - Enum.each(guest.items, fn item -> - merge_cart_item(user, item) - end) - - # Mark guest cart as merged - guest - |> Cart.status_changeset("merged", %{ - merged_into_cart_uuid: user.uuid - }) - |> repo().update!() - - # Recalculate user cart - recalculate_cart_totals!(user) - - repo().get_by!(Cart, uuid: user.uuid) - |> repo().preload([:items, :shipping_method, :payment_option]) - end) - end - - defp merge_cart_item(user_cart, item) do - existing = - find_cart_item_by_specs(user_cart.uuid, item.product_uuid, item.selected_specs || %{}) - - case existing do - nil -> - attrs = - Map.from_struct(item) - |> Map.drop([:__meta__, :id, :uuid, :cart, :product, :inserted_at, :updated_at]) - |> Map.put(:cart_uuid, user_cart.uuid) - - %CartItem{} - |> CartItem.changeset(attrs) - |> repo().insert!() - - existing_item -> - new_qty = existing_item.quantity + item.quantity - existing_item |> CartItem.changeset(%{quantity: new_qty}) |> repo().update!() - end - end - - @doc """ - Lists carts with filters for admin. - """ - def list_carts_with_count(opts \\ []) do - page = Keyword.get(opts, :page, 1) - per_page = Keyword.get(opts, :per_page, 25) - offset = (page - 1) * per_page - status = Keyword.get(opts, :status) - search = Keyword.get(opts, :search) - - base_query = Cart - - base_query = - if status && status != "" do - where(base_query, [c], c.status == ^status) - else - base_query - end - - base_query = - if search && search != "" do - search_term = "%#{search}%" - - base_query - |> join(:left, [c], u in assoc(c, :user)) - |> where([c, u], ilike(u.email, ^search_term) or c.session_id == ^search) - else - base_query - end - - total = repo().aggregate(base_query, :count) - - carts = - base_query - |> order_by([c], desc: c.updated_at) - |> limit(^per_page) - |> offset(^offset) - |> preload([:user, :items]) - |> repo().all() - - {carts, total} - end - - @doc """ - Marks abandoned carts (no activity for X days). - """ - def mark_abandoned_carts(days \\ 7) do - threshold = UtilsDate.utc_now() |> DateTime.add(-days, :day) - - {count, _} = - Cart - |> where([c], c.status == "active") - |> where([c], c.updated_at < ^threshold) - |> repo().update_all(set: [status: "abandoned"]) - - {:ok, count} - end - - @doc """ - Expires old guest carts. - """ - def expire_old_carts do - now = UtilsDate.utc_now() - - {count, _} = - Cart - |> where([c], c.status == "active") - |> where([c], not is_nil(c.expires_at)) - |> where([c], c.expires_at < ^now) - |> repo().update_all(set: [status: "expired"]) - - {:ok, count} - end - - @doc """ - Counts active carts. - """ - def count_active_carts do - Cart - |> where([c], c.status == "active") - |> repo().aggregate(:count) - rescue - _ -> 0 - end - - # ============================================ - # CHECKOUT / ORDER CONVERSION - # ============================================ - - @doc """ - Converts a cart to a Billing.Order. - - Takes an active cart with items and creates an Order with: - - All cart items as line_items - - Shipping as additional line item (if selected) - - Billing profile snapshot (from profile_uuid or direct billing_data) - - Cart marked as "converted" - - For guest checkout (no user_uuid on cart): - - Creates a guest user via `Auth.create_guest_user/1` - - Guest user has `confirmed_at = nil` until email verification - - Sends confirmation email automatically - - Order remains in "pending" status - - ## Options - - - `billing_profile_uuid: uuid` - Use existing billing profile (for logged-in users) - - `billing_data: map` - Use direct billing data (for guest checkout) - - ## Returns - - - `{:ok, order}` - Order created successfully - - `{:error, :cart_not_active}` - Cart is not active - - `{:error, :cart_empty}` - Cart has no items - - `{:error, :no_shipping_method}` - No shipping method selected - - `{:error, :email_already_registered}` - Guest email belongs to confirmed user - - `{:error, changeset}` - Validation errors - """ - def convert_cart_to_order(%Cart{} = cart, opts) when is_list(opts) do - cart = get_cart!(cart.uuid) - - # Wrap entire conversion in a transaction to ensure atomicity - # If any step fails after order creation, the order is rolled back - repo().transaction(fn -> - # Use atomic status transition to prevent double-conversion on double-click - # This atomically changes status from "active" to "converting" and fails - # if another request already started conversion - with :ok <- validate_cart_convertible(cart), - {:ok, cart} <- try_lock_cart_for_conversion(cart), - {:ok, user_uuid, cart} <- resolve_checkout_user(cart, opts), - line_items <- build_order_line_items(cart), - order_attrs <- build_order_attrs(cart, line_items, opts), - {:ok, order} <- do_create_order(user_uuid, order_attrs), - {:ok, _cart} <- mark_cart_converted(cart, order.uuid), - :ok <- maybe_send_guest_confirmation(user_uuid) do - {:ok, order} - else - {:error, reason} -> - # Rollback transaction on any error, unwrapping the {:error, _} tuple - # so the transaction returns {:error, reason} (not {:error, {:error, reason}}) - repo().rollback(reason) - - other -> - repo().rollback(other) - end - end) - # unwrap the transaction result - |> case do - {:ok, {:ok, order}} -> {:ok, order} - {:error, reason} -> {:error, reason} - end - end - - defp validate_cart_convertible(%Cart{} = cart) do - cond do - cart.status != "active" -> - {:error, :cart_not_active} - - Enum.empty?(cart.items) -> - {:error, :cart_empty} - - is_nil(cart.shipping_method_uuid) -> - {:error, :no_shipping_method} - - true -> - :ok - end - end - - defp build_order_line_items(%Cart{} = cart) do - product_items = - Enum.map(cart.items, fn item -> - %{ - "name" => item.product_title, - "description" => format_item_description(item), - "selected_specs" => item.selected_specs || %{}, - "quantity" => item.quantity, - "unit_price" => Decimal.to_string(item.unit_price), - "total" => Decimal.to_string(item.line_total), - "sku" => item.product_sku, - "type" => "product" - } - end) - - shipping_item = - if cart.shipping_method do - [ - %{ - "name" => "Shipping: #{cart.shipping_method.name}", - "description" => cart.shipping_method.description || "", - "quantity" => 1, - "unit_price" => Decimal.to_string(cart.shipping_amount || Decimal.new(0)), - "total" => Decimal.to_string(cart.shipping_amount || Decimal.new(0)), - "type" => "shipping" - } - ] - else - [] - end - - product_items ++ shipping_item - end - - defp build_order_attrs(%Cart{} = cart, line_items, opts) do - billing_profile_uuid = Keyword.get(opts, :billing_profile_uuid) - billing_data = Keyword.get(opts, :billing_data) - - # Get shipping country from billing data or cart - shipping_country = get_shipping_country(billing_profile_uuid, billing_data, cart) - - # Use string keys to match Billing.maybe_set_order_number behavior - base_attrs = %{ - "currency" => cart.currency, - "line_items" => line_items, - "subtotal" => cart.subtotal, - "tax_amount" => cart.tax_amount || Decimal.new(0), - "tax_rate" => Decimal.new(0), - "discount_amount" => cart.discount_amount || Decimal.new(0), - "discount_code" => cart.discount_code, - "total" => cart.total, - "status" => "pending", - "metadata" => %{ - "source" => "shop_checkout", - "cart_uuid" => cart.uuid, - "shipping_country" => shipping_country, - "shipping_method_uuid" => cart.shipping_method_uuid - } - } - - cond do - # Logged-in user with billing profile - not is_nil(billing_profile_uuid) -> - Map.put(base_attrs, "billing_profile_uuid", billing_profile_uuid) - - # Guest checkout with billing data - clean up _unused_ keys from LiveView - is_map(billing_data) -> - cleaned_billing_data = clean_billing_data(billing_data) - Map.put(base_attrs, "billing_snapshot", cleaned_billing_data) - - true -> - base_attrs - end - end - - # Get shipping country from billing profile, billing data, or cart - defp get_shipping_country(billing_profile_uuid, _billing_data, cart) - when not is_nil(billing_profile_uuid) do - case Billing.get_billing_profile(billing_profile_uuid) do - %{country: country} when is_binary(country) -> country - _ -> cart.shipping_country - end - end - - defp get_shipping_country(_billing_profile_uuid, billing_data, cart) - when is_map(billing_data) do - billing_data["country"] || cart.shipping_country - end - - defp get_shipping_country(_billing_profile_uuid, _billing_data, cart) do - cart.shipping_country - end - - # Remove _unused_ prefixed keys that Phoenix LiveView adds - defp clean_billing_data(data) when is_map(data) do - data - |> Enum.reject(fn {key, _value} -> - key_str = if is_atom(key), do: Atom.to_string(key), else: key - String.starts_with?(key_str, "_unused_") - end) - |> Map.new() - end - - # Resolve user for checkout: logged-in user or create guest user - defp resolve_checkout_user(%Cart{user_uuid: user_uuid} = cart, _opts) - when not is_nil(user_uuid) do - # Cart already has a user (logged-in checkout) - {:ok, user_uuid, cart} - end - - defp resolve_checkout_user(%Cart{user_uuid: nil} = cart, opts) do - # Check if logged-in user_uuid was passed in opts (user is logged in but has guest cart) - case Keyword.get(opts, :user_uuid) do - user_uuid when not is_nil(user_uuid) -> - resolve_logged_in_user_with_guest_cart(cart, user_uuid) - - nil -> - resolve_guest_checkout(cart, opts) - end - end - - defp resolve_logged_in_user_with_guest_cart(cart, user_uuid) do - user = Auth.get_user(user_uuid) - - case user && assign_cart_to_user(cart, user) do - {:ok, updated_cart} -> {:ok, user_uuid, updated_cart} - _ -> {:ok, user_uuid, cart} - end - end - - defp resolve_guest_checkout(cart, opts) do - billing_data = Keyword.get(opts, :billing_data) - - if valid_billing_data?(billing_data) do - create_guest_user_and_assign_cart(cart, billing_data) - else - {:ok, nil, cart} - end - end - - defp valid_billing_data?(data), do: is_map(data) and Map.has_key?(data, "email") - - defp create_guest_user_and_assign_cart(cart, billing_data) do - case Auth.create_guest_user(%{ - email: billing_data["email"], - first_name: billing_data["first_name"], - last_name: billing_data["last_name"] - }) do - {:ok, user} -> - assign_cart_and_return(cart, user) - - {:error, :email_exists_unconfirmed, user} -> - assign_cart_and_return(cart, user) - - {:error, :email_exists_confirmed} -> - {:error, :email_already_registered} - - {:error, changeset} -> - {:error, changeset} - end - end - - defp assign_cart_and_return(cart, %{uuid: user_uuid} = user) do - case assign_cart_to_user(cart, user) do - {:ok, updated_cart} -> {:ok, user_uuid, updated_cart} - {:error, _} -> {:ok, user_uuid, cart} - end - end - - # Assign cart to user (for guest -> user conversion) - defp assign_cart_to_user(%Cart{} = cart, %{uuid: user_uuid}) do - cart - |> Cart.changeset(%{user_uuid: user_uuid, session_id: nil}) - |> repo().update() - end - - # Create order with or without user - defp do_create_order(nil, order_attrs) do - Billing.create_order(order_attrs) - end - - defp do_create_order(user_uuid, order_attrs) do - Billing.create_order(user_uuid, order_attrs) - end - - # Send confirmation email to guest users - defp maybe_send_guest_confirmation(nil), do: :ok - - defp maybe_send_guest_confirmation(user_uuid) do - case Auth.get_user(user_uuid) do - %{confirmed_at: nil} = user -> - # Guest user - send confirmation email - Auth.deliver_user_confirmation_instructions( - user, - &Routes.url("/users/confirm/#{&1}") - ) - - :ok - - _ -> - # Already confirmed user - no action needed - :ok - end - end - - # Atomically transition cart from "active" to "converting" status. - # This prevents double-conversion when user double-clicks checkout button. - # If another request already started conversion, this returns error. - defp try_lock_cart_for_conversion(%Cart{uuid: cart_uuid}) do - # Use atomic UPDATE with WHERE clause to ensure only one request wins - {count, _} = - Cart - |> where([c], c.uuid == ^cart_uuid and c.status == "active") - |> repo().update_all(set: [status: "converting", updated_at: UtilsDate.utc_now()]) - - if count == 1 do - # Successfully locked - reload cart with new status - {:ok, get_cart!(cart_uuid)} - else - # Another request already started conversion - {:error, :cart_already_converting} - end - end - - defp mark_cart_converted(%Cart{} = cart, order_uuid) do - cart - |> Cart.status_changeset("converted", %{ - converted_at: UtilsDate.utc_now(), - metadata: Map.put(cart.metadata || %{}, "order_uuid", order_uuid) - }) - |> repo().update() - end - - # ============================================ - # PRIVATE HELPERS - # ============================================ - - # Format cart item description including selected_specs - defp format_item_description(%CartItem{product_slug: slug, selected_specs: specs}) - when specs == %{} or is_nil(specs) do - slug - end - - defp format_item_description(%CartItem{product_slug: slug, selected_specs: specs}) do - specs_text = - Enum.map_join(specs, ", ", fn {key, value} -> "#{humanize_key(key)}: #{value}" end) - - "#{slug} (#{specs_text})" - end - - # Convert key to human-readable format: "material_type" -> "Material Type" - defp humanize_key(key) when is_binary(key) do - key - |> String.replace("_", " ") - |> String.split(" ") - |> Enum.map_join(" ", &String.capitalize/1) - end - - defp humanize_key(key), do: to_string(key) - - defp count_products do - Product |> repo().aggregate(:count) - rescue - _ -> 0 - end - - defp count_products_by_status(status) do - Product - |> where([p], p.status == ^status) - |> repo().aggregate(:count) - rescue - _ -> 0 - end - - defp count_products_by_type(product_type) do - Product - |> where([p], p.product_type == ^product_type) - |> repo().aggregate(:count) - rescue - _ -> 0 - end - - defp count_categories do - Category |> repo().aggregate(:count) - rescue - _ -> 0 - end - - defp apply_product_filters(query, opts) do - query - |> filter_by_status(Keyword.get(opts, :status)) - |> filter_by_product_type(Keyword.get(opts, :product_type)) - |> filter_by_category(Keyword.get(opts, :category_uuid)) - |> filter_by_product_search(Keyword.get(opts, :search)) - |> filter_by_visible_categories(Keyword.get(opts, :exclude_hidden_categories, false)) - |> filter_by_price_range(Keyword.get(opts, :price_min), Keyword.get(opts, :price_max)) - |> filter_by_vendors(Keyword.get(opts, :vendors)) - |> filter_by_metadata_options(Keyword.get(opts, :metadata_filters)) - end - - defp filter_by_status(query, nil), do: query - defp filter_by_status(query, status), do: where(query, [p], p.status == ^status) - - defp filter_by_product_type(query, nil), do: query - defp filter_by_product_type(query, type), do: where(query, [p], p.product_type == ^type) - - defp filter_by_category(query, nil), do: query - - defp filter_by_category(query, uuid) when is_binary(uuid) do - if UUIDUtils.valid?(uuid) do - where(query, [p], p.category_uuid == ^uuid) - else - query - end - end - - defp filter_by_visible_categories(query, false), do: query - - defp filter_by_visible_categories(query, true) do - # Exclude products from categories with status "hidden" - # Products from "active" and "unlisted" categories are visible - # Use distinct to avoid duplicates from the left_join - from(p in query, - left_join: c in Category, - on: c.uuid == p.category_uuid, - where: is_nil(c.uuid) or c.status != "hidden", - distinct: p.uuid - ) - end - - defp filter_by_price_range(query, nil, nil), do: query - defp filter_by_price_range(query, min, nil), do: where(query, [p], p.price >= ^min) - defp filter_by_price_range(query, nil, max), do: where(query, [p], p.price <= ^max) - - defp filter_by_price_range(query, min, max), - do: where(query, [p], p.price >= ^min and p.price <= ^max) - - defp filter_by_vendors(query, nil), do: query - defp filter_by_vendors(query, []), do: query - - defp filter_by_vendors(query, vendors) when is_list(vendors), - do: where(query, [p], p.vendor in ^vendors) - - defp filter_by_metadata_options(query, nil), do: query - defp filter_by_metadata_options(query, []), do: query - - defp filter_by_metadata_options(query, filters) when is_list(filters) do - Enum.reduce(filters, query, fn %{key: key, values: values}, q -> - where( - q, - [p], - fragment( - "EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(?->'_option_values'->?, '[]'::jsonb)) elem WHERE elem = ANY(?))", - p.metadata, - ^key, - ^values - ) - ) - end) - end - - defp filter_by_product_search(query, nil), do: query - defp filter_by_product_search(query, ""), do: query - - defp filter_by_product_search(query, search) do - search_term = "%#{search}%" - default_lang = Translations.default_language() - - # Search in JSONB localized fields using PostgreSQL operators - # Searches in default language and falls back to any language match - where( - query, - [p], - fragment( - "(COALESCE(title->>?, '') ILIKE ? OR COALESCE(description->>?, '') ILIKE ? OR EXISTS (SELECT 1 FROM jsonb_each_text(title) WHERE value ILIKE ?) OR EXISTS (SELECT 1 FROM jsonb_each_text(description) WHERE value ILIKE ?))", - ^default_lang, - ^search_term, - ^default_lang, - ^search_term, - ^search_term, - ^search_term - ) - ) - end - - defp apply_category_filters(query, opts) do - query - |> filter_by_parent_uuid(Keyword.get(opts, :parent_uuid, :skip)) - |> filter_by_category_status(Keyword.get(opts, :status, :skip)) - |> filter_by_category_search(Keyword.get(opts, :search)) - end - - defp filter_by_parent_uuid(query, :skip), do: query - defp filter_by_parent_uuid(query, nil), do: where(query, [c], is_nil(c.parent_uuid)) - defp filter_by_parent_uuid(query, uuid), do: where(query, [c], c.parent_uuid == ^uuid) - - defp filter_by_category_status(query, :skip), do: query - defp filter_by_category_status(query, nil), do: query - - defp filter_by_category_status(query, status) when is_binary(status) do - where(query, [c], c.status == ^status) - end - - defp filter_by_category_status(query, statuses) when is_list(statuses) do - where(query, [c], c.status in ^statuses) - end - - defp filter_by_category_search(query, nil), do: query - defp filter_by_category_search(query, ""), do: query - - defp filter_by_category_search(query, search) do - search_term = "%#{search}%" - default_lang = Translations.default_language() - - # Search in JSONB localized name field using PostgreSQL operators - where( - query, - [c], - fragment( - "(COALESCE(name->>?, '') ILIKE ? OR EXISTS (SELECT 1 FROM jsonb_each_text(name) WHERE value ILIKE ?))", - ^default_lang, - ^search_term, - ^search_term - ) - ) - end - - defp maybe_preload(query, nil), do: query - defp maybe_preload(query, preloads), do: preload(query, ^preloads) - - # Shipping filters - defp filter_shipping_by_active(query, nil), do: query - defp filter_shipping_by_active(query, active), do: where(query, [s], s.active == ^active) - - # Cart helpers - - # Find cart item by product and selected_specs - defp find_cart_item_by_specs(cart_uuid, product_uuid, specs) when map_size(specs) == 0 do - # No specs - find item without specs - CartItem - |> where([i], i.cart_uuid == ^cart_uuid and i.product_uuid == ^product_uuid) - |> where([i], i.selected_specs == ^%{}) - |> repo().one() - end - - defp find_cart_item_by_specs(cart_uuid, product_uuid, specs) when is_map(specs) do - # With specs - find item with matching specs - CartItem - |> where([i], i.cart_uuid == ^cart_uuid and i.product_uuid == ^product_uuid) - |> where([i], i.selected_specs == ^specs) - |> repo().one() - end - - defp recalculate_cart_totals!(%Cart{} = cart) do - items = CartItem |> where([i], i.cart_uuid == ^cart.uuid) |> repo().all() - - subtotal = - Enum.reduce(items, Decimal.new("0"), fn i, acc -> - Decimal.add(acc, i.line_total || Decimal.new("0")) - end) - - total_weight = - Enum.reduce(items, 0, fn i, acc -> - acc + (i.weight_grams || 0) * i.quantity - end) - - items_count = - Enum.reduce(items, 0, fn i, acc -> - acc + i.quantity - end) - - shipping_amount = calculate_shipping(cart, subtotal, total_weight) - - # Calculate tax - tax_rate = get_tax_rate(cart) - taxable_amount = Decimal.sub(subtotal, cart.discount_amount || Decimal.new("0")) - tax_amount = Decimal.mult(taxable_amount, tax_rate) |> Decimal.round(2) - - # Calculate total - total = - subtotal - |> Decimal.add(shipping_amount) - |> Decimal.add(tax_amount) - |> Decimal.sub(cart.discount_amount || Decimal.new("0")) - - cart - |> Cart.totals_changeset(%{ - subtotal: subtotal, - shipping_amount: shipping_amount, - tax_amount: tax_amount, - total: total, - total_weight_grams: total_weight, - items_count: items_count - }) - |> repo().update!() - |> repo().preload([:items, :shipping_method], force: true) - end - - defp calculate_shipping(cart, subtotal, total_weight) do - if cart.shipping_method_uuid do - shipping_method = repo().get_by(ShippingMethod, uuid: cart.shipping_method_uuid) - - case shipping_method do - nil -> - Decimal.new("0") - - method -> - if ShippingMethod.available_for?(method, %{ - weight_grams: total_weight, - subtotal: subtotal, - country: cart.shipping_country - }) do - ShippingMethod.calculate_cost(method, subtotal) - else - Decimal.new("0") - end - end - else - cart.shipping_amount || Decimal.new("0") - end - end - - defp get_tax_rate(%Cart{shipping_country: nil}), do: Decimal.new("0") - - defp get_tax_rate(%Cart{shipping_country: _country}) do - if Settings.get_setting_cached("shop_tax_enabled", "true") == "true" do - rate = Settings.get_setting_cached("shop_tax_rate", "20") - Decimal.div(Decimal.new(rate), Decimal.new("100")) - else - Decimal.new("0") - end - end - - defp repo, do: PhoenixKit.RepoHelper.repo() - - # ============================================ - # IMPORT LOGS - # ============================================ - - alias PhoenixKit.Modules.Shop.ImportLog - - @doc """ - Creates a new import log entry. - """ - def create_import_log(attrs) do - %ImportLog{} - |> ImportLog.create_changeset(attrs) - |> repo().insert() - end - - @doc """ - Gets an import log by ID. - """ - def get_import_log(id, opts \\ []) - - def get_import_log(uuid, opts) when is_binary(uuid) do - ImportLog - |> maybe_preload(Keyword.get(opts, :preload)) - |> repo().get_by(uuid: uuid) - end - - @doc """ - Gets an import log by ID, raises if not found. - """ - def get_import_log!(id) when is_binary(id) do - case get_import_log(id) do - nil -> raise Ecto.NoResultsError, queryable: ImportLog - log -> log - end - end - - @doc """ - Lists recent import logs. - """ - def list_import_logs(opts \\ []) do - limit = Keyword.get(opts, :limit, 20) - - ImportLog - |> order_by([l], desc: l.inserted_at) - |> limit(^limit) - |> repo().all() - |> repo().preload(:user) - end - - @doc """ - Updates an import log. - """ - def update_import_log(%ImportLog{} = import_log, attrs) do - import_log - |> ImportLog.update_changeset(attrs) - |> repo().update() - end - - @doc """ - Marks import as started. - """ - def start_import(%ImportLog{} = import_log, total_rows) do - import_log - |> ImportLog.start_changeset(total_rows) - |> repo().update() - end - - @doc """ - Updates import progress. - """ - def update_import_progress(%ImportLog{} = import_log, attrs) do - import_log - |> ImportLog.progress_changeset(attrs) - |> repo().update() - end - - @doc """ - Marks import as completed. - """ - def complete_import(%ImportLog{} = import_log, stats) do - import_log - |> ImportLog.complete_changeset(stats) - |> repo().update() - end - - @doc """ - Marks import as failed. - """ - def fail_import(%ImportLog{} = import_log, error) do - import_log - |> ImportLog.fail_changeset(error) - |> repo().update() - end - - @doc """ - Deletes an import log. - """ - def delete_import_log(%ImportLog{} = import_log) do - # Also delete the temp file if it exists - if import_log.file_path && File.exists?(import_log.file_path) do - File.rm(import_log.file_path) - end - - repo().delete(import_log) - end - - # ============================================ - # IMPORT CONFIG CRUD - # ============================================ - - @doc """ - Lists all active import configs. - """ - def list_import_configs(opts \\ []) do - query = - ImportConfig - |> order_by([c], desc: c.is_default, asc: c.name) - - query = - if Keyword.get(opts, :active_only, true) do - where(query, [c], c.active == true) - else - query - end - - repo().all(query) - end - - @doc """ - Gets an import config by ID. - """ - def get_import_config(uuid) when is_binary(uuid) do - repo().get_by(ImportConfig, uuid: uuid) - end - - @doc """ - Gets an import config by ID, raises if not found. - """ - def get_import_config!(id) when is_binary(id) do - case get_import_config(id) do - nil -> raise Ecto.NoResultsError, queryable: ImportConfig - config -> config - end - end - - @doc """ - Gets the default import config, if one exists. - """ - def get_default_import_config do - ImportConfig - |> where([c], c.is_default == true and c.active == true) - |> limit(1) - |> repo().one() - end - - @doc """ - Gets an import config by name. - """ - def get_import_config_by_name(name) when is_binary(name) do - repo().get_by(ImportConfig, name: name) - end - - @doc """ - Creates an import config. - """ - def create_import_config(attrs \\ %{}) do - result = - %ImportConfig{} - |> ImportConfig.changeset(attrs) - |> repo().insert() - - # If this is the new default, clear other defaults - case result do - {:ok, %ImportConfig{is_default: true} = config} -> - clear_other_defaults(config.uuid) - {:ok, config} - - other -> - other - end - end - - @doc """ - Updates an import config. - """ - def update_import_config(%ImportConfig{} = config, attrs) do - result = - config - |> ImportConfig.changeset(attrs) - |> repo().update() - - # If this is the new default, clear other defaults - case result do - {:ok, %ImportConfig{is_default: true} = updated_config} -> - clear_other_defaults(updated_config.uuid) - {:ok, updated_config} - - other -> - other - end - end - - @doc """ - Deletes an import config. - """ - def delete_import_config(%ImportConfig{} = config) do - repo().delete(config) - end - - defp clear_other_defaults(except_uuid) do - ImportConfig - |> where([c], c.is_default == true and c.uuid != ^except_uuid) - |> repo().update_all(set: [is_default: false]) - end - - @doc """ - Returns a changeset for tracking import config changes. - """ - def change_import_config(%ImportConfig{} = config, attrs \\ %{}) do - ImportConfig.changeset(config, attrs) - end - - @doc """ - Creates the legacy default import config if no configs exist. - - Returns `{:created, config}` if a new config was created, - or `:exists` if configs already exist. - """ - def ensure_default_import_config do - if repo().aggregate(ImportConfig, :count) == 0 do - attrs = Map.from_struct(ImportConfig.from_legacy_defaults()) - {:ok, config} = create_import_config(attrs) - {:created, config} - else - :exists - end - end - - @doc """ - Ensures a default Prom.ua import config exists. - Creates one if no config with name "prom_ua_default" is found. - """ - def ensure_prom_ua_import_config do - case repo().get_by(ImportConfig, name: "prom_ua_default") do - nil -> - attrs = - ImportConfig.from_prom_ua_defaults() - |> Map.from_struct() - |> Map.drop([:__meta__, :id, :uuid, :inserted_at, :updated_at]) - - {:ok, config} = create_import_config(attrs) - {:created, config} - - config -> - {:exists, config} - end - end - - # ============================================ - # PRODUCT UPSERT - # ============================================ - - @doc """ - Creates or updates a product by slug. - - Uses explicit find-or-create pattern with proper localized field merging. - After V47 migration, slug is a JSONB map (e.g., %{"en-US" => "my-slug"}), - so ON CONFLICT doesn't work correctly - this function handles the lookup manually. - - Returns {:ok, product, action} where action is :inserted or :updated. - - ## Parameters - - - `attrs` - Product attributes including localized fields as maps - - ## Examples - - # Create new product - iex> upsert_product(%{title: %{"en-US" => "Planter"}, slug: %{"en-US" => "planter"}, price: 10}) - {:ok, %Product{}, :inserted} - - # Update existing product (found by slug) - iex> upsert_product(%{title: %{"en-US" => "Planter V2"}, slug: %{"en-US" => "planter"}, price: 15}) - {:ok, %Product{}, :updated} - - # Add translation to existing product - iex> upsert_product(%{title: %{"es-ES" => "Maceta"}, slug: %{"es-ES" => "maceta", "en-US" => "planter"}, price: 10}) - {:ok, %Product{title: %{"en-US" => "Planter", "es-ES" => "Maceta"}}, :updated} - - """ - def upsert_product(attrs) do - slug_map = get_attr(attrs, :slug) || %{} - - case find_product_by_slug_map(slug_map) do - nil -> - # New product - create it - case create_product(attrs) do - {:ok, product} -> {:ok, product, :inserted} - error -> error - end - - existing -> - # Existing product - merge localized fields and update - merged_attrs = merge_localized_attrs(existing, attrs) - - case update_product(existing, merged_attrs) do - {:ok, product} -> {:ok, product, :updated} - error -> error - end - end - end - - @doc """ - Finds an existing product by any slug in the provided slug map. - - Searches through each slug value in the map to find a matching product. - Returns the first product found, or nil if no match. - - ## Examples - - iex> find_product_by_slug_map(%{"en-US" => "planter"}) - %Product{} | nil - - iex> find_product_by_slug_map(%{"en-US" => "planter", "es-ES" => "maceta"}) - %Product{} | nil # Finds by first matching slug - """ - def find_product_by_slug_map(slug_map) when map_size(slug_map) == 0, do: nil - - def find_product_by_slug_map(slug_map) when is_map(slug_map) do - # Try to find by any slug in the map - Enum.find_value(slug_map, fn {lang, slug} -> - case get_product_by_slug_localized(slug, lang) do - {:ok, product} -> product - _ -> nil - end - end) - end - - @doc """ - Merges localized fields from new attributes into existing product. - - Preserves existing translations while adding new ones from attrs. - Non-localized fields are replaced entirely. - - ## Examples - - iex> merge_localized_attrs(%Product{title: %{"en-US" => "Old"}}, %{title: %{"es-ES" => "Nuevo"}}) - %{title: %{"en-US" => "Old", "es-ES" => "Nuevo"}} - """ - def merge_localized_attrs(existing, new_attrs) do - localized_fields = [:title, :slug, :description, :body_html, :seo_title, :seo_description] - - # Start with all new attrs - Enum.reduce(localized_fields, new_attrs, fn field, acc -> - existing_map = Map.get(existing, field) || %{} - new_map = get_attr(acc, field) || %{} - - # Only merge if there's something to merge - if map_size(new_map) > 0 do - # Merge: new values take precedence for same language - merged = Map.merge(existing_map, new_map) - put_attr(acc, field, merged) - else - acc - end - end) - end - - # Helper to get attribute from either atom or string keyed map - defp get_attr(attrs, key) when is_atom(key) do - Map.get(attrs, key) || Map.get(attrs, to_string(key)) - end - - # Helper to put attribute preserving the map's key type - defp put_attr(attrs, key, value) when is_atom(key) do - cond do - Map.has_key?(attrs, key) -> Map.put(attrs, key, value) - Map.has_key?(attrs, to_string(key)) -> Map.put(attrs, to_string(key), value) - true -> Map.put(attrs, key, value) - end - end - - # ============================================ - # LOCALIZED API (Multi-Language Support) - # ============================================ - - alias PhoenixKit.Modules.Shop.SlugResolver - alias PhoenixKit.Modules.Shop.Translations - - @doc """ - Gets a product by slug with language awareness. - - Searches both translated slugs and canonical slug for the specified language. - - ## Parameters - - - `slug` - The URL slug to search for - - `language` - Language code (e.g., "es-ES" or base code "en") - - `opts` - Options: `:preload`, `:status` - - ## Examples - - iex> Shop.get_product_by_slug_localized("maceta-geometrica", "es-ES") - {:ok, %Product{}} - - iex> Shop.get_product_by_slug_localized("geometric-planter", "en") - {:ok, %Product{}} - """ - def get_product_by_slug_localized(slug, language, opts \\ []) do - SlugResolver.find_product_by_slug(slug, language, opts) - end - - @doc """ - Gets a category by slug with language awareness. - - Searches both translated slugs and canonical slug for the specified language. - - ## Parameters - - - `slug` - The URL slug to search for - - `language` - Language code (e.g., "es-ES" or base code "en") - - `opts` - Options: `:preload`, `:status` - - ## Examples - - iex> Shop.get_category_by_slug_localized("jarrones-macetas", "es-ES") - {:ok, %Category{}} - """ - def get_category_by_slug_localized(slug, language, opts \\ []) do - SlugResolver.find_category_by_slug(slug, language, opts) - end - - @doc """ - Updates translation for a specific language on a product. - - ## Parameters - - - `product` - The product struct - - `language` - Language code (e.g., "es-ES") - - `attrs` - Translation attributes: title, slug, description, body_html, seo_title, seo_description - - ## Examples - - iex> Shop.update_product_translation(product, "es-ES", %{ - ...> "title" => "Maceta Geométrica", - ...> "slug" => "maceta-geometrica" - ...> }) - {:ok, %Product{}} - """ - def update_product_translation(%Product{} = product, language, attrs) - when is_binary(language) do - # Convert attrs to atom-keyed map for changeset_attrs_multi - field_values = - attrs - |> Enum.map(fn {k, v} -> {to_atom(k), v} end) - |> Map.new() - - translation_attrs = Translations.changeset_attrs_multi(product, language, field_values) - update_product(product, translation_attrs) - end - - defp to_atom(key) when is_atom(key), do: key - defp to_atom(key) when is_binary(key), do: String.to_existing_atom(key) - - @doc """ - Updates translation for a specific language on a category. - - ## Parameters - - - `category` - The category struct - - `language` - Language code (e.g., "es-ES") - - `attrs` - Translation attributes: name, slug, description - - ## Examples - - iex> Shop.update_category_translation(category, "es-ES", %{ - ...> "name" => "Jarrones y Macetas", - ...> "slug" => "jarrones-macetas" - ...> }) - {:ok, %Category{}} - """ - def update_category_translation(%Category{} = category, language, attrs) - when is_binary(language) do - # Convert attrs to atom-keyed map for changeset_attrs_multi - field_values = - attrs - |> Enum.map(fn {k, v} -> {to_atom(k), v} end) - |> Map.new() - - translation_attrs = Translations.changeset_attrs_multi(category, language, field_values) - update_category(category, translation_attrs) - end - - @doc """ - Lists products with translated fields for a specific language. - - Returns products with an additional `:localized` virtual map containing - translated fields with fallback to defaults. - - ## Parameters - - - `language` - Language code for translations - - `opts` - Standard list options: `:page`, `:per_page`, `:status`, `:category_uuid`, etc. - - ## Examples - - iex> Shop.list_products_localized("es-ES", status: "active") - [%Product{localized: %{title: "Maceta...", ...}}, ...] - """ - def list_products_localized(language, opts \\ []) do - products = list_products(opts) - - Enum.map(products, fn product -> - Map.put(product, :localized, build_localized_product(product, language)) - end) - end - - @doc """ - Lists categories with translated fields for a specific language. - - ## Parameters - - - `language` - Language code for translations - - `opts` - Standard list options - - ## Examples - - iex> Shop.list_categories_localized("es-ES", status: "active") - [%Category{localized: %{name: "Jarrones...", ...}}, ...] - """ - def list_categories_localized(language, opts \\ []) do - categories = list_categories(opts) - - Enum.map(categories, fn category -> - Map.put(category, :localized, build_localized_category(category, language)) - end) - end - - @doc """ - Gets the localized slug for a product. - - Returns translated slug if available, otherwise canonical slug. - - ## Examples - - iex> Shop.get_product_slug(product, "es-ES") - "maceta-geometrica" - """ - def get_product_slug(%Product{} = product, language) do - SlugResolver.product_slug(product, language) - end - - @doc """ - Gets the localized slug for a category. - - ## Examples - - iex> Shop.get_category_slug(category, "es-ES") - "jarrones-macetas" - """ - def get_category_slug(%Category{} = category, language) do - SlugResolver.category_slug(category, language) - end - - @doc """ - Finds a product by slug in any language. - - Searches across all translated slugs to find the product. - Useful for cross-language redirect when user visits with a slug - from a different language. - - ## Examples - - iex> Shop.get_product_by_any_slug("maceta-geometrica") - {:ok, %Product{}, "es"} - - iex> Shop.get_product_by_any_slug("nonexistent") - {:error, :not_found} - """ - def get_product_by_any_slug(slug, opts \\ []) do - SlugResolver.find_product_by_any_slug(slug, opts) - end - - @doc """ - Finds a category by slug in any language. - - ## Examples - - iex> Shop.get_category_by_any_slug("jarrones-macetas") - {:ok, %Category{}, "es"} - """ - def get_category_by_any_slug(slug, opts \\ []) do - SlugResolver.find_category_by_any_slug(slug, opts) - end - - # ============================================ - # URL GENERATION - # ============================================ - - @doc """ - Generates a localized URL for a product. - - Returns the correct locale-prefixed URL with translated slug. - The URL respects the PhoenixKit URL prefix configuration. - - ## Parameters - - - `product` - The Product struct - - `language` - Language code (e.g., "en-US", "ru", "es-ES") - - ## Examples - - iex> Shop.product_url(product, "es-ES") - "/es/shop/product/maceta-geometrica" - - iex> Shop.product_url(product, "ru") - "/ru/shop/product/geometricheskoe-kashpo" - - iex> Shop.product_url(product, "en") - "/shop/product/geometric-planter" # Default language - no prefix - """ - @spec product_url(Product.t(), String.t()) :: String.t() - def product_url(%Product{} = product, language) do - slug = SlugResolver.product_slug(product, language) - base = DialectMapper.extract_base(language) - # Let Routes.path handle locale prefix - it adds prefix for non-default locales - Routes.path("/shop/product/#{slug}", locale: base) - end - - @doc """ - Generates a localized URL for a category. - - Returns the correct locale-prefixed URL with translated slug. - - ## Parameters - - - `category` - The Category struct - - `language` - Language code (e.g., "en-US", "ru", "es-ES") - - ## Examples - - iex> Shop.category_url(category, "es-ES") - "/es/shop/category/jarrones-macetas" - - iex> Shop.category_url(category, "en") - "/shop/category/vases-planters" # Default language - no prefix - """ - @spec category_url(Category.t(), String.t()) :: String.t() - def category_url(%Category{} = category, language) do - slug = SlugResolver.category_slug(category, language) - base = DialectMapper.extract_base(language) - # Let Routes.path handle locale prefix - it adds prefix for non-default locales - Routes.path("/shop/category/#{slug}", locale: base) - end - - @doc """ - Generates a localized URL for the shop catalog. - - ## Examples - - iex> Shop.catalog_url("es-ES") - "/es/shop" - - iex> Shop.catalog_url("en") - "/shop" - """ - @spec catalog_url(String.t()) :: String.t() - def catalog_url(language) do - base = DialectMapper.extract_base(language) - # Let Routes.path handle locale prefix - it adds prefix for non-default locales - Routes.path("/shop", locale: base) - end - - @doc """ - Generates a localized URL for the cart page. - - ## Examples - - iex> Shop.cart_url("ru") - "/ru/cart" - - iex> Shop.cart_url("en") - "/cart" - """ - @spec cart_url(String.t()) :: String.t() - def cart_url(language) do - base = DialectMapper.extract_base(language) - # Let Routes.path handle locale prefix - it adds prefix for non-default locales - Routes.path("/cart", locale: base) - end - - @doc """ - Generates a localized URL for the checkout page. - - ## Examples - - iex> Shop.checkout_url("ru") - "/ru/checkout" - - iex> Shop.checkout_url("en") - "/checkout" - """ - @spec checkout_url(String.t()) :: String.t() - def checkout_url(language) do - base = DialectMapper.extract_base(language) - # Let Routes.path handle locale prefix - it adds prefix for non-default locales - Routes.path("/checkout", locale: base) - end - - @doc """ - Gets the default language code (base code, e.g., "en"). - - Reads from Languages module configuration or falls back to "en". - """ - @spec get_default_language() :: String.t() - def get_default_language do - case Languages.get_default_language() do - nil -> "en" - lang -> DialectMapper.extract_base(lang.code) - end - end - - @doc """ - Checks if a product slug exists for a language. - - Useful for validation during translation editing. - - ## Examples - - iex> Shop.product_slug_exists?("maceta-geometrica", "es-ES") - true - - iex> Shop.product_slug_exists?("maceta-geometrica", "es-ES", exclude_uuid: "some-uuid") - false - """ - def product_slug_exists?(slug, language, opts \\ []) do - SlugResolver.product_slug_exists?(slug, language, opts) - end - - @doc """ - Checks if a category slug exists for a language. - - ## Examples - - iex> Shop.category_slug_exists?("jarrones-macetas", "es-ES") - true - """ - def category_slug_exists?(slug, language, opts \\ []) do - SlugResolver.category_slug_exists?(slug, language, opts) - end - - @doc """ - Returns translation helpers module for direct access. - - ## Examples - - iex> Shop.translations() - PhoenixKit.Modules.Shop.Translations - """ - def translations, do: Translations - - # Build localized map for a product - defp build_localized_product(product, language) do - %{ - title: Translations.get_field(product, :title, language), - slug: Translations.get_field(product, :slug, language) || product.slug, - description: Translations.get_field(product, :description, language), - body_html: Translations.get_field(product, :body_html, language), - seo_title: Translations.get_field(product, :seo_title, language), - seo_description: Translations.get_field(product, :seo_description, language) - } - end - - # Build localized map for a category - defp build_localized_category(category, language) do - %{ - name: Translations.get_field(category, :name, language), - slug: Translations.get_field(category, :slug, language) || category.slug, - description: Translations.get_field(category, :description, language) - } - end -end diff --git a/lib/modules/shop/slug_resolver.ex b/lib/modules/shop/slug_resolver.ex deleted file mode 100644 index 45ad23da3..000000000 --- a/lib/modules/shop/slug_resolver.ex +++ /dev/null @@ -1,666 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.SlugResolver do - @moduledoc """ - Resolves URL slugs to Products and Categories with language awareness. - - This module provides language-aware slug resolution for the Shop module, - supporting per-language URL slugs for SEO optimization. - - ## Features - - - Per-language SEO-friendly URL slugs - - Fallback to canonical slug when translation not found - - Base code matching (e.g., "en" matches "en-US") - - Efficient queries using JSONB operators - - ## URL Architecture - - ``` - /shop/products/geometric-planter # Default language - /es/shop/products/maceta-geometrica # Spanish (SEO slug) - /ru/shop/products/geometricheskoe-kashpo # Russian (SEO slug) - ``` - - ## Usage Examples - - # Find product by slug in specific language - SlugResolver.find_product_by_slug("maceta-geometrica", "es-ES") - # => {:ok, %Product{}} - - # Find product with base code (resolves to full dialect) - SlugResolver.find_product_by_slug("geometric-planter", "en") - # => {:ok, %Product{}} (matches en-US via base code) - - # Find category by slug - SlugResolver.find_category_by_slug("jarrones-macetas", "es-ES") - # => {:ok, %Category{}} - - ## Query Behavior - - The resolver checks both translated slugs and canonical slugs: - - 1. First tries `translations->'language'->>'slug' = ?` - 2. Falls back to canonical `slug = ?` - - This ensures URLs work even for products without translations. - """ - - import Ecto.Query - - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Modules.Shop.Category - alias PhoenixKit.Modules.Shop.Product - alias PhoenixKit.Modules.Shop.Translations - - # ============================================================================ - # Product Slug Resolution - # ============================================================================ - - @doc """ - Finds a product by URL slug for a specific language. - - ## Parameters - - - `url_slug` - The URL slug to search for - - `language` - Language code (supports both "es-ES" and base codes like "en") - - `opts` - Optional keyword list: - - `:preload` - Associations to preload (default: []) - - `:status` - Filter by status (e.g., "active") - - ## Examples - - iex> SlugResolver.find_product_by_slug("maceta-geometrica", "es-ES") - {:ok, %Product{title: "Maceta Geométrica", ...}} - - iex> SlugResolver.find_product_by_slug("geometric-planter", "en") - {:ok, %Product{}} # Matches en-US via base code resolution - - iex> SlugResolver.find_product_by_slug("nonexistent", "en-US") - {:error, :not_found} - - ## Query Details - - The query checks both: - 1. Translated slug: `translations->'lang'->>'slug'` - 2. Canonical slug: `slug` column - - This ensures backward compatibility with products that have no translations. - """ - @spec find_product_by_slug(String.t(), String.t(), keyword()) :: - {:ok, Product.t()} | {:error, :not_found} - def find_product_by_slug(url_slug, language, opts \\ []) do - lang = normalize_language(language) - preload = Keyword.get(opts, :preload, []) - status = Keyword.get(opts, :status) - - # Localized fields: slug is a JSONB map like %{"en" => "planter", "ru" => "kashpo"} - # Search for exact language match or fallback to default language - query = - from(p in Product, - where: - fragment( - "slug->>? = ?", - ^lang, - ^url_slug - ), - limit: 1 - ) - - query = - if status do - from(p in query, where: p.status == ^status) - else - query - end - - query = - if preload != [] do - from(p in query, preload: ^preload) - else - query - end - - case repo().one(query) do - nil -> {:error, :not_found} - product -> {:ok, product} - end - end - - @doc """ - Finds a product by slug, requiring exact language match. - - Unlike `find_product_by_slug/3`, this does not fall back to canonical slug. - Useful when you need to ensure the translation exists. - - ## Examples - - iex> SlugResolver.find_product_by_translated_slug("maceta-geometrica", "es-ES") - {:ok, %Product{}} - - iex> SlugResolver.find_product_by_translated_slug("maceta-geometrica", "en-US") - {:error, :not_found} # No fallback to canonical - """ - @spec find_product_by_translated_slug(String.t(), String.t(), keyword()) :: - {:ok, Product.t()} | {:error, :not_found} - def find_product_by_translated_slug(url_slug, language, opts \\ []) do - lang = normalize_language(language) - preload = Keyword.get(opts, :preload, []) - - # Localized fields: slug is a JSONB map - query = - from(p in Product, - where: - fragment( - "slug->>? = ?", - ^lang, - ^url_slug - ), - limit: 1 - ) - - query = - if preload != [] do - from(p in query, preload: ^preload) - else - query - end - - case repo().one(query) do - nil -> {:error, :not_found} - product -> {:ok, product} - end - end - - # ============================================================================ - # Category Slug Resolution - # ============================================================================ - - @doc """ - Finds a category by URL slug for a specific language. - - ## Parameters - - - `url_slug` - The URL slug to search for - - `language` - Language code (supports both full and base codes) - - `opts` - Optional keyword list: - - `:preload` - Associations to preload (default: []) - - `:status` - Filter by status (e.g., "active") - - ## Examples - - iex> SlugResolver.find_category_by_slug("jarrones-macetas", "es-ES") - {:ok, %Category{name: "Jarrones y Macetas", ...}} - - iex> SlugResolver.find_category_by_slug("vases-planters", "en") - {:ok, %Category{}} - - iex> SlugResolver.find_category_by_slug("nonexistent", "en-US") - {:error, :not_found} - """ - @spec find_category_by_slug(String.t(), String.t(), keyword()) :: - {:ok, Category.t()} | {:error, :not_found} - def find_category_by_slug(url_slug, language, opts \\ []) do - lang = normalize_language(language) - preload = Keyword.get(opts, :preload, []) - status = Keyword.get(opts, :status) - - # Localized fields: slug is a JSONB map - query = - from(c in Category, - where: - fragment( - "slug->>? = ?", - ^lang, - ^url_slug - ), - limit: 1 - ) - - query = - if status do - from(c in query, where: c.status == ^status) - else - query - end - - query = - if preload != [] do - from(c in query, preload: ^preload) - else - query - end - - case repo().one(query) do - nil -> {:error, :not_found} - category -> {:ok, category} - end - end - - @doc """ - Finds a category by slug, requiring exact language match. - - Does not fall back to canonical slug. - - ## Examples - - iex> SlugResolver.find_category_by_translated_slug("jarrones-macetas", "es-ES") - {:ok, %Category{}} - """ - @spec find_category_by_translated_slug(String.t(), String.t(), keyword()) :: - {:ok, Category.t()} | {:error, :not_found} - def find_category_by_translated_slug(url_slug, language, opts \\ []) do - lang = normalize_language(language) - preload = Keyword.get(opts, :preload, []) - - # Localized fields: slug is a JSONB map - query = - from(c in Category, - where: - fragment( - "slug->>? = ?", - ^lang, - ^url_slug - ), - limit: 1 - ) - - query = - if preload != [] do - from(c in query, preload: ^preload) - else - query - end - - case repo().one(query) do - nil -> {:error, :not_found} - category -> {:ok, category} - end - end - - # ============================================================================ - # Batch Resolution - # ============================================================================ - - @doc """ - Finds multiple products by their slugs for a specific language. - - Useful for preloading products in listing pages. - - ## Examples - - iex> SlugResolver.find_products_by_slugs(["planter-1", "planter-2"], "en-US") - [%Product{}, %Product{}] - """ - @spec find_products_by_slugs([String.t()], String.t(), keyword()) :: [Product.t()] - def find_products_by_slugs(url_slugs, language, opts \\ []) when is_list(url_slugs) do - lang = normalize_language(language) - preload = Keyword.get(opts, :preload, []) - status = Keyword.get(opts, :status) - - # Localized fields: slug is a JSONB map - query = - from(p in Product, - where: - fragment( - "slug->>? = ANY(?)", - ^lang, - ^url_slugs - ) - ) - - query = - if status do - from(p in query, where: p.status == ^status) - else - query - end - - query = - if preload != [] do - from(p in query, preload: ^preload) - else - query - end - - repo().all(query) - end - - @doc """ - Finds multiple categories by their slugs for a specific language. - - ## Examples - - iex> SlugResolver.find_categories_by_slugs(["cat-1", "cat-2"], "en-US") - [%Category{}, %Category{}] - """ - @spec find_categories_by_slugs([String.t()], String.t(), keyword()) :: [Category.t()] - def find_categories_by_slugs(url_slugs, language, opts \\ []) when is_list(url_slugs) do - lang = normalize_language(language) - preload = Keyword.get(opts, :preload, []) - status = Keyword.get(opts, :status) - - # Localized fields: slug is a JSONB map - query = - from(c in Category, - where: - fragment( - "slug->>? = ANY(?)", - ^lang, - ^url_slugs - ) - ) - - query = - if status do - from(c in query, where: c.status == ^status) - else - query - end - - query = - if preload != [] do - from(c in query, preload: ^preload) - else - query - end - - repo().all(query) - end - - # ============================================================================ - # Slug Existence Checks - # ============================================================================ - - @doc """ - Checks if a product slug exists for a specific language. - - Useful for slug validation during product creation/editing. - - ## Parameters - - - `slug` - The slug to check - - `language` - Language code - - `exclude_uuid` - Product UUID to exclude from check (for edits) - - ## Examples - - iex> SlugResolver.product_slug_exists?("geometric-planter", "en-US") - true - - iex> SlugResolver.product_slug_exists?("geometric-planter", "en-US", exclude_uuid: "some-uuid") - false # Excludes product with given UUID from check - """ - @spec product_slug_exists?(String.t(), String.t(), keyword()) :: boolean() - def product_slug_exists?(slug, language, opts \\ []) do - lang = normalize_language(language) - exclude_uuid = Keyword.get(opts, :exclude_uuid) - - # Localized fields: slug is a JSONB map - query = - from(p in Product, - where: - fragment( - "slug->>? = ?", - ^lang, - ^slug - ), - select: count(p.uuid) - ) - - query = - if is_binary(exclude_uuid) && match?({:ok, _}, Ecto.UUID.cast(exclude_uuid)) do - from(p in query, where: p.uuid != ^exclude_uuid) - else - query - end - - repo().one(query) > 0 - end - - @doc """ - Checks if a category slug exists for a specific language. - - ## Examples - - iex> SlugResolver.category_slug_exists?("vases-planters", "en-US") - true - """ - @spec category_slug_exists?(String.t(), String.t(), keyword()) :: boolean() - def category_slug_exists?(slug, language, opts \\ []) do - lang = normalize_language(language) - exclude_uuid = Keyword.get(opts, :exclude_uuid) - - # Localized fields: slug is a JSONB map - query = - from(c in Category, - where: - fragment( - "slug->>? = ?", - ^lang, - ^slug - ), - select: count(c.uuid) - ) - - query = - if is_binary(exclude_uuid) && match?({:ok, _}, Ecto.UUID.cast(exclude_uuid)) do - from(c in query, where: c.uuid != ^exclude_uuid) - else - query - end - - repo().one(query) > 0 - end - - # ============================================================================ - # URL Generation - # ============================================================================ - - @doc """ - Gets the best slug for a product in a specific language. - - Returns translated slug if available, otherwise canonical slug. - - ## Examples - - iex> SlugResolver.product_slug(product, "es-ES") - "maceta-geometrica" - - iex> SlugResolver.product_slug(product, "fr-FR") - "geometric-planter" # Falls back to canonical - """ - @spec product_slug(Product.t(), String.t()) :: String.t() | nil - def product_slug(%Product{} = product, language) do - lang = normalize_language(language) - slug_map = product.slug || %{} - - # Localized fields approach: slug is directly a map - slug_map[lang] || slug_map[default_language()] || first_slug(slug_map) - end - - @doc """ - Gets the best slug for a category in a specific language. - - Returns translated slug if available, otherwise canonical slug. - - ## Examples - - iex> SlugResolver.category_slug(category, "es-ES") - "jarrones-macetas" - """ - @spec category_slug(Category.t(), String.t()) :: String.t() | nil - def category_slug(%Category{} = category, language) do - lang = normalize_language(language) - slug_map = category.slug || %{} - - # Localized fields approach: slug is directly a map - slug_map[lang] || slug_map[default_language()] || first_slug(slug_map) - end - - # ============================================================================ - # Cross-Language Slug Resolution - # ============================================================================ - - @doc """ - Finds a product by slug in any language. - - Searches across all translated slugs to find the product. - Useful for cross-language redirect when user visits with a slug - from a different language. - - ## Parameters - - - `url_slug` - The URL slug to search for - - `opts` - Optional keyword list: - - `:preload` - Associations to preload (default: []) - - `:status` - Filter by status (e.g., "active") - - ## Examples - - iex> SlugResolver.find_product_by_any_slug("maceta-geometrica") - {:ok, %Product{}, "es"} # Returns product with language that matched - - iex> SlugResolver.find_product_by_any_slug("geometric-planter") - {:ok, %Product{}, "en"} - - iex> SlugResolver.find_product_by_any_slug("nonexistent") - {:error, :not_found} - """ - @spec find_product_by_any_slug(String.t(), keyword()) :: - {:ok, Product.t(), String.t()} | {:error, :not_found} - def find_product_by_any_slug(url_slug, opts \\ []) do - preload = Keyword.get(opts, :preload, []) - status = Keyword.get(opts, :status) - - # Search across all language slugs using JSONB query - # slug is a JSONB map like %{"en" => "planter", "ru" => "kashpo", "es" => "maceta"} - query = - from(p in Product, - where: - fragment( - "EXISTS (SELECT 1 FROM jsonb_each_text(slug) WHERE value = ?)", - ^url_slug - ), - limit: 1 - ) - - query = - if status do - from(p in query, where: p.status == ^status) - else - query - end - - query = - if preload != [] do - from(p in query, preload: ^preload) - else - query - end - - case repo().one(query) do - nil -> - {:error, :not_found} - - product -> - # Find which language matched - matched_lang = find_matching_language(product.slug || %{}, url_slug) - {:ok, product, matched_lang} - end - end - - @doc """ - Finds a category by slug in any language. - - ## Examples - - iex> SlugResolver.find_category_by_any_slug("jarrones-macetas") - {:ok, %Category{}, "es"} - """ - @spec find_category_by_any_slug(String.t(), keyword()) :: - {:ok, Category.t(), String.t()} | {:error, :not_found} - def find_category_by_any_slug(url_slug, opts \\ []) do - preload = Keyword.get(opts, :preload, []) - status = Keyword.get(opts, :status) - - query = - from(c in Category, - where: - fragment( - "EXISTS (SELECT 1 FROM jsonb_each_text(slug) WHERE value = ?)", - ^url_slug - ), - limit: 1 - ) - - query = - if status do - from(c in query, where: c.status == ^status) - else - query - end - - query = - if preload != [] do - from(c in query, preload: ^preload) - else - query - end - - case repo().one(query) do - nil -> - {:error, :not_found} - - category -> - matched_lang = find_matching_language(category.slug || %{}, url_slug) - {:ok, category, matched_lang} - end - end - - # Find which language key contains the matching slug - defp find_matching_language(slug_map, slug) do - Enum.find_value(slug_map, default_language(), fn {lang, lang_slug} -> - if lang_slug == slug, do: lang, else: nil - end) - end - - # ============================================================================ - # Private Helpers - # ============================================================================ - - @doc """ - Normalizes a language code to dialect format. - - Converts base codes to full dialect (e.g., "en" -> "en-US"). - Used by import system to ensure consistent language keys in JSONB fields. - """ - def normalize_language_public(lang) when is_binary(lang), do: normalize_language(lang) - - # Normalize language code (convert base code to full dialect) - defp normalize_language(lang) when is_binary(lang) do - cond do - # Already a full dialect code (contains hyphen) - String.contains?(lang, "-") -> - lang - - # Base code only - convert to dialect - String.length(lang) == 2 -> - DialectMapper.base_to_dialect(lang) - - # Unknown format - use as-is - true -> - lang - end - end - - defp default_language do - Translations.default_language() - end - - defp first_slug(map) when map == %{}, do: nil - - defp first_slug(map) do - map |> Map.values() |> List.first() - end - - defp repo, do: PhoenixKit.RepoHelper.repo() -end diff --git a/lib/modules/shop/translations.ex b/lib/modules/shop/translations.ex deleted file mode 100644 index 9e6dc80bb..000000000 --- a/lib/modules/shop/translations.ex +++ /dev/null @@ -1,387 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Translations do - @moduledoc """ - Localized fields helper for Shop module. - - All translatable fields are stored as JSONB maps directly in the field: - - %Product{ - title: %{"en" => "Planter", "ru" => "Кашпо"}, - slug: %{"en" => "planter", "ru" => "kashpo"}, - description: %{"en" => "Modern pot", "ru" => "Современное кашпо"} - } - - ## Fallback Chain - - When retrieving a translated field, the fallback chain is: - 1. Exact language match (e.g., "ru") - 2. Default language from Languages module - 3. First available value in the map - - ## Usage Examples - - # Get translated field with automatic fallback - Translations.get(product, :title, "ru") - #=> "Кашпо" - - Translations.get(product, :title, "fr") - #=> "Planter" (fallback to default or first available) - - # Set a single translated field - product = Translations.put(product, :title, "es", "Maceta") - - # Build changeset attrs for localized field update - attrs = Translations.changeset_attrs(product, :title, "ru", "Новое кашпо") - #=> %{title: %{"en" => "Planter", "ru" => "Новое кашпо"}} - """ - - alias PhoenixKit.Modules.Languages - alias PhoenixKit.Settings - - @product_fields [:title, :slug, :description, :body_html, :seo_title, :seo_description] - @category_fields [:name, :slug, :description] - - # ============================================================================ - # Language Configuration - # ============================================================================ - - @doc """ - Returns the default/master language code. - - Checks Languages module first, falls back to Settings content language, - then defaults to "en". - - ## Examples - - iex> Translations.default_language() - "en" - """ - @spec default_language() :: String.t() - def default_language do - if languages_enabled?() do - case Languages.get_default_language() do - %{code: code} -> code - _ -> "en" - end - else - Settings.get_content_language() || "en" - end - end - - @doc """ - Returns list of enabled language codes. - - When Languages module is enabled, returns all enabled language codes. - Otherwise returns only the default language. - - ## Examples - - iex> Translations.enabled_languages() - ["en", "es", "ru"] - - # When Languages module disabled: - iex> Translations.enabled_languages() - ["en"] - """ - @spec enabled_languages() :: [String.t()] - def enabled_languages do - if languages_enabled?() do - Languages.get_enabled_language_codes() - else - [default_language()] - end - end - - @doc """ - Checks if Languages module is enabled. - """ - @spec languages_enabled?() :: boolean() - def languages_enabled? do - Code.ensure_loaded?(Languages) and function_exported?(Languages, :enabled?, 0) and - Languages.enabled?() - end - - # ============================================================================ - # Reading Translations (New Localized Fields Approach) - # ============================================================================ - - @doc """ - Gets a localized value with automatic fallback chain. - - Fallback order: - 1. Exact language match - 2. Default language - 3. First available value - - ## Parameters - - - `entity` - Product or Category struct - - `field` - Field atom (e.g., :title, :name, :slug) - - `language` - Language code (e.g., "ru", "en") - - ## Examples - - iex> product = %Product{title: %{"en" => "Planter", "ru" => "Кашпо"}} - iex> Translations.get(product, :title, "ru") - "Кашпо" - - iex> Translations.get(product, :title, "fr") - "Planter" # Falls back to default or first available - """ - @spec get(struct(), atom(), String.t()) :: any() - def get(entity, field, language) do - field_map = Map.get(entity, field) || %{} - - field_map[language] || - field_map[default_language()] || - first_available(field_map) - end - - @doc """ - Gets the localized slug with fallback. - - Convenience function for URL slug retrieval. - - ## Examples - - iex> Translations.get_slug(product, "es") - "maceta-geometrica" - """ - @spec get_slug(struct(), String.t()) :: String.t() | nil - def get_slug(entity, language) do - get(entity, :slug, language) - end - - @doc """ - Gets all values for a specific language from the entity's localized fields. - - Returns a map of field => value for the given language. - - ## Examples - - iex> Translations.get_all_for_language(product, "ru", [:title, :slug, :description]) - %{title: "Кашпо", slug: "kashpo", description: "Описание"} - """ - @spec get_all_for_language(struct(), String.t(), [atom()]) :: map() - def get_all_for_language(entity, language, fields) do - Enum.reduce(fields, %{}, fn field, acc -> - value = get(entity, field, language) - Map.put(acc, field, value) - end) - end - - # ============================================================================ - # Writing Translations - # ============================================================================ - - @doc """ - Sets a localized value for a language. - - Returns the updated entity struct (not persisted to database). - - ## Examples - - iex> product = Translations.put(product, :title, "ru", "Новое кашпо") - %Product{title: %{"en" => "Planter", "ru" => "Новое кашпо"}} - """ - @spec put(struct(), atom(), String.t(), any()) :: struct() - def put(entity, field, language, value) do - current = Map.get(entity, field) || %{} - updated = Map.put(current, language, value) - Map.put(entity, field, updated) - end - - @doc """ - Builds changeset attrs for localized field update. - - Merges the new value into the existing field map for the given language. - - ## Examples - - iex> Translations.changeset_attrs(product, :title, "ru", "Новое кашпо") - %{title: %{"en" => "Planter", "ru" => "Новое кашпо"}} - """ - @spec changeset_attrs(struct(), atom(), String.t(), any()) :: map() - def changeset_attrs(entity, field, language, value) do - current = Map.get(entity, field) || %{} - updated = Map.put(current, language, value) - %{field => updated} - end - - @doc """ - Builds changeset attrs for multiple localized fields at once. - - ## Examples - - iex> Translations.changeset_attrs_multi(product, "ru", %{title: "Кашпо", slug: "kashpo"}) - %{title: %{"en" => "Planter", "ru" => "Кашпо"}, slug: %{"en" => "planter", "ru" => "kashpo"}} - """ - @spec changeset_attrs_multi(struct(), String.t(), map()) :: map() - def changeset_attrs_multi(entity, language, field_values) do - Enum.reduce(field_values, %{}, fn {field, value}, acc -> - Map.merge(acc, changeset_attrs(entity, field, language, value)) - end) - end - - @doc """ - Sets multiple translated fields for a language. - - Returns the updated entity struct (not persisted to database). - - ## Examples - - iex> product = Translations.put_all(product, "es", %{title: "Maceta", slug: "maceta"}) - %Product{title: %{"en" => "Planter", "es" => "Maceta"}, ...} - """ - @spec put_all(struct(), String.t(), map()) :: struct() - def put_all(entity, language, field_values) do - Enum.reduce(field_values, entity, fn {field, value}, acc -> - put(acc, field, language, value) - end) - end - - # ============================================================================ - # Inspection Helpers - # ============================================================================ - - @doc """ - Gets all languages that have a value for a field. - - ## Examples - - iex> Translations.available_languages(product, :title) - ["en", "ru"] - """ - @spec available_languages(struct(), atom()) :: [String.t()] - def available_languages(entity, field) do - field_map = Map.get(entity, field) || %{} - - field_map - |> Map.keys() - |> Enum.filter(fn lang -> - value = Map.get(field_map, lang) - value != nil and value != "" - end) - end - - @doc """ - Checks if translation exists for language in a specific field. - - ## Examples - - iex> Translations.has_translation?(product, :title, "ru") - true - - iex> Translations.has_translation?(product, :title, "zh") - false - """ - @spec has_translation?(struct(), atom(), String.t()) :: boolean() - def has_translation?(entity, field, language) do - field_map = Map.get(entity, field) || %{} - value = Map.get(field_map, language) - value != nil and value != "" - end - - @doc """ - Gets translation completeness for a language across all translatable fields. - - ## Examples - - iex> Translations.translation_status(product, "ru") - %{complete: 4, total: 6, percentage: 67, missing: [:body_html, :seo_description]} - """ - @spec translation_status(struct(), String.t(), [atom()] | nil) :: map() - def translation_status(entity, language, required_fields \\ nil) do - fields = required_fields || translatable_fields(entity) - - present = - Enum.filter(fields, fn field -> - has_translation?(entity, field, language) - end) - - missing = fields -- present - present_count = Enum.count(present) - total_count = Enum.count(fields) - - %{ - complete: present_count, - total: total_count, - percentage: if(total_count > 0, do: round(present_count / total_count * 100), else: 0), - missing: missing - } - end - - # ============================================================================ - # Field Definitions - # ============================================================================ - - @doc """ - Returns the list of translatable fields for products. - """ - @spec product_fields() :: [atom()] - def product_fields, do: @product_fields - - @doc """ - Returns the list of translatable fields for categories. - """ - @spec category_fields() :: [atom()] - def category_fields, do: @category_fields - - @doc """ - Returns translatable fields based on entity type. - """ - @spec translatable_fields(struct()) :: [atom()] - def translatable_fields(%{__struct__: PhoenixKit.Modules.Shop.Product}), do: @product_fields - def translatable_fields(%{__struct__: PhoenixKit.Modules.Shop.Category}), do: @category_fields - def translatable_fields(_), do: [] - - # ============================================================================ - # Legacy Compatibility (Deprecated) - # ============================================================================ - - @doc """ - DEPRECATED: Use `get/3` instead. - - This function exists for backward compatibility during migration. - """ - @spec get_field(struct(), atom(), String.t()) :: any() - def get_field(entity, field, language) do - get(entity, field, language) - end - - @doc """ - DEPRECATED: Use `put/4` instead. - - This function exists for backward compatibility during migration. - """ - @spec put_field(struct(), atom(), String.t(), any()) :: struct() - def put_field(entity, field, language, value) do - put(entity, field, language, value) - end - - @doc """ - DEPRECATED: Use `changeset_attrs_multi/3` instead. - - Builds changeset attrs for updating translations. - This function adapts the old API to the new localized fields approach. - """ - @spec translation_changeset_attrs(map() | nil, String.t(), map()) :: map() - def translation_changeset_attrs(_current_translations, _language, _params) do - # This function is no longer applicable in the new approach - # where each field is its own map. - # Kept for compilation but should not be used. - %{} - end - - # ============================================================================ - # Private Helpers - # ============================================================================ - - defp first_available(map) when map == %{}, do: nil - - defp first_available(map) do - case Enum.at(map, 0) do - {_key, value} -> value - nil -> nil - end - end -end diff --git a/lib/modules/shop/web/cart_page.ex b/lib/modules/shop/web/cart_page.ex deleted file mode 100644 index 99bc7e2f6..000000000 --- a/lib/modules/shop/web/cart_page.ex +++ /dev/null @@ -1,521 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.CartPage do - @moduledoc """ - Public cart page LiveView for E-Commerce module. - - Supports real-time cart synchronization across multiple browser tabs - via PubSub subscription. When cart is updated in one tab, all other - tabs receive the update automatically. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Events - alias PhoenixKit.Modules.Shop.ShippingMethod - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Shop.Web.Components.ShopLayouts - - import PhoenixKit.Modules.Shop.Web.Helpers, - only: [format_price: 2, humanize_key: 1, get_current_user: 1] - - @impl true - def mount(_params, session, socket) do - # Get session_id from session (for guest users) - session_id = session["shop_session_id"] || generate_session_id() - - # Get current language for localized URLs - current_language = socket.assigns[:current_locale] || Translations.default_language() - - # Get current user if logged in - user = get_current_user(socket) - user_uuid = if user, do: user.uuid, else: nil - - # Get or create cart - {:ok, cart} = - Shop.get_or_create_cart(user_uuid: user_uuid, session_id: session_id) - - # Subscribe to cart events for real-time sync across tabs - if connected?(socket) do - Events.subscribe_to_cart(cart) - end - - # Get available shipping methods - shipping_methods = Shop.get_available_shipping_methods(cart) - - # Auto-select cheapest shipping method if none selected - {:ok, cart} = Shop.auto_select_shipping_method(cart, shipping_methods) - - # Get default currency from Billing - currency = Shop.get_default_currency() - - # Check if user is authenticated - authenticated = not is_nil(socket.assigns[:phoenix_kit_current_user]) - - socket = - socket - |> assign(:page_title, "Shopping Cart") - |> assign(:cart, cart) - |> assign(:session_id, session_id) - |> assign(:shipping_methods, shipping_methods) - |> assign(:currency, currency) - |> assign(:authenticated, authenticated) - |> assign(:current_language, current_language) - - {:ok, socket} - end - - @impl true - def handle_event("update_quantity", %{"item_uuid" => item_uuid, "quantity" => quantity}, socket) do - quantity = max(1, String.to_integer(quantity)) - - update_item_quantity(socket, item_uuid, quantity) - end - - @impl true - def handle_event("remove_item", %{"item_uuid" => item_uuid}, socket) do - item = Enum.find(socket.assigns.cart.items, &(&1.uuid == item_uuid)) - - if item do - case Shop.remove_from_cart(item) do - {:ok, updated_cart} -> - shipping_methods = Shop.get_available_shipping_methods(updated_cart) - - {:noreply, - socket - |> assign(:cart, updated_cart) - |> assign(:shipping_methods, shipping_methods) - |> put_flash(:info, "Item removed from cart")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to remove item")} - end - else - {:noreply, socket} - end - end - - @impl true - def handle_event("select_shipping", %{"method_uuid" => method_uuid}, socket) do - method = Enum.find(socket.assigns.shipping_methods, &(&1.uuid == method_uuid)) - cart = socket.assigns.cart - - if method do - # Country will be set at checkout based on billing info - case Shop.set_cart_shipping(cart, method, nil) do - {:ok, updated_cart} -> - {:noreply, assign(socket, :cart, updated_cart)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to set shipping method")} - end - else - {:noreply, socket} - end - end - - @impl true - def handle_event("proceed_to_checkout", _params, socket) do - cart = socket.assigns.cart - - cond do - cart.items == [] -> - {:noreply, put_flash(socket, :error, "Your cart is empty")} - - is_nil(cart.shipping_method_uuid) -> - {:noreply, put_flash(socket, :error, "Please select a shipping method")} - - true -> - {:noreply, push_navigate(socket, to: Shop.checkout_url(socket.assigns.current_language))} - end - end - - defp update_item_quantity(socket, item_uuid, quantity) do - item = Enum.find(socket.assigns.cart.items, &(&1.uuid == item_uuid)) - - if item do - case Shop.update_cart_item(item, quantity) do - {:ok, updated_cart} -> - shipping_methods = Shop.get_available_shipping_methods(updated_cart) - - {:noreply, - socket - |> assign(:cart, updated_cart) - |> assign(:shipping_methods, shipping_methods)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to update quantity")} - end - else - {:noreply, socket} - end - end - - # ============================================ - # PUBSUB EVENT HANDLERS - # ============================================ - - @impl true - def handle_info({:cart_updated, cart}, socket) do - shipping_methods = Shop.get_available_shipping_methods(cart) - - {:noreply, - socket - |> assign(:cart, cart) - |> assign(:shipping_methods, shipping_methods)} - end - - @impl true - def handle_info({:item_added, cart, _item}, socket) do - shipping_methods = Shop.get_available_shipping_methods(cart) - - {:noreply, - socket - |> assign(:cart, cart) - |> assign(:shipping_methods, shipping_methods)} - end - - @impl true - def handle_info({:item_removed, cart, _item_id}, socket) do - shipping_methods = Shop.get_available_shipping_methods(cart) - - {:noreply, - socket - |> assign(:cart, cart) - |> assign(:shipping_methods, shipping_methods)} - end - - @impl true - def handle_info({:quantity_updated, cart, _item}, socket) do - shipping_methods = Shop.get_available_shipping_methods(cart) - - {:noreply, - socket - |> assign(:cart, cart) - |> assign(:shipping_methods, shipping_methods)} - end - - @impl true - def handle_info({:shipping_selected, cart}, socket) do - {:noreply, assign(socket, :cart, cart)} - end - - @impl true - def handle_info({:payment_selected, cart}, socket) do - {:noreply, assign(socket, :cart, cart)} - end - - @impl true - def handle_info({:cart_cleared, cart}, socket) do - shipping_methods = Shop.get_available_shipping_methods(cart) - - {:noreply, - socket - |> assign(:cart, cart) - |> assign(:shipping_methods, shipping_methods)} - end - - @impl true - def render(assigns) do - ~H""" - -
- <%!-- Header --%> -
-
- <.link - navigate={Shop.catalog_url(@current_language)} - class="btn btn-ghost btn-sm" - > - <.icon name="hero-arrow-left" class="w-4 h-4" /> - -
-

Shopping Cart

-

Review your items before checkout

-
-
-
- -
- <%!-- Cart Items --%> -
- <%= if @cart.items == [] do %> -
-
- <.icon name="hero-shopping-cart" class="w-16 h-16 mx-auto mb-4 opacity-30" /> -

Your cart is empty

-

Add some products to get started

- <.link navigate={Shop.catalog_url(@current_language)} class="btn btn-primary"> - Browse Products - -
-
- <% else %> -
-
-
- - - - - - - - - - - <%= for item <- @cart.items do %> - - - - - - - <% end %> - -
ProductQuantityPrice
-
- <%= 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" - > - {item.product_title} - - <% else %> -
- {item.product_title} -
- <% 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 -
-
- -
-
-
-
- <% end %> - - <%!-- Shipping Section --%> - <%= if @cart.items != [] do %> -
-
-

Shipping Method

- - <%= if @shipping_methods == [] do %> -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> - No shipping methods available for your selection -
- <% else %> -
- <%= for method <- @shipping_methods do %> - - <% end %> -
- <% end %> -
-
- <% end %> -
- - <%!-- Order Summary --%> -
-
-
-

Order Summary

- -
-
- - Subtotal ({@cart.items_count || 0} items) - - {format_price(@cart.subtotal, @currency)} -
- -
- Shipping - <%= if is_nil(@cart.shipping_method_uuid) do %> - Select method - <% else %> - <%= if Decimal.compare(@cart.shipping_amount || Decimal.new("0"), Decimal.new("0")) == :eq do %> - FREE - <% else %> - {format_price(@cart.shipping_amount, @currency)} - <% end %> - <% end %> -
- - <%= if @cart.discount_amount && Decimal.compare(@cart.discount_amount, Decimal.new("0")) == :gt do %> -
- Discount - -{format_price(@cart.discount_amount, @currency)} -
- <% end %> - - <%= if @cart.tax_amount && Decimal.compare(@cart.tax_amount, Decimal.new("0")) == :gt do %> -
- Tax - {format_price(@cart.tax_amount, @currency)} -
- <% end %> - -
- -
- Total - {format_price(@cart.total, @currency)} -
-
- - - - <%= if @cart.items != [] do %> -

- Secure checkout powered by PhoenixKit -

- <% end %> -
-
-
-
-
-
- """ - end - - # Private helpers - - defp generate_session_id do - :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) - end - - defp product_item_url(item, language) do - base = DialectMapper.extract_base(language) - Routes.path("/shop/product/#{item.product_slug}", locale: base) - end -end diff --git a/lib/modules/shop/web/carts.ex b/lib/modules/shop/web/carts.ex deleted file mode 100644 index 775f6caf5..000000000 --- a/lib/modules/shop/web/carts.ex +++ /dev/null @@ -1,255 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Carts do - @moduledoc """ - Carts admin list LiveView for E-Commerce module. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing.Currency - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Utils.Routes - - @per_page 25 - - @impl true - def mount(_params, _session, socket) do - {carts, total} = Shop.list_carts_with_count(per_page: @per_page) - currency = Shop.get_default_currency() - - socket = - socket - |> assign(:page_title, "Shopping Carts") - |> assign(:carts, carts) - |> assign(:total, total) - |> assign(:page, 1) - |> assign(:per_page, @per_page) - |> assign(:status_filter, nil) - |> assign(:search, "") - |> assign(:currency, currency) - - {:ok, socket} - end - - @impl true - def handle_params(params, _uri, socket) do - page = String.to_integer(params["page"] || "1") - status = params["status"] - search = params["search"] || "" - - {carts, total} = - Shop.list_carts_with_count( - page: page, - per_page: @per_page, - status: status, - search: search - ) - - socket = - socket - |> assign(:carts, carts) - |> assign(:total, total) - |> assign(:page, page) - |> assign(:status_filter, status) - |> assign(:search, search) - - {:noreply, socket} - end - - @impl true - def handle_event("filter_status", %{"status" => status}, socket) do - status = if status == "", do: nil, else: status - {:noreply, push_patch(socket, to: build_url(socket.assigns, status: status, page: 1))} - end - - @impl true - def handle_event("search", %{"search" => search}, socket) do - {:noreply, push_patch(socket, to: build_url(socket.assigns, search: search, page: 1))} - end - - defp build_url(assigns, overrides) do - params = - %{ - status: Keyword.get(overrides, :status, assigns.status_filter), - search: Keyword.get(overrides, :search, assigns.search), - page: Keyword.get(overrides, :page, assigns.page) - } - |> Enum.filter(fn {_k, v} -> v && v != "" end) - |> URI.encode_query() - - if params == "" do - Routes.path("/admin/shop/carts") - else - Routes.path("/admin/shop/carts?#{params}") - end - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header back={Routes.path("/admin/shop")}> -

Shopping Carts

-

{@total} carts total

- - - <%!-- Controls Bar --%> -
-
- <%!-- Search --%> -
- -
- -
-
- - <%!-- Status Filter --%> -
- - -
-
-
- - <%!-- Carts Table --%> -
-
- - - - - - - - - - - - <%= if @carts == [] do %> - - - - <% else %> - <%= for cart <- @carts do %> - - - - - - - - <% end %> - <% end %> - -
CustomerItemsTotalStatusUpdated
- <.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 %> -
-
-
- - <%!-- Pagination --%> - <%= if @total > @per_page do %> -
-
- <%= for page_num <- 1..ceil(@total / @per_page) do %> - <.link - patch={build_url(assigns, page: page_num)} - class={["join-item btn btn-sm", if(@page == page_num, do: "btn-active")]} - > - {page_num} - - <% end %> -
-
- <% end %> -
-
- """ - end - - defp status_badge_class("active"), do: "badge badge-success" - defp status_badge_class("converted"), do: "badge badge-info" - defp status_badge_class("abandoned"), do: "badge badge-warning" - defp status_badge_class("expired"), do: "badge badge-neutral" - defp status_badge_class("merged"), do: "badge badge-secondary" - defp status_badge_class(_), do: "badge" - - defp format_price(nil, _currency), do: "-" - - defp format_price(amount, %Currency{} = currency) do - Currency.format_amount(amount, currency) - end - - defp format_price(amount, nil) do - "$#{Decimal.round(amount, 2)}" - end - - defp format_weight(grams) when grams >= 1000, do: "#{Float.round(grams / 1000, 1)} kg" - defp format_weight(grams), do: "#{grams} g" - - defp format_datetime(nil), do: "-" - - defp format_datetime(datetime) do - Calendar.strftime(datetime, "%Y-%m-%d %H:%M") - end -end diff --git a/lib/modules/shop/web/catalog_category.ex b/lib/modules/shop/web/catalog_category.ex deleted file mode 100644 index ede66fa2d..000000000 --- a/lib/modules/shop/web/catalog_category.ex +++ /dev/null @@ -1,461 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.CatalogCategory do - @moduledoc """ - Public shop category page. - Shows products filtered by category. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.SlugResolver - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Shop.Web.Components.CatalogSidebar - alias PhoenixKit.Modules.Shop.Web.Components.FilterHelpers - alias PhoenixKit.Modules.Shop.Web.Components.ShopCards - alias PhoenixKit.Modules.Shop.Web.Components.ShopLayouts - alias PhoenixKit.Modules.Shop.Web.Helpers - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - alias PhoenixKitWeb.AdminEditHelper - - @impl true - def mount(%{"slug" => slug} = params, _session, socket) do - # Determine language: use URL locale param if present, otherwise default - # This ensures /shop/... always uses default language, not session - current_language = Helpers.get_language_from_params_or_default(params) - - case Shop.get_category_by_slug_localized(slug, current_language, preload: [:parent]) do - {:error, :not_found} -> - # Slug not found in current language - try cross-language lookup - handle_cross_language_redirect(slug, current_language, socket) - - # Redirect if category is hidden (products not visible) - {:ok, %{status: "hidden"}} -> - {:ok, - socket - |> put_flash(:error, "Category not found") - |> push_navigate(to: Shop.catalog_url(current_language))} - - {:ok, category} -> - per_page = 24 - page = Helpers.parse_page(params["page"]) - - # Load storefront filters - {enabled_filters, filter_values} = - FilterHelpers.load_filter_data(category_uuid: category.uuid) - - active_filters = FilterHelpers.parse_filter_params(params, enabled_filters) - filter_opts = FilterHelpers.build_query_opts(active_filters, enabled_filters) - - {products, total} = - Shop.list_products_with_count( - [ - status: "active", - category_uuid: category.uuid, - page: 1, - per_page: page * per_page, - preload: [:category] - ] ++ filter_opts - ) - - total_pages = max(1, ceil(total / per_page)) - page = min(page, total_pages) - - currency = Shop.get_default_currency() - all_categories = Shop.list_active_categories(preload: [:featured_product]) - - # Check if user is authenticated - authenticated = not is_nil(socket.assigns[:phoenix_kit_current_user]) - - # Get localized category content - localized_name = Translations.get(category, :name, current_language) - localized_description = Translations.get(category, :description, current_language) - - # Get current path for language switcher - current_path = - socket.assigns[:url_path] || - "/shop/category/#{Translations.get(category, :slug, current_language)}" - - socket = - socket - |> assign(:page_title, localized_name) - |> assign(:category, category) - |> assign(:current_language, current_language) - |> assign(:localized_name, localized_name) - |> assign(:localized_description, localized_description) - |> assign(:products, products) - |> assign(:total_products, total) - |> assign(:page, page) - |> assign(:per_page, per_page) - |> assign(:total_pages, total_pages) - |> assign(:categories, all_categories) - |> assign(:currency, currency) - |> assign(:authenticated, authenticated) - |> assign(:current_path, current_path) - |> assign(:enabled_filters, enabled_filters) - |> assign(:filter_values, filter_values) - |> assign(:active_filters, active_filters) - |> assign(:filter_qs, FilterHelpers.build_query_string(active_filters, enabled_filters)) - |> assign(:show_mobile_filters, false) - |> assign( - :category_name_wrap, - Settings.get_setting_cached("shop_category_name_display", "truncate") == "wrap" - ) - |> assign( - :category_icon_mode, - Settings.get_setting_cached("shop_category_icon_mode", "none") - ) - |> AdminEditHelper.assign_admin_edit( - Routes.path("/admin/shop/categories/#{category.uuid}/edit"), - "Edit Category" - ) - - {:ok, socket} - end - end - - @impl true - def handle_params(params, _uri, socket) do - page = Helpers.parse_page(params["page"]) - active_filters = FilterHelpers.parse_filter_params(params, socket.assigns.enabled_filters) - filter_opts = FilterHelpers.build_query_opts(active_filters, socket.assigns.enabled_filters) - - # Reload products if filters or page changed - filters_changed = active_filters != socket.assigns.active_filters - page = min(page, max(1, socket.assigns.total_pages)) - - if filters_changed || page != socket.assigns.page do - # Reset to page 1 when filters change - effective_page = if filters_changed, do: 1, else: page - - {products, total} = - Shop.list_products_with_count( - [ - status: "active", - category_uuid: socket.assigns.category.uuid, - page: 1, - per_page: effective_page * socket.assigns.per_page, - preload: [:category] - ] ++ filter_opts - ) - - total_pages = max(1, ceil(total / socket.assigns.per_page)) - - {:noreply, - socket - |> assign(:page, min(effective_page, total_pages)) - |> assign(:products, products) - |> assign(:total_products, total) - |> assign(:total_pages, total_pages) - |> assign(:active_filters, active_filters) - |> assign( - :filter_qs, - FilterHelpers.build_query_string(active_filters, socket.assigns.enabled_filters) - )} - else - {:noreply, socket} - end - end - - # Handle cross-language slug redirect - # When user visits with a slug from a different language, redirect to correct localized URL - defp handle_cross_language_redirect(slug, current_language, socket) do - case Shop.get_category_by_any_slug(slug, preload: [:parent]) do - {:error, :not_found} -> - # Category truly not found - {:ok, - socket - |> put_flash(:error, "Category not found") - |> push_navigate(to: Shop.catalog_url(current_language))} - - {:ok, %{status: "hidden"}, _matched_lang} -> - # Category is hidden - {:ok, - socket - |> put_flash(:error, "Category not found") - |> push_navigate(to: Shop.catalog_url(current_language))} - - {:ok, category, _matched_lang} -> - # Found category - redirect to best enabled language that has a slug - case Helpers.best_redirect_language(category.slug || %{}) do - nil -> - {:ok, - socket - |> put_flash(:error, "Category not found") - |> push_navigate(to: Shop.catalog_url(current_language))} - - redirect_lang -> - slug = SlugResolver.category_slug(category, redirect_lang) - - {:ok, - push_navigate(socket, - to: Helpers.build_lang_url("/shop/category/#{slug}", redirect_lang) - )} - end - end - end - - @impl true - def handle_event("filter_price", params, socket) do - filter_key = params["filter_key"] || "price" - - active_filters = - FilterHelpers.update_price_filter( - socket.assigns.active_filters, - filter_key, - params["price_min"], - params["price_max"] - ) - - path = build_filter_path(socket.assigns, active_filters) - {:noreply, push_patch(socket, to: path)} - end - - @impl true - def handle_event("toggle_filter", %{"key" => key, "val" => value}, socket) do - active_filters = FilterHelpers.toggle_filter_value(socket.assigns.active_filters, key, value) - path = build_filter_path(socket.assigns, active_filters) - {:noreply, push_patch(socket, to: path)} - end - - @impl true - def handle_event("clear_filters", _params, socket) do - base_path = Shop.category_url(socket.assigns.category, socket.assigns.current_language) - {:noreply, push_patch(socket, to: base_path)} - end - - @impl true - def handle_event("toggle_mobile_filters", _params, socket) do - {:noreply, assign(socket, :show_mobile_filters, !socket.assigns.show_mobile_filters)} - end - - @impl true - def handle_event("load_more", _params, socket) do - next_page = socket.assigns.page + 1 - path = build_filter_path(socket.assigns, socket.assigns.active_filters, page: next_page) - {:noreply, push_patch(socket, to: path)} - end - - @impl true - def render(assigns) do - assigns = - if assigns.authenticated do - assign(assigns, :sidebar_after_shop, shop_sidebar(assigns)) - else - assigns - end - - ~H""" - -
- <%!-- Breadcrumbs --%> - - - <%!-- Mobile filter toggle --%> -
- -
- - <%!-- Mobile filter drawer --%> - <%= if @show_mobile_filters do %> -
-
-
- -
-
-
- <% end %> - - <%= if @authenticated do %> - <%!-- Authenticated layout: Categories are in dashboard sidebar --%> - <%!-- Category Header --%> -
-

{@localized_name}

- <%= if @localized_description do %> -

{@localized_description}

- <% end %> -

- {@total_products} product(s) found -

-
- - <%!-- Full-width Products Grid --%> - <%= if @products == [] do %> -
-
- <.icon name="hero-cube" class="w-16 h-16 mx-auto mb-4 opacity-30" /> -

- No products in this category -

-

- Check back soon or browse other categories -

- <.link - navigate={Shop.catalog_url(@current_language) <> @filter_qs} - class="btn btn-primary" - > - Browse All Products - -
-
- <% else %> -
- <%= for product <- @products do %> - - <% end %> -
- - - <% end %> - <% else %> - <%!-- Guest layout: With sidebar for filters + category navigation --%> -
- <%!-- Sidebar --%> - - - <%!-- Main Content --%> -
- <%!-- Category Header --%> -
-

{@localized_name}

- <%= if @localized_description do %> -

{@localized_description}

- <% end %> -

- {@total_products} product(s) found -

-
- - <%!-- Products Grid --%> - <%= if @products == [] do %> -
-
- <.icon name="hero-cube" class="w-16 h-16 mx-auto mb-4 opacity-30" /> -

- No products in this category -

-

- Check back soon or browse other categories -

- <.link - navigate={Shop.catalog_url(@current_language) <> @filter_qs} - class="btn btn-primary" - > - Browse All Products - -
-
- <% else %> -
- <%= for product <- @products do %> - - <% end %> -
- - - <% end %> -
-
- <% end %> -
-
- """ - end - - defp shop_sidebar(assigns) do - ~H""" - - """ - end - - # Build category path with filter params and optional page - defp build_filter_path(assigns, active_filters, opts \\ []) do - base_path = Shop.category_url(assigns.category, assigns.current_language) - page = Keyword.get(opts, :page) - - FilterHelpers.build_filter_url(base_path, active_filters, assigns.enabled_filters, page: page) - end -end diff --git a/lib/modules/shop/web/catalog_product.ex b/lib/modules/shop/web/catalog_product.ex deleted file mode 100644 index 2f2c92b5e..000000000 --- a/lib/modules/shop/web/catalog_product.ex +++ /dev/null @@ -1,1317 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.CatalogProduct do - @moduledoc """ - Public product detail page with add-to-cart functionality. - - Supports dynamic option-based pricing with fixed and percent modifiers. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Events - alias PhoenixKit.Modules.Shop.Options - alias PhoenixKit.Modules.Shop.SlugResolver - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Shop.Web.Components.CatalogSidebar - alias PhoenixKit.Modules.Shop.Web.Components.FilterHelpers - alias PhoenixKit.Modules.Shop.Web.Components.ShopLayouts - alias PhoenixKit.Modules.Shop.Web.Helpers - import PhoenixKit.Modules.Shop.Web.Helpers, only: [format_price: 2] - alias PhoenixKit.Modules.Storage - alias PhoenixKit.Modules.Storage.URLSigner - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.Routes - alias PhoenixKitWeb.AdminEditHelper - - # Data URI placeholder for broken images - works without external file serving - @placeholder_data_uri "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'%3E%3Crect width='400' height='400' fill='%23e5e7eb'/%3E%3Cg fill='%239ca3af'%3E%3Crect x='160' y='140' width='80' height='60' rx='4'/%3E%3Ccircle cx='180' cy='160' r='8'/%3E%3Cpath d='M160 190 l25-20 l15 15 l20-25 l20 30 v10 h-80 z'/%3E%3C/g%3E%3C/svg%3E" - - @impl true - def mount(%{"slug" => slug} = params, session, socket) do - # Determine language: use URL locale param if present, otherwise default - # This ensures /shop/... always uses default language, not session - current_language = get_language_from_params_or_default(params) - - # Try localized slug lookup first - case Shop.get_product_by_slug_localized(slug, current_language, preload: [:category]) do - {:error, :not_found} -> - # Slug not found in current language - try cross-language lookup - handle_cross_language_redirect(slug, current_language, params, socket) - - # Hide product if its category is hidden - {:ok, %{category: %{status: "hidden"}}} -> - {:ok, - socket - |> put_flash(:error, "Product not found") - |> push_navigate(to: Shop.catalog_url(current_language))} - - {:ok, product} -> - # Get session_id for guest cart - session_id = session["shop_session_id"] || generate_session_id() - user = Helpers.get_current_user(socket) - user_uuid = if user, do: user.uuid, else: nil - - currency = Shop.get_default_currency() - - # Check if user is authenticated - authenticated = not is_nil(socket.assigns[:phoenix_kit_current_user]) - - # Build specifications from options (non-price-affecting for display) - specifications = build_specifications(product) - - # Load price-affecting specs for dynamic pricing - price_affecting_specs = Shop.get_price_affecting_specs(product) - - # Load ALL selectable specs for UI display (includes non-price-affecting like Color) - selectable_specs = Shop.get_selectable_specs(product) - - # Initialize selected specs with defaults from product metadata - # Use selectable_specs to include all options, not just price-affecting - selected_specs = build_default_specs(selectable_specs, product.metadata || %{}) - - # Calculate initial price - calculated_price = Shop.calculate_product_price(product, selected_specs) - - # Check if product is already in cart - cart_item = find_cart_item_with_specs(user_uuid, session_id, product.uuid, selected_specs) - - # Calculate missing required specs for UI (check all selectable specs, not just price-affecting) - missing_required_specs = get_missing_required_specs(selected_specs, selectable_specs) - - all_categories = Shop.list_active_categories(preload: [:featured_product]) - - # Parse filter context from URL for navigation back-links - category_uuid = if product.category, do: product.category.uuid, else: nil - {enabled_filters, _fv} = FilterHelpers.load_filter_data(category_uuid: category_uuid) - active_filters = FilterHelpers.parse_filter_params(params, enabled_filters) - filter_qs = FilterHelpers.build_query_string(active_filters, enabled_filters) - - # Get localized content - localized_title = Translations.get(product, :title, current_language) - localized_description = Translations.get(product, :description, current_language) - localized_body = Translations.get(product, :body_html, current_language) - - # Get current path for language switcher - current_path = socket.assigns[:url_path] || Shop.product_url(product, current_language) - - # Subscribe to product updates if connected - if connected?(socket) do - Events.subscribe_product(product.uuid) - Events.subscribe_inventory() - end - - socket = - socket - |> assign(:page_title, localized_title) - |> assign(:product, product) - |> assign(:current_language, current_language) - |> assign(:localized_title, localized_title) - |> assign(:localized_description, localized_description) - |> assign(:localized_body, localized_body) - |> assign(:currency, currency) - |> assign(:quantity, 1) - |> assign(:session_id, session_id) - |> assign(:user_uuid, user_uuid) - |> assign(:selected_image, first_image(product)) - |> assign(:adding_to_cart, false) - |> assign(:authenticated, authenticated) - |> assign(:cart_item, cart_item) - |> assign(:specifications, specifications) - |> assign(:price_affecting_specs, price_affecting_specs) - |> assign(:selectable_specs, selectable_specs) - |> assign(:selected_specs, selected_specs) - |> assign(:calculated_price, calculated_price) - |> assign(:missing_required_specs, missing_required_specs) - |> assign(:current_path, current_path) - |> assign(:categories, all_categories) - |> assign(:filter_qs, filter_qs) - |> assign( - :category_name_wrap, - Settings.get_setting_cached("shop_category_name_display", "truncate") == "wrap" - ) - |> assign( - :category_icon_mode, - Settings.get_setting_cached("shop_category_icon_mode", "none") - ) - |> AdminEditHelper.assign_admin_edit( - Routes.path("/admin/shop/products/#{product.uuid}/edit"), - "Edit Product" - ) - - {:ok, socket} - end - end - - # Handle cross-language slug redirect - # When user visits with a slug from a different language, redirect to correct localized URL - defp handle_cross_language_redirect(slug, current_language, params, socket) do - case Shop.get_product_by_any_slug(slug, preload: [:category]) do - {:error, :not_found} -> - # Product truly not found - {:ok, - socket - |> put_flash(:error, "Product not found") - |> push_navigate(to: Shop.catalog_url(current_language))} - - {:ok, %{category: %{status: "hidden"}}, _matched_lang} -> - # Product's category is hidden - {:ok, - socket - |> put_flash(:error, "Product not found") - |> push_navigate(to: Shop.catalog_url(current_language))} - - {:ok, product, _matched_lang} -> - # Found product in different language - # Check if we need to redirect or can just use the product - redirect_lang = Helpers.best_redirect_language(product.slug || %{}) - - # Normalize both languages to compare (e.g., "en" <-> "en-US") - current_base = DialectMapper.extract_base(current_language) - redirect_base = redirect_lang && DialectMapper.extract_base(redirect_lang) - - cond do - # No valid redirect language found - is_nil(redirect_lang) -> - {:ok, - socket - |> put_flash(:error, "Product not found") - |> push_navigate(to: Shop.catalog_url(current_language))} - - # Same base language (e.g., "en" vs "en-US") - use product without redirect - current_base == redirect_base -> - # Re-run mount with found product to avoid redirect loop - mount_with_product(product, current_language, params, socket) - - # Different language - redirect to correct URL - true -> - slug = SlugResolver.product_slug(product, redirect_lang) - - {:ok, - push_navigate(socket, - to: Helpers.build_lang_url("/shop/product/#{slug}", redirect_lang) - )} - end - end - end - - # Mount product page using already-found product (avoids redirect loop) - # Used when cross-language lookup finds a product with same base language - defp mount_with_product(product, current_language, params, socket) do - # Note: We don't have session here, so we'll generate new session_id if needed - # This is acceptable since this path is only hit on first mount, not during LiveView lifecycle - session_id = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) - user = Helpers.get_current_user(socket) - user_uuid = if user, do: user.uuid, else: nil - - currency = Shop.get_default_currency() - authenticated = not is_nil(socket.assigns[:phoenix_kit_current_user]) - - # Build specifications - specifications = build_specifications(product) - price_affecting_specs = Shop.get_price_affecting_specs(product) - selectable_specs = Shop.get_selectable_specs(product) - selected_specs = build_default_specs(selectable_specs, product.metadata || %{}) - calculated_price = Shop.calculate_product_price(product, selected_specs) - cart_item = find_cart_item_with_specs(user_uuid, session_id, product.uuid, selected_specs) - missing_required_specs = get_missing_required_specs(selected_specs, selectable_specs) - - all_categories = Shop.list_active_categories(preload: [:featured_product]) - - # Compute filter_qs from URL params (preserves filters across cross-language redirect) - category_uuid = if product.category, do: product.category.uuid, else: nil - {enabled_filters, _fv} = FilterHelpers.load_filter_data(category_uuid: category_uuid) - active_filters = FilterHelpers.parse_filter_params(params, enabled_filters) - filter_qs = FilterHelpers.build_query_string(active_filters, enabled_filters) - - # Get localized content - localized_title = Translations.get(product, :title, current_language) - localized_description = Translations.get(product, :description, current_language) - localized_body = Translations.get(product, :body_html, current_language) - current_path = socket.assigns[:url_path] || Shop.product_url(product, current_language) - - # Subscribe to updates - if connected?(socket) do - Events.subscribe_product(product.uuid) - Events.subscribe_inventory() - end - - socket = - socket - |> assign(:page_title, localized_title) - |> assign(:product, product) - |> assign(:current_language, current_language) - |> assign(:localized_title, localized_title) - |> assign(:localized_description, localized_description) - |> assign(:localized_body, localized_body) - |> assign(:currency, currency) - |> assign(:quantity, 1) - |> assign(:session_id, session_id) - |> assign(:user_uuid, user_uuid) - |> assign(:selected_image, first_image(product)) - |> assign(:adding_to_cart, false) - |> assign(:authenticated, authenticated) - |> assign(:cart_item, cart_item) - |> assign(:specifications, specifications) - |> assign(:price_affecting_specs, price_affecting_specs) - |> assign(:selectable_specs, selectable_specs) - |> assign(:selected_specs, selected_specs) - |> assign(:calculated_price, calculated_price) - |> assign(:missing_required_specs, missing_required_specs) - |> assign(:current_path, current_path) - |> assign(:categories, all_categories) - |> assign(:filter_qs, filter_qs) - |> assign( - :category_name_wrap, - Settings.get_setting_cached("shop_category_name_display", "truncate") == "wrap" - ) - |> assign( - :category_icon_mode, - Settings.get_setting_cached("shop_category_icon_mode", "none") - ) - |> AdminEditHelper.assign_admin_edit( - Routes.path("/admin/shop/products/#{product.uuid}/edit"), - "Edit Product" - ) - - {:ok, socket} - end - - @impl true - def handle_event("set_quantity", %{"quantity" => quantity}, socket) do - quantity = String.to_integer(quantity) |> max(1) - {:noreply, assign(socket, :quantity, quantity)} - end - - @impl true - def handle_event("increment", _params, socket) do - {:noreply, assign(socket, :quantity, socket.assigns.quantity + 1)} - end - - @impl true - def handle_event("decrement", _params, socket) do - quantity = max(socket.assigns.quantity - 1, 1) - {:noreply, assign(socket, :quantity, quantity)} - end - - @impl true - def handle_event("select_image", %{"url" => url}, socket) do - {:noreply, assign(socket, :selected_image, url)} - end - - @impl true - def handle_event("select_spec", params, socket) do - key = params["key"] || "" - value = params["opt"] || "" - - selected_specs = Map.put(socket.assigns.selected_specs, key, value) - product = socket.assigns.product - selectable_specs = socket.assigns.selectable_specs - - # Recalculate price with new spec selection - calculated_price = Shop.calculate_product_price(product, selected_specs) - - # Check for image mapping - update selected_image if mapping exists - selected_image = get_mapped_image(product, key, value, socket.assigns.selected_image) - - # Check if this combination is in cart - cart_item = - find_cart_item_with_specs( - socket.assigns.user_uuid, - socket.assigns.session_id, - product.uuid, - selected_specs - ) - - # Update missing required specs for UI (check all selectable specs) - missing_required_specs = get_missing_required_specs(selected_specs, selectable_specs) - - socket = - socket - |> assign(:selected_specs, selected_specs) - |> assign(:calculated_price, calculated_price) - |> assign(:selected_image, selected_image) - |> assign(:cart_item, cart_item) - |> assign(:missing_required_specs, missing_required_specs) - - {:noreply, socket} - end - - @impl true - def handle_event("select_storage_image", %{"uuid" => uuid}, socket) do - url = get_storage_image_url(uuid, "large") - {:noreply, assign(socket, :selected_image, url)} - end - - @impl true - def handle_event("add_to_cart", _params, socket) do - do_add_to_cart(socket) - end - - defp do_add_to_cart(socket) do - %{ - selected_specs: selected_specs, - selectable_specs: selectable_specs - } = socket.assigns - - # Validate required options before proceeding (check all selectable specs) - case validate_required_specs(selected_specs, selectable_specs) do - :ok -> - do_add_to_cart_impl(socket) - - {:error, missing_labels} -> - message = "Please select: #{Enum.join(missing_labels, ", ")}" - {:noreply, put_flash(socket, :error, message)} - end - end - - defp do_add_to_cart_impl(socket) do - socket = assign(socket, :adding_to_cart, true) - - # Get or create cart - {:ok, cart} = - Shop.get_or_create_cart( - user_uuid: socket.assigns.user_uuid, - session_id: socket.assigns.session_id - ) - - %{ - product: product, - quantity: quantity, - currency: currency, - selected_specs: selected_specs, - price_affecting_specs: price_affecting_specs, - calculated_price: calculated_price - } = socket.assigns - - # Add to cart with specs if any options were selected - has_specs = selected_specs != %{} and map_size(selected_specs) > 0 - - add_result = - if has_specs do - Shop.add_to_cart(cart, product, quantity, selected_specs: selected_specs) - else - Shop.add_to_cart(cart, product, quantity) - end - - case add_result do - {:ok, updated_cart} -> - unit_price = - if price_affecting_specs != [] do - calculated_price - else - product.price - end - - display_name = build_cart_display_name(product, price_affecting_specs, selected_specs) - - message = - build_cart_message(display_name, quantity, unit_price, updated_cart.total, currency) - - updated_cart_item = - find_cart_item_after_add( - updated_cart.items, - product.uuid, - selected_specs, - price_affecting_specs - ) - - {:noreply, - socket - |> assign(:adding_to_cart, false) - |> assign(:quantity, 1) - |> assign(:cart_item, updated_cart_item) - |> put_flash(:info, message) - |> push_event("cart_updated", %{})} - - {:error, reason} -> - # Log error for admin monitoring - log_cart_error( - "Failed to add to cart", - reason, - socket.assigns.product.uuid, - socket.assigns.user_uuid - ) - - {:noreply, - socket - |> assign(:adding_to_cart, false) - |> put_flash( - :error, - "Unable to add this product to cart. Please refresh the page and try again." - )} - - {:error, code, detail} -> - # Log detailed error for admin monitoring - log_cart_error( - "Failed to add to cart", - {code, detail}, - socket.assigns.product.uuid, - socket.assigns.user_uuid - ) - - # Show user-friendly message based on error code - user_message = get_user_friendly_error_message(code, detail) - - {:noreply, - socket - |> assign(:adding_to_cart, false) - |> put_flash(:error, user_message)} - end - end - - # Get user-friendly error message based on error code and details - # Keep messages concise for toast display (max ~80 chars per line) - defp get_user_friendly_error_message(:invalid_option_value, detail) do - option_name = detail[:key] || "option" - - case detail[:value] do - nil -> - "Selected options are no longer available.\nPlease refresh and select again." - - val -> - "Option \"#{option_name}: #{val}\" is no longer available.\nPlease refresh the page for current options." - end - end - - defp get_user_friendly_error_message(code, detail) do - case code do - :unknown_option_key -> - option_name = detail[:key] || "option" - "Option \"#{option_name}\" does not exist.\nProduct was updated - please reload the page." - - :missing_required_option -> - missing_option = if is_binary(detail), do: detail, else: "required option" - "Missing required option: #{missing_option}.\nPlease select all required parameters." - - :out_of_stock -> - "Product is out of stock.\nPlease try again later or choose another product." - - :insufficient_stock -> - available = detail[:available] || 0 - "Insufficient stock (only #{available} available).\nPlease reduce quantity." - - :price_changed -> - "Product price has changed.\nPlease refresh to see current price." - - _ -> - # Generic fallback message - "Unable to add to cart.\nPlease try again or contact support." - end - end - - # Log cart errors for admin monitoring and debugging - # In production, this could trigger alerts via email, Slack, or error tracking service - defp log_cart_error(message, error_details, product_uuid, user_uuid) do - require Logger - - error_info = %{ - message: message, - error: error_details, - product_uuid: product_uuid, - user_uuid: user_uuid, - timestamp: UtilsDate.utc_now() - } - - # Log as warning level (not error) since it's gracefully handled - Logger.warning("[Shop] Cart operation failed: #{inspect(error_info)}") - - :ok - end - - defp build_cart_display_name(product, _price_affecting_specs, selected_specs) do - # Get localized title (use default language for cart display) - title = Translations.get(product, :title, Translations.default_language()) - - if map_size(selected_specs) > 0 do - specs_str = selected_specs |> Map.values() |> Enum.join(", ") - "#{title} (#{specs_str})" - else - title - end - end - - defp build_cart_message(display_name, quantity, unit_price, cart_total, currency) do - line_total = Decimal.mult(unit_price, quantity) - line_str = format_price(line_total, currency) - cart_total_str = format_price(cart_total, currency) - unit_price_str = format_price(unit_price, currency) - - "#{display_name} (#{quantity} × #{unit_price_str} = #{line_str}) added to cart.\nCart total: #{cart_total_str}" - end - - defp find_cart_item_after_add(items, product_uuid, selected_specs, _price_affecting_specs) do - if map_size(selected_specs) > 0 do - Enum.find(items, &(&1.product_uuid == product_uuid && &1.selected_specs == selected_specs)) - else - Enum.find(items, &(&1.product_uuid == product_uuid)) - end - end - - @impl true - def render(assigns) do - assigns = - if assigns.authenticated do - assign(assigns, :sidebar_after_shop, shop_sidebar(assigns)) - else - assigns - end - - ~H""" - -
- <%!-- Breadcrumbs --%> - - -
- <%!-- Guest: category navigation only (no filters on product page) --%> - <%= if !@authenticated do %> - - <% end %> - <%!-- Product Images --%> -
- <%!-- Main Image --%> -
- <%= if @selected_image do %> - {@localized_title} - <% else %> -
- <.icon name="hero-cube" class="w-32 h-32 opacity-30" /> -
- <% end %> -
- - <%!-- Thumbnails from Storage --%> - <% display_images = get_display_images(@product) %> - <%= if display_images != [] do %> -
- <%= for image_uuid <- display_images do %> - <% thumb_url = get_storage_image_url(image_uuid, "thumbnail") %> - <% large_url = get_storage_image_url(image_uuid, "large") %> - - <% end %> -
- <% end %> - - <%!-- Legacy URL-based thumbnails (only show if no Storage images) --%> - <%= if has_multiple_images?(@product) and get_display_images(@product) == [] do %> -
- <%= for {image, _idx} <- Enum.with_index(@product.images || []) do %> - <% url = image_url(image) %> - <%= if url do %> - - <% end %> - <% end %> -
- <% end %> -
- - <%!-- Product Info --%> -
-
-

{@localized_title}

- - <%= if @product.vendor do %> -

by {@product.vendor}

- <% end %> -
- - <%!-- Price --%> -
- <%= if @price_affecting_specs != [] do %> - <%!-- Has price-affecting specs - show calculated price --%> - - {format_price(@calculated_price, @currency)} - - <%= if @product.compare_at_price && Decimal.compare(@product.compare_at_price, @calculated_price) == :gt do %> - - {format_price(@product.compare_at_price, @currency)} - - <% end %> - <% else %> - <%!-- Simple product - show base price --%> - - {format_price(@product.price, @currency)} - - <%= if @product.compare_at_price && Decimal.compare(@product.compare_at_price, @product.price) == :gt do %> - - {format_price(@product.compare_at_price, @currency)} - - - {discount_percentage(@product)}% OFF - - <% end %> - <% end %> -
- - <%!-- Description --%> - <%= if @localized_description do %> - <.markdown content={@localized_description} sanitize={false} compact /> - <% end %> - - <%!-- Product Details --%> -
- -
- <%= if @product.weight_grams && @product.weight_grams > 0 do %> -
- Weight: - {@product.weight_grams}g -
- <% end %> - - <%= if @product.category do %> - <% cat_name = Translations.get(@product.category, :name, @current_language) %> -
- Category: - <.link - navigate={Shop.category_url(@product.category, @current_language) <> @filter_qs} - class="ml-2 link link-primary" - > - {cat_name} - -
- <% end %> -
- - <%!-- Specifications Table --%> - <%= if @specifications != [] do %> -
- -

- <.icon name="hero-tag" class="w-5 h-5 inline" /> Specifications -

- -
- - - <%= for {label, value, unit} <- @specifications do %> - - - - - <% end %> - -
{label} - {format_spec_value(value)} - <%= if unit do %> - {unit} - <% end %> -
-
- <% end %> - -
- - <%!-- Add to Cart Section --%> - <%= if @product.status == "active" do %> -
- <%!-- Option Selector (All Selectable Options) --%> - <%= if @selectable_specs != [] do %> -
-

- <.icon name="hero-adjustments-horizontal" class="w-5 h-5 inline" /> - Choose Options -

- - <%= for attr <- @selectable_specs do %> - <% is_missing = MapSet.member?(@missing_required_specs, attr["key"]) %> - <% affects_price = attr["affects_price"] == true %> -
- - {attr["label"]} - <%= if attr["required"] do %> - * - <% end %> - - <%= if is_missing do %> -

Please select an option

- <% end %> -
- <%= for opt_value <- get_option_values(@product, attr) do %> - <%= if affects_price do %> - <.option_button - option_key={attr["key"]} - option_value={opt_value} - price={ - calculate_option_total_price( - @product, - @price_affecting_specs, - @selected_specs, - attr["key"], - opt_value - ) - } - selected={@selected_specs[attr["key"]] == opt_value} - is_missing={is_missing} - currency={@currency} - /> - <% else %> - <.option_button_simple - option_key={attr["key"]} - option_value={opt_value} - selected={@selected_specs[attr["key"]] == opt_value} - is_missing={is_missing} - /> - <% end %> - <% end %> -
-
- <% end %> -
- <% end %> - - <%!-- Quantity Selector --%> -
- Quantity -
-
- -
- -
- -
- × - - {format_price( - current_display_price(@product, @calculated_price, @price_affecting_specs), - @currency - )} - - = - - {format_price( - line_total( - current_display_price(@product, @calculated_price, @price_affecting_specs), - @quantity - ), - @currency - )} - -
-
- - <%!-- Already in Cart Notice --%> - <%= if @cart_item do %> -
- <.icon name="hero-shopping-cart" class="w-5 h-5" /> -
- Already in cart: - - {@cart_item.quantity} × {format_price(@cart_item.unit_price, @currency)} = {format_price( - @cart_item.line_total, - @currency - )} - -
-
- <% end %> - - <%!-- Add to Cart Button --%> - - - <%!-- View Cart Link --%> - <.link navigate={Shop.cart_url(@current_language)} class="btn btn-outline w-full"> - <.icon name="hero-eye" class="w-5 h-5 mr-2" /> View Cart - -
- <% else %> -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> - This product is currently unavailable -
- <% end %> - - <%!-- Tags --%> - <%= if @product.tags && @product.tags != [] do %> -
- <%= for tag <- @product.tags do %> - {tag} - <% end %> -
- <% end %> -
-
-
-
- """ - end - - # Option button component - isolated for better debugging - attr :option_key, :any, required: true - attr :option_value, :any, required: true - attr :price, :any, required: true - attr :selected, :boolean, default: false - attr :is_missing, :boolean, default: false - attr :currency, :any, required: true - - defp option_button(assigns) do - ~H""" - - """ - end - - # Simple option button without price - for non-price-affecting options - attr :option_key, :any, required: true - attr :option_value, :any, required: true - attr :selected, :boolean, default: false - attr :is_missing, :boolean, default: false - - defp option_button_simple(assigns) do - ~H""" - - """ - end - - defp shop_sidebar(assigns) do - ~H""" - - """ - end - - # Private helpers - - defp generate_session_id do - :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) - end - - # Image helpers - prefer Storage images over legacy URL-based images - - # Get mapped image URL for selected option value, or keep current image if no mapping - # Supports both Storage IDs and legacy URLs (from Shopify imports) - defp get_mapped_image(product, option_key, option_value, current_image) do - case get_in(product.metadata || %{}, ["_image_mappings", option_key, option_value]) do - nil -> current_image - "" -> current_image - # If it's a URL (starts with http), use directly - "http" <> _ = url -> url - # Otherwise it's a Storage ID - image_uuid -> get_storage_image_url(image_uuid, "large") || current_image - end - end - - defp first_image(%{featured_image_uuid: id}) when is_binary(id) do - get_storage_image_url(id, "large") - end - - defp first_image(%{image_uuids: [id | _]}) when is_binary(id) do - get_storage_image_url(id, "large") - end - - defp first_image(%{images: [%{"src" => src} | _]}), do: src - defp first_image(%{images: [first | _]}) when is_binary(first), do: first - defp first_image(_), do: nil - - # Extract URL from image (handles both map and string formats) - defp image_url(%{"src" => src}), do: src - defp image_url(url) when is_binary(url), do: url - defp image_url(_), do: nil - - defp has_storage_images?(%{featured_image_uuid: id}) when is_binary(id), do: true - defp has_storage_images?(%{image_uuids: [_ | _]}), do: true - defp has_storage_images?(_), do: false - - defp has_multiple_images?(%{images: [_, _ | _]}), do: true - defp has_multiple_images?(_), do: false - - # Get display images for gallery - defp get_display_images(product) do - if has_storage_images?(product) do - product_image_uuids(product) - else - [] - end - end - - # Get all product Storage image IDs (featured + gallery, no duplicates) - defp product_image_uuids(%{featured_image_uuid: nil, image_uuids: ids}), do: ids || [] - - defp product_image_uuids(%{featured_image_uuid: featured, image_uuids: ids}) do - # Ensure featured is first, but don't duplicate if already in ids - all_ids = ids || [] - - if featured in all_ids do - # Move featured to front if not already there - [featured | Enum.reject(all_ids, &(&1 == featured))] - else - [featured | all_ids] - end - end - - defp product_image_uuids(_), do: [] - - defp get_storage_image_url(nil, _variant), do: placeholder_image_url() - - defp get_storage_image_url(file_uuid, variant) do - # Storage.get_file/1 returns %File{} struct or nil (not {:ok, file} tuple) - case Storage.get_file(file_uuid) do - %{uuid: uuid} = _file -> - # Check if requested variant exists, fall back to original if not - case Storage.get_file_instance_by_name(uuid, variant) do - nil -> - # Variant doesn't exist - try original - case Storage.get_file_instance_by_name(uuid, "original") do - nil -> placeholder_image_url() - _instance -> URLSigner.signed_url(file_uuid, "original") - end - - _instance -> - URLSigner.signed_url(file_uuid, variant) - end - - nil -> - placeholder_image_url() - end - end - - defp placeholder_image_url, do: @placeholder_data_uri - - # Get option values for a product, with fallback to schema defaults - # Allows per-product customization of available option values via metadata - defp get_option_values(product, option) do - key = option["key"] - - case product.metadata do - %{"_option_values" => %{^key => values}} when is_list(values) and values != [] -> - values - - _ -> - option["options"] || [] - end - end - - defp discount_percentage(%{price: price, compare_at_price: compare}) when not is_nil(compare) do - diff = Decimal.sub(compare, price) - percent = Decimal.div(diff, compare) |> Decimal.mult(100) |> Decimal.round(0) - Decimal.to_integer(percent) - end - - defp discount_percentage(_), do: 0 - - defp line_total(price, quantity) when not is_nil(price) do - Decimal.mult(price, quantity) - end - - defp line_total(_, _), do: Decimal.new("0") - - # Build specifications list from product options (for display only) - defp build_specifications(product) do - schema = Options.get_option_schema_for_product(product) - metadata = product.metadata || %{} - - schema - |> Enum.filter(fn opt -> - value = Map.get(metadata, opt["key"]) - value != nil and value != "" and value != [] - end) - |> Enum.sort_by(& &1["position"]) - |> Enum.map(fn opt -> - {opt["label"], Map.get(metadata, opt["key"]), opt["unit"]} - end) - end - - # Format specification value for display - defp format_spec_value(true), do: "Yes" - defp format_spec_value(false), do: "No" - defp format_spec_value("true"), do: "Yes" - defp format_spec_value("false"), do: "No" - defp format_spec_value(list) when is_list(list), do: Enum.join(list, ", ") - defp format_spec_value(value) when is_binary(value), do: value - defp format_spec_value(value) when is_number(value), do: to_string(value) - defp format_spec_value(value), do: inspect(value) - - # Get current display price - defp current_display_price(_product, calculated_price, price_affecting_specs) - when price_affecting_specs != [] do - calculated_price - end - - defp current_display_price(%{price: price}, _, _), do: price - - # Get set of missing required spec keys for UI highlighting - defp get_missing_required_specs(selected_specs, price_affecting_specs) do - price_affecting_specs - |> Enum.filter(fn attr -> attr["required"] == true end) - |> Enum.reject(fn attr -> - value = Map.get(selected_specs, attr["key"]) - value != nil and value != "" - end) - |> Enum.map(& &1["key"]) - |> MapSet.new() - end - - # Validate that all required specs have been selected - defp validate_required_specs(selected_specs, price_affecting_specs) do - missing = - price_affecting_specs - |> Enum.filter(fn attr -> attr["required"] == true end) - |> Enum.reject(fn attr -> - value = Map.get(selected_specs, attr["key"]) - value != nil and value != "" - end) - |> Enum.map(fn attr -> attr["label"] || attr["key"] end) - - case missing do - [] -> :ok - labels -> {:error, labels} - end - end - - # Build default specs from product metadata, schema defaults, or first option - defp build_default_specs(price_affecting_specs, metadata) do - Enum.reduce(price_affecting_specs, %{}, fn attr, acc -> - key = attr["key"] - default_value = Map.get(metadata, key) - schema_default = attr["default"] - - cond do - # 1. Product metadata override - default_value && default_value != "" -> - Map.put(acc, key, default_value) - - # 2. Schema default value - schema_default && schema_default != "" -> - Map.put(acc, key, schema_default) - - # 3. First option for required fields - attr["required"] == true && is_list(attr["options"]) && attr["options"] != [] -> - [first | _] = attr["options"] - Map.put(acc, key, first) - - true -> - acc - end - end) - end - - # Find cart item matching selected specs - defp find_cart_item_with_specs(user_uuid, session_id, product_uuid, selected_specs) do - case Shop.find_active_cart(user_uuid: user_uuid, session_id: session_id) do - %{items: items} when is_list(items) -> - Enum.find(items, fn item -> - item.product_uuid == product_uuid && - specs_match?(item.selected_specs, selected_specs) - end) - - _ -> - nil - end - end - - # Safe comparison of specs maps (handles nil and empty maps) - defp specs_match?(nil, specs) when is_map(specs) and map_size(specs) == 0, do: true - defp specs_match?(specs, nil) when is_map(specs) and map_size(specs) == 0, do: true - defp specs_match?(nil, nil), do: true - defp specs_match?(%{} = a, %{} = b), do: Map.equal?(a, b) - defp specs_match?(_, _), do: false - - # Calculate total price when a specific option value is selected - # This shows what the customer would pay if they select this option - defp calculate_option_total_price( - product, - price_affecting_specs, - current_selected, - option_key, - option_value - ) do - # Create a temporary specs map with the specific option selected - temp_specs = Map.put(current_selected, option_key, option_value) - - # Fill in defaults for other required options that aren't selected - temp_specs = - Enum.reduce(price_affecting_specs, temp_specs, fn attr, acc -> - key = attr["key"] - - if Map.has_key?(acc, key) and Map.get(acc, key) != nil and Map.get(acc, key) != "" do - acc - else - # Use first option as default for calculation - options = attr["options"] || [] - - case options do - [first | _] -> Map.put(acc, key, first) - _ -> acc - end - end - end) - - Shop.calculate_product_price(product, temp_specs) - end - - # Determine language from URL params - use locale param if present, otherwise default - # This ensures non-localized routes (/shop/...) always use default language, - # regardless of what's stored in session from previous visits - defp get_language_from_params_or_default(%{"locale" => locale}) when is_binary(locale) do - # Localized route - use the locale from URL - DialectMapper.resolve_dialect(locale, nil) - end - - defp get_language_from_params_or_default(_params) do - # Non-localized route - use admin default language for consistency with Routes.path - Routes.get_default_admin_locale() - end - - # PubSub event handlers - @impl true - def handle_info({:product_updated, updated_product}, socket) do - # Only update if it's the same product - if updated_product.uuid == socket.assigns.product.uuid do - {:noreply, assign(socket, :product, updated_product)} - else - {:noreply, socket} - end - end - - @impl true - def handle_info({:inventory_updated, product_uuid, _change}, socket) do - if product_uuid == socket.assigns.product.uuid do - # Reload product to get updated stock - product = Shop.get_product!(product_uuid) - {:noreply, assign(socket, :product, product)} - else - {:noreply, socket} - end - end -end diff --git a/lib/modules/shop/web/categories.ex b/lib/modules/shop/web/categories.ex deleted file mode 100644 index 91dd159d5..000000000 --- a/lib/modules/shop/web/categories.ex +++ /dev/null @@ -1,666 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Categories do - @moduledoc """ - Categories list LiveView for Shop module. - - Provides search, filtering, pagination, and bulk operations - for category management. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Category - alias PhoenixKit.Modules.Shop.Events - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Users.Auth.Scope - alias PhoenixKit.Utils.Routes - - @per_page 25 - - @impl true - def mount(_params, _session, socket) do - if connected?(socket) do - Events.subscribe_categories() - end - - current_language = Translations.default_language() - - socket = - socket - |> assign(:page_title, "Categories") - |> assign(:page, 1) - |> assign(:per_page, @per_page) - |> assign(:search, "") - |> assign(:status_filter, nil) - |> assign(:parent_filter, nil) - |> assign(:current_language, current_language) - |> assign(:selected_uuids, MapSet.new()) - |> assign(:show_bulk_modal, nil) - |> load_static_category_data() - |> load_filtered_categories() - - {:ok, socket} - end - - # ============================================ - # EVENT HANDLERS - # ============================================ - - @impl true - def handle_event("search", %{"search" => search}, socket) do - socket = - socket - |> assign(:search, search) - |> assign(:page, 1) - |> load_filtered_categories() - - {:noreply, socket} - end - - @impl true - def handle_event("filter_status", %{"status" => status}, socket) do - status = if status == "", do: nil, else: status - - socket = - socket - |> assign(:status_filter, status) - |> assign(:page, 1) - |> load_filtered_categories() - - {:noreply, socket} - end - - @impl true - def handle_event("filter_parent", %{"parent" => parent}, socket) do - parent = if parent == "", do: nil, else: parent - - socket = - socket - |> assign(:parent_filter, parent) - |> assign(:page, 1) - |> load_filtered_categories() - - {:noreply, socket} - end - - @impl true - def handle_event("change_page", %{"page" => page}, socket) do - page = String.to_integer(page) - - socket = - socket - |> assign(:page, page) - |> load_filtered_categories() - - {:noreply, socket} - end - - @impl true - def handle_event("delete", %{"uuid" => uuid}, socket) do - category = Shop.get_category!(uuid) - - case Shop.delete_category(category) do - {:ok, _} -> - {:noreply, - socket - |> load_static_category_data() - |> load_filtered_categories() - |> put_flash(:info, "Category deleted")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to delete category")} - end - end - - # Bulk selection events - - @impl true - def handle_event("toggle_select", %{"uuid" => uuid}, socket) do - selected = socket.assigns.selected_uuids - - selected = - if MapSet.member?(selected, uuid) do - MapSet.delete(selected, uuid) - else - MapSet.put(selected, uuid) - end - - {:noreply, assign(socket, :selected_uuids, selected)} - end - - @impl true - def handle_event("select_all", _params, socket) do - all_uuids = Enum.map(socket.assigns.categories, & &1.uuid) |> MapSet.new() - current = socket.assigns.selected_uuids - - selected = - if MapSet.subset?(all_uuids, current) do - MapSet.difference(current, all_uuids) - else - MapSet.union(current, all_uuids) - end - - {:noreply, assign(socket, :selected_uuids, selected)} - end - - @impl true - def handle_event("clear_selection", _params, socket) do - {:noreply, assign(socket, :selected_uuids, MapSet.new())} - end - - # Bulk action modals - - @impl true - def handle_event("show_bulk_modal", %{"action" => action}, socket) do - {:noreply, assign(socket, :show_bulk_modal, action)} - end - - @impl true - def handle_event("close_bulk_modal", _params, socket) do - {:noreply, assign(socket, :show_bulk_modal, nil)} - end - - # Bulk actions (require admin role) - - @impl true - def handle_event("bulk_change_status", %{"status" => status}, socket) do - if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do - uuids = MapSet.to_list(socket.assigns.selected_uuids) - count = Shop.bulk_update_category_status(uuids, status) - - {:noreply, - socket - |> load_static_category_data() - |> load_filtered_categories() - |> assign(:selected_uuids, MapSet.new()) - |> assign(:show_bulk_modal, nil) - |> put_flash(:info, "#{count} categories updated to #{status}")} - else - {:noreply, put_flash(socket, :error, "Not authorized")} - end - end - - @impl true - def handle_event("bulk_change_parent", %{"parent_uuid" => parent_uuid}, socket) do - if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do - uuids = MapSet.to_list(socket.assigns.selected_uuids) - parent_uuid = if parent_uuid == "", do: nil, else: parent_uuid - count = Shop.bulk_update_category_parent(uuids, parent_uuid) - - {:noreply, - socket - |> load_static_category_data() - |> load_filtered_categories() - |> assign(:selected_uuids, MapSet.new()) - |> assign(:show_bulk_modal, nil) - |> put_flash(:info, "#{count} categories updated")} - else - {:noreply, put_flash(socket, :error, "Not authorized")} - end - end - - @impl true - def handle_event("bulk_delete", _params, socket) do - if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do - uuids = MapSet.to_list(socket.assigns.selected_uuids) - count = Shop.bulk_delete_categories(uuids) - - {:noreply, - socket - |> load_static_category_data() - |> load_filtered_categories() - |> assign(:selected_uuids, MapSet.new()) - |> assign(:show_bulk_modal, nil) - |> put_flash(:info, "#{count} categories deleted")} - else - {:noreply, put_flash(socket, :error, "Not authorized")} - end - end - - # ============================================ - # PUBSUB HANDLERS - # ============================================ - - @impl true - def handle_info({:category_created, _category}, socket) do - {:noreply, socket |> load_static_category_data() |> load_filtered_categories()} - end - - @impl true - def handle_info({:category_updated, _category}, socket) do - {:noreply, socket |> load_static_category_data() |> load_filtered_categories()} - end - - @impl true - def handle_info({:category_deleted, _category_uuid}, socket) do - {:noreply, socket |> load_static_category_data() |> load_filtered_categories()} - end - - @impl true - def handle_info({:categories_bulk_status_changed, _uuids, _status}, socket) do - {:noreply, socket |> load_static_category_data() |> load_filtered_categories()} - end - - @impl true - def handle_info({:categories_bulk_parent_changed, _uuids, _parent_uuid}, socket) do - {:noreply, socket |> load_static_category_data() |> load_filtered_categories()} - end - - @impl true - def handle_info({:categories_bulk_deleted, _uuids}, socket) do - {:noreply, socket |> load_static_category_data() |> load_filtered_categories()} - end - - # ============================================ - # PRIVATE HELPERS - # ============================================ - - defp load_static_category_data(socket) do - all_categories = Shop.list_categories(preload: [:parent]) - product_counts = Shop.product_counts_by_category() - - socket - |> assign(:all_categories, all_categories) - |> assign(:product_counts, product_counts) - end - - defp load_filtered_categories(socket) do - parent_uuid_opt = - case socket.assigns.parent_filter do - nil -> :skip - "root" -> nil - uuid -> uuid - end - - opts = [ - page: socket.assigns.page, - per_page: @per_page, - search: socket.assigns.search, - status: socket.assigns.status_filter, - parent_uuid: parent_uuid_opt, - preload: [:parent, :featured_product] - ] - - {categories, total} = Shop.list_categories_with_count(opts) - - socket - |> assign(:categories, categories) - |> assign(:total, total) - end - - defp all_selected?(categories, selected_uuids) do - categories != [] and - Enum.all?(categories, fn c -> MapSet.member?(selected_uuids, c.uuid) end) - end - - defp status_badge_class("active"), do: "badge badge-success" - defp status_badge_class("unlisted"), do: "badge badge-warning" - defp status_badge_class("hidden"), do: "badge badge-error" - defp status_badge_class(_), do: "badge badge-success" - - # ============================================ - # RENDER - # ============================================ - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header back={Routes.path("/admin/shop")}> -

Categories

-

- {if @total == 1, do: "1 category", else: "#{@total} categories"} -

- - - <%!-- Controls Bar --%> -
-
- <%!-- Search --%> -
- -
- -
-
- - <%!-- Status Filter --%> -
- -
- -
-
- - <%!-- Parent Filter --%> -
- -
- -
-
- - <%!-- Add Button --%> -
- - <.link - navigate={Routes.path("/admin/shop/categories/new")} - class="btn btn-primary w-full" - > - <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Category - -
-
-
- - <%!-- Bulk Actions Bar --%> - <%= if MapSet.size(@selected_uuids) > 0 do %> -
-
-
- - {MapSet.size(@selected_uuids)} selected - - -
-
- - - -
-
-
- <% end %> - - <%!-- Categories Table --%> -
-
- - - - - - - - - - - - - - - <%= if Enum.empty?(@categories) do %> - - - - <% else %> - <%= for category <- @categories do %> - <% cat_name = Translations.get(category, :name, @current_language) %> - <% cat_slug = Translations.get(category, :slug, @current_language) %> - - - - - - - - - - - <% end %> - <% end %> - -
- - NameSlugParentStatusPositionProductsActions
- <.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

-
- - -
-
-
- <%= if image_url = Category.get_image_url(category, size: "thumbnail") do %> - {cat_name} - <% else %> - <.icon name="hero-folder" class="w-5 h-5" /> - <% end %> -
-
- {cat_name} -
-
{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")} - - -
-
-
- - <%!-- Pagination --%> - <%= if @total > @per_page do %> -
-
-
- <%= for page <- 1..ceil(@total / @per_page) do %> - - <% end %> -
-
-
- <% end %> -
-
- - <%!-- Bulk Status Change Modal --%> - <%= if @show_bulk_modal == "status" do %> - - <% end %> - - <%!-- Bulk Parent Change Modal --%> - <%= if @show_bulk_modal == "parent" do %> - - <% end %> - - <%!-- Bulk Delete Confirmation Modal --%> - <%= if @show_bulk_modal == "delete" do %> - - <% end %> -
- """ - end -end diff --git a/lib/modules/shop/web/category_form.ex b/lib/modules/shop/web/category_form.ex deleted file mode 100644 index 850ba9874..000000000 --- a/lib/modules/shop/web/category_form.ex +++ /dev/null @@ -1,1060 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.CategoryForm do - @moduledoc """ - Category create/edit form LiveView for Shop module. - - Includes management of category-specific product options. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Category - alias PhoenixKit.Modules.Shop.Options - alias PhoenixKit.Modules.Shop.OptionTypes - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Shop.Web.Components.TranslationTabs - alias PhoenixKit.Modules.Storage.URLSigner - alias PhoenixKit.Utils.Routes - - import TranslationTabs - - @impl true - def mount(_params, _session, socket) do - socket = - socket - |> assign(:page_title, "New Category") - |> assign(:supported_types, OptionTypes.supported_types()) - |> assign(:show_media_selector, false) - |> assign(:image_uuid, nil) - - {:ok, socket} - end - - @impl true - def handle_params(params, _uri, socket) do - socket = apply_action(socket, socket.assigns.live_action, params) - {:noreply, socket} - end - - defp apply_action(socket, :new, _params) do - category = %Category{} - changeset = Shop.change_category(category) - parent_options = Shop.category_options() - global_options = Options.get_enabled_global_options() - - socket - |> assign(:page_title, "New Category") - |> assign(:category, category) - |> assign(:changeset, changeset) - |> assign(:parent_options, parent_options) - |> assign(:category_options, []) - |> assign(:global_options, global_options) - |> assign(:merged_preview, global_options) - |> assign(:show_opt_modal, false) - |> assign(:editing_opt, nil) - |> assign(:opt_form_data, initial_opt_form_data()) - |> assign(:image_uuid, nil) - |> assign(:product_options, []) - |> assign_translation_state(%Category{}) - end - - defp apply_action(socket, :edit, %{"id" => id}) do - category = Shop.get_category!(id) - changeset = Shop.change_category(category) - category_options = Options.get_category_options(category) - global_options = Options.get_enabled_global_options() - merged = Options.merge_schemas(global_options, category_options) - - # Exclude self from parent options - parent_options = - Shop.category_options() - |> Enum.reject(fn {_name, parent_uuid} -> parent_uuid == category.uuid end) - - product_options = Shop.list_category_product_options(category.uuid) - - socket - |> assign( - :page_title, - "Edit #{Translations.get(category, :name, TranslationTabs.get_default_language())}" - ) - |> assign(:category, category) - |> assign(:changeset, changeset) - |> assign(:parent_options, parent_options) - |> assign(:category_options, category_options) - |> assign(:global_options, global_options) - |> assign(:merged_preview, merged) - |> assign(:show_opt_modal, false) - |> assign(:editing_opt, nil) - |> assign(:opt_form_data, initial_opt_form_data()) - |> assign(:image_uuid, category.image_uuid) - |> assign(:product_options, product_options) - |> assign_translation_state(category) - end - - # Assign translation-related state (localized fields model) - defp assign_translation_state(socket, category) do - enabled_languages = TranslationTabs.get_enabled_languages() - default_language = TranslationTabs.get_default_language() - show_translations = TranslationTabs.show_translation_tabs?() - - # Build translations map from localized fields for UI - translatable_fields = Translations.category_fields() - translations_map = TranslationTabs.build_translations_map(category, translatable_fields) - - socket - |> assign(:enabled_languages, enabled_languages) - |> assign(:default_language, default_language) - |> assign(:current_translation_language, default_language) - |> assign(:show_translation_tabs, show_translations) - |> assign(:category_translations, translations_map) - end - - @impl true - def handle_event("validate", %{"category" => category_params}, socket) do - # Update translations from form params - category_translations = - merge_translation_params( - socket.assigns[:category_translations] || %{}, - category_params["translations"] - ) - - # Build localized field attrs from main form values and translations - category_params = - build_localized_params( - socket.assigns.category, - category_params, - category_translations, - socket.assigns.default_language - ) - - changeset = - socket.assigns.category - |> Shop.change_category(category_params) - |> Map.put(:action, :validate) - - socket - |> assign(:changeset, changeset) - |> assign(:category_translations, category_translations) - |> then(&{:noreply, &1}) - end - - @impl true - def handle_event("save", %{"category" => category_params}, socket) do - # Add Storage image_uuid from socket assigns - category_params = Map.put(category_params, "image_uuid", socket.assigns.image_uuid) - - # Build localized field attrs from main form values and translations - category_params = - build_localized_params( - socket.assigns.category, - category_params, - socket.assigns[:category_translations] || %{}, - socket.assigns.default_language - ) - - save_category(socket, socket.assigns.live_action, category_params) - end - - def handle_event("switch_language", %{"language" => language}, socket) do - {:noreply, assign(socket, :current_translation_language, language)} - end - - # Media Picker Events - - @impl true - def handle_event("open_media_picker", _params, socket) do - {:noreply, assign(socket, :show_media_selector, true)} - end - - @impl true - def handle_event("remove_image", _params, socket) do - {:noreply, assign(socket, :image_uuid, nil)} - end - - # Option Modal Events - - @impl true - def handle_event("show_add_opt_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_opt_modal, true) - |> assign(:editing_opt, nil) - |> assign(:opt_form_data, initial_opt_form_data())} - end - - @impl true - def handle_event("show_edit_opt_modal", %{"key" => key}, socket) do - option = Enum.find(socket.assigns.category_options, &(&1["key"] == key)) - - if option do - form_data = %{ - key: option["key"], - label: option["label"], - type: option["type"], - options: option["options"] || [], - required: option["required"] || false, - unit: option["unit"] || "" - } - - {:noreply, - socket - |> assign(:show_opt_modal, true) - |> assign(:editing_opt, option) - |> assign(:opt_form_data, form_data)} - else - {:noreply, socket} - end - end - - @impl true - def handle_event("close_opt_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_opt_modal, false) - |> assign(:editing_opt, nil) - |> assign(:opt_form_data, initial_opt_form_data())} - end - - @impl true - def handle_event("validate_opt_form", %{"option" => params}, socket) do - form_data = %{ - key: params["key"] || "", - label: params["label"] || "", - type: params["type"] || "text", - options: parse_options(params["options"]), - required: params["required"] == "true", - unit: params["unit"] || "" - } - - # Auto-generate key from label if creating new - form_data = - if socket.assigns.editing_opt == nil and form_data.key == "" do - %{form_data | key: slugify_key(form_data.label)} - else - form_data - end - - {:noreply, assign(socket, :opt_form_data, form_data)} - end - - @impl true - def handle_event("save_category_option", %{"option" => params}, socket) do - form_data = parse_opt_form_data(params) - opt = build_option(form_data) - - current = socket.assigns.category_options - editing = socket.assigns.editing_opt - - updated_opts = - if editing do - Enum.map(current, fn o -> - if o["key"] == editing["key"], do: Map.merge(o, opt), else: o - end) - else - opt = Map.put(opt, "position", length(current)) - current ++ [opt] - end - - # Save to category - try do - case Options.update_category_options(socket.assigns.category, updated_opts) do - {:ok, updated_category} -> - merged = Options.merge_schemas(socket.assigns.global_options, updated_opts) - - {:noreply, - socket - |> assign(:category, updated_category) - |> assign(:category_options, updated_opts) - |> assign(:merged_preview, merged) - |> assign(:show_opt_modal, false) - |> assign(:editing_opt, nil) - |> assign(:opt_form_data, initial_opt_form_data()) - |> put_flash(:info, if(editing, do: "Option updated", else: "Option added"))} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Error: #{inspect(reason)}")} - end - rescue - e -> - require Logger - Logger.error("Category option save failed: #{Exception.message(e)}") - {:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")} - end - end - - @impl true - def handle_event("delete_category_option", %{"key" => key}, socket) do - updated_opts = Enum.reject(socket.assigns.category_options, &(&1["key"] == key)) - - case Options.update_category_options(socket.assigns.category, updated_opts) do - {:ok, updated_category} -> - merged = Options.merge_schemas(socket.assigns.global_options, updated_opts) - - {:noreply, - socket - |> assign(:category, updated_category) - |> assign(:category_options, updated_opts) - |> assign(:merged_preview, merged) - |> put_flash(:info, "Option removed")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Error: #{inspect(reason)}")} - end - end - - @impl true - def handle_event("reorder_category_options", %{"ordered_ids" => ordered_keys}, socket) do - current = socket.assigns.category_options - - reordered = - ordered_keys - |> Enum.with_index() - |> Enum.map(fn {key, idx} -> - opt = Enum.find(current, &(&1["key"] == key)) - if opt, do: Map.put(opt, "position", idx), else: nil - end) - |> Enum.reject(&is_nil/1) - - case Options.update_category_options(socket.assigns.category, reordered) do - {:ok, updated_category} -> - merged = Options.merge_schemas(socket.assigns.global_options, reordered) - - {:noreply, - socket - |> assign(:category, updated_category) - |> assign(:category_options, reordered) - |> assign(:merged_preview, merged)} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Reorder failed: #{inspect(reason)}")} - end - end - - @impl true - def handle_event("add_opt_option", _params, socket) do - form_data = socket.assigns.opt_form_data - updated = %{form_data | options: form_data.options ++ [""]} - {:noreply, assign(socket, :opt_form_data, updated)} - end - - @impl true - def handle_event("remove_opt_option", %{"index" => idx}, socket) do - form_data = socket.assigns.opt_form_data - index = String.to_integer(idx) - updated = %{form_data | options: List.delete_at(form_data.options, index)} - {:noreply, assign(socket, :opt_form_data, updated)} - end - - # Media Picker Info Handlers - - @impl true - def handle_info({:media_selected, file_uuids}, socket) do - image_uuid = List.first(file_uuids) - - {:noreply, - socket - |> assign(:image_uuid, image_uuid) - |> assign(:show_media_selector, false)} - end - - @impl true - def handle_info({:media_selector_closed}, socket) do - {:noreply, assign(socket, :show_media_selector, false)} - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header back={Routes.path("/admin/shop/categories")}> -

{@page_title}

-

- {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" - > -
-
-

Basic Information

- -
-
- - - <%= if @changeset.errors[:name] do %> - - <% end %> -
- -
- - -
- -
- - -
- -
- - -
- -
- - - -
- -
- - -
- - <%!-- Category Image Section --%> -
- -
- <%!-- Image Preview --%> - <%= if @image_uuid do %> -
- Category image - -
- <% else %> -
- <.icon name="hero-photo" class="w-8 h-8 opacity-30" /> -
- <% end %> - - <%!-- Select from Storage --%> -
- -
-
-
- - <%!-- Featured Product (fallback image source) --%> - <%= if @live_action == :edit do %> -
- - <%= if @product_options != [] do %> - - <% else %> -
- <.icon name="hero-information-circle" class="w-4 h-4 inline mr-1" /> - No products with images in this category. Add product images to enable this option. -
- <% end %> - -
- <% end %> -
-
-
- - <%!-- Card: Translations (only show when Languages module enabled with 2+ languages) --%> - <%= if @show_translation_tabs do %> -
-
-

Translations

-

- 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 --%> -
- <.translation_fields - language={@current_translation_language} - translations={@category_translations} - is_default_language={@current_translation_language == @default_language} - form_prefix="category" - fields={[ - %{ - key: :name, - label: "Name", - type: :text, - placeholder: "Translated category name" - }, - %{ - key: :slug, - label: "URL Slug", - type: :text, - placeholder: "translated-url-slug", - hint: "SEO-friendly URL for this language" - }, - %{ - key: :description, - label: "Description", - type: :textarea, - placeholder: "Translated description" - } - ]} - /> -
-
-
- <% end %> - - <%!-- Category Options (only in edit mode) --%> - <%= if @live_action == :edit do %> -
-
-
-

- <.icon name="hero-tag" class="w-5 h-5" /> Category Options -

- -
- -

- 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

-
- <% else %> -
- <%= for opt <- @category_options do %> -
-
-
- {opt["label"]} - {opt["type"]} - <%= if opt["required"] do %> - Required - <% end %> -
-
- Key: {opt["key"]} -
-
-
- - -
-
- <% end %> -
- <% end %> - - <%!-- Merged Preview --%> -
-

- <.icon name="hero-eye" class="w-4 h-4 inline" /> Preview: Merged Schema -

-

- Products in this category will show these options: -

-
- <%= for opt <- @merged_preview do %> - - {opt["label"]} - <%= if opt["required"] do %> - * - <% end %> - - <% end %> - <%= if @merged_preview == [] do %> - No options defined - <% end %> -
-

- Blue - = Category specific, Gray - = Global -

-
-
-
- <% end %> - - <%!-- Submit --%> -
- <.link navigate={Routes.path("/admin/shop/categories")} class="btn btn-outline"> - Cancel - - -
- -
- - <%!-- Option Modal --%> - <%= if @show_opt_modal do %> - - <% end %> - - <%!-- Media Selector Modal --%> - <.live_component - module={PhoenixKitWeb.Live.Components.MediaSelectorModal} - id="media-selector-modal" - show={@show_media_selector} - mode={:single} - selected_uuids={if @image_uuid, do: [@image_uuid], else: []} - phoenix_kit_current_user={@phoenix_kit_current_user} - /> -
- """ - end - - # Private action helpers - - defp save_category(socket, :new, category_params) do - case Shop.create_category(category_params) do - {:ok, _category} -> - {:noreply, - socket - |> put_flash(:info, "Category created") - |> push_navigate(to: Routes.path("/admin/shop/categories"))} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :changeset, changeset)} - end - end - - defp save_category(socket, :edit, category_params) do - case Shop.update_category(socket.assigns.category, category_params) do - {:ok, _category} -> - {:noreply, - socket - |> put_flash(:info, "Category updated") - |> push_navigate(to: Routes.path("/admin/shop/categories"))} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :changeset, changeset)} - end - end - - # Private helpers - - defp initial_opt_form_data do - %{ - key: "", - label: "", - type: "text", - options: [], - required: false, - unit: "" - } - end - - defp slugify_key(""), do: "" - - defp slugify_key(text) do - text - |> String.downcase() - |> String.replace(~r/[^a-z0-9\s]/, "") - |> String.replace(~r/\s+/, "_") - |> String.replace(~r/_+/, "_") - |> String.trim("_") - end - - defp parse_opt_form_data(params) do - %{ - key: params["key"] || "", - label: params["label"] || "", - type: params["type"] || "text", - options: parse_options(params["options"]), - required: params["required"] == "true", - unit: params["unit"] || "" - } - end - - defp build_option(form_data) do - key = if form_data.key == "", do: slugify_key(form_data.label), else: form_data.key - - %{ - "key" => key, - "label" => form_data.label, - "type" => form_data.type, - "required" => form_data.required - } - |> maybe_put_options(form_data) - |> maybe_put_unit(form_data) - end - - defp maybe_put_options(opt, %{type: type, options: options}) - when type in ["select", "multiselect"], - do: Map.put(opt, "options", options) - - defp maybe_put_options(opt, _), do: opt - - defp maybe_put_unit(opt, %{unit: ""}), do: opt - defp maybe_put_unit(opt, %{unit: unit}), do: Map.put(opt, "unit", unit) - - defp parse_options(nil), do: [] - - defp parse_options(options) when is_map(options) do - options - |> Enum.sort_by(fn {k, _v} -> String.to_integer(k) end) - |> Enum.map(fn {_k, v} -> v end) - |> Enum.reject(&(&1 == "")) - end - - defp parse_options(options) when is_list(options), do: options - defp parse_options(_), do: [] - - # Get Storage image URL - defp get_storage_image_url(nil, _variant), do: nil - - defp get_storage_image_url(file_uuid, variant) do - URLSigner.signed_url(file_uuid, variant) - rescue - _ -> nil - end - - # Merge translation params from form into existing translations (for UI state during validate) - defp merge_translation_params(existing, nil), do: existing - - defp merge_translation_params(existing, new_params) when is_map(new_params) do - Enum.reduce(new_params, existing, fn {lang, fields}, acc -> - existing_lang = Map.get(acc, lang, %{}) - merged_lang = Map.merge(existing_lang, fields || %{}) - # Remove empty values - cleaned_lang = Enum.reject(merged_lang, fn {_k, v} -> v == "" end) |> Map.new() - if cleaned_lang == %{}, do: Map.delete(acc, lang), else: Map.put(acc, lang, cleaned_lang) - end) - end - - defp merge_translation_params(existing, _), do: existing - - # Build localized field params from main form values and translations - defp build_localized_params(entity, params, translations_map, default_language) do - translatable_fields = Translations.category_fields() - - # Extract main form values for default language - default_values = %{ - "name" => params["name"], - "slug" => params["slug"], - "description" => params["description"] - } - - # Merge translations into localized field maps - localized_attrs = - TranslationTabs.merge_translations_to_attrs( - entity, - translations_map, - default_values, - default_language, - translatable_fields - ) - - # Replace simple field values with localized maps - params - |> Map.put("name", localized_attrs[:name]) - |> Map.put("slug", localized_attrs[:slug]) - |> Map.put("description", localized_attrs[:description]) - end -end diff --git a/lib/modules/shop/web/checkout_complete.ex b/lib/modules/shop/web/checkout_complete.ex deleted file mode 100644 index b4fde6a73..000000000 --- a/lib/modules/shop/web/checkout_complete.ex +++ /dev/null @@ -1,281 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.CheckoutComplete do - @moduledoc """ - Order confirmation page after successful checkout. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Web.Components.ShopLayouts - - import PhoenixKit.Modules.Shop.Web.Helpers, - only: [format_price: 2, profile_display_name: 1, profile_address: 1, get_current_user: 1] - - alias PhoenixKit.Users.Auth - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"uuid" => uuid}, _session, socket) do - user = get_current_user(socket) - - case Billing.get_order_by_uuid(uuid) do - nil -> - {:ok, redirect_with_error(socket, "Order not found")} - - order -> - handle_order_access(socket, order, user) - end - end - - defp handle_order_access(socket, order, user) do - if has_order_access?(order, user) do - {:ok, setup_order_assigns(socket, order)} - else - {:ok, redirect_with_error(socket, "You don't have access to this order")} - end - end - - defp has_order_access?(order, user) do - cond do - # No user_uuid on order - legacy guest order - is_nil(order.user_uuid) -> true - # Logged-in user owns the order - not is_nil(user) and order.user_uuid == user.uuid -> true - # Guest checkout - order belongs to unconfirmed user (allow access to confirmation page) - guest_user_order?(order) -> true - true -> false - end - end - - # Check if order belongs to an unconfirmed guest user - defp guest_user_order?(%{user_uuid: nil}), do: false - - defp guest_user_order?(%{user_uuid: user_uuid}) do - case Auth.get_user(user_uuid) do - %{confirmed_at: nil} -> true - _ -> false - end - end - - defp setup_order_assigns(socket, order) do - currency = Shop.get_default_currency() - billing_profile = get_billing_profile(order) - {is_guest_order, order_email} = check_guest_order(order) - - # Check if user is authenticated - authenticated = not is_nil(socket.assigns[:phoenix_kit_current_user]) - - socket - |> assign(:page_title, "Order Confirmed") - |> assign(:order, order) - |> assign(:currency, currency) - |> assign(:billing_profile, billing_profile) - |> assign(:is_guest_order, is_guest_order) - |> assign(:order_email, order_email) - |> assign(:authenticated, authenticated) - end - - defp get_billing_profile(%{billing_profile_uuid: nil}), do: nil - defp get_billing_profile(%{billing_profile_uuid: uuid}), do: Billing.get_billing_profile(uuid) - - defp check_guest_order(%{user_uuid: nil} = order) do - email = get_in(order.billing_snapshot, ["email"]) - {not is_nil(email), email} - end - - defp check_guest_order(%{user_uuid: user_uuid}) do - case Auth.get_user(user_uuid) do - %{confirmed_at: nil, email: email} -> {true, email} - _ -> {false, nil} - end - end - - defp redirect_with_error(socket, message) do - socket - |> put_flash(:error, message) - |> push_navigate(to: Routes.path("/shop")) - end - - @impl true - def render(assigns) do - ~H""" - -
- <%!-- Success Header --%> -
-
- <.icon name="hero-check-circle" class="w-12 h-12 text-success" /> -
-

Order Confirmed!

-

- Thank you for your order. We've received your order and will process it shortly. -

-
- - <%!-- Guest Order Email Confirmation Reminder --%> - <%= if @is_guest_order do %> -
-
-
- <.icon name="hero-envelope" class="w-8 h-8 text-info flex-shrink-0" /> -
-

Check your inbox

-

- We've sent a confirmation email to {@order_email}. -

-
    -
  1. Open the email titled "Confirm your account"
  2. -
  3. Click the confirmation link inside
  4. -
  5. Your account will be activated and you can track your order
  6. -
-

- Don't see it? Check your spam or junk folder. The email may take a minute to arrive. -

-
-
-
-
- <% end %> - - <%!-- Order Number --%> -
-
-
Order Number
-
{@order.order_number}
- <%= unless @is_guest_order do %> -
- A confirmation email will be sent to your email address. -
- <% end %> -
-
- - <%!-- Order Details --%> -
-
-

Order Details

- - <%!-- Billing Info --%> - <%= if @billing_profile do %> -
-

Billing Information

-
-
{profile_display_name(@billing_profile)}
-
{profile_address(@billing_profile)}
- <%= if @billing_profile.email do %> -
{@billing_profile.email}
- <% end %> -
-
- <% else %> - <%!-- Guest order - show billing snapshot --%> - <%= if @order.billing_snapshot && map_size(@order.billing_snapshot) > 0 do %> -
-

Billing Information

-
-
- {@order.billing_snapshot["first_name"]} {@order.billing_snapshot["last_name"]} -
-
- {[ - @order.billing_snapshot["address_line1"], - @order.billing_snapshot["city"], - @order.billing_snapshot["postal_code"], - @order.billing_snapshot["country"] - ] - |> Enum.filter(&(&1 && &1 != "")) - |> Enum.join(", ")} -
- <%= if @order.billing_snapshot["email"] do %> -
{@order.billing_snapshot["email"]}
- <% end %> -
-
- <% end %> - <% end %> - - <%!-- Items --%> -
-

Items

-
- <%= for item <- @order.line_items || [] do %> -
-
- {item["name"]} - <%= if item["type"] != "shipping" do %> - × {item["quantity"]} - <% end %> -
-
- {format_price_string(item["total"])} -
-
- <% end %> -
-
- - <%!-- Totals --%> -
-
- Subtotal - {format_price(@order.subtotal, @currency)} -
- - <%= if @order.tax_amount && Decimal.compare(@order.tax_amount, Decimal.new("0")) == :gt do %> -
- Tax - {format_price(@order.tax_amount, @currency)} -
- <% end %> - - <%= if @order.discount_amount && Decimal.compare(@order.discount_amount, Decimal.new("0")) == :gt do %> -
- Discount - -{format_price(@order.discount_amount, @currency)} -
- <% end %> - -
- Total - {format_price(@order.total, @currency)} -
-
-
-
- - <%!-- Status --%> -
-
-
-
-

Order Status

-

Your order is being processed

-
-
{@order.status}
-
-
-
- - <%!-- Actions --%> -
- <.link navigate={Routes.path("/shop")} class="btn btn-primary"> - <.icon name="hero-shopping-bag" class="w-5 h-5 mr-2" /> Continue Shopping - - <%= if @authenticated do %> - <.link navigate={Routes.path("/dashboard/orders")} class="btn btn-outline"> - <.icon name="hero-clipboard-document-list" class="w-5 h-5 mr-2" /> My Orders - - <% end %> -
-
-
- """ - end - - # Helpers - - defp format_price_string(nil), do: "-" - defp format_price_string(amount) when is_binary(amount), do: "$#{amount}" - defp format_price_string(amount), do: "$#{amount}" -end diff --git a/lib/modules/shop/web/checkout_page.ex b/lib/modules/shop/web/checkout_page.ex deleted file mode 100644 index 981d9a1c5..000000000 --- a/lib/modules/shop/web/checkout_page.ex +++ /dev/null @@ -1,1197 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.CheckoutPage do - @moduledoc """ - Checkout page LiveView for converting cart to order. - Supports both logged-in users (with billing profiles) and guest checkout. - - Supports real-time cart synchronization across multiple browser tabs - via PubSub subscription. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.CountryData - alias PhoenixKit.Modules.Billing.PaymentOption - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Events - alias PhoenixKit.Modules.Shop.Web.Components.ShopLayouts - - import PhoenixKit.Modules.Shop.Web.Helpers, - only: [ - format_price: 2, - humanize_key: 1, - profile_display_name: 1, - profile_address: 1, - get_current_user: 1 - ] - - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, session, socket) do - user = get_current_user(socket) - session_id = session["shop_session_id"] - user_uuid = if user, do: user.uuid - - case Shop.find_active_cart(user_uuid: user_uuid, session_id: session_id) do - nil -> - {:ok, redirect_to_cart(socket, "Your cart is empty")} - - cart -> - handle_cart_validation(socket, cart, user) - end - end - - defp handle_cart_validation(socket, cart, user) do - cond do - Enum.empty?(cart.items) -> - {:ok, redirect_to_cart(socket, "Your cart is empty")} - - is_nil(cart.shipping_method_uuid) -> - {:ok, redirect_to_cart(socket, "Please select a shipping method")} - - true -> - {:ok, setup_checkout_assigns(socket, cart, user)} - end - end - - defp setup_checkout_assigns(socket, cart, user) do - is_guest = is_nil(user) - authenticated = not is_nil(socket.assigns[:phoenix_kit_current_user]) - - # Subscribe to cart events for real-time sync across tabs - if connected?(socket) do - Events.subscribe_to_cart(cart) - end - - # Load and auto-select payment option - payment_options = Billing.list_active_payment_options() - - {cart, selected_payment_option, needs_payment_selection} = - prepare_payment_options(cart, payment_options) - - # Load billing profiles - billing_profiles = load_billing_profiles(user) - {selected_profile, needs_profile_selection} = select_billing_profile(billing_profiles) - - # Determine if billing is needed and initial step - needs_billing = - payment_option_needs_billing?(selected_payment_option, is_guest, billing_profiles) - - initial_step = - determine_initial_step( - needs_payment_selection, - needs_billing, - is_guest, - billing_profiles, - needs_profile_selection - ) - - build_checkout_socket(socket, %{ - cart: cart, - is_guest: is_guest, - authenticated: authenticated, - payment_options: payment_options, - selected_payment_option: selected_payment_option, - needs_payment_selection: needs_payment_selection, - billing_profiles: billing_profiles, - selected_profile: selected_profile, - needs_profile_selection: needs_profile_selection, - needs_billing: needs_billing, - initial_step: initial_step, - user: user - }) - end - - defp prepare_payment_options(cart, payment_options) do - {selected, needs_selection} = select_payment_option(payment_options, cart) - cart = maybe_auto_select_payment(cart, payment_options) - {cart, selected, needs_selection} - end - - defp maybe_auto_select_payment(cart, payment_options) do - if length(payment_options) == 1 and is_nil(cart.payment_option_uuid) do - case Shop.set_cart_payment_option(cart, hd(payment_options)) do - {:ok, updated_cart} -> updated_cart - _ -> cart - end - else - cart - end - end - - defp determine_initial_step(needs_payment, needs_billing, is_guest, profiles, needs_profile) do - cond do - needs_payment -> :payment - needs_billing and (is_guest or profiles == []) -> :billing - needs_billing and needs_profile -> :billing - true -> :review - end - end - - defp build_checkout_socket(socket, assigns) do - socket - |> assign(:page_title, "Checkout") - |> assign(:cart, assigns.cart) - |> assign(:currency, Shop.get_default_currency()) - |> assign(:is_guest, assigns.is_guest) - |> assign(:authenticated, assigns.authenticated) - |> assign(:payment_options, assigns.payment_options) - |> assign(:selected_payment_option, assigns.selected_payment_option) - |> assign(:needs_payment_selection, assigns.needs_payment_selection) - |> assign(:billing_profiles, assigns.billing_profiles) - |> assign( - :selected_profile_uuid, - if(assigns.selected_profile, do: assigns.selected_profile.uuid) - ) - |> assign(:use_new_profile, assigns.is_guest or assigns.billing_profiles == []) - |> assign(:needs_profile_selection, assigns.needs_profile_selection) - |> assign(:needs_billing, assigns.needs_billing) - |> assign(:billing_data, initial_billing_data(assigns.user, assigns.cart)) - |> assign(:countries, CountryData.list_countries()) - |> assign(:step, assigns.initial_step) - |> assign(:processing, false) - |> assign(:error_message, nil) - |> assign(:email_exists_error, false) - |> assign(:form_errors, %{}) - end - - # Select payment option with smart defaults - defp select_payment_option([], _cart), do: {nil, false} - - defp select_payment_option(options, cart) do - # Check if cart already has a payment option selected - selected = - if cart.payment_option_uuid do - Enum.find(options, &(&1.uuid == cart.payment_option_uuid)) - end - - cond do - # Cart has valid selected option - selected -> {selected, false} - # Only one option available - length(options) == 1 -> {hd(options), false} - # Multiple options - user must choose - true -> {hd(options), true} - end - end - - # Check if billing info is needed for the payment option - defp payment_option_needs_billing?(nil, _is_guest, _profiles), do: true - - defp payment_option_needs_billing?( - %PaymentOption{requires_billing_profile: true}, - _is_guest, - _profiles - ), - do: true - - defp payment_option_needs_billing?( - %PaymentOption{requires_billing_profile: false}, - true, - _profiles - ), - do: true - - defp payment_option_needs_billing?( - %PaymentOption{requires_billing_profile: false}, - false, - _profiles - ), - do: false - - # Select billing profile with smart defaults - defp select_billing_profile([]), do: {nil, false} - - defp select_billing_profile(profiles) do - default = Enum.find(profiles, & &1.is_default) - - cond do - # Has default profile - use it - default -> {default, false} - # Only one profile - auto-select it - length(profiles) == 1 -> {hd(profiles), false} - # Multiple profiles without default - select first, show prompt - true -> {hd(profiles), true} - end - end - - defp load_billing_profiles(nil), do: [] - defp load_billing_profiles(user), do: Billing.list_user_billing_profiles(user.uuid) - - defp initial_billing_data(user, cart) do - %{ - "type" => "individual", - "first_name" => "", - "last_name" => "", - "email" => if(user, do: user.email, else: ""), - "phone" => "", - "address_line1" => "", - "city" => "", - "postal_code" => "", - "country" => cart.shipping_country || "EE" - } - end - - defp profile_to_billing_data(profile, cart) do - %{ - "type" => profile.type || "individual", - "first_name" => profile.first_name || "", - "last_name" => profile.last_name || "", - "email" => profile.email || "", - "phone" => profile.phone || "", - "address_line1" => profile.address_line1 || "", - "city" => profile.city || "", - "postal_code" => profile.postal_code || "", - "country" => profile.country || cart.shipping_country || "EE" - } - end - - defp redirect_to_cart(socket, message) do - socket - |> put_flash(:error, message) - |> push_navigate(to: Routes.path("/cart")) - end - - @impl true - def handle_event("select_payment_option", %{"option_uuid" => option_uuid}, socket) do - option = Enum.find(socket.assigns.payment_options, &(&1.uuid == option_uuid)) - - if option do - case Shop.set_cart_payment_option(socket.assigns.cart, option) do - {:ok, updated_cart} -> - # Update needs_billing based on new payment option - needs_billing = - payment_option_needs_billing?( - option, - socket.assigns.is_guest, - socket.assigns.billing_profiles - ) - - {:noreply, - socket - |> assign(:cart, updated_cart) - |> assign(:selected_payment_option, option) - |> assign(:needs_billing, needs_billing)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to set payment option")} - end - else - {:noreply, socket} - end - end - - @impl true - def handle_event("proceed_to_billing", _params, socket) do - if socket.assigns.needs_billing do - {:noreply, assign(socket, :step, :billing)} - else - {:noreply, assign(socket, :step, :review)} - end - end - - @impl true - def handle_event("back_to_payment", _params, socket) do - {:noreply, assign(socket, :step, :payment)} - end - - @impl true - def handle_event("select_profile", %{"profile_uuid" => profile_uuid}, socket) do - {:noreply, - socket - |> assign(:selected_profile_uuid, profile_uuid) - |> assign(:use_new_profile, false)} - end - - @impl true - def handle_event("use_new_profile", _params, socket) do - # Pre-fill form from selected profile if available - billing_data = - case Enum.find( - socket.assigns.billing_profiles, - &(to_string(&1.uuid) == to_string(socket.assigns.selected_profile_uuid)) - ) do - nil -> socket.assigns.billing_data - profile -> profile_to_billing_data(profile, socket.assigns.cart) - end - - {:noreply, - socket - |> assign(:use_new_profile, true) - |> assign(:billing_data, billing_data) - |> assign(:selected_profile_uuid, nil)} - end - - @impl true - def handle_event("use_existing_profile", _params, socket) do - default_profile = Enum.find(socket.assigns.billing_profiles, & &1.is_default) - first_profile = List.first(socket.assigns.billing_profiles) - profile = default_profile || first_profile - - {:noreply, - socket - |> assign(:use_new_profile, false) - |> assign(:selected_profile_uuid, if(profile, do: profile.uuid))} - end - - @impl true - def handle_event("update_billing", %{"billing" => params}, socket) do - billing_data = Map.merge(socket.assigns.billing_data, params) - {:noreply, assign(socket, :billing_data, billing_data)} - end - - @impl true - def handle_event("proceed_to_review", _params, socket) do - if socket.assigns.use_new_profile do - # Validate billing data - errors = validate_billing_data(socket.assigns.billing_data) - - if Enum.empty?(errors) do - {:noreply, assign(socket, step: :review, form_errors: %{})} - else - {:noreply, - socket - |> assign(:form_errors, errors) - |> put_flash(:error, "Please fill in all required fields")} - end - else - if is_nil(socket.assigns.selected_profile_uuid) do - {:noreply, put_flash(socket, :error, "Please select a billing profile")} - else - {:noreply, assign(socket, :step, :review)} - end - end - end - - @impl true - def handle_event("back_to_billing", _params, socket) do - {:noreply, assign(socket, :step, :billing)} - end - - @impl true - def handle_event("confirm_order", _params, socket) do - socket = assign(socket, :processing, true) - - cart = socket.assigns.cart - - # Get user identifier from current scope if logged in - user_uuid = - case socket.assigns[:phoenix_kit_current_scope] do - %{user: %{uuid: uuid}} -> uuid - _ -> nil - end - - # Build options for convert_cart_to_order - opts = - if socket.assigns.use_new_profile do - # Guest or new profile - use billing_data directly - [billing_data: socket.assigns.billing_data, user_uuid: user_uuid] - else - # Logged-in user with existing profile - [billing_profile_uuid: socket.assigns.selected_profile_uuid, user_uuid: user_uuid] - end - - case Shop.convert_cart_to_order(cart, opts) do - {:ok, order} -> - {:noreply, - socket - |> assign(:processing, false) - |> push_navigate(to: Routes.path("/checkout/complete/#{order.uuid}"))} - - {:error, :cart_not_active} -> - {:noreply, - socket - |> assign(:processing, false) - |> assign(:error_message, "Cart is no longer active") - |> put_flash(:error, "Cart is no longer active")} - - {:error, :cart_empty} -> - {:noreply, - socket - |> assign(:processing, false) - |> push_navigate(to: Routes.path("/cart"))} - - {:error, :no_shipping_method} -> - {:noreply, - socket - |> assign(:processing, false) - |> put_flash(:error, "Please select a shipping method") - |> push_navigate(to: Routes.path("/cart"))} - - {:error, :email_already_registered} -> - {:noreply, - socket - |> assign(:processing, false) - |> assign(:email_exists_error, true) - |> assign(:error_message, nil)} - - {:error, _reason} -> - {:noreply, - socket - |> assign(:processing, false) - |> assign(:error_message, "Failed to create order. Please try again.") - |> put_flash(:error, "Failed to create order")} - end - end - - defp validate_billing_data(data) do - errors = %{} - - errors = - if blank?(data["first_name"]), - do: Map.put(errors, :first_name, "is required"), - else: errors - - errors = - if blank?(data["last_name"]), - do: Map.put(errors, :last_name, "is required"), - else: errors - - errors = - if blank?(data["email"]), - do: Map.put(errors, :email, "is required"), - else: errors - - errors = - if blank?(data["address_line1"]), - do: Map.put(errors, :address_line1, "is required"), - else: errors - - errors = - if blank?(data["city"]), do: Map.put(errors, :city, "is required"), else: errors - - errors = - if blank?(data["country"]), - do: Map.put(errors, :country, "is required"), - else: errors - - errors - end - - defp blank?(nil), do: true - defp blank?(""), do: true - defp blank?(str) when is_binary(str), do: String.trim(str) == "" - defp blank?(_), do: false - - # ============================================ - # PUBSUB EVENT HANDLERS - # ============================================ - - @impl true - def handle_info({:cart_updated, cart}, socket) do - {:noreply, assign(socket, :cart, cart)} - end - - @impl true - def handle_info({:item_added, cart, _item}, socket) do - {:noreply, assign(socket, :cart, cart)} - end - - @impl true - def handle_info({:item_removed, cart, _item_id}, socket) do - # If cart becomes empty, redirect to cart page - if Enum.empty?(cart.items) do - {:noreply, redirect_to_cart(socket, "Your cart is empty")} - else - {:noreply, assign(socket, :cart, cart)} - end - end - - @impl true - def handle_info({:quantity_updated, cart, _item}, socket) do - {:noreply, assign(socket, :cart, cart)} - end - - @impl true - def handle_info({:shipping_selected, cart}, socket) do - {:noreply, assign(socket, :cart, cart)} - end - - @impl true - def handle_info({:payment_selected, cart}, socket) do - # Also update selected_payment_option if it changed - selected = Enum.find(socket.assigns.payment_options, &(&1.uuid == cart.payment_option_uuid)) - - {:noreply, - socket - |> assign(:cart, cart) - |> assign(:selected_payment_option, selected)} - end - - @impl true - def handle_info({:cart_cleared, _cart}, socket) do - # Cart was cleared, redirect to cart page - {:noreply, redirect_to_cart(socket, "Your cart is empty")} - end - - @impl true - def render(assigns) do - ~H""" - -
-
-

Checkout

- <.link navigate={Routes.path("/cart")} class="btn btn-ghost btn-sm"> - <.icon name="hero-arrow-left" class="w-4 h-4" /> - -
- - <%!-- Steps Indicator --%> -
- <%= if length(@payment_options) > 1 do %> -
- Payment -
- <% end %> - <%= if @needs_billing do %> -
Billing
- <% end %> -
Review & Confirm
-
- - <%!-- Guest Checkout Info --%> - <%= if @is_guest do %> -
- <.icon name="hero-envelope" class="w-5 h-5" /> -
-
Checking out as a guest
-
- After placing your order, we'll send a confirmation email to verify your address. - Check your inbox and click the link to activate your account and track your order. -
-
-
- <% end %> - -
- <%!-- Main Content --%> -
- <%= case @step do %> - <% :payment -> %> - <.payment_step - payment_options={@payment_options} - selected_payment_option={@selected_payment_option} - needs_billing={@needs_billing} - /> - <% :billing -> %> - <.billing_step - is_guest={@is_guest} - billing_profiles={@billing_profiles} - selected_profile_uuid={@selected_profile_uuid} - use_new_profile={@use_new_profile} - needs_profile_selection={@needs_profile_selection} - billing_data={@billing_data} - form_errors={@form_errors} - countries={@countries} - payment_options={@payment_options} - /> - <% :review -> %> - <.review_step - cart={@cart} - is_guest={@is_guest} - billing_profiles={@billing_profiles} - selected_profile_uuid={@selected_profile_uuid} - use_new_profile={@use_new_profile} - billing_data={@billing_data} - currency={@currency} - processing={@processing} - error_message={@error_message} - email_exists_error={@email_exists_error} - selected_payment_option={@selected_payment_option} - needs_billing={@needs_billing} - payment_options={@payment_options} - /> - <% end %> -
- - <%!-- Order Summary Sidebar --%> -
- <.order_summary cart={@cart} currency={@currency} /> -
-
-
-
- """ - end - - # Components - - defp payment_step(assigns) do - ~H""" -
-
-

Select Payment Method

- -
- <%= for option <- @payment_options do %> - - <% end %> -
- -
- -
-
-
- """ - end - - defp billing_step(assigns) do - ~H""" -
-
-

- <%= if @is_guest or @billing_profiles == [] do %> - Billing Information - <% else %> - Select Billing Profile - <% end %> -

- - <%= if @use_new_profile do %> - <%!-- Guest checkout or no profiles - show billing form --%> - <.billing_form - billing_data={@billing_data} - form_errors={@form_errors} - countries={@countries} - /> - <% else %> - <%!-- Authenticated user with multiple profiles - show selector --%> - <.profile_selector - billing_profiles={@billing_profiles} - selected_profile_uuid={@selected_profile_uuid} - needs_profile_selection={@needs_profile_selection} - /> - <% end %> - -
- <%= if length(@payment_options) > 1 do %> - - <% else %> -
- <% end %> - -
-
-
- """ - end - - defp profile_selector(assigns) do - ~H""" -
- <%!-- Show info alert when multiple profiles exist without a default --%> - <%= if @needs_profile_selection do %> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> - - You have multiple billing profiles. Please select one or <.link - navigate={Routes.path("/dashboard/billing-profiles")} - class="link" - > - set a default in your account settings - . - -
- <% end %> - - <%= for profile <- @billing_profiles do %> -
- - <%!-- Edit button for selected profile --%> - <%= if to_string(@selected_profile_uuid) == to_string(profile.uuid) do %> - <.link - navigate={ - Routes.path("/dashboard/billing-profiles/#{profile.uuid}/edit?return_to=/checkout") - } - class="btn btn-ghost btn-sm" - > - <.icon name="hero-pencil" class="w-4 h-4" /> - - <% end %> -
- <% end %> -
- """ - end - - defp billing_form(assigns) do - ~H""" -
-
-
- First Name * - - <%= if @form_errors[:first_name] do %> -

{@form_errors[:first_name]}

- <% end %> -
- -
- Last Name * - - <%= if @form_errors[:last_name] do %> -

{@form_errors[:last_name]}

- <% end %> -
-
- -
-
- Email * - - <%= if @form_errors[:email] do %> -

{@form_errors[:email]}

- <% end %> -
- -
- Phone - -
-
- -
- Address * - - <%= if @form_errors[:address_line1] do %> -

{@form_errors[:address_line1]}

- <% end %> -
- -
-
- City * - - <%= if @form_errors[:city] do %> -

{@form_errors[:city]}

- <% end %> -
- -
- Postal Code - -
- -
- Country * - - <%= if @form_errors[:country] do %> -

{@form_errors[:country]}

- <% end %> -
-
-
- """ - end - - defp review_step(assigns) do - selected_profile = - if assigns.use_new_profile do - nil - else - Enum.find( - assigns.billing_profiles, - &(to_string(&1.uuid) == to_string(assigns.selected_profile_uuid)) - ) - end - - assigns = assign(assigns, :selected_profile, selected_profile) - - ~H""" -
- <%!-- Payment Method --%> -
-
-
-

Payment Method

- <%= if length(@payment_options) > 1 do %> - - <% end %> -
- - <%= if @selected_payment_option do %> -
- <.icon - name={PaymentOption.icon_name(@selected_payment_option)} - class="w-6 h-6 text-base-content/70" - /> -
-
{@selected_payment_option.name}
- <%= if @selected_payment_option.description do %> -
- {@selected_payment_option.description} -
- <% end %> -
-
- <% end %> -
-
- - <%!-- Billing Info (only if billing is needed) --%> - <%= if @needs_billing do %> -
-
-
-

Billing Information

- -
- -
- <%= if @use_new_profile do %> -
- {@billing_data["first_name"]} {@billing_data["last_name"]} -
-
- {[ - @billing_data["address_line1"], - @billing_data["city"], - @billing_data["postal_code"], - @billing_data["country"] - ] - |> Enum.filter(&(&1 && &1 != "")) - |> Enum.join(", ")} -
-
{@billing_data["email"]}
- <%= if @billing_data["phone"] && @billing_data["phone"] != "" do %> -
{@billing_data["phone"]}
- <% end %> - <% else %> - <%= if @selected_profile do %> -
{profile_display_name(@selected_profile)}
-
{profile_address(@selected_profile)}
- <%= if @selected_profile.email do %> -
{@selected_profile.email}
- <% end %> - <%= if @selected_profile.phone do %> -
{@selected_profile.phone}
- <% end %> - <% end %> - <% end %> -
-
-
- <% end %> - - <%!-- Shipping Info --%> -
-
-
-

Shipping Method

- <.link navigate={Routes.path("/cart")} class="btn btn-ghost btn-sm"> - <.icon name="hero-pencil" class="w-4 h-4 mr-1" /> Change - -
- - <%= if @cart.shipping_method do %> -
-
-
{@cart.shipping_method.name}
- <%= if @cart.shipping_method.description do %> -
{@cart.shipping_method.description}
- <% end %> -
-
- <%= if Decimal.compare(@cart.shipping_amount || Decimal.new("0"), Decimal.new("0")) == :eq do %> - FREE - <% else %> - {format_price(@cart.shipping_amount, @currency)} - <% end %> -
-
- <% end %> -
-
- - <%!-- Order Items --%> -
-
-
-

Order Items

- <.link navigate={Routes.path("/cart")} class="btn btn-ghost btn-sm"> - <.icon name="hero-pencil" class="w-4 h-4 mr-1" /> Edit Cart - -
- -
- <%= for item <- @cart.items do %> -
- <%= if item.product_image do %> -
- {item.product_title} -
- <% else %> -
- <.icon name="hero-cube" class="w-8 h-8 opacity-30" /> -
- <% end %> -
-
{item.product_title}
- <%= if item.selected_specs && item.selected_specs != %{} do %> -
- <%= for {key, value} <- item.selected_specs do %> - - {humanize_key(key)}: - {value} - - <% end %> -
- <% end %> -
- Qty: {item.quantity} × {format_price(item.unit_price, @currency)} -
-
-
- {format_price(item.line_total, @currency)} -
-
- <% end %> -
-
-
- - <%!-- Email Already Registered --%> - <%= if @email_exists_error do %> -
-
-
- <.icon name="hero-user-circle" class="w-8 h-8 text-warning flex-shrink-0" /> -
-

Account already exists

-

- An account with this email is already registered. - Please log in to complete your order. -

-
- <.link - navigate={Routes.path("/users/log-in") <> "?return_to=" <> Routes.path("/checkout")} - class="btn btn-primary btn-sm" - > - <.icon name="hero-arrow-right-on-rectangle" class="w-4 h-4 mr-1" /> - Log in to continue - -
-
-
-
-
- <% end %> - - <%!-- Error Message --%> - <%= if @error_message do %> -
- <.icon name="hero-exclamation-circle" class="w-5 h-5" /> - {@error_message} -
- <% end %> - - <%!-- Confirm Button --%> -
- <%= cond do %> - <% @needs_billing -> %> - - <% length(@payment_options) > 1 -> %> - - <% true -> %> -
- <% end %> - -
-
- """ - end - - defp order_summary(assigns) do - ~H""" -
-
-

Order Summary

- -
-
- - Subtotal ({@cart.items_count || 0} items) - - {format_price(@cart.subtotal, @currency)} -
- -
- Shipping - <%= if is_nil(@cart.shipping_method_uuid) do %> - - - <% else %> - <%= if Decimal.compare(@cart.shipping_amount || Decimal.new("0"), Decimal.new("0")) == :eq do %> - FREE - <% else %> - {format_price(@cart.shipping_amount, @currency)} - <% end %> - <% end %> -
- - <%= if @cart.tax_amount && Decimal.compare(@cart.tax_amount, Decimal.new("0")) == :gt do %> -
- Tax - {format_price(@cart.tax_amount, @currency)} -
- <% end %> - - <%= if @cart.discount_amount && Decimal.compare(@cart.discount_amount, Decimal.new("0")) == :gt do %> -
- Discount - -{format_price(@cart.discount_amount, @currency)} -
- <% end %> - -
- -
- Total - {format_price(@cart.total, @currency)} -
-
-
-
- """ - end -end diff --git a/lib/modules/shop/web/components/catalog_sidebar.ex b/lib/modules/shop/web/components/catalog_sidebar.ex deleted file mode 100644 index 1d5a16716..000000000 --- a/lib/modules/shop/web/components/catalog_sidebar.ex +++ /dev/null @@ -1,377 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Components.CatalogSidebar do - @moduledoc """ - Reusable sidebar component for the shop storefront. - - Renders collapsible filter sections and category tree navigation. - Uses native HTML `
/` for collapse behavior. - """ - - use Phoenix.Component - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Category - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Shop.Web.Components.FilterHelpers - alias PhoenixKitWeb.Components.Core.Icon - - @doc """ - Renders the full catalog sidebar with filters and category tree. - - ## Attributes - - `filters` - List of enabled filter definitions - - `filter_values` - Map of aggregated values per filter key - - `active_filters` - Map of currently active filter selections - - `categories` - List of active categories for navigation - - `current_category` - Currently selected category (or nil) - - `current_language` - Current language code - - `category_icon_mode` - Icon mode setting - - `category_name_wrap` - Whether to wrap category names - - `show_categories` - Whether to show category tree (default: true) - - `show_filters` - Whether to show filter sections (default: true) - """ - attr :filters, :list, required: true - attr :filter_values, :map, required: true - attr :active_filters, :map, required: true - attr :categories, :list, default: [] - attr :current_category, :any, default: nil - attr :current_language, :string, default: "en" - attr :category_icon_mode, :string, default: "none" - attr :category_name_wrap, :boolean, default: false - attr :show_categories, :boolean, default: true - attr :show_filters, :boolean, default: true - attr :filter_qs, :string, default: "" - - def catalog_sidebar(assigns) do - assigns = - assigns - |> assign(:has_active, FilterHelpers.has_active_filters?(assigns.active_filters)) - |> assign(:categories_open, true) - - ~H""" -
- <%!-- FILTERS (price, vendor, metadata) --%> - <%= if @show_filters do %> - <%!-- Active filters summary + clear button --%> - <%= if @has_active do %> -
- -
- <% end %> - - <%!-- Filter sections --%> - <%= for filter <- @filters do %> - <.filter_section - filter={filter} - values={Map.get(@filter_values, filter["key"], %{})} - active={Map.get(@active_filters, filter["key"])} - /> - <% end %> - <% end %> - - <%!-- CATEGORY NAVIGATION (separate from filters) --%> - <%= if @show_categories && @categories != [] do %> -
- - <.icon - name="hero-chevron-right" - class="w-3 h-3 transition-transform group-open:rotate-90" - /> Categories {length(@categories)} - -
- -
-
- <% end %> -
- """ - end - - @doc """ - Renders only the category navigation tree (no filters). - - Lightweight component for pages where filters don't apply (e.g. product detail). - - ## Attributes - - `categories` - List of active categories for navigation - - `current_category` - Currently selected category (or nil) - - `current_language` - Current language code - - `category_icon_mode` - Icon mode setting - - `category_name_wrap` - Whether to wrap category names - - `open` - Whether the details element is open (default: true) - """ - attr :categories, :list, required: true - attr :current_category, :any, default: nil - attr :current_language, :string, default: "en" - attr :category_icon_mode, :string, default: "none" - attr :category_name_wrap, :boolean, default: false - attr :open, :boolean, default: true - attr :filter_qs, :string, default: "" - - def category_nav(assigns) do - ~H""" - <%= if @categories != [] do %> -
- - <.icon - name="hero-chevron-right" - class="w-3 h-3 transition-transform group-open:rotate-90" - /> Categories {length(@categories)} - -
- -
-
- <% end %> - """ - end - - @doc """ - Renders a single filter section. - - Dispatches to the correct sub-component based on filter type. - """ - attr :filter, :map, required: true - attr :values, :any, required: true - attr :active, :any, default: nil - - def filter_section(%{filter: %{"type" => "price_range"}} = assigns) do - min_val = if assigns.active, do: assigns.active[:min] - max_val = if assigns.active, do: assigns.active[:max] - range = assigns.values - - assigns = - assigns - |> assign(:min_val, min_val) - |> assign(:max_val, max_val) - |> assign(:range_min, range[:min]) - |> assign(:range_max, range[:max]) - - ~H""" -
- - <.icon name="hero-chevron-right" class="w-3 h-3 transition-transform group-open:rotate-90" /> - {@filter["label"]} - -
-
- -
- Decimal.to_string(), else: "Min" - } - class="input input-sm w-full" - min="0" - step="any" - /> - - Decimal.to_string(), else: "Max" - } - class="input input-sm w-full" - min="0" - step="any" - /> -
- <%= if @range_min && @range_max do %> -

- Range: {Decimal.round(@range_min, 2) |> Decimal.to_string()} – {Decimal.round( - @range_max, - 2 - ) - |> Decimal.to_string()} -

- <% end %> - -
-
-
- """ - end - - def filter_section(%{filter: %{"type" => type}} = assigns) - when type in ["vendor", "metadata_option"] do - values = if is_list(assigns.values), do: assigns.values, else: [] - active_list = assigns.active || [] - assigns = assign(assigns, values: values, active_list: active_list) - - ~H""" -
- - <.icon name="hero-chevron-right" class="w-3 h-3 transition-transform group-open:rotate-90" /> - {@filter["label"]} - <%= if @active_list != [] do %> - {length(@active_list)} - <% end %> - -
- <%= if @values == [] do %> -

No options available

- <% else %> - <%= for item <- @values do %> - - <% end %> - <% end %> -
-
- """ - end - - def filter_section(assigns) do - ~H""" - """ - end - - @doc """ - Renders a compact filter list for the dashboard sidebar. - - Simplified version without category tree, designed to fit - below the dashboard tab navigation. - """ - attr :filters, :list, required: true - attr :filter_values, :map, required: true - attr :active_filters, :map, required: true - - def dashboard_filters(assigns) do - assigns = - assign(assigns, :has_active, FilterHelpers.has_active_filters?(assigns.active_filters)) - - ~H""" -
- <%= if @has_active do %> - - <% end %> - - <%= for filter <- @filters do %> - <.filter_section - filter={filter} - values={Map.get(@filter_values, filter["key"], %{})} - active={Map.get(@active_filters, filter["key"])} - /> - <% end %> -
- """ - end - - # Category icon component for sidebar - attr :mode, :string, required: true - attr :category, :any, required: true - - def sidebar_cat_icon(%{mode: "folder"} = assigns) do - ~H""" - <.icon name="hero-folder" class="w-4 h-4 shrink-0" /> - """ - end - - def sidebar_cat_icon(%{mode: "category"} = assigns) do - image_url = Category.get_image_url(assigns.category, size: "thumbnail") - assigns = assign(assigns, :image_url, image_url) - - ~H""" - <%= if @image_url do %> - - <% end %> - """ - end - - def sidebar_cat_icon(assigns) do - ~H""" - """ - end - - defp icon(assigns) do - Icon.icon(assigns) - end -end diff --git a/lib/modules/shop/web/components/filter_helpers.ex b/lib/modules/shop/web/components/filter_helpers.ex deleted file mode 100644 index 2a8b79712..000000000 --- a/lib/modules/shop/web/components/filter_helpers.ex +++ /dev/null @@ -1,238 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Components.FilterHelpers do - @moduledoc """ - Shared helpers for storefront filter state management. - - Used by both ShopCatalog and CatalogCategory LiveViews to: - - Load enabled filters and aggregate values - - Parse filter params from URL query string - - Build query opts for product listing - - Build URLs with filter params - """ - - alias PhoenixKit.Modules.Shop - - @doc """ - Loads enabled filters and their aggregated values. - - Returns `{filters, filter_values}` tuple. - - Options: - - `:category_uuid` - Scope aggregation to a category by UUID - """ - def load_filter_data(opts \\ []) do - filters = Shop.get_enabled_storefront_filters() - filter_values = Shop.aggregate_filter_values(opts) - {filters, filter_values} - end - - @doc """ - Parses URL query params into active filter state. - - Returns a map: `%{"price" => %{min: Decimal, max: Decimal}, "vendor" => ["V1", "V2"], ...}` - """ - def parse_filter_params(params, filters) do - Enum.reduce(filters, %{}, fn filter, acc -> - case parse_single_filter(filter, params) do - nil -> acc - value -> Map.put(acc, filter["key"], value) - end - end) - end - - defp parse_single_filter(%{"type" => "price_range", "key" => key}, params) do - min_val = parse_decimal(params["#{key}_min"]) - max_val = parse_decimal(params["#{key}_max"]) - - if min_val || max_val do - %{min: min_val, max: max_val} - else - nil - end - end - - defp parse_single_filter(%{"type" => type, "key" => key}, params) - when type in ["vendor", "metadata_option"] do - case params[key] do - nil -> nil - "" -> nil - value when is_binary(value) -> String.split(value, ",", trim: true) - values when is_list(values) -> values - end - end - - defp parse_single_filter(_filter, _params), do: nil - - @doc """ - Converts active filter state into keyword opts for `Shop.list_products_with_count/1`. - """ - def build_query_opts(active_filters, filters) do - Enum.reduce(filters, [], fn filter, opts -> - case Map.get(active_filters, filter["key"]) do - nil -> - opts - - %{min: min_val, max: max_val} -> - opts - |> maybe_add_opt(:price_min, min_val) - |> maybe_add_opt(:price_max, max_val) - - values when is_list(values) and values != [] -> - case filter["type"] do - "vendor" -> - Keyword.put(opts, :vendors, values) - - "metadata_option" -> - existing = Keyword.get(opts, :metadata_filters, []) - meta = %{key: filter["option_key"] || filter["key"], values: values} - Keyword.put(opts, :metadata_filters, existing ++ [meta]) - - _ -> - opts - end - - _ -> - opts - end - end) - end - - @doc """ - Builds a query string from active filter state (e.g. `"?price_min=10&price_max=100"` or `""`). - - Used to append filter params to navigation links so filter state persists - across page transitions. - """ - def build_query_string(active_filters, filters) do - params = build_params_map(active_filters, filters) - if params == %{}, do: "", else: "?" <> URI.encode_query(params) - end - - @doc """ - Builds a URL path with filter query params. - - Merges filter state into a clean query string, preserving page param only - when `keep_page` is true. - """ - def build_filter_url(base_path, active_filters, filters, opts \\ []) do - page = Keyword.get(opts, :page) - params = build_params_map(active_filters, filters) - params = if page && page > 1, do: Map.put(params, "page", page), else: params - - if params == %{} do - base_path - else - query = URI.encode_query(params) - "#{base_path}?#{query}" - end - end - - defp build_params_map(active_filters, filters) do - Enum.reduce(filters, %{}, fn filter, acc -> - case Map.get(active_filters, filter["key"]) do - nil -> - acc - - %{min: min_val, max: max_val} -> - acc - |> maybe_put_param("#{filter["key"]}_min", min_val) - |> maybe_put_param("#{filter["key"]}_max", max_val) - - values when is_list(values) and values != [] -> - Map.put(acc, filter["key"], Enum.join(values, ",")) - - _ -> - acc - end - end) - end - - @doc """ - Returns true if any filters are currently active. - """ - def has_active_filters?(active_filters) do - active_filters != %{} and - Enum.any?(active_filters, fn - {_key, %{min: nil, max: nil}} -> false - {_key, []} -> false - {_key, nil} -> false - _ -> true - end) - end - - @doc """ - Counts the number of active filter values (for mobile badge). - """ - def active_filter_count(active_filters) do - Enum.reduce(active_filters, 0, fn - {_key, %{min: min_val, max: max_val}}, count -> - count + if(min_val, do: 1, else: 0) + if max_val, do: 1, else: 0 - - {_key, values}, count when is_list(values) -> - count + length(values) - - _, count -> - count - end) - end - - @doc """ - Toggles a value in a checkbox-type filter. - Returns updated active_filters map. - """ - def toggle_filter_value(active_filters, filter_key, value) do - current = Map.get(active_filters, filter_key, []) - - updated = - if value in current do - List.delete(current, value) - else - current ++ [value] - end - - if updated == [] do - Map.delete(active_filters, filter_key) - else - Map.put(active_filters, filter_key, updated) - end - end - - @doc """ - Updates price range filter. - Returns updated active_filters map. - """ - def update_price_filter(active_filters, filter_key, min_val, max_val) do - min_dec = parse_decimal(min_val) - max_dec = parse_decimal(max_val) - - if min_dec || max_dec do - Map.put(active_filters, filter_key, %{min: min_dec, max: max_dec}) - else - Map.delete(active_filters, filter_key) - end - end - - defp parse_decimal(nil), do: nil - defp parse_decimal(""), do: nil - - defp parse_decimal(val) when is_binary(val) do - case Decimal.parse(val) do - {decimal, ""} -> decimal - {decimal, _} -> decimal - :error -> nil - end - end - - defp parse_decimal(val) when is_number(val), do: Decimal.new(val) - defp parse_decimal(%Decimal{} = val), do: val - - defp maybe_add_opt(opts, _key, nil), do: opts - defp maybe_add_opt(opts, key, val), do: Keyword.put(opts, key, val) - - defp maybe_put_param(params, _key, nil), do: params - - defp maybe_put_param(params, key, %Decimal{} = val) do - Map.put(params, key, Decimal.to_string(val)) - end - - defp maybe_put_param(params, key, val), do: Map.put(params, key, to_string(val)) -end diff --git a/lib/modules/shop/web/components/shop_cards.ex b/lib/modules/shop/web/components/shop_cards.ex deleted file mode 100644 index 60b65fe9a..000000000 --- a/lib/modules/shop/web/components/shop_cards.ex +++ /dev/null @@ -1,143 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Components.ShopCards do - @moduledoc """ - Reusable product display components for the shop storefront. - - Provides product card and pagination components shared between - the main catalog page and category pages. - """ - - use Phoenix.Component - - import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1] - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Shop.Web.Components.FilterHelpers - alias PhoenixKit.Modules.Shop.Web.Helpers - - @doc """ - Renders a product card with image, title, price, and optional category badge. - """ - attr :product, :map, required: true - attr :currency, :any, required: true - attr :language, :string, default: "en" - attr :filter_qs, :string, default: "" - attr :show_category, :boolean, default: false - - def product_card(assigns) do - assigns = - assigns - |> assign(:product_title, Translations.get(assigns.product, :title, assigns.language)) - |> assign(:product_url, Shop.product_url(assigns.product, assigns.language)) - |> assign(:product_image_url, Helpers.first_image(assigns.product)) - |> assign( - :category_name, - if(assigns.show_category && assigns.product.category, - do: Translations.get(assigns.product.category, :name, assigns.language), - else: nil - ) - ) - - ~H""" - <.link - navigate={@product_url <> @filter_qs} - class="card bg-base-100 shadow-md hover:shadow-xl transition-all hover:-translate-y-1" - > -
- <%= if @product_image_url do %> - {@product_title} - <% else %> -
- <.icon name="hero-cube" class="w-16 h-16 opacity-30" /> -
- <% end %> -
-
-

{@product_title}

- -
- - {Helpers.format_price(@product.price, @currency)} - - <%= if @product.compare_at_price && Decimal.compare(@product.compare_at_price, @product.price) == :gt do %> - - {Helpers.format_price(@product.compare_at_price, @currency)} - - <% end %> -
- - <%= if @category_name do %> -
- {@category_name} -
- <% end %> -
- - """ - end - - @doc """ - Renders a "load more" button + page links for product grids. - """ - attr :page, :integer, required: true - attr :total_pages, :integer, required: true - attr :total_products, :integer, required: true - attr :per_page, :integer, required: true - attr :base_path, :string, required: true - attr :active_filters, :map, default: %{} - attr :enabled_filters, :list, default: [] - - def shop_pagination(assigns) do - remaining = assigns.total_products - assigns.page * assigns.per_page - - assigns = - assigns - |> assign(:remaining, max(0, remaining)) - |> assign(:has_more, assigns.page < assigns.total_pages) - - ~H""" - <%= if @total_pages > 1 do %> -
- <%!-- Load More Button --%> - <%= if @has_more do %> -
- -
- <% end %> - - <%!-- Page Links for SEO and direct access --%> - - - <%!-- Status text --%> -

- Showing {min(@page * @per_page, @total_products)} of {@total_products} products -

-
- <% end %> - """ - end -end diff --git a/lib/modules/shop/web/components/shop_layouts.ex b/lib/modules/shop/web/components/shop_layouts.ex deleted file mode 100644 index 3e5357744..000000000 --- a/lib/modules/shop/web/components/shop_layouts.ex +++ /dev/null @@ -1,120 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Components.ShopLayouts do - @moduledoc """ - Shared layout components for the shop storefront public pages. - - Provides two components: - - `shop_public_layout/1` - Public navbar + flash + main content wrapper for guest users - - `shop_layout/1` - Top-level layout dispatcher: dashboard for authenticated, public/app for guests - """ - - use Phoenix.Component - - import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1] - import PhoenixKitWeb.Components.Core.Flash, only: [flash_group: 1] - import PhoenixKitWeb.Components.Core.LanguageSwitcher, only: [language_switcher_dropdown: 1] - import PhoenixKitWeb.LayoutHelpers, only: [dashboard_assigns: 1] - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Utils.Routes - - @doc """ - Public shop layout with navbar, flash messages, and main content area. - - Used for guest users on catalog/category/product pages. - """ - slot :inner_block, required: true - attr :current_language, :string, required: true - attr :current_path, :string, required: true - attr :flash, :map, required: true - - def shop_public_layout(assigns) do - ~H""" -
- <%!-- Simple navbar for shop --%> - - - <%!-- Flash messages --%> - <.flash_group flash={@flash} /> - - <%!-- Wide content area --%> -
- {render_slot(@inner_block)} -
-
- """ - end - - @doc """ - Top-level layout wrapper for shop pages. - - Routes to: - - Dashboard layout for authenticated users - - `shop_public_layout` for guests when `show_sidebar` is true (catalog/category/product pages) - - `LayoutWrapper.app_layout` for guests when `show_sidebar` is false (cart/checkout pages) - """ - slot :inner_block, required: true - attr :authenticated, :boolean, required: true - attr :show_sidebar, :boolean, default: false - attr :flash, :map, required: true - attr :phoenix_kit_current_scope, :any, required: true - attr :url_path, :string, required: true - attr :current_locale, :string, required: true - attr :page_title, :string, required: true - attr :sidebar_after_shop, :any, default: nil - # Used when show_sidebar is true (catalog/category/product pages) - attr :current_language, :string, default: nil - attr :current_path, :string, default: nil - - def shop_layout(assigns) do - ~H""" - <%= if @authenticated do %> - - {render_slot(@inner_block)} - - <% else %> - <%= if @show_sidebar do %> - <.shop_public_layout - flash={@flash} - current_language={@current_language} - current_path={@current_path} - > - {render_slot(@inner_block)} - - <% else %> - - {render_slot(@inner_block)} - - <% end %> - <% end %> - """ - end -end diff --git a/lib/modules/shop/web/components/translation_tabs.ex b/lib/modules/shop/web/components/translation_tabs.ex deleted file mode 100644 index e9a808b9e..000000000 --- a/lib/modules/shop/web/components/translation_tabs.ex +++ /dev/null @@ -1,443 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Components.TranslationTabs do - @moduledoc """ - Translation tabs component for Shop module forms. - - Displays language tabs for editing product/category translations. - Only visible when the Languages module is enabled and has multiple languages. - - ## Localized Fields Model - - With the new localized fields approach, each translatable field stores - a map of language → value directly: - - %Product{ - title: %{"en" => "Planter", "ru" => "Кашпо"}, - slug: %{"en" => "planter", "ru" => "kashpo"} - } - - The component provides helpers to work with this structure in forms. - - ## Examples - - <.translation_tabs - languages={@enabled_languages} - current_language={@current_language} - entity={@product} - translatable_fields={[:title, :slug, :description]} - on_click="switch_language" - /> - """ - - use Phoenix.Component - - alias PhoenixKit.Modules.Languages - alias PhoenixKit.Modules.Shop.Translations - - @doc """ - Renders translation tabs for multi-language editing. - - ## Attributes - - - `languages` - List of language maps with keys: `code`, `name`, `flag` - - `current_language` - Currently active language code - - `translations` - Current translations map from entity - - `translatable_fields` - List of field atoms that should be translated - - `on_click` - Event name for tab click handler - - `class` - Additional CSS classes - """ - attr :languages, :list, required: true - attr :current_language, :string, required: true - attr :translations, :map, default: %{} - attr :translatable_fields, :list, default: [] - attr :on_click, :string, default: "switch_language" - attr :class, :string, default: "" - - def translation_tabs(assigns) do - # Calculate translation status for each language - # Extract fields into plain maps to allow adding :status key - languages_with_status = - Enum.map(assigns.languages, fn lang -> - code = lang.code - status = calculate_status(assigns.translations, code, assigns.translatable_fields) - - %{ - code: code, - name: lang.name, - is_default: lang.is_default, - status: status - } - end) - - assigns = assign(assigns, :languages_with_status, languages_with_status) - - ~H""" -
- <%= for lang <- @languages_with_status do %> - <% code = lang.code %> - <% name = lang.name || code %> - <% is_current = code == @current_language %> - <% is_default = lang.is_default || false %> - - <% end %> -
- """ - end - - @doc """ - Renders translation fields for the current language. - - ## Attributes - - - `language` - Current language code being edited - - `translations` - Current translations map from entity - - `fields` - List of field configs: `[%{key: :title, label: "Title", type: :text}, ...]` - - `form_prefix` - Form name prefix (e.g., "product") - """ - attr :language, :string, required: true - attr :translations, :map, default: %{} - attr :fields, :list, required: true - attr :form_prefix, :string, required: true - attr :is_default_language, :boolean, default: false - - def translation_fields(assigns) do - current_translation = Map.get(assigns.translations, assigns.language, %{}) - assigns = assign(assigns, :current_translation, current_translation) - - ~H""" -
- <%= if @is_default_language do %> -
- - - - - This is the default language. Edit the main fields above for canonical content. -
- <% else %> - <%= for field <- @fields do %> - <.translation_field - field={field} - language={@language} - value={Map.get(@current_translation, to_string(field.key), "")} - form_prefix={@form_prefix} - /> - <% end %> - <% end %> -
- """ - end - - attr :field, :map, required: true - attr :language, :string, required: true - attr :value, :string, default: "" - attr :form_prefix, :string, required: true - - defp translation_field(assigns) do - field_name = "#{assigns.form_prefix}[translations][#{assigns.language}][#{assigns.field.key}]" - assigns = assign(assigns, :field_name, field_name) - - ~H""" -
- - {@field.label} - {String.upcase(@language)} - - <%= case @field.type do %> - <% :textarea -> %> - - <% :html -> %> - - <% _ -> %> - - <% end %> - <%= if @field[:hint] do %> -

{@field.hint}

- <% end %> -
- """ - end - - # Status badge showing translation completeness - attr :status, :map, required: true - attr :is_default, :boolean, default: false - - defp status_badge(assigns) do - ~H""" - <%= cond do %> - <% @is_default -> %> - Default - <% @status.percentage == 100 -> %> - - <% @status.percentage > 0 -> %> - {@status.percentage}% - <% true -> %> - - <% end %> - """ - end - - defp format_display_name(name, code) do - # Extract base language name, removing region part - base_name = - name - |> String.split("(") - |> List.first() - |> String.trim() - - # If name is same as code, use code uppercase - if String.downcase(base_name) == String.downcase(code) do - String.upcase(code) - else - base_name - end - end - - defp calculate_status(translations, language, fields) when is_list(fields) do - translation = Map.get(translations || %{}, language, %{}) - - present = - Enum.count(fields, fn field -> - value = Map.get(translation, to_string(field)) - value != nil and value != "" - end) - - total = length(fields) - - %{ - complete: present, - total: total, - percentage: if(total > 0, do: round(present / total * 100), else: 0) - } - end - - defp calculate_status(_, _, _), do: %{complete: 0, total: 0, percentage: 0} - - # ============================================================================ - # Helper Functions - # ============================================================================ - - @doc """ - Returns list of enabled languages for translation tabs. - - Returns empty list if Languages module is disabled or only one language enabled. - """ - @spec get_enabled_languages() :: [map()] - def get_enabled_languages do - if languages_enabled?() do - Languages.get_enabled_languages() - else - [] - end - end - - @doc """ - Returns the default language code. - """ - @spec get_default_language() :: String.t() - def get_default_language do - Translations.default_language() - end - - @doc """ - Checks if multi-language editing should be shown. - - Returns true if Languages module is enabled and has 2+ languages. - """ - @spec show_translation_tabs?() :: boolean() - def show_translation_tabs? do - if languages_enabled?() do - length(Languages.get_enabled_language_codes()) > 1 - else - false - end - end - - # ============================================================================ - # Localized Fields Helpers - # ============================================================================ - - @doc """ - Gets the value of a localized field for a specific language. - - Works with the new localized fields model where each field is a map. - - ## Examples - - iex> get_localized_value(%Product{title: %{"en" => "Planter", "ru" => "Кашпо"}}, :title, "en") - "Planter" - - iex> get_localized_value(%Product{title: %{"en" => "Planter"}}, :title, "ru") - nil - """ - @spec get_localized_value(struct() | Ecto.Changeset.t(), atom(), String.t()) :: - String.t() | nil - def get_localized_value(%Ecto.Changeset{} = changeset, field, language) do - field_map = Ecto.Changeset.get_field(changeset, field) || %{} - Map.get(field_map, language) - end - - def get_localized_value(entity, field, language) when is_struct(entity) do - field_map = Map.get(entity, field) || %{} - Map.get(field_map, language) - end - - def get_localized_value(_, _, _), do: nil - - @doc """ - Builds a translations map from entity's localized fields. - - Transforms from new model (field → lang → value) to UI model (lang → field → value). - This allows the TranslationTabs UI to work with the new localized fields model. - - ## Examples - - iex> entity = %Product{ - ...> title: %{"en" => "Planter", "ru" => "Кашпо"}, - ...> slug: %{"en" => "planter", "ru" => "kashpo"} - ...> } - iex> build_translations_map(entity, [:title, :slug]) - %{ - "en" => %{"title" => "Planter", "slug" => "planter"}, - "ru" => %{"title" => "Кашпо", "slug" => "kashpo"} - } - """ - @spec build_translations_map(struct(), [atom()]) :: map() - def build_translations_map(entity, fields) when is_struct(entity) and is_list(fields) do - # Get all languages present in any field - all_languages = - fields - |> Enum.flat_map(fn field -> - field_map = Map.get(entity, field) || %{} - Map.keys(field_map) - end) - |> Enum.uniq() - - # Build translations map: lang => {field => value} - Enum.reduce(all_languages, %{}, fn lang, acc -> - field_values = - Enum.reduce(fields, %{}, fn field, field_acc -> - field_map = Map.get(entity, field) || %{} - value = Map.get(field_map, lang) - - if value do - Map.put(field_acc, to_string(field), value) - else - field_acc - end - end) - - if field_values != %{} do - Map.put(acc, lang, field_values) - else - acc - end - end) - end - - def build_translations_map(_, _), do: %{} - - @doc """ - Merges translations map back into localized field attrs for changeset. - - Transforms from UI model (lang → field → value) to new model attrs. - - ## Parameters - - - `entity` - The current entity (to preserve existing values) - - `translations_map` - UI translations map - - `default_lang_values` - Values from main form fields (for default language) - - `fields` - List of translatable field atoms - - ## Examples - - iex> merge_translations_to_attrs( - ...> %Product{title: %{"en" => "Old"}, slug: %{"en" => "old"}}, - ...> %{"ru" => %{"title" => "Кашпо", "slug" => "kashpo"}}, - ...> %{"title" => "New Planter", "slug" => "new-planter"}, - ...> "en", - ...> [:title, :slug] - ...> ) - %{ - title: %{"en" => "New Planter", "ru" => "Кашпо"}, - slug: %{"en" => "new-planter", "ru" => "kashpo"} - } - """ - @spec merge_translations_to_attrs(struct(), map(), map(), String.t(), [atom()]) :: map() - def merge_translations_to_attrs(entity, translations_map, default_values, default_lang, fields) do - Enum.reduce(fields, %{}, fn field, acc -> - # Start with existing field values - existing = Map.get(entity, field) || %{} - - # Add default language value from main form - field_str = to_string(field) - - updated = merge_field_value(existing, default_lang, default_values, field_str) - - # Merge translations from other languages - updated = - Enum.reduce(translations_map, updated, fn {lang, field_values}, field_acc -> - merge_field_value(field_acc, lang, field_values, field_str) - end) - - Map.put(acc, field, updated) - end) - end - - # Helper to merge a single field value, reducing nesting depth - defp merge_field_value(field_acc, lang, field_values, field_str) do - case Map.fetch(field_values, field_str) do - {:ok, value} when is_binary(value) and value != "" -> - Map.put(field_acc, lang, value) - - {:ok, _empty_value} -> - Map.delete(field_acc, lang) - - :error -> - field_acc - end - end - - defp languages_enabled? do - Code.ensure_loaded?(Languages) and Languages.enabled?() - end -end diff --git a/lib/modules/shop/web/dashboard.ex b/lib/modules/shop/web/dashboard.ex deleted file mode 100644 index ba9fa80f5..000000000 --- a/lib/modules/shop/web/dashboard.ex +++ /dev/null @@ -1,194 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Dashboard do - @moduledoc """ - E-Commerce module dashboard LiveView. - - Displays e-commerce statistics and quick access to management features. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if connected?(socket), do: :timer.send_interval(30_000, self(), :refresh_stats) - - stats = Shop.get_dashboard_stats() - - socket = - socket - |> assign(:page_title, "E-Commerce") - |> assign(:stats, stats) - |> assign(:enabled, Shop.enabled?()) - - {:ok, socket} - end - - @impl true - def handle_info(:refresh_stats, socket) do - stats = Shop.get_dashboard_stats() - {:noreply, assign(socket, :stats, stats)} - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header - back={Routes.path("/admin")} - title="E-Commerce" - subtitle="Manage your e-commerce store" - /> - - <%!-- Controls Bar --%> -
-
- <.link navigate={Routes.path("/admin/shop/products/new")} class="btn btn-primary"> - <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Product - -
-
- - <%!-- Stats Grid --%> -
- <%!-- Total Products --%> -
-
-
-
-

Total Products

-

{@stats.total_products}

-
-
- <.icon name="hero-cube" class="w-8 h-8 text-primary" /> -
-
-
-
- - <%!-- Active Products --%> -
-
-
-
-

Active Products

-

{@stats.active_products}

-
-
- <.icon name="hero-check-circle" class="w-8 h-8 text-success" /> -
-
-
-
- - <%!-- Draft Products --%> -
-
-
-
-

Draft Products

-

{@stats.draft_products}

-
-
- <.icon name="hero-pencil-square" class="w-8 h-8 text-warning" /> -
-
-
-
- - <%!-- Categories --%> -
-
-
-
-

Categories

-

{@stats.total_categories}

-
-
- <.icon name="hero-folder" class="w-8 h-8 text-info" /> -
-
-
-
-
- - <%!-- Product Types Grid --%> -
- <%!-- Physical Products --%> -
-
-

- <.icon name="hero-truck" class="w-6 h-6" /> Physical Products -

-

{@stats.physical_products}

-

Products requiring shipping

-
-
- - <%!-- Digital Products --%> -
-
-

- <.icon name="hero-arrow-down-tray" class="w-6 h-6" /> Digital Products -

-

{@stats.digital_products}

-

Downloadable products

-
-
-
- - <%!-- Quick Actions --%> -
-
-

Quick Actions

-
- <.link - navigate={Routes.path("/admin/shop/products")} - class="btn btn-outline btn-lg justify-start" - > - <.icon name="hero-cube" class="w-5 h-5 mr-2" /> Products - - - <.link - navigate={Routes.path("/admin/shop/categories")} - class="btn btn-outline btn-lg justify-start" - > - <.icon name="hero-folder" class="w-5 h-5 mr-2" /> Categories - - - <.link - navigate={Routes.path("/admin/shop/carts")} - class="btn btn-outline btn-lg justify-start" - > - <.icon name="hero-shopping-cart" class="w-5 h-5 mr-2" /> Carts - - - <.link - navigate={Routes.path("/admin/shop/imports")} - class="btn btn-outline btn-lg justify-start" - > - <.icon name="hero-cloud-arrow-up" class="w-5 h-5 mr-2" /> CSV Import - - - <.link - navigate={Routes.path("/admin/shop/settings")} - class="btn btn-outline btn-lg justify-start" - > - <.icon name="hero-cog-6-tooth" class="w-5 h-5 mr-2" /> Settings - -
-
-
-
-
- """ - end -end diff --git a/lib/modules/shop/web/helpers.ex b/lib/modules/shop/web/helpers.ex deleted file mode 100644 index 46274de74..000000000 --- a/lib/modules/shop/web/helpers.ex +++ /dev/null @@ -1,195 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Helpers do - @moduledoc """ - Shared helper functions for Shop public LiveViews. - - Centralizes utility functions that were duplicated across shop_catalog, - catalog_category, catalog_product, cart_page, checkout_page, and checkout_complete. - """ - - alias PhoenixKit.Modules.Billing.Currency - alias PhoenixKit.Modules.Languages - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Storage - alias PhoenixKit.Modules.Storage.URLSigner - alias PhoenixKit.Utils.Routes - - # --------------------------------------------------------------------------- - # Price formatting - # --------------------------------------------------------------------------- - - @doc "Format a price value with currency. Returns \"-\" for nil price." - def format_price(nil, _currency), do: "-" - - def format_price(price, %Currency{} = currency) do - Currency.format_amount(price, currency) - end - - def format_price(price, nil) do - "$#{Decimal.round(price, 2)}" - end - - # --------------------------------------------------------------------------- - # Current user - # --------------------------------------------------------------------------- - - @doc "Extract current user from socket assigns scope." - def get_current_user(socket) do - case socket.assigns[:phoenix_kit_current_scope] do - %{user: %{uuid: _} = user} -> user - _ -> nil - end - end - - # --------------------------------------------------------------------------- - # Language helpers - # --------------------------------------------------------------------------- - - @doc """ - Determine language from URL params. - - Uses locale param if present, otherwise falls back to Translations.default_language/0. - Used by catalog and category pages (non-product pages). - """ - def get_language_from_params_or_default(%{"locale" => locale}) when is_binary(locale) do - DialectMapper.resolve_dialect(locale, nil) - end - - def get_language_from_params_or_default(_params) do - Translations.default_language() - end - - @doc """ - Find the best enabled language that has a slug for this entity. - - Prefers the default language, then checks other enabled languages. - Returns nil if no valid language found. - """ - def best_redirect_language(slug_map) when slug_map == %{}, do: nil - - def best_redirect_language(slug_map) do - enabled = Languages.get_enabled_languages() - default_first = Enum.sort_by(enabled, fn l -> if l.is_default, do: 0, else: 1 end) - - Enum.find_value(default_first, fn lang -> - code = lang.code - base = DialectMapper.extract_base(code) - if Map.has_key?(slug_map, code) or Map.has_key?(slug_map, base), do: code - end) - end - - @doc """ - Build a localized URL path, adding language prefix for non-default languages. - Delegates to Routes.path which handles default vs non-default consistently. - """ - def build_lang_url(path, lang) do - base = DialectMapper.extract_base(lang) - Routes.path(path, locale: base) - end - - # --------------------------------------------------------------------------- - # Pagination helpers - # --------------------------------------------------------------------------- - - @doc "Parse page param with validation. Returns 1 for invalid/missing values." - def parse_page(nil), do: 1 - def parse_page(""), do: 1 - - def parse_page(page) when is_binary(page) do - case Integer.parse(page) do - {p, ""} when p > 0 -> p - _ -> 1 - end - end - - def parse_page(page) when is_integer(page) and page > 0, do: page - def parse_page(_), do: 1 - - # --------------------------------------------------------------------------- - # Image helpers (for catalog list pages - uses featured_image_uuid) - # --------------------------------------------------------------------------- - - @doc """ - Get the first image URL for a product. - - Handles Storage-based images (new format with featured_image_uuid or image_uuids) - and legacy URL-based images (Shopify imports). - Returns nil if no image is available. - """ - def first_image(%{featured_image_uuid: id}) when is_binary(id) do - get_storage_image_url(id, "small") - end - - def first_image(%{image_uuids: [id | _]}) when is_binary(id) do - get_storage_image_url(id, "small") - end - - # Legacy URL-based images (Shopify imports) - def first_image(%{images: [%{"src" => src} | _]}), do: src - def first_image(%{images: [first | _]}) when is_binary(first), do: first - def first_image(_), do: nil - - @doc """ - Get signed URL for a Storage image file. - - Returns nil if file or variant not found (unlike product detail page - which returns a placeholder). Falls back to original variant if - requested variant is not available. - """ - def get_storage_image_url(file_uuid, variant) do - case Storage.get_file(file_uuid) do - %{uuid: uuid} -> - case Storage.get_file_instance_by_name(uuid, variant) do - nil -> - case Storage.get_file_instance_by_name(uuid, "original") do - nil -> nil - _instance -> URLSigner.signed_url(file_uuid, "original") - end - - _instance -> - URLSigner.signed_url(file_uuid, variant) - end - - nil -> - nil - end - end - - # --------------------------------------------------------------------------- - # UI helpers - # --------------------------------------------------------------------------- - - @doc """ - Convert a key string to human-readable format. - - Example: "material_type" -> "Material Type" - """ - def humanize_key(key) when is_binary(key) do - key - |> String.replace("_", " ") - |> String.split(" ") - |> Enum.map_join(" ", &String.capitalize/1) - end - - def humanize_key(key), do: to_string(key) - - # --------------------------------------------------------------------------- - # Billing profile helpers - # --------------------------------------------------------------------------- - - @doc "Format display name for a billing profile." - def profile_display_name(%{type: "company"} = profile) do - profile.company_name || "#{profile.first_name} #{profile.last_name}" - end - - def profile_display_name(profile) do - "#{profile.first_name} #{profile.last_name}" - end - - @doc "Format address for a billing profile." - def profile_address(profile) do - [profile.address_line1, profile.city, profile.postal_code, profile.country] - |> Enum.filter(& &1) - |> Enum.join(", ") - end -end diff --git a/lib/modules/shop/web/import_configs.ex b/lib/modules/shop/web/import_configs.ex deleted file mode 100644 index da429e7fa..000000000 --- a/lib/modules/shop/web/import_configs.ex +++ /dev/null @@ -1,714 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.ImportConfigs do - @moduledoc """ - Import configurations management LiveView. - - Allows administrators to manage CSV import filter configurations - including keyword filters, category rules, and option mappings. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - # Auto-seed defaults on first visit - Shop.ensure_default_import_config() - Shop.ensure_prom_ua_import_config() - - configs = Shop.list_import_configs(active_only: false) - - socket = - socket - |> assign(:page_title, "Import Configurations") - |> assign(:configs, configs) - |> assign(:show_modal, false) - |> assign(:editing_config, nil) - |> assign(:form_data, initial_form_data()) - |> assign(:delete_confirm_uuid, nil) - - {:ok, socket} - end - - @impl true - def handle_event("show_add_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_modal, true) - |> assign(:editing_config, nil) - |> assign(:form_data, initial_form_data())} - end - - @impl true - def handle_event("show_edit_modal", %{"uuid" => uuid}, socket) do - config = Enum.find(socket.assigns.configs, &(to_string(&1.uuid) == uuid)) - - if config do - form_data = %{ - name: config.name || "", - skip_filter: config.skip_filter || false, - include_keywords_text: Enum.join(config.include_keywords || [], ", "), - exclude_keywords_text: Enum.join(config.exclude_keywords || [], ", "), - exclude_phrases_text: Enum.join(config.exclude_phrases || [], ", "), - category_rules: config.category_rules || [], - default_category_slug: config.default_category_slug || "", - download_images: config.download_images || false, - is_default: config.is_default || false, - active: config.active - } - - {:noreply, - socket - |> assign(:show_modal, true) - |> assign(:editing_config, config) - |> assign(:form_data, form_data)} - else - {:noreply, socket} - end - end - - @impl true - def handle_event("close_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_modal, false) - |> assign(:editing_config, nil) - |> assign(:form_data, initial_form_data())} - end - - @impl true - def handle_event("validate_form", %{"config" => params}, socket) do - form_data = %{ - name: params["name"] || "", - skip_filter: params["skip_filter"] == "true", - include_keywords_text: params["include_keywords_text"] || "", - exclude_keywords_text: params["exclude_keywords_text"] || "", - exclude_phrases_text: params["exclude_phrases_text"] || "", - category_rules: socket.assigns.form_data.category_rules, - default_category_slug: params["default_category_slug"] || "", - download_images: params["download_images"] == "true", - is_default: params["is_default"] == "true", - active: params["active"] == "true" - } - - {:noreply, assign(socket, :form_data, form_data)} - end - - @impl true - def handle_event("save_config", %{"config" => params}, socket) do - form_data = %{ - name: params["name"] || "", - skip_filter: params["skip_filter"] == "true", - include_keywords_text: params["include_keywords_text"] || "", - exclude_keywords_text: params["exclude_keywords_text"] || "", - exclude_phrases_text: params["exclude_phrases_text"] || "", - category_rules: socket.assigns.form_data.category_rules, - default_category_slug: params["default_category_slug"] || "", - download_images: params["download_images"] == "true", - is_default: params["is_default"] == "true", - active: params["active"] == "true" - } - - attrs = build_attrs(form_data) - editing = socket.assigns.editing_config - - result = - if editing do - Shop.update_import_config(editing, attrs) - else - Shop.create_import_config(attrs) - end - - case result do - {:ok, _} -> - {:noreply, - socket - |> assign(:configs, Shop.list_import_configs(active_only: false)) - |> assign(:show_modal, false) - |> assign(:editing_config, nil) - |> assign(:form_data, initial_form_data()) - |> put_flash(:info, if(editing, do: "Config updated", else: "Config created"))} - - {:error, changeset} -> - message = format_changeset_errors(changeset) - {:noreply, put_flash(socket, :error, "Error: #{message}")} - end - end - - @impl true - def handle_event("delete_config", %{"uuid" => uuid}, socket) do - config = Enum.find(socket.assigns.configs, &(to_string(&1.uuid) == uuid)) - - if config do - case Shop.delete_import_config(config) do - {:ok, _} -> - {:noreply, - socket - |> assign(:configs, Shop.list_import_configs(active_only: false)) - |> put_flash(:info, "Config deleted")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to delete config")} - end - else - {:noreply, socket} - end - end - - @impl true - def handle_event("toggle_active", %{"uuid" => uuid}, socket) do - config = Enum.find(socket.assigns.configs, &(to_string(&1.uuid) == uuid)) - - if config do - case Shop.update_import_config(config, %{active: !config.active}) do - {:ok, _} -> - {:noreply, - socket - |> assign(:configs, Shop.list_import_configs(active_only: false)) - |> put_flash(:info, "Config #{if config.active, do: "deactivated", else: "activated"}")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to update config")} - end - else - {:noreply, socket} - end - end - - @impl true - def handle_event("set_default", %{"uuid" => uuid}, socket) do - config = Enum.find(socket.assigns.configs, &(to_string(&1.uuid) == uuid)) - - if config do - case Shop.update_import_config(config, %{is_default: true}) do - {:ok, _} -> - {:noreply, - socket - |> assign(:configs, Shop.list_import_configs(active_only: false)) - |> put_flash(:info, "\"#{config.name}\" set as default")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to set default")} - end - else - {:noreply, socket} - end - end - - # Category rule management - @impl true - def handle_event("add_category_rule", _params, socket) do - form_data = socket.assigns.form_data - new_rule = %{"keywords" => [], "slug" => "", "keywords_text" => ""} - updated = %{form_data | category_rules: form_data.category_rules ++ [new_rule]} - {:noreply, assign(socket, :form_data, updated)} - end - - @impl true - def handle_event("remove_category_rule", %{"index" => idx}, socket) do - form_data = socket.assigns.form_data - index = String.to_integer(idx) - updated = %{form_data | category_rules: List.delete_at(form_data.category_rules, index)} - {:noreply, assign(socket, :form_data, updated)} - end - - @impl true - def handle_event("update_category_rule", %{"index" => idx} = params, socket) do - form_data = socket.assigns.form_data - index = String.to_integer(idx) - rule = Enum.at(form_data.category_rules, index) - - if rule do - keywords_text = params["keywords"] || Map.get(rule, "keywords_text", "") - slug = params["slug"] || Map.get(rule, "slug", "") - keywords = parse_comma_list(keywords_text) - - updated_rule = %{ - "keywords" => keywords, - "slug" => slug, - "keywords_text" => keywords_text - } - - updated_rules = List.replace_at(form_data.category_rules, index, updated_rule) - {:noreply, assign(socket, :form_data, %{form_data | category_rules: updated_rules})} - else - {:noreply, socket} - end - end - - @impl true - def handle_event("toggle_skip_filter", _params, socket) do - form_data = socket.assigns.form_data - {:noreply, assign(socket, :form_data, %{form_data | skip_filter: !form_data.skip_filter})} - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header - back={Routes.path("/admin/shop/settings")} - title="Import Configurations" - subtitle="Configure keyword filters and category rules for CSV product imports" - /> - - <%!-- Controls Bar --%> -
-
- -
-
- - <%!-- Info Alert --%> -
- <.icon name="hero-information-circle" class="w-6 h-6" /> -
-

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. -

-
-
- - <%!-- Configs List --%> -
-
-

- <.icon name="hero-funnel" class="w-5 h-5" /> Configurations -

- - <%= if @configs == [] do %> -
- <.icon name="hero-funnel" class="w-16 h-16 mx-auto mb-4 opacity-30" /> -

No configurations defined yet

-

Add your first import configuration to get started

-
- <% else %> -
- <%= for config <- @configs do %> -
-
-
-
- {config.name} - <%= if config.is_default do %> - Default - <% end %> - <%= if config.active do %> - Active - <% else %> - Inactive - <% end %> - <%= if config.skip_filter do %> - Skip Filter - <% end %> - <%= if config.download_images do %> - Download Images - <% end %> -
-
- - <.icon name="hero-plus-circle" class="w-3 h-3 inline" /> - {length(config.include_keywords)} include - - - <.icon name="hero-minus-circle" class="w-3 h-3 inline" /> - {length(config.exclude_keywords)} exclude - - - <.icon name="hero-tag" class="w-3 h-3 inline" /> - {length(config.category_rules)} category rules - - <%= if config.default_category_slug && config.default_category_slug != "" do %> - - <.icon name="hero-folder" class="w-3 h-3 inline" /> - default: {config.default_category_slug} - - <% end %> -
-
- -
- <%= unless config.is_default do %> - - <% end %> - - - -
-
-
- <% end %> -
- <% end %> -
-
-
- - <%!-- Modal for Add/Edit Config --%> - <%= if @show_modal do %> - - <% end %> -
- """ - end - - # Private helpers - - defp initial_form_data do - %{ - name: "", - skip_filter: false, - include_keywords_text: "", - exclude_keywords_text: "", - exclude_phrases_text: "", - category_rules: [], - default_category_slug: "", - download_images: false, - is_default: false, - active: true - } - end - - defp build_attrs(form_data) do - category_rules = - form_data.category_rules - |> Enum.map(fn rule -> - keywords_text = - Map.get(rule, "keywords_text", Enum.join(Map.get(rule, "keywords", []), ", ")) - - %{ - "keywords" => parse_comma_list(keywords_text), - "slug" => Map.get(rule, "slug", "") - } - end) - |> Enum.reject(fn rule -> rule["slug"] == "" and rule["keywords"] == [] end) - - %{ - name: form_data.name, - skip_filter: form_data.skip_filter, - include_keywords: parse_comma_list(form_data.include_keywords_text), - exclude_keywords: parse_comma_list(form_data.exclude_keywords_text), - exclude_phrases: parse_comma_list(form_data.exclude_phrases_text), - category_rules: category_rules, - default_category_slug: form_data.default_category_slug, - download_images: form_data.download_images, - is_default: form_data.is_default, - active: form_data.active - } - end - - defp parse_comma_list(text) when is_binary(text) do - text - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - end - - defp parse_comma_list(_), do: [] - - defp format_changeset_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - end) - end) - |> Enum.map_join(", ", fn {field, errors} -> - "#{field}: #{Enum.join(errors, ", ")}" - end) - end -end diff --git a/lib/modules/shop/web/import_show.ex b/lib/modules/shop/web/import_show.ex deleted file mode 100644 index c53cf01ec..000000000 --- a/lib/modules/shop/web/import_show.ex +++ /dev/null @@ -1,270 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.ImportShow do - @moduledoc """ - LiveView for displaying import details. - - Shows: - - Import metadata (filename, user, dates) - - Statistics summary (imported/updated/skipped/errors) - - List of imported products with links to edit - - Error details (if any) - """ - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"uuid" => uuid}, _session, socket) do - case Shop.get_import_log(uuid, preload: [:user]) do - nil -> - {:ok, - socket - |> put_flash(:error, "Import not found") - |> push_navigate(to: Routes.path("/admin/shop/imports"))} - - import_log -> - products = load_products(import_log.product_uuids || []) - - socket = - socket - |> assign(:page_title, "Import: #{import_log.filename}") - |> assign(:import, import_log) - |> assign(:products, products) - - {:ok, socket} - end - end - - @impl true - def handle_params(_params, uri, socket) do - {:noreply, assign(socket, :url_path, URI.parse(uri).path)} - end - - defp load_products([]), do: [] - - defp load_products(product_uuids) do - Shop.list_products_by_ids(product_uuids) - end - - defp format_datetime(nil), do: "-" - - defp format_datetime(datetime) do - Calendar.strftime(datetime, "%b %d, %Y %H:%M") - end - - defp get_localized(nil), do: "-" - defp get_localized(value) when is_binary(value), do: value - - defp get_localized(value) when is_map(value) do - lang = Translations.default_language() - Map.get(value, lang) || Map.get(value, "en") || Map.values(value) |> List.first() || "-" - end - - defp format_price(nil), do: "-" - defp format_price(%Decimal{} = price), do: Decimal.to_string(price) - defp format_price(price) when is_number(price), do: to_string(price) - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header back={Routes.path("/admin/shop/imports")} title="Import Details"> - <:actions> - "badge-warning" - "processing" -> "badge-info" - "completed" -> "badge-success" - "failed" -> "badge-error" - _ -> "badge-ghost" - end - ]}> - {@import.status} - - - - - <%!-- Metadata card --%> -
-
-

- <.icon name="hero-document-text" class="w-5 h-5" /> Import Information -

-
-
-
Filename
-
{@import.filename}
-
-
-
User
-
{if @import.user, do: @import.user.email, else: "-"}
-
-
-
Started At
-
{format_datetime(@import.started_at)}
-
-
-
Completed At
-
{format_datetime(@import.completed_at)}
-
-
-
-
- - <%!-- Statistics --%> -
-
-
- <.icon name="hero-plus-circle" class="w-8 h-8" /> -
-
Imported
-
{@import.imported_count}
-
new products
-
- -
-
- <.icon name="hero-arrow-path" class="w-8 h-8" /> -
-
Updated
-
{@import.updated_count}
-
existing products
-
- -
-
- <.icon name="hero-minus-circle" class="w-8 h-8" /> -
-
Skipped
-
{@import.skipped_count}
-
filtered out
-
- -
-
- <.icon name="hero-exclamation-circle" class="w-8 h-8" /> -
-
Errors
-
{@import.error_count}
-
failed rows
-
-
- - <%!-- Products list --%> - <%= if @products != [] do %> -
-
-

- <.icon name="hero-cube" class="w-5 h-5" /> Imported Products - {length(@products)} -

-
- - - - - - - - - - - <%= for product <- @products do %> - - - - - - - <% end %> - -
TitleSlugPrice
- {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")} - -
-
-
-
-
- <% else %> -
- <.icon name="hero-information-circle" class="w-6 h-6" /> - - No products tracked for this import. Product tracking was added in a later version. - -
- <% end %> - - <%!-- Errors section (if any) --%> - <%= if @import.error_count > 0 and @import.error_details != [] do %> -
-
-

- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> Errors - {@import.error_count} -

-
- - - - - - - - - - <%= for error <- Enum.take(@import.error_details, 50) do %> - - - - - - <% end %> - -
HandleErrorTime
{error["handle"]} - {error["error"]} - - {error["timestamp"]} -
- <%= if length(@import.error_details) > 50 do %> -

- Showing first 50 of {length(@import.error_details)} errors -

- <% end %> -
-
-
- <% end %> -
-
- """ - end -end diff --git a/lib/modules/shop/web/imports.ex b/lib/modules/shop/web/imports.ex deleted file mode 100644 index 4e73eeac0..000000000 --- a/lib/modules/shop/web/imports.ex +++ /dev/null @@ -1,1622 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Imports do - @moduledoc """ - Admin LiveView for managing CSV product imports. - - Supports multiple CSV formats (Shopify, Prom.ua, etc.) via the ImportFormat behaviour. - Format is auto-detected from file headers after upload. - - Features: - - Multi-step import wizard with format-aware steps - - File upload with drag-and-drop - - Option mapping UI for formats that require it (e.g. Shopify) - - Direct import for formats that don't (e.g. Prom.ua) - - Import history table with statistics - - Real-time progress tracking via PubSub - - Retry failed imports - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Import.{CSVAnalyzer, FormatDetector} - alias PhoenixKit.Modules.Shop.ImportLog - alias PhoenixKit.Modules.Shop.Options - alias PhoenixKit.Modules.Shop.Services.ImageMigration - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Shop.Workers.CSVImportWorker - alias PhoenixKit.PubSub.Manager - alias PhoenixKit.Utils.Routes - - require Logger - - @impl true - def mount(_params, _session, socket) do - if connected?(socket) do - # Subscribe to import updates - Manager.subscribe("shop:imports") - # Subscribe to image migration updates - Manager.subscribe("shop:image_migration:batch") - - # Subscribe to any active imports (processing status) - subscribe_to_active_imports() - end - - # Language selection for import - enabled_languages = Translations.enabled_languages() - current_language = Translations.default_language() - show_language_selector = length(enabled_languages) > 1 - - # Get image migration stats - migration_stats = ImageMigration.migration_stats() - - # Get global options for mapping UI - global_options = Options.get_enabled_global_options() - - # Load import configs for filter selection - import_configs = Shop.list_import_configs(active_only: true) - default_config = Shop.get_default_import_config() - - socket = - socket - |> assign(:page_title, "CSV Import") - |> assign(:imports, list_imports()) - |> assign(:current_import, nil) - |> assign(:import_progress, nil) - |> assign(:current_language, current_language) - |> assign(:enabled_languages, enabled_languages) - |> assign(:show_language_selector, show_language_selector) - |> assign(:download_images, false) - |> assign(:skip_empty_categories, true) - |> assign(:migration_stats, migration_stats) - |> assign(:migration_in_progress, migration_stats.in_progress > 0) - |> assign(:import_configs, import_configs) - |> assign(:selected_config, default_config) - |> assign(:selected_config_uuid, if(default_config, do: default_config.uuid)) - # Multi-step wizard state - |> assign(:import_step, :upload) - |> assign(:format_mod, nil) - |> assign(:format_name, nil) - |> assign(:csv_analysis, nil) - |> assign(:option_mappings, []) - |> assign(:uploaded_file_path, nil) - |> assign(:uploaded_filename, nil) - |> assign(:confirm_product_count, nil) - |> assign(:global_options, global_options) - |> allow_upload(:csv_file, - accept: ~w(.csv), - max_file_size: 50_000_000, - max_entries: 1, - auto_upload: true, - progress: &handle_progress/3 - ) - - {:ok, socket} - end - - @impl true - def handle_event("validate", _params, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("cancel_upload", %{"ref" => ref}, socket) do - {:noreply, cancel_upload(socket, :csv_file, ref)} - end - - @impl true - def handle_event("start_import", _params, socket) do - # Multi-step: consume upload, detect format, then route to appropriate step - case consume_uploaded_entries(socket, :csv_file, fn %{path: path}, entry -> - dest_dir = Path.join(System.tmp_dir!(), "shop_imports") - File.mkdir_p!(dest_dir) - - dest_path = - Path.join(dest_dir, "#{System.system_time(:millisecond)}_#{entry.client_name}") - - File.cp!(path, dest_path) - {:ok, {dest_path, entry.client_name}} - end) do - [{dest_path, filename}] -> - # Detect format from file headers - case FormatDetector.detect(dest_path) do - {:ok, format_mod} -> - if format_mod.requires_option_mapping?() do - # Shopify path: analyze CSV, show mapping UI - handle_mapping_format(socket, dest_path, filename, format_mod) - else - # Prom.ua path: skip configure, go to confirm - handle_direct_format(socket, dest_path, filename, format_mod) - end - - {:error, :unknown_format} -> - File.rm(dest_path) - {:noreply, put_flash(socket, :error, "Unrecognized CSV format")} - - {:error, _reason} -> - File.rm(dest_path) - {:noreply, put_flash(socket, :error, "Failed to read CSV file headers")} - end - - [] -> - {:noreply, put_flash(socket, :error, "Please select a CSV file first")} - end - end - - @impl true - def handle_event("confirm_import", _params, socket) do - # Direct import from confirm step (no option mappings) - run_import_with_mappings(socket, []) - end - - @impl true - def handle_event("skip_mapping", _params, socket) do - # Skip mapping step and run import directly - run_import_with_mappings(socket, []) - end - - @impl true - def handle_event("run_import", _params, socket) do - # Run import with current mappings - mappings = socket.assigns.option_mappings - run_import_with_mappings(socket, mappings) - end - - @impl true - def handle_event("update_mapping", %{"index" => index_str} = params, socket) do - index = String.to_integer(index_str) - mappings = socket.assigns.option_mappings - - updated_mapping = - mappings - |> Enum.at(index) - |> update_mapping_from_params(params) - - updated_mappings = List.replace_at(mappings, index, updated_mapping) - - {:noreply, assign(socket, :option_mappings, updated_mappings)} - end - - @impl true - def handle_event("toggle_auto_add", %{"index" => index_str}, socket) do - index = String.to_integer(index_str) - mappings = socket.assigns.option_mappings - - updated_mapping = - mappings - |> Enum.at(index) - |> Map.update!(:auto_add, &(!&1)) - - updated_mappings = List.replace_at(mappings, index, updated_mapping) - - {:noreply, assign(socket, :option_mappings, updated_mappings)} - end - - @impl true - def handle_event("back_to_upload", _params, socket) do - if socket.assigns.uploaded_file_path do - File.rm(socket.assigns.uploaded_file_path) - end - - socket = - socket - |> assign(:import_step, :upload) - |> assign(:format_mod, nil) - |> assign(:format_name, nil) - |> assign(:csv_analysis, nil) - |> assign(:option_mappings, []) - |> assign(:uploaded_file_path, nil) - |> assign(:uploaded_filename, nil) - |> assign(:confirm_product_count, nil) - - {:noreply, socket} - end - - @impl true - def handle_event("retry_import", %{"id" => id}, socket) do - case parse_uuid(id) do - {:ok, import_uuid} -> - do_retry_import(import_uuid, socket) - - :error -> - {:noreply, put_flash(socket, :error, "Invalid import ID")} - end - end - - @impl true - def handle_event("delete_import", %{"id" => id}, socket) do - case parse_uuid(id) do - {:ok, import_uuid} -> - do_delete_import(import_uuid, socket) - - :error -> - {:noreply, put_flash(socket, :error, "Invalid import ID")} - end - end - - @impl true - def handle_event("toggle_download_images", _params, socket) do - {:noreply, assign(socket, :download_images, not socket.assigns.download_images)} - end - - @impl true - def handle_event("toggle_skip_empty_categories", _params, socket) do - {:noreply, assign(socket, :skip_empty_categories, not socket.assigns.skip_empty_categories)} - end - - @impl true - def handle_event("select_language", %{"language" => lang}, socket) do - {:noreply, assign(socket, :current_language, lang)} - end - - @impl true - def handle_event("select_config", %{"config_uuid" => ""}, socket) do - socket = - socket - |> assign(:selected_config, nil) - |> assign(:selected_config_uuid, nil) - |> maybe_reanalyze_csv() - - {:noreply, socket} - end - - @impl true - def handle_event("select_config", %{"config_uuid" => id_str}, socket) do - config = Enum.find(socket.assigns.import_configs, &(&1.uuid == id_str)) - - socket = - socket - |> assign(:selected_config, config) - |> assign(:selected_config_uuid, if(config, do: config.uuid)) - |> maybe_reanalyze_csv() - - {:noreply, socket} - end - - @impl true - def handle_event("start_image_migration", _params, socket) do - user = socket.assigns.phoenix_kit_current_scope.user - {:ok, count} = ImageMigration.queue_all_migrations(user.uuid) - - socket = - socket - |> assign(:migration_in_progress, true) - |> assign(:migration_stats, ImageMigration.migration_stats()) - |> put_flash(:info, "Started migration for #{count} products") - - {:noreply, socket} - end - - @impl true - def handle_event("cancel_image_migration", _params, socket) do - case ImageMigration.cancel_pending_migrations() do - {:ok, count} -> - socket = - socket - |> assign(:migration_in_progress, false) - |> assign(:migration_stats, ImageMigration.migration_stats()) - |> put_flash(:info, "Cancelled #{count} pending migration jobs") - - {:noreply, socket} - end - end - - @impl true - def handle_event("refresh_migration_stats", _params, socket) do - stats = ImageMigration.migration_stats() - - socket = - socket - |> assign(:migration_stats, stats) - |> assign(:migration_in_progress, stats.in_progress > 0) - - {:noreply, socket} - end - - # Handle PubSub messages - @impl true - def handle_info({:import_started, %{total: total}}, socket) do - socket = - socket - |> assign(:import_progress, %{percent: 0, current: 0, total: total}) - |> assign(:imports, list_imports()) - - {:noreply, socket} - end - - @impl true - def handle_info({:import_progress, progress}, socket) do - {:noreply, assign(socket, :import_progress, progress)} - end - - @impl true - def handle_info({:import_complete, _stats}, socket) do - socket = - socket - |> assign(:current_import, nil) - |> assign(:import_progress, nil) - |> assign(:import_step, :upload) - |> assign(:format_mod, nil) - |> assign(:format_name, nil) - |> assign(:csv_analysis, nil) - |> assign(:option_mappings, []) - |> assign(:uploaded_file_path, nil) - |> assign(:uploaded_filename, nil) - |> assign(:confirm_product_count, nil) - |> assign(:imports, list_imports()) - |> put_flash(:info, "Import completed successfully!") - - {:noreply, socket} - end - - @impl true - def handle_info({:import_failed, %{reason: reason}}, socket) do - socket = - socket - |> assign(:current_import, nil) - |> assign(:import_progress, nil) - |> assign(:import_step, :upload) - |> assign(:format_mod, nil) - |> assign(:format_name, nil) - |> assign(:csv_analysis, nil) - |> assign(:option_mappings, []) - |> assign(:uploaded_file_path, nil) - |> assign(:uploaded_filename, nil) - |> assign(:confirm_product_count, nil) - |> assign(:imports, list_imports()) - |> put_flash(:error, "Import failed: #{reason}") - - {:noreply, socket} - end - - # Image migration PubSub handlers - @impl true - def handle_info({:migration_started, %{total: total}}, socket) do - Logger.info("Image migration started for #{total} products") - - socket = - socket - |> assign(:migration_in_progress, true) - |> assign(:migration_stats, ImageMigration.migration_stats()) - - {:noreply, socket} - end - - @impl true - def handle_info( - {:product_migrated, %{product_uuid: _product_uuid, images_migrated: _count}}, - socket - ) do - # Update stats on each product completion - stats = ImageMigration.migration_stats() - - socket = - socket - |> assign(:migration_stats, stats) - |> assign(:migration_in_progress, stats.in_progress > 0) - - {:noreply, socket} - end - - @impl true - def handle_info({:migration_cancelled, %{cancelled: count}}, socket) do - Logger.info("Image migration cancelled: #{count} jobs") - - socket = - socket - |> assign(:migration_in_progress, false) - |> assign(:migration_stats, ImageMigration.migration_stats()) - - {:noreply, socket} - end - - # Catch-all for other messages - @impl true - def handle_info(_message, socket) do - {:noreply, socket} - end - - defp handle_progress(:csv_file, entry, socket) do - if entry.done? do - {:noreply, socket} - else - {:noreply, socket} - end - end - - defp list_imports do - Shop.list_import_logs(limit: 20, order_by: [desc: :inserted_at]) - end - - # Subscribe to any imports currently in "processing" status - defp subscribe_to_active_imports do - Shop.list_import_logs(limit: 10, order_by: [desc: :inserted_at]) - |> Enum.filter(&(&1.status == "processing")) - |> Enum.each(fn import_log -> - Manager.subscribe("shop:import:#{import_log.uuid}") - end) - end - - # Parse UUID from phx-value (comes as string from the template) - defp parse_uuid(id) when is_binary(id) do - if match?({:ok, _}, Ecto.UUID.cast(id)), do: {:ok, id}, else: :error - end - - defp parse_uuid(_), do: :error - - defp do_retry_import(import_uuid, socket) do - case Shop.get_import_log(import_uuid) do - nil -> - {:noreply, put_flash(socket, :error, "Import not found")} - - import_log -> - if import_log.status == "failed" && import_log.file_path && - File.exists?(import_log.file_path) do - # Reset import log status - {:ok, updated_log} = - Shop.update_import_log(import_log, %{status: "pending", error_details: []}) - - # Re-enqueue job with language and config_uuid - language = socket.assigns.current_language - - config_uuid = - get_in(import_log.options, ["config_uuid"]) || - get_in(import_log.options, ["config_id"]) - - worker_args = %{ - import_log_uuid: updated_log.uuid, - path: import_log.file_path, - language: language - } - - worker_args = - if config_uuid, - do: Map.put(worker_args, :config_uuid, config_uuid), - else: worker_args - - worker_args - |> CSVImportWorker.new() - |> Oban.insert() - - # Subscribe to updates - Manager.subscribe("shop:import:#{updated_log.uuid}") - - socket = - socket - |> assign(:current_import, updated_log) - |> assign(:import_progress, %{percent: 0, current: 0, total: 0}) - |> assign(:imports, list_imports()) - |> put_flash(:info, "Retrying import: #{import_log.filename}") - - {:noreply, socket} - else - {:noreply, put_flash(socket, :error, "Cannot retry: file no longer exists")} - end - end - end - - defp do_delete_import(import_uuid, socket) do - case Shop.get_import_log(import_uuid) do - nil -> - {:noreply, put_flash(socket, :error, "Import not found")} - - import_log -> - case Shop.delete_import_log(import_log) do - {:ok, _} -> - socket = - socket - |> assign(:imports, list_imports()) - |> put_flash(:info, "Import log deleted") - - {:noreply, socket} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to delete import log")} - end - end - end - - # Run import with given mappings - defp run_import_with_mappings(socket, mappings) do - user = socket.assigns.phoenix_kit_current_scope.user - dest_path = socket.assigns.uploaded_file_path - filename = socket.assigns.uploaded_filename - - # Add new values to global options if auto_add enabled - add_new_values_to_global_options(mappings, socket.assigns.global_options) - - # Convert mappings to format expected by worker - worker_mappings = convert_mappings_for_worker(mappings) - - # Create import log with config_uuid - config_uuid = socket.assigns.selected_config_uuid - - case Shop.create_import_log(%{ - filename: filename, - file_path: dest_path, - user_uuid: user.uuid, - options: %{"option_mappings" => worker_mappings, "config_uuid" => config_uuid} - }) do - {:ok, import_log} -> - # Enqueue Oban job with language, mappings, config_uuid, and download_images option - language = socket.assigns.current_language - download_images = socket.assigns.download_images - - skip_empty_categories = socket.assigns[:skip_empty_categories] || false - - worker_args = %{ - import_log_uuid: import_log.uuid, - path: dest_path, - language: language, - option_mappings: worker_mappings, - download_images: download_images, - skip_empty_categories: skip_empty_categories - } - - worker_args = - if config_uuid, - do: Map.put(worker_args, :config_uuid, config_uuid), - else: worker_args - - worker_args - |> CSVImportWorker.new() - |> Oban.insert() - - # Subscribe to this specific import - Manager.subscribe("shop:import:#{import_log.uuid}") - - socket = - socket - |> assign(:current_import, import_log) - |> assign(:import_progress, %{percent: 0, current: 0, total: 0}) - |> assign(:imports, list_imports()) - |> assign(:import_step, :importing) - |> put_flash(:info, "Import started: #{filename}") - - {:noreply, socket} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, "Failed to create import log")} - end - end - - # Build initial mappings by matching CSV options to global options - defp build_initial_mappings(csv_options, global_options) do - Enum.map(csv_options, fn csv_opt -> - # Try to find matching global option - matching_global = find_matching_global_option(csv_opt.name, global_options) - - # Compare values if we have a match - comparison = - if matching_global do - CSVAnalyzer.compare_with_global_option(csv_opt.values, matching_global) - else - %{existing: [], new: csv_opt.values} - end - - %{ - csv_name: csv_opt.name, - csv_position: csv_opt.position, - csv_values: csv_opt.values, - source_key: if(matching_global, do: matching_global["key"], else: nil), - slot_key: normalize_slot_key(csv_opt.name), - label: csv_opt.name, - auto_add: false, - new_values: comparison.new, - existing_values: comparison.existing, - global_option: matching_global - } - end) - end - - # Find global option that might match the CSV option name - defp find_matching_global_option(csv_name, global_options) do - # Normalize names for comparison - normalized_csv = normalize_for_comparison(csv_name) - - Enum.find(global_options, fn opt -> - opt_key = opt["key"] - opt_label = get_option_label(opt) - - normalized_csv == normalize_for_comparison(opt_key) or - normalized_csv == normalize_for_comparison(opt_label) or - String.contains?(normalized_csv, normalize_for_comparison(opt_key)) - end) - end - - defp get_option_label(%{"label" => label}) when is_binary(label), do: label - defp get_option_label(%{"label" => label}) when is_map(label), do: Map.get(label, "en", "") - defp get_option_label(_), do: "" - - defp normalize_for_comparison(str) when is_binary(str) do - str - |> String.downcase() - |> String.replace(~r/[\s_-]+/, "") - end - - defp normalize_for_comparison(_), do: "" - - defp normalize_slot_key(name) do - name - |> String.downcase() - |> String.replace(~r/\s+/, "_") - |> String.replace(~r/[^a-z0-9_]/, "") - end - - # Update mapping from form params - defp update_mapping_from_params(mapping, params) do - mapping - |> maybe_update(:source_key, params["source_key"]) - |> maybe_update(:slot_key, params["slot_key"]) - |> maybe_update(:label, params["label"]) - end - - defp maybe_update(map, _key, nil), do: map - defp maybe_update(map, _key, ""), do: map - defp maybe_update(map, key, value), do: Map.put(map, key, value) - - # Add new values to global options for mappings with auto_add enabled - defp add_new_values_to_global_options(mappings, _global_options) do - # Log all mappings to see auto_add state - Logger.info("add_new_values_to_global_options: #{length(mappings)} mappings") - - eligible = - mappings - |> Enum.filter(fn m -> m.auto_add && m.source_key && m.new_values != [] end) - - Logger.info("Eligible mappings with auto_add=true: #{length(eligible)}") - - Enum.each(eligible, fn mapping -> - Logger.info("Adding #{length(mapping.new_values)} values to #{mapping.source_key}") - - Enum.each(mapping.new_values, fn value -> - result = Options.add_value_to_global_option(mapping.source_key, value) - Logger.debug("Added #{value} to #{mapping.source_key}: #{inspect(result)}") - end) - end) - end - - # Convert UI mappings to worker format - defp convert_mappings_for_worker(mappings) do - mappings - |> Enum.filter(fn m -> m.source_key != nil end) - |> Enum.map(fn m -> - %{ - "csv_name" => m.csv_name, - "slot_key" => m.slot_key, - "source_key" => m.source_key, - "label" => m.label, - "auto_add" => m.auto_add - } - end) - end - - # Handle format that requires option mapping (Shopify) - defp handle_mapping_format(socket, dest_path, filename, format_mod) do - case safe_analyze_csv(dest_path, socket.assigns.selected_config) do - {:ok, analysis} -> - initial_mappings = - build_initial_mappings(analysis.options, socket.assigns.global_options) - - socket = - socket - |> assign(:format_mod, format_mod) - |> assign(:format_name, FormatDetector.format_name(format_mod)) - |> assign(:uploaded_file_path, dest_path) - |> assign(:uploaded_filename, filename) - |> assign(:csv_analysis, analysis) - |> assign(:option_mappings, initial_mappings) - |> assign(:import_step, :configure) - - {:noreply, socket} - - {:error, message} -> - File.rm(dest_path) - {:noreply, put_flash(socket, :error, message)} - end - end - - # Handle format that doesn't require option mapping (Prom.ua) - defp handle_direct_format(socket, dest_path, filename, format_mod) do - product_count = - try do - format_mod.count(dest_path, nil) - rescue - _ -> 0 - end - - socket = - socket - |> assign(:format_mod, format_mod) - |> assign(:format_name, FormatDetector.format_name(format_mod)) - |> assign(:uploaded_file_path, dest_path) - |> assign(:uploaded_filename, filename) - |> assign(:confirm_product_count, product_count) - |> assign(:import_step, :confirm) - - {:noreply, socket} - end - - # Re-analyze CSV when config changes during configure step - defp maybe_reanalyze_csv(socket) do - with :configure <- socket.assigns.import_step, - path when is_binary(path) <- socket.assigns[:uploaded_file_path], - {:ok, analysis} <- safe_analyze_csv(path, socket.assigns.selected_config) do - initial_mappings = - build_initial_mappings(analysis.options, socket.assigns.global_options) - - socket - |> assign(:csv_analysis, analysis) - |> assign(:option_mappings, initial_mappings) - else - _ -> socket - end - end - - # Safe CSV analysis with error handling - defp safe_analyze_csv(path, config) do - CSVAnalyzer.analyze_options(path, config) - |> then(&{:ok, &1}) - rescue - e in NimbleCSV.ParseError -> - message = parse_csv_error(e.message) - {:error, message} - - e -> - Logger.error("CSV analysis failed: #{inspect(e)}") - {:error, "Failed to parse CSV file. Please check the file format."} - end - - defp parse_csv_error(message) do - cond do - String.contains?(message, "unexpected escape character") -> - "CSV format error: The file contains incorrectly escaped quotes. " <> - "Please export directly from Shopify using 'Export > CSV for Excel'." - - String.contains?(message, "reached the end of file") -> - "CSV format error: The file has unclosed quotes. " <> - "Please re-export from Shopify or check for corrupted data." - - true -> - "CSV parse error: #{String.slice(message, 0, 100)}" - end - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header back={Routes.path("/admin/shop")}> -

CSV Import

-

- Import products from CSV files - <%= if @format_name do %> - {@format_name} - <% end %> -

- - - <%!-- Import Wizard Card --%> -
-
- <%!-- Wizard Steps Indicator --%> - <%= if @format_mod && !@format_mod.requires_option_mapping?() do %> - <%!-- 2-step wizard for formats without option mapping --%> -
    -
  • - Upload -
  • -
  • - Confirm -
  • -
  • - Import -
  • -
- <% else %> - <%!-- 3-step wizard for formats with option mapping --%> -
    -
  • - Upload -
  • -
  • - Configure -
  • -
  • - Import -
  • -
- <% end %> - - <%= case @import_step do %> - <% :upload -> %> - <.render_upload_step - uploads={@uploads} - show_language_selector={@show_language_selector} - enabled_languages={@enabled_languages} - current_language={@current_language} - download_images={@download_images} - skip_empty_categories={@skip_empty_categories} - import_configs={@import_configs} - selected_config={@selected_config} - selected_config_uuid={@selected_config_uuid} - /> - <% :configure -> %> - <.render_configure_step - csv_analysis={@csv_analysis} - option_mappings={@option_mappings} - global_options={@global_options} - uploaded_filename={@uploaded_filename} - format_name={@format_name} - import_configs={@import_configs} - selected_config={@selected_config} - selected_config_uuid={@selected_config_uuid} - /> - <% :confirm -> %> - <.render_confirm_step - format_name={@format_name} - uploaded_filename={@uploaded_filename} - confirm_product_count={@confirm_product_count} - download_images={@download_images} - skip_empty_categories={@skip_empty_categories} - /> - <% :importing -> %> - <.render_importing_step - current_import={@current_import} - import_progress={@import_progress} - /> - <% end %> -
-
- - <%!-- Image Migration Card --%> -
-
-
-

- <.icon name="hero-photo" class="w-6 h-6" /> Image Migration -

- -
- -

- Migrate product images from external CDN URLs to the Storage module for better control and reliability. -

- - <%!-- Migration Stats --%> -
-
-
Total Products
-
{@migration_stats.total}
-
with images
-
- -
-
Migrated
-
{@migration_stats.migrated}
-
in Storage
-
- -
-
Pending
-
{@migration_stats.pending}
-
legacy URLs
-
- -
-
In Progress
-
{@migration_stats.in_progress}
-
jobs
-
- - <%= if @migration_stats.failed > 0 do %> -
-
Failed
-
{@migration_stats.failed}
-
errors
-
- <% end %> -
- - <%!-- Progress Bar (when migration in progress) --%> - <%= if @migration_in_progress and @migration_stats.total > 0 do %> -
- <% progress_percent = - if @migration_stats.total > 0, - do: round(@migration_stats.migrated / @migration_stats.total * 100), - else: 0 %> - -

- {progress_percent}% complete ({@migration_stats.migrated}/{@migration_stats.total}) -

-
- <% end %> - - <%!-- Action Buttons --%> -
- <%= if @migration_in_progress do %> - - <% else %> - <%= if @migration_stats.pending > 0 do %> - - <% else %> - - <% end %> - <% end %> -
-
-
- - <%!-- Import History --%> -
-
-

- <.icon name="hero-clock" class="w-6 h-6" /> Import History -

- - <%= if Enum.empty?(@imports) do %> -
- <.icon name="hero-inbox" class="w-12 h-12 mx-auto mb-2 opacity-50" /> -

No imports yet

-
- <% else %> -
- - - - - - - - - - - - - <%= for import <- @imports do %> - - - - - - - - - <% end %> - -
FileStatusProgressResultsDate
- <.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 %> -
-
-
- <% end %> -
-
- - <%!-- Info Alert --%> -
- <.icon name="hero-information-circle" class="w-6 h-6" /> -
-

About CSV Import

-
    -
  • Supported formats: Shopify, Prom.ua (auto-detected from file headers)
  • -
  • Products are automatically categorized based on title or category name
  • -
  • Existing products with the same slug are updated
  • -
  • Import runs in the background — you can leave this page
  • -
-
-
-
-
- """ - end - - # ============================================ - # WIZARD STEP COMPONENTS - # ============================================ - - defp render_upload_step(assigns) do - ~H""" -

- <.icon name="hero-cloud-arrow-up" class="w-6 h-6" /> Upload CSV File -

- - <%!-- Language Selection --%> - <%= if @show_language_selector do %> -
- -
- <%= for lang <- @enabled_languages do %> - - <% end %> -
- -
- <% else %> -
- <.icon name="hero-language" class="w-4 h-4" /> - Import language: {String.upcase(@current_language)} -
- <% end %> - - <%!-- Import Config Selector --%> - <%= if @import_configs != [] do %> -
- - - <%= if @selected_config do %> -
- <%= unless @selected_config.skip_filter do %> - - {length(@selected_config.include_keywords)} include - - - {length(@selected_config.exclude_keywords)} exclude - - - {length(@selected_config.category_rules)} category rules - - <% else %> - Skip filter — all products imported - <% end %> -
- <% end %> -
- <% end %> - - <%!-- File Upload Zone --%> -
-
- - <.live_file_input upload={@uploads.csv_file} class="hidden" /> -
- - <%!-- Upload Progress --%> - <%= for entry <- @uploads.csv_file.entries do %> -
-
- {entry.client_name} - -
- - - <%= for err <- upload_errors(@uploads.csv_file, entry) do %> -

{error_to_string(err)}

- <% end %> -
- <% end %> - - <%!-- Download Images Option --%> -
- -
- -
- -
- - <%!-- Start Import Button --%> - <%= if length(@uploads.csv_file.entries) > 0 do %> - <% entry = List.first(@uploads.csv_file.entries) %> - <%= if entry.done? do %> - - <% end %> - <% end %> -
- """ - end - - defp render_configure_step(assigns) do - ~H""" -

- <.icon name="hero-adjustments-horizontal" class="w-6 h-6" /> Configure Option Mappings - <%= if @format_name do %> - {@format_name} - <% end %> -

- - <%!-- Import Config Selector (allows changing filter at configure step) --%> - <%= if @import_configs != [] do %> -
- - - <%= if @selected_config do %> -
- <%= unless @selected_config.skip_filter do %> - - {length(@selected_config.include_keywords)} include - - - {length(@selected_config.exclude_keywords)} exclude - - - {length(@selected_config.category_rules)} category rules - - <% else %> - Skip filter — all products imported - <% end %> -
- <% end %> -
- <% end %> - -
- <.icon name="hero-information-circle" class="w-5 h-5" /> -
-

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 %> -
-
- - <%= if @option_mappings == [] do %> -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> - No options found in CSV file. You can proceed with basic import. -
- <% else %> -
- <%= for {mapping, index} <- Enum.with_index(@option_mappings) do %> - <.render_mapping_card mapping={mapping} index={index} global_options={@global_options} /> - <% end %> -
- <% end %> - - <%!-- Action Buttons --%> -
- -
- <%= if @option_mappings != [] do %> - - <% end %> - -
- """ - end - - defp render_mapping_card(assigns) do - ~H""" -
-
-
- <%!-- CSV Option Info --%> -
-

{@mapping.csv_name}

-

- Position {@mapping.csv_position} · {@mapping.csv_values |> length()} values -

-
- <%= for value <- Enum.take(@mapping.csv_values, 5) do %> - {value} - <% end %> - <%= if length(@mapping.csv_values) > 5 do %> - - +{length(@mapping.csv_values) - 5} more - - <% end %> -
-
- - <%!-- Mapping Config --%> -
- - - <%= if @mapping.source_key do %> - - <% end %> -
-
- - <%!-- New Values Warning --%> - <%= if @mapping.source_key && @mapping.new_values != [] do %> -
-
-
-

- <.icon name="hero-exclamation-triangle" class="w-4 h-4 inline" /> - {length(@mapping.new_values)} new values not in global option -

-
- <%= for value <- Enum.take(@mapping.new_values, 3) do %> - {value} - <% end %> - <%= if length(@mapping.new_values) > 3 do %> - +{length(@mapping.new_values) - 3} - <% end %> -
-
- -
- <% end %> -
-
- """ - end - - defp render_confirm_step(assigns) do - ~H""" -

- <.icon name="hero-check-circle" class="w-6 h-6" /> Confirm Import - {@format_name} -

- -
- <.icon name="hero-information-circle" class="w-5 h-5" /> -
-

File: {@uploaded_filename}

-

- Found {@confirm_product_count} products to import -

-
-
- -
-

Import details:

-
    -
  • - <.icon name="hero-document-text" class="w-4 h-4 inline mr-1" /> Format: - {@format_name} -
  • -
  • - <.icon name="hero-cube" class="w-4 h-4 inline mr-1" /> Products: - {@confirm_product_count} -
  • -
  • - <.icon name="hero-photo" class="w-4 h-4 inline mr-1" /> Download images: - {if @download_images, do: "Yes", else: "No"} -
  • -
  • - <.icon name="hero-folder" class="w-4 h-4 inline mr-1" /> Skip empty categories: - {if @skip_empty_categories, do: "Yes", else: "No"} -
  • -
-
- -
- -
- -
- -
- -
- """ - end - - defp render_importing_step(assigns) do - ~H""" -

- <.icon name="hero-arrow-path" class="w-6 h-6 animate-spin" /> Import in Progress -

- - <%= if @current_import do %> -
-
-

{@current_import.filename}

- <%= if @import_progress do %> -
- -

- {@import_progress.current} / {@import_progress.total} products ({@import_progress.percent}%) -

-
- <% else %> -

Preparing import...

- <% end %> -
-
- <% end %> - """ - end - - defp get_global_option_label(%{"label" => label}) when is_binary(label), do: label - - defp get_global_option_label(%{"label" => label}) when is_map(label), - do: Map.get(label, "en", "") - - defp get_global_option_label(_), do: "" - - defp status_badge(assigns) do - ~H""" - "badge-neutral" - "processing" -> "badge-info" - "completed" -> "badge-success" - "failed" -> "badge-error" - _ -> "badge-ghost" - end - ]}> - {@status} - - """ - end - - defp format_datetime(nil), do: "-" - - defp format_datetime(datetime) do - Calendar.strftime(datetime, "%b %d, %Y %H:%M") - end - - defp error_to_string(:too_large), do: "File is too large (max 50MB)" - defp error_to_string(:not_accepted), do: "Only CSV files are accepted" - defp error_to_string(:too_many_files), do: "Only one file at a time" - defp error_to_string(err), do: inspect(err) -end diff --git a/lib/modules/shop/web/option_state.ex b/lib/modules/shop/web/option_state.ex deleted file mode 100644 index 96d81d846..000000000 --- a/lib/modules/shop/web/option_state.ex +++ /dev/null @@ -1,445 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.OptionState do - @moduledoc """ - Encapsulates option-related state for product form. - - This module manages all option-related data in a single struct, - replacing the multiple assigns previously used in product_form.ex: - - `new_value_inputs` -> `state.new_inputs` - - `selected_option_values` -> `state.selected` - - `original_option_values` -> `state.available` - - `metadata["_price_modifiers"]` -> `state.modifiers` - - `option_schema` -> `state.schema` - - ## Usage - - # Initialize state from product and schema - state = OptionState.new(product, option_schema) - - # Toggle a value selection - state = OptionState.toggle_value(state, "size", "M", ["S", "M", "L"]) - - # Add a new custom value - state = OptionState.add_value(state, "size", "XL") - - # Remove a value - state = OptionState.remove_value(state, "size", "XL") - - # Update a price modifier - state = OptionState.update_modifier(state, "size", "M", "5.00") - - # Convert back to metadata for saving - metadata = OptionState.to_metadata(state) - """ - - defstruct [ - # Merged global + category option schema - schema: [], - # All available values per option key (original + manually added) - available: %{}, - # Currently selected values per option key - selected: %{}, - # Price modifiers per option/value (string format) - modifiers: %{}, - # Temporary input field values for "add value" inputs - new_inputs: %{} - ] - - @type t :: %__MODULE__{ - schema: list(map()), - available: %{String.t() => list(String.t())}, - selected: %{String.t() => list(String.t())}, - modifiers: %{String.t() => %{String.t() => String.t()}}, - new_inputs: %{String.t() => String.t()} - } - - @doc """ - Creates a new OptionState from a product and option schema. - - ## Examples - - product = %Product{metadata: %{"_option_values" => %{"size" => ["M", "L"]}}} - schema = [%{"key" => "size", "type" => "select", "options" => ["S", "M", "L"]}] - - state = OptionState.new(product, schema) - # => %OptionState{ - # schema: [...], - # available: %{"size" => ["S", "M", "L"]}, - # selected: %{"size" => ["M", "L"]}, - # modifiers: %{}, - # new_inputs: %{} - # } - """ - def new(product, option_schema) when is_list(option_schema) do - metadata = (product && product.metadata) || %{} - option_values = Map.get(metadata, "_option_values", %{}) - price_modifiers = Map.get(metadata, "_price_modifiers", %{}) - - # Build available values from option_values (imported/saved) - available = option_values - - # Selected = saved option values (if present) or all available - selected = option_values - - %__MODULE__{ - schema: option_schema, - available: available, - selected: selected, - modifiers: normalize_modifiers(price_modifiers), - new_inputs: %{} - } - end - - def new(nil, option_schema), do: new(%{metadata: %{}}, option_schema) - - @doc """ - Toggles a value selection on/off. - - If the value is selected, it will be deselected. If not selected, it will be selected. - The `all_values` parameter is used to determine when all values are selected - (in which case the key is removed from selected map). - - ## Examples - - state = OptionState.toggle_value(state, "size", "M", ["S", "M", "L"]) - """ - def toggle_value(%__MODULE__{} = state, option_key, value, all_values) - when is_binary(option_key) and is_binary(value) and is_list(all_values) do - current = Map.get(state.selected, option_key, all_values) - - updated = - if value in current do - Enum.reject(current, &(&1 == value)) - else - current ++ [value] - end - - # Normalize selection state - new_selected = - cond do - # None selected - keep explicit empty list - updated == [] -> - Map.put(state.selected, option_key, []) - - # All selected - remove key to indicate "all" - Enum.sort(updated) == Enum.sort(all_values) -> - Map.delete(state.selected, option_key) - - # Partial selection - true -> - Map.put(state.selected, option_key, updated) - end - - %{state | selected: new_selected} - end - - @doc """ - Adds a new value to an option. - - The value is added to both `available` and `selected` maps. - Returns `{:ok, state}` or `{:error, reason}`. - - ## Examples - - {:ok, state} = OptionState.add_value(state, "size", "XL") - {:error, "already exists"} = OptionState.add_value(state, "size", "M") - """ - def add_value(%__MODULE__{} = state, option_key, value) - when is_binary(option_key) and is_binary(value) do - value = String.trim(value) - - if value == "" do - {:error, "value cannot be empty"} - else - # Get all existing values (schema + available) - schema_values = get_schema_values(state.schema, option_key) - available_values = Map.get(state.available, option_key, []) - all_existing = Enum.uniq(schema_values ++ available_values) - - if value in all_existing do - {:error, "value '#{value}' already exists"} - else - # Add to available - new_available = - Map.update(state.available, option_key, [value], fn existing -> - existing ++ [value] - end) - - # Add to selected (new value is selected by default) - current_selected = Map.get(state.selected, option_key, all_existing) - new_selected = Map.put(state.selected, option_key, current_selected ++ [value]) - - # Clear input - new_inputs = Map.put(state.new_inputs, option_key, "") - - {:ok, %{state | available: new_available, selected: new_selected, new_inputs: new_inputs}} - end - end - end - - @doc """ - Removes a value from an option. - - Removes from `available`, `selected`, and any associated modifiers. - - ## Examples - - state = OptionState.remove_value(state, "size", "XL") - """ - def remove_value(%__MODULE__{} = state, option_key, value) - when is_binary(option_key) and is_binary(value) do - # Remove from available - new_available = - case Map.get(state.available, option_key) do - nil -> - state.available - - values -> - updated = Enum.reject(values, &(&1 == value)) - - if updated == [] do - Map.delete(state.available, option_key) - else - Map.put(state.available, option_key, updated) - end - end - - # Remove from selected - new_selected = - case Map.get(state.selected, option_key) do - nil -> - state.selected - - values -> - updated = Enum.reject(values, &(&1 == value)) - - if updated == [] do - Map.delete(state.selected, option_key) - else - Map.put(state.selected, option_key, updated) - end - end - - # Remove modifier - new_modifiers = remove_modifier(state.modifiers, option_key, value) - - %{state | available: new_available, selected: new_selected, modifiers: new_modifiers} - end - - @doc """ - Updates a price modifier for a specific option value. - - ## Examples - - state = OptionState.update_modifier(state, "size", "M", "5.00") - """ - def update_modifier(%__MODULE__{} = state, option_key, value, modifier_value) - when is_binary(option_key) and is_binary(value) do - new_modifiers = - if modifier_value == nil or modifier_value == "" or modifier_value == "0" do - remove_modifier(state.modifiers, option_key, value) - else - option_mods = Map.get(state.modifiers, option_key, %{}) - option_mods = Map.put(option_mods, value, modifier_value) - Map.put(state.modifiers, option_key, option_mods) - end - - %{state | modifiers: new_modifiers} - end - - @doc """ - Updates the new value input for an option key. - - ## Examples - - state = OptionState.update_new_input(state, "size", "XL") - """ - def update_new_input(%__MODULE__{} = state, option_key, value) - when is_binary(option_key) do - %{state | new_inputs: Map.put(state.new_inputs, option_key, value || "")} - end - - @doc """ - Converts the state back to a metadata map for saving. - - Returns a map with `_option_values` and `_price_modifiers` keys. - Empty maps are omitted. - - ## Examples - - state = %OptionState{ - selected: %{"size" => ["M", "L"]}, - modifiers: %{"size" => %{"M" => "5.00"}} - } - - OptionState.to_metadata(state) - # => %{ - # "_option_values" => %{"size" => ["M", "L"]}, - # "_price_modifiers" => %{"size" => %{"M" => "5.00"}} - # } - """ - def to_metadata(%__MODULE__{} = state) do - metadata = %{} - - # Add _option_values if present - metadata = - if state.available != %{} do - Map.put(metadata, "_option_values", state.available) - else - metadata - end - - # Add _price_modifiers if present - metadata = - if state.modifiers != %{} do - Map.put(metadata, "_price_modifiers", state.modifiers) - else - metadata - end - - metadata - end - - @doc """ - Checks if a value is currently selected for an option. - - ## Examples - - OptionState.value_selected?(state, "size", "M", ["S", "M", "L"]) - # => true - """ - def value_selected?(%__MODULE__{} = state, option_key, value, all_values) do - case Map.get(state.selected, option_key) do - nil -> value in all_values - selected -> value in selected - end - end - - @doc """ - Gets all values available for an option (schema + custom added). - - ## Examples - - OptionState.get_all_values(state, "size") - # => ["S", "M", "L", "XL"] - """ - def get_all_values(%__MODULE__{} = state, option_key) do - schema_values = get_schema_values(state.schema, option_key) - custom_values = Map.get(state.available, option_key, []) - Enum.uniq(schema_values ++ custom_values) - end - - @doc """ - Gets selected values for an option (or all if not explicitly set). - - ## Examples - - OptionState.get_selected_values(state, "size", ["S", "M", "L"]) - # => ["M", "L"] - """ - def get_selected_values(%__MODULE__{} = state, option_key, all_values) do - Map.get(state.selected, option_key, all_values) - end - - @doc """ - Gets the modifier value for an option/value pair. - - ## Examples - - OptionState.get_modifier(state, "size", "M") - # => "5.00" - """ - def get_modifier(%__MODULE__{} = state, option_key, value) do - get_in(state.modifiers, [option_key, value]) - end - - @doc """ - Checks if option has custom selection (not all values selected). - - ## Examples - - OptionState.has_custom_selection?(state, "size") - # => true - """ - def has_custom_selection?(%__MODULE__{} = state, option_key) do - Map.has_key?(state.selected, option_key) - end - - @doc """ - Adds a completely new option with an initial value. - - Returns `{:ok, state}` or `{:error, reason}`. - - ## Examples - - {:ok, state} = OptionState.add_new_option(state, "material", "Wood") - """ - def add_new_option(%__MODULE__{} = state, option_key, value) - when is_binary(option_key) and is_binary(value) do - key = option_key |> String.trim() |> String.downcase() |> String.replace(~r/\s+/, "_") - value = String.trim(value) - - cond do - key == "" or value == "" -> - {:error, "option key and value are required"} - - # Check if value already exists in this option - value in get_all_values(state, key) -> - {:error, "value '#{value}' already exists in '#{key}'"} - - # Check if this is adding to existing option - Map.has_key?(state.available, key) or - Enum.any?(state.schema, &(&1["key"] == key)) -> - # Add value to existing option - add_value(state, key, value) - - # New option entirely - true -> - new_available = Map.put(state.available, key, [value]) - new_selected = Map.put(state.selected, key, [value]) - {:ok, %{state | available: new_available, selected: new_selected}} - end - end - - # Private helpers - - defp get_schema_values(schema, option_key) do - case Enum.find(schema, &(&1["key"] == option_key)) do - nil -> [] - opt -> opt["options"] || [] - end - end - - defp remove_modifier(modifiers, option_key, value) do - case Map.get(modifiers, option_key) do - nil -> - modifiers - - option_mods -> - updated = Map.delete(option_mods, value) - - if updated == %{} do - Map.delete(modifiers, option_key) - else - Map.put(modifiers, option_key, updated) - end - end - end - - # Normalize modifiers to ensure all values are strings - defp normalize_modifiers(modifiers) when is_map(modifiers) do - Enum.map(modifiers, fn {key, values} when is_map(values) -> - normalized_values = - Enum.map(values, fn - {k, v} when is_binary(v) -> {k, v} - {k, %{"value" => v}} when is_binary(v) -> {k, v} - {k, _} -> {k, "0"} - end) - |> Map.new() - - {key, normalized_values} - end) - |> Map.new() - end - - defp normalize_modifiers(_), do: %{} -end diff --git a/lib/modules/shop/web/options_settings.ex b/lib/modules/shop/web/options_settings.ex deleted file mode 100644 index e4ff60d0a..000000000 --- a/lib/modules/shop/web/options_settings.ex +++ /dev/null @@ -1,885 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.OptionsSettings do - @moduledoc """ - Global product options settings LiveView. - - Allows administrators to manage global options that apply to all products. - Supports both fixed and percentage-based price modifiers. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Shop.Options - alias PhoenixKit.Modules.Shop.OptionTypes - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - options = Options.get_global_options() - - socket = - socket - |> assign(:page_title, "Product Options") - |> assign(:options, options) - |> assign(:show_modal, false) - |> assign(:editing_option, nil) - |> assign(:form_data, initial_form_data()) - |> assign(:supported_types, OptionTypes.supported_types()) - |> assign(:modifier_types, OptionTypes.modifier_types()) - - {:ok, socket} - end - - @impl true - def handle_event("show_add_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_modal, true) - |> assign(:editing_option, nil) - |> assign(:form_data, initial_form_data())} - end - - @impl true - def handle_event("show_edit_modal", %{"key" => key}, socket) do - option = Enum.find(socket.assigns.options, &(&1["key"] == key)) - - if option do - form_data = %{ - key: option["key"], - label: option["label"], - type: option["type"], - options: option["options"] || [], - required: option["required"] || false, - unit: option["unit"] || "", - affects_price: option["affects_price"] || false, - modifier_type: option["modifier_type"] || "fixed", - price_modifiers: option["price_modifiers"] || %{}, - allow_override: option["allow_override"] || false, - enabled: Map.get(option, "enabled", true) - } - - {:noreply, - socket - |> assign(:show_modal, true) - |> assign(:editing_option, option) - |> assign(:form_data, form_data)} - else - {:noreply, socket} - end - end - - @impl true - def handle_event("close_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_modal, false) - |> assign(:editing_option, nil) - |> assign(:form_data, initial_form_data())} - end - - @impl true - def handle_event("validate_form", %{"option" => params}, socket) do - options = parse_options(params["options"]) - - form_data = %{ - key: params["key"] || "", - label: params["label"] || "", - type: params["type"] || "text", - options: options, - required: params["required"] == "true", - unit: params["unit"] || "", - affects_price: params["affects_price"] == "true", - modifier_type: params["modifier_type"] || "fixed", - price_modifiers: parse_price_modifiers(params["price_modifiers"], options), - allow_override: params["allow_override"] == "true" - } - - # Auto-generate key from label if creating new - form_data = - if socket.assigns.editing_option == nil and form_data.key == "" do - %{form_data | key: slugify_key(form_data.label)} - else - form_data - end - - {:noreply, assign(socket, :form_data, form_data)} - end - - @impl true - def handle_event("toggle_affects_price", _params, socket) do - form_data = socket.assigns.form_data - updated = %{form_data | affects_price: !form_data.affects_price} - - # Initialize price modifiers with "0" for all options when enabling - updated = - if updated.affects_price and map_size(updated.price_modifiers) == 0 do - modifiers = Map.new(updated.options, fn opt -> {opt, "0"} end) - %{updated | price_modifiers: modifiers} - else - updated - end - - {:noreply, assign(socket, :form_data, updated)} - end - - @impl true - def handle_event("set_modifier_type", %{"type" => type}, socket) do - form_data = socket.assigns.form_data - updated = %{form_data | modifier_type: type} - {:noreply, assign(socket, :form_data, updated)} - end - - @impl true - def handle_event("toggle_allow_override", _params, socket) do - form_data = socket.assigns.form_data - updated = %{form_data | allow_override: !form_data.allow_override} - {:noreply, assign(socket, :form_data, updated)} - end - - @impl true - def handle_event("save_option", %{"option" => params}, socket) do - form_data = parse_form_params(params) - opt = build_option(form_data) - - current = socket.assigns.options - editing = socket.assigns.editing_option - - result = - if editing do - updated = - Enum.map(current, fn o -> - if o["key"] == editing["key"], do: Map.merge(o, opt), else: o - end) - - Options.update_global_options(updated) - else - opt = Map.put(opt, "position", length(current)) - Options.add_global_option(opt) - end - - case result do - {:ok, _} -> - {:noreply, - socket - |> assign(:options, Options.get_global_options()) - |> assign(:show_modal, false) - |> assign(:editing_option, nil) - |> assign(:form_data, initial_form_data()) - |> put_flash(:info, if(editing, do: "Option updated", else: "Option created"))} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Error: #{reason}")} - end - end - - @impl true - def handle_event("delete_option", %{"key" => key}, socket) do - case Options.remove_global_option(key) do - {:ok, _} -> - {:noreply, - socket - |> assign(:options, Options.get_global_options()) - |> put_flash(:info, "Option deleted")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Error: #{reason}")} - end - end - - @impl true - def handle_event("toggle_enabled", %{"key" => key}, socket) do - current = socket.assigns.options - - updated = - Enum.map(current, fn opt -> - if opt["key"] == key do - current_enabled = Map.get(opt, "enabled", true) - Map.put(opt, "enabled", !current_enabled) - else - opt - end - end) - - case Options.update_global_options(updated) do - {:ok, _} -> - toggled = Enum.find(updated, &(&1["key"] == key)) - label = if Map.get(toggled, "enabled", true), do: "enabled", else: "disabled" - - {:noreply, - socket - |> assign(:options, Options.get_global_options()) - |> put_flash(:info, "Option #{key} #{label}")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Error: #{reason}")} - end - end - - @impl true - def handle_event("reorder_options", %{"ordered_ids" => ordered_keys}, socket) do - current = socket.assigns.options - - # Reorder options based on new order - reordered = - ordered_keys - |> Enum.with_index() - |> Enum.map(fn {key, idx} -> - opt = Enum.find(current, &(&1["key"] == key)) - if opt, do: Map.put(opt, "position", idx), else: nil - end) - |> Enum.reject(&is_nil/1) - - case Options.update_global_options(reordered) do - {:ok, _} -> - {:noreply, assign(socket, :options, Options.get_global_options())} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Reorder failed: #{reason}")} - end - end - - @impl true - def handle_event("add_option", _params, socket) do - form_data = socket.assigns.form_data - updated = %{form_data | options: form_data.options ++ [""]} - {:noreply, assign(socket, :form_data, updated)} - end - - @impl true - def handle_event("remove_option", %{"index" => idx}, socket) do - form_data = socket.assigns.form_data - index = String.to_integer(idx) - updated = %{form_data | options: List.delete_at(form_data.options, index)} - {:noreply, assign(socket, :form_data, updated)} - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header - back={Routes.path("/admin/shop/settings")} - title="Product Options" - subtitle="Define global options that apply to all products" - /> - - <%!-- Controls Bar --%> -
-
-
- <.icon name="hero-information-circle" class="w-4 h-4" /> - - Global options apply to all products. Categories can override or add their own. - -
- -
-
- - <%!-- Options List --%> -
-
-
-

- <.icon name="hero-adjustments-horizontal" class="w-5 h-5" /> Global Options -

- - {length(@options)} {if length(@options) == 1, do: "option", else: "options"} - -
- - <%= if @options == [] do %> -
- <.icon - name="hero-adjustments-horizontal" - class="w-16 h-16 mx-auto text-base-content/30 mb-4" - /> -

- No options defined yet -

-

- Add your first global option to get started -

- -
- <% else %> - <%!-- Table Header --%> - - -
- <%= for opt <- @options do %> - <% enabled = Map.get(opt, "enabled", true) != false %> -
- <%!-- Content --%> -
-
- - {opt["label"]} - - {opt["type"]} - <%= if !enabled do %> - Disabled - <% end %> - <%= if opt["required"] do %> - Required - <% end %> - <%= if opt["unit"] do %> - {opt["unit"]} - <% end %> - <%= if opt["affects_price"] do %> - - {opt["modifier_type"] || "fixed"} - - <%= if opt["allow_override"] do %> - Override - <% end %> - <% end %> -
-
- - {opt["key"]} - - <%= if opt["options"] && opt["options"] != [] do %> - - {format_options_with_modifiers(opt)} - - <% end %> -
-
- - <%!-- Actions Column --%> -
- - - -
-
- <% end %> -
- <% end %> -
-
- - <%!-- Reference Section (collapsible) --%> -
- -
- <.icon name="hero-book-open" class="w-4 h-4" /> Option Types Reference -
-
-
-
-

- Input Types -

-
- text - number - boolean - select - multiselect -
-
-
-

- Price Modifiers -

-
- fixed (+10) - percent (+20%) -
-

- Enable "Allow Override" for per-product values -

-
-
-
-
-
- - <%!-- Modal for Add/Edit Option --%> - <%= if @show_modal do %> - - <% end %> -
- """ - end - - # Private helpers - - defp initial_form_data do - %{ - key: "", - label: "", - type: "text", - options: [], - required: false, - unit: "", - affects_price: false, - modifier_type: "fixed", - price_modifiers: %{}, - allow_override: false, - enabled: true - } - end - - defp slugify_key(""), do: "" - - defp slugify_key(text) do - text - |> String.downcase() - |> String.replace(~r/[^a-z0-9\s]/, "") - |> String.replace(~r/\s+/, "_") - |> String.replace(~r/_+/, "_") - |> String.trim("_") - end - - defp parse_form_params(params) do - options = parse_options(params["options"]) - - %{ - key: params["key"] || "", - label: params["label"] || "", - type: params["type"] || "text", - options: options, - required: params["required"] == "true", - unit: params["unit"] || "", - affects_price: params["affects_price"] == "true", - modifier_type: params["modifier_type"] || "fixed", - price_modifiers: parse_price_modifiers(params["price_modifiers"], options), - allow_override: params["allow_override"] == "true" - } - end - - defp build_option(form_data) do - key = if form_data.key == "", do: slugify_key(form_data.label), else: form_data.key - - %{ - "key" => key, - "label" => form_data.label, - "type" => form_data.type, - "required" => form_data.required - } - |> maybe_put_options(form_data) - |> maybe_put_unit(form_data) - |> maybe_put_price_modifiers(form_data) - end - - defp maybe_put_options(opt, %{type: type, options: options}) - when type in ["select", "multiselect"], - do: Map.put(opt, "options", options) - - defp maybe_put_options(opt, _), do: opt - - defp maybe_put_unit(opt, %{unit: ""}), do: opt - defp maybe_put_unit(opt, %{unit: unit}), do: Map.put(opt, "unit", unit) - - defp maybe_put_price_modifiers( - opt, - %{ - type: type, - affects_price: true, - modifier_type: modifier_type, - price_modifiers: mods, - allow_override: allow_override - } - ) - when type in ["select", "multiselect"] do - opt - |> Map.put("affects_price", true) - |> Map.put("modifier_type", modifier_type) - |> Map.put("price_modifiers", mods) - |> Map.put("allow_override", allow_override) - end - - defp maybe_put_price_modifiers(opt, _), do: Map.put(opt, "affects_price", false) - - defp parse_options(nil), do: [] - - defp parse_options(options) when is_map(options) do - options - # Filter out Phoenix LiveView's hidden _unused_ fields - |> Enum.reject(fn {k, _v} -> String.starts_with?(k, "_unused") end) - |> Enum.sort_by(fn {k, _v} -> - case Integer.parse(k) do - {num, ""} -> num - _ -> 0 - end - end) - |> Enum.map(fn {_k, v} -> v end) - |> Enum.reject(&(&1 == "")) - end - - defp parse_options(options) when is_list(options), do: options - defp parse_options(_), do: [] - - defp parse_price_modifiers(nil, _options), do: %{} - - defp parse_price_modifiers(modifiers, options) when is_map(modifiers) do - # Only keep modifiers for valid options, with valid decimal values - Enum.reduce(options, %{}, fn opt, acc -> - value = Map.get(modifiers, opt, "0") - # Normalize the value to a valid decimal string - normalized = normalize_price_modifier(value) - Map.put(acc, opt, normalized) - end) - end - - defp parse_price_modifiers(_, _), do: %{} - - defp normalize_price_modifier(value) when is_binary(value) do - case Decimal.parse(value) do - {decimal, ""} -> Decimal.to_string(decimal) - _ -> "0" - end - end - - defp normalize_price_modifier(_), do: "0" - - defp format_options_with_modifiers(%{ - "affects_price" => true, - "options" => options, - "modifier_type" => modifier_type, - "price_modifiers" => modifiers - }) - when is_list(options) and is_map(modifiers) do - suffix = if modifier_type == "percent", do: "%", else: "" - - Enum.map_join(options, ", ", fn opt -> - case Map.get(modifiers, opt) do - nil -> opt - "0" -> opt - mod -> "#{opt} (+#{mod}#{suffix})" - end - end) - end - - defp format_options_with_modifiers(%{ - "affects_price" => true, - "options" => options, - "price_modifiers" => modifiers - }) - when is_list(options) and is_map(modifiers) do - # Default to fixed for backward compatibility - Enum.map_join(options, ", ", fn opt -> - case Map.get(modifiers, opt) do - nil -> opt - "0" -> opt - mod -> "#{opt} (+#{mod})" - end - end) - end - - defp format_options_with_modifiers(%{"options" => options}) when is_list(options) do - Enum.join(options, ", ") - end - - defp format_options_with_modifiers(_), do: "" -end diff --git a/lib/modules/shop/web/plugs/shop_session.ex b/lib/modules/shop/web/plugs/shop_session.ex deleted file mode 100644 index 8c67ca26f..000000000 --- a/lib/modules/shop/web/plugs/shop_session.ex +++ /dev/null @@ -1,59 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Plugs.ShopSession do - @moduledoc """ - Plug that ensures a persistent shop session ID exists. - - This plug generates a unique session ID for guest users and stores it - both in a dedicated cookie AND in the Phoenix session. This ensures - the same cart is used across different pages. - """ - - import Plug.Conn - - alias PhoenixKit.Modules.Shop - - @cookie_name "shop_session_id" - # 30 days - @cookie_max_age 60 * 60 * 24 * 30 - - def init(opts), do: opts - - def call(conn, _opts) do - if Shop.enabled?() do - # First try to get from cookie (most reliable) - # Then fall back to session - session_id = get_shop_session_id(conn) - - case session_id do - nil -> - new_id = generate_session_id() - - conn - |> put_resp_cookie(@cookie_name, new_id, max_age: @cookie_max_age, http_only: true) - |> put_session("shop_session_id", new_id) - - existing_id -> - put_session(conn, "shop_session_id", existing_id) - end - else - conn - end - end - - defp get_shop_session_id(conn) do - # Try cookie first - conn = fetch_cookies(conn) - - case conn.cookies[@cookie_name] do - nil -> - # Fall back to session - get_session(conn, "shop_session_id") - - cookie_value -> - cookie_value - end - end - - defp generate_session_id do - :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) - end -end diff --git a/lib/modules/shop/web/product_detail.ex b/lib/modules/shop/web/product_detail.ex deleted file mode 100644 index 53a4576bc..000000000 --- a/lib/modules/shop/web/product_detail.ex +++ /dev/null @@ -1,855 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.ProductDetail do - @moduledoc """ - Product detail view LiveView for Shop module. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing.Currency - alias PhoenixKit.Modules.Languages - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Options - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Storage - alias PhoenixKit.Modules.Storage.URLSigner - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"id" => id}, _session, socket) do - product = Shop.get_product!(id, preload: [:category]) - currency = Shop.get_default_currency() - - # Get price-affecting specs for admin view - price_affecting_specs = Options.get_price_affecting_specs_for_product(product) - - # Get all selectable specs for admin view (includes all schema options, not filtered) - selectable_specs = Options.get_all_selectable_specs_for_admin(product) - - {min_price, max_price} = - Options.get_price_range(price_affecting_specs, product.price, product.metadata) - - default_lang = Translations.default_language() - product_title = Translations.get(product, :title, default_lang) - product_slug = Translations.get(product, :slug, default_lang) - product_description = Translations.get(product, :description, default_lang) - product_body_html = Translations.get(product, :body_html, default_lang) - product_seo_title = Translations.get(product, :seo_title, default_lang) - product_seo_description = Translations.get(product, :seo_description, default_lang) - - # Get enabled languages for preview switcher - available_languages = get_available_languages() - - # Get all images for the gallery - all_images = get_all_product_images(product) - first_image_uuid = get_first_image_uuid(product) - - # Auto-select first value of each option for immediate add-to-cart - # Uses selectable_specs to include both metadata and schema-defined options - selected_specs = - selectable_specs - |> Enum.map(fn spec -> - key = spec["key"] - values = get_option_values(product, spec) - {key, List.first(values)} - end) - |> Enum.reject(fn {_key, value} -> is_nil(value) end) - |> Enum.into(%{}) - - socket = - socket - |> assign(:page_title, product_title) - |> assign(:product, product) - |> assign(:product_title, product_title) - |> assign(:product_slug, product_slug) - |> assign(:product_description, product_description) - |> assign(:product_body_html, product_body_html) - |> assign(:product_seo_title, product_seo_title) - |> assign(:product_seo_description, product_seo_description) - |> assign(:current_language, default_lang) - |> assign(:available_languages, available_languages) - |> assign(:currency, currency) - |> assign(:price_affecting_specs, price_affecting_specs) - |> assign(:min_price, min_price) - |> assign(:max_price, max_price) - |> assign(:all_images, all_images) - |> assign(:selected_image_uuid, first_image_uuid) - |> assign(:selectable_specs, selectable_specs) - |> assign(:selected_specs, selected_specs) - |> assign(:show_delete_modal, false) - |> assign(:delete_media_checked, false) - - {:ok, socket} - end - - @impl true - def handle_event("confirm_delete", _params, socket) do - {:noreply, socket |> assign(:show_delete_modal, true) |> assign(:delete_media_checked, false)} - end - - @impl true - def handle_event("cancel_delete", _params, socket) do - {:noreply, - socket |> assign(:show_delete_modal, false) |> assign(:delete_media_checked, false)} - end - - @impl true - def handle_event("toggle_delete_media", _params, socket) do - {:noreply, assign(socket, :delete_media_checked, !socket.assigns.delete_media_checked)} - end - - @impl true - def handle_event("delete", _params, socket) do - product = socket.assigns.product - - file_uuids = - if socket.assigns.delete_media_checked, - do: Shop.collect_product_file_uuids(product), - else: [] - - case Shop.delete_product(product) do - {:ok, _} -> - if file_uuids != [], do: Storage.queue_file_cleanup(file_uuids) - - {:noreply, - socket - |> put_flash(:info, "Product deleted") - |> push_navigate(to: Routes.path("/admin/shop/products"))} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to delete product")} - end - end - - @impl true - def handle_event("select_image", %{"uuid" => image_uuid}, socket) do - {:noreply, assign(socket, :selected_image_uuid, image_uuid)} - end - - @impl true - def handle_event("select_option", %{"key" => key, "value" => value}, socket) do - product = socket.assigns.product - selected_specs = Map.put(socket.assigns.selected_specs, key, value) - - # Check for image mapping - update selected_image_uuid if mapping exists - selected_image_uuid = - get_mapped_image_uuid(product, key, value, socket.assigns.selected_image_uuid) - - {:noreply, - socket - |> assign(:selected_specs, selected_specs) - |> assign(:selected_image_uuid, selected_image_uuid)} - end - - @impl true - def handle_event("switch_preview_language", %{"language" => language}, socket) do - product = socket.assigns.product - - # Update localized content for the selected language - product_title = Translations.get(product, :title, language) - product_slug = Translations.get(product, :slug, language) - product_description = Translations.get(product, :description, language) - product_body_html = Translations.get(product, :body_html, language) - product_seo_title = Translations.get(product, :seo_title, language) - product_seo_description = Translations.get(product, :seo_description, language) - - socket = - socket - |> assign(:current_language, language) - |> assign(:product_title, product_title) - |> assign(:product_slug, product_slug) - |> assign(:product_description, product_description) - |> assign(:product_body_html, product_body_html) - |> assign(:product_seo_title, product_seo_title) - |> assign(:product_seo_description, product_seo_description) - - {:noreply, socket} - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header - back={Routes.path("/admin/shop/products")} - title={@product_title} - subtitle={@product_slug} - /> - - <%!-- Controls Bar --%> -
-
- <%!-- Language Preview Switcher --%> -
- - <.icon name="hero-eye" class="w-4 h-4 inline mr-1" /> Preview: - -
- <%= for lang <- @available_languages do %> - - <% end %> -
-
- - <%!-- Action Buttons --%> -
- <.link - navigate={Routes.path("/admin/shop/products/#{@product.uuid}/edit")} - class="btn btn-primary" - > - <.icon name="hero-pencil" class="w-4 h-4 mr-2" /> Edit - - -
-
-
- -
- <%!-- Main Content --%> -
- <%!-- Product Image --%> -
-
-

Image

- <% selected_url = get_image_url_by_uuid(@product, @selected_image_uuid) %> -
- <%= if selected_url do %> - {@product_title} - <% else %> -
- <.icon name="hero-photo" class="w-16 h-16 opacity-30" /> - No image -
- <% end %> -
- <%= if has_multiple_images?(@product) do %> -
- <%= for {image_uuid, url} <- @all_images do %> - <%= if url do %> - - <% end %> - <% end %> -
- <% end %> -
-
- - <%!-- Option Values Section --%> - <% image_mappings = @product.metadata["_image_mappings"] || %{} %> - <%= if @selectable_specs != [] do %> -
-
-

- <.icon name="hero-adjustments-horizontal" class="w-5 h-5" /> Available Options -

-
- <%= for attr <- @selectable_specs do %> - <% affects_price = attr["affects_price"] == true %> -
- {attr["label"]}: - <%= for value <- get_option_values(@product, attr) do %> - <% has_image = get_in(image_mappings, [attr["key"], value]) not in [nil, ""] %> - <% price_mod = - affects_price && get_price_modifier(@product, attr["key"], value) %> - - <% end %> -
- <% end %> -
-
-
- <% end %> - - <%!-- Details --%> -
-
-

Product Details

- - <%= if @product_description do %> - <.markdown - content={@product_description} - sanitize={false} - compact - class="text-base-content/80" - /> - <% end %> - -
- -
-
- Type: - {@product.product_type} -
-
- Vendor: - {@product.vendor || "—"} -
-
- Taxable: - {if @product.taxable, do: "Yes", else: "No"} -
-
- Weight: - {@product.weight_grams || 0}g -
-
- Requires Shipping: - - {if @product.requires_shipping, do: "Yes", else: "No"} - -
-
- Made to Order: - - {if @product.made_to_order, do: "Yes", else: "No"} - -
-
- - <%!-- Tags --%> - <%= if @product.tags && @product.tags != [] do %> -
-
- Tags: -
- <%= for tag <- @product.tags do %> - {tag} - <% end %> -
-
- <% end %> - - <%!-- Body HTML --%> - <%= if @product_body_html && @product_body_html != "" do %> -
-
- Full Description: -
- {Phoenix.HTML.raw(@product_body_html)} -
-
- <% end %> -
-
- - <%!-- Pricing --%> -
-
-
-

Pricing

- {(@currency && @currency.code) || "—"} -
- -
-
-
Price
-
- {format_price(@product.price, @currency)} -
-
- - <%= if @product.compare_at_price do %> -
-
Compare At
-
- {format_price(@product.compare_at_price, @currency)} -
-
- <% end %> - - <%= if @product.cost_per_item do %> -
-
Cost
-
- {format_price(@product.cost_per_item, @currency)} -
-
- <% end %> -
-
-
- - <%!-- Price Modifiers Section (Admin Only) --%> - <%= if @price_affecting_specs != [] do %> -
-
-

- <.icon name="hero-calculator" class="w-5 h-5" /> Price Calculation -

- -
- <%!-- Base Price --%> -
- Base Price - {format_price(@product.price, @currency)} -
- - <%!-- Options with modifiers --%> - <%= for spec <- @price_affecting_specs do %> -
-
- {spec["label"]} - - {spec["modifier_type"] || "fixed"} - -
-
- <%= for {value, modifier} <- spec["price_modifiers"] || %{} do %> - <% mod_value = parse_modifier(modifier) %> - - {value} - <%= if Decimal.compare(mod_value, Decimal.new("0")) != :eq do %> - - +{format_modifier(mod_value, spec["modifier_type"], @currency)} - - <% end %> - - <% end %> -
-
- <% end %> - - <%!-- Price Range --%> -
-
- Price Range - - <%= if Decimal.compare(@min_price, @max_price) == :eq do %> - {format_price(@min_price, @currency)} - <% else %> - {format_price(@min_price, @currency)} — {format_price(@max_price, @currency)} - <% end %> - -
-
-
-
- <% end %> -
- - <%!-- Sidebar --%> -
- <%!-- Status --%> -
-
-

Status

-
- - {String.capitalize(@product.status)} - -
-
-
- - <%!-- Category --%> -
-
-

Category

- <%= if @product.category do %> - - {Translations.get(@product.category, :name, @current_language)} - - <% else %> - No category - <% end %> -
-
- - <%!-- Digital Product --%> - <%= if @product.product_type == "digital" do %> -
-
-

Digital Product

-
-
- File: - - {if @product.file_uuid, do: "Attached", else: "—"} - -
-
- Download Limit: - {@product.download_limit || "Unlimited"} -
-
- Expiry: - - {if @product.download_expiry_days, - do: "#{@product.download_expiry_days} days", - else: "Never"} - -
-
-
-
- <% end %> - - <%!-- SEO --%> - <%= if @product_seo_title || @product_seo_description do %> -
-
-

SEO

-
- <%= if @product_seo_title do %> -
- Title: -

{@product_seo_title}

-
- <% end %> - <%= if @product_seo_description do %> -
- Description: -

{@product_seo_description}

-
- <% end %> -
-
-
- <% end %> - - <%!-- Timestamps --%> -
-
-

Timestamps

-
-
- Created: - - {Calendar.strftime(@product.inserted_at, "%Y-%m-%d %H:%M")} - -
-
- Updated: - - {Calendar.strftime(@product.updated_at, "%Y-%m-%d %H:%M")} - -
-
-
-
-
-
-
- <%!-- Delete Product Modal --%> - <%= if @show_delete_modal do %> - - <% end %> -
- """ - end - - defp get_mapped_image_uuid(product, option_key, option_value, current_image_uuid) do - case get_in(product.metadata || %{}, ["_image_mappings", option_key, option_value]) do - nil -> current_image_uuid - "" -> current_image_uuid - "http" <> _rest -> current_image_uuid - image_uuid -> image_uuid - end - end - - defp get_option_values(product, option) do - key = option["key"] - - values = - case product.metadata do - %{"_option_values" => %{^key => vals}} when is_list(vals) and vals != [] -> - vals - - _ -> - option["options"] || [] - end - - # Apply stored order if exists - stored_order = get_in(product.metadata, ["_option_value_order", key]) - - if stored_order do - # Filter to only include values that still exist - ordered_existing = Enum.filter(stored_order, &(&1 in values)) - # Add any new values not in stored order at the end - new_values = Enum.reject(values, &(&1 in stored_order)) - ordered_existing ++ new_values - else - values - end - end - - defp get_price_modifier(product, key, value) do - case product.metadata do - %{"_price_modifiers" => %{^key => modifiers}} when is_map(modifiers) -> - case Map.get(modifiers, value) do - mod when is_number(mod) -> Decimal.new("#{mod}") - mod when is_binary(mod) -> Decimal.new(mod) - _ -> nil - end - - _ -> - nil - end - end - - defp format_price_modifier(nil, _currency), do: "" - - defp format_price_modifier(mod, currency) do - cond do - Decimal.compare(mod, 0) == :gt -> "+#{format_price(mod, currency)}" - Decimal.compare(mod, 0) == :lt -> format_price(mod, currency) - true -> "" - end - end - - defp status_badge_class("active"), do: "badge badge-success badge-lg" - defp status_badge_class("draft"), do: "badge badge-warning badge-lg" - defp status_badge_class("archived"), do: "badge badge-neutral badge-lg" - defp status_badge_class(_), do: "badge badge-lg" - - defp format_price(nil, _currency), do: "—" - - defp format_price(price, %Currency{} = currency) do - Currency.format_amount(price, currency) - end - - defp format_price(price, nil) do - "$#{Decimal.round(price, 2)}" - end - - # Get signed URL for Storage image (skip URLs - they are legacy Shopify images) - defp get_storage_image_url("http" <> _ = _url, _variant), do: nil - - defp get_storage_image_url(file_uuid, variant) do - case Storage.get_file(file_uuid) do - %{uuid: uuid} -> - case Storage.get_file_instance_by_name(uuid, variant) do - nil -> - case Storage.get_file_instance_by_name(uuid, "original") do - nil -> nil - _instance -> URLSigner.signed_url(file_uuid, "original") - end - - _instance -> - URLSigner.signed_url(file_uuid, variant) - end - - nil -> - nil - end - end - - defp image_url(%{"src" => src}), do: src - defp image_url(url) when is_binary(url), do: url - defp image_url(_), do: nil - - # Check if product has multiple images (Storage format or legacy) - defp has_multiple_images?(%{featured_image_uuid: id, image_uuids: [_ | _]}) - when is_binary(id), - do: true - - defp has_multiple_images?(%{image_uuids: [_, _ | _]}), do: true - defp has_multiple_images?(%{images: [_, _ | _]}), do: true - defp has_multiple_images?(_), do: false - - # Get ID of the first image (for initial selection) - defp get_first_image_uuid(%{featured_image_uuid: id}) when is_binary(id), do: id - defp get_first_image_uuid(%{image_uuids: [id | _]}) when is_binary(id), do: id - defp get_first_image_uuid(%{images: [%{"src" => src} | _]}), do: src - defp get_first_image_uuid(%{images: [url | _]}) when is_binary(url), do: url - defp get_first_image_uuid(_), do: nil - - # Get image URL by ID (for selected image display) - # Storage-based images: featured_image_uuid is a UUID string - defp get_image_url_by_uuid(%{featured_image_uuid: featured_uuid} = product, image_uuid) - when is_binary(featured_uuid) and is_binary(image_uuid) do - cond do - featured_uuid == image_uuid -> get_storage_image_url(image_uuid, "small") - image_uuid in (product.image_uuids || []) -> get_storage_image_url(image_uuid, "small") - true -> get_storage_image_url(image_uuid, "small") - end - end - - defp get_image_url_by_uuid(%{image_uuids: [_ | _] = ids}, image_uuid) - when is_binary(image_uuid) do - if image_uuid in ids do - get_storage_image_url(image_uuid, "small") - else - nil - end - end - - defp get_image_url_by_uuid(%{images: images}, image_uuid) when is_binary(image_uuid) do - # For legacy images, image_uuid is the URL itself - if Enum.any?(images, fn img -> image_url(img) == image_uuid end) do - image_uuid - else - nil - end - end - - defp get_image_url_by_uuid(_, _), do: nil - - # Get all product images as list of {id, url} tuples (featured first, then gallery) - defp get_all_product_images(%{featured_image_uuid: featured_uuid, image_uuids: gallery_uuids}) - when is_binary(featured_uuid) do - # Combine featured + gallery, avoiding duplicates - all_ids = [featured_uuid | Enum.reject(gallery_uuids || [], &(&1 == featured_uuid))] - - Enum.map(all_ids, fn id -> - url = get_storage_image_url(id, "thumbnail") - {id, url} - end) - |> Enum.reject(fn {_, url} -> is_nil(url) end) - end - - defp get_all_product_images(%{image_uuids: [_ | _] = ids}) do - Enum.map(ids, fn id -> - url = get_storage_image_url(id, "thumbnail") - {id, url} - end) - |> Enum.reject(fn {_, url} -> is_nil(url) end) - end - - defp get_all_product_images(%{images: images}) when is_list(images) do - # For legacy images, use URL as ID - Enum.map(images, fn img -> - url = image_url(img) - {url, url} - end) - |> Enum.reject(fn {_, url} -> is_nil(url) end) - end - - defp get_all_product_images(_), do: [] - - # Price modifier helpers - defp parse_modifier(value) when is_binary(value) do - case Decimal.parse(value) do - {decimal, ""} -> decimal - _ -> Decimal.new("0") - end - end - - defp parse_modifier(%{"value" => value}), do: parse_modifier(value) - defp parse_modifier(_), do: Decimal.new("0") - - defp format_modifier(value, "percent", _currency) do - "#{Decimal.round(value, 0)}%" - end - - defp format_modifier(value, _type, %Currency{} = currency) do - Currency.format_amount(value, currency) - end - - defp format_modifier(value, _type, _currency) do - "$#{Decimal.round(value, 2)}" - end - - # Get available languages for preview switcher - defp get_available_languages do - case Languages.get_enabled_languages() do - [] -> - # Fallback to default language when no languages enabled - [%{code: Translations.default_language(), base: "en", flag: "🇺🇸", name: "English"}] - - enabled -> - Enum.map(enabled, fn lang -> - code = lang.code - base = DialectMapper.extract_base(code) - predefined = Languages.get_predefined_language(code) - - %{ - code: code, - base: base, - flag: (predefined && predefined.flag) || "🌐", - name: lang.name || code - } - end) - end - end -end diff --git a/lib/modules/shop/web/product_form.ex b/lib/modules/shop/web/product_form.ex deleted file mode 100644 index 64bf5491d..000000000 --- a/lib/modules/shop/web/product_form.ex +++ /dev/null @@ -1,2401 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.ProductForm do - @moduledoc """ - Product create/edit form LiveView for Shop module. - - Includes dynamic option fields based on merged global + category schema, - and displays option prices table for options that affect pricing. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing.Currency - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Options - alias PhoenixKit.Modules.Shop.Product - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Shop.Web.Components.TranslationTabs - alias PhoenixKit.Modules.Storage.URLSigner - alias PhoenixKit.Utils.Routes - - import TranslationTabs - - @impl true - def mount(_params, _session, socket) do - {:ok, assign(socket, :page_title, "New Product")} - end - - @impl true - def handle_params(params, _uri, socket) do - socket = apply_action(socket, socket.assigns.live_action, params) - {:noreply, socket} - end - - defp apply_action(socket, :new, _params) do - product = %Product{} - changeset = Shop.change_product(product) - categories = Shop.category_options() - currency = Shop.get_default_currency() - - # Get global options (no category selected yet) - option_schema = Options.get_enabled_global_options() - price_affecting_options = get_price_affecting_options(option_schema) - - socket - |> assign(:page_title, "New Product") - |> assign(:product, product) - |> assign(:changeset, changeset) - |> assign(:categories, categories) - |> assign(:currency, currency) - |> assign(:option_schema, option_schema) - |> assign(:metadata, %{}) - |> assign(:price_affecting_options, price_affecting_options) - |> assign(:show_media_selector, false) - |> assign(:media_selection_mode, :single) - |> assign(:media_selection_target, nil) - |> assign(:all_image_uuids, []) - |> assign(:new_value_inputs, %{}) - |> assign(:selected_option_values, %{}) - |> assign(:original_option_values, %{}) - |> assign(:add_option_key, "") - |> assign(:add_option_value, "") - |> assign_translation_state(%Product{}) - end - - defp apply_action(socket, :edit, %{"id" => id}) do - product = Shop.get_product!(id, preload: [:category]) - changeset = Shop.change_product(product) - categories = Shop.category_options() - currency = Shop.get_default_currency() - - # Get merged option schema for the product - option_schema = Options.get_option_schema_for_product(product) - metadata = product.metadata || %{} - price_affecting_options = get_price_affecting_options(option_schema) - - # Build unified image list: featured first, then gallery (for unified drag-and-drop UI) - gallery_uuids = product.image_uuids || [] - featured_uuid = product.featured_image_uuid - all_image_uuids = build_all_image_uuids(featured_uuid, gallery_uuids) - valid_image_uuids = all_image_uuids - - # Clean stale image mappings (images that no longer exist) - {metadata, had_stale_mappings} = clean_stale_image_mappings(metadata, valid_image_uuids) - - # Calculate price range for display (pass metadata for custom modifiers) - base_price = product.price || Decimal.new("0") - - {min_price, max_price} = - Options.get_price_range(price_affecting_options, base_price, metadata) - - # Store original option values for UI (so unchecking all doesn't hide the section) - original_option_values = metadata["_option_values"] || %{} - - # Selected option values - managed in assigns, not in form - selected_option_values = metadata["_option_values"] || %{} - - product_title = Translations.get(product, :title, TranslationTabs.get_default_language()) - - socket - |> assign(:page_title, "Edit #{product_title}") - |> assign(:product, product) - |> assign(:changeset, changeset) - |> assign(:categories, categories) - |> assign(:currency, currency) - |> assign(:option_schema, option_schema) - |> assign(:metadata, metadata) - |> assign(:original_option_values, original_option_values) - |> assign(:price_affecting_options, price_affecting_options) - |> assign(:min_price, min_price) - |> assign(:max_price, max_price) - |> assign(:show_media_selector, false) - |> assign(:media_selection_mode, :single) - |> assign(:media_selection_target, nil) - |> assign(:all_image_uuids, all_image_uuids) - |> assign(:new_value_inputs, %{}) - |> assign(:selected_option_values, selected_option_values) - |> assign(:add_option_key, "") - |> assign(:add_option_value, "") - |> assign_translation_state(product) - |> maybe_warn_stale_mappings(had_stale_mappings) - end - - # Assign translation-related state (localized fields model) - defp assign_translation_state(socket, product) do - enabled_languages = TranslationTabs.get_enabled_languages() - default_language = TranslationTabs.get_default_language() - show_translations = TranslationTabs.show_translation_tabs?() - - # Build translations map from localized fields for UI - translatable_fields = Translations.product_fields() - translations_map = TranslationTabs.build_translations_map(product, translatable_fields) - - socket - |> assign(:enabled_languages, enabled_languages) - |> assign(:default_language, default_language) - |> assign(:current_translation_language, default_language) - |> assign(:show_translation_tabs, show_translations) - |> assign(:product_translations, translations_map) - end - - @impl true - def handle_event("validate", %{"product" => product_params} = params, socket) do - # Update translations from form params (needed before build_localized_params) - product_translations = - merge_translation_params( - socket.assigns[:product_translations] || %{}, - product_params["translations"] - ) - - # Build localized field attrs from main form values and translations - product_params = - build_localized_params( - socket.assigns.product, - product_params, - product_translations, - socket.assigns.default_language - ) - - changeset = - socket.assigns.product - |> Shop.change_product(product_params) - |> Map.put(:action, :validate) - - # Update option schema if category changed - new_category_uuid = product_params["category_uuid"] - - old_category_uuid = - socket.assigns.product.category_uuid - - socket = - if new_category_uuid != old_category_uuid do - option_schema = get_schema_for_category_uuid(new_category_uuid) - price_affecting_options = get_price_affecting_options(option_schema) - - socket - |> assign(:option_schema, option_schema) - |> assign(:price_affecting_options, price_affecting_options) - else - socket - end - - base_price = parse_decimal(product_params["price"]) - raw_metadata = product_params["metadata"] || %{} - new_value_inputs = extract_new_value_inputs(params, socket.assigns[:new_value_inputs] || %{}) - add_option_key = params["_add_option_key"] || "" - add_option_value = params["_add_option_first_value"] || "" - metadata = convert_final_prices_to_modifiers(raw_metadata, base_price) - socket = maybe_update_price_range(socket, product_params, metadata) - - socket - |> assign(:changeset, changeset) - |> assign(:metadata, metadata) - |> assign(:new_value_inputs, new_value_inputs) - |> assign(:add_option_key, add_option_key) - |> assign(:add_option_value, add_option_value) - |> assign(:product_translations, product_translations) - |> then(&{:noreply, &1}) - end - - @impl true - def handle_event("save", %{"product" => product_params}, socket) do - # Remove helper fields from params (they're just UI helpers) - product_params = - product_params - |> Enum.reject(fn {k, _v} -> - String.starts_with?(k, "_new_option_value_") or - String.starts_with?(k, "_add_option_") - end) - |> Map.new() - - # Merge metadata into product params - metadata = product_params["metadata"] || %{} - base_price = parse_decimal(product_params["price"]) - - # Convert final_price inputs to modifier values - metadata = convert_final_prices_to_modifiers(metadata, base_price) - - # Remove _option_values from form metadata (may have garbage from Phoenix) - metadata = Map.delete(metadata, "_option_values") - - # Add _option_values from socket assigns (managed via phx-click) - selected_option_values = socket.assigns.selected_option_values - - metadata = - if selected_option_values == %{} do - metadata - else - Map.put(metadata, "_option_values", selected_option_values) - end - - # Clean up _option_values - remove entries where all values are selected - metadata = - clean_option_values( - metadata, - socket.assigns.option_schema, - socket.assigns[:original_option_values] || %{} - ) - - # Clean up _image_mappings - remove empty values and invalid image IDs - valid_image_uuids = build_valid_image_uuids(socket.assigns) - metadata = clean_image_mappings(metadata, valid_image_uuids) - - # Clean up metadata - convert multiselect arrays if needed - cleaned_metadata = - metadata - |> Enum.map(fn - {k, v} when is_list(v) -> {k, Enum.reject(v, &(&1 == ""))} - {k, v} -> {k, v} - end) - |> Map.new() - - product_params = Map.put(product_params, "metadata", cleaned_metadata) - - # Extract featured and gallery from unified image list - all_images = socket.assigns.all_image_uuids - featured_uuid = List.first(all_images) - gallery_uuids = Enum.drop(all_images, 1) - - product_params = - product_params - |> Map.put("featured_image_uuid", featured_uuid) - |> Map.put("image_uuids", gallery_uuids) - - # Build localized field attrs from main form values and translations - product_params = - build_localized_params( - socket.assigns.product, - product_params, - socket.assigns[:product_translations] || %{}, - socket.assigns.default_language - ) - - save_product(socket, socket.assigns.live_action, product_params) - end - - # =========================================== - # TRANSLATION LANGUAGE SWITCHING - # =========================================== - - def handle_event("switch_language", %{"language" => language}, socket) do - {:noreply, assign(socket, :current_translation_language, language)} - end - - # IMAGE MANAGEMENT - # =========================================== - - def handle_event("open_media_picker", _params, socket) do - {:noreply, - socket - |> assign(:show_media_selector, true) - |> assign(:media_selection_mode, :multiple) - |> assign(:media_selection_target, :gallery)} - end - - def handle_event("remove_image", %{"uuid" => uuid}, socket) do - updated = Enum.reject(socket.assigns.all_image_uuids, &(&1 == uuid)) - {:noreply, assign(socket, :all_image_uuids, updated)} - end - - def handle_event("reorder_images", %{"ordered_ids" => ordered_ids}, socket) do - {:noreply, assign(socket, :all_image_uuids, ordered_ids)} - end - - # =========================================== - # OPTION VALUES MANAGEMENT - # =========================================== - - # Toggle option value selection (managed in socket assigns, not form) - # all_values is passed as JSON to know what "all selected" means - def handle_event( - "toggle_option_value", - %{"key" => option_key, "opt-value" => value, "all-values" => all_values_json}, - socket - ) do - selected = socket.assigns.selected_option_values - all_values = Jason.decode!(all_values_json) - - # If this key doesn't exist in selected, it means "all are selected" - # We need to initialize it properly when user starts toggling - current_for_key = - if Map.has_key?(selected, option_key) do - Map.get(selected, option_key, []) - else - # Key not in selected = all values are implicitly selected - all_values - end - - updated_for_key = - if value in current_for_key do - # Remove this value - Enum.reject(current_for_key, &(&1 == value)) - else - # Add this value - current_for_key ++ [value] - end - - # If updated list equals all values, remove the key (implicit "all selected") - updated_selected = - cond do - updated_for_key == [] -> - # None selected - keep explicit empty list - Map.put(selected, option_key, []) - - Enum.sort(updated_for_key) == Enum.sort(all_values) -> - # All selected - remove key to indicate "all" - Map.delete(selected, option_key) - - true -> - Map.put(selected, option_key, updated_for_key) - end - - {:noreply, assign(socket, :selected_option_values, updated_selected)} - end - - # Track input value changes for add new value fields - def handle_event("update_new_value_input", %{"key" => key, "value" => value}, socket) do - new_inputs = Map.put(socket.assigns[:new_value_inputs] || %{}, key, value) - {:noreply, assign(socket, :new_value_inputs, new_inputs)} - end - - # Handle Enter key in add value input - def handle_event("add_option_value_keydown", %{"key" => option_key}, socket) do - new_inputs = socket.assigns[:new_value_inputs] || %{} - value = Map.get(new_inputs, option_key, "") |> String.trim() - do_add_option_value(socket, option_key, value) - end - - # Handle click on Add button - get value from tracked inputs - def handle_event("add_option_value_click", %{"key" => option_key}, socket) do - new_inputs = socket.assigns[:new_value_inputs] || %{} - value = Map.get(new_inputs, option_key, "") |> String.trim() - do_add_option_value(socket, option_key, value) - end - - def handle_event("add_option_value", %{"key" => option_key, "new_value" => value}, socket) do - value = String.trim(value) - - if value == "" do - {:noreply, socket} - else - # Check in both original and current values - original_values = socket.assigns[:original_option_values] || %{} - original_for_key = Map.get(original_values, option_key, []) - - metadata = socket.assigns.metadata - option_values = metadata["_option_values"] || %{} - current_values = Map.get(option_values, option_key, []) - - # Also check schema values - schema_opt = Enum.find(socket.assigns.option_schema, &(&1["key"] == option_key)) - schema_values = (schema_opt && schema_opt["options"]) || [] - - all_existing = Enum.uniq(original_for_key ++ current_values ++ schema_values) - - if value in all_existing do - {:noreply, put_flash(socket, :error, "Value '#{value}' already exists")} - else - # Add to original_option_values - updated_original = Map.put(original_values, option_key, original_for_key ++ [value]) - - # Add to selected_option_values (new value is selected by default) - # If key doesn't exist in selected, initialize with all values first - selected = socket.assigns.selected_option_values - - current_selected = - if Map.has_key?(selected, option_key) do - Map.get(selected, option_key, []) - else - # Key not present = all values implicitly selected - Enum.uniq(schema_values ++ original_for_key) - end - - updated_selected = Map.put(selected, option_key, current_selected ++ [value]) - - {:noreply, - socket - |> assign(:original_option_values, updated_original) - |> assign(:selected_option_values, updated_selected)} - end - end - end - - def handle_event("add_option_value", %{"key" => _option_key}, socket) do - # No value provided - {:noreply, socket} - end - - # Handle click on Add button for new option (reads from assigns) - def handle_event("add_new_option_click", _params, socket) do - key = - (socket.assigns[:add_option_key] || "") - |> String.trim() - |> String.downcase() - |> String.replace(~r/\s+/, "_") - - value = (socket.assigns[:add_option_value] || "") |> String.trim() - do_add_new_option(socket, key, value) - end - - # Handle form submit for new option (legacy, reads from form params) - def handle_event("add_new_option", %{"option_key" => key, "first_value" => value}, socket) do - key = key |> String.trim() |> String.downcase() |> String.replace(~r/\s+/, "_") - value = String.trim(value) - do_add_new_option(socket, key, value) - end - - def handle_event("remove_option_value", %{"key" => option_key, "opt-value" => value}, socket) do - # Remove from original_option_values (available values) - original_values = socket.assigns[:original_option_values] || %{} - original_for_key = Map.get(original_values, option_key, []) - updated_original_for_key = Enum.reject(original_for_key, &(&1 == value)) - - updated_original = - if updated_original_for_key == [] do - Map.delete(original_values, option_key) - else - Map.put(original_values, option_key, updated_original_for_key) - end - - # Remove from selected_option_values (selected values) - selected = socket.assigns.selected_option_values - current_selected = Map.get(selected, option_key, []) - updated_selected_for_key = Enum.reject(current_selected, &(&1 == value)) - - updated_selected = - if updated_selected_for_key == [] do - Map.delete(selected, option_key) - else - Map.put(selected, option_key, updated_selected_for_key) - end - - # Also remove price modifier for this value if exists - metadata = socket.assigns.metadata - updated_metadata = remove_price_modifier_for_value(metadata, option_key, value) - - {:noreply, - socket - |> assign(:metadata, updated_metadata) - |> assign(:original_option_values, updated_original) - |> assign(:selected_option_values, updated_selected)} - end - - def handle_event( - "reorder_option_values:" <> option_key, - %{"ordered_ids" => ordered_values}, - socket - ) do - metadata = socket.assigns.metadata || %{} - - # Update the order in metadata - current_order = Map.get(metadata, "_option_value_order", %{}) - updated_order = Map.put(current_order, option_key, ordered_values) - - metadata = Map.put(metadata, "_option_value_order", updated_order) - - {:noreply, assign(socket, :metadata, metadata)} - end - - @impl true - def handle_info({:media_selected, file_uuids}, socket) do - socket = apply_media_selection(socket, socket.assigns.media_selection_target, file_uuids) - - {:noreply, assign(socket, :show_media_selector, false)} - end - - @impl true - def handle_info({:media_selector_closed}, socket) do - {:noreply, assign(socket, :show_media_selector, false)} - end - - defp apply_media_selection(socket, :gallery, file_uuids) do - current = socket.assigns.all_image_uuids - new_ids = Enum.reject(file_uuids, &(&1 in current)) - assign(socket, :all_image_uuids, current ++ new_ids) - end - - defp apply_media_selection(socket, _, _), do: socket - - # =========================================== - # PRIVATE FUNCTIONS - # =========================================== - - # Shared logic for adding option value - defp do_add_option_value(socket, option_key, value) do - if value == "" do - {:noreply, put_flash(socket, :error, "Please enter a value first")} - else - original_values = socket.assigns[:original_option_values] || %{} - original_for_key = Map.get(original_values, option_key, []) - - metadata = socket.assigns.metadata - option_values = metadata["_option_values"] || %{} - current_values = Map.get(option_values, option_key, []) - - # Also check schema values - schema_opt = Enum.find(socket.assigns.option_schema, &(&1["key"] == option_key)) - schema_values = (schema_opt && schema_opt["options"]) || [] - - all_existing = Enum.uniq(original_for_key ++ current_values ++ schema_values) - - if value in all_existing do - {:noreply, put_flash(socket, :error, "Value '#{value}' already exists")} - else - # Add to original_option_values (tracks all available values) - updated_original = Map.put(original_values, option_key, original_for_key ++ [value]) - - # Add to selected_option_values (new value is selected by default) - # If key doesn't exist in selected, initialize with all schema values first - selected = socket.assigns.selected_option_values - - current_selected = - if Map.has_key?(selected, option_key) do - Map.get(selected, option_key, []) - else - # Key not present = all values implicitly selected - # Initialize with schema values + original values - Enum.uniq(schema_values ++ original_for_key) - end - - updated_selected = Map.put(selected, option_key, current_selected ++ [value]) - - # Clear the input field - new_inputs = socket.assigns[:new_value_inputs] || %{} - new_inputs = Map.put(new_inputs, option_key, "") - - {:noreply, - socket - |> assign(:original_option_values, updated_original) - |> assign(:selected_option_values, updated_selected) - |> assign(:new_value_inputs, new_inputs) - |> put_flash(:info, "Value '#{value}' added")} - end - end - end - - defp do_add_new_option(socket, key, value) do - if key == "" or value == "" do - {:noreply, put_flash(socket, :error, "Option key and value are required")} - else - original_values = socket.assigns[:original_option_values] || %{} - current_values = socket.assigns.metadata["_option_values"] || %{} - - # Check if option already exists - if so, add value to it - existing_original = Map.get(original_values, key, []) - existing_current = Map.get(current_values, key, []) - all_existing = Enum.uniq(existing_original ++ existing_current) - - # Also check schema values - schema_opt = Enum.find(socket.assigns.option_schema, &(&1["key"] == key)) - schema_values = (schema_opt && schema_opt["options"]) || [] - all_existing = Enum.uniq(all_existing ++ schema_values) - - # Get current selected_option_values - selected = socket.assigns.selected_option_values - current_selected = Map.get(selected, key, []) - - cond do - # Value already exists in this option - value in all_existing -> - {:noreply, put_flash(socket, :error, "Value '#{value}' already exists in '#{key}'")} - - # Option exists - add value to it - all_existing != [] -> - # Initialize selected with all existing values if not already set - init_selected = if current_selected == [], do: all_existing, else: current_selected - updated_original = Map.put(original_values, key, existing_original ++ [value]) - updated_selected = Map.put(selected, key, init_selected ++ [value]) - - {:noreply, - socket - |> assign(:original_option_values, updated_original) - |> assign(:selected_option_values, updated_selected) - |> assign(:add_option_key, "") - |> assign(:add_option_value, "") - |> put_flash(:info, "Value '#{value}' added to '#{key}'")} - - # New option - create it - true -> - updated_original = Map.put(original_values, key, [value]) - updated_selected = Map.put(selected, key, [value]) - - {:noreply, - socket - |> assign(:original_option_values, updated_original) - |> assign(:selected_option_values, updated_selected) - |> assign(:add_option_key, "") - |> assign(:add_option_value, "") - |> put_flash(:info, "Option '#{key}' created")} - end - end - end - - defp save_product(socket, :new, product_params) do - case Shop.create_product(product_params) do - {:ok, product} -> - {:noreply, - socket - |> put_flash(:info, "Product created") - |> push_navigate(to: Routes.path("/admin/shop/products/#{product.uuid}"))} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :changeset, changeset)} - end - rescue - e -> - require Logger - Logger.error("Product save failed: #{Exception.message(e)}") - {:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")} - end - - defp save_product(socket, :edit, product_params) do - case Shop.update_product(socket.assigns.product, product_params) do - {:ok, product} -> - changeset = Shop.change_product(product) - - {:noreply, - socket - |> assign(:product, product) - |> assign(:changeset, changeset) - |> put_flash(:info, "Product updated")} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :changeset, changeset)} - end - rescue - e -> - require Logger - Logger.error("Product save failed: #{Exception.message(e)}") - {:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")} - end - - # Get options with affects_price=true - defp get_price_affecting_options(option_schema) do - Enum.filter(option_schema, fn opt -> - Map.get(opt, "affects_price", false) == true - end) - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header back={Routes.path("/admin/shop/products")}> -

{@page_title}

-

- {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 --%> -
-
-

Product Details

- -
- <%!-- Row 1: Title + Status --%> -
- - - <%= if @changeset.errors[:title] do %> - - <% end %> -
- -
- - -
- - <%!-- Row 2: Slug + Vendor --%> -
- - -
- -
- - -
- - <%!-- Row 3: Product Type + Category --%> -
- - -
- -
- - -
- - <%!-- Row 4: Description (full width) --%> -
- - -
-
-
-
- - <%!-- Card 2: Pricing --%> -
-
-

Pricing

- -
- <%!-- Row 1: Base Price + Compare Price --%> -
- - -
- -
- - -
- - <%!-- Row 2: Cost + Taxable --%> -
- - -
- -
- - -
-
-
-
- - <%!-- Card: Translations (only show when Languages module enabled with 2+ languages) --%> - <%= if @show_translation_tabs do %> -
-
-

Translations

-

- 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 --%> -
- <.translation_fields - language={@current_translation_language} - translations={@product_translations} - is_default_language={@current_translation_language == @default_language} - form_prefix="product" - fields={[ - %{ - key: :title, - label: "Title", - type: :text, - placeholder: "Translated product title" - }, - %{ - key: :slug, - label: "URL Slug", - type: :text, - placeholder: "translated-url-slug", - hint: "SEO-friendly URL for this language" - }, - %{ - key: :description, - label: "Description", - type: :textarea, - placeholder: "Short translated description" - }, - %{ - key: :body_html, - label: "Full Description (HTML)", - type: :html, - placeholder: "

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)" - } - ]} - /> -
-
-
- <% end %> - - <%!-- Available Option Values Section --%> - <% # Use original_option_values for showing all available values (persists across unchecks) - # Use current metadata for determining which are currently selected - original_values = assigns[:original_option_values] || %{} - current_option_values = @metadata["_option_values"] || %{} - - # Merge original + current to get all known values - all_known_values = - Map.merge(original_values, current_option_values, fn _k, orig, curr -> - Enum.uniq(orig ++ curr) - end) - - # 1. ALL select/multiselect options from schema (even with empty options list) - # This allows adding custom values to options defined in category schema - schema_options = - Enum.filter(@option_schema, fn opt -> - opt["type"] in ["select", "multiselect"] - end) - - # 2. Options from _option_values (imported) that are NOT already in schema - schema_keys_with_values = Enum.map(schema_options, & &1["key"]) - - option_slots = @metadata["_option_slots"] || [] - - imported_options = - all_known_values - |> Enum.reject(fn {key, _} -> key in schema_keys_with_values end) - |> Enum.map(fn {key, values} -> - # Find option in schema (may exist but with empty options list) - schema_opt = Enum.find(@option_schema, &(&1["key"] == key)) - # Find label from _option_slots (e.g. "Liquid Color" for slot "liquid_color") - slot_label = - Enum.find_value(option_slots, fn slot -> - if slot["slot"] == key, do: slot["label"] - end) - - %{ - "key" => key, - "label" => (schema_opt && schema_opt["label"]) || slot_label || humanize_key(key), - "type" => (schema_opt && schema_opt["type"]) || "select", - "options" => values, - "imported" => true - } - end) - - # Combine: schema options first, then imported-only options - all_select_options = schema_options ++ imported_options %> - <%= if @live_action == :edit do %> -
-
-

- <.icon name="hero-adjustments-horizontal" class="w-5 h-5" /> Available Options -

-

- <%= if all_select_options != [] do %> - Select which option values are available for this product. - <% else %> - Add custom options for this product. - <% end %> -

- -
- <%= for option <- all_select_options do %> - <% option_key = option["key"] %> - <% # Determine all available values - schema_values = option["options"] || [] - original_imported = Map.get(original_values, option_key, []) - current_imported = Map.get(current_option_values, option_key, []) - # Also include manually added values from socket assigns - manually_added = Map.get(@original_option_values, option_key, []) - - # All values = schema values + manually added OR merged original+current imported - all_values = - if schema_values != [] do - Enum.uniq(schema_values ++ manually_added) - else - Enum.uniq(original_imported ++ current_imported ++ manually_added) - end - - # Apply stored order if exists - stored_order = get_in(@metadata, ["_option_value_order", option_key]) - - ordered_values = - if stored_order do - # Filter to only include values that still exist - ordered_existing = Enum.filter(stored_order, &(&1 in all_values)) - # Add any new values not in stored order at the end - new_values = Enum.reject(all_values, &(&1 in stored_order)) - ordered_existing ++ new_values - else - all_values - end - - # Active values = from socket assigns (managed via phx-click, not form) - # If selected_option_values has this key, use it; otherwise all are active - active_values = Map.get(@selected_option_values, option_key, ordered_values) - - is_imported = option["imported"] == true - - is_editable = - is_imported or schema_values == [] or option["allow_override"] == true - - has_custom_selection = Map.has_key?(@selected_option_values, option_key) %> - -
-
- - {option["label"]} - <%= if is_imported do %> - Imported - <% end %> - - <%= if has_custom_selection do %> - Custom selection - <% else %> - All values - <% end %> -
- - <%!-- Option values as draggable badges --%> - <.draggable_list - id={"option-values-#{option_key}"} - items={ordered_values} - item_id={fn value -> value end} - on_reorder={"reorder_option_values:#{option_key}"} - layout={:list} - item_class="flex items-center gap-2 p-2 bg-base-100 rounded-lg border border-base-200 hover:bg-base-200" - > - <:item :let={value}> - <% is_selected = value in active_values %> -
- - <%= if is_editable do %> - - <% end %> -
- - - - <%!-- Add new value input --%> - <%= if is_editable do %> - <% input_value = Map.get(assigns[:new_value_inputs] || %{}, option_key, "") %> -
- - -
- <% end %> -
- <% end %> -
- - <%!-- Add Option/Value Section --%> -
Add Option or Value
-

- Enter an existing option key to add a new value, or a new key to create a new option. -

-
-
- - -
-
- - -
- -
-
-
- <% end %> - - <%!-- Option Price Modifiers Section --%> - <% # Filter to only options that have actual values to display - price_options_with_values = - Enum.filter(@price_affecting_options, fn opt -> - (opt["options"] || []) != [] - end) - - # Editable options: has allow_override flag AND has options - editable_options = - Enum.filter(price_options_with_values, fn opt -> - opt["allow_override"] == true - end) - - # Read-only options: without allow_override AND has price_modifiers - readonly_options = - price_options_with_values - |> Enum.reject(fn opt -> opt["allow_override"] == true end) - |> Enum.filter(fn opt -> (opt["price_modifiers"] || %{}) != %{} end) - - # Only show section if there's something to display - has_schema_price_content = editable_options != [] or readonly_options != [] %> - <%= if has_schema_price_content do %> -
-
-

- <.icon name="hero-calculator" class="w-5 h-5" /> Option Prices -

-

- 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 %> -
-

- Editable - Per-product price modifiers -

-

- Leave as "Default" to use global option values, or set custom values per-product. -

-
- <%= for option <- editable_options do %> -
-
- {option["label"]} - - Default: {option["modifier_type"] || "fixed"} - -
-
- <% base_price = - Ecto.Changeset.get_field(@changeset, :price) || Decimal.new("0") %> - <% # Combine schema values with manually added values - schema_values = option["options"] || [] - manually_added = Map.get(@original_option_values, option["key"], []) - all_option_values = Enum.uniq(schema_values ++ manually_added) - # Calculate min modifier for suggesting price for added values - price_modifiers = option["price_modifiers"] || %{} - - min_modifier = - price_modifiers - |> Map.values() - |> Enum.map(&parse_decimal/1) - |> Enum.min(fn -> Decimal.new("0") end) %> - - - - - - - - - - <%= for opt_value <- all_option_values do %> - <% is_from_schema = opt_value in schema_values %> - <% # For schema values use their modifier; for added values use min modifier - default_val = - if is_from_schema do - get_in(option, ["price_modifiers", opt_value]) || "0" - else - Decimal.to_string(min_modifier) - end %> - <% default_type = option["modifier_type"] || "fixed" %> - <% default_final = - calculate_option_price(base_price, default_type, default_val) %> - <% override = - get_modifier_override(@metadata, option["key"], opt_value) %> - <% custom_final = - if override, - do: - calculate_option_price( - base_price, - override["type"] || "fixed", - override["value"] || "0" - ), - else: nil %> - - - - - - <% end %> - -
ValueDefault PriceCustom 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 %> -
-
-
-
- <% end %> -
-
- <% end %> - - <%!-- Read-only Options --%> - <%= if readonly_options != [] do %> -
- - - - - - - - - - - <%= for option <- readonly_options do %> - <%= for {value, modifier} <- option["price_modifiers"] || %{} do %> - - - - - - - <% end %> - <% end %> - -
OptionValueModifierType
{option["label"]}{value} - <%= if option["modifier_type"] == "percent" do %> - +{modifier}% - <% else %> - +{format_price(modifier, @currency)} - <% end %> - - - {option["modifier_type"] || "fixed"} - -
-
- <% end %> - - <%!-- Price Range Preview --%> - <%= if @live_action == :edit && assigns[:min_price] && assigns[:max_price] do %> -
-
- Price Range: - - {format_price(@min_price, @currency)} — {format_price(@max_price, @currency)} - -
-
- <% end %> -
-
- <% end %> - - <%!-- Imported Option Prices Section --%> - <% # Use original_option_values to ensure we show all values even if some unchecked - price_original_values = assigns[:original_option_values] || %{} - imported_price_modifiers = @metadata["_price_modifiers"] || %{} - - # Use original_option_values directly (it contains all available values) - all_price_values = price_original_values - - # Find options that exist in _option_values but NOT in price_affecting_options schema - schema_price_keys = Enum.map(@price_affecting_options, & &1["key"]) - - imported_price_options = - all_price_values - |> Enum.reject(fn {key, _} -> key in schema_price_keys end) - |> Enum.map(fn {key, values} -> - %{ - "key" => key, - "label" => String.capitalize(String.replace(key, "_", " ")), - "values" => values, - "modifiers" => Map.get(imported_price_modifiers, key, %{}) - } - end) %> - <%= if imported_price_options != [] and @live_action == :edit do %> -
-
-

- <.icon name="hero-currency-dollar" class="w-5 h-5" /> Imported Option Prices - From Import -

-

- Set prices for each option value. Enter the final price (base price + modifier). -

- - <% base_price = Ecto.Changeset.get_field(@changeset, :price) || Decimal.new("0") %> - -
- <%= for opt <- imported_price_options do %> -
-
- {opt["label"]} - Imported -
-
- - - - - - - - - - <%= for value <- opt["values"] do %> - <% # Get existing modifier (stored as string like "12.01") - stored_modifier = opt["modifiers"][value] - - modifier_value = - if is_binary(stored_modifier), do: stored_modifier, else: "0" - - modifier_decimal = parse_decimal(modifier_value) - final_price = Decimal.add(base_price, modifier_decimal) %> - - - - - - <% end %> - -
ValueCurrent ModifierFinal Price
{value} - <%= if modifier_decimal != Decimal.new("0") do %> - +{modifier_value} - <% else %> - +0 - <% end %> - -
- - - {currency_symbol(@currency)} - -
-
-
-
- <% end %> -
-
-
- <% end %> - - <%!-- Variant Images Section - supports both Storage and legacy URL-based images --%> - <% has_storage_images = @all_image_uuids != [] %> - <% legacy_images = get_legacy_images(@product) %> - <% has_legacy_images = legacy_images != [] %> - <%= if (has_storage_images or has_legacy_images) and has_mappable_options?(assigns) do %> -
-
-

- <.icon name="hero-photo" class="w-5 h-5" /> Variant Images -

-

- 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 %> -
-

{humanize_key(option_key)}

-
- <%= for value <- option_values do %> -
- {value} - - <%!-- Preview thumbnail --%> - <%= if mapping = get_image_mapping(@metadata, option_key, value) do %> - {"Preview - <% end %> -
- <% end %> -
-
- <% end %> -
-
- <% end %> - - <%!-- Product Images - Unified drag-and-drop gallery with featured image --%> -
-
-

- <.icon name="hero-photo" class="w-5 h-5" /> Product Images -

-

- 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}> -
- - <%!-- Featured badge on first image --%> - <%= if image_uuid == List.first(@all_image_uuids) do %> - - <.icon name="hero-star" class="w-3 h-3 mr-1" /> Featured - - <% end %> - <%!-- Remove button --%> - -
- - <:add_button> - - - -
-
- - <%!-- Product Specifications (Options without affects_price) --%> - <% non_price_options = Enum.reject(@option_schema, & &1["affects_price"]) %> - <%= if non_price_options != [] do %> -
-
-

- <.icon name="hero-tag" class="w-5 h-5" /> Specifications -

-

- Fill in the product specifications based on global and category options. -

- -
- <%= for opt <- non_price_options do %> - <.option_field opt={opt} value={@metadata[opt["key"]]} currency={@currency} /> - <% end %> -
-
-
- <% end %> - - <%!-- Submit --%> -
- <.link navigate={Routes.path("/admin/shop/products")} class="btn btn-ghost"> - Cancel - - -
- - - <%!-- Media Selector Modal --%> - <.live_component - module={PhoenixKitWeb.Live.Components.MediaSelectorModal} - id="media-selector-modal" - show={@show_media_selector} - mode={@media_selection_mode} - selected_uuids={@all_image_uuids} - phoenix_kit_current_user={@phoenix_kit_current_user} - /> -
-
- """ - end - - # Build unified image list from featured + gallery (featured always first) - defp build_all_image_uuids(nil, gallery_uuids), do: Enum.uniq(gallery_uuids) - - defp build_all_image_uuids(featured_uuid, gallery_uuids) do - [featured_uuid | Enum.reject(gallery_uuids, &(&1 == featured_uuid))] - |> Enum.uniq() - end - - # Get image URL from Storage - defp get_image_url(nil, _variant), do: nil - - defp get_image_url(file_uuid, variant) do - URLSigner.signed_url(file_uuid, variant) - rescue - _ -> nil - end - - # Get image URL - supports both Storage IDs and direct URLs - # Used for preview thumbnails in variant image mapping - defp get_image_url_or_direct(nil, _variant), do: nil - defp get_image_url_or_direct("http" <> _ = url, _variant), do: url - - defp get_image_url_or_direct(file_uuid, variant) do - URLSigner.signed_url(file_uuid, variant) - rescue - _ -> nil - end - - # Get legacy image URLs from product.images (Shopify import format) - defp get_legacy_images(%{images: images}) when is_list(images) do - Enum.map(images, &extract_image_url/1) |> Enum.reject(&is_nil/1) - end - - defp get_legacy_images(_), do: [] - - # Extract URL from legacy image (handles both map and string formats) - defp extract_image_url(%{"src" => src}) when is_binary(src), do: src - defp extract_image_url(url) when is_binary(url), do: url - defp extract_image_url(_), do: nil - - # Format price for display with currency - defp format_price(nil, _currency), do: "—" - defp format_price("", _currency), do: "—" - - defp format_price(price, %Currency{} = currency) when is_binary(price) do - case Decimal.parse(price) do - {decimal, _} -> Currency.format_amount(decimal, currency) - :error -> Currency.format_amount(Decimal.new("0"), currency) - end - end - - defp format_price(price, %Currency{} = currency) do - Currency.format_amount(price, currency) - end - - defp format_price(price, nil) do - "$#{Decimal.round(price, 2)}" - end - - # Get currency symbol for display - defp currency_symbol(%Currency{symbol: symbol}), do: symbol - defp currency_symbol(_), do: "$" - - # Get modifier override from product metadata - # Handles both formats: - # - String format (unified): "10.00" -> %{"type" => "fixed", "value" => "10.00"} - # - Object format (legacy): %{"type" => "fixed", "value" => "10.00"} -> returned as-is - defp get_modifier_override(metadata, option_key, option_value) do - case metadata do - %{"_price_modifiers" => %{^option_key => %{^option_value => override}}} - when is_map(override) -> - # Object format (legacy): %{"type" => "fixed", "value" => "10"} - if (override["type"] && override["type"] != "") or - (override["value"] && override["value"] != "") do - %{ - "type" => override["type"] || "fixed", - "value" => override["value"] || "0" - } - else - nil - end - - %{"_price_modifiers" => %{^option_key => %{^option_value => value}}} - when is_binary(value) and value != "" -> - # String format (unified): convert to object for UI display - %{"type" => "fixed", "value" => value} - - _ -> - nil - end - end - - # Calculate final price for a single option value - defp calculate_option_price(base_price, modifier_type, modifier_value) do - base = if is_nil(base_price), do: Decimal.new("0"), else: base_price - - modifier = - case Decimal.parse(modifier_value || "0") do - {decimal, _} -> decimal - :error -> Decimal.new("0") - end - - case modifier_type do - "percent" -> - # base * (1 + modifier/100) - multiplier = Decimal.add(Decimal.new("1"), Decimal.div(modifier, Decimal.new("100"))) - Decimal.mult(base, multiplier) |> Decimal.round(2) - - _ -> - # fixed: base + modifier - Decimal.add(base, modifier) |> Decimal.round(2) - end - end - - # Dynamic option field component - attr :opt, :map, required: true - attr :value, :any, default: nil - attr :currency, :any, default: nil - - defp option_field(assigns) do - ~H""" -
- - - <%= case @opt["type"] do %> - <% "text" -> %> - - <% "number" -> %> - - <% "boolean" -> %> -
- - - Yes -
- <% "select" -> %> - - <% "multiselect" -> %> -
- <%= for opt_val <- @opt["options"] || [] do %> - - <% end %> - <%= if (@opt["options"] || []) == [] do %> - No options defined - <% end %> -
- <% _ -> %> - - <% end %> -
- """ - end - - # Get option schema based on category_uuid string - defp get_schema_for_category_uuid(nil), do: Options.get_enabled_global_options() - defp get_schema_for_category_uuid(""), do: Options.get_enabled_global_options() - - defp get_schema_for_category_uuid(category_uuid) when is_binary(category_uuid) do - category = Shop.get_category!(category_uuid) - product = %Product{category: category, category_uuid: category.uuid} - Options.get_option_schema_for_product(product) - rescue - _ -> Options.get_enabled_global_options() - end - - defp get_schema_for_category_uuid(_), do: Options.get_enabled_global_options() - - # Clean up _option_values - remove entries where all values are selected (use defaults) - defp clean_option_values(metadata, option_schema, original_option_values) do - case metadata["_option_values"] do - nil -> - metadata - - option_values when is_map(option_values) -> - schema_values = build_schema_values_map(option_schema) - - cleaned = - option_values - |> Enum.map(fn {key, selected_values} -> - schema_for_key = Map.get(schema_values, key, []) - original_for_key = Map.get(original_option_values, key, []) - clean_option_entry(key, selected_values, schema_for_key, original_for_key) - end) - |> Enum.reject(fn {_k, v} -> is_nil(v) end) - |> Map.new() - - if cleaned == %{} do - Map.delete(metadata, "_option_values") - else - Map.put(metadata, "_option_values", cleaned) - end - - _ -> - metadata - end - end - - # Build a map of option_key -> available values from schema - defp build_schema_values_map(option_schema) do - option_schema - |> Enum.filter(&(&1["type"] in ["select", "multiselect"])) - |> Enum.map(&{&1["key"], &1["options"] || []}) - |> Map.new() - end - - # Determine whether to keep an option entry or discard it (nil) - defp clean_option_entry(key, selected_values, schema_for_key, original_for_key) do - all_values = Enum.uniq(schema_for_key ++ original_for_key) - selected = if is_list(selected_values), do: selected_values, else: [] - has_custom_values = original_for_key != [] and original_for_key != schema_for_key - - cond do - # Has custom values - always keep to preserve the added values - has_custom_values and selected != [] -> - {key, selected} - - # All selected from schema only - can be nil - Enum.sort(selected) == Enum.sort(all_values) -> - {key, nil} - - # None selected - nil - selected == [] -> - {key, nil} - - # Partial selection - keep - true -> - {key, selected} - end - end - - # Remove price modifier for a specific option value when it's deleted - defp remove_price_modifier_for_value(metadata, option_key, value) do - case metadata["_price_modifiers"] do - nil -> - metadata - - price_modifiers when is_map(price_modifiers) -> - case Map.get(price_modifiers, option_key) do - nil -> - metadata - - option_modifiers when is_map(option_modifiers) -> - updated_option_modifiers = Map.delete(option_modifiers, value) - - updated_price_modifiers = - if updated_option_modifiers == %{} do - Map.delete(price_modifiers, option_key) - else - Map.put(price_modifiers, option_key, updated_option_modifiers) - end - - if updated_price_modifiers == %{} do - Map.delete(metadata, "_price_modifiers") - else - Map.put(metadata, "_price_modifiers", updated_price_modifiers) - end - - _ -> - metadata - end - - _ -> - metadata - end - end - - # Convert final_price inputs to modifier values - # final_price - base_price = modifier (for fixed type) - # Handles two formats: - # 1. Schema options: %{"final_price" => "123.45"} -> %{"type" => "fixed", "value" => "23.45"} - # 2. Imported options: "123.45" -> "23.45" (simple string modifier) - defp convert_final_prices_to_modifiers(metadata, base_price) do - case metadata["_price_modifiers"] do - nil -> - metadata - - price_modifiers when is_map(price_modifiers) -> - converted = - Enum.map(price_modifiers, fn {option_key, option_values} -> - converted_values = - Enum.map(option_values, fn {opt_value, modifier_data} -> - converted_data = convert_modifier_data(modifier_data, base_price) - {opt_value, converted_data} - end) - |> Enum.reject(fn {_k, v} -> v == nil end) - |> Map.new() - - {option_key, converted_values} - end) - |> Enum.reject(fn {_k, v} -> v == nil or v == %{} end) - |> Map.new() - - if converted == %{} do - Map.delete(metadata, "_price_modifiers") - else - Map.put(metadata, "_price_modifiers", converted) - end - end - end - - # Convert a single modifier data entry - # Handle map format (from schema options with final_price key) - # Always returns string format for consistency with imports - defp convert_modifier_data(modifier_data, base_price) when is_map(modifier_data) do - final_price_str = modifier_data["final_price"] - - cond do - # If final_price is provided, calculate modifier from it - final_price_str && final_price_str != "" -> - final_price = parse_decimal(final_price_str) - # modifier = final_price - base_price - modifier = Decimal.sub(final_price, base_price) - - # Only store if it's different from 0 (otherwise use default) - if Decimal.compare(modifier, Decimal.new("0")) == :eq do - nil - else - # Return simple string (unified format) - Decimal.to_string(Decimal.round(modifier, 2)) - end - - # If no final_price but has explicit value, extract and return as string - modifier_data["value"] && modifier_data["value"] != "" -> - # Return just the value string (unified format) - modifier_data["value"] - - # No valid data - true -> - nil - end - end - - # Handle string format (from imported options where input sends final_price directly) - defp convert_modifier_data(final_price_str, base_price) when is_binary(final_price_str) do - if final_price_str == "" do - nil - else - final_price = parse_decimal(final_price_str) - # modifier = final_price - base_price - modifier = Decimal.sub(final_price, base_price) - - # Store as string for consistency with import format - modifier_str = Decimal.to_string(Decimal.round(modifier, 2)) - - # Return as simple string (import format) not map - modifier_str - end - end - - defp convert_modifier_data(_, _), do: nil - - defp extract_new_value_inputs(params, existing) do - new = - params - |> Enum.filter(fn {k, _v} -> String.starts_with?(k, "_new_option_value_") end) - |> Enum.map(fn {k, v} -> - {String.replace_prefix(k, "_new_option_value_", ""), v} - end) - |> Map.new() - - Map.merge(existing, new, fn _k, old, new -> if new == "", do: old, else: new end) - end - - defp maybe_update_price_range(socket, product_params, metadata) do - with :edit <- socket.assigns.live_action, - new_price when new_price not in [nil, ""] <- product_params["price"] do - base_price = Decimal.new(new_price) - - {min_price, max_price} = - Options.get_price_range(socket.assigns.price_affecting_options, base_price, metadata) - - socket |> assign(:min_price, min_price) |> assign(:max_price, max_price) - else - _ -> socket - end - end - - # Parse string to Decimal safely - defp parse_decimal(nil), do: Decimal.new("0") - defp parse_decimal(""), do: Decimal.new("0") - - defp parse_decimal(value) when is_binary(value) do - case Decimal.parse(value) do - {decimal, _} -> decimal - :error -> Decimal.new("0") - end - end - - defp parse_decimal(%Decimal{} = value), do: value - defp parse_decimal(_), do: Decimal.new("0") - - # Merge translation params from form into existing translations - defp merge_translation_params(existing, nil), do: existing - - defp merge_translation_params(existing, new_params) when is_map(new_params) do - Enum.reduce(new_params, existing, fn {lang, fields}, acc -> - existing_lang = Map.get(acc, lang, %{}) - merged_lang = Map.merge(existing_lang, fields || %{}) - # Remove empty values - cleaned_lang = Enum.reject(merged_lang, fn {_k, v} -> v == "" end) |> Map.new() - if cleaned_lang == %{}, do: Map.delete(acc, lang), else: Map.put(acc, lang, cleaned_lang) - end) - end - - defp merge_translation_params(existing, _), do: existing - - # Build localized field params from main form values and translations - defp build_localized_params(entity, params, translations_map, default_language) do - translatable_fields = Translations.product_fields() - - # Extract main form values for default language - default_values = %{ - "title" => params["title"], - "slug" => params["slug"], - "description" => params["description"], - "body_html" => params["body_html"], - "seo_title" => params["seo_title"], - "seo_description" => params["seo_description"] - } - - # Merge translations into localized field maps - localized_attrs = - TranslationTabs.merge_translations_to_attrs( - entity, - translations_map, - default_values, - default_language, - translatable_fields - ) - - # Replace simple field values with localized maps - params - |> Map.put("title", localized_attrs[:title]) - |> Map.put("slug", localized_attrs[:slug]) - |> Map.put("description", localized_attrs[:description]) - |> Map.put("body_html", localized_attrs[:body_html]) - |> Map.put("seo_title", localized_attrs[:seo_title]) - |> Map.put("seo_description", localized_attrs[:seo_description]) - end - - # =========================================== - # IMAGE MAPPING HELPERS - # =========================================== - - # Build list of valid image IDs from socket assigns - defp build_valid_image_uuids(assigns) do - assigns[:all_image_uuids] || [] - end - - # Clean up _image_mappings - remove empty values and invalid image IDs - # Preserves URL values (starting with "http") for legacy Shopify images - defp clean_image_mappings(metadata, valid_image_uuids) do - case metadata["_image_mappings"] do - nil -> - metadata - - mappings when is_map(mappings) -> - cleaned = - mappings - |> Enum.map(fn {option_key, value_mappings} -> - cleaned_values = - value_mappings - |> Enum.reject(&invalid_image_mapping?(&1, valid_image_uuids)) - |> Map.new() - - {option_key, cleaned_values} - end) - |> Enum.reject(fn {_k, v} -> v == %{} end) - |> Map.new() - - if cleaned == %{} do - Map.delete(metadata, "_image_mappings") - else - Map.put(metadata, "_image_mappings", cleaned) - end - - _ -> - metadata - end - end - - # Check if mapping is invalid (should be rejected) - # Keep URLs (legacy images) and valid Storage IDs - defp invalid_image_mapping?({_v, image_uuid}, _valid_ids) when image_uuid in ["", nil], do: true - defp invalid_image_mapping?({_v, "http" <> _}, _valid_ids), do: false - defp invalid_image_mapping?({_v, image_uuid}, valid_ids), do: image_uuid not in valid_ids - - # Clean stale image mappings on product load, returns {cleaned_metadata, had_stale?} - # Preserves URL values (starting with "http") for legacy Shopify images - defp clean_stale_image_mappings(metadata, valid_ids) do - case metadata["_image_mappings"] do - nil -> - {metadata, false} - - mappings when is_map(mappings) -> - # Count original mappings - original_count = - Enum.reduce(mappings, 0, fn {_k, v}, acc -> - acc + map_size(v) - end) - - # Clean mappings - keep URLs and valid Storage IDs - cleaned = - Enum.map(mappings, fn {key, value_map} -> - filtered = - value_map - |> Enum.reject(&invalid_image_mapping?(&1, valid_ids)) - |> Map.new() - - {key, filtered} - end) - |> Enum.reject(fn {_k, v} -> v == %{} end) - |> Map.new() - - # Count cleaned mappings - cleaned_count = - Enum.reduce(cleaned, 0, fn {_k, v}, acc -> - acc + map_size(v) - end) - - had_stale = cleaned_count < original_count - - updated_metadata = - if cleaned == %{}, - do: Map.delete(metadata, "_image_mappings"), - else: Map.put(metadata, "_image_mappings", cleaned) - - {updated_metadata, had_stale} - - _ -> - {metadata, false} - end - end - - # Show warning if stale mappings were cleaned - defp maybe_warn_stale_mappings(socket, false), do: socket - - defp maybe_warn_stale_mappings(socket, true) do - put_flash( - socket, - :warning, - "Some variant image mappings were removed because the linked images no longer exist." - ) - end - - # Check if product has mappable options (select/multiselect with values) - defp has_mappable_options?(assigns) do - assigns[:original_option_values] != %{} or - Enum.any?(assigns[:option_schema] || [], fn opt -> - opt["type"] in ["select", "multiselect"] and (opt["options"] || []) != [] - end) - end - - # Get all mappable options with their values - # Combines schema options with product-specific option values - defp get_mappable_options(assigns) do - # Get options from schema - schema_options = - (assigns[:option_schema] || []) - |> Enum.filter(&(&1["type"] in ["select", "multiselect"])) - |> Enum.map(&{&1["key"], &1["options"] || []}) - |> Map.new() - - # Get product-specific option values (from imports or manual additions) - product_options = assigns[:original_option_values] || %{} - - # Merge: schema provides base, product overrides/extends - Map.merge(schema_options, product_options, fn _k, schema, product -> - Enum.uniq(schema ++ product) - end) - |> Enum.reject(fn {_k, v} -> v == [] end) - |> Enum.sort_by(fn {k, _v} -> k end) - end - - # Get image mapping for option key + value from metadata - defp get_image_mapping(metadata, option_key, value) do - get_in(metadata, ["_image_mappings", option_key, value]) - end - - # Humanize option key for display (color -> Color, frame_material -> Frame material) - defp humanize_key(key) do - key - |> String.replace("_", " ") - |> String.split(" ") - |> Enum.map_join(" ", &String.capitalize/1) - end -end diff --git a/lib/modules/shop/web/products.ex b/lib/modules/shop/web/products.ex deleted file mode 100644 index 158cc5a67..000000000 --- a/lib/modules/shop/web/products.ex +++ /dev/null @@ -1,874 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Products do - @moduledoc """ - Products list LiveView for Shop module. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing.Currency - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Events - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Storage - alias PhoenixKit.Modules.Storage.URLSigner - alias PhoenixKit.Utils.Routes - - @per_page 25 - - @impl true - def mount(_params, _session, socket) do - if connected?(socket) do - Events.subscribe_products() - Events.subscribe_inventory() - end - - {products, total} = Shop.list_products_with_count(per_page: @per_page, preload: [:category]) - currency = Shop.get_default_currency() - categories = Shop.list_categories() - - # Get current language for admin (use default language) - current_language = Translations.default_language() - - socket = - socket - |> assign(:page_title, "Products") - |> assign(:products, products) - |> assign(:total, total) - |> assign(:page, 1) - |> assign(:per_page, @per_page) - |> assign(:search, "") - |> assign(:status_filter, nil) - |> assign(:type_filter, nil) - |> assign(:category_filter, nil) - |> assign(:categories, categories) - |> assign(:currency, currency) - |> assign(:selected_uuids, MapSet.new()) - |> assign(:show_bulk_modal, nil) - |> assign(:current_language, current_language) - |> assign(:delete_target, nil) - |> assign(:delete_media_checked, false) - |> assign(:bulk_delete_media, false) - - {:ok, socket} - end - - @impl true - def handle_params(params, _uri, socket) do - page = (params["page"] || "1") |> String.to_integer() - search = params["search"] || "" - status = if params["status"] in ["", nil], do: nil, else: params["status"] - type = if params["type"] in ["", nil], do: nil, else: params["type"] - category_uuid = parse_category_uuid(params["category"]) - - opts = [ - page: page, - per_page: @per_page, - search: search, - status: status, - product_type: type, - category_uuid: category_uuid, - preload: [:category] - ] - - {products, total} = Shop.list_products_with_count(opts) - - socket = - socket - |> assign(:products, products) - |> assign(:total, total) - |> assign(:page, page) - |> assign(:search, search) - |> assign(:status_filter, status) - |> assign(:type_filter, type) - |> assign(:category_filter, category_uuid) - - {:noreply, socket} - end - - @impl true - def handle_event("search", %{"search" => search}, socket) do - socket = - socket - |> assign(:search, search) - |> assign(:page, 1) - |> load_products() - - {:noreply, socket} - end - - @impl true - def handle_event("filter_status", %{"status" => status}, socket) do - status = if status == "", do: nil, else: status - - socket = - socket - |> assign(:status_filter, status) - |> assign(:page, 1) - |> load_products() - - {:noreply, socket} - end - - @impl true - def handle_event("filter_type", %{"type" => type}, socket) do - type = if type == "", do: nil, else: type - - socket = - socket - |> assign(:type_filter, type) - |> assign(:page, 1) - |> load_products() - - {:noreply, socket} - end - - @impl true - def handle_event("filter_category", %{"category" => category}, socket) do - category_uuid = parse_category_uuid(category) - - socket = - socket - |> assign(:category_filter, category_uuid) - |> assign(:page, 1) - |> load_products() - - {:noreply, socket} - end - - @impl true - def handle_event("change_page", %{"page" => page}, socket) do - page = String.to_integer(page) - - socket = - socket - |> assign(:page, page) - |> load_products() - - {:noreply, socket} - end - - @impl true - def handle_event("view_product", %{"uuid" => uuid}, socket) do - {:noreply, push_navigate(socket, to: Routes.path("/admin/shop/products/#{uuid}"))} - end - - @impl true - def handle_event("confirm_delete", %{"uuid" => uuid}, socket) do - product = Shop.get_product!(uuid) - {:noreply, socket |> assign(:delete_target, product) |> assign(:delete_media_checked, false)} - end - - @impl true - def handle_event("toggle_delete_media", _params, socket) do - {:noreply, assign(socket, :delete_media_checked, !socket.assigns.delete_media_checked)} - end - - @impl true - def handle_event("cancel_delete", _params, socket) do - {:noreply, socket |> assign(:delete_target, nil) |> assign(:delete_media_checked, false)} - end - - @impl true - def handle_event("execute_delete", _params, socket) do - product = socket.assigns.delete_target - - file_uuids = - if socket.assigns.delete_media_checked, - do: Shop.collect_product_file_uuids(product), - else: [] - - case Shop.delete_product(product) do - {:ok, _} -> - if file_uuids != [], do: Storage.queue_file_cleanup(file_uuids) - - {products, total} = - Shop.list_products_with_count( - page: socket.assigns.page, - per_page: @per_page, - search: socket.assigns.search, - status: socket.assigns.status_filter, - product_type: socket.assigns.type_filter, - category_uuid: socket.assigns.category_filter, - preload: [:category] - ) - - {:noreply, - socket - |> assign(:products, products) - |> assign(:total, total) - |> assign(:delete_target, nil) - |> assign(:delete_media_checked, false) - |> put_flash(:info, "Product deleted")} - - {:error, _} -> - {:noreply, - socket - |> assign(:delete_target, nil) - |> put_flash(:error, "Failed to delete product")} - end - end - - @impl true - def handle_event("delete_product", %{"uuid" => uuid}, socket) do - product = Shop.get_product!(uuid) - - case Shop.delete_product(product) do - {:ok, _} -> - {products, total} = - Shop.list_products_with_count( - page: socket.assigns.page, - per_page: @per_page, - preload: [:category] - ) - - {:noreply, - socket - |> assign(:products, products) - |> assign(:total, total) - |> put_flash(:info, "Product deleted")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to delete product")} - end - end - - # Bulk selection events - @impl true - def handle_event("toggle_select", %{"uuid" => uuid}, socket) do - selected = socket.assigns.selected_uuids - - selected = - if MapSet.member?(selected, uuid) do - MapSet.delete(selected, uuid) - else - MapSet.put(selected, uuid) - end - - {:noreply, assign(socket, :selected_uuids, selected)} - end - - @impl true - def handle_event("select_all", _params, socket) do - all_uuids = Enum.map(socket.assigns.products, & &1.uuid) |> MapSet.new() - current = socket.assigns.selected_uuids - - selected = - if MapSet.subset?(all_uuids, current) do - MapSet.difference(current, all_uuids) - else - MapSet.union(current, all_uuids) - end - - {:noreply, assign(socket, :selected_uuids, selected)} - end - - @impl true - def handle_event("clear_selection", _params, socket) do - {:noreply, assign(socket, :selected_uuids, MapSet.new())} - end - - # Bulk action modals - @impl true - def handle_event("show_bulk_modal", %{"action" => action}, socket) do - {:noreply, assign(socket, :show_bulk_modal, action)} - end - - @impl true - def handle_event("close_bulk_modal", _params, socket) do - {:noreply, assign(socket, :show_bulk_modal, nil)} - end - - # Bulk actions - @impl true - def handle_event("bulk_change_status", %{"status" => status}, socket) do - uuids = MapSet.to_list(socket.assigns.selected_uuids) - count = Shop.bulk_update_product_status(uuids, status) - - socket = load_products(socket) - - {:noreply, - socket - |> assign(:selected_uuids, MapSet.new()) - |> assign(:show_bulk_modal, nil) - |> put_flash(:info, "#{count} products updated to #{status}")} - end - - @impl true - def handle_event("bulk_change_category", %{"category_uuid" => category_uuid}, socket) do - uuids = MapSet.to_list(socket.assigns.selected_uuids) - category_uuid = if category_uuid == "", do: nil, else: category_uuid - count = Shop.bulk_update_product_category(uuids, category_uuid) - - socket = load_products(socket) - - {:noreply, - socket - |> assign(:selected_uuids, MapSet.new()) - |> assign(:show_bulk_modal, nil) - |> put_flash(:info, "#{count} products moved")} - end - - @impl true - def handle_event("toggle_bulk_delete_media", _params, socket) do - {:noreply, assign(socket, :bulk_delete_media, !socket.assigns.bulk_delete_media)} - end - - @impl true - def handle_event("bulk_delete", _params, socket) do - uuids = MapSet.to_list(socket.assigns.selected_uuids) - - file_uuids = - if socket.assigns.bulk_delete_media, - do: Shop.collect_products_file_uuids(uuids), - else: [] - - count = Shop.bulk_delete_products(uuids) - if file_uuids != [], do: Storage.queue_file_cleanup(file_uuids) - - socket = load_products(socket) - - {:noreply, - socket - |> assign(:selected_uuids, MapSet.new()) - |> assign(:show_bulk_modal, nil) - |> assign(:bulk_delete_media, false) - |> put_flash(:info, "#{count} products deleted")} - end - - defp load_products(socket) do - {products, total} = - Shop.list_products_with_count( - page: socket.assigns.page, - per_page: @per_page, - search: socket.assigns.search, - status: socket.assigns.status_filter, - product_type: socket.assigns.type_filter, - category_uuid: socket.assigns.category_filter, - preload: [:category] - ) - - socket - |> assign(:products, products) - |> assign(:total, total) - end - - # PubSub event handlers - @impl true - def handle_info({:product_created, _product}, socket) do - {:noreply, load_products(socket)} - end - - @impl true - def handle_info({:product_updated, _product}, socket) do - {:noreply, load_products(socket)} - end - - @impl true - def handle_info({:product_deleted, _product_uuid}, socket) do - {:noreply, load_products(socket)} - end - - @impl true - def handle_info({:products_bulk_status_changed, _ids, _status}, socket) do - {:noreply, load_products(socket)} - end - - @impl true - def handle_info({:inventory_updated, _product_uuid, _change}, socket) do - {:noreply, load_products(socket)} - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header back={Routes.path("/admin/shop")}> -

Products

-

- {if @total == 1, do: "1 product", else: "#{@total} products"} -

- - - <%!-- Controls Bar --%> -
-
- <%!-- Search --%> -
- -
- -
-
- - <%!-- Status Filter --%> -
- -
- -
-
- - <%!-- Type Filter --%> -
- -
- -
-
- - <%!-- Category Filter --%> -
- -
- -
-
- - <%!-- Add Button --%> -
- - <.link - navigate={Routes.path("/admin/shop/products/new")} - class="btn btn-primary w-full" - > - <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Product - -
-
-
- - <%!-- Bulk Actions Bar --%> - <%= if MapSet.size(@selected_uuids) > 0 do %> -
-
-
- - {MapSet.size(@selected_uuids)} selected - - -
-
- - - -
-
-
- <% end %> - - <%!-- Products Table --%> -
-
- - - - - - - - - - - - - - <%= if Enum.empty?(@products) do %> - - - - <% else %> - <%= for product <- @products do %> - - - - - - - - - - <% end %> - <% end %> - -
- - ProductStatusTypeCategoryPriceActions
- <.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} - <% else %> - <.icon name="hero-cube" class="w-6 h-6" /> - <% end %> -
-
-
-
{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")} - - -
-
-
- - <%!-- Pagination --%> - <%= if @total > @per_page do %> -
-
-
- <%= for page <- 1..ceil(@total / @per_page) do %> - - <% end %> -
-
-
- <% end %> -
-
- - <%!-- Bulk Status Change Modal --%> - <%= if @show_bulk_modal == "status" do %> - - <% end %> - - <%!-- Bulk Category Change Modal --%> - <%= if @show_bulk_modal == "category" do %> - - <% end %> - - <%!-- Bulk Delete Confirmation Modal --%> - <%= if @show_bulk_modal == "delete" do %> - - <% end %> - - <%!-- Single Product Delete Confirmation Modal --%> - <%= if @delete_target do %> - - <% end %> -
- """ - end - - defp all_selected?(products, selected_uuids) do - products != [] and - Enum.all?(products, fn p -> MapSet.member?(selected_uuids, p.uuid) end) - end - - defp parse_category_uuid(nil), do: nil - defp parse_category_uuid(""), do: nil - defp parse_category_uuid(id) when is_binary(id), do: id - - defp status_badge_class("active"), do: "badge badge-success" - defp status_badge_class("draft"), do: "badge badge-warning" - defp status_badge_class("archived"), do: "badge badge-neutral" - defp status_badge_class(_), do: "badge" - - defp type_badge_class("physical"), do: "badge badge-info badge-outline" - defp type_badge_class("digital"), do: "badge badge-secondary badge-outline" - defp type_badge_class(_), do: "badge badge-outline" - - defp format_price(nil, _currency), do: "—" - - defp format_price(price, %Currency{} = currency) do - Currency.format_amount(price, currency) - end - - defp format_price(price, nil) do - # Fallback if no currency configured - "$#{Decimal.round(price, 2)}" - end - - # Get product thumbnail - prefers Storage images over legacy URLs - defp get_product_thumbnail(%{featured_image_uuid: id}) when is_binary(id) do - get_storage_image_url(id, "small") - end - - defp get_product_thumbnail(%{image_uuids: [id | _]}) when is_binary(id) do - get_storage_image_url(id, "small") - end - - defp get_product_thumbnail(%{featured_image: url}) when is_binary(url) and url != "" do - url - end - - defp get_product_thumbnail(%{images: [%{"src" => src} | _]}), do: src - defp get_product_thumbnail(%{images: [first | _]}) when is_binary(first), do: first - defp get_product_thumbnail(_), do: nil - - defp get_storage_image_url(file_uuid, variant) do - case Storage.get_file(file_uuid) do - %{uuid: uuid} -> - case Storage.get_file_instance_by_name(uuid, variant) do - nil -> - case Storage.get_file_instance_by_name(uuid, "original") do - nil -> nil - _instance -> URLSigner.signed_url(file_uuid, "original") - end - - _instance -> - URLSigner.signed_url(file_uuid, variant) - end - - nil -> - nil - end - end -end diff --git a/lib/modules/shop/web/settings.ex b/lib/modules/shop/web/settings.ex deleted file mode 100644 index 776965256..000000000 --- a/lib/modules/shop/web/settings.ex +++ /dev/null @@ -1,564 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.Settings do - @moduledoc """ - E-Commerce module settings LiveView. - - Allows configuration of e-commerce settings including inventory tracking. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - config = Shop.get_config() - - # Load storefront filter configuration - storefront_filters = Shop.get_storefront_filters() - discovered_options = Shop.discover_filterable_options() - - socket = - socket - |> assign(:page_title, "E-Commerce Settings") - |> assign(:enabled, config.enabled) - |> assign(:inventory_tracking, config.inventory_tracking) - |> assign(:billing_enabled, billing_enabled?()) - |> assign(:category_name_display, get_category_name_display()) - |> assign(:category_icon_mode, get_category_icon_mode()) - |> assign(:sidebar_show_categories, get_sidebar_show_categories()) - |> assign(:storefront_filters, storefront_filters) - |> assign(:discovered_options, discovered_options) - - {:ok, socket} - end - - defp get_category_name_display do - Settings.get_setting_cached("shop_category_name_display", "truncate") - end - - defp get_category_icon_mode do - Settings.get_setting_cached("shop_category_icon_mode", "none") - end - - defp get_sidebar_show_categories do - Settings.get_setting_cached("shop_sidebar_show_categories", "true") == "true" - end - - @impl true - def handle_event("toggle_inventory_tracking", _params, socket) do - new_value = !socket.assigns.inventory_tracking - value_str = if(new_value, do: "true", else: "false") - - case Settings.update_setting("shop_inventory_tracking", value_str) do - {:ok, _} -> - {:noreply, - socket - |> assign(:inventory_tracking, new_value) - |> put_flash( - :info, - if(new_value, do: "Inventory tracking enabled", else: "Inventory tracking disabled") - )} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to update inventory setting")} - end - end - - @impl true - def handle_event("update_category_display", %{"display" => display}, socket) do - case Settings.update_setting("shop_category_name_display", display) do - {:ok, _} -> - {:noreply, - socket - |> assign(:category_name_display, display) - |> put_flash(:info, "Category display setting updated")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to update category display setting")} - end - end - - @impl true - def handle_event("toggle_sidebar_categories", _params, socket) do - new_value = !socket.assigns.sidebar_show_categories - value_str = if(new_value, do: "true", else: "false") - - case Settings.update_setting("shop_sidebar_show_categories", value_str) do - {:ok, _} -> - {:noreply, - socket - |> assign(:sidebar_show_categories, new_value) - |> put_flash( - :info, - if(new_value, - do: "Categories in shop enabled", - else: "Categories in shop disabled" - ) - )} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to update setting")} - end - end - - @impl true - def handle_event("update_category_icon", %{"mode" => mode}, socket) do - case Settings.update_setting("shop_category_icon_mode", mode) do - {:ok, _} -> - {:noreply, - socket - |> assign(:category_icon_mode, mode) - |> put_flash(:info, "Category icon setting updated")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to update category icon setting")} - end - end - - @impl true - def handle_event("toggle_storefront_filter", %{"key" => key}, socket) do - filters = - Enum.map(socket.assigns.storefront_filters, fn f -> - if f["key"] == key, do: Map.put(f, "enabled", !f["enabled"]), else: f - end) - - case Shop.update_storefront_filters(filters) do - {:ok, _} -> - {:noreply, - socket - |> assign(:storefront_filters, filters) - |> put_flash(:info, "Storefront filter updated")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to update filter")} - end - end - - @impl true - def handle_event("update_filter_label", %{"key" => key, "label" => label}, socket) do - filters = - Enum.map(socket.assigns.storefront_filters, fn f -> - if f["key"] == key, do: Map.put(f, "label", label), else: f - end) - - case Shop.update_storefront_filters(filters) do - {:ok, _} -> - {:noreply, assign(socket, :storefront_filters, filters)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to update filter label")} - end - end - - @impl true - def handle_event("add_metadata_filter", %{"key" => option_key}, socket) do - existing_keys = Enum.map(socket.assigns.storefront_filters, & &1["key"]) - - if option_key in existing_keys do - {:noreply, put_flash(socket, :error, "Filter for '#{option_key}' already exists")} - else - max_pos = - socket.assigns.storefront_filters - |> Enum.map(& &1["position"]) - |> Enum.max(fn -> 0 end) - - new_filter = %{ - "key" => option_key, - "type" => "metadata_option", - "option_key" => option_key, - "label" => String.capitalize(option_key), - "enabled" => true, - "position" => max_pos + 1 - } - - filters = socket.assigns.storefront_filters ++ [new_filter] - - case Shop.update_storefront_filters(filters) do - {:ok, _} -> - {:noreply, - socket - |> assign(:storefront_filters, filters) - |> put_flash(:info, "Filter '#{option_key}' added")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to add filter")} - end - end - end - - @impl true - def handle_event("remove_filter", %{"key" => key}, socket) do - filters = Enum.reject(socket.assigns.storefront_filters, &(&1["key"] == key)) - - case Shop.update_storefront_filters(filters) do - {:ok, _} -> - {:noreply, - socket - |> assign(:storefront_filters, filters) - |> put_flash(:info, "Filter removed")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to remove filter")} - end - end - - @impl true - def handle_event("reset_default_filters", _params, socket) do - filters = Shop.default_storefront_filters() - - case Shop.update_storefront_filters(filters) do - {:ok, _} -> - {:noreply, - socket - |> assign(:storefront_filters, filters) - |> put_flash(:info, "Filters reset to defaults")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to reset filters")} - end - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header - back={Routes.path("/admin/shop")} - title="E-Commerce Settings" - subtitle="Configure your e-commerce store" - /> - - <%!-- Inventory Settings (toggle pattern) --%> -
-
-

- <.icon name="hero-archive-box" class="w-6 h-6" /> Inventory -

- -
- -
-
-
- - <%!-- Info about Billing --%> -
- <.icon name="hero-information-circle" class="w-6 h-6" /> -
-

Currency & Tax Settings

-

- Currency and tax configuration is managed in the - <.link navigate={Routes.path("/admin/settings/billing")} class="link font-medium"> - Billing module settings - -

-
-
- - <%!-- Product Options --%> -
-
-

- <.icon name="hero-tag" class="w-6 h-6" /> Product Options -

- -
- -
-
-
- - <%!-- Import Configurations --%> -
-
-

- <.icon name="hero-funnel" class="w-6 h-6" /> Import Configurations -

- -
- -
-
-
- - <%!-- Storefront Filters --%> -
-
-
-

- <.icon name="hero-funnel" class="w-6 h-6" /> Storefront Filters -

- -
- -

- Configure product filters shown on the storefront sidebar. - Customers can filter by price, vendor, and product options. -

- - <%!-- Current Filters Table --%> -
- - - - - - - - - - - - <%= for filter <- @storefront_filters do %> - - - - - - - - <% end %> - -
FilterTypeLabelEnabled
{filter["key"]} - {filter["type"]} - -
- - -
-
- - - <%= if filter["type"] == "metadata_option" do %> - - <% end %> -
-
- - <%!-- Auto-discovered option keys --%> - <%= if @discovered_options != [] do %> -
Available Product Options
-

- These option keys were found in product metadata. Click to add as a filter. -

-
- <% existing_keys = Enum.map(@storefront_filters, & &1["key"]) %> - <%= for opt <- @discovered_options do %> - <%= if opt.key not in existing_keys do %> - - <% end %> - <% end %> -
- <% end %> -
-
- - <%!-- Sidebar Display Settings --%> -
-
-

- <.icon name="hero-bars-3" class="w-6 h-6" /> Sidebar Display -

- - <%!-- Show Categories in Shop --%> -
- -
- -
- - <%!-- Category Name Display --%> -
- -

- How category names should be displayed in the sidebar -

-
- - -
-
- -
- - <%!-- Category Icon Mode --%> -
- -

- Show icons next to category names in sidebar -

-
- - - -
-
-
-
-
-
- """ - end - - defp billing_enabled? do - Code.ensure_loaded?(Billing) and - function_exported?(Billing, :enabled?, 0) and - Billing.enabled?() - rescue - _ -> false - end -end diff --git a/lib/modules/shop/web/shipping_method_form.ex b/lib/modules/shop/web/shipping_method_form.ex deleted file mode 100644 index 484823d96..000000000 --- a/lib/modules/shop/web/shipping_method_form.ex +++ /dev/null @@ -1,416 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.ShippingMethodForm do - @moduledoc """ - Shipping method create/edit form LiveView. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.ShippingMethod - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(params, _uri, socket) do - socket = apply_action(socket, socket.assigns.live_action, params) - {:noreply, socket} - end - - defp apply_action(socket, :new, _params) do - default_currency = Billing.get_default_currency() - default_currency_code = if default_currency, do: default_currency.code, else: "USD" - - method = %ShippingMethod{currency: default_currency_code} - changeset = Shop.change_shipping_method(method) - currencies = load_currencies() - - socket - |> assign(:page_title, "New Shipping Method") - |> assign(:method, method) - |> assign(:changeset, changeset) - |> assign(:currencies, currencies) - |> assign(:default_currency, default_currency) - end - - defp apply_action(socket, :edit, %{"id" => id}) do - method = Shop.get_shipping_method!(id) - changeset = Shop.change_shipping_method(method) - currencies = load_currencies() - default_currency = Billing.get_default_currency() - - socket - |> assign(:page_title, "Edit #{method.name}") - |> assign(:method, method) - |> assign(:changeset, changeset) - |> assign(:currencies, currencies) - |> assign(:default_currency, default_currency) - end - - @impl true - def handle_event("validate", %{"shipping_method" => params}, socket) do - changeset = - socket.assigns.method - |> Shop.change_shipping_method(params) - |> Map.put(:action, :validate) - - {:noreply, assign(socket, :changeset, changeset)} - end - - @impl true - def handle_event("save", %{"shipping_method" => params}, socket) do - save_method(socket, socket.assigns.live_action, params) - end - - defp save_method(socket, :new, params) do - case Shop.create_shipping_method(params) do - {:ok, _method} -> - {:noreply, - socket - |> put_flash(:info, "Shipping method created") - |> push_navigate(to: Routes.path("/admin/shop/shipping"))} - - {:error, changeset} -> - {:noreply, assign(socket, :changeset, changeset)} - end - end - - defp save_method(socket, :edit, params) do - case Shop.update_shipping_method(socket.assigns.method, params) do - {:ok, _method} -> - {:noreply, - socket - |> put_flash(:info, "Shipping method updated") - |> push_navigate(to: Routes.path("/admin/shop/shipping"))} - - {:error, changeset} -> - {:noreply, assign(socket, :changeset, changeset)} - end - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header - back={Routes.path("/admin/shop/shipping")} - title={@page_title} - subtitle="Configure shipping method details" - /> - - <.form for={@changeset} phx-change="validate" phx-submit="save" class="space-y-6"> - <%!-- Basic Info --%> -
-
-

Basic Information

- -
-
- - - <%= if @changeset.errors[:name] do %> - - <% end %> -
- -
- - -
- -
- - -
-
-
-
- - <%!-- Pricing --%> -
-
-

Pricing

- -
-
- - -
- -
- - <%= if @currencies == [] do %> -
- {if @default_currency, - do: "#{@default_currency.code} - #{@default_currency.name}", - else: "USD"} -
- - <% else %> - - <% end %> -
- -
- - -
-
-
-
- - <%!-- Constraints --%> -
-
-

Constraints

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
-
- - <%!-- Delivery & Status --%> -
-
-

Delivery & Status

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
-
- -
- -
- -
-
-
- - <%!-- Submit --%> -
- <.link navigate={Routes.path("/admin/shop/shipping")} class="btn btn-outline"> - Cancel - - -
- -
-
- """ - end - - defp load_currencies do - Billing.list_currencies(enabled: true) - rescue - _ -> [] - end -end diff --git a/lib/modules/shop/web/shipping_methods.ex b/lib/modules/shop/web/shipping_methods.ex deleted file mode 100644 index 1b4f20055..000000000 --- a/lib/modules/shop/web/shipping_methods.ex +++ /dev/null @@ -1,198 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.ShippingMethods do - @moduledoc """ - Shipping methods list LiveView for E-Commerce module admin. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing.Currency - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - methods = Shop.list_shipping_methods() - currency = Shop.get_default_currency() - - socket = - socket - |> assign(:page_title, "Shipping Methods") - |> assign(:methods, methods) - |> assign(:currency, currency) - - {:ok, socket} - end - - @impl true - def handle_event("toggle_active", %{"uuid" => uuid}, socket) do - method = Shop.get_shipping_method!(uuid) - {:ok, updated} = Shop.update_shipping_method(method, %{active: !method.active}) - - methods = - Enum.map(socket.assigns.methods, fn m -> - if m.uuid == updated.uuid, do: updated, else: m - end) - - {:noreply, assign(socket, :methods, methods)} - end - - @impl true - def handle_event("delete", %{"uuid" => uuid}, socket) do - method = Shop.get_shipping_method!(uuid) - - case Shop.delete_shipping_method(method) do - {:ok, _} -> - methods = Enum.reject(socket.assigns.methods, &(&1.uuid == method.uuid)) - - {:noreply, - socket - |> assign(:methods, methods) - |> put_flash(:info, "Shipping method deleted")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to delete shipping method")} - end - end - - @impl true - def render(assigns) do - ~H""" - -
- <.admin_page_header back={Routes.path("/admin/shop")}> -

- Shipping Methods -

-

- {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 - - - - -
-
- - - - - - - - - - - - - <%= if @methods == [] do %> - - - - <% else %> - <%= for method <- @methods do %> - - - - - - - - - <% end %> - <% end %> - -
MethodPriceConstraintsDeliveryStatusActions
- <.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" /> - - -
-
-
-
-
-
- """ - end - - defp format_price(nil, _currency), do: "—" - - defp format_price(amount, %Currency{} = currency) do - Currency.format_amount(amount, currency) - end - - defp format_price(amount, nil) do - "$#{Decimal.round(amount || Decimal.new("0"), 2)}" - end - - defp format_weight(grams) when grams >= 1000, do: "#{div(grams, 1000)} kg" - defp format_weight(grams), do: "#{grams} g" -end diff --git a/lib/modules/shop/web/shop_catalog.ex b/lib/modules/shop/web/shop_catalog.ex deleted file mode 100644 index 11fa3313e..000000000 --- a/lib/modules/shop/web/shop_catalog.ex +++ /dev/null @@ -1,371 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.ShopCatalog do - @moduledoc """ - Public shop catalog main page. - Shows categories and featured/active products. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Shop.Web.Components.CatalogSidebar - alias PhoenixKit.Modules.Shop.Web.Components.FilterHelpers - alias PhoenixKit.Modules.Shop.Web.Components.ShopCards - alias PhoenixKit.Modules.Shop.Web.Components.ShopLayouts - alias PhoenixKit.Modules.Shop.Web.Helpers - - @impl true - def mount(params, _session, socket) do - # Determine language: use URL locale param if present, otherwise default - # This ensures /shop always uses default language, not session - current_language = Helpers.get_language_from_params_or_default(params) - - categories = Shop.list_active_categories(preload: [:parent, :featured_product]) - - per_page = 24 - page = Helpers.parse_page(params["page"]) - - # Load storefront filters - {enabled_filters, filter_values} = FilterHelpers.load_filter_data() - active_filters = FilterHelpers.parse_filter_params(params, enabled_filters) - filter_opts = FilterHelpers.build_query_opts(active_filters, enabled_filters) - - {products, total} = - Shop.list_products_with_count( - [ - status: "active", - page: 1, - per_page: page * per_page, - exclude_hidden_categories: true - ] ++ filter_opts - ) - - total_pages = max(1, ceil(total / per_page)) - page = min(page, total_pages) - - currency = Shop.get_default_currency() - - # Check if user is authenticated - authenticated = not is_nil(socket.assigns[:phoenix_kit_current_user]) - - # Get current path for language switcher - current_path = socket.assigns[:url_path] || "/shop" - - socket = - socket - |> assign(:page_title, "Shop") - |> assign(:categories, categories) - |> assign(:products, products) - |> assign(:total_products, total) - |> assign(:page, page) - |> assign(:per_page, per_page) - |> assign(:total_pages, total_pages) - |> assign(:currency, currency) - |> assign(:current_language, current_language) - |> assign(:authenticated, authenticated) - |> assign(:current_path, current_path) - |> assign(:enabled_filters, enabled_filters) - |> assign(:filter_values, filter_values) - |> assign(:active_filters, active_filters) - |> assign(:filter_qs, FilterHelpers.build_query_string(active_filters, enabled_filters)) - |> assign(:show_mobile_filters, false) - |> assign( - :category_name_wrap, - PhoenixKit.Settings.get_setting_cached("shop_category_name_display", "truncate") == "wrap" - ) - |> assign( - :category_icon_mode, - PhoenixKit.Settings.get_setting_cached("shop_category_icon_mode", "none") - ) - |> assign( - :show_categories_grid, - PhoenixKit.Settings.get_setting_cached("shop_sidebar_show_categories", "true") == "true" - ) - - {:ok, socket} - end - - @impl true - def handle_params(params, _uri, socket) do - page = Helpers.parse_page(params["page"]) - active_filters = FilterHelpers.parse_filter_params(params, socket.assigns.enabled_filters) - filter_opts = FilterHelpers.build_query_opts(active_filters, socket.assigns.enabled_filters) - - filters_changed = active_filters != socket.assigns.active_filters - page = min(page, max(1, socket.assigns.total_pages)) - - if filters_changed || page != socket.assigns.page do - effective_page = if filters_changed, do: 1, else: page - - {products, total} = - Shop.list_products_with_count( - [ - status: "active", - page: 1, - per_page: effective_page * socket.assigns.per_page, - exclude_hidden_categories: true - ] ++ filter_opts - ) - - total_pages = max(1, ceil(total / socket.assigns.per_page)) - - {:noreply, - socket - |> assign(:page, min(effective_page, total_pages)) - |> assign(:products, products) - |> assign(:total_products, total) - |> assign(:total_pages, total_pages) - |> assign(:active_filters, active_filters) - |> assign( - :filter_qs, - FilterHelpers.build_query_string(active_filters, socket.assigns.enabled_filters) - )} - else - {:noreply, socket} - end - end - - @impl true - def handle_event("filter_price", params, socket) do - filter_key = params["filter_key"] || "price" - - active_filters = - FilterHelpers.update_price_filter( - socket.assigns.active_filters, - filter_key, - params["price_min"], - params["price_max"] - ) - - path = build_filter_path(socket.assigns, active_filters) - {:noreply, push_patch(socket, to: path)} - end - - @impl true - def handle_event("toggle_filter", %{"key" => key, "val" => value}, socket) do - active_filters = FilterHelpers.toggle_filter_value(socket.assigns.active_filters, key, value) - path = build_filter_path(socket.assigns, active_filters) - {:noreply, push_patch(socket, to: path)} - end - - @impl true - def handle_event("clear_filters", _params, socket) do - base_path = Shop.catalog_url(socket.assigns.current_language) - {:noreply, push_patch(socket, to: base_path)} - end - - @impl true - def handle_event("toggle_mobile_filters", _params, socket) do - {:noreply, assign(socket, :show_mobile_filters, !socket.assigns.show_mobile_filters)} - end - - @impl true - def handle_event("load_more", _params, socket) do - next_page = socket.assigns.page + 1 - path = build_filter_path(socket.assigns, socket.assigns.active_filters, page: next_page) - {:noreply, push_patch(socket, to: path)} - end - - @impl true - def render(assigns) do - assigns = - if assigns.authenticated do - assign(assigns, :sidebar_after_shop, shop_sidebar(assigns)) - else - assigns - end - - ~H""" - -
- <%!-- Hero Section --%> -
-
-

Welcome to Our Shop

-

- Browse our collection of products across various categories -

-
-
- - <%!-- Mobile filter toggle --%> -
- -
- - <%!-- Mobile filter drawer (filters only, no categories) --%> - <%= if @show_mobile_filters do %> -
-
-
- -
-
-
- <% end %> - - <%!-- Main layout: sidebar + content --%> -
- <%!-- Sidebar: filters + optional categories --%> - <%= if !@authenticated do %> - - <% end %> - -
- <%!-- Category Grid (controlled by setting) --%> - <%= if @show_categories_grid && @categories != [] do %> -
-

Categories

-
- <%= for cat <- @categories do %> - <.link - navigate={Shop.category_url(cat, @current_language) <> @filter_qs} - class="card bg-base-100 shadow-md hover:shadow-lg transition-all hover:-translate-y-1" - > -
- <% cat_image = category_image(cat) %> - <%= if cat_image do %> - {Translations.get(cat, - <% else %> -
- <.icon name="hero-folder" class="w-10 h-10 opacity-30" /> -
- <% end %> -
-
-

- {Translations.get(cat, :name, @current_language)} -

-
- - <% end %> -
-
- <% end %> - - <%!-- Products Section --%> -
-

Products

- <.link navigate={Shop.cart_url(@current_language)} class="btn btn-outline btn-sm gap-2"> - <.icon name="hero-shopping-cart" class="w-4 h-4" /> View Cart - -
- - <%= if @products == [] do %> -
-
- <.icon name="hero-cube" class="w-16 h-16 mx-auto mb-4 opacity-30" /> -

No products available

-

- <%= if FilterHelpers.has_active_filters?(@active_filters) do %> - No products match your filters. - - <% else %> - Check back soon for new arrivals - <% end %> -

-
-
- <% else %> -
- <%= for product <- @products do %> - - <% end %> -
- - - <% end %> -
-
-
-
- """ - end - - defp shop_sidebar(assigns) do - ~H""" - - """ - end - - defp category_image(category) do - Shop.Category.get_image_url(category, size: "small") - end - - # Build catalog path with filter params and optional page - defp build_filter_path(assigns, active_filters, opts \\ []) do - base_path = Shop.catalog_url(assigns.current_language) - page = Keyword.get(opts, :page) - - FilterHelpers.build_filter_url(base_path, active_filters, assigns.enabled_filters, page: page) - end -end diff --git a/lib/modules/shop/web/test_shop.ex b/lib/modules/shop/web/test_shop.ex deleted file mode 100644 index 8dc5428ab..000000000 --- a/lib/modules/shop/web/test_shop.ex +++ /dev/null @@ -1,372 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.TestShop do - @moduledoc """ - Test module for verifying Shop functionality: - - Specification price modifiers (fixed and percent) - - Storage image integration - - Price calculation - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.CartItem - alias PhoenixKit.Modules.Shop.Options - alias PhoenixKit.Modules.Shop.OptionTypes - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Storage - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - socket = - socket - |> assign(:page_title, "Shop Test Module") - |> assign(:test_results, []) - |> assign(:products, []) - |> assign(:show_products, false) - - {:ok, socket} - end - - @impl true - def handle_event("run_tests", _params, socket) do - results = [ - test_option_types(), - test_option_schema(), - test_price_calculation(), - test_storage_integration(), - test_cart_with_specs() - ] - - {:noreply, assign(socket, :test_results, results)} - end - - @impl true - def handle_event("load_products", _params, socket) do - products = Shop.list_products(limit: 10, preload: [:category]) - {:noreply, assign(socket, products: products, show_products: true)} - end - - @impl true - def handle_event("test_product_price", %{"id" => id}, socket) do - product = Shop.get_product(id, preload: [:category]) - - if product do - price_specs = Shop.get_price_affecting_specs(product) - - # Build test selections from first options - test_selections = - Enum.reduce(price_specs, %{}, fn opt, acc -> - case opt["options"] do - [first | _] -> Map.put(acc, opt["key"], first) - _ -> acc - end - end) - - calculated_price = Shop.calculate_product_price(product, test_selections) - {min_price, max_price} = Shop.get_price_range(product) - - product_title = Translations.get(product, :title, Translations.default_language()) - - result = %{ - name: "Price Test: #{product_title}", - status: :ok, - details: - "Base: $#{product.price}, Calculated: $#{calculated_price}, Range: $#{min_price} - $#{max_price}" - } - - {:noreply, assign(socket, :test_results, socket.assigns.test_results ++ [result])} - else - {:noreply, put_flash(socket, :error, "Product not found")} - end - end - - @impl true - def render(assigns) do - ~H""" - -
-
-

- <.icon name="hero-beaker" class="w-7 h-7 inline" /> Shop Test Module -

- <.link navigate={Routes.path("/admin/shop/products")} class="btn btn-ghost btn-sm"> - <.icon name="hero-arrow-left" class="w-4 h-4" /> - -
- - <%!-- Test Actions --%> -
-
-

Run Tests

-

- Verify specification-based pricing (fixed and percent modifiers) and Storage image integration. -

-
- - -
-
-
- - <%!-- Test Results --%> - <%= if @test_results != [] do %> -
-
-

Test Results

-
- - - - - - - - - - <%= for result <- @test_results do %> - - - - - - <% end %> - -
TestStatusDetails
{result.name} - <%= case result.status do %> - <% :ok -> %> - PASS - <% :error -> %> - FAIL - <% :skip -> %> - SKIP - <% end %> - - {result.details} -
-
-
-
- <% end %> - - <%!-- Products List --%> - <%= if @show_products do %> -
-
-

Products ({length(@products)})

- <%= if @products == [] do %> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> - No products found. Create some products first. -
- <% else %> -
- - - - - - - - - - - - <%= for product <- @products do %> - <% price_specs = Shop.get_price_affecting_specs(product) %> - <% has_storage_images = - product.featured_image_uuid != nil or (product.image_uuids || []) != [] %> - <% default_lang = Translations.default_language() %> - - - - - - - - <% end %> - -
ProductBase PriceHas OptionsHas ImagesActions
-
- {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 %> - - -
-
- <% end %> -
-
- <% end %> - - <%!-- Feature Documentation --%> -
-
-

Features Implemented

-
-
-

- <.icon name="hero-calculator" class="w-4 h-4 inline" /> Price Modifiers -

-
    -
  • Fixed modifiers: +$X per option
  • -
  • Percent modifiers: +X% of base price
  • -
  • Order: fixed first, then percent applied
  • -
  • Cart items freeze price at add time
  • -
-
-
-

- <.icon name="hero-photo" class="w-4 h-4 inline" /> Storage Images -

-
    -
  • featured_image_uuid - main product image
  • -
  • image_uuids[] - gallery images
  • -
  • Media selector integration
  • -
  • URL signing for secure access
  • -
-
-
-
-
-
-
- """ - end - - # Test functions - - defp test_option_types do - # Test that affects_price validation works for select types with modifier_type - valid_opt = %{ - "key" => "material", - "label" => "Material", - "type" => "select", - "options" => ["PLA", "ABS", "PETG"], - "affects_price" => true, - "modifier_type" => "fixed", - "price_modifiers" => %{ - "PLA" => "0", - "ABS" => "5.00", - "PETG" => "10.00" - } - } - - result = - case OptionTypes.validate_option(valid_opt) do - {:ok, _} -> :ok - {:error, _} -> :error - end - - %{ - name: "OptionTypes - Price Modifiers Validation", - status: result, - details: - if(result == :ok, - do: "Valid: select with affects_price, modifier_type=fixed", - else: "Validation failed" - ) - } - end - - defp test_option_schema do - # Test that global option schema loads correctly - schema = Options.get_global_options() - price_affecting = Enum.filter(schema, & &1["affects_price"]) - - %{ - name: "Option Schema - Global Load", - status: :ok, - details: "Found #{length(schema)} options, #{length(price_affecting)} price-affecting" - } - end - - defp test_price_calculation do - # Test price calculation with mock data (fixed modifiers) - base_price = Decimal.new("20.00") - - mock_specs = [ - %{ - "key" => "material", - "type" => "select", - "affects_price" => true, - "modifier_type" => "fixed", - "price_modifiers" => %{"PLA" => "0", "PETG" => "10.00"} - } - ] - - selections = %{"material" => "PETG"} - - # Calculate final price - final_price = Options.calculate_final_price(mock_specs, selections, base_price) - - # Expected: $20 + $10 = $30 - expected = Decimal.new("30.00") - - %{ - name: "Price Calculation - Fixed Modifier", - status: if(Decimal.compare(final_price, expected) == :eq, do: :ok, else: :error), - details: "Base $20 + PETG $10 = $#{final_price} (expected $#{expected})" - } - end - - defp test_storage_integration do - # Test Storage module availability - storage_enabled = function_exported?(Storage, :get_file, 1) - - %{ - name: "Storage Integration - Module Available", - status: if(storage_enabled, do: :ok, else: :skip), - details: - if(storage_enabled, - do: "Storage.get_file/1 available", - else: "Storage module not available" - ) - } - end - - defp test_cart_with_specs do - # Test CartItem schema has selected_specs field - cart_item_fields = CartItem.__schema__(:fields) - has_selected_specs = :selected_specs in cart_item_fields - - %{ - name: "CartItem Schema - selected_specs Field", - status: if(has_selected_specs, do: :ok, else: :error), - details: - if(has_selected_specs, - do: "CartItem has selected_specs field", - else: "selected_specs field missing" - ) - } - end -end diff --git a/lib/modules/shop/web/user_order_details.ex b/lib/modules/shop/web/user_order_details.ex deleted file mode 100644 index b13fd7acd..000000000 --- a/lib/modules/shop/web/user_order_details.ex +++ /dev/null @@ -1,127 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.UserOrderDetails do - @moduledoc """ - LiveView for displaying order details to the order owner. - - Users can view their own orders with full details including: - - Order items and totals - - Billing information - - Order status - - Security: - - Users can only view their own orders (user_uuid check) - """ - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Currency - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"uuid" => uuid}, _session, socket) do - if Billing.enabled?() do - current_user = socket.assigns[:phoenix_kit_current_user] - - case Billing.get_order_by_uuid(uuid) do - nil -> - {:ok, - socket - |> put_flash(:error, gettext("Order not found")) - |> push_navigate(to: Routes.path("/dashboard/orders"))} - - order -> - if order.user_uuid != current_user.uuid do - {:ok, - socket - |> put_flash(:error, gettext("Access denied")) - |> push_navigate(to: Routes.path("/dashboard/orders"))} - else - {:ok, setup_order_assigns(socket, order, current_user)} - end - end - else - {:ok, - socket - |> put_flash(:error, gettext("Billing module is not enabled")) - |> push_navigate(to: Routes.path("/dashboard"))} - end - end - - defp setup_order_assigns(socket, order, current_user) do - currency = Shop.get_default_currency() - billing_profile = get_billing_profile(order) - - socket - |> assign(:page_title, gettext("Order %{number}", number: order.order_number)) - |> assign(:order, order) - |> assign(:current_user, current_user) - |> assign(:currency, currency) - |> assign(:billing_profile, billing_profile) - end - - defp get_billing_profile(%{billing_profile_uuid: nil}), do: nil - defp get_billing_profile(%{billing_profile_uuid: uuid}), do: Billing.get_billing_profile(uuid) - - @impl true - def handle_params(_params, uri, socket) do - {:noreply, assign(socket, :url_path, URI.parse(uri).path)} - end - - # View helpers - - defp status_badge_class("pending"), do: "badge-warning" - defp status_badge_class("processing"), do: "badge-info" - defp status_badge_class("completed"), do: "badge-success" - defp status_badge_class("shipped"), do: "badge-info" - defp status_badge_class("delivered"), do: "badge-success" - defp status_badge_class("cancelled"), do: "badge-error" - defp status_badge_class("refunded"), do: "badge-neutral" - defp status_badge_class(_), do: "badge-ghost" - - defp format_date(nil), do: "-" - - defp format_date(%DateTime{} = dt) do - Calendar.strftime(dt, "%B %d, %Y at %H:%M") - end - - defp format_date(%NaiveDateTime{} = dt) do - Calendar.strftime(dt, "%B %d, %Y at %H:%M") - end - - defp format_price(nil, _currency), do: "-" - - defp format_price(amount, %Currency{} = currency) do - Currency.format_amount(amount, currency) - end - - defp format_price(amount, nil) do - "$#{Decimal.round(amount, 2)}" - end - - defp format_price_string(nil), do: "-" - defp format_price_string(amount) when is_binary(amount), do: "$#{amount}" - defp format_price_string(amount), do: "$#{amount}" - - defp profile_display_name(%{type: "company"} = profile) do - profile.company_name || "#{profile.first_name} #{profile.last_name}" - end - - defp profile_display_name(profile) do - "#{profile.first_name} #{profile.last_name}" - end - - defp profile_address(profile) do - [profile.address_line1, profile.city, profile.postal_code, profile.country] - |> Enum.filter(& &1) - |> Enum.join(", ") - end - - defp items_count(nil), do: 0 - defp items_count([]), do: 0 - - defp items_count(items) do - items - |> Enum.filter(&(&1["type"] != "shipping")) - |> length() - end -end diff --git a/lib/modules/shop/web/user_order_details.html.heex b/lib/modules/shop/web/user_order_details.html.heex deleted file mode 100644 index f24929f1d..000000000 --- a/lib/modules/shop/web/user_order_details.html.heex +++ /dev/null @@ -1,219 +0,0 @@ - -
- <%!-- Back Button --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/dashboard/orders")} - class="btn btn-ghost btn-sm gap-2" - > - <.icon name="hero-arrow-left" class="h-4 w-4" /> - {gettext("Back to Orders")} - -
- -
- <%!-- Main Content --%> -
- <%!-- Order Info Card --%> -
-
-
-
-

{@order.order_number}

-
- - {gettext("Placed on %{date}", date: format_date(@order.inserted_at))} - -
-
-
- - {@order.status} - -
-
- -
- - <%!-- Order Items --%> -

{gettext("Order Items")}

-
- <%= for item <- @order.line_items || [] do %> -
-
- {item["name"]} - <%= if item["type"] != "shipping" do %> - x {item["quantity"]} - <% end %> - <%= if item["description"] && item["description"] != "" do %> -
{item["description"]}
- <% end %> -
-
- {format_price_string(item["total"])} -
-
- <% end %> -
- -
- - <%!-- Totals --%> -
-
- {gettext("Subtotal")} - {format_price(@order.subtotal, @currency)} -
- - <%= if @order.tax_amount && Decimal.compare(@order.tax_amount, Decimal.new("0")) == :gt do %> -
- {gettext("Tax")} - {format_price(@order.tax_amount, @currency)} -
- <% end %> - - <%= if @order.discount_amount && Decimal.compare(@order.discount_amount, Decimal.new("0")) == :gt do %> -
- {gettext("Discount")} - -{format_price(@order.discount_amount, @currency)} -
- <% end %> - -
- {gettext("Total")} - {format_price(@order.total, @currency)} -
-
-
-
- - <%!-- Billing Information Card --%> -
-
-

{gettext("Billing Information")}

- - <%= if @billing_profile do %> -
-
{profile_display_name(@billing_profile)}
-
{profile_address(@billing_profile)}
- <%= if @billing_profile.email do %> -
{@billing_profile.email}
- <% end %> -
- <% else %> - <%= if @order.billing_snapshot && map_size(@order.billing_snapshot) > 0 do %> -
-
- {@order.billing_snapshot["first_name"]} {@order.billing_snapshot["last_name"]} -
-
- {[ - @order.billing_snapshot["address_line1"], - @order.billing_snapshot["city"], - @order.billing_snapshot["postal_code"], - @order.billing_snapshot["country"] - ] - |> Enum.filter(&(&1 && &1 != "")) - |> Enum.join(", ")} -
- <%= if @order.billing_snapshot["email"] do %> -
- {@order.billing_snapshot["email"]} -
- <% end %> -
- <% else %> -
- {gettext("No billing information available")} -
- <% end %> - <% end %> -
-
-
- - <%!-- Sidebar --%> -
- <%!-- Order Summary Card --%> -
-
-

{gettext("Order Summary")}

- - <%!-- Status --%> -
- {gettext("Status")} -
- - {@order.status} - -
-
- - <%!-- Order Number --%> -
- {gettext("Order Number")} -
{@order.order_number}
-
- - <%!-- Order Date --%> -
- {gettext("Order Date")} -
{format_date(@order.inserted_at)}
-
- - <%!-- Items Count --%> -
- {gettext("Items")} -
{items_count(@order.line_items)}
-
- - <%!-- Total --%> -
- {gettext("Total")} -
{format_price(@order.total, @currency)}
-
- - <%!-- Status Messages --%> - <%= if @order.status == "delivered" or @order.status == "completed" do %> -
- <.icon name="hero-check-circle" class="w-5 h-5" /> - {gettext("Your order has been delivered.")} -
- <% end %> - - <%= if @order.status == "shipped" do %> -
- <.icon name="hero-truck" class="w-5 h-5" /> - {gettext("Your order is on its way!")} -
- <% end %> - - <%= if @order.status == "cancelled" do %> -
- <.icon name="hero-x-circle" class="w-5 h-5" /> - {gettext("This order has been cancelled.")} -
- <% end %> -
-
- - <%!-- Actions Card --%> -
-
-

{gettext("Need help?")}

-

- {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")} - -
-
-
-
-
-
diff --git a/lib/modules/shop/web/user_orders.ex b/lib/modules/shop/web/user_orders.ex deleted file mode 100644 index cd64fc693..000000000 --- a/lib/modules/shop/web/user_orders.ex +++ /dev/null @@ -1,185 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Web.UserOrders do - @moduledoc """ - LiveView for displaying user's shop orders. - - Users can view only their own orders with status filtering and pagination. - This is the user-facing order portal, using the dashboard layout. - """ - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Billing - alias PhoenixKit.Modules.Billing.Currency - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if Billing.enabled?() do - current_user = socket.assigns[:phoenix_kit_current_user] - - socket = - socket - |> assign(:page_title, gettext("My Orders")) - |> assign(:current_user, current_user) - |> assign(:orders, []) - |> assign(:total_count, 0) - |> assign(:loading, true) - |> assign_filter_defaults() - |> assign_pagination_defaults() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, gettext("Billing module is not enabled")) - |> push_navigate(to: Routes.path("/dashboard"))} - end - end - - @impl true - def handle_params(params, uri, socket) do - socket = - socket - |> assign(:url_path, URI.parse(uri).path) - |> apply_params(params) - |> load_user_orders() - - {:noreply, assign(socket, :loading, false)} - end - - @impl true - def handle_event("filter", params, socket) do - filter_params = %{} - - filter_params = - case Map.get(params, "filters") do - %{"status" => status} when status != "" -> - Map.put(filter_params, "status", status) - - _ -> - filter_params - end - - {:noreply, - push_patch(socket, to: Routes.path("/dashboard/orders", map_to_keyword(filter_params)))} - end - - @impl true - def handle_event("clear_filters", _params, socket) do - {:noreply, push_patch(socket, to: Routes.path("/dashboard/orders"))} - end - - @impl true - def handle_event("change_page", %{"page" => page}, socket) do - page = String.to_integer(page) - current_params = build_current_params(socket) - params = Map.put(current_params, "page", page) - - {:noreply, push_patch(socket, to: Routes.path("/dashboard/orders", map_to_keyword(params)))} - end - - # Private functions - - defp assign_filter_defaults(socket) do - assign(socket, :status_filter, nil) - end - - defp assign_pagination_defaults(socket) do - socket - |> assign(:page, 1) - |> assign(:per_page, 20) - |> assign(:total_pages, 1) - end - - defp apply_params(socket, params) do - page = params |> Map.get("page", "1") |> String.to_integer() |> max(1) - status = Map.get(params, "status") - - socket - |> assign(:page, page) - |> assign(:status_filter, status) - end - - defp load_user_orders(socket) do - user_uuid = socket.assigns.current_user.uuid - currency = Shop.get_default_currency() - - # Build filters for Billing.list_user_orders - filters = build_query_filters(socket) - all_orders = Billing.list_user_orders(user_uuid, filters) - total_count = length(all_orders) - - # Apply pagination manually - per_page = socket.assigns.per_page - page = socket.assigns.page - orders = all_orders |> Enum.drop((page - 1) * per_page) |> Enum.take(per_page) - total_pages = max(1, ceil(total_count / per_page)) - - socket - |> assign(:orders, orders) - |> assign(:total_count, total_count) - |> assign(:total_pages, total_pages) - |> assign(:currency, currency) - end - - defp build_query_filters(socket) do - filters = %{} - - case socket.assigns.status_filter do - nil -> filters - status -> Map.put(filters, :status, status) - end - end - - defp build_current_params(socket) do - params = %{} - - if socket.assigns.status_filter, - do: Map.put(params, "status", socket.assigns.status_filter), - else: params - end - - defp map_to_keyword(map) when is_map(map) do - Enum.map(map, fn {k, v} -> {String.to_existing_atom(k), v} end) - end - - # View helpers - - defp status_badge_class("pending"), do: "badge-warning" - defp status_badge_class("processing"), do: "badge-info" - defp status_badge_class("completed"), do: "badge-success" - defp status_badge_class("shipped"), do: "badge-info" - defp status_badge_class("delivered"), do: "badge-success" - defp status_badge_class("cancelled"), do: "badge-error" - defp status_badge_class("refunded"), do: "badge-neutral" - defp status_badge_class(_), do: "badge-ghost" - - defp format_date(nil), do: "-" - - defp format_date(%DateTime{} = dt) do - Calendar.strftime(dt, "%B %d, %Y") - end - - defp format_date(%NaiveDateTime{} = dt) do - Calendar.strftime(dt, "%B %d, %Y") - end - - defp format_price(nil, _currency), do: "-" - - defp format_price(amount, %Currency{} = currency) do - Currency.format_amount(amount, currency) - end - - defp format_price(amount, nil) do - "$#{Decimal.round(amount, 2)}" - end - - defp items_count(nil), do: 0 - defp items_count([]), do: 0 - - defp items_count(items) do - items - |> Enum.filter(&(&1["type"] != "shipping")) - |> length() - end -end diff --git a/lib/modules/shop/web/user_orders.html.heex b/lib/modules/shop/web/user_orders.html.heex deleted file mode 100644 index 4669e435b..000000000 --- a/lib/modules/shop/web/user_orders.html.heex +++ /dev/null @@ -1,146 +0,0 @@ - -
- <%!-- Header --%> -
-
-

{gettext("My Orders")}

-

{gettext("View your order history")}

-
-
- <.link - navigate={PhoenixKit.Utils.Routes.path("/shop")} - class="btn btn-primary" - > - <.icon name="hero-shopping-bag" class="h-5 w-5" /> {gettext("Browse Shop")} - -
-
- - <%!-- Status Filter --%> -
-
-
- -
- - <%= if @status_filter do %> - - <% end %> - -
- {ngettext("%{count} order", "%{count} orders", @total_count, count: @total_count)} -
-
-
- - <%!-- Orders List --%> -
- <%= if @loading do %> -
- -
- <% else %> - <%= if Enum.empty?(@orders) do %> -
- <.icon name="hero-shopping-bag" class="h-16 w-16 mx-auto text-base-content/30 mb-4" /> -

{gettext("No orders yet")}

-

- <%= 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")} - -
- <% else %> - <%= for order <- @orders do %> - <.link - navigate={PhoenixKit.Utils.Routes.path("/dashboard/orders/#{order.uuid}")} - class="block bg-base-100 rounded-lg shadow hover:shadow-md transition p-4" - > -
-
-

{order.order_number}

-

- {items_count(order.line_items)} {ngettext( - "item", - "items", - items_count(order.line_items) - )} -

-
-
- - {order.status} - -
- {format_price(order.total, @currency)} -
-
-
-
- - <.icon name="hero-calendar" class="w-4 h-4" /> - {format_date(order.inserted_at)} - -
- - <% end %> - - <%!-- Pagination --%> - <%= if @total_pages > 1 do %> -
- - - {gettext("Page %{page} of %{total}", page: @page, total: @total_pages)} - - -
- <% end %> - <% end %> - <% end %> -
-
-
diff --git a/lib/modules/shop/workers/csv_import_worker.ex b/lib/modules/shop/workers/csv_import_worker.ex deleted file mode 100644 index 7ba38e581..000000000 --- a/lib/modules/shop/workers/csv_import_worker.ex +++ /dev/null @@ -1,464 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Workers.CSVImportWorker do - @moduledoc """ - Oban worker for background CSV import. - - Processes CSV files with automatic format detection via the ImportFormat behaviour. - Supports Shopify, Prom.ua, and other formats transparently. - - ## Job Arguments - - - `import_log_uuid` - UUID of the ImportLog record - - `path` - Path to the uploaded CSV file - - `config_uuid` - Optional ImportConfig UUID for filtering rules - - ## Usage - - The Imports LiveView enqueues jobs after file upload: - - CSVImportWorker.new(%{ - import_log_uuid: log.uuid, - path: "/tmp/uploads/products.csv", - config_uuid: config.uuid # optional - }) - |> Oban.insert() - - ## Queue Configuration - - Add the shop_imports queue to your Oban config: - - config :my_app, Oban, - queues: [default: 10, shop_imports: 2] - """ - - use Oban.Worker, - queue: :shop_imports, - max_attempts: 3, - unique: [period: :infinity, keys: [:import_log_uuid], states: :incomplete] - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Import.{CSVValidator, FormatDetector} - alias PhoenixKit.Modules.Shop.ImportConfig - alias PhoenixKit.Modules.Shop.Translations - alias PhoenixKit.Modules.Shop.Workers.ImageMigrationWorker - alias PhoenixKit.PubSub.Manager - alias PhoenixKit.Utils.Date, as: UtilsDate - - require Logger - - @progress_interval 50 - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"import_log_uuid" => _} = args}) do - import_log_uuid = Map.fetch!(args, "import_log_uuid") - path = Map.fetch!(args, "path") - config_uuid = Map.get(args, "config_uuid") - language = Map.get(args, "language") || default_import_language() - option_mappings = Map.get(args, "option_mappings", []) - download_images = Map.get(args, "download_images", false) - skip_empty_categories = Map.get(args, "skip_empty_categories", false) - - Logger.info("CSVImportWorker: Starting import #{import_log_uuid} from #{path}") - - with {:ok, import_log} <- get_import_log(import_log_uuid), - {:ok, config} <- load_config(config_uuid, import_log), - {:ok, format_mod} <- detect_format(path), - :ok <- validate_file(path, format_mod, config), - {:ok, total_rows} <- count_products(path, format_mod, config), - {:ok, import_log} <- start_import(import_log, total_rows, format_mod), - {:ok, stats} <- - process_file( - import_log, - path, - format_mod, - config, - language, - option_mappings, - download_images - ), - {:ok, _import_log} <- complete_import(import_log, stats) do - if skip_empty_categories, do: cleanup_empty_categories(stats) - cleanup_file(path) - broadcast_complete(import_log_uuid, stats) - Logger.info("CSVImportWorker: Completed import #{import_log_uuid} - #{inspect(stats)}") - :ok - else - {:error, reason} = error -> - Logger.error("CSVImportWorker: Failed import #{import_log_uuid} - #{inspect(reason)}") - handle_failure(import_log_uuid, reason) - error - end - end - - # Backward-compat clause: old key names (import_log_id / config_id) delegate to new clause - def perform(%Oban.Job{args: %{"import_log_id" => _} = args} = job) do - new_args = - args - |> Map.delete("import_log_id") - |> Map.put("import_log_uuid", args["import_log_id"]) - |> then(fn a -> - case Map.pop(a, "config_id") do - {nil, a} -> a - {v, a} -> Map.put(a, "config_uuid", v) - end - end) - - perform(%Oban.Job{job | args: new_args}) - end - - # ============================================ - # PRIVATE HELPERS - # ============================================ - - defp get_import_log(id) do - case Shop.get_import_log(id) do - nil -> {:error, :import_log_not_found} - log -> {:ok, log} - end - end - - defp load_config(nil, import_log) do - config_uuid = - get_in(import_log.options, ["config_uuid"]) || - get_in(import_log.options, ["config_id"]) - - if config_uuid do - load_config_by_uuid(config_uuid) - else - case Shop.get_default_import_config() do - nil -> {:ok, nil} - config -> {:ok, config} - end - end - end - - defp load_config(config_uuid, _import_log) when is_binary(config_uuid) do - load_config_by_uuid(config_uuid) - end - - defp load_config_by_uuid(config_uuid) do - case Shop.get_import_config(config_uuid) do - nil -> {:ok, nil} - config -> {:ok, config} - end - end - - defp detect_format(path) do - case FormatDetector.detect(path) do - {:ok, format_mod} -> - Logger.info("CSVImportWorker: Detected format: #{FormatDetector.format_name(format_mod)}") - - {:ok, format_mod} - - {:error, :unknown_format} -> - {:error, {:validation_failed, :unknown_format}} - - {:error, _} = error -> - error - end - end - - defp validate_file(path, format_mod, config) do - if File.exists?(path) do - required_columns = get_required_columns(format_mod, config) - - case CSVValidator.validate_headers(path, required_columns) do - {:ok, _headers} -> :ok - {:error, reason} -> {:error, {:validation_failed, reason}} - end - else - {:error, :file_not_found} - end - end - - defp get_required_columns(format_mod, config) do - # Use format-specific required columns from default_config_attrs - # rather than the loaded config (which may be for a different format) - format_defaults = format_mod.default_config_attrs() - format_required = Map.get(format_defaults, :required_columns, []) - - if format_required != [] do - format_required - else - case config do - %ImportConfig{required_columns: cols} when is_list(cols) -> cols - _ -> ImportConfig.default_required_columns() - end - end - end - - defp count_products(path, format_mod, config) do - {:ok, format_mod.count(path, config)} - rescue - e -> - Logger.error("CSVImportWorker: Failed to count products - #{inspect(e)}") - {:error, {:parse_error, e}} - end - - defp start_import(import_log, total_rows, format_mod) do - with {:ok, updated_log} <- Shop.start_import(import_log, total_rows) do - broadcast_started(import_log.uuid, total_rows) - - Logger.info( - "CSVImportWorker: Format #{FormatDetector.format_name(format_mod)}, #{total_rows} products" - ) - - {:ok, updated_log} - end - end - - defp process_file( - import_log, - path, - format_mod, - config, - language, - option_mappings, - download_images_arg - ) do - categories_map = build_categories_map() - download_images = download_images_arg || should_download_images?(config) - user_uuid = import_log.user_uuid - - opts = [language: language, option_mappings: option_mappings] - - stats = %{ - imported_count: 0, - updated_count: 0, - skipped_count: 0, - error_count: 0, - error_details: [], - image_jobs_queued: 0, - product_uuids: [] - } - - result = - format_mod.parse_and_transform(path, categories_map, config, opts) - |> Enum.with_index(1) - |> Enum.reduce(stats, fn {attrs, index}, acc -> - result = upsert_product(attrs) - new_acc = update_stats(acc, result) - new_acc = maybe_queue_image_migration(new_acc, result, download_images, user_uuid) - - if rem(index, @progress_interval) == 0 do - broadcast_progress(import_log.uuid, index, import_log.total_rows, new_acc) - end - - new_acc - end) - - {:ok, result} - rescue - e -> - Logger.error("CSVImportWorker: Failed to process file - #{inspect(e)}") - {:error, {:process_error, e}} - end - - defp upsert_product(attrs) do - case Shop.upsert_product(attrs) do - {:ok, product, :inserted} -> - {:imported, nil, product} - - {:ok, product, :updated} -> - {:updated, nil, product} - - {:error, changeset} -> - {:error, nil, changeset} - end - rescue - e -> - {:error, nil, e} - end - - defp update_stats(stats, result) do - case result do - {:imported, _handle, product} -> - %{ - stats - | imported_count: stats.imported_count + 1, - product_uuids: [product.uuid | stats.product_uuids] - } - - {:updated, _handle, product} -> - %{ - stats - | updated_count: stats.updated_count + 1, - product_uuids: [product.uuid | stats.product_uuids] - } - - {:error, handle, error} -> - error_detail = %{ - "handle" => handle, - "error" => format_error(error), - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601() - } - - %{ - stats - | error_count: stats.error_count + 1, - error_details: [error_detail | stats.error_details] - } - end - end - - defp format_error(%Ecto.Changeset{errors: errors}) do - Enum.map_join(errors, ", ", fn {field, {msg, _}} -> "#{field}: #{msg}" end) - end - - defp format_error(error), do: inspect(error) - - defp should_download_images?(%ImportConfig{download_images: true}), do: true - defp should_download_images?(_), do: false - - defp maybe_queue_image_migration(stats, result, download_images, user_uuid) do - if download_images do - case result do - {:imported, _handle, product} -> - queue_image_job(product, user_uuid) - %{stats | image_jobs_queued: stats.image_jobs_queued + 1} - - {:updated, _handle, product} -> - queue_image_job(product, user_uuid) - %{stats | image_jobs_queued: stats.image_jobs_queued + 1} - - _ -> - stats - end - else - stats - end - end - - defp queue_image_job(product, user_uuid) do - has_legacy = has_legacy_images?(product) - has_storage = has_storage_images?(product) - - if has_legacy and not has_storage do - ImageMigrationWorker.new(%{ - product_uuid: product.uuid, - user_uuid: user_uuid - }) - |> Oban.insert() - end - end - - defp has_legacy_images?(product) do - (is_list(product.images) and product.images != []) or - (is_binary(product.featured_image) and String.starts_with?(product.featured_image, "http")) - end - - defp has_storage_images?(product) do - not is_nil(product.featured_image_uuid) or - (is_list(product.image_uuids) and product.image_uuids != []) - end - - defp complete_import(import_log, stats) do - corrected_stats = Map.update!(stats, :product_uuids, &Enum.reverse/1) - Shop.complete_import(import_log, corrected_stats) - end - - defp handle_failure(import_log_uuid, reason) do - case Shop.get_import_log(import_log_uuid) do - nil -> - :ok - - import_log -> - Shop.fail_import(import_log, reason) - broadcast_failed(import_log_uuid, reason) - end - end - - defp cleanup_empty_categories(_stats) do - empty_categories = Shop.list_empty_categories() - - Enum.each(empty_categories, fn cat -> - case Shop.delete_category(cat) do - {:ok, _} -> - Logger.info("CSVImportWorker: Removed empty category: #{cat.uuid}") - - {:error, _} -> - Logger.warning("CSVImportWorker: Failed to remove empty category: #{cat.uuid}") - end - end) - - if empty_categories != [] do - Logger.info("CSVImportWorker: Cleaned up #{length(empty_categories)} empty categories") - end - rescue - e -> - Logger.warning("CSVImportWorker: Category cleanup failed - #{inspect(e)}") - end - - defp cleanup_file(path) do - File.rm(path) - rescue - _ -> :ok - end - - defp build_categories_map do - lang = Translations.default_language() - - Shop.list_categories() - |> Enum.reduce(%{}, fn cat, acc -> - slug = Translations.get(cat, :slug, lang) - - if slug && slug != "" do - Map.put(acc, slug, cat.uuid) - else - acc - end - end) - end - - # ============================================ - # PUBSUB BROADCASTS - # ============================================ - - defp broadcast_started(import_log_uuid, total) do - broadcast(import_log_uuid, {:import_started, %{total: total}}) - end - - defp broadcast_progress(import_log_uuid, current, total, stats) do - percent = if total > 0, do: trunc(current / total * 100), else: 0 - - broadcast( - import_log_uuid, - {:import_progress, - %{ - current: current, - total: total, - percent: percent, - stats: stats - }} - ) - end - - defp broadcast_complete(import_log_uuid, stats) do - broadcast(import_log_uuid, {:import_complete, stats}) - broadcast_general({:import_complete, %{import_log_uuid: import_log_uuid, stats: stats}}) - end - - defp broadcast_failed(import_log_uuid, reason) do - broadcast(import_log_uuid, {:import_failed, %{reason: inspect(reason)}}) - - broadcast_general( - {:import_failed, %{import_log_uuid: import_log_uuid, reason: inspect(reason)}} - ) - end - - defp broadcast(import_log_uuid, message) do - topic = "shop:import:#{import_log_uuid}" - Manager.broadcast(topic, message) - rescue - _ -> :ok - end - - defp broadcast_general(message) do - Manager.broadcast("shop:imports", message) - rescue - _ -> :ok - end - - defp default_import_language do - Translations.default_language() - end -end diff --git a/lib/modules/shop/workers/image_migration_worker.ex b/lib/modules/shop/workers/image_migration_worker.ex deleted file mode 100644 index d8a922bd6..000000000 --- a/lib/modules/shop/workers/image_migration_worker.ex +++ /dev/null @@ -1,330 +0,0 @@ -defmodule PhoenixKit.Modules.Shop.Workers.ImageMigrationWorker do - @moduledoc """ - Oban worker for migrating product images from external URLs to Storage module. - - Processes a single product per job, downloading all legacy images and updating - the product with Storage UUIDs. - - ## Job Arguments - - * `"product_uuid"` - The product UUID to migrate - * `"user_uuid"` - The user UUID for ownership of stored files - - ## Queue - - Uses the `shop_imports` queue with max 3 attempts. - - ## Usage - - # Queue a single product for migration - %{product_uuid: product_uuid, user_uuid: user_uuid} - |> ImageMigrationWorker.new() - |> Oban.insert() - - """ - - use Oban.Worker, queue: :shop_imports, max_attempts: 3 - - import Ecto.Query, warn: false - - require Logger - - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Services.ImageDownloader - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"product_uuid" => product_uuid, "user_uuid" => user_uuid}}) do - Logger.info("Starting image migration for product #{product_uuid}") - - case Shop.get_product(product_uuid) do - nil -> - Logger.warning("Product not found: #{product_uuid}") - {:error, :product_not_found} - - product -> - migrate_product_images(product, user_uuid) - end - end - - # Backward-compat: jobs queued before the product_id → product_uuid rename - def perform(%Oban.Job{args: %{"product_id" => product_uuid, "user_uuid" => user_uuid}}) do - perform(%Oban.Job{args: %{"product_uuid" => product_uuid, "user_uuid" => user_uuid}}) - end - - # Backward-compat: jobs queued with both old keys (product_id + user_id) - def perform(%Oban.Job{args: %{"product_id" => product_uuid, "user_id" => user_uuid}}) do - perform(%Oban.Job{args: %{"product_uuid" => product_uuid, "user_uuid" => user_uuid}}) - end - - defp migrate_product_images(product, user_uuid) do - # Use transaction with pessimistic lock to prevent race conditions - repo = PhoenixKit.Config.get_repo() - - repo.transaction(fn -> - # Re-fetch product with lock - locked_product = - Ecto.Query.from(p in PhoenixKit.Modules.Shop.Product, - where: p.uuid == ^product.uuid, - lock: "FOR UPDATE" - ) - |> repo.one() - - cond do - is_nil(locked_product) -> - Logger.warning("Product #{product.uuid} not found during migration") - {:error, :product_not_found} - - already_migrated?(locked_product) -> - Logger.info("Product #{product.uuid} already has image_uuids, skipping migration") - :ok - - true -> - do_migrate_images(locked_product, user_uuid) - end - end) - |> case do - {:ok, result} -> result - {:error, reason} -> {:error, reason} - end - end - - defp already_migrated?(product) do - # Check if product has any storage-based images - has_featured_image_uuid = not is_nil(product.featured_image_uuid) - has_image_uuids = is_list(product.image_uuids) and product.image_uuids != [] - - has_featured_image_uuid or has_image_uuids - end - - defp do_migrate_images(product, user_uuid) do - # Validate product has required fields before migration - with :ok <- validate_product_for_migration(product) do - do_migrate_validated_images(product, user_uuid) - end - end - - defp validate_product_for_migration(product) do - cond do - is_nil(product.title) or product.title == %{} -> - Logger.warning("Product #{product.uuid} missing title, skipping migration") - {:error, :missing_title} - - is_nil(product.slug) or product.slug == %{} -> - Logger.warning("Product #{product.uuid} missing slug, skipping migration") - {:error, :missing_slug} - - true -> - :ok - end - end - - defp do_migrate_validated_images(product, user_uuid) do - # Collect all unique image URLs from product - image_urls = collect_image_urls(product) - - if Enum.empty?(image_urls) do - Logger.info("No legacy images found for product #{product.uuid}") - :ok - else - # Validate URLs first to skip unavailable images - {valid_urls, invalid_urls} = ImageDownloader.validate_urls(image_urls) - - if invalid_urls != [] do - Logger.warning( - "Product #{product.uuid}: #{length(invalid_urls)} invalid URLs skipped: #{inspect(invalid_urls)}" - ) - end - - if valid_urls == [] do - Logger.warning("Product #{product.uuid}: All image URLs invalid, marking as failed") - {:error, :all_urls_invalid} - else - Logger.info("Migrating #{length(valid_urls)} valid images for product #{product.uuid}") - - # Download and store all images - results = - ImageDownloader.download_batch(valid_urls, user_uuid, - concurrency: 3, - timeout: 60_000, - on_progress: fn url, result, index, total -> - broadcast_progress(product.uuid, index, total, url, result) - end - ) - - # Build URL -> file_uuid mapping - url_to_file_uuid = build_url_mapping(results) - - # Update product with new image IDs, preserving order - update_product_with_storage_uuids(product, url_to_file_uuid) - end - end - end - - defp collect_image_urls(product) do - urls = [] - - # Add featured_image URL if present - urls = - if is_binary(product.featured_image) and String.starts_with?(product.featured_image, "http") do - [product.featured_image | urls] - else - urls - end - - # Add all images from the legacy images array - legacy_image_urls = - (product.images || []) - |> Enum.flat_map(fn - %{"src" => src} when is_binary(src) -> [src] - src when is_binary(src) -> [src] - _ -> [] - end) - |> Enum.filter(&String.starts_with?(&1, "http")) - - # Combine and deduplicate - (urls ++ legacy_image_urls) - |> Enum.uniq() - end - - defp build_url_mapping(results) do - results - |> Enum.reduce(%{}, fn - {url, {:ok, file_uuid}}, acc -> - Map.put(acc, url, file_uuid) - - {url, {:error, reason}}, acc -> - Logger.warning("Failed to download image #{url}: #{inspect(reason)}") - acc - end) - end - - defp update_product_with_storage_uuids(product, url_to_file_uuid) do - if map_size(url_to_file_uuid) == 0 do - Logger.warning("No images were successfully downloaded for product #{product.uuid}") - {:error, :no_images_downloaded} - else - # Map featured_image to featured_image_uuid - featured_image_uuid = Map.get(url_to_file_uuid, product.featured_image) - - # Map legacy images to image_uuids, preserving order from original images array - image_uuids = - (product.images || []) - |> Enum.flat_map(fn - %{"src" => src} -> [src] - src when is_binary(src) -> [src] - _ -> [] - end) - |> Enum.map(&Map.get(url_to_file_uuid, &1)) - |> Enum.reject(&is_nil/1) - - # If no featured_image_uuid but we have image_uuids, use the first one - featured_image_uuid = featured_image_uuid || List.first(image_uuids) - - # Ensure featured image is first in image_uuids (no duplicates) - image_uuids = - if featured_image_uuid && featured_image_uuid in image_uuids do - [featured_image_uuid | Enum.reject(image_uuids, &(&1 == featured_image_uuid))] - else - image_uuids - end - - # Update variant image mappings in metadata if present - metadata = update_image_mappings(product.metadata, url_to_file_uuid) - - attrs = %{ - featured_image_uuid: featured_image_uuid, - image_uuids: image_uuids, - metadata: metadata, - # Clear legacy fields after successful migration - images: [], - featured_image: nil - } - - case Shop.update_product(product, attrs) do - {:ok, updated_product} -> - Logger.info( - "Successfully migrated images for product #{product.uuid}: " <> - "featured_image_uuid=#{featured_image_uuid}, image_uuids=#{length(image_uuids)}" - ) - - broadcast_complete(product.uuid, length(image_uuids)) - {:ok, updated_product} - - {:error, changeset} -> - Logger.error("Failed to update product #{product.uuid}: #{inspect(changeset.errors)}") - {:error, changeset} - end - end - end - - defp update_image_mappings(nil, _url_to_file_uuid), do: nil - - defp update_image_mappings(metadata, url_to_file_uuid) when is_map(metadata) do - case Map.get(metadata, "_image_mappings") do - nil -> - metadata - - mappings when is_map(mappings) -> - updated_mappings = - Enum.reduce(mappings, %{}, fn {option_key, value_map}, acc -> - updated_value_map = - Enum.reduce(value_map, %{}, fn {value, image_ref}, inner_acc -> - new_ref = convert_url_to_file_uuid(image_ref, url_to_file_uuid) - Map.put(inner_acc, value, new_ref) - end) - - Map.put(acc, option_key, updated_value_map) - end) - - Map.put(metadata, "_image_mappings", updated_mappings) - end - end - - defp update_image_mappings(metadata, _url_to_file_uuid), do: metadata - - defp convert_url_to_file_uuid(image_ref, url_to_file_uuid) - when is_binary(image_ref) do - if String.starts_with?(image_ref, "http") do - Map.get(url_to_file_uuid, image_ref, image_ref) - else - image_ref - end - end - - defp convert_url_to_file_uuid(image_ref, _url_to_file_uuid), do: image_ref - - # PubSub broadcasts for progress tracking - - defp broadcast_progress(product_uuid, index, total, url, result) do - status = if match?({:ok, _}, result), do: :success, else: :failed - - PhoenixKit.PubSubHelper.broadcast( - "shop:image_migration:#{product_uuid}", - {:image_progress, - %{ - product_uuid: product_uuid, - current: index, - total: total, - url: url, - status: status - }} - ) - end - - defp broadcast_complete(product_uuid, image_count) do - PhoenixKit.PubSubHelper.broadcast( - "shop:image_migration:#{product_uuid}", - {:migration_complete, - %{ - product_uuid: product_uuid, - images_migrated: image_count - }} - ) - - # Also broadcast to the batch migration topic - PhoenixKit.PubSubHelper.broadcast( - "shop:image_migration:batch", - {:product_migrated, %{product_uuid: product_uuid, images_migrated: image_count}} - ) - end -end diff --git a/lib/modules/sitemap/sources/shop.ex b/lib/modules/sitemap/sources/shop.ex index 039c9cb91..304c617b3 100644 --- a/lib/modules/sitemap/sources/shop.ex +++ b/lib/modules/sitemap/sources/shop.ex @@ -1,4 +1,5 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Shop do + @compile {:no_warn_undefined, PhoenixKit.Modules.Shop} @moduledoc """ Shop source for sitemap generation. diff --git a/lib/modules/storage/web/bucket_form.html.heex b/lib/modules/storage/web/bucket_form.html.heex index f8a56715f..ac70f902f 100644 --- a/lib/modules/storage/web/bucket_form.html.heex +++ b/lib/modules/storage/web/bucket_form.html.heex @@ -10,7 +10,7 @@ <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/settings/media")}>

{@page_title}

- Configure storage provider settings and access credentials + {gettext("Configure storage provider settings and access credentials")}

@@ -21,15 +21,17 @@ <%!-- Basic Information --%>
@@ -38,20 +40,20 @@ <%!-- Provider Selection (only in new mode) --%>
@@ -125,13 +129,13 @@ <%!-- Cloud Provider Settings --%> <%= if @current_provider in ["s3", "b2", "r2"] do %> -
Cloud Provider Configuration
+
{gettext("Cloud Provider Configuration")}
<%!-- Access Credentials --%> -
Access Credentials
+
{gettext("Access Credentials")}
<%!-- Additional Settings (shown for all providers) --%> -
Additional Settings
+
{gettext("Additional Settings")}
<%= if @current_provider in ["s3", "b2", "r2"] do %>
@@ -264,8 +270,8 @@
@@ -294,7 +302,7 @@ checked={Ecto.Changeset.get_field(@changeset, :enabled)} class="checkbox checkbox-primary" /> - Enable this bucket + {gettext("Enable this bucket")}
@@ -302,13 +310,13 @@ <%!-- Form Actions --%>
<.link navigate={PhoenixKit.Utils.Routes.path("/admin/settings/media")} class="btn btn-outline" > - Cancel + {gettext("Cancel")}
@@ -319,12 +327,16 @@
<.icon name="hero-light-bulb" class="w-5 h-5" />
-

Storage Provider Configuration

+

{gettext("Storage Provider Configuration")}

- Local Filesystem: No additional configuration needed
- AWS S3: Requires region, bucket name, and access credentials
- Backblaze B2: Requires endpoint and access credentials
- Cloudflare R2: Requires account ID and access credentials + {gettext("Local Filesystem:")} {gettext( + "No additional configuration needed" + )}
+ AWS S3: {gettext( + "Requires region, bucket name, and access credentials" + )}
+ Backblaze B2: {gettext("Requires endpoint and access credentials")}
+ Cloudflare R2: {gettext("Requires account ID and access credentials")}

@@ -334,26 +346,27 @@ <%= if @show_create_path_modal do %>