From 6611017bc241a65049a07b446c6d802cd2ea7fca Mon Sep 17 00:00:00 2001 From: timujeen Date: Tue, 24 Mar 2026 17:12:33 +0000 Subject: [PATCH 1/8] Fix cookie consent: dynamic legal links, theme-aware backdrop, daisyUI toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded cookie/privacy URLs with Routes.path() to fix double-slash bug - Add dynamic legal_links from published pages + single /legal index link - Use bg-base-100/70 backdrop instead of bg-black for light/dark theme compatibility - Improve glass opacity (0.95→0.98), card bg (50→80%), text contrast - Replace custom toggle with standard daisyUI toggle toggle-primary --- lib/modules/legal/legal.ex | 40 +++++++-- lib/modules/sync/web/connections_live.ex | 3 + .../components/core/cookie_consent.ex | 89 ++++++------------- .../components/layout_wrapper.ex | 2 + 4 files changed, 67 insertions(+), 67 deletions(-) diff --git a/lib/modules/legal/legal.ex b/lib/modules/legal/legal.ex index 20121c2a1..e2169761d 100644 --- a/lib/modules/legal/legal.ex +++ b/lib/modules/legal/legal.ex @@ -38,6 +38,7 @@ defmodule PhoenixKit.Modules.Legal do alias PhoenixKit.Modules.Legal.PageType alias PhoenixKit.Modules.Legal.TemplateGenerator alias PhoenixKit.Settings + alias PhoenixKit.Utils.Routes @enabled_key "legal_enabled" @module_name "legal" @@ -551,12 +552,26 @@ defmodule PhoenixKit.Modules.Legal do - google_consent_mode: boolean - hide_for_authenticated: boolean - frameworks: list of framework IDs - - cookie_policy_url: string - - privacy_policy_url: string + - 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 - prefix = PhoenixKit.Config.get_url_prefix() + legal_links = get_published_legal_links() + + cookie_policy_url = + case Enum.find(legal_links, &String.ends_with?(&1.url, "/cookie-policy")) do + %{url: url} -> url + nil -> Routes.path("/legal/cookie-policy") + end + + privacy_policy_url = + case Enum.find(legal_links, &String.ends_with?(&1.url, "/privacy-policy")) do + %{url: url} -> url + nil -> Routes.path("/legal/privacy-policy") + end %{ enabled: consent_widget_enabled?(), @@ -567,11 +582,26 @@ defmodule PhoenixKit.Modules.Legal do policy_version: get_auto_policy_version(), google_consent_mode: google_consent_mode_enabled?(), frameworks: get_selected_frameworks(), - cookie_policy_url: "#{prefix}/legal/cookie-policy", - privacy_policy_url: "#{prefix}/legal/privacy-policy" + cookie_policy_url: cookie_policy_url, + privacy_policy_url: privacy_policy_url, + legal_links: legal_links, + legal_index_url: Routes.path("/legal") } end + @doc """ + Returns a list of all published legal pages as link maps. + + Each map has `:title` and `:url` keys. Used by the cookie consent widget + to render dynamic links to all published legal pages. + """ + @spec get_published_legal_links() :: list(%{title: String.t(), url: String.t()}) + def get_published_legal_links do + list_generated_pages() + |> Enum.filter(&(&1.status == "published")) + |> Enum.map(&%{title: &1.title, url: Routes.path("/legal/#{&1.slug}")}) + end + @doc """ Check if there are unpublished legal pages that are required. diff --git a/lib/modules/sync/web/connections_live.ex b/lib/modules/sync/web/connections_live.ex index 1111486cd..ebfdfe137 100644 --- a/lib/modules/sync/web/connections_live.ex +++ b/lib/modules/sync/web/connections_live.ex @@ -1163,6 +1163,9 @@ defmodule PhoenixKit.Modules.Sync.Web.ConnectionsLive do topo_sort(graph, Map.keys(graph), [], MapSet.new()) end + @dialyzer {:no_opaque, + [topo_sort: 4, visit_node: 5, get_table_dependencies: 2, get_table_dependencies: 3]} + defp topo_sort(_graph, [], sorted, _visited), do: sorted defp topo_sort(graph, [node | rest], sorted, visited) do diff --git a/lib/phoenix_kit_web/components/core/cookie_consent.ex b/lib/phoenix_kit_web/components/core/cookie_consent.ex index ed04a93b4..1e9c5e2eb 100644 --- a/lib/phoenix_kit_web/components/core/cookie_consent.ex +++ b/lib/phoenix_kit_web/components/core/cookie_consent.ex @@ -75,6 +75,13 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do attr :policy_version, :string, default: "1.0", doc: "Policy version for consent tracking" attr :cookie_policy_url, :string, default: "/legal/cookie-policy" attr :privacy_policy_url, :string, default: "/legal/privacy-policy" + + attr :legal_links, :list, + default: [], + doc: "Dynamic list of %{title, url} for published legal pages" + + attr :legal_index_url, :string, default: "/legal", doc: "URL to legal pages index" + attr :google_consent_mode, :boolean, default: false, doc: "Enable Google Consent Mode v2" attr :class, :string, default: "" @@ -173,7 +180,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do } .pk-glass { - background: oklch(var(--b1) / 0.95); + background: oklch(var(--b1) / 0.98); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); border: 1px solid var(--pk-border); @@ -190,25 +197,6 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do transform: translateY(-2px); box-shadow: 0 4px 12px oklch(var(--bc) / 0.1); } - - .pk-toggle-track { - background: var(--pk-border); - transition: background-color 0.2s ease; - } - - .pk-toggle-track.active { - background: var(--pk-primary); - } - - .pk-toggle-thumb { - background: var(--pk-bg); - box-shadow: 0 1px 3px oklch(var(--bc) / 0.2); - transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); - } - - input:checked + .pk-toggle-track .pk-toggle-thumb { - transform: translateX(20px); - } <%!-- Floating Icon (only for opt-in frameworks) --%> @@ -254,17 +242,16 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do

{gettext("We value your privacy")}

-

+

{gettext( "We use cookies to enhance your browsing experience and analyze our traffic." )} {" "} - {gettext("Cookie Policy")} + {gettext("Legal")}

@@ -308,7 +295,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do > <%!-- Backdrop --%>
@@ -328,7 +315,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do

{gettext("Privacy Preferences")}

-

+

{gettext("Manage your cookie settings")}

@@ -356,7 +343,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do <%= for category <- @categories do %>
@@ -374,34 +361,21 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do <% end %>
-

+

{category.description}

- <%!-- Custom Toggle --%> - + <%!-- Toggle --%> + <% end %> @@ -411,21 +385,12 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
<%!-- Policy Links --%> -
- - {gettext("Privacy Policy")} - - + diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index 5f77299a7..e557c0c41 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -700,6 +700,8 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do policy_version={config.policy_version} cookie_policy_url={config.cookie_policy_url} privacy_policy_url={config.privacy_policy_url} + legal_links={config.legal_links} + legal_index_url={config.legal_index_url} google_consent_mode={config.google_consent_mode} /> <% end %> From 14d0259727fc6ad904f485c5c8c50fb97b6bacbb Mon Sep 17 00:00:00 2001 From: timujeen Date: Wed, 25 Mar 2026 09:39:18 +0000 Subject: [PATCH 2/8] Add CountryData to core utils for billing extraction --- lib/phoenix_kit/utils/country_data.ex | 601 ++++++++++++++++++++++++++ 1 file changed, 601 insertions(+) create mode 100644 lib/phoenix_kit/utils/country_data.ex diff --git a/lib/phoenix_kit/utils/country_data.ex b/lib/phoenix_kit/utils/country_data.ex new file mode 100644 index 000000000..270a037b4 --- /dev/null +++ b/lib/phoenix_kit/utils/country_data.ex @@ -0,0 +1,601 @@ +defmodule PhoenixKit.Utils.CountryData do + @moduledoc """ + Wrapper for BeamLabCountries with country data utility functions. + + Provides a convenient API for working with country data: + country selection, tax rates, EU membership. + + Includes workaround for charlist bug in VAT rates until fixed upstream. + + ## Examples + + # Get list of countries for dropdown + countries = CountryData.countries_for_select() + # [{"🇦🇩 Andorra", "AD"}, {"🇦🇪 United Arab Emirates", "AE"}, ...] + + # Get standard VAT rate + rate = CountryData.get_standard_vat_rate("EE") + # #Decimal<0.20> + + # Check EU membership + CountryData.eu_member?("EE") + # true + + # Get country information + country = CountryData.get_country("DE") + # %BeamLabCountries.Country{name: "Germany", ...} + + # Format company address from Settings + address = CountryData.format_company_address() + # "123 Business Street\\nTallinn 10115\\nEstonia" + """ + + alias PhoenixKit.Modules.Billing.IbanData + alias PhoenixKit.Settings + + @doc """ + Get all countries sorted by name. + + ## Examples + + iex> countries = CountryData.list_countries() + iex> length(countries) + 250 + iex> hd(countries).name + "Afghanistan" + """ + def list_countries do + BeamLabCountries.all() + |> Enum.sort_by(& &1.name) + end + + @doc """ + Get country by alpha-2 code. + + ## Examples + + iex> country = CountryData.get_country("EE") + iex> country.name + "Estonia" + + iex> CountryData.get_country("XX") + nil + """ + def get_country(code) when is_binary(code) do + BeamLabCountries.get(code) + end + + def get_country(_), do: nil + + @doc """ + Get standard VAT rate for a country as Decimal. + + Returns rate in decimal format (0.20 = 20%). + If country not found or has no VAT rates, returns 0. + + ## Examples + + iex> CountryData.get_standard_vat_rate("EE") + #Decimal<0.20> + + iex> CountryData.get_standard_vat_rate("DE") + #Decimal<0.19> + + iex> CountryData.get_standard_vat_rate("US") + #Decimal<0> + """ + def get_standard_vat_rate(country_code) when is_binary(country_code) do + case get_country(country_code) do + %{vat_rates: %{standard: rate}} when is_number(rate) -> + rate + |> Decimal.new() + |> Decimal.div(100) + + _ -> + Decimal.new("0") + end + end + + def get_standard_vat_rate(_), do: Decimal.new("0") + + @doc """ + Get standard VAT rate as percentage (integer). + + Returns rate as percentage (20 = 20%). + + ## Examples + + iex> CountryData.get_standard_vat_percent("EE") + 20 + + iex> CountryData.get_standard_vat_percent("DE") + 19 + + iex> CountryData.get_standard_vat_percent("US") + 0 + """ + def get_standard_vat_percent(country_code) when is_binary(country_code) do + case get_country(country_code) do + %{vat_rates: %{standard: rate}} when is_number(rate) -> rate + _ -> 0 + end + end + + def get_standard_vat_percent(_), do: 0 + + @doc """ + Get all VAT rates with workaround for charlist bug. + + Returns map with normalized rates: + - :standard - standard rate (integer) + - :reduced - reduced rates (list of integers) + - :super_reduced - super reduced rate (integer or nil) + - :parking - parking rate (integer or nil) + + ## Examples + + iex> CountryData.get_vat_rates("EE") + %{standard: 20, reduced: [9], super_reduced: nil, parking: nil} + + iex> CountryData.get_vat_rates("FR") + %{standard: 20, reduced: [5.5, 10], super_reduced: 2.1, parking: nil} + + iex> CountryData.get_vat_rates("US") + nil + """ + def get_vat_rates(country_code) when is_binary(country_code) do + case get_country(country_code) do + %{vat_rates: rates} when is_map(rates) -> normalize_rates(rates) + _ -> nil + end + end + + def get_vat_rates(_), do: nil + + @doc """ + Check if country is an EU member. + + ## Examples + + iex> CountryData.eu_member?("EE") + true + + iex> CountryData.eu_member?("GB") + false + + iex> CountryData.eu_member?("US") + false + """ + def eu_member?(country_code) when is_binary(country_code) do + case get_country(country_code) do + %{eu_member: true} -> true + _ -> false + end + end + + def eu_member?(_), do: false + + @doc """ + Check if country is an EEA (European Economic Area) member. + + EEA includes EU + Norway, Iceland, Liechtenstein. + + ## Examples + + iex> CountryData.eea_member?("EE") + true + + iex> CountryData.eea_member?("NO") + true + + iex> CountryData.eea_member?("CH") + false + """ + def eea_member?(country_code) when is_binary(country_code) do + case get_country(country_code) do + %{eea_member: true} -> true + _ -> false + end + end + + def eea_member?(_), do: false + + @doc """ + Get list of EU countries. + + ## Examples + + iex> eu = CountryData.eu_countries() + iex> length(eu) + 27 + iex> Enum.map(eu, & &1.alpha2) |> Enum.sort() |> Enum.take(5) + ["AT", "BE", "BG", "CY", "CZ"] + """ + def eu_countries do + BeamLabCountries.filter_by(:eu_member, true) + end + + @doc """ + Get list of EEA countries (EU + Norway, Iceland, Liechtenstein). + """ + def eea_countries do + BeamLabCountries.filter_by(:eea_member, true) + end + + @doc """ + Get list of countries for select dropdown. + + Returns list of tuples {display_name, alpha2_code} for use + in Phoenix form selects. + + ## Examples + + iex> countries = CountryData.countries_for_select() + iex> {"🇦🇫 Afghanistan", "AF"} in countries + true + """ + def countries_for_select do + list_countries() + |> Enum.map(fn c -> + display_name = + case c.flag do + nil -> c.name + "" -> c.name + flag -> flag <> " " <> c.name + end + + {display_name, c.alpha2} + end) + end + + @doc """ + Get the subdivision label for a country. + + Returns appropriate label like "State", "Province", "Region", etc. + based on what the country uses for administrative divisions. + + ## Examples + + iex> CountryData.get_subdivision_label("US") + "State" + + iex> CountryData.get_subdivision_label("CA") + "Province" + + iex> CountryData.get_subdivision_label("EE") + "County" + """ + def get_subdivision_label(nil), do: "State/Province" + def get_subdivision_label(""), do: "State/Province" + + def get_subdivision_label(alpha2) when is_binary(alpha2) do + case BeamLabCountries.get(alpha2) do + nil -> "State/Province" + country -> Map.get(country, :subdivision_type) || "State/Province" + end + end + + @doc """ + Get list of EU countries for select dropdown. + """ + def eu_countries_for_select do + eu_countries() + |> Enum.sort_by(& &1.name) + |> Enum.map(fn c -> + display_name = + case c.flag do + nil -> c.name + "" -> c.name + flag -> flag <> " " <> c.name + end + + {display_name, c.alpha2} + end) + end + + @doc """ + Get country currency code. + + ## Examples + + iex> CountryData.get_currency_code("EE") + "EUR" + + iex> CountryData.get_currency_code("GB") + "GBP" + + iex> CountryData.get_currency_code("US") + "USD" + """ + def get_currency_code(country_code) when is_binary(country_code) do + case get_country(country_code) do + %{currency_code: code} when is_binary(code) -> code + _ -> nil + end + end + + def get_currency_code(_), do: nil + + @doc """ + Get country name. + + ## Examples + + iex> CountryData.get_country_name("EE") + "Estonia" + + iex> CountryData.get_country_name("XX") + nil + """ + def get_country_name(country_code) when is_binary(country_code) do + case get_country(country_code) do + %{name: name} -> name + _ -> nil + end + end + + def get_country_name(_), do: nil + + @doc """ + Get country flag (emoji). + + ## Examples + + iex> CountryData.get_flag("EE") + "🇪🇪" + """ + def get_flag(country_code) when is_binary(country_code) do + case get_country(country_code) do + %{flag: flag} -> flag + _ -> nil + end + end + + def get_flag(_), do: nil + + @doc """ + Check if country with given code exists. + + ## Examples + + iex> CountryData.exists?("EE") + true + + iex> CountryData.exists?("XX") + false + """ + def exists?(country_code) when is_binary(country_code) do + get_country(country_code) != nil + end + + def exists?(_), do: false + + @doc """ + Format company address from Settings for document printing. + + Assembles address from individual fields (address_line1, address_line2, city, state, + postal_code, country) into a single string with line breaks. + + ## Returns + + Formatted address as string, for example: + ``` + 123 Business Street + Suite 100 + Tallinn 10115 + Estonia + ``` + + ## Examples + + iex> CountryData.format_company_address() + "123 Business Street\\nTallinn 10115\\nEstonia" + """ + def format_company_address do + company_info = get_company_info() + + address_line1 = company_info["address_line1"] || "" + address_line2 = company_info["address_line2"] || "" + city = company_info["city"] || "" + state = company_info["state"] || "" + postal_code = company_info["postal_code"] || "" + country_code = company_info["country"] || "" + + country_name = + case get_country(country_code) do + %{name: name} -> name + _ -> country_code + end + + city_postal = + [city, postal_code] + |> Enum.filter(&(&1 != "")) + |> Enum.join(" ") + + [address_line1, address_line2, city_postal, state, country_name] + |> Enum.filter(&(&1 != "" && &1 != " ")) + |> Enum.join("\n") + end + + @doc """ + Get company information from consolidated Settings. + + Reads from `company_info` JSONB with fallback to legacy `billing_company_*` keys. + """ + def get_company_info do + case Settings.get_json_setting("company_info", nil) do + nil -> + # Fallback to legacy billing_company_* keys + %{ + "name" => Settings.get_setting("billing_company_name", ""), + "address_line1" => Settings.get_setting("billing_company_address_line1", ""), + "address_line2" => Settings.get_setting("billing_company_address_line2", ""), + "city" => Settings.get_setting("billing_company_city", ""), + "state" => Settings.get_setting("billing_company_state", ""), + "postal_code" => Settings.get_setting("billing_company_postal_code", ""), + "country" => Settings.get_setting("billing_company_country", ""), + "vat_number" => Settings.get_setting("billing_company_vat", ""), + "registration_number" => "" + } + + info when is_map(info) -> + info + + _ -> + %{} + end + end + + @doc """ + Get bank details from consolidated Settings. + + Reads from `company_bank_details` JSONB with fallback to legacy `billing_bank_*` keys. + """ + def get_bank_details do + case Settings.get_json_setting("company_bank_details", nil) do + nil -> + # Fallback to legacy billing_bank_* keys + %{ + "bank_name" => Settings.get_setting("billing_bank_name", ""), + "iban" => Settings.get_setting("billing_bank_iban", ""), + "swift" => Settings.get_setting("billing_bank_swift", "") + } + + info when is_map(info) -> + info + + _ -> + %{} + end + end + + # ========================================================================== + # Banking Validation Functions + # ========================================================================== + + @doc """ + Validate IBAN format (length based on bank country, not company country). + + Bank can be in a different country than the company - this is legal. + Validates format and length based on IBAN's country prefix. + + Returns :ok or {:error, reason}. + + ## Examples + + iex> CountryData.validate_iban_format("EE382200221020145685", "EE") + :ok + + iex> CountryData.validate_iban_format("DE89370400440532013000", "EE") + :ok # German bank for Estonian company is valid + + iex> CountryData.validate_iban_format("DE123", "EE") + {:error, "IBAN must be 22 characters for DE"} + """ + def validate_iban_format(iban, _country_code) + when is_binary(iban) do + iban = String.replace(iban, ~r/\s/, "") |> String.upcase() + iban_country = String.slice(iban, 0, 2) + expected_length = IbanData.get_iban_length(iban_country) + + cond do + iban == "" -> + :ok + + expected_length == nil -> + # Unknown IBAN country - just validate basic format + if Regex.match?(~r/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/, iban) do + :ok + else + {:error, "Invalid IBAN format"} + end + + String.length(iban) != expected_length -> + {:error, "IBAN must be #{expected_length} characters for #{iban_country}"} + + not Regex.match?(~r/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/, iban) -> + {:error, "Invalid IBAN format"} + + true -> + :ok + end + end + + def validate_iban_format(_, _), do: :ok + + @doc """ + Validate SWIFT/BIC format (8 or 11 characters). + + SWIFT codes structure: + - 4 letters: bank code + - 2 letters: country code (ISO 3166) + - 2 characters: location code + - 3 characters (optional): branch code + + ## Examples + + iex> CountryData.validate_swift_format("HABAEE2X") + :ok + + iex> CountryData.validate_swift_format("HABAEE2XXXX") + :ok + + iex> CountryData.validate_swift_format("INVALID") + {:error, "SWIFT/BIC must be 8 or 11 characters"} + """ + def validate_swift_format(swift) when is_binary(swift) do + swift = String.replace(swift, ~r/\s/, "") |> String.upcase() + + cond do + swift == "" -> + :ok + + String.length(swift) not in [8, 11] -> + {:error, "SWIFT/BIC must be 8 or 11 characters"} + + not Regex.match?(~r/^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/, swift) -> + {:error, "Invalid SWIFT/BIC format"} + + true -> + :ok + end + end + + def validate_swift_format(_), do: :ok + + # ========================================================================== + # Private Functions - Workaround for charlist bug in BeamLabCountries + # ========================================================================== + # + # YAML parser interprets single-digit numbers in lists as charlists: + # - [9] → ~c"\t" (tab) + # - [7] → ~c"\a" (bell) + # - [10] → ~c"\n" (newline) + # + # These functions normalize data until fixed upstream. + + defp normalize_rates(rates) when is_map(rates) do + Map.new(rates, fn {k, v} -> {k, normalize_rate_value(v)} end) + end + + defp normalize_rate_value(nil), do: nil + + defp normalize_rate_value(list) when is_list(list) do + # If charlist of single element (bug), convert back + if charlist_single_digit?(list) do + [hd(list)] + else + Enum.map(list, &ensure_number/1) + end + end + + defp normalize_rate_value(value), do: value + + # Check if list is a charlist of single ASCII digit code + defp charlist_single_digit?([n]) when is_integer(n) and n >= 0 and n <= 127, do: true + defp charlist_single_digit?(_), do: false + + defp ensure_number(n) when is_integer(n), do: n + defp ensure_number(n) when is_float(n), do: n + defp ensure_number(_), do: nil +end From 522e78dd937b70c5f027bbdeecceaf32303a81fc Mon Sep 17 00:00:00 2001 From: timujeen Date: Wed, 25 Mar 2026 10:09:08 +0000 Subject: [PATCH 3/8] Decouple billing from core: routes, auth, cart, module registry --- lib/modules/shop/schemas/cart.ex | 8 +- lib/modules/shop/shop.ex | 4 +- lib/phoenix_kit/module_registry.ex | 1 - lib/phoenix_kit/users/auth.ex | 8 +- lib/phoenix_kit/utils/html_sanitizer.ex | 138 ++ lib/phoenix_kit/utils/multilang.ex | 407 +++++ .../components/core/admin_page_header.ex | 2 +- lib/phoenix_kit_web/integration.ex | 145 -- .../live/settings/organization.ex | 2 +- scripts/AWS_SETUP_README.md | 1229 +++++++++++++++ scripts/PHOENIXKIT_AWS_COMPATIBILITY.md | 1353 +++++++++++++++++ scripts/QUALITY_CHECK_USAGE.md | 114 ++ scripts/README.md | 243 +++ scripts/README_ZAI_ORCHESTRATION.md | 164 ++ scripts/SUBMISSION_TO_PHOENIXKIT.md | 311 ++++ scripts/agent.sh | 86 ++ scripts/aws_infrastructure_setup.ex | 372 +++++ scripts/init-container.sh | 168 ++ scripts/phoenix_kit_with_google_fix.tar.gz | Bin 0 -> 592967 bytes scripts/phoenixkit-oauth-bugs-and-fixes.md | 785 ++++++++++ scripts/run_agent.sh | 106 ++ scripts/run_agent_with_output.sh | 119 ++ scripts/run_sdk_agent.py | 177 +++ scripts/sesv2.ex | 111 ++ scripts/setup_aws_email_infrastructure.sh | 425 ++++++ scripts/setup_aws_infrastructure.exs | 297 ++++ scripts/test_aws_setup.sh | 345 +++++ scripts/zai_helper.sh | 327 ++++ scripts/zai_helper_timeout.sh | 44 + scripts/zai_workflow.sh | 260 ++++ 30 files changed, 7593 insertions(+), 158 deletions(-) create mode 100644 lib/phoenix_kit/utils/html_sanitizer.ex create mode 100644 lib/phoenix_kit/utils/multilang.ex create mode 100644 scripts/AWS_SETUP_README.md create mode 100644 scripts/PHOENIXKIT_AWS_COMPATIBILITY.md create mode 100644 scripts/QUALITY_CHECK_USAGE.md create mode 100644 scripts/README.md create mode 100644 scripts/README_ZAI_ORCHESTRATION.md create mode 100644 scripts/SUBMISSION_TO_PHOENIXKIT.md create mode 100755 scripts/agent.sh create mode 100644 scripts/aws_infrastructure_setup.ex create mode 100755 scripts/init-container.sh create mode 100644 scripts/phoenix_kit_with_google_fix.tar.gz create mode 100644 scripts/phoenixkit-oauth-bugs-and-fixes.md create mode 100755 scripts/run_agent.sh create mode 100755 scripts/run_agent_with_output.sh create mode 100755 scripts/run_sdk_agent.py create mode 100644 scripts/sesv2.ex create mode 100644 scripts/setup_aws_email_infrastructure.sh create mode 100644 scripts/setup_aws_infrastructure.exs create mode 100755 scripts/test_aws_setup.sh create mode 100755 scripts/zai_helper.sh create mode 100644 scripts/zai_helper_timeout.sh create mode 100755 scripts/zai_workflow.sh diff --git a/lib/modules/shop/schemas/cart.ex b/lib/modules/shop/schemas/cart.ex index b77b0de44..2829484f5 100644 --- a/lib/modules/shop/schemas/cart.ex +++ b/lib/modules/shop/schemas/cart.ex @@ -21,7 +21,6 @@ defmodule PhoenixKit.Modules.Shop.Cart do 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 @@ -47,11 +46,8 @@ defmodule PhoenixKit.Modules.Shop.Cart do field :shipping_country, :string - # Payment - belongs_to :payment_option, PaymentOption, - foreign_key: :payment_option_uuid, - references: :uuid, - type: UUIDv7 + # Payment option from billing package (cross-package reference) + field :payment_option_uuid, UUIDv7 # Totals (cached) field :subtotal, :decimal, default: Decimal.new("0") diff --git a/lib/modules/shop/shop.ex b/lib/modules/shop/shop.ex index 3b5b6cea7..7c7a1602c 100644 --- a/lib/modules/shop/shop.ex +++ b/lib/modules/shop/shop.ex @@ -1421,7 +1421,7 @@ defmodule PhoenixKit.Modules.Shop do base_query = Cart |> where([c], c.status == "active") - |> preload([:items, :shipping_method, :payment_option]) + |> preload([:items, :shipping_method]) cond do not is_nil(user_uuid) -> @@ -1470,7 +1470,7 @@ defmodule PhoenixKit.Modules.Shop do if UUIDUtils.valid?(uuid) do Cart |> where([c], c.uuid == ^uuid) - |> preload([:items, :shipping_method, :payment_option]) + |> preload([:items, :shipping_method]) |> repo().one() else nil diff --git a/lib/phoenix_kit/module_registry.ex b/lib/phoenix_kit/module_registry.ex index 64b9314cd..de64810e2 100644 --- a/lib/phoenix_kit/module_registry.ex +++ b/lib/phoenix_kit/module_registry.ex @@ -402,7 +402,6 @@ defmodule PhoenixKit.ModuleRegistry do defp internal_modules do [ PhoenixKit.Modules.AI, - PhoenixKit.Modules.Billing, PhoenixKit.Modules.Comments, PhoenixKit.Modules.Connections, PhoenixKit.Modules.DB, diff --git a/lib/phoenix_kit/users/auth.ex b/lib/phoenix_kit/users/auth.ex index e11df51b8..6d59803a0 100644 --- a/lib/phoenix_kit/users/auth.ex +++ b/lib/phoenix_kit/users/auth.ex @@ -2308,8 +2308,12 @@ defmodule PhoenixKit.Users.Auth do # Delete billing profiles for a user (uses user_uuid for safety) defp delete_user_billing_profiles(user_uuid) do - from(bp in PhoenixKit.Modules.Billing.BillingProfile, where: bp.user_uuid == ^user_uuid) - |> Repo.repo().delete_all() + if Code.ensure_loaded?(PhoenixKit.Modules.Billing) do + billing_profile_schema = PhoenixKit.Modules.Billing.BillingProfile + + from(bp in billing_profile_schema, where: bp.user_uuid == ^user_uuid) + |> Repo.repo().delete_all() + end end # Delete shop carts for a user (uses user_uuid for safety) diff --git a/lib/phoenix_kit/utils/html_sanitizer.ex b/lib/phoenix_kit/utils/html_sanitizer.ex new file mode 100644 index 000000000..adf96ced8 --- /dev/null +++ b/lib/phoenix_kit/utils/html_sanitizer.ex @@ -0,0 +1,138 @@ +defmodule PhoenixKit.Utils.HtmlSanitizer do + @moduledoc """ + HTML sanitization for rich text content in entities. + + This module provides basic HTML sanitization to prevent XSS attacks + while allowing safe HTML tags commonly used in rich text editors. + + ## Allowed Tags + + The following tags are allowed: + - Block elements: p, div, br, hr, h1-h6, blockquote, pre, code + - Inline elements: span, strong, b, em, i, u, s, a, sub, sup, mark + - Lists: ul, ol, li + - Tables: table, thead, tbody, tr, th, td + - Media placeholders: img (with src validation) + + ## Removed Content + + The following are stripped completely: + - script tags and content + - style tags and content + - event handlers (onclick, onerror, etc.) + - javascript: and data: URLs + - iframe, object, embed tags + + ## Usage + + iex> PhoenixKit.Utils.HtmlSanitizer.sanitize("

Hello

") + "

Hello

" + + iex> PhoenixKit.Utils.HtmlSanitizer.sanitize("Click") + "Click" + """ + + # Note: These are documented for reference. The current simple implementation + # strips dangerous content rather than whitelisting allowed tags. + # A more complete implementation using a library like HtmlSanitizeEx would use these. + # + # Allowed tags: + # p div br hr h1-h6 blockquote pre code + # span strong b em i u s a sub sup mark + # ul ol li table thead tbody tr th td img + # + # Allowed attributes: + # a: href title target rel + # img: src alt title width height + # td/th: colspan rowspan + # all: class id + + @doc """ + Sanitizes HTML content by removing dangerous elements and attributes. + + Returns sanitized HTML string that is safe to render. + + ## Parameters + + - `html` - The HTML string to sanitize + + ## Examples + + iex> PhoenixKit.Utils.HtmlSanitizer.sanitize("

Hello

") + "

Hello

" + """ + def sanitize(nil), do: nil + def sanitize(""), do: "" + + def sanitize(html) when is_binary(html) do + html + |> remove_dangerous_patterns() + |> sanitize_urls() + |> String.trim() + end + + def sanitize(other), do: other + + @doc """ + Sanitizes all rich_text fields in an entity data map. + + Takes entity field definitions and data, returns data with all + rich_text fields sanitized. + + ## Parameters + + - `fields_definition` - List of field definition maps + - `data` - Map of field key => value + + ## Examples + + iex> fields = [%{"type" => "rich_text", "key" => "content"}] + iex> data = %{"content" => "

Hello

"} + iex> PhoenixKit.Utils.HtmlSanitizer.sanitize_rich_text_fields(fields, data) + %{"content" => "

Hello

"} + """ + def sanitize_rich_text_fields(fields_definition, data) + when is_list(fields_definition) and is_map(data) do + rich_text_keys = + fields_definition + |> Enum.filter(fn field -> field["type"] == "rich_text" end) + |> Enum.map(fn field -> field["key"] end) + + Enum.reduce(rich_text_keys, data, fn key, acc -> + case Map.get(acc, key) do + nil -> acc + value -> Map.put(acc, key, sanitize(value)) + end + end) + end + + def sanitize_rich_text_fields(_fields, data), do: data + + # Private functions + + defp remove_dangerous_patterns(html) do + dangerous_patterns = [ + # Script tags with content + ~r/]*>[\s\S]*?<\/script>/i, + # Style tags with content + ~r/]*>[\s\S]*?<\/style>/i, + # Event handlers + ~r/\s+on\w+\s*=\s*["'][^"']*["']/i, + ~r/\s+on\w+\s*=\s*[^\s>]+/i, + # Dangerous tags + ~r/<\s*(iframe|object|embed|form|input|button|meta|link|base)\b[^>]*>/i, + ~r/<\/\s*(iframe|object|embed|form|input|button|meta|link|base)\s*>/i + ] + + Enum.reduce(dangerous_patterns, html, fn pattern, acc -> + Regex.replace(pattern, acc, "") + end) + end + + defp sanitize_urls(html) do + # Remove dangerous href and src attributes + html + |> then(&Regex.replace(~r/href\s*=\s*["']\s*(javascript|vbscript|data):[^"']*["']/i, &1, "")) + |> then(&Regex.replace(~r/src\s*=\s*["']\s*(javascript|vbscript|data):[^"']*["']/i, &1, "")) + end +end diff --git a/lib/phoenix_kit/utils/multilang.ex b/lib/phoenix_kit/utils/multilang.ex new file mode 100644 index 000000000..e60ed2f39 --- /dev/null +++ b/lib/phoenix_kit/utils/multilang.ex @@ -0,0 +1,407 @@ +defmodule PhoenixKit.Utils.Multilang do + @moduledoc """ + Multi-language data transformation helpers for entity data JSONB. + + Multi-language support is driven by the Languages module globally. + When the Languages module is enabled and has more than one language, + all entities automatically support multilang data. There is no + per-entity toggle — languages are configured system-wide. + + The `data` JSONB column stores a nested structure: + + %{ + "_primary_language" => "en-US", + "en-US" => %{"_title" => "Acme", "name" => "Acme", "tagline" => "Quality products"}, + "es-ES" => %{"_title" => "Acme España", "name" => "Acme España"} + } + + The primary language always has complete data. Secondary languages + store only overrides — fields that differ from primary. Display + merges primary values as defaults with language-specific overrides. + + The `_title` key stores the record title alongside custom fields, + unifying title translation with the same override-only storage pattern. + The `title` DB column remains a denormalized copy for queries/sorting. + """ + + alias PhoenixKit.Modules.Languages + + @primary_language_key "_primary_language" + + # ── Global language helpers ───────────────────────────────────── + + @doc """ + Checks if multilang is enabled globally. + Returns true when the Languages module is enabled and has more than one language. + """ + @spec enabled?() :: boolean() + def enabled? do + if languages_available?() do + length(enabled_language_codes()) > 1 + else + false + end + end + + @doc """ + Gets the primary (default) language code. + Returns the Languages module default, falling back to "en-US". + """ + @spec primary_language() :: String.t() + def primary_language do + default_language_code() + end + + @doc """ + Gets the list of enabled language codes. + Returns at minimum the primary language. + """ + @spec enabled_languages() :: [String.t()] + def enabled_languages do + if languages_available?() do + codes = enabled_language_codes() + primary = primary_language() + if primary in codes, do: codes, else: [primary | codes] + else + [primary_language()] + end + end + + # ── Data read helpers ───────────────────────────────────────── + + @doc """ + Extracts the data map for a specific language from a record's data. + + For multilang data: returns merged data (primary as base + overrides). + For flat data: returns data as-is (backward compat). + """ + @spec get_language_data(map() | nil, String.t()) :: map() + def get_language_data(data, lang_code) do + if multilang_data?(data) do + primary = primary_language_from_data(data) + primary_data = Map.get(data, primary, %{}) + + if lang_code == primary do + primary_data + else + lang_data = Map.get(data, lang_code, %{}) + Map.merge(primary_data, lang_data) + end + else + data || %{} + end + end + + @doc """ + Gets the primary language data from a record (for display in lists etc). + """ + @spec get_primary_data(map() | nil) :: map() + def get_primary_data(data) do + if multilang_data?(data) do + primary = primary_language_from_data(data) + Map.get(data, primary, %{}) + else + data || %{} + end + end + + @doc """ + Gets raw (non-merged) language-specific data for a language. + Used by the form UI to detect which fields are overridden vs inherited. + """ + @spec get_raw_language_data(map() | nil, String.t()) :: map() + def get_raw_language_data(data, lang_code) do + if multilang_data?(data) do + Map.get(data, lang_code, %{}) + else + data || %{} + end + end + + @doc """ + Checks if a data map uses the multilang structure. + Presence of `_primary_language` key indicates multilang. + """ + @spec multilang_data?(map() | nil) :: boolean() + def multilang_data?(nil), do: false + + def multilang_data?(data) when is_map(data) do + Map.has_key?(data, @primary_language_key) + end + + def multilang_data?(_), do: false + + # ── Data write helpers ──────────────────────────────────────── + + @doc """ + Merges language-specific form data into the full multilang JSONB. + + For primary language: stores ALL fields. + For secondary language: stores only fields that differ from primary. + """ + @spec put_language_data(map() | nil, String.t(), map()) :: map() + def put_language_data(existing_data, lang_code, new_field_data) do + existing_data = existing_data || %{} + + # Use embedded primary for existing multilang data, global for new/flat data + primary = + if multilang_data?(existing_data) do + primary_language_from_data(existing_data) + else + primary_language() + end + + # Ensure multilang structure + base_data = + if multilang_data?(existing_data) do + existing_data + else + # Convert flat data to multilang (migration path) + %{@primary_language_key => primary, primary => existing_data} + end + + if lang_code == primary do + # Primary language: store all fields + Map.put(base_data, lang_code, new_field_data) + else + # Secondary language: only store overrides + primary_data = Map.get(base_data, primary, %{}) + overrides = compute_overrides(new_field_data, primary_data) + + if map_size(overrides) == 0 do + Map.delete(base_data, lang_code) + else + Map.put(base_data, lang_code, overrides) + end + end + end + + @doc """ + Converts existing flat data to multilang structure. + """ + @spec migrate_to_multilang(map() | nil, String.t()) :: map() + def migrate_to_multilang(flat_data, primary_lang) do + flat_data = flat_data || %{} + + %{ + @primary_language_key => primary_lang, + primary_lang => flat_data + } + end + + @doc """ + Converts multilang data back to flat structure. + Returns primary language data. + """ + @spec flatten_to_primary(map() | nil) :: map() + def flatten_to_primary(nil), do: %{} + + def flatten_to_primary(data) when is_map(data) do + primary = data[@primary_language_key] + if primary, do: Map.get(data, primary, %{}), else: data + end + + def flatten_to_primary(_), do: %{} + + # ── Primary language re-keying ────────────────────────────── + + @doc """ + Re-keys multilang data to a new primary language. + + Updates `_primary_language` to the new primary and ensures the new + primary has complete data (fills missing fields from the old primary). + All secondary languages are recomputed: their overrides are recalculated + against the new promoted primary, and languages with zero overrides are removed. + + Returns data unchanged if already using the given primary or not multilang. + """ + @spec rekey_primary(map() | nil, String.t()) :: map() + def rekey_primary(nil, _new_primary), do: nil + + def rekey_primary(data, new_primary) when is_map(data) do + cond do + not multilang_data?(data) -> + data + + primary_language_from_data(data) == new_primary -> + data + + true -> + old_primary = primary_language_from_data(data) + old_primary_data = Map.get(data, old_primary, %{}) + new_primary_data = Map.get(data, new_primary, %{}) + + # Promote: fill missing fields in new primary from old primary + promoted = Map.merge(old_primary_data, new_primary_data) + + data = + data + |> Map.put(@primary_language_key, new_primary) + |> Map.put(new_primary, promoted) + + # Recompute all secondaries (including old primary) against the new base + recompute_all_secondaries(data, new_primary, promoted, old_primary_data) + end + end + + def rekey_primary(data, _new_primary), do: data + + @doc """ + Checks if data needs re-keying (embedded primary != global primary). + Returns re-keyed data if needed, original data otherwise. + """ + @spec maybe_rekey_data(map() | nil) :: map() | nil + def maybe_rekey_data(data) do + if multilang_data?(data) do + global = primary_language() + embedded = primary_language_from_data(data) + + if embedded != global do + rekey_primary(data, global) + else + data + end + else + data + end + end + + # ── Language tab helpers ────────────────────────────────────── + + @doc """ + Builds language tab data for the UI from the Languages module. + Returns a list of maps with code, name, flag, and is_primary fields. + """ + @spec build_language_tabs() :: [map()] + def build_language_tabs do + if enabled?() do + primary = primary_language() + langs = enabled_languages() + + # Ensure primary is always first + ordered = [primary | Enum.reject(langs, &(&1 == primary))] + + Enum.map(ordered, fn code -> + info = get_language_info(code) + + %{ + code: code, + name: info.name, + flag: info.flag, + is_primary: code == primary, + short_code: compute_short_code(code, ordered) + } + end) + else + [] + end + end + + # ── Private helpers ─────────────────────────────────────────── + + defp compute_short_code(code, all_codes) do + base = code |> String.split("-") |> List.first() |> String.upcase() + + collision = + Enum.any?(all_codes, fn other -> + other != code and + other |> String.split("-") |> List.first() |> String.upcase() == base + end) + + if collision, do: String.upcase(code), else: base + end + + # After rekeying, recompute overrides for every secondary language against the + # new promoted primary. Removes language keys that have zero overrides. + defp recompute_all_secondaries(data, new_primary, promoted, old_primary_data) do + Enum.reduce(data, data, fn + {@primary_language_key, _}, acc -> + acc + + {^new_primary, _}, acc -> + acc + + {lang, lang_data}, acc when is_map(lang_data) -> + # Reconstruct full data using OLD primary as base (overrides were against old primary) + full_lang_data = Map.merge(old_primary_data, lang_data) + # Then diff against the NEW primary to compute new overrides + overrides = compute_overrides(full_lang_data, promoted) + put_or_remove_language(acc, lang, overrides) + + {_key, _value}, acc -> + acc + end) + end + + defp put_or_remove_language(data, lang, overrides) do + if map_size(overrides) == 0 do + Map.delete(data, lang) + else + Map.put(data, lang, overrides) + end + end + + defp compute_overrides(lang_data, primary_data) do + lang_data + |> Enum.filter(fn {key, value} -> + value != nil and value != "" and Map.get(primary_data, key) != value + end) + |> Map.new() + end + + defp primary_language_from_data(data) do + data[@primary_language_key] || primary_language() + end + + defp languages_available? do + Code.ensure_loaded?(Languages) and + function_exported?(Languages, :enabled?, 0) and + Languages.enabled?() + end + + defp enabled_language_codes do + if Code.ensure_loaded?(Languages) and + function_exported?(Languages, :get_enabled_language_codes, 0) do + Languages.get_enabled_language_codes() + else + [default_language_code()] + end + end + + defp default_language_code do + if Code.ensure_loaded?(Languages) and + function_exported?(Languages, :get_default_language, 0) do + case Languages.get_default_language() do + %{code: code} when is_binary(code) -> code + _ -> "en-US" + end + else + "en-US" + end + end + + defp get_language_info(code) do + lang = + if Code.ensure_loaded?(Languages) and + function_exported?(Languages, :get_language, 1) do + Languages.get_language(code) + end + + available = + if is_nil(lang) and Code.ensure_loaded?(Languages) and + function_exported?(Languages, :get_available_language_by_code, 1) do + Languages.get_available_language_by_code(code) + end + + cond do + lang != nil -> + %{name: Map.get(lang, :name, code), flag: Map.get(lang, :flag, nil)} + + available != nil -> + %{name: Map.get(available, :name, code), flag: Map.get(available, :flag, nil)} + + true -> + %{name: code, flag: nil} + end + end +end diff --git a/lib/phoenix_kit_web/components/core/admin_page_header.ex b/lib/phoenix_kit_web/components/core/admin_page_header.ex index a0b936289..9422621eb 100644 --- a/lib/phoenix_kit_web/components/core/admin_page_header.ex +++ b/lib/phoenix_kit_web/components/core/admin_page_header.ex @@ -46,7 +46,7 @@ defmodule PhoenixKitWeb.Components.Core.AdminPageHeader do <%!-- Rich title content --%> - <.admin_page_header back={Routes.path("/admin/billing")}> + <.admin_page_header back={Routes.path("/admin/orders")}>

Invoice #123

Created 2 days ago

diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index 8d0abd304..99fcafae6 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -317,23 +317,6 @@ defmodule PhoenixKitWeb.Integration do :xsl_index_stylesheet end - # Billing webhook routes - uses PhoenixKit.Modules.Billing namespace (no PhoenixKitWeb prefix) - scope unquote(url_prefix) do - pipe_through [:phoenix_kit_api] - - post "/webhooks/billing/stripe", - PhoenixKit.Modules.Billing.Web.WebhookController, - :stripe - - post "/webhooks/billing/paypal", - PhoenixKit.Modules.Billing.Web.WebhookController, - :paypal - - post "/webhooks/billing/razorpay", - PhoenixKit.Modules.Billing.Web.WebhookController, - :razorpay - end - # Shop public routes are generated via generate_shop_public_routes/1 helper # This supports locale-prefixed URLs (/:locale/shop/...) with language switching # Shop user dashboard routes are now in phoenix_kit_authenticated_routes/1. @@ -514,104 +497,6 @@ defmodule PhoenixKitWeb.Integration do :index, as: :sitemap_settings - # Billing admin routes - live "/admin/billing", PhoenixKit.Modules.Billing.Web.Index, :index, as: :billing_index - - live "/admin/billing/orders", PhoenixKit.Modules.Billing.Web.Orders, :index, - as: :billing_orders - - live "/admin/billing/orders/new", PhoenixKit.Modules.Billing.Web.OrderForm, :new, - as: :billing_order_new - - live "/admin/billing/orders/:id", PhoenixKit.Modules.Billing.Web.OrderDetail, :show, - as: :billing_order_detail - - live "/admin/billing/orders/:id/edit", PhoenixKit.Modules.Billing.Web.OrderForm, :edit, - as: :billing_order_edit - - live "/admin/billing/invoices", PhoenixKit.Modules.Billing.Web.Invoices, :index, - as: :billing_invoices - - live "/admin/billing/invoices/:id", PhoenixKit.Modules.Billing.Web.InvoiceDetail, :show, - as: :billing_invoice_detail - - live "/admin/billing/invoices/:id/print", - PhoenixKit.Modules.Billing.Web.InvoicePrint, - :print, - as: :billing_invoice_print - - live "/admin/billing/invoices/:id/receipt", - PhoenixKit.Modules.Billing.Web.ReceiptPrint, - :receipt, - as: :billing_receipt_print - - live "/admin/billing/invoices/:id/credit-note/:transaction_uuid", - PhoenixKit.Modules.Billing.Web.CreditNotePrint, - :credit_note, - as: :billing_credit_note - - live "/admin/billing/invoices/:id/payment/:transaction_uuid", - PhoenixKit.Modules.Billing.Web.PaymentConfirmationPrint, - :payment_confirmation, - as: :billing_payment_confirmation - - live "/admin/billing/transactions", PhoenixKit.Modules.Billing.Web.Transactions, :index, - as: :billing_transactions - - live "/admin/billing/subscriptions", - PhoenixKit.Modules.Billing.Web.Subscriptions, - :index, - as: :billing_subscriptions - - live "/admin/billing/subscriptions/new", - PhoenixKit.Modules.Billing.Web.SubscriptionForm, - :new, - as: :billing_subscription_new - - live "/admin/billing/subscriptions/:id", - PhoenixKit.Modules.Billing.Web.SubscriptionDetail, - :show, - as: :billing_subscription_detail - - live "/admin/billing/subscription-types", - PhoenixKit.Modules.Billing.Web.SubscriptionTypes, - :index, - as: :billing_subscription_types - - live "/admin/billing/subscription-types/new", - PhoenixKit.Modules.Billing.Web.SubscriptionTypeForm, - :new, - as: :billing_subscription_type_new - - live "/admin/billing/subscription-types/:id/edit", - PhoenixKit.Modules.Billing.Web.SubscriptionTypeForm, - :edit, - as: :billing_subscription_type_edit - - live "/admin/billing/profiles", PhoenixKit.Modules.Billing.Web.BillingProfiles, :index, - as: :billing_profiles - - live "/admin/billing/profiles/new", - PhoenixKit.Modules.Billing.Web.BillingProfileForm, - :new, - as: :billing_profile_new - - live "/admin/billing/profiles/:id/edit", - PhoenixKit.Modules.Billing.Web.BillingProfileForm, - :edit, - as: :billing_profile_edit - - live "/admin/billing/currencies", PhoenixKit.Modules.Billing.Web.Currencies, :index, - as: :billing_currencies - - live "/admin/settings/billing", PhoenixKit.Modules.Billing.Web.Settings, :settings, - as: :billing_settings - - live "/admin/settings/billing/providers", - PhoenixKit.Modules.Billing.Web.ProviderSettings, - :index, - as: :billing_provider_settings - # DB Explorer routes live "/admin/db", PhoenixKit.Modules.DB.Web.Index, :index, as: :db_index @@ -818,21 +703,6 @@ defmodule PhoenixKitWeb.Integration do live "/dashboard/orders/:uuid", PhoenixKit.Modules.Shop.Web.UserOrderDetails, :show, as: :shop_user_order_details - live "/dashboard/billing-profiles", - PhoenixKit.Modules.Billing.Web.UserBillingProfiles, - :index, - as: :user_billing_profiles - - live "/dashboard/billing-profiles/new", - PhoenixKit.Modules.Billing.Web.UserBillingProfileForm, - :new, - as: :user_billing_profile_new - - live "/dashboard/billing-profiles/:id/edit", - PhoenixKit.Modules.Billing.Web.UserBillingProfileForm, - :edit, - as: :user_billing_profile_edit - # Tickets user pages live "/dashboard/customer-service/tickets", PhoenixKit.Modules.CustomerService.Web.UserList, @@ -860,21 +730,6 @@ defmodule PhoenixKitWeb.Integration do live "/dashboard/orders/:uuid", PhoenixKit.Modules.Shop.Web.UserOrderDetails, :show, as: :shop_user_order_details_locale - live "/dashboard/billing-profiles", - PhoenixKit.Modules.Billing.Web.UserBillingProfiles, - :index, - as: :user_billing_profiles_locale - - live "/dashboard/billing-profiles/new", - PhoenixKit.Modules.Billing.Web.UserBillingProfileForm, - :new, - as: :user_billing_profile_new_locale - - live "/dashboard/billing-profiles/:id/edit", - PhoenixKit.Modules.Billing.Web.UserBillingProfileForm, - :edit, - as: :user_billing_profile_edit_locale - # Tickets user pages (locale variants) live "/dashboard/customer-service/tickets", PhoenixKit.Modules.CustomerService.Web.UserList, diff --git a/lib/phoenix_kit_web/live/settings/organization.ex b/lib/phoenix_kit_web/live/settings/organization.ex index e6236b345..9b2e1c5a7 100644 --- a/lib/phoenix_kit_web/live/settings/organization.ex +++ b/lib/phoenix_kit_web/live/settings/organization.ex @@ -8,9 +8,9 @@ defmodule PhoenixKitWeb.Live.Settings.Organization do use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext - alias PhoenixKit.Modules.Billing.CountryData alias PhoenixKit.PubSub.Manager, as: PubSubManager alias PhoenixKit.Settings + alias PhoenixKit.Utils.CountryData alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.Routes diff --git a/scripts/AWS_SETUP_README.md b/scripts/AWS_SETUP_README.md new file mode 100644 index 000000000..37b5bc37f --- /dev/null +++ b/scripts/AWS_SETUP_README.md @@ -0,0 +1,1229 @@ +# AWS Email Infrastructure Setup Script + +Automated script to create complete AWS infrastructure for email event handling via SNS and SQS. + +## What the Script Creates + +The script automatically creates a complete infrastructure for processing email events: + +``` +SES Events → SNS Topic → SQS Main Queue → Your Application + ↓ (after N failed attempts) + SQS Dead Letter Queue +``` + +### Created Resources: + +1. **SNS Topic** - receives events from SES +2. **SQS Main Queue** - main queue for message processing +3. **SQS Dead Letter Queue (DLQ)** - queue for failed messages +4. **IAM Policies** - access policies between services +5. **SNS Subscription** - SQS subscription to SNS topic +6. **SES Configuration Set** - tracks email events (sends, bounces, opens, clicks, etc.) +7. **SES Event Destination** - forwards SES events to SNS topic + +## Usage + +### 1. Configure Parameters + +Open `scripts/setup_aws_email_infrastructure.sh` and modify parameters in the **CONFIGURATION** section: + +```bash +# Project name (used as prefix for all resources) +PROJECT_NAME="myapp" + +# AWS region +AWS_REGION="eu-north-1" + +# Queue names (created automatically) +MAIN_QUEUE_NAME="${PROJECT_NAME}-email-queue" +DLQ_NAME="${PROJECT_NAME}-email-dlq" + +# SNS Topic name +SNS_TOPIC_NAME="${PROJECT_NAME}-email-events" + +# SES Configuration Set name +SES_CONFIG_SET_NAME="${PROJECT_NAME}-emailing" + +# Queue configurations +MAIN_QUEUE_VISIBILITY_TIMEOUT=300 # 5 minutes +MAIN_QUEUE_MESSAGE_RETENTION=345600 # 4 days +MAIN_QUEUE_MAX_RECEIVE_COUNT=3 # Retries before DLQ +MAIN_QUEUE_RECEIVE_WAIT_TIME=20 # Long polling + +DLQ_VISIBILITY_TIMEOUT=60 # 1 minute +DLQ_MESSAGE_RETENTION=1209600 # 14 days + +# Application SQS polling interval (milliseconds) +SQS_POLLING_INTERVAL=5000 # 5 seconds +``` + +### 2. Verify AWS CLI + +Ensure AWS CLI is configured with proper credentials: + +```bash +aws configure list +aws sts get-caller-identity +``` + +### 3. Run the Script + +```bash +bash scripts/setup_aws_email_infrastructure.sh +``` + +or: + +```bash +./scripts/setup_aws_email_infrastructure.sh +``` + +### 4. Execution Result + +The script will output: +- Progress of resource creation +- **Ready-to-use values for configuration form** +- Commands for infrastructure verification +- Save configuration to file `aws-email-config-{PROJECT_NAME}.txt` + +## Example Output + +``` +============================================== +AWS Email Infrastructure Setup +============================================== + +Configuration: + Project: myapp + Region: eu-north-1 + Main Queue: myapp-email-queue + DLQ: myapp-email-dlq + SNS Topic: myapp-email-events + SES Configuration Set: myapp-emailing + +[1/9] Getting AWS Account ID... + ✓ Account ID: 123456789012 + +[2/9] Creating Dead Letter Queue... + ✓ DLQ Created/Found + URL: https://sqs.eu-north-1.amazonaws.com/123456789012/myapp-email-dlq + ARN: arn:aws:sqs:eu-north-1:123456789012:myapp-email-dlq + +[3/9] Setting DLQ policy... + ✓ DLQ Policy set + +[4/9] Creating SNS Topic... + ✓ SNS Topic Created/Found + ARN: arn:aws:sns:eu-north-1:123456789012:myapp-email-events + +[5/9] Creating Main Queue with DLQ redrive policy... + ✓ Main Queue Created/Found + URL: https://sqs.eu-north-1.amazonaws.com/123456789012/myapp-email-queue + ARN: arn:aws:sqs:eu-north-1:123456789012:myapp-email-queue + +[6/9] Setting Main Queue policy to allow SNS and account access... + ✓ Main Queue Policy set + +[7/9] Creating SNS subscription to SQS... + ✓ SNS → SQS Subscription created + +[8/9] Creating SES Configuration Set... + ✓ SES Configuration Set created/verified + Name: myapp-emailing + +[9/9] Configuring SES event tracking to SNS... + ✓ SES Event Tracking configured + Events: send, reject, bounce, complaint, delivery, open, click, renderingFailure + Destination: SNS → SQS + +============================================== +✓ Setup Complete! +============================================== + +Copy these values to your application configuration form: + +╔════════════════════════════════════════════════════════════════════╗ +║ AWS Configuration Form ║ +╚════════════════════════════════════════════════════════════════════╝ + +AWS Region: + eu-north-1 + +───────────────────────────────────────────────────────────────────── +Email Sender Settings +───────────────────────────────────────────────────────────────────── + +From Email: + [e.g., hello@myapp.com] + +From Name: + [e.g., Myapp] + +NOTE: Configure these in your application's config.exs and runtime.exs + +───────────────────────────────────────────────────────────────────── +AWS SES & SQS Settings +───────────────────────────────────────────────────────────────────── + +SES Configuration Set: + myapp-emailing + +SQS Polling Interval: + 5000 (ms) + +SNS Topic ARN: + arn:aws:sns:eu-north-1:123456789012:myapp-email-events + +SQS Queue URL: + https://sqs.eu-north-1.amazonaws.com/123456789012/myapp-email-queue + +SQS Queue ARN: + arn:aws:sqs:eu-north-1:123456789012:myapp-email-queue + +SQS Dead Letter Queue URL: + https://sqs.eu-north-1.amazonaws.com/123456789012/myapp-email-dlq + +SQS Dead Letter Queue ARN: + arn:aws:sqs:eu-north-1:123456789012:myapp-email-dlq +``` + +## Multi-Site Configuration + +### Using the Script for Multiple Sites in One AWS Account + +**Good news**: The script is designed for multi-site usage! You can run it multiple times with different `PROJECT_NAME` values. + +#### Why it works perfectly: + +1. **Resource Isolation**: Each PROJECT_NAME creates unique resource names: + - `site1-email-queue`, `site1-email-dlq`, `site1-email-events` + - `site2-email-queue`, `site2-email-dlq`, `site2-email-events` + +2. **Independent Processing**: Each site processes only its own messages + +3. **Different SES Configuration Sets**: Each site can have its own configuration set + +4. **AWS Limits**: SQS and SNS have high limits (tens of thousands of resources) + +#### Best Practices for Multi-Site Setup: + +1. **Naming**: Use meaningful PROJECT_NAME values (e.g., `eznews`, `blogsite`, `shop`) +2. **Monitoring**: Add tags to resources for grouping (optional enhancement) +3. **Billing**: Use tags to track costs per project (optional enhancement) + +#### Example for Multiple Sites: + +```bash +# Site 1 +PROJECT_NAME="eznews" ./scripts/setup_aws_email_infrastructure.sh + +# Site 2 +PROJECT_NAME="blogsite" ./scripts/setup_aws_email_infrastructure.sh + +# Site 3 +PROJECT_NAME="shop" ./scripts/setup_aws_email_infrastructure.sh +``` + +**No script modifications needed** - it's ready for multi-site usage! + +## Using in Different AWS Accounts + +To create infrastructure in a different AWS account: + +1. Configure AWS CLI for the new account: + ```bash + aws configure --profile new-account + ``` + +2. Modify parameters in the script (PROJECT_NAME, region, etc.) + +3. Run script with the appropriate profile: + ```bash + AWS_PROFILE=new-account ./scripts/setup_aws_email_infrastructure.sh + ``` + +## SES Identity Verification + +**IMPORTANT**: Before you can send emails with AWS SES, you must verify the email addresses or domains you want to send from. + +### Understanding SES Sandbox vs Production + +When you first create an AWS account, your SES account is in **Sandbox mode** with these restrictions: + +#### Sandbox Mode Restrictions: +- ❌ Can only send TO verified email addresses +- ❌ Can only send FROM verified email addresses +- ❌ Limited to 200 emails per day +- ❌ Limited to 1 email per second + +#### Production Mode Benefits: +- ✅ Can send to ANY email address +- ✅ Higher sending quota (default: 50,000 emails/day) +- ✅ Higher sending rate (default: 14 emails/second) +- ✅ Can request quota increases + +### Checking Your SES Account Status + +```bash +# Check if you're in sandbox or production mode +aws sesv2 get-account --region YOUR_REGION + +# Look for "ProductionAccessEnabled": true or false +``` + +### Verifying Email Addresses + +To verify a single email address: + +```bash +# Send verification email +aws ses verify-email-identity \ + --email-address hello@yourdomain.com \ + --region YOUR_REGION + +# Check verification status +aws ses get-identity-verification-attributes \ + --identities hello@yourdomain.com \ + --region YOUR_REGION + +# List all verified identities +aws ses list-identities --region YOUR_REGION +``` + +**Steps:** +1. Run the `verify-email-identity` command +2. Check the inbox of that email address +3. Click the verification link in the email from AWS +4. Verification completes instantly after clicking the link + +### Verifying Domains (Recommended for Production) + +Domain verification allows you to send from ANY email address at that domain (e.g., hello@, noreply@, support@). + +```bash +# Start domain verification +aws ses verify-domain-identity \ + --domain yourdomain.com \ + --region YOUR_REGION + +# This returns DNS records you need to add +``` + +**Steps:** +1. Run the command above - it returns TXT records +2. Add the TXT record to your domain's DNS settings +3. Wait for DNS propagation (can take up to 72 hours, usually 30 minutes) +4. AWS automatically detects the DNS record and verifies the domain + +**Example DNS Record:** +``` +Type: TXT +Name: _amazonses.yourdomain.com +Value: [Long verification code from AWS] +TTL: 1800 +``` + +### Checking Verification Status + +```bash +# Check specific email/domain +aws ses get-identity-verification-attributes \ + --identities yourdomain.com hello@yourdomain.com \ + --region YOUR_REGION + +# Check all identities +aws ses list-identities --region YOUR_REGION + +# Check with detailed info +aws sesv2 list-email-identities --region YOUR_REGION +``` + +### Requesting Production Access + +If you're still in Sandbox mode, request production access: + +1. **Via AWS Console:** + - Go to SES console → Account Dashboard + - Click "Request production access" + - Fill out the form explaining your use case + - Usually approved within 24 hours + +2. **Via AWS CLI:** + ```bash + # Check current status + aws sesv2 get-account --region YOUR_REGION | jq '.ProductionAccessEnabled' + ``` + +### Common Verification Issues + +#### Issue: "Email address is not verified" error +**Solution**: Verify the FROM email address: +```bash +aws ses verify-email-identity --email-address your-from-email@domain.com --region YOUR_REGION +``` + +#### Issue: Can only send to certain addresses +**Solution**: You're in Sandbox mode. Either: +- Verify recipient email addresses (for testing) +- Request production access (for real usage) + +#### Issue: Domain verification not working +**Solution**: +```bash +# Check DNS propagation +dig TXT _amazonses.yourdomain.com + +# Verify DNS record matches AWS value exactly +aws ses verify-domain-identity --domain yourdomain.com --region YOUR_REGION +``` + +### Best Practices + +1. **For Development/Testing**: Verify individual email addresses +2. **For Production**: Verify the entire domain +3. **Multiple Domains**: You can verify multiple domains in the same account +4. **Sender Reputation**: Use a consistent FROM address (e.g., hello@yourdomain.com) +5. **DKIM**: Enable DKIM signing for better deliverability + +### Setting up DKIM (Optional but Recommended) + +DKIM improves email deliverability and helps prevent emails from going to spam: + +```bash +# Enable DKIM for a domain +aws sesv2 create-email-identity \ + --email-identity yourdomain.com \ + --dkim-signing-attributes SigningEnabled=true \ + --region YOUR_REGION + +# Get DKIM records to add to DNS +aws sesv2 get-email-identity \ + --email-identity yourdomain.com \ + --region YOUR_REGION | jq '.DkimAttributes' +``` + +This returns 3 CNAME records to add to your DNS. + +## Verifying Created Infrastructure + +### Check message count in queue: +```bash +aws sqs get-queue-attributes \ + --queue-url "https://sqs.eu-north-1.amazonaws.com/123456789012/myapp-email-queue" \ + --attribute-names ApproximateNumberOfMessages +``` + +### Check DLQ: +```bash +aws sqs get-queue-attributes \ + --queue-url "https://sqs.eu-north-1.amazonaws.com/123456789012/myapp-email-dlq" \ + --attribute-names ApproximateNumberOfMessages +``` + +### Test SNS publish: +```bash +aws sns publish \ + --topic-arn "arn:aws:sns:eu-north-1:123456789012:myapp-email-events" \ + --message "Test message" +``` + +### Receive messages from queue: +```bash +aws sqs receive-message \ + --queue-url "https://sqs.eu-north-1.amazonaws.com/123456789012/myapp-email-queue" \ + --max-number-of-messages 10 \ + --wait-time-seconds 20 +``` + +## Features + +### Idempotency +The script can be run multiple times - it won't create duplicates but will use existing resources. + +### Security & Permissions + +#### SQS Queue Policy +The script configures a comprehensive SQS policy with two statements: + +1. **SNS Access** - Allows SNS to publish messages: + - Principal: `sns.amazonaws.com` + - Action: `SQS:SendMessage` + - Condition: Only from the configured SNS topic + +2. **Account Access** - Allows IAM users/roles in the account to manage messages: + - Principal: `arn:aws:iam::{ACCOUNT_ID}:root` + - Actions: + - `SQS:ReceiveMessage` - Read messages from queue + - `SQS:DeleteMessage` - Delete processed messages + - `SQS:GetQueueAttributes` - Check queue status + - `SQS:SendMessage` - Send test messages + +**Why both statements are needed:** +- The first allows SES → SNS → SQS event flow +- The second allows your application to receive and delete messages after processing +- Without the second statement, you'll see `[warning] Failed to delete SQS message` errors + +#### Additional Security Features +- SQS encryption enabled (SQS-managed SSE) +- SNS can only write to the specified SQS queue (restricted by ARN condition) +- All policies follow principle of least privilege + +### Long Polling +The main queue uses long polling (20 seconds) for efficient message retrieval and cost reduction. + +### Dead Letter Queue +After 3 failed processing attempts, messages are automatically moved to DLQ for further analysis. + +## PhoenixKit Integration + +This script is designed to work seamlessly with PhoenixKit, an Elixir/Phoenix framework extension. Here's how to integrate the AWS infrastructure with your Phoenix application. + +### Step 1: Configure Environment Variables + +Create or update your `.env` file with AWS credentials: + +```bash +# .env file +AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +AWS_REGION=eu-north-1 + +# Optional: Email sender settings +FROM_EMAIL=hello@yourdomain.com +FROM_NAME=YourApp +``` + +**Security Note**: Never commit `.env` files to version control. Add `.env` to your `.gitignore`. + +### Step 2: Configure config.exs + +Update your `config/config.exs` with PhoenixKit settings: + +```elixir +# config/config.exs +import Config + +# Configure Swoosh API client +config :swoosh, api_client: Swoosh.ApiClient.Finch + +# Configure PhoenixKit +config :phoenix_kit, + repo: YourApp.Repo, + mailer: YourApp.Mailer, + layouts_module: YourAppWeb.Layouts, + phoenix_version_strategy: :modern, + from_email: "hello@yourdomain.com", + from_name: "YourApp" + +# Configure your application +config :your_app, + ecto_repos: [YourApp.Repo] + +# Configure the mailer for development (uses local adapter) +config :your_app, YourApp.Mailer, + adapter: Swoosh.Adapters.Local +``` + +### Step 3: Configure runtime.exs + +Update your `config/runtime.exs` to use AWS SES in production: + +```elixir +# config/runtime.exs +import Config +import Dotenvy + +# Load environment variables +source!([".env", ".env.#{config_env()}", System.get_env()]) + +# Helper function to get environment variables +env! = fn key, type, default \\ nil -> + case env!(key, type) do + nil -> default + value -> value + end +end + +if config_env() == :dev do + # Development: optionally use AWS SES if credentials provided + if env!("AWS_ACCESS_KEY_ID", :string) && + env!("AWS_SECRET_ACCESS_KEY", :string) do + config :your_app, YourApp.Mailer, + adapter: Swoosh.Adapters.AmazonSES, + access_key: env!("AWS_ACCESS_KEY_ID", :string!), + secret: env!("AWS_SECRET_ACCESS_KEY", :string!), + region: env!("AWS_REGION", :string, "eu-north-1") + end + + # Configure PhoenixKit email settings + config :phoenix_kit, + from_email: env!("FROM_EMAIL", :string, "noreply@localhost"), + from_name: env!("FROM_NAME", :string, "YourApp Dev") +end + +if config_env() == :prod do + # Production: use AWS SES + config :your_app, YourApp.Mailer, + adapter: Swoosh.Adapters.AmazonSES, + access_key: env!("AWS_ACCESS_KEY_ID", :string!), + secret: env!("AWS_SECRET_ACCESS_KEY", :string!), + region: env!("AWS_REGION", :string, "eu-north-1") + + # Configure PhoenixKit email settings + config :phoenix_kit, + from_email: env!("FROM_EMAIL", :string, "hello@yourdomain.com"), + from_name: env!("FROM_NAME", :string, "YourApp") +end +``` + +### Step 4: PhoenixKit Configuration in Admin Panel + +After running the setup script, you'll receive configuration values. Enter them in your PhoenixKit admin panel: + +1. Navigate to `/admin/settings/email` (or your PhoenixKit configuration page) +2. Fill in the form with values from the script output: + +``` +AWS Region: eu-north-1 +SES Configuration Set: myapp-emailing +SQS Polling Interval: 5000 +SNS Topic ARN: arn:aws:sns:... +SQS Queue URL: https://sqs.eu-north-1... +SQS Queue ARN: arn:aws:sqs:... +SQS Dead Letter Queue URL: https://sqs.eu-north-1... +``` + +### Step 5: Email Tracking with PhoenixKit + +PhoenixKit automatically tracks email events from SQS. The email tracker runs as a background process. + +#### Mix Tasks for Email Management + +```bash +# Sync email statuses from SQS (manual trigger) +mix phoenix_kit.sync_email_status + +# Process Dead Letter Queue messages +mix phoenix_kit.email.process_dlq + +# Peek at DLQ messages without processing +mix phoenix_kit.email.process_dlq --peek + +# Debug SQS queue status +mix phoenix_kit.email.debug_sqs +``` + +#### How Email Tracking Works + +1. Your app sends an email via `YourApp.Mailer.deliver(email)` +2. Email is sent through AWS SES with configuration set `myapp-emailing` +3. SES generates events (send, delivery, bounce, open, click, etc.) +4. Events flow: SES → SNS → SQS +5. PhoenixKit polls SQS queue every 5 seconds (configurable) +6. Email statuses are updated in your database automatically + +#### Email Event Types Tracked + +- `send` - Email accepted by SES +- `delivery` - Email delivered to recipient +- `bounce` - Email bounced (permanent or temporary) +- `complaint` - Recipient marked as spam +- `open` - Recipient opened the email +- `click` - Recipient clicked a link +- `reject` - SES rejected the email +- `renderingFailure` - Template rendering failed + +### Step 6: Testing the Integration + +#### Send a test email: + +```elixir +# In IEx console +iex> alias YourApp.Mailer +iex> import Swoosh.Email + +iex> email = new() +...> |> to("recipient@example.com") +...> |> from({"YourApp", "hello@yourdomain.com"}) +...> |> subject("Test Email") +...> |> text_body("This is a test email from AWS SES") + +iex> Mailer.deliver(email) +{:ok, %{id: "..."}} +``` + +#### Check SQS queue for events: + +```bash +# Check message count +aws sqs get-queue-attributes \ + --queue-url "YOUR_QUEUE_URL" \ + --attribute-names ApproximateNumberOfMessages \ + --region eu-north-1 + +# Manually receive messages to see events +aws sqs receive-message \ + --queue-url "YOUR_QUEUE_URL" \ + --max-number-of-messages 1 \ + --region eu-north-1 +``` + +#### Monitor email tracker logs: + +```bash +# If using Supervisor (production) +tail -f /var/log/supervisor/elixir.log | grep "email" + +# Development logs +tail -f log/dev.log | grep "email" +``` + +### Step 7: Handling Failed Messages (DLQ) + +Messages end up in the Dead Letter Queue after 3 failed processing attempts. This usually indicates: + +- Malformed SES event JSON +- Database errors during status update +- Application errors in email tracking code + +#### Investigate DLQ messages: + +```bash +# View DLQ messages +mix phoenix_kit.email.process_dlq --peek + +# Process and retry DLQ messages +mix phoenix_kit.email.process_dlq + +# Manually inspect via AWS CLI +aws sqs receive-message \ + --queue-url "YOUR_DLQ_URL" \ + --max-number-of-messages 10 \ + --region eu-north-1 +``` + +#### Clear DLQ after investigation: + +```bash +# Purge all messages from DLQ +aws sqs purge-queue \ + --queue-url "YOUR_DLQ_URL" \ + --region eu-north-1 +``` + +### Common Integration Issues + +#### Issue: Emails send but events not appearing in database +**Causes:** +- SQS polling not running +- Configuration Set not attached to emails +- Wrong SQS queue URL in config + +**Solutions:** +```bash +# Check if PhoenixKit email tracker is running +ps aux | grep "phoenix_kit" + +# Manually trigger sync +mix phoenix_kit.sync_email_status + +# Verify SES configuration set +aws ses describe-configuration-set \ + --configuration-set-name myapp-emailing \ + --region eu-north-1 +``` + +#### Issue: All messages going to DLQ +**Causes:** +- Database migration not run +- Email tracking schema mismatch +- Application code errors + +**Solutions:** +```bash +# Check application logs for errors +tail -f log/prod.log + +# Run migrations +mix ecto.migrate + +# Check DLQ message format +mix phoenix_kit.email.process_dlq --peek +``` + +#### Issue: Swoosh not using configuration set +**Solution:** PhoenixKit automatically adds the configuration set. Verify it's configured: + +```elixir +# In your mailer or email tracking configuration +config :phoenix_kit, + ses_configuration_set: "myapp-emailing" +``` + +### Production Deployment Checklist + +- [ ] `.env` file configured with AWS credentials +- [ ] `config.exs` updated with from_email and from_name +- [ ] `runtime.exs` configured for production with AWS SES +- [ ] PhoenixKit admin panel configured with SQS/SNS ARNs +- [ ] SES domain/email verified (check "SES Identity Verification" section) +- [ ] SES account in production mode (not sandbox) +- [ ] Test email sent and delivered successfully +- [ ] SQS queue receiving events (check queue message count) +- [ ] Email statuses updating in database +- [ ] DLQ monitored and empty (or low message count) +- [ ] Application logs show no SQS/SES errors + +## Troubleshooting + +### Common Issues and Solutions + +#### Issue: "jq: command not found" +**Solution**: Install jq +```bash +# Ubuntu/Debian +apt-get install jq + +# macOS +brew install jq +``` + +#### Issue: "AWS CLI not configured" +**Solution**: Configure AWS credentials +```bash +aws configure +``` + +#### Issue: "Access Denied" errors +**Solution**: Ensure your IAM user/role has permissions for: +- SNS: CreateTopic, Subscribe, SetTopicAttributes +- SQS: CreateQueue, SetQueueAttributes, GetQueueUrl +- STS: GetCallerIdentity + +#### Issue: Script creates resources but they don't appear in AWS Console +**Solution**: Check you're viewing the correct region in AWS Console (match script's AWS_REGION) + +#### Issue: `[warning] Failed to delete SQS message` in application logs +**Symptoms:** +- Application logs show warnings about failed message deletions +- Messages appear in "ApproximateNumberOfMessagesNotVisible" and return to queue after visibility timeout +- Same messages are processed multiple times + +**Cause**: +SQS Queue Policy is missing permissions for account to delete messages. The policy only allows SNS to send messages, but not your IAM users/roles to manage them. + +**Solution**: +Update the SQS Queue Policy to include account access. The script automatically does this, but if you created queues manually, add this statement: + +```json +{ + "Sid": "AllowAccountAccess", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::{YOUR_ACCOUNT_ID}:root" + }, + "Action": [ + "SQS:ReceiveMessage", + "SQS:DeleteMessage", + "SQS:GetQueueAttributes", + "SQS:SendMessage" + ], + "Resource": "arn:aws:sqs:{REGION}:{ACCOUNT_ID}:{QUEUE_NAME}" +} +``` + +**To verify the issue:** +```bash +# Check for stuck messages +aws sqs get-queue-attributes \ + --queue-url "YOUR_QUEUE_URL" \ + --attribute-names ApproximateNumberOfMessages,ApproximateNumberOfMessagesNotVisible \ + --region YOUR_REGION + +# If "ApproximateNumberOfMessagesNotVisible" is > 0 and keeps growing, you have this issue +``` + +**To fix existing queue:** +Re-run the setup script (it's idempotent) or manually update the policy using AWS Console or CLI. + +#### Issue: SES events not appearing in SQS +**Cause**: SES Configuration Set not configured or not attached to emails + +**Solution**: +1. Verify Configuration Set exists: `aws ses list-configuration-sets --region YOUR_REGION` +2. Check event destinations are configured (the script does this automatically) +3. Ensure your application uses the Configuration Set when sending emails +4. For Swoosh adapter, this is automatic if configured correctly + +## Monitoring and Maintenance + +Regular monitoring ensures your email infrastructure is healthy and helps catch issues early. + +### Daily/Weekly Monitoring + +#### 1. Check Queue Message Counts + +Monitor your SQS queues for message buildup: + +```bash +# Check main queue +aws sqs get-queue-attributes \ + --queue-url "YOUR_MAIN_QUEUE_URL" \ + --attribute-names ApproximateNumberOfMessages,ApproximateNumberOfMessagesNotVisible \ + --region YOUR_REGION | jq '.Attributes' + +# Check DLQ (should be 0 or very low) +aws sqs get-queue-attributes \ + --queue-url "YOUR_DLQ_URL" \ + --attribute-names ApproximateNumberOfMessages \ + --region YOUR_REGION | jq '.Attributes' +``` + +**What to look for:** +- Main queue: Should process messages quickly (< 100 messages normally) +- DLQ: Should be 0 or very low (< 10 messages) +- Messages not visible: Indicates messages being processed + +**Red flags:** +- ⚠️ Main queue growing steadily → Processing not keeping up +- 🚨 DLQ has messages → Application errors need investigation +- ⚠️ Messages not visible stays high → Visibility timeout too short or processing too slow + +#### 2. Check SES Sending Statistics + +```bash +# Get recent sending stats +aws ses get-send-statistics --region YOUR_REGION | jq '.SendDataPoints[-5:]' + +# Get account status and quota +aws sesv2 get-account --region YOUR_REGION | jq '{ + SendQuota: .SendQuota, + ProductionAccess: .ProductionAccessEnabled, + EnforcementStatus: .EnforcementStatus +}' +``` + +**What to monitor:** +- Bounces: Should be < 5% of sends +- Complaints: Should be < 0.1% of sends +- Rejects: Should be 0 or very low +- Quota usage: Should stay below 80% of daily limit + +**Red flags:** +- 🚨 Bounce rate > 5% → Email list quality issue +- 🚨 Complaint rate > 0.1% → Spam complaints (can disable account) +- ⚠️ Near quota limit → May need quota increase + +#### 3. Monitor Application Logs + +```bash +# Production (Supervisor) +tail -f /var/log/supervisor/elixir.log | grep -i "email\|sqs\|ses" + +# Development +tail -f log/dev.log | grep -i "email\|sqs\|ses" + +# Look for errors +grep -i "error\|warning\|failed" /var/log/supervisor/elixir.log | grep -i "email\|sqs" +``` + +**What to look for:** +- Successful email sends +- SQS message processing +- Email status updates + +**Red flags:** +- 🚨 "Failed to delete SQS message" warnings +- 🚨 "MessageRejected" errors from SES +- 🚨 Database errors during email tracking + +### Handling Dead Letter Queue Messages + +When messages appear in DLQ, investigate immediately: + +#### Step 1: View DLQ Messages + +```bash +# Using PhoenixKit +mix phoenix_kit.email.process_dlq --peek + +# Or manually with AWS CLI +aws sqs receive-message \ + --queue-url "YOUR_DLQ_URL" \ + --max-number-of-messages 10 \ + --region YOUR_REGION +``` + +#### Step 2: Identify the Problem + +Common causes: +- **Malformed JSON**: SES sent unexpected event format +- **Database errors**: Migration missing or schema mismatch +- **Application errors**: Bug in email tracking code +- **Network issues**: Temporary connectivity problems + +#### Step 3: Fix and Retry + +```bash +# After fixing the underlying issue, retry DLQ messages +mix phoenix_kit.email.process_dlq + +# If messages are permanently bad, purge them +aws sqs purge-queue --queue-url "YOUR_DLQ_URL" --region YOUR_REGION +``` + +### Maintenance Tasks + +#### Monthly: Review SES Reputation Metrics + +```bash +# Check reputation dashboard +aws sesv2 get-account --region YOUR_REGION | jq '.EnforcementStatus' + +# List suppression list (bounced/complained emails) +aws sesv2 list-suppressed-destinations --region YOUR_REGION +``` + +**Actions:** +- Review bounced emails and remove from mailing list +- Investigate spam complaints +- Clean up suppression list if needed + +#### Monthly: Audit Queue Policies + +```bash +# Review main queue policy +aws sqs get-queue-attributes \ + --queue-url "YOUR_MAIN_QUEUE_URL" \ + --attribute-names Policy \ + --region YOUR_REGION | jq -r '.Attributes.Policy' | jq + +# Verify both SNS and account access statements exist +``` + +#### Quarterly: Review AWS Costs + +```bash +# SES costs +aws ce get-cost-and-usage \ + --time-period Start=2025-01-01,End=2025-03-31 \ + --granularity MONTHLY \ + --metrics BlendedCost \ + --filter file://<(echo '{ + "Dimensions": { + "Key": "SERVICE", + "Values": ["Amazon Simple Email Service"] + } + }') \ + --region us-east-1 + +# Similar for SQS and SNS +``` + +**Expected costs:** +- SES: $0-10/month for small volumes (< 10,000 emails) +- SQS: $0 (free tier covers most usage) +- SNS: $0 (free tier covers most usage) + +### Setting Up CloudWatch Alarms + +Create alarms for proactive monitoring: + +#### Alarm 1: DLQ Messages > 10 + +```bash +aws cloudwatch put-metric-alarm \ + --alarm-name "email-dlq-messages-high" \ + --alarm-description "Alert when DLQ has > 10 messages" \ + --metric-name ApproximateNumberOfMessagesVisible \ + --namespace AWS/SQS \ + --statistic Average \ + --period 300 \ + --threshold 10 \ + --comparison-operator GreaterThanThreshold \ + --datapoints-to-alarm 1 \ + --evaluation-periods 1 \ + --dimensions Name=QueueName,Value=YOUR_DLQ_NAME \ + --alarm-actions "arn:aws:sns:YOUR_REGION:YOUR_ACCOUNT:YOUR_ALERT_TOPIC" \ + --region YOUR_REGION +``` + +#### Alarm 2: High Bounce Rate + +```bash +aws cloudwatch put-metric-alarm \ + --alarm-name "ses-high-bounce-rate" \ + --alarm-description "Alert when bounce rate > 5%" \ + --metric-name Reputation.BounceRate \ + --namespace AWS/SES \ + --statistic Average \ + --period 3600 \ + --threshold 0.05 \ + --comparison-operator GreaterThanThreshold \ + --datapoints-to-alarm 1 \ + --evaluation-periods 1 \ + --alarm-actions "arn:aws:sns:YOUR_REGION:YOUR_ACCOUNT:YOUR_ALERT_TOPIC" \ + --region YOUR_REGION +``` + +### Backup and Disaster Recovery + +#### Backing Up Configuration + +```bash +# Export all infrastructure details +cat > aws-email-backup-$(date +%Y%m%d).json < 80" | bc) -eq 1 ] && echo "⚠️ WARNING: Near quota limit!" + +echo "" +echo "✅ Health check complete" +``` + +Run it regularly: +```bash +chmod +x health-check-email-infra.sh +./health-check-email-infra.sh + +# Or add to cron for daily checks +0 9 * * * /path/to/health-check-email-infra.sh >> /var/log/email-health.log 2>&1 +``` + +## Deleting Infrastructure + +If you need to delete the created infrastructure: + +```bash +# Delete SNS subscription +aws sns list-subscriptions-by-topic \ + --topic-arn "arn:aws:sns:eu-north-1:123456789012:myapp-email-events" +# Copy the SubscriptionArn and execute: +aws sns unsubscribe --subscription-arn "SUBSCRIPTION_ARN" + +# Delete SNS topic +aws sns delete-topic \ + --topic-arn "arn:aws:sns:eu-north-1:123456789012:myapp-email-events" + +# Delete queues +aws sqs delete-queue \ + --queue-url "https://sqs.eu-north-1.amazonaws.com/123456789012/myapp-email-queue" + +aws sqs delete-queue \ + --queue-url "https://sqs.eu-north-1.amazonaws.com/123456789012/myapp-email-dlq" +``` + +## Requirements + +- AWS CLI version 2.x +- `jq` for JSON processing +- Permissions to create SNS and SQS resources in AWS account + +## Script Improvements from Testing + +The script has been tested in production and includes these improvements: + +1. **Dependency Checks**: Validates AWS CLI and jq are installed before execution +2. **Improved JSON Handling**: Proper escaping for policy documents +3. **Error Handling**: Graceful handling of existing resources (idempotency) +4. **Compact JSON**: Uses `jq -c` for policy compression +5. **Detailed Output**: Step-by-step progress with clear success indicators + +## Support + +If you encounter errors: + +1. Check AWS credentials: `aws sts get-caller-identity` +2. Verify IAM user permissions +3. Ensure the region is available +4. Check AWS account limits for SQS/SNS resources + +## Files + +- `scripts/setup_aws_email_infrastructure.sh` - main installation script +- `aws-email-config-{PROJECT_NAME}.txt` - saved configuration (created automatically) +- `scripts/AWS_SETUP_README.md` - this documentation file + +## Production-Tested Configuration + +The script has been successfully tested with the following configuration: + +- **Project**: beamlab +- **Region**: eu-north-1 +- **Account**: 123456789012 (example) +- **Resources Created**: + - SNS Topic: `beamlab-email-events` + - Main Queue: `beamlab-email-queue` + - DLQ: `beamlab-email-dlq` +- **Test Results**: ✅ SNS → SQS message flow verified and working +- **Status**: Ready for production use + +## License + +This script is provided as-is for use with AWS services. Ensure you understand AWS pricing for SNS and SQS services before using in production. diff --git a/scripts/PHOENIXKIT_AWS_COMPATIBILITY.md b/scripts/PHOENIXKIT_AWS_COMPATIBILITY.md new file mode 100644 index 000000000..edb747064 --- /dev/null +++ b/scripts/PHOENIXKIT_AWS_COMPATIBILITY.md @@ -0,0 +1,1353 @@ +# PhoenixKit AWS Infrastructure Setup - Compatibility Issues & Solutions + +**Date:** 2025-10-23 +**PhoenixKit Version:** 1.4.4 +**Status:** ⚠️ CRITICAL - Email sending broken in containerized environments + +--- + +## 📩 For PhoenixKit Core Developers + +**Dear PhoenixKit Team,** + +We've identified and resolved **3 critical issues** in the AWS Infrastructure Setup module that prevent email functionality in production environments. This document provides complete analysis, working solutions, and code ready for integration into PhoenixKit core. + +### TL;DR (Quick Summary) + +| Issue | Severity | Impact | Status | +|-------|----------|--------|--------| +| **#1: sweet_xml parsing** | Medium | Setup fails at step 1 if sweet_xml installed | ✅ Fixed | +| **#2: SQS attribute format** | Medium | ArgumentError when creating queues | ✅ Fixed | +| **#3: AWS CLI dependency** | **CRITICAL** | Email sending completely broken | ✅ Fixed | + +**Critical Impact:** Issue #3 causes **silent failure** - setup appears successful but emails cannot be sent. This affects **all containerized deployments** (Docker, Kubernetes) where AWS CLI is not installed. + +### What Needs to Change in PhoenixKit Core + +1. **`lib/phoenix_kit/aws/infrastructure_setup.ex`** + - Lines 117-121, 195-201: Change SQS attributes from string-keyed tuples to atom-keyed keyword lists + - Lines 159, 249: Change policy attributes from `{"Policy", policy}` to `[policy: policy]` + - Lines 277-311: Replace `System.cmd("aws", ...)` with ExAws API calls (new module provided) + - Lines 313-361: Replace CLI-based SES event configuration with API calls + +2. **New Module Needed: `lib/phoenix_kit/aws/sesv2.ex`** + - Complete implementation provided in this document + - Handles SES v2 API operations not yet in ExAws + - No external dependencies (pure ExAws) + +3. **Optional: `lib/phoenix_kit/aws/infrastructure_cleanup.ex`** + - Bonus reusable cleanup script for testing + - Safe resource deletion with dry-run mode + - Ready for inclusion in PhoenixKit + +### Files Provided + +All code is production-tested and ready to integrate: +- ✅ Fixed infrastructure setup module (see Issue #2 & #3 sections) +- ✅ New SES v2 API module (see Issue #3 → Solution section) +- ✅ Reusable cleanup script (bonus - not required) +- ✅ Comprehensive test results +- ✅ Migration guide for existing deployments + +### Backward Compatibility + +All fixes maintain backward compatibility: +- ✅ Works with AND without sweet_xml installed +- ✅ Works in Docker, bare metal, and local development +- ✅ Handles existing resources gracefully (idempotent) +- ✅ No breaking changes to public API + +### Testing Performed + +- ✅ Fresh setup from scratch (all 9 steps) +- ✅ Cleanup and re-setup (verified idempotency) +- ✅ Email sending with SES event tracking +- ✅ Event tracking (all 8 event types) +- ✅ Docker environment (no AWS CLI) +- ✅ Production environment verification + +### Estimated Integration Effort + +- **Code changes**: ~200 lines (replacements + new module) +- **Testing**: 2-3 hours (AWS account required) +- **Risk level**: Low (backward compatible, isolated changes) +- **Priority**: **HIGH** - Blocks email functionality in production + +### Contact & Questions + +If you need clarification or have questions about the implementation, please reference: +- This complete documentation with code samples +- Test results and verification logs included +- Working example in phoenixkit_eu project + +Thank you for maintaining PhoenixKit! These fixes enable reliable email infrastructure in all deployment environments. + +--- + +## Executive Summary + +PhoenixKit 1.4.4's `AWS.InfrastructureSetup` module expects **raw XML responses** from AWS APIs, but when `sweet_xml` library is installed, ExAws **automatically parses XML into Elixir maps**, causing a type mismatch. + +**Result:** The setup fails at Step 1 (Getting AWS Account ID) with: +``` +** (BadMapError) expected a map, got: nil + (elixir 1.19.1) lib/map.ex:541: Map.get(nil, "GetCallerIdentityResult", nil) +``` + +--- + +## Root Cause Analysis + +### The Problem + +1. **PhoenixKit's Expectation** (line 179-185 in `deps/phoenix_kit/lib/phoenix_kit/aws/infrastructure_setup.ex`): +```elixir +case STS.get_caller_identity() |> ExAws.request(aws_config(config)) do + {:ok, %{body: body}} -> + account_id = + body + |> Map.get("GetCallerIdentityResponse") # ← Expects nested XML structure + |> Map.get("GetCallerIdentityResult") # ← With string keys + |> Map.get("Account") # ← And specific nesting +``` + +2. **What AWS Actually Returns** (raw XML): +```xml + + + 459426957596 + AIDAWV573BEOCCSUCI5ZB + arn:aws:iam::459426957596:user/phoenix_kit_eznews_eu + + +``` + +3. **What sweet_xml Converts It To**: +```elixir +%{ + account: "459426957596", # ← Flat structure + user_id: "AIDAWV573...", # ← Atom keys (not strings) + arn: "arn:aws:iam::...", # ← No nested "GetCallerIdentityResponse" + request_id: "..." +} +``` + +### Why It Happens + +**ExAws Behavior:** +- **WITHOUT sweet_xml**: Returns raw XML string in `body` +- **WITH sweet_xml**: Automatically parses XML and flattens structure with atom keys + +**PhoenixKit Dependencies:** +```elixir +# From phoenix_kit/mix.exs +{:ex_aws, "~> 2.4"}, +{:ex_aws_sts, "~> 2.3"}, +# sweet_xml is marked as OPTIONAL in ex_aws +``` + +PhoenixKit **assumes sweet_xml is NOT installed**, but our project needs it for other AWS operations (S3, SQS parsing). + +### Additional Issue: SQS Attribute Format + +**ExAws.SQS Expectation:** +ExAws.SQS expects queue attributes as **atom-keyed keyword lists**, not string-keyed tuples. + +**Correct Format:** +```elixir +# ✅ Correct (atom keys) +SQS.create_queue(queue_name, [ + visibility_timeout: "60", + message_retention_period: "1209600", + sqs_managed_sse_enabled: "true" +]) + +# ❌ Incorrect (string keys) - causes ArgumentError +SQS.create_queue(queue_name, [ + {"VisibilityTimeout", "60"}, + {"MessageRetentionPeriod", "1209600"} +]) +``` + +**Error When Using Wrong Format:** +``` +** (ArgumentError) errors were found at the given arguments: + * 1st argument: not an atom + (erts 16.1) :erlang.atom_to_binary("VisibilityTimeout") +``` + +--- + +## Our Solution + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────┐ +│ Web UI: /phoenix_kit/admin/settings/aws │ +│ PhoenixkitEuWeb.Live.AWSSettingsLive │ +└──────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Custom Module (sweet_xml compatible) │ +│ PhoenixkitEu.AWSInfrastructureSetup │ +│ lib/phoenixkit_eu/aws_infrastructure_setup.ex │ +└──────────────────────┬──────────────────────────────┘ + │ + ▼ + ┌──────────────┴──────────────┐ + │ │ + ▼ ▼ +┌───────────────┐ ┌──────────────────┐ +│ ExAws + JSON │ │ ExAws + XML │ +│ (SQS, etc) │ │ (STS, SNS, SES) │ +└───────────────┘ └──────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────────────────┐ + │ AWS API (Real Services) │ + └──────────────────────────────────┘ +``` + +### Files Created + +#### 1. Core Module: `lib/phoenixkit_eu/aws_infrastructure_setup.ex` + +**Purpose:** Drop-in replacement for `PhoenixKit.AWS.InfrastructureSetup` + +**Key Differences from PhoenixKit:** + +| Aspect | PhoenixKit (Original) | Our Module (Fixed) | +|--------|----------------------|-------------------| +| XML Parsing | Expects nested string keys | Handles flat atom keys | +| Response Format | `body["GetCallerIdentityResponse"]["GetCallerIdentityResult"]["Account"]` | `body[:account]` or `body["account"]` | +| Error Handling | Basic pattern matching | Defensive with fallbacks | +| SQS Attributes | String-keyed tuples (incorrect) | Atom-keyed keyword lists | +| SES Config | Uses SES v2 API calls | Uses AWS CLI with graceful degradation | + +**Critical Code Section:** +```elixir +# Our fix for Step 1 (Getting Account ID) +defp get_account_id(config) do + Logger.info("[AWS Setup] [1/9] Getting AWS Account ID...") + + case STS.get_caller_identity() |> ExAws.request(config) do + {:ok, %{body: body}} when is_map(body) -> + # Handle sweet_xml parsed response (atom keys) - OUR FIX + account_id = body[:account] || body["account"] + + if account_id do + Logger.info("[AWS Setup] ✓ Account ID: #{account_id}") + {:ok, account_id} + else + {:error, "get_account_id", "Could not parse account ID"} + end + + {:error, reason} -> + {:error, "get_account_id", "AWS API error: #{inspect(reason)}"} + end +end +``` + +**Complete Implementation:** All 9 steps reimplemented with sweet_xml compatibility + +#### 2. Custom LiveView: `lib/phoenixkit_eu_web/live/aws_settings_live.ex` + +**Purpose:** Web UI that calls our custom module instead of PhoenixKit's + +**Key Features:** +- Loads/saves AWS credentials from database +- Calls `PhoenixkitEu.AWSInfrastructureSetup.run/1` instead of `PhoenixKit.AWS.InfrastructureSetup.run/1` +- Handles success/error states with user-friendly messages +- Automatically saves created resources to database + +**Event Handler:** +```elixir +def handle_event("setup_aws_infrastructure", _params, socket) do + # Uses OUR module (not PhoenixKit's) + case PhoenixkitEu.AWSInfrastructureSetup.run(project_name: project_name) do + {:ok, config} -> + # Save to database and show success + Settings.update_settings_batch(config) + # ... + end +end +``` + +#### 3. Router Override: `lib/phoenixkit_eu_web/router.ex` + +**Purpose:** Route `/phoenix_kit/admin/settings/aws` to OUR LiveView + +**Implementation:** +```elixir +# Custom AWS Settings Route (overrides PhoenixKit default) +scope "/phoenix_kit" do + pipe_through :browser + + live_session :custom_aws_settings, + on_mount: [{PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_admin}] do + live "/admin/settings/aws", PhoenixkitEuWeb.Live.AWSSettingsLive, :index + end +end + +phoenix_kit_routes() # PhoenixKit's default routes (our route takes precedence) +``` + +**Why This Works:** Phoenix router matches routes in order - our route is defined BEFORE `phoenix_kit_routes()`, so it takes precedence. + +#### 4. Standalone Script: `scripts/setup_aws_infrastructure.exs` + +**Purpose:** Alternative to Web UI for testing/automation + +**Usage:** +```bash +mix run scripts/setup_aws_infrastructure.exs +``` + +--- + +## Dependencies Added + +### Required Dependency: sweet_xml + +**Added to `mix.exs`:** +```elixir +{:sweet_xml, "~> 0.7"}, # Required for ExAws XML response parsing (STS, SNS) +``` + +**Why Required:** +- ExAws marks it as optional, but it's needed for XML parsing +- Without it, AWS STS/SNS return raw XML strings +- With it, responses are automatically parsed to Elixir maps + +**Version Installed:** `sweet_xml 0.7.5` + +--- + +## Configuration Changes + +### ExAws Configuration: `config/config.exs` + +**Added:** +```elixir +# Configure ExAws to use JSON parser instead of XML +config :ex_aws, + json_codec: Jason, + access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role], + secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role] +``` + +**Purpose:** +- Set Jason as JSON codec for AWS services that support it +- Provide credential fallback order (settings → env vars → instance role) + +--- + +## Testing & Verification + +### Test the Fix + +**Option 1: Web UI** +``` +http://your-domain/phoenix_kit/admin/settings/aws +``` +Click "Setup AWS Infrastructure" button + +**Option 2: Command Line** +```bash +mix run scripts/setup_aws_infrastructure.exs +``` + +**Option 3: IEx** +```elixir +iex -S mix phx.server +PhoenixkitEu.AWSInfrastructureSetup.run(project_name: "test") +``` + +### Expected Success Output + +**With AWS CLI Available:** +```log +[info] [AWS Setup] Starting infrastructure setup for project: phoenixkit +[info] [AWS Setup] Region: eu-north-1 +[info] [AWS Setup] [1/9] Getting AWS Account ID... +[info] [AWS Setup] ✓ Account ID: 459426957596 +[info] [AWS Setup] [2/9] Creating Dead Letter Queue... +[info] [AWS Setup] ✓ DLQ Created +[info] [AWS Setup] URL: https://sqs.eu-north-1.amazonaws.com/459426957596/phoenixkit-email-dlq +[info] [AWS Setup] ARN: arn:aws:sqs:eu-north-1:459426957596:phoenixkit-email-dlq +[info] [AWS Setup] [3/9] Setting DLQ policy... +[info] [AWS Setup] ✓ DLQ Policy set +[info] [AWS Setup] [4/9] Creating SNS Topic... +[info] [AWS Setup] ✓ SNS Topic Created/Found +[info] [AWS Setup] ARN: arn:aws:sns:eu-north-1:459426957596:phoenixkit-email-events +[info] [AWS Setup] [5/9] Creating Main Queue with DLQ redrive policy... +[info] [AWS Setup] ✓ Main Queue Created +[info] [AWS Setup] URL: https://sqs.eu-north-1.amazonaws.com/459426957596/phoenixkit-email-queue +[info] [AWS Setup] ARN: arn:aws:sqs:eu-north-1:459426957596:phoenixkit-email-queue +[info] [AWS Setup] [6/9] Setting Main Queue policy to allow SNS and account access... +[info] [AWS Setup] ✓ Main Queue Policy set +[info] [AWS Setup] [7/9] Creating SNS subscription to SQS... +[info] [AWS Setup] ✓ SNS → SQS Subscription created +[info] [AWS Setup] Subscription ARN: arn:aws:sns:eu-north-1:459426957596:phoenixkit-email-events:... +[info] [AWS Setup] [8/9] Creating SES Configuration Set... +[info] [AWS Setup] ✓ SES Configuration Set created +[info] [AWS Setup] Name: phoenixkit-emailing +[info] [AWS Setup] [9/9] Configuring SES event tracking to SNS... +[info] [AWS Setup] ✓ SES Event Tracking configured +[info] [AWS Setup] Events: SEND, REJECT, BOUNCE, COMPLAINT, DELIVERY, OPEN, CLICK, RENDERING_FAILURE +[info] [AWS Setup] Destination: arn:aws:sns:eu-north-1:459426957596:phoenixkit-email-events +[info] [AWS Setup] ✅ Infrastructure setup completed successfully! +``` + +**Without AWS CLI (Still Successful):** +```log +[info] [AWS Setup] Starting infrastructure setup for project: phoenixkit +[info] [AWS Setup] Region: eu-north-1 +[info] [AWS Setup] [1/9] Getting AWS Account ID... +[info] [AWS Setup] ✓ Account ID: 459426957596 +[info] [AWS Setup] [2/9] Creating Dead Letter Queue... +[info] [AWS Setup] ✓ DLQ Created +[info] [AWS Setup] URL: https://sqs.eu-north-1.amazonaws.com/459426957596/phoenixkit-email-dlq +[info] [AWS Setup] ARN: arn:aws:sqs:eu-north-1:459426957596:phoenixkit-email-dlq +[info] [AWS Setup] [3/9] Setting DLQ policy... +[info] [AWS Setup] ✓ DLQ Policy set +[info] [AWS Setup] [4/9] Creating SNS Topic... +[info] [AWS Setup] ✓ SNS Topic Created/Found +[info] [AWS Setup] ARN: arn:aws:sns:eu-north-1:459426957596:phoenixkit-email-events +[info] [AWS Setup] [5/9] Creating Main Queue with DLQ redrive policy... +[info] [AWS Setup] ✓ Main Queue Created +[info] [AWS Setup] URL: https://sqs.eu-north-1.amazonaws.com/459426957596/phoenixkit-email-queue +[info] [AWS Setup] ARN: arn:aws:sqs:eu-north-1:459426957596:phoenixkit-email-queue +[info] [AWS Setup] [6/9] Setting Main Queue policy to allow SNS and account access... +[info] [AWS Setup] ✓ Main Queue Policy set +[info] [AWS Setup] [7/9] Creating SNS subscription to SQS... +[info] [AWS Setup] ✓ SNS → SQS Subscription created +[info] [AWS Setup] Subscription ARN: arn:aws:sns:eu-north-1:459426957596:phoenixkit-email-events:... +[info] [AWS Setup] [8/9] Creating SES Configuration Set... +[warning] [AWS Setup] ⚠️ AWS CLI not available or error: %ErlangError{original: :enoent, reason: nil} +[info] [AWS Setup] ℹ️ Using expected config set name: phoenixkit-emailing +[info] [AWS Setup] [9/9] Configuring SES event tracking to SNS... +[warning] [AWS Setup] ⚠️ AWS CLI not available or error: %ErlangError{original: :enoent, reason: nil} +[info] [AWS Setup] ℹ️ Manual setup required: +[info] [AWS Setup] Config Set: phoenixkit-emailing → Topic: arn:aws:sns:eu-north-1:459426957596:phoenixkit-email-events +[info] [AWS Setup] ✅ Infrastructure setup completed successfully! +``` + +**Note:** Steps 8-9 (SES Configuration Set) require AWS CLI to be installed. If it's not available, the setup continues successfully with manual setup instructions provided. + +### Verify in Database + +```elixir +alias PhoenixKit.Settings + +Settings.get_setting("aws_sns_topic_arn") +# => "arn:aws:sns:eu-north-1:459426957596:phoenixkit-email-events" + +Settings.get_setting("aws_sqs_queue_url") +# => "https://sqs.eu-north-1.amazonaws.com/459426957596/phoenixkit-email-queue" + +Settings.get_setting("aws_ses_configuration_set") +# => "phoenixkit-emailing" +``` + +### Manual SES Setup (If AWS CLI Not Available) + +If the automated setup couldn't create the SES configuration set (steps 8-9), you can set it up manually: + +**Step 1: Create SES Configuration Set** +```bash +# From your local machine or a machine with AWS CLI installed +aws sesv2 create-configuration-set \ + --configuration-set-name "phoenixkit-emailing" \ + --region eu-north-1 +``` + +**Step 2: Create Event Destination** +```bash +# Replace YOUR_TOPIC_ARN with the actual ARN from the setup logs +aws sesv2 create-configuration-set-event-destination \ + --configuration-set-name "phoenixkit-emailing" \ + --event-destination-name "email-events-to-sns" \ + --event-destination '{ + "Enabled": true, + "MatchingEventTypes": [ + "SEND", "REJECT", "BOUNCE", "COMPLAINT", + "DELIVERY", "OPEN", "CLICK", "RENDERING_FAILURE" + ], + "SnsDestination": { + "TopicArn": "arn:aws:sns:eu-north-1:459426957596:phoenixkit-email-events" + } + }' \ + --region eu-north-1 +``` + +**Step 3: Verify Setup** +```bash +# List configuration sets +aws sesv2 list-configuration-sets --region eu-north-1 + +# Get event destinations +aws sesv2 get-configuration-set-event-destinations \ + --configuration-set-name "phoenixkit-emailing" \ + --region eu-north-1 +``` + +**Note:** Replace `phoenixkit-emailing` with your actual project name if different, and use the SNS Topic ARN from the setup logs. + +--- + +## Future Maintenance + +### When PhoenixKit Updates + +**Check These Files:** +1. `deps/phoenix_kit/lib/phoenix_kit/aws/infrastructure_setup.ex` + - Look for changes to XML parsing logic + - Check if sweet_xml is now handled correctly + +2. Test with our module: + ```bash + mix run scripts/setup_aws_infrastructure.exs + ``` + +3. If PhoenixKit fixes the issue: + - We can remove our custom module + - Update router to use PhoenixKit's default + - Keep sweet_xml dependency (needed for other AWS operations) + +### Updating Our Module + +**When AWS APIs Change:** + +1. **STS API Changes** → Update `get_account_id/1` +2. **SQS API Changes** → Update `create_dlq/3`, `create_main_queue/4` +3. **SNS API Changes** → Update `create_sns_topic/2`, `subscribe_sqs_to_sns/3` +4. **SES API Changes** → Update `create_ses_config_set/2`, `configure_ses_events/3` + +**Test After Changes:** +```bash +# 1. Update the function +# 2. Recompile +mix compile + +# 3. Restart app +supervisorctl restart elixir + +# 4. Test via script +mix run scripts/setup_aws_infrastructure.exs + +# 5. Test via Web UI +# Navigate to /phoenix_kit/admin/settings/aws +``` + +--- + +## Reporting to PhoenixKit + +### Issue to Report + +**Title:** AWS Infrastructure Setup fails when sweet_xml is installed + +**Description:** +``` +PhoenixKit version: 1.4.4 + +When `sweet_xml` is installed in the project, +`PhoenixKit.AWS.InfrastructureSetup.run/1` fails at Step 1 +with a BadMapError. + +Root Cause: +- PhoenixKit expects raw XML nested structure with string keys +- ExAws + sweet_xml auto-parses XML to flat maps with atom keys + +Expected behavior: +PhoenixKit should handle both parsed and unparsed XML responses + +Current code (line 179-185): +```elixir +body +|> Map.get("GetCallerIdentityResponse") +|> Map.get("GetCallerIdentityResult") +|> Map.get("Account") +``` + +Suggested fix: +```elixir +account_id = case body do + %{account: id} -> id # sweet_xml parsed + %{"account" => id} -> id # alternative parsing + # fallback to nested structure + nested when is_map(nested) -> + nested + |> Map.get("GetCallerIdentityResponse", %{}) + |> Map.get("GetCallerIdentityResult", %{}) + |> Map.get("Account") +end +``` + +**Reproduction:** +1. Add `{:sweet_xml, "~> 0.7"}` to deps +2. Run `PhoenixKit.AWS.InfrastructureSetup.run(...)` +3. Observe failure at step_1_get_account_id/1 + +**Workaround:** +We've implemented a compatible module that handles both response formats. +``` + +--- + +## Extended Functions We Created + +### 1. Response Format Handler + +**Function:** `get_queue_url_from_response/1` + +```elixir +defp get_queue_url_from_response(body) when is_map(body) do + body[:queue_url] || body["QueueUrl"] || body["queue_url"] +end +``` + +**Purpose:** Handle multiple response formats from AWS SQS (atom keys, string keys, different capitalizations) + +**When to Use:** Any time you parse AWS SQS queue responses + +--- + +### 2. Existing Queue Handler + +**Function:** `handle_existing_queue/4` + +```elixir +defp handle_existing_queue(queue_name, account_id, config, queue_type) do + case SQS.get_queue_url(queue_name) |> ExAws.request(config) do + {:ok, %{body: body}} -> + queue_url = get_queue_url_from_response(body) + region = config[:region] + queue_arn = "arn:aws:sqs:#{region}:#{account_id}:#{queue_name}" + Logger.info("[AWS Setup] ✓ #{queue_type} Found (already exists)") + {:ok, queue_url, queue_arn} + + {:error, reason} -> + {:error, "get_existing_queue", "Failed to get existing #{queue_type}"} + end +end +``` + +**Purpose:** Handle the case where queues already exist (idempotent setup) + +**When to Use:** When creating SQS queues that might already exist + +--- + +### 3. Project Name Sanitizer + +**Function:** `sanitize_project_name/1` + +```elixir +defp sanitize_project_name(name) do + name + |> String.downcase() + |> String.replace(~r/[^a-z0-9-]/, "-") + |> String.trim("-") +end +``` + +**Purpose:** Convert user-provided project names to AWS-compatible resource names + +**Rules:** +- Lowercase only +- Only alphanumeric and hyphens +- No leading/trailing hyphens + +**Example:** +```elixir +sanitize_project_name("PhoenixKit EU") +# => "phoenixkit-eu" +``` + +--- + +### 4. Defensive Response Parsing + +**Pattern Used Throughout:** + +```elixir +# Handle multiple response formats +account_id = body[:account] || body["account"] +topic_arn = body[:topic_arn] || body["TopicArn"] || body["topic_arn"] +queue_url = body[:queue_url] || body["QueueUrl"] || body["queue_url"] +``` + +**Purpose:** Work with both sweet_xml parsed (atom keys) and raw parsed (string keys) responses + +--- + +## Best Practices for Similar Issues + +### 1. Check ExAws Response Format + +**Before implementing AWS SDK calls:** + +```elixir +# Test response structure in IEx +alias ExAws.STS +config = [access_key_id: "...", secret_access_key: "...", region: "..."] + +case STS.get_caller_identity() |> ExAws.request(config) do + {:ok, response} -> + IO.inspect(response, label: "Response structure") + IO.inspect(Map.keys(response.body), label: "Body keys") + # Check if keys are atoms or strings +end +``` + +### 2. Handle Both Response Formats + +**Always use defensive parsing:** + +```elixir +defp safe_get(map, key) when is_atom(key) do + map[key] || map[Atom.to_string(key)] || map[to_pascal_case(key)] +end + +defp to_pascal_case(atom) do + atom + |> Atom.to_string() + |> String.split("_") + |> Enum.map(&String.capitalize/1) + |> Enum.join("") +end + +# Usage +account_id = safe_get(body, :account) +``` + +### 3. Log Response Structures + +**For debugging:** + +```elixir +Logger.debug("AWS Response: #{inspect(body, pretty: true)}") +``` + +### 4. Idempotent Operations + +**Always handle "already exists" errors:** + +```elixir +case create_resource(...) do + {:ok, result} -> + {:ok, result} + + {:error, {:http_error, 400, %{body: body}}} when is_binary(body) -> + if String.contains?(body, "AlreadyExists") do + get_existing_resource(...) # Retrieve existing instead + else + {:error, body} + end +end +``` + +--- + +## Summary + +### What We Changed + +1. ✅ Added `sweet_xml` dependency (required for ExAws XML parsing) +2. ✅ Created `PhoenixkitEu.AWSInfrastructureSetup` module (sweet_xml compatible) +3. ✅ Fixed SQS attribute format (atom-keyed keyword lists instead of string-keyed tuples) +4. ✅ Implemented AWS CLI integration for SES configuration (with graceful degradation) +5. ✅ Created `PhoenixkitEuWeb.Live.AWSSettingsLive` (custom Web UI) +6. ✅ Added router override to use our LiveView +7. ✅ Created standalone script for testing + +### What to Monitor + +1. **PhoenixKit Updates** - Check if they fix sweet_xml compatibility and SQS attribute format +2. **ExAws Updates** - Check for changes in response parsing or attribute handling +3. **AWS API Changes** - Update our module if AWS changes STS/SQS/SNS/SES APIs +4. **sweet_xml Updates** - Verify parsing still works correctly +5. **AWS CLI Availability** - Monitor if AWS CLI becomes available in production container + +### Key Takeaways + +**Issue #1: XML Parsing** +PhoenixKit 1.4.4 assumes sweet_xml is NOT installed. When it IS installed, ExAws automatically parses XML responses, breaking PhoenixKit's assumptions about response structure. + +**Issue #2: SQS Attributes** +ExAws.SQS requires atom-keyed keyword lists for queue attributes, not string-keyed tuples. Using the wrong format causes ArgumentError. + +**Issue #3: SES Configuration** +SES v2 configuration sets require AWS CLI (sesv2 commands). Our module gracefully handles missing CLI by providing manual setup instructions. + +**Our solution:** Implement a parallel module that: +- Handles both parsed (atom keys) and unparsed (string keys) XML responses +- Uses correct attribute format for SQS operations +- Attempts AWS CLI for SES, falls back to manual instructions if unavailable + +--- + +## Quick Reference + +### Files Changed/Created + +| File | Type | Purpose | +|------|------|---------| +| `lib/phoenixkit_eu/aws_infrastructure_setup.ex` | New | sweet_xml compatible setup | +| `lib/phoenixkit_eu_web/live/aws_settings_live.ex` | New | Custom Web UI | +| `lib/phoenixkit_eu_web/router.ex` | Modified | Route override | +| `scripts/setup_aws_infrastructure.exs` | New | Standalone test script | +| `mix.exs` | Modified | Added sweet_xml dependency | +| `config/config.exs` | Modified | ExAws configuration | + +### Commands + +```bash +# Test via script +mix run scripts/setup_aws_infrastructure.exs + +# Test via Web UI +# http://your-domain/phoenix_kit/admin/settings/aws + +# Test via IEx +iex -S mix phx.server +PhoenixkitEu.AWSInfrastructureSetup.run(project_name: "test") + +# Verify settings +alias PhoenixKit.Settings +Settings.get_setting("aws_sns_topic_arn") +``` + +--- + +## Change Log + +### Version 1.1 (2025-10-23) +- Added SQS attribute format fix (atom-keyed keyword lists) +- Implemented AWS CLI integration for SES configuration +- Added graceful degradation when AWS CLI is not available +- Updated expected output examples to show both scenarios +- Added manual SES setup instructions + +### Version 1.0 (2025-10-23) +- Initial documentation +- sweet_xml compatibility fix +- Custom AWS infrastructure setup module + +--- + +## Issue #3: AWS CLI Dependency for SES Configuration (CRITICAL) + +### Problem Summary + +**Date Discovered:** 2025-10-23 +**Severity:** CRITICAL - Email sending completely broken + +The AWS infrastructure setup appeared to complete successfully but **emails failed to send** with error: +``` +ConfigurationSetDoesNotExist: Configuration set 'phoenixkit-emailing' does not exist +``` + +### Root Cause + +The setup process had a **silent failure** in steps 8-9: + +```elixir +# Steps 8-9 tried to use AWS CLI commands +System.cmd("aws", ["sesv2", "create-configuration-set", ...]) + +# But AWS CLI was NOT installed in Docker container +# Result: ErlangError{original: :enoent} # "file not found" +``` + +**What happened:** +1. ✅ Steps 1-7 succeeded (SQS, SNS) using ExAws API +2. ❌ Step 8: `create_ses_config_set()` tried to run `aws sesv2 create-configuration-set` +3. ❌ CLI not found → caught error → logged warning +4. ⚠️ **Critical mistake**: Continued anyway, saved config set name to database +5. ❌ Step 9: `configure_ses_events()` tried to run `aws sesv2 create-configuration-set-event-destination` +6. ❌ CLI not found → caught error → logged "Manual setup required" +7. ✅ Setup marked as "completed successfully" +8. ❌ **Result**: Database had config set name BUT resource never created in AWS + +**When sending email:** +- App configured Swoosh to use configuration set: `phoenixkit-emailing` +- AWS SES responded: "That configuration set doesn't exist" +- Email sending failed + +### Solution: Custom SES v2 API Module + +Created `lib/phoenixkit_eu/aws/sesv2.ex` - a custom API client for SES v2 operations not supported by ExAws. + +#### File: lib/phoenixkit_eu/aws/sesv2.ex + +```elixir +defmodule PhoenixkitEu.AWS.SESv2 do + @moduledoc """ + AWS SES v2 API client for operations not supported by ExAws. + Uses ExAws.Operation.JSON to make signed requests to SES v2 API. + """ + + def create_configuration_set(name, config) do + # Use ExAws.Operation.JSON for SES v2 API + request = %ExAws.Operation.JSON{ + http_method: :post, + service: :ses, # IMPORTANT: Must be :ses, not :email + path: "/v2/email/configuration-sets", + data: %{"ConfigurationSetName" => name}, + headers: [{"content-type", "application/json"}] + } + + case ExAws.request(request, config) do + {:ok, _} -> {:ok, name} + {:error, {:http_error, 409, _}} -> {:ok, name} # Already exists + {:error, reason} -> {:error, inspect(reason)} + end + end + + def create_configuration_set_event_destination( + config_set_name, + destination_name, + topic_arn, + config + ) do + data = %{ + "EventDestinationName" => destination_name, + "EventDestination" => %{ + "Enabled" => true, + "MatchingEventTypes" => [ + "SEND", "REJECT", "BOUNCE", "COMPLAINT", + "DELIVERY", "OPEN", "CLICK", "RENDERING_FAILURE" + ], + "SnsDestination" => %{"TopicArn" => topic_arn} + } + } + + request = %ExAws.Operation.JSON{ + http_method: :post, + service: :ses, + path: "/v2/email/configuration-sets/#{URI.encode(config_set_name)}/event-destinations", + data: data, + headers: [{"content-type", "application/json"}] + } + + case ExAws.request(request, config) do + {:ok, _} -> :ok + {:error, {:http_error, 409, _}} -> :ok # Already exists + {:error, reason} -> {:error, inspect(reason)} + end + end +end +``` + +#### Key Implementation Details + +**Critical Point**: Service must be `:ses` not `:email` + +```elixir +# ❌ Wrong - causes "Credential should be scoped to correct service: 'ses'" +service: :email + +# ✅ Correct - AWS SES v2 service identifier +service: :ses +``` + +**Why this works:** +- ExAws.Operation.JSON handles AWS Signature Version 4 signing +- No external dependencies (no AWS CLI needed) +- Works in any environment (dev, Docker, production) +- Idempotent (handles "already exists" errors gracefully) + +#### Updated Infrastructure Setup + +**Changes to `lib/phoenixkit_eu/aws_infrastructure_setup.ex`:** + +```elixir +# OLD: Used System.cmd (required AWS CLI) +defp create_ses_config_set(project_name, region) do + case System.cmd("aws", ["sesv2", "create-configuration-set", ...]) do + # ... + end +rescue + error -> + Logger.warning("[AWS Setup] ⚠️ AWS CLI not available") + {:ok, config_set_name} # ❌ Silent failure! +end + +# NEW: Uses ExAws API (no CLI needed) +defp create_ses_config_set(project_name, _region, config) do + alias PhoenixkitEu.AWS.SESv2 + + case SESv2.create_configuration_set(config_set_name, config) do + {:ok, ^config_set_name} -> + Logger.info("[AWS Setup] ✓ SES Configuration Set created") + {:ok, config_set_name} + + {:error, reason} -> + Logger.error("[AWS Setup] ❌ Failed to create SES Configuration Set") + {:error, "create_ses_config_set", reason} # ✅ Proper error handling + end +end +``` + +**Function signature changes:** +```elixir +# Added `config` parameter to both functions +create_ses_config_set(project_name, region, config) # OLD: no config +configure_ses_events(config_set, topic_arn, region, config) # OLD: no config +``` + +### Testing & Verification + +**Test in IEx:** +```elixir +alias PhoenixkitEu.AWS.SESv2 +alias PhoenixKit.Settings + +config = [ + access_key_id: Settings.get_setting("aws_access_key_id"), + secret_access_key: Settings.get_setting("aws_secret_access_key"), + region: Settings.get_setting("aws_region") +] + +# Test configuration set creation +SESv2.create_configuration_set("phoenixkit-emailing", config) +# => {:ok, "phoenixkit-emailing"} + +# Test event destination creation +topic_arn = Settings.get_setting("aws_sns_topic_arn") +SESv2.create_configuration_set_event_destination( + "phoenixkit-emailing", + "email-events-to-sns", + topic_arn, + config +) +# => :ok +``` + +**Expected Output (Success):** +```log +[info] [AWS Setup] [8/9] Creating SES Configuration Set... +[info] [AWS Setup] ✓ SES Configuration Set created +[info] [AWS Setup] Name: phoenixkit-emailing +[info] [AWS Setup] [9/9] Configuring SES event tracking to SNS... +[info] [AWS Setup] ✓ SES Event Tracking configured +[info] [AWS Setup] Events: SEND, REJECT, BOUNCE, COMPLAINT, DELIVERY, OPEN, CLICK, RENDERING_FAILURE +[info] [AWS Setup] Destination: arn:aws:sns:eu-north-1:459426957596:phoenixkit-email-events +[info] [AWS Setup] ✅ Infrastructure setup completed successfully! +``` + +### Email Event Tracking - Understanding the Data + +#### Event Types & Reliability + +| Event | Reliability | Use Case | +|-------|------------|----------| +| **SEND** | ✅ 100% | Email accepted by AWS SES | +| **DELIVERY** | ✅ 100% | Email reached recipient's server | +| **BOUNCE** | ✅ 100% | Email rejected (bad address, full inbox) | +| **COMPLAINT** | ✅ 100% | User marked as spam | +| **REJECT** | ✅ 100% | AWS rejected before sending | +| **CLICK** | ✅ ~95% | User clicked link (most reliable engagement) | +| **RENDERING_FAILURE** | ✅ High | Email HTML failed to render | +| **OPEN** | ⚠️ **30-50%** | Tracking pixel loaded (UNRELIABLE) | + +#### Why OPEN Events Are Unreliable + +**You may see OPEN events even if the user never opened the email. This is expected behavior!** + +##### False Positives (Email marked "opened" but user didn't open it): + +1. **Email Client Pre-loading** (Most common) + - **Apple Mail**: Automatically loads all images through proxy (Mail Privacy Protection) + - **Gmail**: Pre-loads images for security scanning and caching + - **Outlook**: Loads images when email appears in preview pane + - **Result**: OPEN event fires BEFORE user sees the email + +2. **Preview Panes** + - Outlook: Selecting email (without opening) loads images → OPEN event + - Gmail: Three-pane view loads images on selection + +3. **Email Forwarding** + - Each time email is forwarded, images reload → new OPEN event + +4. **Multiple Devices** + - Same user viewing email on phone, then laptop → multiple OPEN events + +##### False Negatives (User opened email but NO OPEN event): + +1. **Images Disabled**: User has "Load images" turned off +2. **Privacy Features**: iOS Mail Privacy Protection, browser extensions +3. **Corporate Firewalls**: Block tracking pixels +4. **Plain Text**: User viewing plain text version + +#### Industry Statistics + +| Timeframe | OPEN Tracking Accuracy | +|-----------|----------------------| +| Pre-2021 (Before iOS 15) | 70-80% accurate | +| 2021+ (After Apple Mail Privacy) | 30-50% accurate | +| Gmail (2022+) | 40-60% accurate | +| Corporate emails | 20-40% accurate | + +**Key Insight**: As of 2023, OPEN tracking is considered **unreliable for individual emails** but still useful for **aggregate trend analysis**. + +#### Best Practices + +**DO Use OPEN Events For:** +- ✅ Aggregate analytics: "20% of campaign opened" +- ✅ A/B testing: Compare open rates between subject lines +- ✅ Trend analysis: "Opens increased 15% this month" + +**DON'T Use OPEN Events For:** +- ❌ User segmentation: "This user never opens emails" +- ❌ Billing/charging: "Charge per open" +- ❌ Compliance: "User didn't open privacy notice" +- ❌ Individual behavior: "User opened at 3pm" + +**Recommended Engagement Metrics (Priority Order):** +1. **CLICK rate** (most reliable) - Use for user engagement +2. **DELIVERY rate** - Confirms email reached inbox +3. **OPEN rate** (least reliable) - Use only for aggregate trends + +#### Example Scenario + +You send an email at **2:00 PM** and see these events: + +``` +2:00 PM - SEND +2:01 PM - DELIVERY +2:02 PM - OPEN ⚠️ Could be Gmail pre-loading +2:05 PM - OPEN ⚠️ Could be email forwarded +2:10 PM - CLICK ✅ User DEFINITELY clicked a link +``` + +**Interpretation:** +- ✅ **DELIVERY**: Email successfully reached inbox +- ⚠️ **OPEN (2:02 PM)**: Could be pre-loading, preview pane, or actual open - **cannot determine** +- ⚠️ **OPEN (2:05 PM)**: Could be second view, forwarding, or different device - **cannot determine** +- ✅ **CLICK (2:10 PM)**: **Reliable proof of engagement** - user interested enough to click + +**Recommended interpretation:** +``` +DELIVERY + CLICK = ✅ Strong engagement (user definitely interested) +DELIVERY + multiple OPEN + no CLICK = ⚠️ Maybe engaged (unreliable) +DELIVERY + no OPEN = ⚠️ Maybe not engaged (or privacy-protected) +``` + +#### Privacy & Compliance + +**GDPR / CAN-SPAM Compliance:** + +1. ✅ **Disclose tracking in privacy policy** + ``` + "We use tracking pixels to measure email engagement" + ``` + +2. ✅ **Honor unsubscribe immediately** + - COMPLAINT event → Unsubscribe automatically (required by law) + - Process within 10 business days (CAN-SPAM) + +3. ✅ **Don't rely on OPEN tracking for critical functionality** + - Privacy trend: More email clients blocking tracking + - Apple Mail Privacy Protection (2021+) + - Users have right to opt-out of tracking + +### Summary of Issue #3 + +**Problems Fixed:** +1. ✅ SES configuration set now created via API (no CLI needed) +2. ✅ Event destination properly configured +3. ✅ Email sending works +4. ✅ Event tracking functional + +**Files Changed:** +- `lib/phoenixkit_eu/aws/sesv2.ex` (new) - Custom SES v2 API client +- `lib/phoenixkit_eu/aws_infrastructure_setup.ex` (modified) - Use API instead of CLI + +**Key Learnings:** +- AWS CLI dependency is a critical failure point in containerized environments +- Silent failures in setup processes can cause hard-to-diagnose production issues +- ExAws.Operation.JSON can be used for AWS services not yet supported by ExAws +- Email OPEN tracking is inherently unreliable (30-50% false positive rate) +- CLICK events are the most reliable engagement metric + +--- + +## 🔧 Integration Checklist for PhoenixKit Core + +This section provides a step-by-step checklist for integrating these fixes into PhoenixKit core. + +### Phase 1: Code Integration + +**1. Create new SES v2 API module** +- [ ] Copy `lib/phoenixkit_eu/aws/sesv2.ex` to `lib/phoenix_kit/aws/sesv2.ex` +- [ ] Update module name: `PhoenixkitEu.AWS.SESv2` → `PhoenixKit.AWS.SESv2` +- [ ] Verify compilation: `mix compile` +- [ ] Location: See Issue #3 → Solution → File: lib/phoenixkit_eu/aws/sesv2.ex + +**2. Update infrastructure setup module** +- [ ] Open `lib/phoenix_kit/aws/infrastructure_setup.ex` +- [ ] **Fix Issue #2 (SQS attributes):** + - [ ] Lines 117-121: Change to atom-keyed keyword list (see Issue #2 code samples) + - [ ] Lines 195-201: Change to atom-keyed keyword list + - [ ] Lines 159, 249: Change policy attributes to `[policy: policy]` +- [ ] **Fix Issue #3 (SES CLI dependency):** + - [ ] Lines 277-311: Replace `create_ses_config_set/2` with API version (add `config` parameter) + - [ ] Lines 313-361: Replace `configure_ses_events/3` with API version (add `config` parameter) + - [ ] Lines 73-74: Update function calls to pass `config` parameter +- [ ] **Fix Issue #1 (sweet_xml parsing):** + - [ ] Line 99: Add fallback for both atom and string keys: `body[:account] || body["account"]` + - [ ] Lines 175, 264: Add similar fallbacks for topic/subscription ARNs + +**3. Optional: Add cleanup module (recommended for testing)** +- [ ] Copy `lib/phoenix_kit/aws/infrastructure_cleanup.ex` to PhoenixKit +- [ ] Module already uses `PhoenixKit` namespace (no changes needed) +- [ ] Provides safe resource cleanup for development/testing + +### Phase 2: Testing + +**1. Local testing (requires AWS account)** +- [ ] Set up test AWS credentials +- [ ] Run fresh setup: `PhoenixKit.AWS.InfrastructureSetup.run(project_name: "test")` +- [ ] Verify all 9 steps complete successfully +- [ ] Check no AWS CLI warnings in steps 8-9 +- [ ] Verify resources created in AWS Console + +**2. Email sending test** +- [ ] Send test email using configuration set created +- [ ] Verify email delivered successfully +- [ ] Check email events tracked (SEND, DELIVERY, etc.) + +**3. Docker environment test** +- [ ] Build Docker image without AWS CLI +- [ ] Run setup inside container +- [ ] Verify steps 8-9 succeed (not fail with CLI error) +- [ ] Test email sending from container + +**4. Cleanup test (if cleanup module included)** +- [ ] Run cleanup: `PhoenixKit.AWS.InfrastructureCleanup.cleanup("test")` +- [ ] Verify resources deleted +- [ ] Run setup again to verify idempotency + +### Phase 3: Documentation Updates + +**1. Update setup documentation** +- [ ] Remove any references to "AWS CLI required" +- [ ] Update expected output logs (no CLI warnings) +- [ ] Add note about SES v2 API usage + +**2. Update changelog** +- [ ] Document fix for sweet_xml compatibility +- [ ] Document fix for SQS attribute format +- [ ] Document fix for SES CLI dependency +- [ ] Mark as **CRITICAL** fix for production deployments + +**3. Migration guide for existing users** +- [ ] Add note that existing setups will continue working +- [ ] Provide instructions for re-running setup if needed +- [ ] Explain that no manual intervention required + +### Phase 4: Release + +**1. Version bump** +- [ ] Consider this a **patch release** (backward compatible) +- [ ] Suggested version: 1.4.5 (or next patch number) + +**2. Release notes** +- [ ] **Critical Fix**: Email sending now works in containerized environments +- [ ] **Fixed**: sweet_xml compatibility when library installed +- [ ] **Fixed**: SQS queue creation attribute format +- [ ] **Improved**: SES configuration now uses API instead of AWS CLI +- [ ] **Added**: Optional cleanup script for development + +**3. Announcement** +- [ ] Notify users of critical fix for production deployments +- [ ] Recommend update for all Docker/Kubernetes deployments +- [ ] No action required for existing working setups + +### Quick Reference: Files to Change + +| File | Action | Severity | +|------|--------|----------| +| `lib/phoenix_kit/aws/sesv2.ex` | **Create new** | Required | +| `lib/phoenix_kit/aws/infrastructure_setup.ex` | **Modify** | Required | +| `lib/phoenix_kit/aws/infrastructure_cleanup.ex` | Create new | Optional | +| Documentation | Update | Recommended | + +### Verification Commands + +After integration, verify with these commands: + +```elixir +# 1. Test infrastructure setup +{:ok, config} = PhoenixKit.AWS.InfrastructureSetup.run(project_name: "test") + +# 2. Verify no CLI warnings in logs +# Look for: "✓ SES Configuration Set created" (not "AWS CLI not available") + +# 3. Test email sending +import Swoosh.Email +new() +|> to("test@example.com") +|> from({"Test", "sender@example.com"}) +|> subject("PhoenixKit AWS Fix Test") +|> text_body("Testing fixed infrastructure") +|> put_provider_option(:configuration_set_name, config["aws_ses_configuration_set"]) +|> PhoenixKit.Mailer.deliver() + +# 4. Optional: Test cleanup +PhoenixKit.AWS.InfrastructureCleanup.cleanup("test", dry_run: true) +``` + +### Support & Questions + +If you encounter issues during integration: + +1. **Check logs** - All setup steps provide detailed logging +2. **Verify AWS credentials** - Ensure IAM permissions include SES v2 API +3. **Test SES v2 module independently** - Can be tested without full setup +4. **Review Issue #3 section** - Complete implementation details provided + +### Timeline Recommendation + +- **Development**: 2-3 hours (code changes + local testing) +- **Testing**: 2-3 hours (AWS environment + Docker testing) +- **Documentation**: 1 hour (update docs + changelog) +- **Release**: Standard release process + +**Total estimated effort**: 1 day for complete integration and testing + +--- + +## Change Log + +### Version 1.2 (2025-10-23) +- Fixed critical SES configuration issue (AWS CLI dependency) +- Added custom SES v2 API module (lib/phoenixkit_eu/aws/sesv2.ex) +- Updated infrastructure setup to use API instead of CLI +- Added comprehensive email event tracking documentation +- Explained OPEN event unreliability and best practices +- Documented privacy considerations and compliance requirements + +### Version 1.1 (2025-10-23) +- Added SQS attribute format fix (atom-keyed keyword lists) +- Implemented AWS CLI integration for SES configuration +- Added graceful degradation when AWS CLI is not available +- Updated expected output examples to show both scenarios +- Added manual SES setup instructions + +### Version 1.0 (2025-10-23) +- Initial documentation +- sweet_xml compatibility fix +- Custom AWS infrastructure setup module + +--- + +**Documentation Version:** 1.2 +**Last Updated:** 2025-10-23 +**Maintainer:** Development Team diff --git a/scripts/QUALITY_CHECK_USAGE.md b/scripts/QUALITY_CHECK_USAGE.md new file mode 100644 index 000000000..4c0998c46 --- /dev/null +++ b/scripts/QUALITY_CHECK_USAGE.md @@ -0,0 +1,114 @@ +# 🔍 Обновлённая функция Quality Check + +## Что делает новая версия + +Функция `quality` в `/app/scripts/zai_helper.sh` теперь автоматически: + +### 1️⃣ **Проверка форматирования (mix format)** +- ✅ Автоматически находит неотформатированные файлы +- ✅ **Исправляет их автоматически** без вопросов +- ✅ Подтверждает успешное исправление + +### 2️⃣ **Статический анализ (mix credo --strict)** +- 📋 Запускает строгую проверку всех файлов +- 🤖 При наличии проблем запрашивает исправления у Z.AI +- 💾 Сохраняет предложения в `/tmp/credo_fixes.txt` + +### 3️⃣ **Проверка типов (mix dialyzer)** +- 🔬 Анализирует типы и спецификации +- 🤖 Запрашивает исправления типов у Z.AI при проблемах +- 💾 Сохраняет предложения в `/tmp/dialyzer_fixes.txt` + +### 4️⃣ **Дополнительный анализ файла** (опционально) +- 🎯 Если указан конкретный файл, делает глубокий анализ +- 🔍 Ищет логические ошибки, проблемы безопасности +- 💾 Сохраняет ревью в `/tmp/file_review_*.txt` + +## Использование + +```bash +# Полная проверка проекта (format + credo + dialyzer) +/app/scripts/zai_helper.sh quality + +# Проверка проекта + глубокий анализ конкретного файла +/app/scripts/zai_helper.sh quality lib/phoenix_kit/emails/metrics.ex +``` + +## Результаты проверки + +После выполнения вы получите: + +### При успехе: +``` +✅ Все проверки качества пройдены успешно! +Код соответствует стандартам качества проекта. +``` + +### При наличии проблем: +``` +⚠️ Найдены проблемы качества + +📁 Результаты проверки сохранены: + - /tmp/quality_errors.log - все ошибки + - /tmp/credo_fixes.txt - исправления Credo + - /tmp/dialyzer_fixes.txt - исправления типов + - /tmp/file_review_*.txt - ревью конкретного файла +``` + +## Автоматические исправления + +✅ **Автоматически применяются:** +- Форматирование кода (mix format) + +⚠️ **Требуют ручного применения:** +- Исправления Credo (смотрите `/tmp/credo_fixes.txt`) +- Исправления типов Dialyzer (смотрите `/tmp/dialyzer_fixes.txt`) +- Логические и бизнес-ошибки (смотрите `/tmp/file_review_*.txt`) + +## Примеры реального использования + +### Быстрая проверка перед коммитом: +```bash +# 1. Проверяем качество +/app/scripts/zai_helper.sh quality + +# 2. Если есть проблемы, смотрим предложения +cat /tmp/credo_fixes.txt + +# 3. Применяем исправления вручную или через Edit + +# 4. Создаём коммит +/app/scripts/zai_helper.sh commit +``` + +### Проверка конкретного модуля: +```bash +# Проверяем и анализируем email модуль +/app/scripts/zai_helper.sh quality lib/phoenix_kit/emails.ex + +# Смотрим детальное ревью +cat /tmp/file_review_emails.ex.txt +``` + +## Известные особенности + +1. **Z.AI может отвечать долго** (20-60 секунд) на сложные запросы +2. **Dialyzer при первом запуске** создаёт PLT файлы (может занять 2-3 минуты) +3. **Форматирование исправляется автоматически** - изменения сразу в файлах + +## Оптимизация скорости + +Если Z.AI отвечает слишком долго, можно: +1. Использовать функцию без файла (только mix quality) +2. Прервать выполнение (Ctrl+C) и посмотреть частичные результаты +3. Использовать отдельные команды: `mix format`, `mix credo`, `mix dialyzer` + +## Итог + +Теперь одна команда `/app/scripts/zai_helper.sh quality` делает: +- ✅ Автоматическое форматирование +- ✅ Полную проверку качества кода +- ✅ Генерацию предложений по исправлению +- ✅ Сохранение всех результатов для дальнейшей работы + +Это существенно ускоряет процесс поддержания качества кода! \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..5bcd9f04a --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,243 @@ +# Claude Code Agent Runner + +Бесшовный запуск агентов с визуальным отображением в tmux и автоматическим возвратом результата. + +## Быстрый старт + +```bash +# Запустить агента - видишь процесс в tmux, результат возвращается автоматически +./scripts/agent.sh "твой промпт" + +# С ограничением инструментов +./scripts/agent.sh "анализ кода" --tools "Read,Grep,Glob" + +# В новом окне tmux +./scripts/agent.sh "долгая задача" --new-window +``` + +## Доступные скрипты + +| Скрипт | Назначение | Визуал | Результат | +|--------|------------|--------|-----------| +| `agent.sh` | **Рекомендуемый** - бесшовный | ✅ | ✅ Автоматически | +| `run_agent.sh` | Только визуал | ✅ | ❌ Вручную | +| `run_agent_with_output.sh` | Визуал + файл | ✅ | 📁 Файл | +| `run_sdk_agent.py` | Python версия | ✅ | ✅ | + +--- + +## Рекомендации по агентам + +### 🚀 Рекомендуется запускать через `agent.sh` + +Эти агенты выполняют долгие задачи с большим выводом — удобно видеть прогресс: + +#### Feature Development (`feature-dev`) + +```bash +# Исследование кодовой базы +./scripts/agent.sh "Проанализируй как работает аутентификация в проекте" \ + --tools "Read,Grep,Glob,Bash" + +# Архитектура новой фичи +./scripts/agent.sh "Спроектируй архитектуру модуля биллинга" \ + --tools "Read,Grep,Glob,Bash" +``` + +| Агент | Описание | Когда использовать | +|-------|----------|-------------------| +| `code-explorer` | Глубокий анализ существующих фич | Изучение кодовой базы | +| `code-architect` | Проектирование архитектуры | Планирование новых фич | +| `code-reviewer` | Ревью кода на баги и качество | После написания кода | + +#### PR Review Toolkit (`pr-review-toolkit`) + +```bash +# Полный ревью PR +./scripts/agent.sh "Сделай полный code review изменений в git diff" \ + --tools "Read,Grep,Glob,Bash" + +# Анализ тестового покрытия +./scripts/agent.sh "Проверь достаточность тестов для PR" \ + --tools "Read,Grep,Glob,Bash" + +# Поиск silent failures +./scripts/agent.sh "Найди места где ошибки могут быть проигнорированы" \ + --tools "Read,Grep,Glob" +``` + +| Агент | Описание | Когда использовать | +|-------|----------|-------------------| +| `code-reviewer` | Полный code review | Перед созданием PR | +| `pr-test-analyzer` | Анализ тестового покрытия | После добавления тестов | +| `silent-failure-hunter` | Поиск скрытых ошибок | При работе с error handling | +| `code-simplifier` | Упрощение кода | После реализации фичи | +| `comment-analyzer` | Проверка комментариев | После документирования | +| `type-design-analyzer` | Анализ типов | При создании новых типов | + +#### PhoenixKit Specific + +```bash +# Создание компонента +./scripts/agent.sh "Создай компонент для отображения статистики пользователей" \ + --tools "Read,Grep,Glob,Write,Edit" + +# Ревью шаблонов +./scripts/agent.sh "Проверь все .heex файлы на соответствие стандартам PhoenixKit" \ + --tools "Read,Grep,Glob" +``` + +| Агент | Описание | Когда использовать | +|-------|----------|-------------------| +| `phoenix-kit-component-architect` | Компоненты PhoenixKit | Создание/ревью UI компонентов | + +#### Plugin Development (`plugin-dev`) + +```bash +# Валидация плагина +./scripts/agent.sh "Проверь структуру плагина в .claude/plugins/my-plugin" \ + --tools "Read,Grep,Glob,Bash" +``` + +| Агент | Описание | Когда использовать | +|-------|----------|-------------------| +| `plugin-validator` | Валидация структуры плагина | После создания плагина | +| `skill-reviewer` | Ревью качества skill | После создания skill | +| `agent-creator` | Создание новых агентов | При добавлении агентов | + +--- + +### ⚡ Лучше запускать напрямую (без визуала) + +Эти задачи быстрые и не требуют визуального мониторинга: + +```bash +# Быстрые запросы - используй claude -p напрямую +claude -p "Какая версия в mix.exs?" --allowedTools "Read,Grep" + +# Или через Task tool внутри Claude Code +# (автоматически выбирает подходящий subagent) +``` + +--- + +## Параллельный запуск + +```bash +# Два агента одновременно +./scripts/agent.sh "Проверь безопасность кода" --tools "Read,Grep" & +./scripts/agent.sh "Проанализируй тесты" --tools "Read,Grep,Bash" & +wait + +# Результаты обоих вернутся после завершения +``` + +--- + +## Опции + +```bash +./scripts/agent.sh "промпт" [опции] + +Опции: + --tools TOOLS Разрешённые инструменты (default: Read,Grep,Glob,Bash) + --new-window Создать новое окно tmux + --timeout SECS Таймаут в секундах (default: 300) + --session NAME Имя tmux сессии (default: phoenixkit) +``` + +--- + +## Примеры использования + +### Git-анализ + +```bash +# Кто изменял файл +./scripts/agent.sh "Кто и когда изменял lib/phoenix_kit/users/auth.ex? Покажи историю." \ + --tools "Bash,Read" + +# Анализ между версиями +./scripts/agent.sh "Что изменилось между v1.6.0 и v1.7.0?" \ + --tools "Bash,Read,Grep" +``` + +### Рефакторинг + +```bash +# Найти дублирование +./scripts/agent.sh "Найди дублирующийся код в lib/phoenix_kit_web/live/" \ + --tools "Read,Grep,Glob" + +# Предложить улучшения +./scripts/agent.sh "Проанализируй lib/phoenix_kit/emails/ и предложи улучшения архитектуры" \ + --tools "Read,Grep,Glob" +``` + +### Документация + +```bash +# Сгенерировать документацию +./scripts/agent.sh "Создай документацию для модуля PhoenixKit.Users.Auth" \ + --tools "Read,Grep" +``` + +--- + +## Архитектура + +``` +┌─────────────────────────────────────────────────────┐ +│ Вызывающий (Claude Code / скрипт) │ +│ │ +│ agent.sh "prompt" ────┬──────────────────────────► │ +│ ▲ │ │ +│ │ ▼ │ +│ Результат ┌─────────────┐ │ +│ (stdout) │ tmux pane │ ◄── Пользователь │ +│ ▲ │ (визуально) │ видит прогресс │ +│ │ └──────┬──────┘ │ +│ │ │ │ +│ │ ▼ │ +│ │ ┌─────────────┐ │ +│ └───────│ temp file │ │ +│ │ (результат) │ │ +│ └─────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## Полный список агентов + +### Глобальные (`~/.claude/agents/`) + +- `phoenix-kit-component-architect` - UI компоненты PhoenixKit + +### Проектные (`/app/.claude/agents/`) + +- `phoenix-kit-component-architect` - UI компоненты PhoenixKit + +### Из плагинов + +#### feature-dev +- `code-explorer` - исследование кода +- `code-architect` - архитектура фич +- `code-reviewer` - code review + +#### pr-review-toolkit +- `code-reviewer` - ревью PR (модель opus) +- `code-simplifier` - упрощение кода +- `comment-analyzer` - анализ комментариев +- `pr-test-analyzer` - анализ тестов +- `silent-failure-hunter` - поиск скрытых ошибок +- `type-design-analyzer` - анализ типов + +#### plugin-dev +- `plugin-validator` - валидация плагинов +- `skill-reviewer` - ревью skills +- `agent-creator` - создание агентов + +#### agent-sdk-dev +- `agent-sdk-verifier-py` - верификация Python SDK +- `agent-sdk-verifier-ts` - верификация TypeScript SDK diff --git a/scripts/README_ZAI_ORCHESTRATION.md b/scripts/README_ZAI_ORCHESTRATION.md new file mode 100644 index 000000000..dd2cc6b63 --- /dev/null +++ b/scripts/README_ZAI_ORCHESTRATION.md @@ -0,0 +1,164 @@ +# Z.AI Orchestration Guide + +## 🎯 Концепция + +Использование Claude Code как главного оркестратора с Z.AI в качестве специализированного помощника для выполнения конкретных задач. + +## 🚀 Быстрый старт + +### Прямой вызов Z.AI + +```bash +# Простой запрос +source /root/.bashrc && zai "Your question here" --print + +# Анализ файла +source /root/.bashrc && zai "Analyze this file: $(cat lib/phoenix_kit/emails.ex)" --print +``` + +### Использование скриптов-помощников + +#### 1. `zai_helper.sh` - базовые операции + +```bash +# Анализ контекста проекта +./scripts/zai_helper.sh context + +# Генерация commit message +git add -A +./scripts/zai_helper.sh commit + +# Проверка качества кода +./scripts/zai_helper.sh quality lib/phoenix_kit/emails/metrics.ex + +# Поиск и исправление ошибок +./scripts/zai_helper.sh fix + +# Генерация документации +./scripts/zai_helper.sh docs lib/phoenix_kit/users/auth.ex +``` + +#### 2. `zai_workflow.sh` - комплексная автоматизация + +```bash +# Планирование новой функции +./scripts/zai_workflow.sh plan "email templates with variables" + +# Генерация кода +./scripts/zai_workflow.sh generate "rate limiter for API endpoints" + +# Полный цикл разработки функции +./scripts/zai_workflow.sh develop "user preferences system" + +# Интеллектуальный commit с автоматической генерацией сообщения +./scripts/zai_workflow.sh commit +``` + +## 📋 Практические примеры использования + +### Пример 1: Исправление найденных ошибок + +```bash +# 1. Z.AI находит ошибки в коде +./scripts/zai_helper.sh quality lib/phoenix_kit/emails/metrics.ex + +# 2. Claude Code применяет исправления +# (вы выполняете исправления на основе рекомендаций Z.AI) + +# 3. Z.AI генерирует commit message +./scripts/zai_helper.sh commit +``` + +### Пример 2: Разработка новой функции + +```bash +# 1. Claude Code определяет требования +# 2. Z.AI планирует реализацию +./scripts/zai_workflow.sh plan "email scheduling system" + +# 3. Z.AI генерирует код +./scripts/zai_workflow.sh generate "scheduled email sender with Oban" + +# 4. Claude Code интегрирует код в проект +# 5. Z.AI проверяет качество +./scripts/zai_workflow.sh review + +# 6. Z.AI генерирует тесты +./scripts/zai_workflow.sh test /tmp/generated_code.ex +``` + +### Пример 3: Комплексный рабочий процесс + +```bash +# Полный цикл с одной командой +./scripts/zai_workflow.sh develop "email bounce handling" + +# Это автоматически выполнит: +# - Анализ текущего проекта +# - Планирование изменений +# - Генерацию кода +# - Проверку качества +# - Создание тестов +``` + +## 🏗️ Архитектура взаимодействия + +``` +┌─────────────────┐ +│ Claude Code │ ← Главный оркестратор +│ (Вы здесь) │ +└────────┬────────┘ + │ + ├─→ Прямые вызовы Z.AI через bash + │ + ├─→ zai_helper.sh (простые задачи) + │ + └─→ zai_workflow.sh (комплексные процессы) +``` + +## 💡 Лучшие практики + +1. **Разделение задач**: + - Claude Code: навигация, интеграция, применение изменений + - Z.AI: анализ, генерация, проверка качества + +2. **Оптимизация промптов**: + - Давайте Z.AI конкретный контекст (файлы, git diff) + - Запрашивайте конкретные действия, а не общие советы + +3. **Итеративный процесс**: + - Начинайте с анализа контекста + - Генерируйте решения поэтапно + - Всегда проверяйте качество перед коммитом + +## 🔧 Настройка моделей + +В `.bashrc` настроены функции для разных моделей: + +- `zai` - Z.AI с моделью GLM-4.6 +- `kimi` - Kimi K2 (Moonshot AI) +- `claude` - стандартный Claude CLI + +Можно переключаться между моделями для разных задач: + +```bash +# Использовать Kimi для творческих задач +kimi "Generate creative email subject lines" --print + +# Использовать Z.AI для технического анализа +zai "Review this code for performance issues" --print +``` + +## ⚠️ Известные ограничения + +1. Ответы Z.AI могут занимать 10-30 секунд +2. Размер контекста ограничен +3. При больших файлах лучше передавать только релевантные части + +## 🎉 Результат + +С этой системой оркестрации вы получаете: +- **Автоматизацию** рутинных задач +- **Качество** кода через автоматические проверки +- **Скорость** разработки через генерацию кода +- **Консистентность** через следование паттернам проекта \ No newline at end of file diff --git a/scripts/SUBMISSION_TO_PHOENIXKIT.md b/scripts/SUBMISSION_TO_PHOENIXKIT.md new file mode 100644 index 000000000..23e326087 --- /dev/null +++ b/scripts/SUBMISSION_TO_PHOENIXKIT.md @@ -0,0 +1,311 @@ +# Submission Package for PhoenixKit Core Team + +**Date:** 2025-10-23 +**Subject:** Critical AWS Infrastructure Setup Fixes +**Priority:** HIGH - Blocks email functionality in production + +--- + +## What to Send to PhoenixKit Developers + +### Primary Document + +**📄 [PHOENIXKIT_AWS_COMPATIBILITY.md](./PHOENIXKIT_AWS_COMPATIBILITY.md)** + +This is the **ONLY document** you need to send. It contains everything: + +✅ **Professional summary** for PhoenixKit developers (at the top) +✅ **TL;DR** with severity table +✅ **What needs to change** in PhoenixKit core +✅ **Complete code solutions** ready to integrate +✅ **Root cause analysis** for all 3 issues +✅ **Testing results** and verification +✅ **Integration checklist** with step-by-step instructions +✅ **Backward compatibility** notes +✅ **Timeline estimates** for integration effort + +--- + +## Document Structure + +The document is organized for quick navigation: + +### Section 1: For PhoenixKit Developers (Lines 9-85) +**Read this first** - Executive summary, impact, and what needs to change + +### Section 2: Executive Summary (Lines 87-92) +High-level overview of the core issue + +### Section 3: Issue #1 - sweet_xml Parsing (Lines 94-200) +- Root cause +- Example code showing the problem +- Solution with code samples + +### Section 4: Issue #2 - SQS Attribute Format (Lines 202-250) +- ArgumentError explanation +- Correct vs incorrect format +- Fixed code samples + +### Section 5: Issue #3 - AWS CLI Dependency (Lines 765-1178) **CRITICAL** +- Why email sending was broken +- Complete SES v2 API module (copy-paste ready) +- Updated infrastructure setup code +- Testing results from production + +### Section 6: Integration Checklist (Lines 1181-1324) +- Step-by-step integration guide +- Testing procedures +- Documentation updates +- Release recommendations + +### Section 7: Change Log (Lines 1327-1349) +Version history of fixes + +--- + +## Quick Summary for Email to PhoenixKit + +You can use this template when emailing PhoenixKit: + +``` +Subject: [CRITICAL] AWS Infrastructure Setup - Email Sending Broken in Production + +Hi PhoenixKit Team, + +We've identified and fixed 3 critical issues in PhoenixKit 1.4.4's AWS +Infrastructure Setup that prevent email functionality in containerized +environments (Docker, Kubernetes). + +**Critical Impact:** +- Issue #3 causes silent failure during setup +- Email sending fails with "ConfigurationSetDoesNotExist" +- Affects ALL deployments without AWS CLI installed + +**What's Included:** +- Complete root cause analysis for all 3 issues +- Production-tested code ready to integrate (~200 lines) +- New SES v2 API module (no AWS CLI dependency) +- Integration checklist with step-by-step instructions +- Backward compatible with existing deployments + +**Estimated Integration Effort:** 1 day +- 2-3 hours: Code changes +- 2-3 hours: Testing +- 1 hour: Documentation + +**Document:** PHOENIXKIT_AWS_COMPATIBILITY.md (attached) +- See "📩 For PhoenixKit Core Developers" section at top +- See "🔧 Integration Checklist" for step-by-step guide + +All code is production-tested in our phoenixkit_eu project with +successful results. + +Thank you for maintaining PhoenixKit! + +Best regards, +[Your Name] +``` + +--- + +## Files They Can Reference (Optional) + +If PhoenixKit developers want to see the actual working implementation +in your codebase, point them to: + +### Our Implementation Files (For Reference) + +1. **SES v2 API Module** + - Location: `lib/phoenixkit_eu/aws/sesv2.ex` + - Purpose: Handles SES v2 operations without AWS CLI + - Status: Production-tested, working + +2. **Fixed Infrastructure Setup** + - Location: `lib/phoenixkit_eu/aws_infrastructure_setup.ex` + - Purpose: Updated setup with all 3 fixes applied + - Status: Successfully creates all resources + +3. **Cleanup Script (Bonus)** + - Location: `lib/phoenix_kit/aws/infrastructure_cleanup.ex` + - Purpose: Safe resource cleanup for testing + - Status: Optional but useful for development + +**Note:** All code is already documented in PHOENIXKIT_AWS_COMPATIBILITY.md +with complete examples. These files are just for reference if they want to +see the full context. + +--- + +## What PhoenixKit Needs to Do + +### Minimal Changes Required + +**1. Create one new file:** +- `lib/phoenix_kit/aws/sesv2.ex` (SES v2 API module) + +**2. Update one existing file:** +- `lib/phoenix_kit/aws/infrastructure_setup.ex` (apply 3 fixes) + +**3. Test in 3 environments:** +- Local development (works) +- Docker container (now works - was broken) +- Production deployment (now works - was broken) + +### No Breaking Changes + +✅ Backward compatible with existing deployments +✅ Works with AND without sweet_xml installed +✅ Handles existing resources gracefully +✅ No changes to public API + +--- + +## Why This Is Important + +### Impact on Users + +**Before Fix:** +``` +Production Docker deployment → Setup appears successful → +Email sending fails → "ConfigurationSetDoesNotExist" → +Manual AWS Console intervention required → Hours of debugging +``` + +**After Fix:** +``` +Production Docker deployment → Setup succeeds → +Email sending works → Event tracking works → +Zero manual intervention → Zero debugging time +``` + +### Affected Environments + +- ❌ **Docker containers** (AWS CLI not installed) +- ❌ **Kubernetes pods** (AWS CLI not installed) +- ❌ **Minimal production images** (AWS CLI not installed) +- ✅ **Local development** (may have AWS CLI) +- ⚠️ **With sweet_xml** (parsing issues) + +**Estimate:** 80%+ of production deployments are affected + +--- + +## Testing Evidence + +We've thoroughly tested the fixes: + +### Test 1: Fresh Setup from Scratch +✅ All 9 steps completed successfully +✅ No AWS CLI warnings +✅ Resources created with correct names +✅ Database settings populated correctly + +### Test 2: Email Sending +✅ Email sent successfully +✅ SEND event tracked +✅ DELIVERY event tracked +✅ Configuration set accepted by AWS SES + +### Test 3: Cleanup and Re-setup +✅ Old resources deleted safely +✅ New resources created with different prefix +✅ Idempotent setup (can run multiple times) +✅ No impact on other projects' resources + +### Test 4: Docker Environment +✅ Setup works without AWS CLI installed +✅ SES configuration created via API +✅ Email sending functional in container + +### Logs Included +Complete setup logs showing success are included in the documentation. + +--- + +## Timeline for PhoenixKit Integration + +### Recommended Approach + +**Week 1: Code Integration** +- Day 1: Integrate fixes into development branch +- Day 2: Internal testing (AWS account required) +- Day 3: Docker testing, create test containers + +**Week 2: Release** +- Day 4: Documentation updates +- Day 5: Release as patch version (1.4.5) +- Day 6: Announce to community + +**Total:** 6 business days from integration to release + +### Can Be Faster If Urgent + +Given the critical impact, this could be fast-tracked: +- Same day: Code integration + basic testing +- Next day: Docker testing + release +- **Total:** 2 days for emergency release + +--- + +## Support Available + +If PhoenixKit developers have questions during integration: + +1. **Documentation is comprehensive** - Most answers are in the doc +2. **Code is production-tested** - Working example in our codebase +3. **Step-by-step checklist** - Integration guide included +4. **We can assist** - Available for clarification if needed + +--- + +## Summary + +### What PhoenixKit Gets + +✅ **Fixed infrastructure setup** that works everywhere +✅ **No AWS CLI dependency** required +✅ **Backward compatible** solution +✅ **Production-tested code** ready to integrate +✅ **Comprehensive documentation** with examples +✅ **Integration checklist** for easy implementation +✅ **Bonus cleanup script** for development + +### What PhoenixKit Users Get + +✅ **Email functionality** in Docker/Kubernetes +✅ **No manual AWS Console** intervention needed +✅ **Reliable setup** without silent failures +✅ **Better developer experience** with clear logs +✅ **Zero breaking changes** or migration needed + +### Integration Effort + +**Time:** 1 day (6-7 hours total) +**Risk:** Low (backward compatible, isolated changes) +**Priority:** HIGH (blocks critical functionality) +**Files:** 2 files (1 new, 1 modified) +**Lines:** ~200 lines of code + +--- + +## Final Checklist + +Before sending to PhoenixKit: + +- [x] Primary document ready: PHOENIXKIT_AWS_COMPATIBILITY.md +- [x] Professional summary at top of document +- [x] Complete code solutions included +- [x] Integration checklist provided +- [x] Testing results documented +- [x] Timeline estimates included +- [x] Backward compatibility verified +- [x] No additional documents needed + +**Status: ✅ READY TO SEND** + +--- + +**This is a temporary solution while PhoenixKit core doesn't have these fixes.** +**Once PhoenixKit integrates these changes, you can remove the custom modules.** +**Until then, your email infrastructure works reliably in all environments.** + diff --git a/scripts/agent.sh b/scripts/agent.sh new file mode 100755 index 000000000..e6096ddb1 --- /dev/null +++ b/scripts/agent.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Seamless agent runner - launches visual agent AND returns result +# +# Usage: +# ./scripts/agent.sh "prompt" [options] +# +# This script: +# 1. Launches agent in tmux pane (user sees progress) +# 2. Waits for completion +# 3. Returns the result to stdout +# +# Perfect for programmatic use while maintaining visual feedback. + +set -e + +PROMPT="" +TOOLS="Read,Grep,Glob,Bash" +NEW_WINDOW=false +TIMEOUT=300 # 5 minutes default +SESSION="phoenixkit" +OUTPUT_DIR="/tmp/claude_agents" + +while [[ $# -gt 0 ]]; do + case $1 in + --tools) TOOLS="$2"; shift 2 ;; + --new-window) NEW_WINDOW=true; shift ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + --session) SESSION="$2"; shift 2 ;; + --help|-h) + echo "Usage: $0 'prompt' [options]" + echo "" + echo "Launches agent with visual tmux output and returns result." + echo "" + echo "Options:" + echo " --tools TOOLS Allowed tools (default: Read,Grep,Glob,Bash)" + echo " --new-window Use new tmux window" + echo " --timeout SECS Max wait time (default: 300)" + exit 0 + ;; + *) [ -z "$PROMPT" ] && PROMPT="$1"; shift ;; + esac +done + +[ -z "$PROMPT" ] && { echo "Error: No prompt provided" >&2; exit 1; } + +mkdir -p "$OUTPUT_DIR" + +AGENT_ID="$(date +%Y%m%d_%H%M%S)_$$" +OUTPUT_FILE="${OUTPUT_DIR}/agent_${AGENT_ID}.txt" +STATUS_FILE="${OUTPUT_DIR}/agent_${AGENT_ID}.status" + +TARGET_WINDOW=$(tmux display-message -p '#{window_index}' 2>/dev/null || echo "4") + +ESCAPED_PROMPT=$(printf '%s' "$PROMPT" | sed "s/'/'\\\\''/g") + +AGENT_CMD="( +echo 'STARTED' > '${STATUS_FILE}' +echo '🤖 Agent ${AGENT_ID}' +echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' +claude -p '${ESCAPED_PROMPT}' --allowedTools '${TOOLS}' 2>&1 | tee '${OUTPUT_FILE}' +echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' +echo 'COMPLETED' > '${STATUS_FILE}' +sleep 3 +)" + +if [ "$NEW_WINDOW" = true ]; then + tmux new-window -t "$SESSION" -n "Agent" "$AGENT_CMD" +else + tmux split-window -h -t "${SESSION}:${TARGET_WINDOW}" "$AGENT_CMD" +fi + +# Wait for completion +ELAPSED=0 +while [ $ELAPSED -lt $TIMEOUT ]; do + if [ -f "$STATUS_FILE" ] && [ "$(cat "$STATUS_FILE" 2>/dev/null)" = "COMPLETED" ]; then + cat "$OUTPUT_FILE" + rm -f "$OUTPUT_FILE" "$STATUS_FILE" 2>/dev/null + exit 0 + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) +done + +echo "Error: Agent timed out after ${TIMEOUT}s" >&2 +[ -f "$OUTPUT_FILE" ] && cat "$OUTPUT_FILE" +exit 1 diff --git a/scripts/aws_infrastructure_setup.ex b/scripts/aws_infrastructure_setup.ex new file mode 100644 index 000000000..4e326e958 --- /dev/null +++ b/scripts/aws_infrastructure_setup.ex @@ -0,0 +1,372 @@ +defmodule PhoenixkitEu.AWSInfrastructureSetup do + @moduledoc """ + AWS Infrastructure Setup compatible with ExAws + sweet_xml. + + This module works around the PhoenixKit 1.4.4 incompatibility with sweet_xml + by handling the parsed XML responses (atom keys) instead of raw nested XML (string keys). + """ + + require Logger + + alias ExAws.{STS, SNS, SQS} + alias PhoenixKit.Settings + + @doc """ + Runs AWS infrastructure setup and saves results to database. + + ## Options + - `:project_name` - Project name (default: from settings or "PhoenixKit") + - `:region` - AWS region (default: from settings or "eu-north-1") + - `:access_key_id` - AWS access key (default: from settings) + - `:secret_access_key` - AWS secret key (default: from settings) + + ## Returns + - `{:ok, config_map}` - Successfully created infrastructure + - `{:error, step, reason}` - Failed at specific step + """ + def run(opts \\ []) do + # Get configuration + project_name = + Keyword.get(opts, :project_name) || + Settings.get_setting("project_title", "PhoenixKit") + + project_name = sanitize_project_name(project_name) + + region = + Keyword.get(opts, :region) || + Settings.get_setting("aws_region", "eu-north-1") + + access_key_id = + Keyword.get(opts, :access_key_id) || + Settings.get_setting("aws_access_key_id") + + secret_access_key = + Keyword.get(opts, :secret_access_key) || + Settings.get_setting("aws_secret_access_key") + + if is_nil(access_key_id) || is_nil(secret_access_key) || access_key_id == "" || + secret_access_key == "" do + {:error, "validation", "AWS credentials not found. Please configure them first."} + else + config = [ + access_key_id: access_key_id, + secret_access_key: secret_access_key, + region: region + ] + + run_setup(project_name, region, config) + end + end + + defp run_setup(project_name, region, config) do + Logger.info("[AWS Setup] Starting infrastructure setup for project: #{project_name}") + Logger.info("[AWS Setup] Region: #{region}") + + with {:ok, account_id} <- get_account_id(config), + {:ok, dlq_url, dlq_arn} <- create_dlq(project_name, account_id, config), + :ok <- set_dlq_policy(dlq_url, dlq_arn, account_id, config), + {:ok, topic_arn} <- create_sns_topic(project_name, config), + {:ok, queue_url, queue_arn} <- + create_main_queue(project_name, account_id, dlq_arn, config), + :ok <- set_queue_policy(queue_url, queue_arn, topic_arn, account_id, config), + {:ok, _sub_arn} <- subscribe_sqs_to_sns(topic_arn, queue_arn, config), + {:ok, config_set} <- create_ses_config_set(project_name, region, config), + :ok <- configure_ses_events(config_set, topic_arn, region, config) do + result_config = %{ + "aws_region" => region, + "aws_sns_topic_arn" => topic_arn, + "aws_sqs_queue_url" => queue_url, + "aws_sqs_queue_arn" => queue_arn, + "aws_sqs_dlq_url" => dlq_url, + "aws_ses_configuration_set" => config_set, + "sqs_polling_interval_ms" => "5000" + } + + Logger.info("[AWS Setup] ✅ Infrastructure setup completed successfully!") + {:ok, result_config} + else + {:error, step, reason} = error -> + Logger.error("[AWS Setup] ❌ Failed at step: #{step}") + Logger.error("[AWS Setup] Reason: #{reason}") + error + end + end + + # Private functions + + defp get_account_id(config) do + Logger.info("[AWS Setup] [1/9] Getting AWS Account ID...") + + case STS.get_caller_identity() |> ExAws.request(config) do + {:ok, %{body: body}} when is_map(body) -> + # Handle sweet_xml parsed response (atom keys) + account_id = body[:account] || body["account"] + + if account_id do + Logger.info("[AWS Setup] ✓ Account ID: #{account_id}") + {:ok, account_id} + else + {:error, "get_account_id", "Could not parse account ID from response: #{inspect(body)}"} + end + + {:error, reason} -> + {:error, "get_account_id", "AWS API error: #{inspect(reason)}"} + end + end + + defp create_dlq(project_name, account_id, config) do + Logger.info("[AWS Setup] [2/9] Creating Dead Letter Queue...") + dlq_name = "#{project_name}-email-dlq" + + case SQS.create_queue(dlq_name, + visibility_timeout: "60", + message_retention_period: "1209600", + sqs_managed_sse_enabled: "true" + ) + |> ExAws.request(config) do + {:ok, %{body: body}} -> + dlq_url = get_queue_url_from_response(body) + region = config[:region] + dlq_arn = "arn:aws:sqs:#{region}:#{account_id}:#{dlq_name}" + + Logger.info("[AWS Setup] ✓ DLQ Created") + Logger.info("[AWS Setup] URL: #{dlq_url}") + Logger.info("[AWS Setup] ARN: #{dlq_arn}") + {:ok, dlq_url, dlq_arn} + + {:error, {:http_error, 400, %{body: body}}} when is_binary(body) -> + if String.contains?(body, "QueueAlreadyExists") do + handle_existing_queue(dlq_name, account_id, config, "DLQ") + else + {:error, "create_dlq", body} + end + + {:error, reason} -> + {:error, "create_dlq", inspect(reason)} + end + end + + defp set_dlq_policy(dlq_url, dlq_arn, account_id, config) do + Logger.info("[AWS Setup] [3/9] Setting DLQ policy...") + + policy = + Jason.encode!(%{ + "Version" => "2012-10-17", + "Id" => "__default_policy_ID", + "Statement" => [ + %{ + "Sid" => "__owner_statement", + "Effect" => "Allow", + "Principal" => %{"AWS" => "arn:aws:iam::#{account_id}:root"}, + "Action" => "SQS:*", + "Resource" => dlq_arn + } + ] + }) + + case SQS.set_queue_attributes(dlq_url, policy: policy) |> ExAws.request(config) do + {:ok, _} -> + Logger.info("[AWS Setup] ✓ DLQ Policy set") + :ok + + {:error, reason} -> + {:error, "set_dlq_policy", inspect(reason)} + end + end + + defp create_sns_topic(project_name, config) do + Logger.info("[AWS Setup] [4/9] Creating SNS Topic...") + topic_name = "#{project_name}-email-events" + + case SNS.create_topic(topic_name) |> ExAws.request(config) do + {:ok, %{body: body}} -> + topic_arn = body[:topic_arn] || body["TopicArn"] || body["topic_arn"] + + Logger.info("[AWS Setup] ✓ SNS Topic Created/Found") + Logger.info("[AWS Setup] ARN: #{topic_arn}") + {:ok, topic_arn} + + {:error, reason} -> + {:error, "create_sns_topic", inspect(reason)} + end + end + + defp create_main_queue(project_name, account_id, dlq_arn, config) do + Logger.info("[AWS Setup] [5/9] Creating Main Queue with DLQ redrive policy...") + queue_name = "#{project_name}-email-queue" + + redrive_policy = + Jason.encode!(%{ + "deadLetterTargetArn" => dlq_arn, + "maxReceiveCount" => 3 + }) + + case SQS.create_queue(queue_name, + visibility_timeout: "600", + message_retention_period: "1209600", + receive_message_wait_time_seconds: "20", + redrive_policy: redrive_policy, + sqs_managed_sse_enabled: "true" + ) + |> ExAws.request(config) do + {:ok, %{body: body}} -> + queue_url = get_queue_url_from_response(body) + region = config[:region] + queue_arn = "arn:aws:sqs:#{region}:#{account_id}:#{queue_name}" + + Logger.info("[AWS Setup] ✓ Main Queue Created") + Logger.info("[AWS Setup] URL: #{queue_url}") + Logger.info("[AWS Setup] ARN: #{queue_arn}") + {:ok, queue_url, queue_arn} + + {:error, {:http_error, 400, %{body: body}}} when is_binary(body) -> + if String.contains?(body, "QueueAlreadyExists") do + handle_existing_queue(queue_name, account_id, config, "Main Queue") + else + {:error, "create_main_queue", body} + end + + {:error, reason} -> + {:error, "create_main_queue", inspect(reason)} + end + end + + defp set_queue_policy(queue_url, queue_arn, topic_arn, account_id, config) do + Logger.info("[AWS Setup] [6/9] Setting Main Queue policy to allow SNS and account access...") + + policy = + Jason.encode!(%{ + "Version" => "2012-10-17", + "Id" => "sqs-policy", + "Statement" => [ + %{ + "Sid" => "AllowSNSPublish", + "Effect" => "Allow", + "Principal" => %{"Service" => "sns.amazonaws.com"}, + "Action" => "SQS:SendMessage", + "Resource" => queue_arn, + "Condition" => %{"ArnEquals" => %{"aws:SourceArn" => topic_arn}} + }, + %{ + "Sid" => "AllowAccountAccess", + "Effect" => "Allow", + "Principal" => %{"AWS" => "arn:aws:iam::#{account_id}:root"}, + "Action" => [ + "SQS:ReceiveMessage", + "SQS:DeleteMessage", + "SQS:GetQueueAttributes", + "SQS:SendMessage" + ], + "Resource" => queue_arn + } + ] + }) + + case SQS.set_queue_attributes(queue_url, policy: policy) |> ExAws.request(config) do + {:ok, _} -> + Logger.info("[AWS Setup] ✓ Main Queue Policy set") + :ok + + {:error, reason} -> + {:error, "set_queue_policy", inspect(reason)} + end + end + + defp subscribe_sqs_to_sns(topic_arn, queue_arn, config) do + Logger.info("[AWS Setup] [7/9] Creating SNS subscription to SQS...") + + case SNS.subscribe(topic_arn, "sqs", queue_arn) |> ExAws.request(config) do + {:ok, %{body: body}} -> + sub_arn = + body[:subscription_arn] || body["SubscriptionArn"] || body["subscription_arn"] || + "confirmed" + + Logger.info("[AWS Setup] ✓ SNS → SQS Subscription created") + + if sub_arn && sub_arn != "pending confirmation" do + Logger.info("[AWS Setup] Subscription ARN: #{sub_arn}") + end + + {:ok, sub_arn} + + {:error, _reason} -> + Logger.info("[AWS Setup] ℹ️ Subscription may already exist") + {:ok, "existing"} + end + end + + defp create_ses_config_set(project_name, _region, config) do + Logger.info("[AWS Setup] [8/9] Creating SES Configuration Set...") + config_set_name = "#{project_name}-emailing" + + alias PhoenixkitEu.AWS.SESv2 + + case SESv2.create_configuration_set(config_set_name, config) do + {:ok, ^config_set_name} -> + Logger.info("[AWS Setup] ✓ SES Configuration Set created") + Logger.info("[AWS Setup] Name: #{config_set_name}") + {:ok, config_set_name} + + {:error, reason} -> + Logger.error("[AWS Setup] ❌ Failed to create SES Configuration Set") + Logger.error("[AWS Setup] Reason: #{inspect(reason)}") + {:error, "create_ses_config_set", reason} + end + end + + defp configure_ses_events(config_set, topic_arn, _region, config) do + Logger.info("[AWS Setup] [9/9] Configuring SES event tracking to SNS...") + + alias PhoenixkitEu.AWS.SESv2 + + case SESv2.create_configuration_set_event_destination( + config_set, + "email-events-to-sns", + topic_arn, + config + ) do + :ok -> + Logger.info("[AWS Setup] ✓ SES Event Tracking configured") + + Logger.info( + "[AWS Setup] Events: SEND, REJECT, BOUNCE, COMPLAINT, DELIVERY, OPEN, CLICK, RENDERING_FAILURE" + ) + + Logger.info("[AWS Setup] Destination: #{topic_arn}") + :ok + + {:error, reason} -> + Logger.error("[AWS Setup] ❌ Failed to configure SES event tracking") + Logger.error("[AWS Setup] Reason: #{inspect(reason)}") + {:error, "configure_ses_events", reason} + end + end + + # Helper functions + + defp get_queue_url_from_response(body) when is_map(body) do + body[:queue_url] || body["QueueUrl"] || body["queue_url"] + end + + defp handle_existing_queue(queue_name, account_id, config, queue_type) do + case SQS.get_queue_url(queue_name) |> ExAws.request(config) do + {:ok, %{body: body}} -> + queue_url = get_queue_url_from_response(body) + region = config[:region] + queue_arn = "arn:aws:sqs:#{region}:#{account_id}:#{queue_name}" + Logger.info("[AWS Setup] ✓ #{queue_type} Found (already exists)") + Logger.info("[AWS Setup] URL: #{queue_url}") + {:ok, queue_url, queue_arn} + + {:error, reason} -> + {:error, "get_existing_queue", "Failed to get existing #{queue_type}: #{inspect(reason)}"} + end + end + + defp sanitize_project_name(name) do + name + |> String.downcase() + |> String.replace(~r/[^a-z0-9-]/, "-") + |> String.trim("-") + end +end diff --git a/scripts/init-container.sh b/scripts/init-container.sh new file mode 100755 index 000000000..efa4b7072 --- /dev/null +++ b/scripts/init-container.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# PhoenixKit Container Initialization Script +# Run after container creation: docker exec phoenix_kit /app/scripts/init-container.sh + +set -e + +echo "=== PhoenixKit Container Initialization ===" + +# Install required packages +echo "Installing packages..." +apt-get update -qq +apt-get install -y -qq tmux git curl lsof net-tools + +# Install Node.js and Claude Code +if ! command -v node &> /dev/null; then + echo "Installing Node.js..." + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y -qq nodejs +fi + +if ! command -v claude &> /dev/null; then + echo "Installing Claude Code..." + curl -fsSL https://claude.ai/install.sh | bash && export PATH="/root/.local/bin:$PATH" +fi + +# Create OSC52 copy script for tmux clipboard passthrough +echo "Setting up OSC52 clipboard..." +cat > /usr/local/bin/osc52-copy.sh << 'EOFCOPY' +#!/bin/bash +# OSC 52 clipboard copy with DCS passthrough for nested tmux +read input +encoded=$(echo -n "$input" | base64 | tr -d '\n') + +# Check if we're in a tmux session +if [ -n "$TMUX" ]; then + # Use DCS passthrough for nested tmux + printf '\ePtmux;\e\e]52;c;%s\a\e\\' "$encoded" +else + # Direct OSC 52 + printf '\e]52;c;%s\a' "$encoded" +fi +EOFCOPY +chmod +x /usr/local/bin/osc52-copy.sh + +# Setup tmux config if not exists +if [ ! -f /root/.tmux.conf ]; then + echo "Setting up tmux configuration..." + cat > /root/.tmux.conf << 'EOFTMUX' +# PhoenixKit Container Tmux Config + +# Container prefix: Ctrl-B (different from host Ctrl-A) +set -g prefix C-b +bind C-b send-prefix + +# General settings +set -g mouse on +set -g base-index 1 +setw -g pane-base-index 1 +set -g history-limit 50000 +set -g display-time 4000 +set -s escape-time 0 + +# Terminal colors +set -g default-terminal "screen-256color" +set -ga terminal-overrides ",*256col*:Tc" + +# Key bindings +bind | split-window -h -c "#{pane_current_path}" +bind - split-window -v -c "#{pane_current_path}" +bind c new-window -c "#{pane_current_path}" +bind h select-pane -L +bind j select-pane -D +bind k select-pane -U +bind l select-pane -R +bind x kill-pane +bind z resize-pane -Z + +# Vi mode +setw -g mode-keys vi +bind-key -T copy-mode-vi 'v' send -X begin-selection +bind-key -T copy-mode-vi 'y' send -X copy-pipe-and-cancel "/usr/local/bin/osc52-copy.sh" +bind-key -T copy-mode-vi MouseDragEnd1Pane send -X copy-pipe-and-cancel "/usr/local/bin/osc52-copy.sh" +bind-key -T copy-mode MouseDragEnd1Pane send -X copy-pipe-and-cancel "/usr/local/bin/osc52-copy.sh" +bind Y run-shell "tmux save-buffer - | /usr/local/bin/osc52-copy.sh" + +# OSC 52 clipboard support +set -g set-clipboard on +set -g allow-passthrough on +set -ag terminal-overrides ",xterm*:Ms=\\E]52;c;%p2%s\\7" +set -ag terminal-overrides ",screen*:Ms=\\E]52;c;%p2%s\\7" +set -ag terminal-overrides ",tmux*:Ms=\\E]52;c;%p2%s\\7" + +# Status bar +set -g status-position bottom +set -g status-style bg=colour234,fg=colour137 +set -g status-left '#[fg=colour233,bg=colour245,bold] #{session_name} ' +set -g status-right '#[fg=colour233,bg=colour241,bold] %H:%M ' +setw -g window-status-format ' #I:#W ' +setw -g window-status-current-format '#[fg=colour233,bg=colour81,bold] #I:#W ' +EOFTMUX +fi + +# Setup Logger filter for TLS warnings +HYDROFORCE_DIR="/root/projects/hydroforce" +if [ -d "$HYDROFORCE_DIR" ]; then + echo "Setting up Logger filter for TLS warnings..." + + FILTER_FILE="$HYDROFORCE_DIR/lib/phoenixkit_hello_world/logger_filter.ex" + if [ ! -f "$FILTER_FILE" ]; then + cat > "$FILTER_FILE" << 'EOFFILTER' +defmodule PhoenixkitHelloWorld.LoggerFilter do + @moduledoc """ + Logger filter to suppress noise from bot scans on exposed debug ports. + """ + + def filter(%{msg: {:string, msg}}, _opts) do + if String.contains?(to_string(msg), "TLS received on a clear channel") do + :stop + else + :ignore + end + end + + def filter(%{msg: {:report, %{msg: msg}}}, _opts) when is_binary(msg) do + if String.contains?(msg, "TLS received on a clear channel") do + :stop + else + :ignore + end + end + + def filter(_event, _opts), do: :ignore +end +EOFFILTER + fi + + # Add config if not present + if ! grep -q "LoggerFilter" "$HYDROFORCE_DIR/config/dev.exs" 2>/dev/null; then + cat >> "$HYDROFORCE_DIR/config/dev.exs" << 'EOFCONFIG' + +# Filter out TLS-on-clear-channel warnings from bots scanning port 4002 +config :logger, :default_handler, + filters: [ + tls_warning_filter: {&PhoenixkitHelloWorld.LoggerFilter.filter/2, []} + ] +EOFCONFIG + fi +fi + +# Setup SSH for GitHub (port 443) +mkdir -p /root/.ssh +if [ ! -f /root/.ssh/config ]; then + cat > /root/.ssh/config << 'EOFSSH' +Host github.com + Hostname ssh.github.com + Port 443 + User git +EOFSSH + chmod 600 /root/.ssh/config +fi + +echo "=== Initialization complete ===" +echo "" +echo "Next steps:" +echo " 1. Generate SSH key if needed: ssh-keygen -t ed25519 -C 'phoenix_kit@laisk'" +echo " 2. Add key to GitHub: cat /root/.ssh/id_ed25519.pub" +echo " 3. Clone hydroforce: git clone git@github.com:timujinne/dev.enter-t.net-Hydroforce.git /root/projects/hydroforce" +echo " 4. Start tmux: tmux new -s LAISK" diff --git a/scripts/phoenix_kit_with_google_fix.tar.gz b/scripts/phoenix_kit_with_google_fix.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..a7b904748121ebfbdec37a36d29f082be371ccca GIT binary patch literal 592967 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

v7tou_-_lv^u#keq9)=D9X>~BNcA|)-|&XXMuQ^ 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

qb!aFr&{lSS?c+~Blp=QWr)8wosLe$ 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%0DdSIoG1(hg8NJm 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±AiklNub?I;h_Q7k-Yc18 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 z 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

t;_GH>GQ7z$drszM2xx13~P5ZJd2$crz@ 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 literal 0 HcmV?d00001 diff --git a/scripts/phoenixkit-oauth-bugs-and-fixes.md b/scripts/phoenixkit-oauth-bugs-and-fixes.md new file mode 100644 index 000000000..6aafa6281 --- /dev/null +++ b/scripts/phoenixkit-oauth-bugs-and-fixes.md @@ -0,0 +1,785 @@ +# PhoenixKit OAuth Implementation Issues and Fixes + +**Document Version**: 1.0 +**Date**: October 29, 2025 +**PhoenixKit Version**: 1.4.6 +**Author**: Beamlab Development Team + +## Executive Summary + +This document details critical bugs discovered in PhoenixKit v1.4.6's OAuth implementation that prevent Google OAuth (and potentially other OAuth providers) from functioning correctly. Two major issues were identified and **fixed directly in the PhoenixKit library**. These fixes eliminate the need for application-level workarounds. + +**Status**: ✅ Fixed in PhoenixKit library (local copy) - Tested and working +**Configuration Required**: `oauth_base_url` setting (legitimate PhoenixKit config for proxy deployments) + +--- + +## Issue #1: Ueberauth Base Path Not Preserved During Configuration + +### Problem Description + +PhoenixKit's OAuth routes are located at `/phoenix_kit/users/auth/:provider`, but the Ueberauth plugin defaults to looking for routes at `/auth/:provider`. When PhoenixKit's `OAuthConfig.configure_providers()` function runs at application startup, it **overwrites the entire Ueberauth configuration** without preserving the `base_path` setting. + +### Root Cause + +**File**: `deps/phoenix_kit/lib/phoenix_kit/users/oauth_config.ex` +**Function**: `configure_ueberauth_base/0` (lines 58-75) + +```elixir +defp configure_ueberauth_base do + providers = build_provider_list() + + config = [ + providers: providers # <-- ONLY sets providers, loses base_path! + ] + + # Always update Ueberauth configuration, even if providers list is empty + # This ensures Ueberauth has a valid configuration at all times + Application.put_env(:ueberauth, Ueberauth, config) # <-- OVERWRITES entire config + + if providers != %{} do + Logger.debug("OAuth: Configured Ueberauth with providers: #{inspect(Map.keys(providers))}") + else + Logger.debug("OAuth: Configured Ueberauth with no active providers") + end +end +``` + +### Impact + +When a user attempts to authenticate via OAuth: +1. User clicks "Sign in with Google" +2. Request goes to `/phoenix_kit/users/auth/google` +3. Ueberauth plug processes the request but expects base path `/auth` (not `/phoenix_kit/users/auth`) +4. Ueberauth fails to process the request (`conn.state == :unset`) +5. PhoenixKit's OAuth controller shows error: `"Ueberauth plugin did not process request for provider"` + +**Error in logs**: +``` +[error] PhoenixKit OAuth: Ueberauth plugin did not process request for provider. +Check if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables are set correctly. +``` + +This error message is **misleading** because the credentials ARE configured correctly - the issue is the base_path mismatch. + +### Observed in Source Code + +**File**: `deps/phoenix_kit/lib/phoenix_kit_web/users/oauth.ex` (lines 107-123) + +```elixir +# Check if Ueberauth plug has already sent a response (e.g., a redirect) +# If response was already sent by Ueberauth, halt() to stop further processing +if conn.state != :unset do + # Response already sent by Ueberauth (e.g., redirect to OAuth provider) + halt(conn) +else + # No response sent - Ueberauth couldn't process the request + # This can happen if provider configuration is missing or invalid + Logger.error( + "PhoenixKit OAuth: Ueberauth plugin did not process request for provider. Check if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables are set correctly." + ) + + conn + |> put_flash(:error, "OAuth authentication unavailable. The provider credentials are not configured. Please contact your administrator or use another sign-in method.") + |> redirect(to: Routes.path("/users/log-in")) +end +``` + +The `conn.state != :unset` check fails because Ueberauth never processes the request due to base_path mismatch. + +### Our Workaround + +**File**: `lib/beamlab/application.ex` + +Added a function that runs **after** PhoenixKit initialization to restore the base_path: + +```elixir +defp fix_ueberauth_base_path do + require Logger + + current_config = Application.get_env(:ueberauth, Ueberauth, []) + + # Only set base_path if it's not already set + if !Keyword.has_key?(current_config, :base_path) do + updated_config = Keyword.put(current_config, :base_path, "/phoenix_kit/users/auth") + Application.put_env(:ueberauth, Ueberauth, updated_config) + Logger.debug("OAuth: Set Ueberauth base_path to /phoenix_kit/users/auth") + end +end +``` + +Called in the startup sequence: + +```elixir +try do + PhoenixKit.Users.OAuthConfig.configure_providers() + fix_github_oauth_module_name() + fix_ueberauth_base_path() # <-- Restore base_path after PhoenixKit overwrites it +rescue + error -> + require Logger + Logger.warning("Failed to configure OAuth providers on startup: #{inspect(error)}") +end +``` + +**File**: `config/runtime.exs` + +Also added explicit OAuth base URL configuration: + +```elixir +config :phoenix_kit, + oauth_base_url: "https://#{app_config.phx_host}" +``` + +### Recommended Fix for PhoenixKit + +**File**: `lib/phoenix_kit/users/oauth_config.ex` +**Function**: `configure_ueberauth_base/0` + +```elixir +defp configure_ueberauth_base do + providers = build_provider_list() + + # Get current config to preserve any existing settings + current_config = Application.get_env(:ueberauth, Ueberauth, []) + + # Preserve base_path if it exists, or set default based on PhoenixKit URL prefix + base_path = Keyword.get(current_config, :base_path) || get_oauth_base_path() + + config = [ + base_path: base_path, # <-- PRESERVE OR SET base_path + providers: providers + ] + + Application.put_env(:ueberauth, Ueberauth, config) + + if providers != %{} do + Logger.debug("OAuth: Configured Ueberauth with providers: #{inspect(Map.keys(providers))} at base_path: #{base_path}") + else + Logger.debug("OAuth: Configured Ueberauth with no active providers") + end +end + +# Helper to get OAuth base path from PhoenixKit URL prefix +defp get_oauth_base_path do + url_prefix = PhoenixKit.Config.get_url_prefix() + + case url_prefix do + "" -> "/users/auth" + prefix -> "#{prefix}/users/auth" + end +end +``` + +--- + +## Issue #2: Struct Field Access Using Bracket Notation + +### Problem Description + +PhoenixKit's `extract_oauth_data/1` function attempts to access fields in the `Ueberauth.Auth.Credentials` struct using bracket notation (`credentials[:token]`), which is **invalid in Elixir**. Structs do not implement the Access behaviour unless explicitly defined. + +### Root Cause + +**File**: `deps/phoenix_kit/lib/phoenix_kit/users/oauth.ex` +**Function**: `extract_oauth_data/1` (lines 104-117) + +```elixir +defp extract_oauth_data(%Ueberauth.Auth{} = auth) do + %{ + provider: to_string(auth.provider), + provider_uid: to_string(auth.uid), + email: auth.info.email, + first_name: auth.info.first_name, + last_name: auth.info.last_name, + image: auth.info.image, + access_token: auth.credentials[:token], # <-- BUG: Bracket notation on struct + refresh_token: auth.credentials[:refresh_token], # <-- BUG: Bracket notation on struct + token_expires_at: get_token_expires_at(auth.credentials), + raw_info: auth.extra[:raw_info] # <-- BUG: Bracket notation on struct + } +end +``` + +### Impact + +After successful OAuth authentication with Google, when the callback is processed: + +**Error**: +``` +[error] ** (UndefinedFunctionError) function Ueberauth.Auth.Credentials.fetch/2 is undefined +(Ueberauth.Auth.Credentials does not implement the Access behaviour + +You can use the "struct.field" syntax to access struct fields. You can also use Access.key!/1 +to access struct fields dynamically inside get_in/put_in/update_in) + (ueberauth 0.10.8) Ueberauth.Auth.Credentials.fetch(%Ueberauth.Auth.Credentials{...}, :token) + (elixir 1.19.1) lib/access.ex:326: Access.get/3 + (phoenix_kit 1.4.6) lib/phoenix_kit/users/oauth.ex:112: PhoenixKit.Users.OAuth.extract_oauth_data/1 + (phoenix_kit 1.4.6) lib/phoenix_kit/users/oauth.ex:23: PhoenixKit.Users.OAuth.handle_oauth_callback/2 + (phoenix_kit 1.4.6) lib/phoenix_kit_web/users/oauth.ex:156: PhoenixKitWeb.Users.OAuth.callback/2 +``` + +The OAuth flow **completely fails** at the callback stage, preventing users from logging in. + +### Why This Happens + +In Elixir, bracket notation (`struct[:key]`) only works for: +- **Maps**: `%{key: "value"}[:key]` ✅ +- **Keyword lists**: `[key: "value"][:key]` ✅ +- **Structs with Access behaviour**: Custom implementation required ❌ + +Structs use **dot notation** by default: +- **Correct**: `struct.field` ✅ +- **Incorrect**: `struct[:field]` ❌ + +The `Ueberauth.Auth.Credentials` struct is defined as: + +```elixir +defmodule Ueberauth.Auth.Credentials do + @type t :: %__MODULE__{ + token: String.t() | nil, + refresh_token: String.t() | nil, + token_type: String.t() | nil, + secret: String.t() | nil, + expires: boolean, + expires_at: integer | nil, + scopes: [String.t()], + other: map + } + + defstruct token: nil, + refresh_token: nil, + token_type: nil, + secret: nil, + expires: false, + expires_at: nil, + scopes: [], + other: %{} +end +``` + +It does **NOT** implement `Access` behaviour, so bracket notation fails. + +### Our Workaround + +Created a patched module that correctly accesses struct fields: + +**File**: `lib/beamlab/phoenix_kit_oauth_patch.ex` + +```elixir +defmodule Beamlab.PhoenixKitOAuthPatch do + @moduledoc """ + Patches PhoenixKit v1.4.6 OAuth bug where it tries to access struct fields using bracket notation. + + Bug: PhoenixKit.Users.OAuth.extract_oauth_data/1 uses `auth.credentials[:token]` + Fix: Use `auth.credentials.token` instead (dot notation for structs) + + This module can be removed once PhoenixKit fixes this issue. + """ + + alias PhoenixKit.RepoHelper, as: Repo + alias PhoenixKit.Users.OAuth, as: PhoenixKitOAuth + + require Logger + + def handle_oauth_callback(%Ueberauth.Auth{} = auth, opts \\ []) do + Logger.debug("Beamlab OAuth: Handling callback for provider: #{auth.provider}") + + # Extract OAuth data with proper struct field access (FIXED) + oauth_data = extract_oauth_data_fixed(auth) + + track_geolocation = Keyword.get(opts, :track_geolocation, false) + ip_address = Keyword.get(opts, :ip_address) + referral_code = Keyword.get(opts, :referral_code) + + # Use PhoenixKit's existing transaction-based logic + Repo.transaction(fn -> + with {:ok, user, _status} <- + PhoenixKitOAuth.find_or_create_user(oauth_data, track_geolocation, ip_address), + {:ok, _provider} <- PhoenixKitOAuth.link_oauth_provider(user, oauth_data), + :ok <- maybe_process_referral_code(user, referral_code) do + user + else + {:error, reason} -> Repo.rollback(reason) + end + end) + end + + # FIXED: Use dot notation instead of bracket notation for struct field access + defp extract_oauth_data_fixed(%Ueberauth.Auth{} = auth) do + %{ + provider: to_string(auth.provider), + provider_uid: to_string(auth.uid), + email: auth.info.email, + first_name: auth.info.first_name, + last_name: auth.info.last_name, + image: auth.info.image, + # FIX: Use dot notation for struct fields instead of bracket notation + access_token: auth.credentials.token, # ✅ FIXED + refresh_token: auth.credentials.refresh_token, # ✅ FIXED + token_expires_at: get_token_expires_at(auth.credentials), + raw_info: get_raw_info(auth.extra) # ✅ FIXED + } + end + + # Handle raw_info which might be in different formats + defp get_raw_info(%{raw_info: raw_info}), do: raw_info + defp get_raw_info(_), do: %{} + + defp get_token_expires_at(%{expires_at: expires_at}) when is_integer(expires_at) do + DateTime.from_unix!(expires_at) + end + + defp get_token_expires_at(_), do: nil + + # Process referral code if provided + defp maybe_process_referral_code(_user, nil), do: :ok + + defp maybe_process_referral_code(user, referral_code) when is_binary(referral_code) do + if Code.ensure_loaded?(PhoenixKit.ReferralCodes) do + case PhoenixKit.ReferralCodes.process_referral_code(user, referral_code) do + {:ok, _} -> :ok + {:error, _} -> :ok # Don't fail the OAuth flow if referral code processing fails + end + else + :ok + end + end +end +``` + +Created custom OAuth controller to use the patched version: + +**File**: `lib/beamlab_web/controllers/oauth_controller.ex` + +```elixir +defmodule BeamlabWeb.OAuthController do + use BeamlabWeb, :controller + + alias PhoenixKit.Settings + alias PhoenixKit.Utils.Routes + alias PhoenixKitWeb.Users.Auth, as: UserAuth + + require Logger + + plug PhoenixKitWeb.Plugs.EnsureOAuthScheme + plug PhoenixKitWeb.Plugs.EnsureOAuthConfig + plug Ueberauth + + def request(conn, params) do + # Delegate to PhoenixKit's request handler (this part works fine) + PhoenixKitWeb.Users.OAuth.request(conn, params) + end + + def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do + Logger.debug("Beamlab OAuth callback for provider: #{auth.provider}") + + track_geolocation = Settings.get_boolean_setting("track_registration_geolocation", false) + ip_address = extract_ip_address(conn) + referral_code = get_session(conn, :oauth_referral_code) + return_to = get_session(conn, :oauth_return_to) + + opts = [ + track_geolocation: track_geolocation, + ip_address: ip_address, + referral_code: referral_code + ] + + # Use our PATCHED version instead of PhoenixKit's buggy one + case Beamlab.PhoenixKitOAuthPatch.handle_oauth_callback(auth, opts) do + {:ok, user} -> + Logger.info("Beamlab: OAuth authentication successful for user #{user.id}") + + conn + |> delete_session(:oauth_referral_code) + |> delete_session(:oauth_return_to) + |> put_flash(:info, "Successfully signed in with #{format_provider_name(auth.provider)}!") + |> UserAuth.log_in_user(user, return_to: return_to) + + {:error, %Ecto.Changeset{} = changeset} -> + Logger.warning("Beamlab: OAuth authentication failed: #{inspect(changeset.errors)}") + + conn + |> put_flash(:error, "Authentication failed. Please try again.") + |> redirect(to: Routes.path("/users/log-in")) + + {:error, reason} -> + Logger.error("Beamlab: OAuth authentication error: #{inspect(reason)}") + + conn + |> put_flash(:error, "Authentication failed. Please try again or use a different sign-in method.") + |> redirect(to: Routes.path("/users/log-in")) + end + end + + def callback(%{assigns: %{ueberauth_failure: failure}} = conn, _params) do + Logger.warning("Beamlab: OAuth authentication failure: #{inspect(failure)}") + + message = + case failure.errors do + [%{message: msg} | _] when is_binary(msg) -> "Authentication failed: #{msg}" + _ -> "Authentication failed. Please try again." + end + + conn + |> put_flash(:error, message) + |> redirect(to: Routes.path("/users/log-in")) + end + + def callback(conn, _params) do + Logger.error("Beamlab: Unexpected OAuth callback without auth or failure") + + conn + |> put_flash(:error, "Authentication failed. Please try again.") + |> redirect(to: Routes.path("/users/log-in")) + end + + defp extract_ip_address(conn) do + case Plug.Conn.get_peer_data(conn) do + %{address: {a, b, c, d}} when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) -> + "#{a}.#{b}.#{c}.#{d}" + + %{address: {a, b, c, d, e, f, g, h}} -> + parts = [a, b, c, d, e, f, g, h] + Enum.map_join(parts, ":", &Integer.to_string(&1, 16)) + + _ -> + nil + end + end + + defp format_provider_name(provider) when is_atom(provider) do + provider |> to_string() |> format_provider_name() + end + + defp format_provider_name("google"), do: "Google" + defp format_provider_name("apple"), do: "Apple" + defp format_provider_name("github"), do: "GitHub" + defp format_provider_name("facebook"), do: "Facebook" + defp format_provider_name(provider), do: String.capitalize(provider) +end +``` + +Override PhoenixKit routes in router: + +**File**: `lib/beamlab_web/router.ex` + +```elixir +# Override PhoenixKit OAuth routes to use our patched controller +# This fixes PhoenixKit v1.4.6 bug with struct field access +# Must be defined BEFORE phoenix_kit_routes() to take precedence +scope "/phoenix_kit" do + pipe_through :browser + + get "/users/auth/:provider", BeamlabWeb.OAuthController, :request + get "/users/auth/:provider/callback", BeamlabWeb.OAuthController, :callback +end + +phoenix_kit_routes() +``` + +### Recommended Fix for PhoenixKit + +**File**: `lib/phoenix_kit/users/oauth.ex` +**Function**: `extract_oauth_data/1` (lines 104-117) + +```elixir +defp extract_oauth_data(%Ueberauth.Auth{} = auth) do + %{ + provider: to_string(auth.provider), + provider_uid: to_string(auth.uid), + email: auth.info.email, + first_name: auth.info.first_name, + last_name: auth.info.last_name, + image: auth.info.image, + # FIXED: Use dot notation for struct fields + access_token: auth.credentials.token, # ✅ Changed from auth.credentials[:token] + refresh_token: auth.credentials.refresh_token, # ✅ Changed from auth.credentials[:refresh_token] + token_expires_at: get_token_expires_at(auth.credentials), + raw_info: get_raw_info(auth.extra) # ✅ Changed from auth.extra[:raw_info] + } +end + +# Add helper function to safely extract raw_info +defp get_raw_info(%{raw_info: raw_info}), do: raw_info +defp get_raw_info(_), do: %{} +``` + +--- + +## Additional Observations + +### GitHub OAuth Module Name Issue + +There's also a separate bug where PhoenixKit uses the wrong module name for GitHub OAuth. + +**Current PhoenixKit code** (hypothetical, based on our fix): +```elixir +{Ueberauth.Strategy.GitHub, opts} # ❌ Wrong - capital H +``` + +**Correct module name**: +```elixir +{Ueberauth.Strategy.Github, opts} # ✅ Correct - lowercase h +``` + +The `ueberauth_github` package uses `Github` (lowercase 'h'), not `GitHub` (capital 'H'). + +**Our workaround** in `lib/beamlab/application.ex`: + +```elixir +defp fix_github_oauth_module_name do + require Logger + + current_config = Application.get_env(:ueberauth, Ueberauth, []) + providers = Keyword.get(current_config, :providers, %{}) + + github_config = case providers do + providers when is_map(providers) -> Map.get(providers, :github) + providers when is_list(providers) -> Keyword.get(providers, :github) + _ -> nil + end + + case github_config do + {Ueberauth.Strategy.GitHub, opts} -> + Logger.debug("OAuth: Fixing GitHub module name (GitHub -> Github)") + + fixed_providers = case providers do + providers when is_map(providers) -> + Map.put(providers, :github, {Ueberauth.Strategy.Github, opts}) + providers when is_list(providers) -> + providers + |> Keyword.delete(:github) + |> Keyword.put(:github, {Ueberauth.Strategy.Github, opts}) + end + + Application.put_env(:ueberauth, Ueberauth, Keyword.put(current_config, :providers, fixed_providers)) + + # Also fix the OAuth strategy config + github_oauth_config = Application.get_env(:ueberauth, Ueberauth.Strategy.GitHub.OAuth, []) + if github_oauth_config != [] do + Application.put_env(:ueberauth, Ueberauth.Strategy.Github.OAuth, github_oauth_config) + Logger.debug("OAuth: Fixed GitHub OAuth strategy config") + end + + Logger.info("OAuth: GitHub module name fixed (Github with lowercase 'h')") + + _ -> + :ok + end +end +``` + +--- + +## Testing Performed + +### Test Environment +- **Phoenix**: 1.8.1 +- **Elixir**: 1.19.1 +- **PhoenixKit**: 1.4.6 +- **Ueberauth**: 0.10.8 +- **Ueberauth Google**: 0.12.1 +- **Ueberauth GitHub**: 0.8.3 + +### Test Cases + +1. ✅ **Google OAuth Login** - Successfully redirects to Google, receives callback, creates/logs in user +2. ✅ **New User Registration via OAuth** - Creates new user account with confirmed email +3. ✅ **Existing User OAuth Login** - Links OAuth provider to existing account +4. ✅ **Token Storage** - Access tokens and refresh tokens properly stored +5. ✅ **Session Management** - User logged in with proper session after OAuth + +### Before Fix +- ❌ Request phase: "Ueberauth plugin did not process request for provider" +- ❌ Callback phase: `UndefinedFunctionError` for `Ueberauth.Auth.Credentials.fetch/2` +- ❌ OAuth completely non-functional + +### After Fix +- ✅ Request phase: Proper redirect to OAuth provider +- ✅ Callback phase: User created/authenticated successfully +- ✅ OAuth fully functional + +--- + +## Recommendations for PhoenixKit Maintainers + +### Priority 1: Critical Bugs (Breaks OAuth entirely) + +1. **Fix struct field access in `extract_oauth_data/1`** + - Change `auth.credentials[:token]` → `auth.credentials.token` + - Change `auth.credentials[:refresh_token]` → `auth.credentials.refresh_token` + - Change `auth.extra[:raw_info]` → safe extraction with pattern matching + +2. **Preserve base_path in Ueberauth configuration** + - Don't overwrite entire config in `configure_ueberauth_base/0` + - Set base_path based on PhoenixKit URL prefix + - Log the base_path for debugging + +### Priority 2: Improvements + +3. **Better error messages** + - Don't suggest checking credentials when the issue is base_path mismatch + - Add debug logging for Ueberauth configuration state + - Check `conn.state` and provide specific error messages + +4. **Add tests for OAuth flow** + - Test that Ueberauth config includes base_path + - Test struct field access doesn't use bracket notation + - Test full OAuth callback flow with mock provider + +5. **Documentation** + - Document the base_path requirement + - Add troubleshooting guide for OAuth issues + - Clarify that credentials are stored in database, not environment variables + +### Priority 3: GitHub OAuth Module Name + +6. **Fix GitHub module name** + - Use `Ueberauth.Strategy.Github` (lowercase 'h') + - Not `Ueberauth.Strategy.GitHub` (capital 'H') + +--- + +## Impact Assessment + +### Without These Fixes +- **OAuth is completely broken** in PhoenixKit 1.4.6 +- Every project using PhoenixKit OAuth needs these workarounds +- Users cannot log in via Google, GitHub, or other OAuth providers +- Misleading error messages waste developer time + +### With These Fixes +- OAuth works out-of-the-box +- No application-level workarounds needed +- Better developer experience +- Clear error messages + +--- + +## Files Affected in Our Workaround + +1. `config/config.exs` - Added base_path to Ueberauth config +2. `config/runtime.exs` - Added oauth_base_url for PhoenixKit +3. `lib/beamlab/application.ex` - Added `fix_ueberauth_base_path()` and `fix_github_oauth_module_name()` +4. `lib/beamlab/phoenix_kit_oauth_patch.ex` - Patched OAuth callback handler +5. `lib/beamlab_web/controllers/oauth_controller.ex` - Custom OAuth controller using patch +6. `lib/beamlab_web/router.ex` - Override PhoenixKit OAuth routes + +**Total**: 6 files modified, ~300 lines of workaround code + +--- + +## Conclusion + +Both bugs are **critical** and **trivial to fix** in the PhoenixKit library itself. The fixes involve: +1. Using dot notation instead of bracket notation for structs (2 lines changed) +2. Preserving base_path when configuring Ueberauth (5 lines changed) + +These changes would eliminate the need for complex workarounds in every PhoenixKit project that uses OAuth. + +We recommend PhoenixKit maintainers: +1. Apply these fixes in the next patch release (v1.4.7) +2. Add integration tests for OAuth flows +3. Improve error messages to help developers troubleshoot issues + +--- + +## Contact + +For questions about this document or the issues described: +- **Project**: Beamlab (https://ddon-dev.beamlab.eu) +- **Date Discovered**: October 29, 2025 +- **PhoenixKit Version**: 1.4.6 + +--- + +## Appendix: Complete Error Stack Traces + +### Error 1: Base Path Mismatch + +``` +[info] GET /phoenix_kit/users/auth/google +[debug] Processing with PhoenixKitWeb.Users.OAuth.request/2 + Parameters: %{"provider" => "google"} + Pipelines: [:browser, :phoenix_kit_auto_setup] +[debug] PhoenixKit OAuth request for provider: google +[debug] PhoenixKit OAuth: Available providers: ["github", "google"] +[error] PhoenixKit OAuth: Ueberauth plugin did not process request for provider. Check if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables are set correctly. +[info] Sent 302 in 17ms +[info] GET /phoenix_kit/users/log-in +``` + +### Error 2: Struct Access with Bracket Notation + +``` +[error] ** (UndefinedFunctionError) function Ueberauth.Auth.Credentials.fetch/2 is undefined (Ueberauth.Auth.Credentials does not implement the Access behaviour + +You can use the "struct.field" syntax to access struct fields. You can also use Access.key!/1 to access struct fields dynamically inside get_in/put_in/update_in) + (ueberauth 0.10.8) Ueberauth.Auth.Credentials.fetch(%Ueberauth.Auth.Credentials{token: "ya29.A0ATi6K2uxgnBNuflZm7p5onlKOhq0iO70wLe5gyyc7glEwMfmbII2Q5cFBRXY_O_y06RCltXAXQvtAoP2zs5RVMLg5zuz5eqkV7R98528l3A6Ssh3Fp1RNYEyK3ckpGA0MCEGAn1yicTu2LsPJPRJVauM6AAeBUD0ei8u-QxkUZXeOctI3ClOtZGf-gxjSeiGz6GuMPdhZA-cTgV-K9bU6t0veEXXc20jqdx7eQ9TCb5HLgRNFTHIAngBYcIooI-bJgVMHLn87DTcHiYEKc4dQkzFT3cQpQaCgYKAVoSARUSFQHGX2MiTe4JghUQ08V7u7woRRSBgw0293", refresh_token: nil, token_type: "Bearer", secret: nil, expires: true, expires_at: 1761772247, scopes: ["https://www.googleapis.com/auth/userinfo.email", "openid"], other: %{}}, :token) + (elixir 1.19.1) lib/access.ex:326: Access.get/3 + (phoenix_kit 1.4.6) lib/phoenix_kit/users/oauth.ex:112: PhoenixKit.Users.OAuth.extract_oauth_data/1 + (phoenix_kit 1.4.6) lib/phoenix_kit/users/oauth.ex:23: PhoenixKit.Users.OAuth.handle_oauth_callback/2 + (phoenix_kit 1.4.6) lib/phoenix_kit_web/users/oauth.ex:156: PhoenixKitWeb.Users.OAuth.callback/2 +``` + +--- + +**End of Document** + +--- + +## Required Configuration: oauth_base_url + +### Not a Workaround - Legitimate PhoenixKit Configuration + +The `oauth_base_url` setting in `config/runtime.exs` is **NOT a workaround** for the bugs above. It's a legitimate PhoenixKit configuration option used by the `EnsureOAuthScheme` plug. + +**Purpose**: Ensures correct HTTPS redirect URIs when deploying behind proxies that don't forward protocol headers. + +**Configuration**: +```elixir +# config/runtime.exs +config :phoenix_kit, + oauth_base_url: "https://#{app_config.phx_host}" +``` + +**Why it's needed**: + +PhoenixKit's `EnsureOAuthScheme` plug checks in this order: +1. `X-Forwarded-Proto` header from nginx/proxy (preferred) +2. `oauth_base_url` config setting (fallback) +3. Endpoint URL config (last resort) + +Without this setting AND without proxy headers, OAuth redirect URIs would be incorrect: +- ❌ Without config: `http://domain.com:4000/phoenix_kit/users/auth/google/callback` +- ✅ With config: `https://domain.com/phoenix_kit/users/auth/google/callback` + +**This is standard configuration for production deployments**, not a bug fix. + +--- + +## Summary: What Was Fixed vs What Was Configured + +### Fixed in PhoenixKit Library (deps/phoenix_kit/) + +1. ✅ **Struct field access bug** - Changed bracket notation to dot notation +2. ✅ **Base path preservation** - Preserve `base_path` in Ueberauth config + +**Total changes**: ~15 lines in 2 files +**Impact**: Fixes OAuth for all PhoenixKit projects + +### Configured in Application (config/runtime.exs) + +1. ✅ **oauth_base_url** - Standard PhoenixKit configuration for proxy deployments + +**Total changes**: 3 lines +**Impact**: Ensures correct HTTPS URLs when proxy doesn't send headers + +### Test Results + +✅ Google OAuth login working +✅ Redirect URI correctly generated: `https://ddon-dev.beamlab.eu/phoenix_kit/users/auth/google/callback` +✅ User authentication and session creation successful +✅ No application-level workarounds required + diff --git a/scripts/run_agent.sh b/scripts/run_agent.sh new file mode 100755 index 000000000..349929b95 --- /dev/null +++ b/scripts/run_agent.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Run Claude Code agent in a new tmux pane with live output +# +# Usage: +# ./scripts/run_agent.sh "your prompt here" [options] +# +# Options: +# --tools "Read,Grep,Glob" Tools to allow (default: Read,Grep,Glob,Bash) +# --new-window Create new window instead of splitting current +# --window N Target specific window number +# +# Examples: +# ./scripts/run_agent.sh "find all TODOs" +# ./scripts/run_agent.sh "analyze migrations" --tools "Read,Grep" +# ./scripts/run_agent.sh "review code" --new-window + +set -e + +# Defaults +PROMPT="" +TOOLS="Read,Grep,Glob,Bash" +NEW_WINDOW=false +TARGET_WINDOW="" +SESSION="phoenixkit" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --tools) + TOOLS="$2" + shift 2 + ;; + --new-window) + NEW_WINDOW=true + shift + ;; + --window) + TARGET_WINDOW="$2" + shift 2 + ;; + --session) + SESSION="$2" + shift 2 + ;; + --help|-h) + echo "Usage: $0 'prompt' [options]" + echo "" + echo "Options:" + echo " --tools TOOLS Comma-separated tools (default: Read,Grep,Glob,Bash)" + echo " --new-window Create new window instead of splitting" + echo " --window N Target specific window number" + echo " --session NAME tmux session name (default: phoenixkit)" + echo "" + echo "Examples:" + echo " $0 'find all TODOs'" + echo " $0 'analyze code' --tools 'Read,Grep'" + echo " $0 'review changes' --new-window" + exit 0 + ;; + *) + if [ -z "$PROMPT" ]; then + PROMPT="$1" + fi + shift + ;; + esac +done + +if [ -z "$PROMPT" ]; then + echo "Error: No prompt provided" + echo "Usage: $0 'prompt' [options]" + echo "Run '$0 --help' for more options" + exit 1 +fi + +# Determine current window if not specified +if [ -z "$TARGET_WINDOW" ]; then + # Try to get current window from tmux + TARGET_WINDOW=$(tmux display-message -p '#{window_index}' 2>/dev/null || echo "") + if [ -z "$TARGET_WINDOW" ]; then + # Fallback: find window with claude running + TARGET_WINDOW=$(tmux list-panes -s -t "$SESSION" -F '#{window_index} #{pane_current_command}' 2>/dev/null | grep -m1 'claude' | awk '{print $1}' || echo "1") + fi +fi + +# Agent command +AGENT_CMD="echo '🤖 Agent starting...' && \ +echo '📋 Prompt: ${PROMPT}' && \ +echo '🔧 Tools: ${TOOLS}' && \ +echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' && \ +claude -p \"${PROMPT}\" --allowedTools \"${TOOLS}\" ; \ +echo '' && \ +echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' && \ +echo '✅ Agent finished. Press Enter to close.' && \ +read" + +if [ "$NEW_WINDOW" = true ]; then + # Create new window + WINDOW_NAME="Agent-$(date +%H%M%S)" + tmux new-window -t "$SESSION" -n "$WINDOW_NAME" "$AGENT_CMD" + echo "Agent launched in new window: $WINDOW_NAME" +else + # Split current/target window + tmux split-window -h -t "${SESSION}:${TARGET_WINDOW}" "$AGENT_CMD" + echo "Agent launched in ${SESSION}:${TARGET_WINDOW} (split pane)" +fi diff --git a/scripts/run_agent_with_output.sh b/scripts/run_agent_with_output.sh new file mode 100755 index 000000000..ad886b591 --- /dev/null +++ b/scripts/run_agent_with_output.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Run Claude agent with BOTH visual output in tmux AND result file for seamless retrieval +# +# Usage: +# ./scripts/run_agent_with_output.sh "prompt" [options] +# +# The script: +# 1. Creates a tmux pane for visual monitoring (user can watch progress) +# 2. Saves full output to a temp file +# 3. Returns the output file path for programmatic reading +# +# Options: +# --tools "Read,Grep" Allowed tools (default: Read,Grep,Glob,Bash) +# --new-window Create new window instead of splitting +# --json Output in JSON format +# --wait Wait for completion and print result + +set -e + +PROMPT="" +TOOLS="Read,Grep,Glob,Bash" +NEW_WINDOW=false +JSON_OUTPUT=false +WAIT_FOR_RESULT=false +SESSION="phoenixkit" +OUTPUT_DIR="/tmp/claude_agents" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --tools) TOOLS="$2"; shift 2 ;; + --new-window) NEW_WINDOW=true; shift ;; + --json) JSON_OUTPUT=true; shift ;; + --wait) WAIT_FOR_RESULT=true; shift ;; + --session) SESSION="$2"; shift 2 ;; + --help|-h) + echo "Usage: $0 'prompt' [options]" + echo "" + echo "Options:" + echo " --tools TOOLS Allowed tools (default: Read,Grep,Glob,Bash)" + echo " --new-window Create new tmux window" + echo " --json JSON output format" + echo " --wait Wait and print result when done" + echo "" + echo "Returns: Path to output file" + exit 0 + ;; + *) [ -z "$PROMPT" ] && PROMPT="$1"; shift ;; + esac +done + +[ -z "$PROMPT" ] && { echo "Error: No prompt"; exit 1; } + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# Generate unique output file +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +AGENT_ID="${TIMESTAMP}_$$" +OUTPUT_FILE="${OUTPUT_DIR}/agent_${AGENT_ID}.txt" +STATUS_FILE="${OUTPUT_DIR}/agent_${AGENT_ID}.status" +JSON_FILE="${OUTPUT_DIR}/agent_${AGENT_ID}.json" + +# Get current window +TARGET_WINDOW=$(tmux display-message -p '#{window_index}' 2>/dev/null || echo "4") + +# Prepare output format flag +OUTPUT_FLAG="" +if [ "$JSON_OUTPUT" = true ]; then + OUTPUT_FLAG="--output-format json" +fi + +# Escape prompt for shell +ESCAPED_PROMPT=$(printf '%s' "$PROMPT" | sed "s/'/'\\\\''/g") + +# Agent command that saves output to file +AGENT_CMD="( +echo 'STARTED' > '${STATUS_FILE}' +echo '🤖 Agent: ${AGENT_ID}' +echo '📋 Prompt: ${ESCAPED_PROMPT}' +echo '🔧 Tools: ${TOOLS}' +echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + +# Run claude and tee output to file +claude -p '${ESCAPED_PROMPT}' --allowedTools '${TOOLS}' ${OUTPUT_FLAG} 2>&1 | tee '${OUTPUT_FILE}' + +echo '' +echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' +echo '✅ Done. Output saved to: ${OUTPUT_FILE}' +echo 'COMPLETED' > '${STATUS_FILE}' + +# Keep pane open briefly for viewing +echo 'Press Enter to close...' +read -t 60 || true +)" + +# Launch in tmux +if [ "$NEW_WINDOW" = true ]; then + WINDOW_NAME="Agent-${AGENT_ID}" + tmux new-window -t "$SESSION" -n "$WINDOW_NAME" "$AGENT_CMD" +else + tmux split-window -h -t "${SESSION}:${TARGET_WINDOW}" "$AGENT_CMD" +fi + +# Output file path for caller +echo "AGENT_ID=${AGENT_ID}" +echo "OUTPUT_FILE=${OUTPUT_FILE}" +echo "STATUS_FILE=${STATUS_FILE}" + +# If --wait, poll for completion and print result +if [ "$WAIT_FOR_RESULT" = true ]; then + echo "Waiting for agent to complete..." + while [ ! -f "$STATUS_FILE" ] || [ "$(cat "$STATUS_FILE" 2>/dev/null)" != "COMPLETED" ]; do + sleep 2 + done + echo "" + echo "=== AGENT RESULT ===" + cat "$OUTPUT_FILE" +fi diff --git a/scripts/run_sdk_agent.py b/scripts/run_sdk_agent.py new file mode 100755 index 000000000..d9fa890a1 --- /dev/null +++ b/scripts/run_sdk_agent.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Run Claude Agent SDK with live output in tmux pane. + +Usage: + python scripts/run_sdk_agent.py "your prompt" [options] + +Options: + --tools Comma-separated tools (default: Read,Grep,Glob,Bash) + --new-window Create new tmux window instead of splitting + --window N Target specific window number + --session NAME tmux session name (default: phoenixkit) + +Examples: + python scripts/run_sdk_agent.py "analyze all migrations" + python scripts/run_sdk_agent.py "find security issues" --tools "Read,Grep,Glob" + python scripts/run_sdk_agent.py "refactor auth module" --new-window +""" + +import asyncio +import argparse +import subprocess +import sys +import os +import shlex +from datetime import datetime + +# Check if claude-agent-sdk is available +try: + from claude_agent_sdk import query, ClaudeAgentOptions + HAS_SDK = True +except ImportError: + HAS_SDK = False + + +def get_current_window(session: str = "phoenixkit") -> str: + """Get current tmux window index.""" + try: + result = subprocess.run( + ["tmux", "display-message", "-p", "#{window_index}"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except Exception: + pass + + # Fallback: find window with claude running + try: + result = subprocess.run( + ["tmux", "list-panes", "-s", "-t", session, "-F", "#{window_index} #{pane_current_command}"], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.strip().split('\n'): + if 'claude' in line: + return line.split()[0] + except Exception: + pass + + return "1" + + +def create_tmux_pane(session: str, window: str) -> str: + """Create a new tmux pane by splitting and return its ID.""" + result = subprocess.run( + ["tmux", "split-window", "-h", "-t", f"{session}:{window}", "-P", "-F", "#{pane_id}"], + capture_output=True, text=True + ) + return result.stdout.strip() + + +def create_tmux_window(session: str, name: str = None) -> str: + """Create a new tmux window and return its index.""" + if not name: + name = f"Agent-{datetime.now().strftime('%H%M%S')}" + result = subprocess.run( + ["tmux", "new-window", "-t", session, "-n", name, "-P", "-F", "#{window_index}"], + capture_output=True, text=True + ) + return result.stdout.strip() + + +def send_to_pane(pane_id: str, text: str): + """Send text to a tmux pane.""" + subprocess.run(["tmux", "send-keys", "-t", pane_id, text, "Enter"]) + + +async def run_with_sdk(prompt: str, tools: list[str], pane_id: str = None): + """Run agent using Claude Agent SDK.""" + print(f"🤖 Starting agent with SDK...") + print(f"📋 Prompt: {prompt}") + print(f"🔧 Tools: {', '.join(tools)}") + print("━" * 50) + + async for message in query( + prompt=prompt, + options=ClaudeAgentOptions(allowed_tools=tools) + ): + output = str(message) + print(output) + if pane_id: + send_to_pane(pane_id, output) + + print("━" * 50) + print("✅ Agent finished") + + +def run_with_cli(prompt: str, tools: list[str], session: str, window: str, new_window: bool = False): + """Run agent using claude CLI in tmux.""" + tools_str = ",".join(tools) + + # Escape prompt for shell + escaped_prompt = prompt.replace('"', '\\"').replace("'", "'\\''") + + agent_cmd = f'''echo '🤖 Agent starting...' && \ +echo '📋 Prompt: {escaped_prompt}' && \ +echo '🔧 Tools: {tools_str}' && \ +echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' && \ +claude -p "{escaped_prompt}" --allowedTools "{tools_str}" ; \ +echo '' && \ +echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' && \ +echo '✅ Agent finished. Press Enter to close.' && \ +read''' + + if new_window: + window_name = f"Agent-{datetime.now().strftime('%H%M%S')}" + subprocess.run([ + "tmux", "new-window", "-t", session, "-n", window_name, agent_cmd + ]) + print(f"Agent launched in new window: {window_name}") + else: + subprocess.run([ + "tmux", "split-window", "-h", "-t", f"{session}:{window}", agent_cmd + ]) + print(f"Agent launched in {session}:{window} (split pane)") + + +def main(): + parser = argparse.ArgumentParser( + description="Run Claude agent with live tmux output", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s "analyze all migrations" + %(prog)s "find security issues" --tools "Read,Grep,Glob" + %(prog)s "refactor auth module" --new-window + %(prog)s "review code" --window 2 + """ + ) + parser.add_argument("prompt", help="The prompt for the agent") + parser.add_argument("--tools", default="Read,Grep,Glob,Bash", + help="Comma-separated list of allowed tools (default: Read,Grep,Glob,Bash)") + parser.add_argument("--new-window", action="store_true", + help="Run in a new tmux window") + parser.add_argument("--window", type=str, default=None, + help="Target tmux window number (default: current)") + parser.add_argument("--session", default="phoenixkit", + help="tmux session name (default: phoenixkit)") + parser.add_argument("--sdk", action="store_true", + help="Force SDK mode (run in current terminal, not tmux)") + + args = parser.parse_args() + tools = [t.strip() for t in args.tools.split(",")] + + # Determine target window + window = args.window if args.window else get_current_window(args.session) + + if args.sdk and HAS_SDK: + # Use SDK directly in current terminal + asyncio.run(run_with_sdk(args.prompt, tools)) + else: + # Use CLI in tmux pane/window + run_with_cli(args.prompt, tools, args.session, window, args.new_window) + + +if __name__ == "__main__": + main() diff --git a/scripts/sesv2.ex b/scripts/sesv2.ex new file mode 100644 index 000000000..f21e20462 --- /dev/null +++ b/scripts/sesv2.ex @@ -0,0 +1,111 @@ +defmodule PhoenixkitEu.AWS.SESv2 do + @moduledoc """ + AWS SES v2 API client for operations not supported by ExAws. + + Uses ExAws.Operation.RestQuery to make signed requests to SES v2 API. + """ + + require Logger + + @doc """ + Creates a SES configuration set. + + ## Parameters + - `name`: Name of the configuration set + - `config`: AWS configuration (access_key_id, secret_access_key, region) + + ## Returns + - `{:ok, name}` on success + - `{:error, reason}` on failure + """ + def create_configuration_set(name, config) do + # Use ExAws.Operation.JSON for SES v2 API + request = %ExAws.Operation.JSON{ + http_method: :post, + service: :ses, + path: "/v2/email/configuration-sets", + data: %{"ConfigurationSetName" => name}, + headers: [ + {"content-type", "application/json"} + ] + } + + case ExAws.request(request, config) do + {:ok, _} -> + {:ok, name} + + {:error, {:http_error, 409, _}} -> + # Already exists + {:ok, name} + + {:error, {:http_error, _code, %{body: body}}} when is_binary(body) -> + case Jason.decode(body) do + {:ok, %{"__type" => "AlreadyExistsException"}} -> {:ok, name} + {:ok, %{"message" => msg}} -> {:error, msg} + _ -> {:error, body} + end + + {:error, reason} -> + {:error, inspect(reason)} + end + end + + @doc """ + Creates an event destination for a configuration set. + + ## Parameters + - `config_set_name`: Name of the configuration set + - `destination_name`: Name of the event destination + - `topic_arn`: SNS topic ARN for events + - `config`: AWS configuration + + ## Returns + - `:ok` on success + - `{:error, reason}` on failure + """ + def create_configuration_set_event_destination(config_set_name, destination_name, topic_arn, config) do + data = %{ + "EventDestinationName" => destination_name, + "EventDestination" => %{ + "Enabled" => true, + "MatchingEventTypes" => [ + "SEND", "REJECT", "BOUNCE", "COMPLAINT", + "DELIVERY", "OPEN", "CLICK", "RENDERING_FAILURE" + ], + "SnsDestination" => %{ + "TopicArn" => topic_arn + } + } + } + + request = %ExAws.Operation.JSON{ + http_method: :post, + service: :ses, + path: "/v2/email/configuration-sets/#{URI.encode(config_set_name)}/event-destinations", + data: data, + headers: [ + {"content-type", "application/json"} + ] + } + + case ExAws.request(request, config) do + {:ok, _} -> + :ok + + {:error, {:http_error, 409, _}} -> + # Already exists + :ok + + {:error, {:http_error, _code, %{body: body}}} when is_binary(body) -> + case Jason.decode(body) do + {:ok, %{"__type" => "AlreadyExistsException"}} -> :ok + {:ok, %{"message" => msg}} -> + if String.contains?(msg, "already exists"), do: :ok, else: {:error, msg} + _ -> {:error, body} + end + + {:error, reason} -> + {:error, inspect(reason)} + end + end +end diff --git a/scripts/setup_aws_email_infrastructure.sh b/scripts/setup_aws_email_infrastructure.sh new file mode 100644 index 000000000..6798c0670 --- /dev/null +++ b/scripts/setup_aws_email_infrastructure.sh @@ -0,0 +1,425 @@ +#!/bin/bash +# +# AWS Email Infrastructure Setup Script +# ====================================== +# +# This script creates the complete AWS infrastructure for email event handling: +# - SNS Topic for email events +# - SQS Dead Letter Queue (DLQ) for failed messages +# - SQS Main Queue with DLQ redrive policy +# - SNS to SQS subscription +# - All necessary IAM policies +# +# Usage: +# 1. Configure the variables in the CONFIGURATION section below +# 2. Ensure AWS CLI is configured with proper credentials +# 3. Run: bash scripts/setup_aws_email_infrastructure.sh +# + +set -e # Exit on any error + +# ============================================================================ +# CONFIGURATION - EDIT THESE VALUES +# ============================================================================ + +# Project/Application name (used as prefix for resource names) +PROJECT_NAME="myapp" + +# AWS Region +AWS_REGION="eu-north-1" + +# Queue names (without URLs - will be generated) +MAIN_QUEUE_NAME="${PROJECT_NAME}-email-queue" +DLQ_NAME="${PROJECT_NAME}-email-dlq" + +# SNS Topic name +SNS_TOPIC_NAME="${PROJECT_NAME}-email-events" + +# SES Configuration Set name (for SES integration) +SES_CONFIG_SET_NAME="${PROJECT_NAME}-emailing" + +# Queue configurations +# Main Queue settings optimized for email event processing +MAIN_QUEUE_VISIBILITY_TIMEOUT=600 # 10 minutes (allows complex DB operations) +MAIN_QUEUE_MESSAGE_RETENTION=1209600 # 14 days (protects against extended outages) +MAIN_QUEUE_MAX_RECEIVE_COUNT=3 # Retries before sending to DLQ +MAIN_QUEUE_RECEIVE_WAIT_TIME=20 # Long polling time (reduces API calls) + +# Dead Letter Queue settings for failed messages +DLQ_VISIBILITY_TIMEOUT=60 # 1 minute (manual processing) +DLQ_MESSAGE_RETENTION=1209600 # 14 days (allows troubleshooting) + +# SQS Polling interval for your application (in milliseconds) +SQS_POLLING_INTERVAL=5000 # 5 seconds + +# ============================================================================ +# SCRIPT EXECUTION - DO NOT EDIT BELOW THIS LINE +# ============================================================================ + +# Check dependencies +echo "Checking dependencies..." +if ! command -v aws &> /dev/null; then + echo "Error: AWS CLI is not installed. Please install it first." + exit 1 +fi + +if ! command -v jq &> /dev/null; then + echo "Error: jq is not installed. Please install it first." + exit 1 +fi + +echo "✓ All dependencies found" +echo "" + +echo "==============================================" +echo "AWS Email Infrastructure Setup" +echo "==============================================" +echo "" +echo "Configuration:" +echo " Project: $PROJECT_NAME" +echo " Region: $AWS_REGION" +echo " Main Queue: $MAIN_QUEUE_NAME" +echo " DLQ: $DLQ_NAME" +echo " SNS Topic: $SNS_TOPIC_NAME" +echo "" +echo "Queue Settings (Optimized for Email Events):" +echo " Main Queue Retention: 14 days (protects against outages)" +echo " Main Queue Visibility: 10 minutes (allows complex processing)" +echo " DLQ Retention: 14 days (allows troubleshooting)" +echo "" +echo "Starting setup..." +echo "" + +# Get AWS Account ID +echo "[1/9] Getting AWS Account ID..." +ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +echo " ✓ Account ID: $ACCOUNT_ID" +echo "" + +# Create Dead Letter Queue (DLQ) +echo "[2/9] Creating Dead Letter Queue..." +DLQ_URL=$(aws sqs create-queue \ + --queue-name "$DLQ_NAME" \ + --attributes "{ + \"VisibilityTimeout\": \"$DLQ_VISIBILITY_TIMEOUT\", + \"MessageRetentionPeriod\": \"$DLQ_MESSAGE_RETENTION\", + \"SqsManagedSseEnabled\": \"true\" + }" \ + --region "$AWS_REGION" \ + --output text \ + --query 'QueueUrl' 2>/dev/null || \ + aws sqs get-queue-url --queue-name "$DLQ_NAME" --region "$AWS_REGION" --output text --query 'QueueUrl') + +DLQ_ARN="arn:aws:sqs:${AWS_REGION}:${ACCOUNT_ID}:${DLQ_NAME}" +echo " ✓ DLQ Created/Found" +echo " URL: $DLQ_URL" +echo " ARN: $DLQ_ARN" +echo "" + +# Set DLQ policy to allow access from account +echo "[3/9] Setting DLQ policy..." +DLQ_POLICY=$(cat </dev/null || \ + echo "arn:aws:sns:${AWS_REGION}:${ACCOUNT_ID}:${SNS_TOPIC_NAME}") + +echo " ✓ SNS Topic Created/Found" +echo " ARN: $SNS_TOPIC_ARN" +echo "" + +# Create Main Queue with Redrive Policy +echo "[5/9] Creating Main Queue with DLQ redrive policy..." +REDRIVE_POLICY=$(cat </dev/null || \ + aws sqs get-queue-url --queue-name "$MAIN_QUEUE_NAME" --region "$AWS_REGION" --output text --query 'QueueUrl') + +MAIN_QUEUE_ARN="arn:aws:sqs:${AWS_REGION}:${ACCOUNT_ID}:${MAIN_QUEUE_NAME}" +echo " ✓ Main Queue Created/Found" +echo " URL: $MAIN_QUEUE_URL" +echo " ARN: $MAIN_QUEUE_ARN" +echo "" + +# Set Main Queue policy to allow SNS to send messages and account to manage messages +echo "[6/9] Setting Main Queue policy to allow SNS and account access..." +MAIN_QUEUE_POLICY=$(cat </dev/null || echo "Already subscribed") + +echo " ✓ SNS → SQS Subscription created" +if [ "$SUBSCRIPTION_ARN" != "Already subscribed" ]; then + echo " Subscription ARN: $SUBSCRIPTION_ARN" +fi +echo "" + +# Create SES Configuration Set using v2 API +echo "[8/9] Creating SES Configuration Set (v2 API)..." +aws sesv2 create-configuration-set \ + --configuration-set-name "$SES_CONFIG_SET_NAME" \ + --region "$AWS_REGION" 2>/dev/null || echo " (Configuration Set already exists)" + +echo " ✓ SES Configuration Set created/verified" +echo " Name: $SES_CONFIG_SET_NAME" +echo "" + +# Add SNS Event Destination to Configuration Set using v2 API with all 10 event types +echo "[9/9] Configuring SES event tracking to SNS (v2 API with 10 event types)..." +aws sesv2 create-configuration-set-event-destination \ + --configuration-set-name "$SES_CONFIG_SET_NAME" \ + --event-destination-name "sns-destination" \ + --event-destination "{ + \"Enabled\": true, + \"MatchingEventTypes\": [\"SEND\", \"REJECT\", \"BOUNCE\", \"COMPLAINT\", \"DELIVERY\", \"OPEN\", \"CLICK\", \"RENDERING_FAILURE\", \"DELIVERY_DELAY\", \"SUBSCRIPTION\"], + \"SnsDestination\": { + \"TopicArn\": \"$SNS_TOPIC_ARN\" + } + }" \ + --region "$AWS_REGION" 2>/dev/null || echo " (Event destination already exists)" + +echo " ✓ SES Event Tracking configured (SES v2 API)" +echo " Events (10 types): SEND, REJECT, BOUNCE, COMPLAINT, DELIVERY, OPEN, CLICK, RENDERING_FAILURE, DELIVERY_DELAY, SUBSCRIPTION" +echo " Destination: SNS → SQS" +echo "" + +# ============================================================================ +# OUTPUT CONFIGURATION FOR APPLICATION FORM +# ============================================================================ + +echo "==============================================" +echo "✓ Setup Complete!" +echo "==============================================" +echo "" +echo "Copy these values to your application configuration form:" +echo "" +echo "╔════════════════════════════════════════════════════════════════════╗" +echo "║ AWS Configuration Form ║" +echo "╚════════════════════════════════════════════════════════════════════╝" +echo "" +echo "AWS Access Key ID:" +echo " [Use your AWS credentials]" +echo "" +echo "AWS Secret Access Key:" +echo " [Use your AWS credentials]" +echo "" +echo "AWS Region:" +echo " $AWS_REGION" +echo "" +echo "─────────────────────────────────────────────────────────────────────" +echo "Email Sender Settings" +echo "─────────────────────────────────────────────────────────────────────" +echo "" +echo "From Email:" +echo " [e.g., hello@${PROJECT_NAME}.com]" +echo "" +echo "From Name:" +echo " [e.g., ${PROJECT_NAME^}]" +echo "" +echo "NOTE: Configure these in your application's config.exs and runtime.exs" +echo "" +echo "─────────────────────────────────────────────────────────────────────" +echo "AWS SES & SQS Settings" +echo "─────────────────────────────────────────────────────────────────────" +echo "" +echo "SES Configuration Set:" +echo " $SES_CONFIG_SET_NAME" +echo "" +echo "SQS Polling Interval:" +echo " $SQS_POLLING_INTERVAL (ms)" +echo "" +echo "SNS Topic ARN:" +echo " $SNS_TOPIC_ARN" +echo "" +echo "SQS Queue URL:" +echo " $MAIN_QUEUE_URL" +echo "" +echo "SQS Queue ARN:" +echo " $MAIN_QUEUE_ARN" +echo "" +echo "SQS Dead Letter Queue URL:" +echo " $DLQ_URL" +echo "" +echo "SQS Dead Letter Queue ARN:" +echo " $DLQ_ARN" +echo "" +echo "─────────────────────────────────────────────────────────────────────" +echo "Architecture Diagram" +echo "─────────────────────────────────────────────────────────────────────" +echo "" +echo " SES Events → SNS ($SNS_TOPIC_NAME)" +echo " ↓" +echo " SQS ($MAIN_QUEUE_NAME) → Your Application" +echo " ↓ (after $MAIN_QUEUE_MAX_RECEIVE_COUNT failed attempts)" +echo " SQS DLQ ($DLQ_NAME)" +echo "" +echo "─────────────────────────────────────────────────────────────────────" +echo "Quick Verification Commands" +echo "─────────────────────────────────────────────────────────────────────" +echo "" +echo "# Check messages in main queue:" +echo "aws sqs get-queue-attributes \\" +echo " --queue-url \"$MAIN_QUEUE_URL\" \\" +echo " --attribute-names ApproximateNumberOfMessages" +echo "" +echo "# Check messages in DLQ:" +echo "aws sqs get-queue-attributes \\" +echo " --queue-url \"$DLQ_URL\" \\" +echo " --attribute-names ApproximateNumberOfMessages" +echo "" +echo "# Test SNS publish:" +echo "aws sns publish \\" +echo " --topic-arn \"$SNS_TOPIC_ARN\" \\" +echo " --message \"Test message\"" +echo "" +echo "# Receive messages:" +echo "aws sqs receive-message \\" +echo " --queue-url \"$MAIN_QUEUE_URL\" \\" +echo " --max-number-of-messages 10" +echo "" +echo "==============================================" + +# Save configuration to a file for reference +CONFIG_FILE="aws-email-config-${PROJECT_NAME}.txt" +cat > "$CONFIG_FILE" < String.downcase() + |> String.replace(~r/[^a-z0-9-]/, "-") + |> String.trim("-") + + region = Settings.get_setting("aws_region", "eu-north-1") + access_key_id = Settings.get_setting("aws_access_key_id") + secret_access_key = Settings.get_setting("aws_secret_access_key") + + config = [ + access_key_id: access_key_id, + secret_access_key: secret_access_key, + region: region + ] + + Logger.info("[AWS Setup] Starting infrastructure setup for project: #{project_name}") + Logger.info("[AWS Setup] Region: #{region}") + + with {:ok, account_id} <- get_account_id(config), + {:ok, dlq_url, dlq_arn} <- create_dlq(project_name, account_id, config), + :ok <- set_dlq_policy(dlq_url, dlq_arn, account_id, config), + {:ok, topic_arn} <- create_sns_topic(project_name, config), + {:ok, queue_url, queue_arn} <- create_main_queue(project_name, account_id, dlq_arn, config), + :ok <- set_queue_policy(queue_url, queue_arn, topic_arn, account_id, config), + {:ok, _sub_arn} <- subscribe_sqs_to_sns(topic_arn, queue_arn, config), + {:ok, config_set} <- create_ses_config_set(project_name, config), + :ok <- configure_ses_events(config_set, topic_arn, config) do + + result = %{ + "aws_region" => region, + "aws_sns_topic_arn" => topic_arn, + "aws_sqs_queue_url" => queue_url, + "aws_sqs_queue_arn" => queue_arn, + "aws_sqs_dlq_url" => dlq_url, + "aws_ses_configuration_set" => config_set, + "sqs_polling_interval_ms" => "5000" + } + + Logger.info("[AWS Setup] ✅ Infrastructure setup completed successfully!") + Logger.info("[AWS Setup] Saving settings to database...") + + # Save to database + case Settings.update_settings_batch(result) do + {:ok, _} -> + Logger.info("[AWS Setup] ✅ Settings saved to database") + IO.puts("\n✅ SUCCESS! AWS Infrastructure Created:\n") + Enum.each(result, fn {k, v} -> IO.puts(" • #{k}: #{v}") end) + IO.puts("\n") + + {:error, _} -> + Logger.error("[AWS Setup] ❌ Failed to save settings to database") + IO.puts("\n⚠️ Resources created but settings not saved. Manual update required.\n") + end + + {:ok, result} + else + {:error, step, reason} = error -> + Logger.error("[AWS Setup] ❌ Failed at step: #{step}") + Logger.error("[AWS Setup] Reason: #{reason}") + IO.puts("\n❌ Setup failed at: #{step}\nReason: #{reason}\n") + error + end + end + + defp get_account_id(config) do + Logger.info("[AWS Setup] [1/9] Getting AWS Account ID...") + + case STS.get_caller_identity() |> ExAws.request(config) do + {:ok, %{body: body}} when is_map(body) -> + account_id = body[:account] || body["account"] + + if account_id do + Logger.info("[AWS Setup] ✓ Account ID: #{account_id}") + {:ok, account_id} + else + {:error, "get_account_id", "Could not parse account ID from response"} + end + + {:error, reason} -> + {:error, "get_account_id", inspect(reason)} + end + end + + defp create_dlq(project_name, account_id, config) do + Logger.info("[AWS Setup] [2/9] Creating Dead Letter Queue...") + dlq_name = "#{project_name}-email-dlq" + + case SQS.create_queue(dlq_name, [ + {"VisibilityTimeout", "60"}, + {"MessageRetentionPeriod", "1209600"}, + {"SqsManagedSseEnabled", "true"} + ]) |> ExAws.request(config) do + {:ok, %{body: body}} -> + dlq_url = body[:queue_url] || body["QueueUrl"] || body["queue_url"] + region = config[:region] + dlq_arn = "arn:aws:sqs:#{region}:#{account_id}:#{dlq_name}" + + Logger.info("[AWS Setup] ✓ DLQ Created") + Logger.info("[AWS Setup] URL: #{dlq_url}") + Logger.info("[AWS Setup] ARN: #{dlq_arn}") + {:ok, dlq_url, dlq_arn} + + {:error, {:http_error, 400, %{body: body}}} when is_binary(body) -> + if String.contains?(body, "QueueAlreadyExists") do + case SQS.get_queue_url(dlq_name) |> ExAws.request(config) do + {:ok, %{body: %{queue_url: dlq_url}}} -> + region = config[:region] + dlq_arn = "arn:aws:sqs:#{region}:#{account_id}:#{dlq_name}" + Logger.info("[AWS Setup] ✓ DLQ Found (already exists)") + {:ok, dlq_url, dlq_arn} + + _ -> + {:error, "create_dlq", "Failed to get existing DLQ"} + end + else + {:error, "create_dlq", body} + end + + {:error, reason} -> + {:error, "create_dlq", inspect(reason)} + end + end + + defp set_dlq_policy(dlq_url, dlq_arn, account_id, config) do + Logger.info("[AWS Setup] [3/9] Setting DLQ policy...") + + policy = Jason.encode!(%{ + "Version" => "2012-10-17", + "Id" => "__default_policy_ID", + "Statement" => [%{ + "Sid" => "__owner_statement", + "Effect" => "Allow", + "Principal" => %{"AWS" => "arn:aws:iam::#{account_id}:root"}, + "Action" => "SQS:*", + "Resource" => dlq_arn + }] + }) + + case SQS.set_queue_attributes(dlq_url, [{"Policy", policy}]) |> ExAws.request(config) do + {:ok, _} -> + Logger.info("[AWS Setup] ✓ DLQ Policy set") + :ok + + {:error, reason} -> + {:error, "set_dlq_policy", inspect(reason)} + end + end + + defp create_sns_topic(project_name, config) do + Logger.info("[AWS Setup] [4/9] Creating SNS Topic...") + topic_name = "#{project_name}-email-events" + + case SNS.create_topic(topic_name) |> ExAws.request(config) do + {:ok, %{body: body}} -> + topic_arn = body[:topic_arn] || body["TopicArn"] || body["topic_arn"] + + Logger.info("[AWS Setup] ✓ SNS Topic Created/Found") + Logger.info("[AWS Setup] ARN: #{topic_arn}") + {:ok, topic_arn} + + {:error, reason} -> + {:error, "create_sns_topic", inspect(reason)} + end + end + + defp create_main_queue(project_name, account_id, dlq_arn, config) do + Logger.info("[AWS Setup] [5/9] Creating Main Queue...") + queue_name = "#{project_name}-email-queue" + + redrive_policy = Jason.encode!(%{ + "deadLetterTargetArn" => dlq_arn, + "maxReceiveCount" => 3 + }) + + case SQS.create_queue(queue_name, [ + {"VisibilityTimeout", "600"}, + {"MessageRetentionPeriod", "1209600"}, + {"ReceiveMessageWaitTimeSeconds", "20"}, + {"RedrivePolicy", redrive_policy}, + {"SqsManagedSseEnabled", "true"} + ]) |> ExAws.request(config) do + {:ok, %{body: body}} -> + queue_url = body[:queue_url] || body["QueueUrl"] || body["queue_url"] + region = config[:region] + queue_arn = "arn:aws:sqs:#{region}:#{account_id}:#{queue_name}" + + Logger.info("[AWS Setup] ✓ Main Queue Created") + Logger.info("[AWS Setup] URL: #{queue_url}") + Logger.info("[AWS Setup] ARN: #{queue_arn}") + {:ok, queue_url, queue_arn} + + {:error, {:http_error, 400, %{body: body}}} when is_binary(body) -> + if String.contains?(body, "QueueAlreadyExists") do + case SQS.get_queue_url(queue_name) |> ExAws.request(config) do + {:ok, %{body: %{queue_url: queue_url}}} -> + region = config[:region] + queue_arn = "arn:aws:sqs:#{region}:#{account_id}:#{queue_name}" + Logger.info("[AWS Setup] ✓ Main Queue Found (already exists)") + {:ok, queue_url, queue_arn} + + _ -> + {:error, "create_main_queue", "Failed to get existing queue"} + end + else + {:error, "create_main_queue", body} + end + + {:error, reason} -> + {:error, "create_main_queue", inspect(reason)} + end + end + + defp set_queue_policy(queue_url, queue_arn, topic_arn, account_id, config) do + Logger.info("[AWS Setup] [6/9] Setting Main Queue policy...") + + policy = Jason.encode!(%{ + "Version" => "2012-10-17", + "Id" => "sqs-policy", + "Statement" => [ + %{ + "Sid" => "AllowSNSPublish", + "Effect" => "Allow", + "Principal" => %{"Service" => "sns.amazonaws.com"}, + "Action" => "SQS:SendMessage", + "Resource" => queue_arn, + "Condition" => %{"ArnEquals" => %{"aws:SourceArn" => topic_arn}} + }, + %{ + "Sid" => "AllowAccountAccess", + "Effect" => "Allow", + "Principal" => %{"AWS" => "arn:aws:iam::#{account_id}:root"}, + "Action" => ["SQS:ReceiveMessage", "SQS:DeleteMessage", "SQS:GetQueueAttributes", "SQS:SendMessage"], + "Resource" => queue_arn + } + ] + }) + + case SQS.set_queue_attributes(queue_url, [{"Policy", policy}]) |> ExAws.request(config) do + {:ok, _} -> + Logger.info("[AWS Setup] ✓ Main Queue Policy set") + :ok + + {:error, reason} -> + {:error, "set_queue_policy", inspect(reason)} + end + end + + defp subscribe_sqs_to_sns(topic_arn, queue_arn, config) do + Logger.info("[AWS Setup] [7/9] Creating SNS subscription to SQS...") + + case SNS.subscribe(topic_arn, "sqs", queue_arn) |> ExAws.request(config) do + {:ok, %{body: body}} -> + sub_arn = body[:subscription_arn] || body["SubscriptionArn"] || body["subscription_arn"] + Logger.info("[AWS Setup] ✓ SNS → SQS Subscription created") + {:ok, sub_arn} + + {:error, reason} -> + Logger.info("[AWS Setup] ℹ️ Subscription may already exist") + {:ok, "existing"} + end + end + + defp create_ses_config_set(project_name, config) do + Logger.info("[AWS Setup] [8/9] Creating SES Configuration Set...") + config_set_name = "#{project_name}-emailing" + + # SES v2 API - just return the name, actual creation needs different approach + Logger.info("[AWS Setup] ✓ SES Configuration Set: #{config_set_name}") + Logger.info("[AWS Setup] (Manual creation required in AWS Console)") + {:ok, config_set_name} + end + + defp configure_ses_events(config_set, topic_arn, config) do + Logger.info("[AWS Setup] [9/9] SES event configuration...") + Logger.info("[AWS Setup] ℹ️ Manual configuration required in AWS Console") + Logger.info("[AWS Setup] Config Set: #{config_set}") + Logger.info("[AWS Setup] SNS Topic: #{topic_arn}") + :ok + end +end + +# Run the setup +AWSInfrastructureHelper.run() diff --git a/scripts/test_aws_setup.sh b/scripts/test_aws_setup.sh new file mode 100755 index 000000000..495a54f86 --- /dev/null +++ b/scripts/test_aws_setup.sh @@ -0,0 +1,345 @@ +#!/bin/bash +# +# TEST AWS Email Infrastructure Setup Script +# ========================================== +# +# This is a TEST version for validating AWS SES event type configuration. +# Uses demo names with timestamps to avoid conflicts. +# +# Testing: All 10 AWS SES event types in UPPERCASE format +# + +set -e # Exit on any error + +# ============================================================================ +# TEST CONFIGURATION - Uses demo names with timestamp +# ============================================================================ + +# Generate unique timestamp for test resources +TIMESTAMP=$(date +%s) + +# Project/Application name (demo prefix for testing) +PROJECT_NAME="demo-test-${TIMESTAMP}" + +# AWS Region +AWS_REGION="eu-north-1" + +# Queue names +MAIN_QUEUE_NAME="${PROJECT_NAME}-queue" +DLQ_NAME="${PROJECT_NAME}-dlq" + +# SNS Topic name +SNS_TOPIC_NAME="${PROJECT_NAME}-sns" + +# SES Configuration Set name +SES_CONFIG_SET_NAME="${PROJECT_NAME}-config" + +# Queue configurations (shorter times for testing) +MAIN_QUEUE_VISIBILITY_TIMEOUT=300 # 5 minutes (test) +MAIN_QUEUE_MESSAGE_RETENTION=345600 # 4 days (test) +MAIN_QUEUE_MAX_RECEIVE_COUNT=3 +MAIN_QUEUE_RECEIVE_WAIT_TIME=20 + +DLQ_VISIBILITY_TIMEOUT=60 +DLQ_MESSAGE_RETENTION=345600 # 4 days (test) + +SQS_POLLING_INTERVAL=5000 + +# ============================================================================ +# SCRIPT EXECUTION +# ============================================================================ + +echo "==============================================" +echo "TEST AWS Email Infrastructure Setup" +echo "==============================================" +echo "" +echo "Configuration:" +echo " Project: $PROJECT_NAME" +echo " Region: $AWS_REGION" +echo " Main Queue: $MAIN_QUEUE_NAME" +echo " DLQ: $DLQ_NAME" +echo " SNS Topic: $SNS_TOPIC_NAME" +echo " Config Set: $SES_CONFIG_SET_NAME" +echo "" +echo "Starting TEST setup..." +echo "" + +# Get AWS Account ID +echo "[1/9] Getting AWS Account ID..." +ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +echo " ✓ Account ID: $ACCOUNT_ID" +echo "" + +# Create Dead Letter Queue (DLQ) +echo "[2/9] Creating Dead Letter Queue..." +DLQ_URL=$(aws sqs create-queue \ + --queue-name "$DLQ_NAME" \ + --attributes "{ + \"VisibilityTimeout\": \"$DLQ_VISIBILITY_TIMEOUT\", + \"MessageRetentionPeriod\": \"$DLQ_MESSAGE_RETENTION\", + \"SqsManagedSseEnabled\": \"true\" + }" \ + --region "$AWS_REGION" \ + --output text \ + --query 'QueueUrl') + +DLQ_ARN="arn:aws:sqs:${AWS_REGION}:${ACCOUNT_ID}:${DLQ_NAME}" +echo " ✓ DLQ Created" +echo " URL: $DLQ_URL" +echo " ARN: $DLQ_ARN" +echo "" + +# Set DLQ policy +echo "[3/9] Setting DLQ policy..." +DLQ_POLICY=$(cat <> Adding ALL 10 AWS SES event types in UPPERCASE format" +aws sesv2 create-configuration-set-event-destination \ + --configuration-set-name "$SES_CONFIG_SET_NAME" \ + --event-destination-name "sns-destination" \ + --event-destination "{ + \"Enabled\": true, + \"MatchingEventTypes\": [\"SEND\", \"REJECT\", \"BOUNCE\", \"COMPLAINT\", \"DELIVERY\", \"OPEN\", \"CLICK\", \"RENDERING_FAILURE\", \"DELIVERY_DELAY\", \"SUBSCRIPTION\"], + \"SnsDestination\": { + \"TopicArn\": \"$SNS_TOPIC_ARN\" + } + }" \ + --region "$AWS_REGION" + +echo " ✓ SES Event Tracking configured (SES v2 API)" +echo " Events (10 types): SEND, REJECT, BOUNCE, COMPLAINT, DELIVERY, OPEN, CLICK, RENDERING_FAILURE, DELIVERY_DELAY, SUBSCRIPTION" +echo " Destination: SNS → SQS" +echo "" + +# ============================================================================ +# OUTPUT TEST CONFIGURATION +# ============================================================================ + +echo "==============================================" +echo "✓ TEST Setup Complete!" +echo "==============================================" +echo "" +echo "Test Resources Created:" +echo " SNS Topic ARN: $SNS_TOPIC_ARN" +echo " SQS Queue URL: $MAIN_QUEUE_URL" +echo " SQS Queue ARN: $MAIN_QUEUE_ARN" +echo " SQS DLQ URL: $DLQ_URL" +echo " Config Set: $SES_CONFIG_SET_NAME" +echo "" +echo "Next Steps:" +echo "1. Verify resources with AWS CLI commands" +echo "2. Send test email" +echo "3. Check SQS for events" +echo "4. Clean up resources when done" +echo "" + +# Save test configuration +TEST_CONFIG_FILE="/tmp/test-aws-config-${TIMESTAMP}.txt" +cat > "$TEST_CONFIG_FILE" </dev/null + +# Цвета для вывода +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Функция вывода заголовка +print_header() { + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN}🤖 Z.AI Assistant: $1${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +} + +# Функция для сбора контекста проекта +gather_context() { + print_header "Сбор контекста проекта" + + local analysis_prompt="Analyze the PhoenixKit project at /app. Focus on: + 1. Current module structure in lib/ + 2. Recent changes in git log (last 5 commits) + 3. TODO comments in the code + 4. Identify areas that need improvement + Be specific and actionable." + + echo -e "${YELLOW}Анализирую проект...${NC}" + zai "$analysis_prompt" --print +} + +# Функция для генерации commit message +generate_commit() { + print_header "Генерация commit message" + + # Получаем git diff + local git_diff=$(git diff --cached) + + if [ -z "$git_diff" ]; then + echo -e "${RED}Нет изменений для коммита (git diff --cached пустой)${NC}" + echo "Сначала добавьте файлы: git add " + return 1 + fi + + local commit_prompt="Based on the following git diff, generate a commit message following these rules: + - Start with action verb: Add, Update, Fix, Remove, Merge + - Be specific about what changed + - Keep it under 50 characters + - Don't mention AI/Claude/assistance + + Git diff: + $git_diff + + Generate ONLY the commit message, nothing else." + + echo -e "${YELLOW}Генерирую commit message...${NC}" + local commit_msg=$(zai "$commit_prompt" --print 2>/dev/null | tail -n 1) + + echo -e "${GREEN}Предлагаемый commit:${NC}" + echo "$commit_msg" + echo "" + echo -e "${YELLOW}Использовать этот commit? (y/n):${NC}" + read -r response + + if [[ "$response" == "y" ]]; then + git commit -m "$commit_msg" + echo -e "${GREEN}✅ Commit создан!${NC}" + else + echo -e "${YELLOW}Commit отменён${NC}" + fi +} + +# Функция проверки качества кода с автоматическим исправлением +quality_check() { + print_header "Комплексная проверка качества с автоисправлением" + + echo -e "${BLUE}🔍 Запускаю mix quality...${NC}" + echo "" + + local has_errors=false + local error_log="/tmp/quality_errors.log" + > "$error_log" + + # 1. Проверка форматирования + echo -e "${YELLOW}📝 Этап 1/3: Проверка форматирования (mix format)${NC}" + if ! mix format --check-formatted 2>&1; then + echo -e "${YELLOW}⚠️ Найдены проблемы форматирования. Исправляю...${NC}" + mix format + echo -e "${GREEN}✅ Форматирование исправлено${NC}" + else + echo -e "${GREEN}✅ Форматирование в порядке${NC}" + fi + echo "" + + # 2. Статический анализ с Credo + echo -e "${YELLOW}🔎 Этап 2/3: Статический анализ (mix credo)${NC}" + local credo_output=$(mix credo --strict --format json 2>/dev/null || echo "{}") + local credo_issues=$(echo "$credo_output" | grep -o '"message"' | wc -l) + + if [ "$credo_issues" -gt 0 ]; then + echo -e "${YELLOW}⚠️ Найдено проблем Credo: $credo_issues${NC}" + has_errors=true + + # Сохраняем читаемый вывод Credo + mix credo --strict 2>&1 | tee -a "$error_log" | head -20 + echo "..." >> "$error_log" + echo "" + + # Запрашиваем исправления у Z.AI + echo -e "${BLUE}🤖 Запрашиваю исправления у Z.AI...${NC}" + + local credo_fix_prompt="Fix these Elixir Credo warnings from PhoenixKit: + +$(mix credo --strict 2>&1 | head -50) + +Provide specific code fixes for each issue. Be concise but complete. +Focus on actual fixes, not explanations." + + local fix_suggestions=$(zai "$credo_fix_prompt" --print 2>/dev/null) + echo "$fix_suggestions" > /tmp/credo_fixes.txt + echo -e "${GREEN}💡 Предложения по исправлению сохранены в /tmp/credo_fixes.txt${NC}" + else + echo -e "${GREEN}✅ Статический анализ пройден${NC}" + fi + echo "" + + # 3. Проверка типов с Dialyzer + echo -e "${YELLOW}🔬 Этап 3/3: Проверка типов (mix dialyzer)${NC}" + echo -e "${YELLOW}(может занять время при первом запуске)${NC}" + + local dialyzer_output=$(mix dialyzer 2>&1 || true) + + if echo "$dialyzer_output" | grep -q "done (warnings were emitted)"; then + echo -e "${YELLOW}⚠️ Найдены предупреждения Dialyzer${NC}" + has_errors=true + + echo "$dialyzer_output" | grep -A 2 "warning:" | head -20 | tee -a "$error_log" + echo "" + + # Запрашиваем исправления у Z.AI + echo -e "${BLUE}🤖 Запрашиваю исправления типов у Z.AI...${NC}" + + local dialyzer_fix_prompt="Fix these Elixir Dialyzer type warnings: + +$(echo "$dialyzer_output" | grep -A 2 "warning:" | head -30) + +Provide specific @spec and type fixes for each warning. +Include exact code to add or modify." + + local type_fixes=$(zai "$dialyzer_fix_prompt" --print 2>/dev/null) + echo "$type_fixes" > /tmp/dialyzer_fixes.txt + echo -e "${GREEN}💡 Исправления типов сохранены в /tmp/dialyzer_fixes.txt${NC}" + elif echo "$dialyzer_output" | grep -q "done (passed successfully)"; then + echo -e "${GREEN}✅ Проверка типов пройдена${NC}" + else + echo -e "${YELLOW}⏭️ Dialyzer пропущен (возможно, не настроен)${NC}" + fi + echo "" + + # 4. Дополнительная проверка конкретного файла, если указан + local file=$1 + if [ -n "$file" ] && [ -f "$file" ]; then + echo -e "${BLUE}📄 Дополнительная проверка файла: $file${NC}" + + local file_content=$(cat "$file") + local file_check_prompt="Review this specific Elixir file for issues not caught by tools: + +File: $file +Content: +$file_content + +Look for: +- Logic errors +- Performance issues +- Security vulnerabilities +- Missing error handling +- Incorrect business logic + +Provide only critical issues with specific fixes." + + echo -e "${YELLOW}Анализирую $file...${NC}" + local file_review=$(zai "$file_check_prompt" --print 2>/dev/null) + echo "$file_review" > /tmp/file_review_$(basename "$file").txt + echo -e "${GREEN}📋 Ревью файла сохранено в /tmp/file_review_$(basename "$file").txt${NC}" + fi + + # 5. Итоговый отчёт + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN}📊 ИТОГОВЫЙ ОТЧЁТ${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + + if [ "$has_errors" = true ]; then + echo -e "${YELLOW}⚠️ Найдены проблемы качества${NC}" + echo "" + echo "📁 Результаты проверки сохранены:" + echo " - /tmp/quality_errors.log - все ошибки" + [ -f /tmp/credo_fixes.txt ] && echo " - /tmp/credo_fixes.txt - исправления Credo" + [ -f /tmp/dialyzer_fixes.txt ] && echo " - /tmp/dialyzer_fixes.txt - исправления типов" + [ -n "$file" ] && [ -f /tmp/file_review_$(basename "$file").txt ] && echo " - /tmp/file_review_$(basename "$file").txt - ревью файла" + echo "" + echo -e "${YELLOW}Хотите применить предложенные исправления? (просмотрите файлы выше)${NC}" + echo "Для применения исправлений используйте редактор или команду Edit" + else + echo -e "${GREEN}✅ Все проверки качества пройдены успешно!${NC}" + echo "Код соответствует стандартам качества проекта." + fi +} + +# Функция автоматического исправления ошибок +fix_errors() { + print_header "Автоматическое исправление ошибок" + + echo -e "${YELLOW}Запускаю тесты и проверки...${NC}" + + # Собираем ошибки + local errors="" + + # Проверка форматирования + if ! mix format --check-formatted 2>/dev/null; then + errors="${errors}Formatting issues found\n" + echo -e "${YELLOW}Исправляю форматирование...${NC}" + mix format + fi + + # Проверка компиляции + local compile_errors=$(mix compile 2>&1 | grep -E "error:|warning:") + if [ -n "$compile_errors" ]; then + errors="${errors}Compilation errors:\n$compile_errors\n" + fi + + # Если есть ошибки, просим Z.AI помочь + if [ -n "$errors" ]; then + local fix_prompt="Help fix these Elixir/Phoenix errors: + + $errors + + Provide specific fixes with code examples. + Explain what needs to be changed and why." + + echo -e "${YELLOW}Найдены ошибки. Запрашиваю решение у Z.AI...${NC}" + zai "$fix_prompt" --print + else + echo -e "${GREEN}✅ Ошибок не найдено!${NC}" + fi +} + +# Функция для создания документации +generate_docs() { + print_header "Генерация документации" + + local module=$1 + + if [ -z "$module" ]; then + echo -e "${RED}Укажите модуль для документирования${NC}" + echo "Использование: $0 docs " + return 1 + fi + + local module_content=$(cat "$module" 2>/dev/null) + + if [ -z "$module_content" ]; then + echo -e "${RED}Не могу прочитать файл: $module${NC}" + return 1 + fi + + local docs_prompt="Generate comprehensive @moduledoc and @doc documentation for this Elixir module: + + $module_content + + Include: + - Module purpose and overview + - Function descriptions with examples + - Parameter explanations + - Return value descriptions + Use proper Elixir ExDoc format." + + echo -e "${YELLOW}Генерирую документацию для $module...${NC}" + zai "$docs_prompt" --print +} + +# Основная логика +case "$1" in + context) + gather_context + ;; + commit) + generate_commit + ;; + quality) + quality_check "$2" + ;; + fix) + fix_errors + ;; + docs) + generate_docs "$2" + ;; + *) + echo -e "${GREEN}Z.AI Helper - Оркестратор для работы с Z.AI${NC}" + echo "" + echo "Использование: $0 <команда> [параметры]" + echo "" + echo "Команды:" + echo " context - Собрать контекст проекта и найти области для улучшения" + echo " commit - Сгенерировать commit message на основе git diff" + echo " quality [file] - Запустить mix quality (format, credo, dialyzer) с автоисправлением" + echo " Опционально: дополнительная проверка конкретного файла" + echo " fix - Найти и предложить исправления ошибок" + echo " docs - Сгенерировать документацию для модуля" + echo "" + echo "Примеры:" + echo " $0 context" + echo " $0 commit" + echo " $0 quality # Полная проверка проекта" + echo " $0 quality lib/phoenix_kit.ex # Проверка проекта + анализ файла" + echo " $0 fix" + echo " $0 docs lib/phoenix_kit/users/auth.ex" + ;; +esac + +echo "" \ No newline at end of file diff --git a/scripts/zai_helper_timeout.sh b/scripts/zai_helper_timeout.sh new file mode 100644 index 000000000..e1c05edda --- /dev/null +++ b/scripts/zai_helper_timeout.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# Функция для вызова Z.AI с тайм-аутом +zai_with_timeout() { + local prompt=$1 + local timeout=${2:-30} # Тайм-аут по умолчанию 30 секунд + local output_file="/tmp/zai_output_$$.txt" + + echo "Запрашиваю у Z.AI (тайм-аут: ${timeout}с)..." + + # Запускаем zai в фоне + ( + source /root/.bashrc 2>/dev/null + zai "$prompt" --print > "$output_file" 2>&1 + ) & + local pid=$! + + # Ждём завершения с тайм-аутом + local count=0 + while kill -0 $pid 2>/dev/null; do + sleep 1 + count=$((count + 1)) + if [ $count -ge $timeout ]; then + kill $pid 2>/dev/null + echo "⚠️ Тайм-аут истёк. Z.AI не успел ответить за ${timeout} секунд" + echo "Продолжаю без предложений Z.AI..." + return 1 + fi + done + + # Если успешно завершился + if [ -f "$output_file" ]; then + cat "$output_file" + rm "$output_file" + return 0 + fi + + return 1 +} + +# Пример использования +if [ "$1" = "test" ]; then + zai_with_timeout "What is 2+2?" 10 +fi \ No newline at end of file diff --git a/scripts/zai_workflow.sh b/scripts/zai_workflow.sh new file mode 100755 index 000000000..83d3b09e2 --- /dev/null +++ b/scripts/zai_workflow.sh @@ -0,0 +1,260 @@ +#!/bin/bash + +# Z.AI Workflow Orchestrator - полная автоматизация рабочего процесса +# Этот скрипт использует Z.AI для выполнения сложных многоэтапных задач + +source /root/.bashrc 2>/dev/null + +# Цвета +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +PURPLE='\033[0;35m' +NC='\033[0m' + +# Функция логирования +log() { + echo -e "${GREEN}[$(date +'%H:%M:%S')]${NC} $1" +} + +# 1. Анализ и планирование изменений +plan_changes() { + local feature=$1 + log "${BLUE}📋 Планирование изменений для: $feature${NC}" + + local plan_prompt="Create a detailed implementation plan for adding '$feature' to PhoenixKit. + Include: + 1. Files that need to be modified + 2. New files that need to be created + 3. Database migrations required + 4. Tests that should be written + 5. Potential risks and how to mitigate them + Be specific with file paths and module names." + + zai "$plan_prompt" --print > /tmp/implementation_plan.txt + echo -e "${GREEN}План сохранён в /tmp/implementation_plan.txt${NC}" +} + +# 2. Генерация кода на основе плана +generate_code() { + local requirement=$1 + log "${BLUE}⚙️ Генерация кода для: $requirement${NC}" + + local code_prompt="Generate production-ready Elixir/Phoenix code for: $requirement + Follow PhoenixKit conventions: + - Use library-first architecture + - Include proper error handling + - Add @moduledoc and @doc documentation + - Follow Elixir style guide + Provide complete, working code." + + zai "$code_prompt" --print > /tmp/generated_code.ex + echo -e "${GREEN}Код сохранён в /tmp/generated_code.ex${NC}" +} + +# 3. Проверка и исправление кода перед коммитом +review_and_fix() { + log "${BLUE}🔍 Проверка всех изменений...${NC}" + + # Собираем все изменённые файлы + local changed_files=$(git diff --name-only) + + if [ -z "$changed_files" ]; then + echo -e "${YELLOW}Нет изменений для проверки${NC}" + return + fi + + for file in $changed_files; do + if [[ $file == *.ex ]] || [[ $file == *.exs ]]; then + log "Проверяю: $file" + + local file_content=$(cat "$file") + local review_prompt="Review this Elixir code for issues: + File: $file + + $file_content + + Check for: + - Syntax errors + - Logic bugs + - Security issues + - Performance problems + - Missing error handling + + If you find issues, provide exact fixes with line numbers." + + zai "$review_prompt" --print > "/tmp/review_${file##*/}.txt" + echo -e "${GREEN}Результат проверки: /tmp/review_${file##*/}.txt${NC}" + fi + done +} + +# 4. Генерация тестов +generate_tests() { + local module_file=$1 + log "${BLUE}🧪 Генерация тестов для: $module_file${NC}" + + if [ ! -f "$module_file" ]; then + echo -e "${RED}Файл не найден: $module_file${NC}" + return + fi + + local module_content=$(cat "$module_file") + + local test_prompt="Generate comprehensive ExUnit tests for this Elixir module: + + $module_content + + Include: + - Unit tests for each public function + - Edge case tests + - Error handling tests + - Integration tests if applicable + Use PhoenixKit test conventions and DataCase where appropriate." + + zai "$test_prompt" --print > "/tmp/test_${module_file##*/}" + echo -e "${GREEN}Тесты сохранены в /tmp/test_${module_file##*/}${NC}" +} + +# 5. Полный цикл разработки фичи +develop_feature() { + local feature_name=$1 + + echo -e "${PURPLE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${PURPLE}🚀 Полный цикл разработки: $feature_name${NC}" + echo -e "${PURPLE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + + # Этап 1: Анализ текущего состояния + log "${BLUE}Этап 1: Анализ проекта${NC}" + local analysis_prompt="Analyze PhoenixKit at /app to understand how to implement '$feature_name'. + Check existing patterns, dependencies, and integration points." + + zai "$analysis_prompt" --print > /tmp/analysis.txt + echo -e "${GREEN}Анализ завершён${NC}" + + # Этап 2: Планирование + log "${BLUE}Этап 2: Планирование${NC}" + plan_changes "$feature_name" + + # Этап 3: Генерация кода + log "${BLUE}Этап 3: Генерация кода${NC}" + generate_code "$feature_name" + + # Этап 4: Проверка качества + log "${BLUE}Этап 4: Проверка качества${NC}" + mix format + review_and_fix + + # Этап 5: Генерация тестов + log "${BLUE}Этап 5: Генерация тестов${NC}" + if [ -f "/tmp/generated_code.ex" ]; then + generate_tests "/tmp/generated_code.ex" + fi + + echo -e "${PURPLE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN}✅ Разработка завершена!${NC}" + echo "" + echo "Результаты сохранены в /tmp/:" + echo " - analysis.txt - анализ проекта" + echo " - implementation_plan.txt - план реализации" + echo " - generated_code.ex - сгенерированный код" + echo " - test_generated_code.ex - тесты" + echo " - review_*.txt - результаты проверки" +} + +# 6. Интеллектуальный коммит +smart_commit() { + log "${BLUE}🎯 Интеллектуальный коммит${NC}" + + # Проверяем staged изменения + local staged_diff=$(git diff --cached) + + if [ -z "$staged_diff" ]; then + # Если нет staged, добавляем все изменения + log "Добавляю все изменения..." + git add -A + staged_diff=$(git diff --cached) + fi + + if [ -z "$staged_diff" ]; then + echo -e "${RED}Нет изменений для коммита${NC}" + return + fi + + # Генерируем коммит + local commit_prompt="Generate a git commit message for these changes: + + $staged_diff + + Rules: + - Start with: Add, Update, Fix, Remove, or Merge + - Be specific about what changed + - Keep under 50 characters + - Focus on WHY, not just WHAT + + Return ONLY the commit message, nothing else." + + local commit_msg=$(zai "$commit_prompt" --print 2>/dev/null | tail -n 1) + + echo -e "${GREEN}Предлагаемый коммит:${NC}" + echo "$commit_msg" + echo "" + echo -n "Создать коммит? (y/n): " + read -r response + + if [[ "$response" == "y" ]]; then + git commit -m "$commit_msg" + echo -e "${GREEN}✅ Коммит создан!${NC}" + + # Предлагаем push + echo -n "Отправить в репозиторий? (y/n): " + read -r push_response + if [[ "$push_response" == "y" ]]; then + git push + echo -e "${GREEN}✅ Изменения отправлены!${NC}" + fi + fi +} + +# Главное меню +case "$1" in + plan) + plan_changes "$2" + ;; + generate) + generate_code "$2" + ;; + review) + review_and_fix + ;; + test) + generate_tests "$2" + ;; + develop) + develop_feature "$2" + ;; + commit) + smart_commit + ;; + *) + echo -e "${PURPLE}Z.AI Workflow Orchestrator${NC}" + echo "Полная автоматизация разработки с помощью Z.AI" + echo "" + echo "Команды:" + echo " plan - Планирование реализации функции" + echo " generate - Генерация кода по описанию" + echo " review - Проверка всех изменений" + echo " test - Генерация тестов для модуля" + echo " develop - Полный цикл разработки" + echo " commit - Интеллектуальный git commit" + echo "" + echo "Примеры:" + echo " $0 plan \"email templates with variables\"" + echo " $0 generate \"rate limiter for API\"" + echo " $0 review" + echo " $0 test lib/phoenix_kit/emails.ex" + echo " $0 develop \"user preferences system\"" + echo " $0 commit" + ;; +esac \ No newline at end of file From 58588b5a364d8c6ad6412bee99abcc3d2a0e9744 Mon Sep 17 00:00:00 2001 From: timujeen Date: Wed, 25 Mar 2026 10:33:39 +0000 Subject: [PATCH 4/8] Remove billing UI components from core (moved to billing package) --- lib/phoenix_kit_web.ex | 4 - .../components/core/currency_display.ex | 248 ------------------ .../components/core/invoice_status_badge.ex | 62 ----- .../components/core/order_status_badge.ex | 64 ----- .../components/core/transaction_type_badge.ex | 53 ---- 5 files changed, 431 deletions(-) delete mode 100644 lib/phoenix_kit_web/components/core/currency_display.ex delete mode 100644 lib/phoenix_kit_web/components/core/invoice_status_badge.ex delete mode 100644 lib/phoenix_kit_web/components/core/order_status_badge.ex delete mode 100644 lib/phoenix_kit_web/components/core/transaction_type_badge.ex diff --git a/lib/phoenix_kit_web.ex b/lib/phoenix_kit_web.ex index da103b2a2..2fc4ba7a7 100644 --- a/lib/phoenix_kit_web.ex +++ b/lib/phoenix_kit_web.ex @@ -136,10 +136,6 @@ defmodule PhoenixKitWeb do import PhoenixKitWeb.Components.Core.MarkdownContent import PhoenixKitWeb.Components.Core.Markdown import PhoenixKitWeb.Components.Core.DraggableList - import PhoenixKitWeb.Components.Core.OrderStatusBadge - import PhoenixKitWeb.Components.Core.InvoiceStatusBadge - import PhoenixKitWeb.Components.Core.TransactionTypeBadge - import PhoenixKitWeb.Components.Core.CurrencyDisplay import PhoenixKitWeb.Components.Core.CookieConsent import PhoenixKitWeb.Components.Core.PkLink import PhoenixKitWeb.Components.Core.Modal diff --git a/lib/phoenix_kit_web/components/core/currency_display.ex b/lib/phoenix_kit_web/components/core/currency_display.ex deleted file mode 100644 index 2a4d72be3..000000000 --- a/lib/phoenix_kit_web/components/core/currency_display.ex +++ /dev/null @@ -1,248 +0,0 @@ -defmodule PhoenixKitWeb.Components.Core.CurrencyDisplay do - @moduledoc """ - Provides currency formatting components for the billing system. - - Supports formatting monetary amounts with currency symbols, - locale-aware number formatting, and various display styles. - """ - - use Phoenix.Component - - @doc """ - Formats and displays a monetary amount with currency symbol. - - ## Attributes - - `amount` - Decimal or number to display (required) - - `currency` - Currency code like "EUR", "USD" (default: "EUR") - - `symbol_position` - :before or :after (default: :before) - - `show_code` - Show currency code alongside symbol (default: false) - - `class` - Additional CSS classes - - ## Examples - - <.currency_amount amount={99.99} /> - <.currency_amount amount={@order.total} currency="USD" /> - <.currency_amount amount={150.00} currency="EUR" show_code={true} /> - """ - attr :amount, :any, required: true - attr :currency, :string, default: "EUR" - attr :symbol_position, :atom, default: :before, values: [:before, :after] - attr :show_code, :boolean, default: false - attr :class, :string, default: "" - - def currency_amount(assigns) do - assigns = assign(assigns, :formatted, format_amount(assigns.amount, assigns.currency)) - - ~H""" - - <%= if @symbol_position == :before do %> - {currency_symbol(@currency)}{@formatted} - <% else %> - {@formatted}{currency_symbol(@currency)} - <% end %> - <%= if @show_code do %> - {@currency} - <% end %> - - """ - end - - @doc """ - Displays a compact currency amount, useful for tables and lists. - - ## Attributes - - `amount` - Decimal or number to display (required) - - `currency` - Currency code (default: "EUR") - - `class` - Additional CSS classes - - ## Examples - - <.currency_compact amount={99.99} /> - <.currency_compact amount={@invoice.total} currency="USD" /> - """ - attr :amount, :any, required: true - attr :currency, :string, default: "EUR" - attr :class, :string, default: "" - - def currency_compact(assigns) do - assigns = assign(assigns, :formatted, format_amount(assigns.amount, assigns.currency)) - - ~H""" - - {currency_symbol(@currency)}{@formatted} - - """ - end - - @doc """ - Displays currency with styling for positive/negative amounts. - - ## Attributes - - `amount` - Decimal or number to display (required) - - `currency` - Currency code (default: "EUR") - - `class` - Additional CSS classes - - ## Examples - - <.currency_colored amount={100.00} /> - <.currency_colored amount={-50.00} currency="USD" /> - """ - attr :amount, :any, required: true - attr :currency, :string, default: "EUR" - attr :class, :string, default: "" - - def currency_colored(assigns) do - assigns = - assigns - |> assign(:formatted, format_amount(assigns.amount, assigns.currency)) - |> assign(:color_class, amount_color_class(assigns.amount)) - - ~H""" - - {currency_symbol(@currency)}{@formatted} - - """ - end - - @doc """ - Displays a currency badge with symbol and name. - - ## Attributes - - `code` - Currency code like "EUR", "USD" (required) - - `name` - Currency name (optional, will be looked up if not provided) - - `size` - Badge size: :xs, :sm, :md, :lg (default: :sm) - - `class` - Additional CSS classes - - ## Examples - - <.currency_badge code="EUR" /> - <.currency_badge code="USD" name="US Dollar" size={:md} /> - """ - attr :code, :string, required: true - attr :name, :string, default: nil - attr :size, :atom, default: :sm, values: [:xs, :sm, :md, :lg] - attr :class, :string, default: "" - - def currency_badge(assigns) do - assigns = - assign_new(assigns, :display_name, fn -> assigns.name || currency_name(assigns.code) end) - - ~H""" - - {currency_symbol(@code)} - {@code} - <%= if @display_name do %> - - {@display_name} - <% end %> - - """ - end - - # Private helper functions - - defp format_amount(nil, _currency), do: "0.00" - - defp format_amount(amount, currency) when is_struct(amount, Decimal) do - places = decimal_places(currency) - - amount - |> Decimal.round(places) - |> Decimal.to_string() - |> format_with_separators() - end - - defp format_amount(amount, currency) when is_number(amount) do - places = decimal_places(currency) - - :erlang.float_to_binary(amount / 1, decimals: places) - |> format_with_separators() - end - - defp format_amount(amount, currency) when is_binary(amount) do - case Decimal.parse(amount) do - {decimal, _} -> format_amount(decimal, currency) - :error -> "0.00" - end - end - - defp format_with_separators(str) do - [integer, decimal] = - case String.split(str, ".") do - [int] -> [int, "00"] - [int, dec] -> [int, String.pad_trailing(dec, 2, "0")] - end - - formatted_int = - integer - |> String.reverse() - |> String.graphemes() - |> Enum.chunk_every(3) - |> Enum.join(",") - |> String.reverse() - - "#{formatted_int}.#{decimal}" - end - - defp currency_symbol("EUR"), do: "€" - defp currency_symbol("USD"), do: "$" - defp currency_symbol("GBP"), do: "£" - defp currency_symbol("JPY"), do: "¥" - defp currency_symbol("CHF"), do: "CHF " - defp currency_symbol("CAD"), do: "C$" - defp currency_symbol("AUD"), do: "A$" - defp currency_symbol("PLN"), do: "zł" - defp currency_symbol("SEK"), do: "kr" - defp currency_symbol("NOK"), do: "kr" - defp currency_symbol("DKK"), do: "kr" - defp currency_symbol("CZK"), do: "Kč" - defp currency_symbol("HUF"), do: "Ft" - defp currency_symbol("RON"), do: "lei" - defp currency_symbol("BGN"), do: "лв" - defp currency_symbol("INR"), do: "₹" - defp currency_symbol(code), do: "#{code} " - - defp currency_name("EUR"), do: "Euro" - defp currency_name("USD"), do: "US Dollar" - defp currency_name("GBP"), do: "British Pound" - defp currency_name("JPY"), do: "Japanese Yen" - defp currency_name("CHF"), do: "Swiss Franc" - defp currency_name("CAD"), do: "Canadian Dollar" - defp currency_name("AUD"), do: "Australian Dollar" - defp currency_name("PLN"), do: "Polish Zloty" - defp currency_name("SEK"), do: "Swedish Krona" - defp currency_name("NOK"), do: "Norwegian Krone" - defp currency_name("DKK"), do: "Danish Krone" - defp currency_name("CZK"), do: "Czech Koruna" - defp currency_name("HUF"), do: "Hungarian Forint" - defp currency_name("RON"), do: "Romanian Leu" - defp currency_name("BGN"), do: "Bulgarian Lev" - defp currency_name("INR"), do: "Indian Rupee" - defp currency_name(_), do: nil - - defp decimal_places("JPY"), do: 0 - defp decimal_places("HUF"), do: 0 - defp decimal_places(_), do: 2 - - defp amount_color_class(amount) when is_struct(amount, Decimal) do - cond do - Decimal.negative?(amount) -> "text-error" - Decimal.positive?(amount) -> "text-success" - true -> "text-base-content" - end - end - - defp amount_color_class(amount) when is_number(amount) do - cond do - amount < 0 -> "text-error" - amount > 0 -> "text-success" - true -> "text-base-content" - end - end - - defp amount_color_class(_), do: "text-base-content" - - defp size_class(:xs), do: "badge-xs" - defp size_class(:sm), do: "badge-sm" - defp size_class(:md), do: "badge-md" - defp size_class(:lg), do: "badge-lg" -end diff --git a/lib/phoenix_kit_web/components/core/invoice_status_badge.ex b/lib/phoenix_kit_web/components/core/invoice_status_badge.ex deleted file mode 100644 index 8b480c5a5..000000000 --- a/lib/phoenix_kit_web/components/core/invoice_status_badge.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule PhoenixKitWeb.Components.Core.InvoiceStatusBadge do - @moduledoc """ - Provides invoice status badge components for the billing system. - - Supports all invoice lifecycle statuses with appropriate color coding. - Follows daisyUI badge styling conventions. - """ - - use Phoenix.Component - - @doc """ - Renders an invoice status badge with appropriate styling. - - ## Attributes - - `status` - Invoice status string (required) - - `size` - Badge size: :xs, :sm, :md, :lg (default: :sm) - - `class` - Additional CSS classes - - ## Supported Statuses - - `draft` - Invoice in draft state (ghost/gray) - - `sent` - Invoice sent to customer (info/blue) - - `paid` - Invoice paid successfully (success/green) - - `void` - Invoice voided (error/red) - - `overdue` - Invoice payment overdue (warning/yellow) - - ## Examples - - <.invoice_status_badge status="paid" /> - <.invoice_status_badge status="overdue" size={:md} /> - <.invoice_status_badge status={@invoice.status} class="ml-2" /> - """ - attr :status, :string, required: true - attr :size, :atom, default: :sm, values: [:xs, :sm, :md, :lg] - attr :class, :string, default: "" - - def invoice_status_badge(assigns) do - ~H""" - - {format_status(@status)} - - """ - end - - # Private helper functions - - # Invoice status badge classes - defp status_class("draft"), do: "badge-ghost" - defp status_class("sent"), do: "badge-info" - defp status_class("paid"), do: "badge-success" - defp status_class("void"), do: "badge-error" - defp status_class("overdue"), do: "badge-warning" - defp status_class(_), do: "badge-ghost" - - # Format status text for display - defp format_status(status), do: String.capitalize(status) - - # Size classes - defp size_class(:xs), do: "badge-xs" - defp size_class(:sm), do: "badge-sm" - defp size_class(:md), do: "badge-md" - defp size_class(:lg), do: "badge-lg" -end diff --git a/lib/phoenix_kit_web/components/core/order_status_badge.ex b/lib/phoenix_kit_web/components/core/order_status_badge.ex deleted file mode 100644 index 4479a40ff..000000000 --- a/lib/phoenix_kit_web/components/core/order_status_badge.ex +++ /dev/null @@ -1,64 +0,0 @@ -defmodule PhoenixKitWeb.Components.Core.OrderStatusBadge do - @moduledoc """ - Provides order status badge components for the billing system. - - Supports all order lifecycle statuses with appropriate color coding. - Follows daisyUI badge styling conventions. - """ - - use Phoenix.Component - - @doc """ - Renders an order status badge with appropriate styling. - - ## Attributes - - `status` - Order status string (required) - - `size` - Badge size: :xs, :sm, :md, :lg (default: :sm) - - `class` - Additional CSS classes - - ## Supported Statuses - - `draft` - Order in draft state (ghost/gray) - - `pending` - Order pending confirmation (warning/yellow) - - `confirmed` - Order confirmed (info/blue) - - `paid` - Order paid successfully (success/green) - - `cancelled` - Order cancelled (error/red) - - `refunded` - Order refunded (secondary/purple) - - ## Examples - - <.order_status_badge status="paid" /> - <.order_status_badge status="pending" size={:md} /> - <.order_status_badge status={@order.status} class="ml-2" /> - """ - attr :status, :string, required: true - attr :size, :atom, default: :sm, values: [:xs, :sm, :md, :lg] - attr :class, :string, default: "" - - def order_status_badge(assigns) do - ~H""" - - {format_status(@status)} - - """ - end - - # Private helper functions - - # Order status badge classes - defp status_class("draft"), do: "badge-ghost" - defp status_class("pending"), do: "badge-warning" - defp status_class("confirmed"), do: "badge-info" - defp status_class("paid"), do: "badge-success" - defp status_class("cancelled"), do: "badge-error" - defp status_class("refunded"), do: "badge-secondary" - defp status_class(_), do: "badge-ghost" - - # Format status text for display - defp format_status(status), do: String.capitalize(status) - - # Size classes - defp size_class(:xs), do: "badge-xs" - defp size_class(:sm), do: "badge-sm" - defp size_class(:md), do: "badge-md" - defp size_class(:lg), do: "badge-lg" -end diff --git a/lib/phoenix_kit_web/components/core/transaction_type_badge.ex b/lib/phoenix_kit_web/components/core/transaction_type_badge.ex deleted file mode 100644 index 1ed1addd5..000000000 --- a/lib/phoenix_kit_web/components/core/transaction_type_badge.ex +++ /dev/null @@ -1,53 +0,0 @@ -defmodule PhoenixKitWeb.Components.Core.TransactionTypeBadge do - @moduledoc """ - Provides transaction type badge components for the billing system. - - Supports payment and refund types with appropriate color coding. - Follows daisyUI badge styling conventions. - """ - - use Phoenix.Component - - @doc """ - Renders a transaction type badge with appropriate styling. - - ## Attributes - - `type` - Transaction type string: "payment" or "refund" (required) - - `size` - Badge size: :xs, :sm, :md, :lg (default: :sm) - - `class` - Additional CSS classes - - ## Supported Types - - `payment` - Positive transaction (success/green) - - `refund` - Negative transaction (error/red) - - ## Examples - - <.transaction_type_badge type="payment" /> - <.transaction_type_badge type="refund" size={:md} /> - """ - attr :type, :string, required: true - attr :size, :atom, default: :sm, values: [:xs, :sm, :md, :lg] - attr :class, :string, default: "" - - def transaction_type_badge(assigns) do - ~H""" - - {format_type(@type)} - - """ - end - - # Type badge classes - defp type_class("payment"), do: "badge-success" - defp type_class("refund"), do: "badge-error" - defp type_class(_), do: "badge-ghost" - - # Format type text for display - defp format_type(type), do: String.capitalize(type) - - # Size classes - defp size_class(:xs), do: "badge-xs" - defp size_class(:sm), do: "badge-sm" - defp size_class(:md), do: "badge-md" - defp size_class(:lg), do: "badge-lg" -end From 4daba29f77fde161e5c6eed73d4e565b809f89f2 Mon Sep 17 00:00:00 2001 From: timujeen Date: Wed, 25 Mar 2026 10:33:50 +0000 Subject: [PATCH 5/8] Remove billing module from core (extracted to phoenix_kit_billing package) --- lib/modules/billing/billing.ex | 3341 ----------------- lib/modules/billing/events.ex | 342 -- lib/modules/billing/providers/paypal.ex | 611 --- lib/modules/billing/providers/provider.ex | 259 -- lib/modules/billing/providers/providers.ex | 373 -- lib/modules/billing/providers/razorpay.ex | 509 --- lib/modules/billing/providers/stripe.ex | 802 ---- .../billing/providers/types/charge_result.ex | 26 - .../providers/types/checkout_session.ex | 24 - .../providers/types/payment_method_info.ex | 47 - .../billing/providers/types/provider_info.ex | 22 - .../billing/providers/types/refund_result.ex | 24 - .../billing/providers/types/setup_session.ex | 22 - .../providers/types/webhook_event_data.ex | 26 - .../billing/schemas/billing_profile.ex | 269 -- lib/modules/billing/schemas/currency.ex | 154 - lib/modules/billing/schemas/invoice.ex | 399 -- lib/modules/billing/schemas/order.ex | 391 -- lib/modules/billing/schemas/payment_method.ex | 200 - lib/modules/billing/schemas/payment_option.ex | 128 - lib/modules/billing/schemas/subscription.ex | 286 -- .../billing/schemas/subscription_type.ex | 179 - lib/modules/billing/schemas/transaction.ex | 109 - lib/modules/billing/schemas/webhook_event.ex | 117 - lib/modules/billing/utils/country_data.ex | 602 --- lib/modules/billing/utils/iban_data.ex | 199 - .../billing/utils/webhook_processor.ex | 356 -- lib/modules/billing/web/README.md | 299 -- .../billing/web/billing_profile_form.ex | 143 - .../web/billing_profile_form.html.heex | 450 --- lib/modules/billing/web/billing_profiles.ex | 150 - .../billing/web/billing_profiles.html.heex | 186 - lib/modules/billing/web/credit_note_print.ex | 98 - .../billing/web/credit_note_print.html.heex | 654 ---- lib/modules/billing/web/currencies.ex | 335 -- lib/modules/billing/web/currencies.html.heex | 413 -- lib/modules/billing/web/index.ex | 70 - lib/modules/billing/web/index.html.heex | 264 -- lib/modules/billing/web/invoice_detail.ex | 266 -- .../billing/web/invoice_detail.html.heex | 1017 ----- .../billing/web/invoice_detail/actions.ex | 301 -- .../billing/web/invoice_detail/helpers.ex | 212 -- .../web/invoice_detail/timeline_event.ex | 34 - lib/modules/billing/web/invoice_print.ex | 94 - .../billing/web/invoice_print.html.heex | 748 ---- lib/modules/billing/web/invoices.ex | 175 - lib/modules/billing/web/invoices.html.heex | 183 - lib/modules/billing/web/order_detail.ex | 126 - .../billing/web/order_detail.html.heex | 363 -- lib/modules/billing/web/order_form.ex | 338 -- lib/modules/billing/web/order_form.html.heex | 288 -- lib/modules/billing/web/orders.ex | 183 - lib/modules/billing/web/orders.html.heex | 183 - .../billing/web/payment_confirmation_print.ex | 128 - .../web/payment_confirmation_print.html.heex | 597 --- lib/modules/billing/web/provider_settings.ex | 179 - .../billing/web/provider_settings.html.heex | 447 --- lib/modules/billing/web/receipt_print.ex | 111 - .../billing/web/receipt_print.html.heex | 873 ----- lib/modules/billing/web/settings.ex | 159 - lib/modules/billing/web/settings.html.heex | 333 -- .../billing/web/subscription_detail.ex | 201 - .../billing/web/subscription_detail.html.heex | 402 -- lib/modules/billing/web/subscription_form.ex | 234 -- .../billing/web/subscription_form.html.heex | 310 -- .../billing/web/subscription_type_form.ex | 162 - .../web/subscription_type_form.html.heex | 328 -- lib/modules/billing/web/subscription_types.ex | 116 - .../billing/web/subscription_types.html.heex | 173 - lib/modules/billing/web/subscriptions.ex | 190 - .../billing/web/subscriptions.html.heex | 246 -- lib/modules/billing/web/transactions.ex | 172 - .../billing/web/transactions.html.heex | 209 -- .../billing/web/user_billing_profile_form.ex | 533 --- .../billing/web/user_billing_profiles.ex | 237 -- lib/modules/billing/web/webhook_controller.ex | 160 - .../workers/subscription_dunning_worker.ex | 212 -- .../workers/subscription_renewal_worker.ex | 269 -- 78 files changed, 24371 deletions(-) delete mode 100644 lib/modules/billing/billing.ex delete mode 100644 lib/modules/billing/events.ex delete mode 100644 lib/modules/billing/providers/paypal.ex delete mode 100644 lib/modules/billing/providers/provider.ex delete mode 100644 lib/modules/billing/providers/providers.ex delete mode 100644 lib/modules/billing/providers/razorpay.ex delete mode 100644 lib/modules/billing/providers/stripe.ex delete mode 100644 lib/modules/billing/providers/types/charge_result.ex delete mode 100644 lib/modules/billing/providers/types/checkout_session.ex delete mode 100644 lib/modules/billing/providers/types/payment_method_info.ex delete mode 100644 lib/modules/billing/providers/types/provider_info.ex delete mode 100644 lib/modules/billing/providers/types/refund_result.ex delete mode 100644 lib/modules/billing/providers/types/setup_session.ex delete mode 100644 lib/modules/billing/providers/types/webhook_event_data.ex delete mode 100644 lib/modules/billing/schemas/billing_profile.ex delete mode 100644 lib/modules/billing/schemas/currency.ex delete mode 100644 lib/modules/billing/schemas/invoice.ex delete mode 100644 lib/modules/billing/schemas/order.ex delete mode 100644 lib/modules/billing/schemas/payment_method.ex delete mode 100644 lib/modules/billing/schemas/payment_option.ex delete mode 100644 lib/modules/billing/schemas/subscription.ex delete mode 100644 lib/modules/billing/schemas/subscription_type.ex delete mode 100644 lib/modules/billing/schemas/transaction.ex delete mode 100644 lib/modules/billing/schemas/webhook_event.ex delete mode 100644 lib/modules/billing/utils/country_data.ex delete mode 100644 lib/modules/billing/utils/iban_data.ex delete mode 100644 lib/modules/billing/utils/webhook_processor.ex delete mode 100644 lib/modules/billing/web/README.md delete mode 100644 lib/modules/billing/web/billing_profile_form.ex delete mode 100644 lib/modules/billing/web/billing_profile_form.html.heex delete mode 100644 lib/modules/billing/web/billing_profiles.ex delete mode 100644 lib/modules/billing/web/billing_profiles.html.heex delete mode 100644 lib/modules/billing/web/credit_note_print.ex delete mode 100644 lib/modules/billing/web/credit_note_print.html.heex delete mode 100644 lib/modules/billing/web/currencies.ex delete mode 100644 lib/modules/billing/web/currencies.html.heex delete mode 100644 lib/modules/billing/web/index.ex delete mode 100644 lib/modules/billing/web/index.html.heex delete mode 100644 lib/modules/billing/web/invoice_detail.ex delete mode 100644 lib/modules/billing/web/invoice_detail.html.heex delete mode 100644 lib/modules/billing/web/invoice_detail/actions.ex delete mode 100644 lib/modules/billing/web/invoice_detail/helpers.ex delete mode 100644 lib/modules/billing/web/invoice_detail/timeline_event.ex delete mode 100644 lib/modules/billing/web/invoice_print.ex delete mode 100644 lib/modules/billing/web/invoice_print.html.heex delete mode 100644 lib/modules/billing/web/invoices.ex delete mode 100644 lib/modules/billing/web/invoices.html.heex delete mode 100644 lib/modules/billing/web/order_detail.ex delete mode 100644 lib/modules/billing/web/order_detail.html.heex delete mode 100644 lib/modules/billing/web/order_form.ex delete mode 100644 lib/modules/billing/web/order_form.html.heex delete mode 100644 lib/modules/billing/web/orders.ex delete mode 100644 lib/modules/billing/web/orders.html.heex delete mode 100644 lib/modules/billing/web/payment_confirmation_print.ex delete mode 100644 lib/modules/billing/web/payment_confirmation_print.html.heex delete mode 100644 lib/modules/billing/web/provider_settings.ex delete mode 100644 lib/modules/billing/web/provider_settings.html.heex delete mode 100644 lib/modules/billing/web/receipt_print.ex delete mode 100644 lib/modules/billing/web/receipt_print.html.heex delete mode 100644 lib/modules/billing/web/settings.ex delete mode 100644 lib/modules/billing/web/settings.html.heex delete mode 100644 lib/modules/billing/web/subscription_detail.ex delete mode 100644 lib/modules/billing/web/subscription_detail.html.heex delete mode 100644 lib/modules/billing/web/subscription_form.ex delete mode 100644 lib/modules/billing/web/subscription_form.html.heex delete mode 100644 lib/modules/billing/web/subscription_type_form.ex delete mode 100644 lib/modules/billing/web/subscription_type_form.html.heex delete mode 100644 lib/modules/billing/web/subscription_types.ex delete mode 100644 lib/modules/billing/web/subscription_types.html.heex delete mode 100644 lib/modules/billing/web/subscriptions.ex delete mode 100644 lib/modules/billing/web/subscriptions.html.heex delete mode 100644 lib/modules/billing/web/transactions.ex delete mode 100644 lib/modules/billing/web/transactions.html.heex delete mode 100644 lib/modules/billing/web/user_billing_profile_form.ex delete mode 100644 lib/modules/billing/web/user_billing_profiles.ex delete mode 100644 lib/modules/billing/web/webhook_controller.ex delete mode 100644 lib/modules/billing/workers/subscription_dunning_worker.ex delete mode 100644 lib/modules/billing/workers/subscription_renewal_worker.ex diff --git a/lib/modules/billing/billing.ex b/lib/modules/billing/billing.ex deleted file mode 100644 index 5656bfd96..000000000 --- a/lib/modules/billing/billing.ex +++ /dev/null @@ -1,3341 +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.Modules.Emails.Templates - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.UUID, as: UUIDUtils - - # ============================================ - # SYSTEM ENABLE/DISABLE - # ============================================ - - @impl PhoenixKit.Module - @doc """ - Checks if the billing system is enabled. - """ - def enabled? do - Settings.get_boolean_setting("billing_enabled", false) - end - - @impl PhoenixKit.Module - @doc """ - Enables the billing system. - """ - def enable_system do - result = Settings.update_boolean_setting_with_module("billing_enabled", true, "billing") - refresh_dashboard_tabs() - result - end - - @impl PhoenixKit.Module - @doc """ - Disables the billing system. - """ - def disable_system do - result = Settings.update_boolean_setting_with_module("billing_enabled", false, "billing") - refresh_dashboard_tabs() - result - end - - defp refresh_dashboard_tabs do - if Code.ensure_loaded?(PhoenixKit.Dashboard.Registry) and - PhoenixKit.Dashboard.Registry.initialized?() do - PhoenixKit.Dashboard.Registry.load_defaults() - end - end - - # ============================================ - # MODULE BEHAVIOUR CALLBACKS - # ============================================ - - @impl PhoenixKit.Module - def module_key, do: "billing" - - @impl PhoenixKit.Module - def module_name, do: "Billing" - - @impl PhoenixKit.Module - def permission_metadata do - %{ - key: "billing", - label: "Billing", - icon: "hero-credit-card", - description: "Payment providers, subscriptions, and invoices" - } - end - - @impl PhoenixKit.Module - def admin_tabs do - [ - Tab.new!( - id: :admin_billing, - label: "Billing", - icon: "hero-banknotes", - path: "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) - - Templates.send_email( - "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) - - Templates.send_email( - "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) - - Templates.send_email( - "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) - - Templates.send_email( - "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: "

", - 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/country_data.ex b/lib/modules/billing/utils/country_data.ex deleted file mode 100644 index b5e71bb54..000000000 --- a/lib/modules/billing/utils/country_data.ex +++ /dev/null @@ -1,602 +0,0 @@ -defmodule PhoenixKit.Modules.Billing.CountryData do - @moduledoc """ - Wrapper for BeamLabCountries with billing-specific functions. - - Provides a convenient API for working with country data in a billing context: - country selection, tax rates, EU membership. - - Includes workaround for charlist bug in VAT rates until fixed upstream. - - ## Examples - - # Get list of countries for dropdown - countries = CountryData.countries_for_select() - # [{"🇦🇩 Andorra", "AD"}, {"🇦🇪 United Arab Emirates", "AE"}, ...] - - # Get standard VAT rate - rate = CountryData.get_standard_vat_rate("EE") - # #Decimal<0.20> - - # Check EU membership - CountryData.eu_member?("EE") - # true - - # Get country information - country = CountryData.get_country("DE") - # %BeamLabCountries.Country{name: "Germany", ...} - - # Format company address from Settings - address = CountryData.format_company_address() - # "123 Business Street\\nTallinn 10115\\nEstonia" - """ - - alias PhoenixKit.Settings - - @doc """ - Get all countries sorted by name. - - ## Examples - - iex> countries = CountryData.list_countries() - iex> length(countries) - 250 - iex> hd(countries).name - "Afghanistan" - """ - def list_countries do - BeamLabCountries.all() - |> Enum.sort_by(& &1.name) - end - - @doc """ - Get country by alpha-2 code. - - ## Examples - - iex> country = CountryData.get_country("EE") - iex> country.name - "Estonia" - - iex> CountryData.get_country("XX") - nil - """ - def get_country(code) when is_binary(code) do - BeamLabCountries.get(code) - end - - def get_country(_), do: nil - - @doc """ - Get standard VAT rate for a country as Decimal. - - Returns rate in decimal format (0.20 = 20%). - If country not found or has no VAT rates, returns 0. - - ## Examples - - iex> CountryData.get_standard_vat_rate("EE") - #Decimal<0.20> - - iex> CountryData.get_standard_vat_rate("DE") - #Decimal<0.19> - - iex> CountryData.get_standard_vat_rate("US") - #Decimal<0> - """ - def get_standard_vat_rate(country_code) when is_binary(country_code) do - case get_country(country_code) do - %{vat_rates: %{standard: rate}} when is_number(rate) -> - rate - |> Decimal.new() - |> Decimal.div(100) - - _ -> - Decimal.new("0") - end - end - - def get_standard_vat_rate(_), do: Decimal.new("0") - - @doc """ - Get standard VAT rate as percentage (integer). - - Returns rate as percentage (20 = 20%). - - ## Examples - - iex> CountryData.get_standard_vat_percent("EE") - 20 - - iex> CountryData.get_standard_vat_percent("DE") - 19 - - iex> CountryData.get_standard_vat_percent("US") - 0 - """ - def get_standard_vat_percent(country_code) when is_binary(country_code) do - case get_country(country_code) do - %{vat_rates: %{standard: rate}} when is_number(rate) -> rate - _ -> 0 - end - end - - def get_standard_vat_percent(_), do: 0 - - @doc """ - Get all VAT rates with workaround for charlist bug. - - Returns map with normalized rates: - - :standard - standard rate (integer) - - :reduced - reduced rates (list of integers) - - :super_reduced - super reduced rate (integer or nil) - - :parking - parking rate (integer or nil) - - ## Examples - - iex> CountryData.get_vat_rates("EE") - %{standard: 20, reduced: [9], super_reduced: nil, parking: nil} - - iex> CountryData.get_vat_rates("FR") - %{standard: 20, reduced: [5.5, 10], super_reduced: 2.1, parking: nil} - - iex> CountryData.get_vat_rates("US") - nil - """ - def get_vat_rates(country_code) when is_binary(country_code) do - case get_country(country_code) do - %{vat_rates: rates} when is_map(rates) -> normalize_rates(rates) - _ -> nil - end - end - - def get_vat_rates(_), do: nil - - @doc """ - Check if country is an EU member. - - ## Examples - - iex> CountryData.eu_member?("EE") - true - - iex> CountryData.eu_member?("GB") - false - - iex> CountryData.eu_member?("US") - false - """ - def eu_member?(country_code) when is_binary(country_code) do - case get_country(country_code) do - %{eu_member: true} -> true - _ -> false - end - end - - def eu_member?(_), do: false - - @doc """ - Check if country is an EEA (European Economic Area) member. - - EEA includes EU + Norway, Iceland, Liechtenstein. - - ## Examples - - iex> CountryData.eea_member?("EE") - true - - iex> CountryData.eea_member?("NO") - true - - iex> CountryData.eea_member?("CH") - false - """ - def eea_member?(country_code) when is_binary(country_code) do - case get_country(country_code) do - %{eea_member: true} -> true - _ -> false - end - end - - def eea_member?(_), do: false - - @doc """ - Get list of EU countries. - - ## Examples - - iex> eu = CountryData.eu_countries() - iex> length(eu) - 27 - iex> Enum.map(eu, & &1.alpha2) |> Enum.sort() |> Enum.take(5) - ["AT", "BE", "BG", "CY", "CZ"] - """ - def eu_countries do - BeamLabCountries.filter_by(:eu_member, true) - end - - @doc """ - Get list of EEA countries (EU + Norway, Iceland, Liechtenstein). - """ - def eea_countries do - BeamLabCountries.filter_by(:eea_member, true) - end - - @doc """ - Get list of countries for select dropdown. - - Returns list of tuples {display_name, alpha2_code} for use - in Phoenix form selects. - - ## Examples - - iex> countries = CountryData.countries_for_select() - iex> {"🇦🇫 Afghanistan", "AF"} in countries - true - """ - def countries_for_select do - list_countries() - |> Enum.map(fn c -> - display_name = - case c.flag do - nil -> c.name - "" -> c.name - flag -> flag <> " " <> c.name - end - - {display_name, c.alpha2} - end) - end - - @doc """ - Get the subdivision label for a country. - - Returns appropriate label like "State", "Province", "Region", etc. - based on what the country uses for administrative divisions. - - ## Examples - - iex> CountryData.get_subdivision_label("US") - "State" - - iex> CountryData.get_subdivision_label("CA") - "Province" - - iex> CountryData.get_subdivision_label("EE") - "County" - """ - def get_subdivision_label(nil), do: "State/Province" - def get_subdivision_label(""), do: "State/Province" - - def get_subdivision_label(alpha2) when is_binary(alpha2) do - case BeamLabCountries.get(alpha2) do - nil -> "State/Province" - country -> Map.get(country, :subdivision_type) || "State/Province" - end - end - - @doc """ - Get list of EU countries for select dropdown. - """ - def eu_countries_for_select do - eu_countries() - |> Enum.sort_by(& &1.name) - |> Enum.map(fn c -> - display_name = - case c.flag do - nil -> c.name - "" -> c.name - flag -> flag <> " " <> c.name - end - - {display_name, c.alpha2} - end) - end - - @doc """ - Get country currency code. - - ## Examples - - iex> CountryData.get_currency_code("EE") - "EUR" - - iex> CountryData.get_currency_code("GB") - "GBP" - - iex> CountryData.get_currency_code("US") - "USD" - """ - def get_currency_code(country_code) when is_binary(country_code) do - case get_country(country_code) do - %{currency_code: code} when is_binary(code) -> code - _ -> nil - end - end - - def get_currency_code(_), do: nil - - @doc """ - Get country name. - - ## Examples - - iex> CountryData.get_country_name("EE") - "Estonia" - - iex> CountryData.get_country_name("XX") - nil - """ - def get_country_name(country_code) when is_binary(country_code) do - case get_country(country_code) do - %{name: name} -> name - _ -> nil - end - end - - def get_country_name(_), do: nil - - @doc """ - Get country flag (emoji). - - ## Examples - - iex> CountryData.get_flag("EE") - "🇪🇪" - """ - def get_flag(country_code) when is_binary(country_code) do - case get_country(country_code) do - %{flag: flag} -> flag - _ -> nil - end - end - - def get_flag(_), do: nil - - @doc """ - Check if country with given code exists. - - ## Examples - - iex> CountryData.exists?("EE") - true - - iex> CountryData.exists?("XX") - false - """ - def exists?(country_code) when is_binary(country_code) do - get_country(country_code) != nil - end - - def exists?(_), do: false - - @doc """ - Format company address from Settings for document printing. - - Assembles address from individual fields (address_line1, address_line2, city, state, - postal_code, country) into a single string with line breaks. - - ## Returns - - Formatted address as string, for example: - ``` - 123 Business Street - Suite 100 - Tallinn 10115 - Estonia - ``` - - ## Examples - - iex> CountryData.format_company_address() - "123 Business Street\\nTallinn 10115\\nEstonia" - """ - def format_company_address do - company_info = get_company_info() - - address_line1 = company_info["address_line1"] || "" - address_line2 = company_info["address_line2"] || "" - city = company_info["city"] || "" - state = company_info["state"] || "" - postal_code = company_info["postal_code"] || "" - country_code = company_info["country"] || "" - - country_name = - case get_country(country_code) do - %{name: name} -> name - _ -> country_code - end - - city_postal = - [city, postal_code] - |> Enum.filter(&(&1 != "")) - |> Enum.join(" ") - - [address_line1, address_line2, city_postal, state, country_name] - |> Enum.filter(&(&1 != "" && &1 != " ")) - |> Enum.join("\n") - end - - @doc """ - Get company information from consolidated Settings. - - Reads from `company_info` JSONB with fallback to legacy `billing_company_*` keys. - """ - def get_company_info do - case Settings.get_json_setting("company_info", nil) do - nil -> - # Fallback to legacy billing_company_* keys - %{ - "name" => Settings.get_setting("billing_company_name", ""), - "address_line1" => Settings.get_setting("billing_company_address_line1", ""), - "address_line2" => Settings.get_setting("billing_company_address_line2", ""), - "city" => Settings.get_setting("billing_company_city", ""), - "state" => Settings.get_setting("billing_company_state", ""), - "postal_code" => Settings.get_setting("billing_company_postal_code", ""), - "country" => Settings.get_setting("billing_company_country", ""), - "vat_number" => Settings.get_setting("billing_company_vat", ""), - "registration_number" => "" - } - - info when is_map(info) -> - info - - _ -> - %{} - end - end - - @doc """ - Get bank details from consolidated Settings. - - Reads from `company_bank_details` JSONB with fallback to legacy `billing_bank_*` keys. - """ - def get_bank_details do - case Settings.get_json_setting("company_bank_details", nil) do - nil -> - # Fallback to legacy billing_bank_* keys - %{ - "bank_name" => Settings.get_setting("billing_bank_name", ""), - "iban" => Settings.get_setting("billing_bank_iban", ""), - "swift" => Settings.get_setting("billing_bank_swift", "") - } - - info when is_map(info) -> - info - - _ -> - %{} - end - end - - # ========================================================================== - # Banking Validation Functions - # ========================================================================== - - alias PhoenixKit.Modules.Billing.IbanData - - @doc """ - Validate IBAN format (length based on bank country, not company country). - - Bank can be in a different country than the company - this is legal. - Validates format and length based on IBAN's country prefix. - - Returns :ok or {:error, reason}. - - ## Examples - - iex> CountryData.validate_iban_format("EE382200221020145685", "EE") - :ok - - iex> CountryData.validate_iban_format("DE89370400440532013000", "EE") - :ok # German bank for Estonian company is valid - - iex> CountryData.validate_iban_format("DE123", "EE") - {:error, "IBAN must be 22 characters for DE"} - """ - def validate_iban_format(iban, _country_code) - when is_binary(iban) do - iban = String.replace(iban, ~r/\s/, "") |> String.upcase() - iban_country = String.slice(iban, 0, 2) - expected_length = IbanData.get_iban_length(iban_country) - - cond do - iban == "" -> - :ok - - expected_length == nil -> - # Unknown IBAN country - just validate basic format - if Regex.match?(~r/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/, iban) do - :ok - else - {:error, "Invalid IBAN format"} - end - - String.length(iban) != expected_length -> - {:error, "IBAN must be #{expected_length} characters for #{iban_country}"} - - not Regex.match?(~r/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/, iban) -> - {:error, "Invalid IBAN format"} - - true -> - :ok - end - end - - def validate_iban_format(_, _), do: :ok - - @doc """ - Validate SWIFT/BIC format (8 or 11 characters). - - SWIFT codes structure: - - 4 letters: bank code - - 2 letters: country code (ISO 3166) - - 2 characters: location code - - 3 characters (optional): branch code - - ## Examples - - iex> CountryData.validate_swift_format("HABAEE2X") - :ok - - iex> CountryData.validate_swift_format("HABAEE2XXXX") - :ok - - iex> CountryData.validate_swift_format("INVALID") - {:error, "SWIFT/BIC must be 8 or 11 characters"} - """ - def validate_swift_format(swift) when is_binary(swift) do - swift = String.replace(swift, ~r/\s/, "") |> String.upcase() - - cond do - swift == "" -> - :ok - - String.length(swift) not in [8, 11] -> - {:error, "SWIFT/BIC must be 8 or 11 characters"} - - not Regex.match?(~r/^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/, swift) -> - {:error, "Invalid SWIFT/BIC format"} - - true -> - :ok - end - end - - def validate_swift_format(_), do: :ok - - # ========================================================================== - # Private Functions - Workaround for charlist bug in BeamLabCountries - # ========================================================================== - # - # YAML parser interprets single-digit numbers in lists as charlists: - # - [9] → ~c"\t" (tab) - # - [7] → ~c"\a" (bell) - # - [10] → ~c"\n" (newline) - # - # These functions normalize data until fixed upstream. - - defp normalize_rates(rates) when is_map(rates) do - Map.new(rates, fn {k, v} -> {k, normalize_rate_value(v)} end) - end - - defp normalize_rate_value(nil), do: nil - - defp normalize_rate_value(list) when is_list(list) do - # If charlist of single element (bug), convert back - if charlist_single_digit?(list) do - [hd(list)] - else - Enum.map(list, &ensure_number/1) - end - end - - defp normalize_rate_value(value), do: value - - # Check if list is a charlist of single ASCII digit code - defp charlist_single_digit?([n]) when is_integer(n) and n >= 0 and n <= 127, do: true - defp charlist_single_digit?(_), do: false - - defp ensure_number(n) when is_integer(n), do: n - defp ensure_number(n) when is_float(n), do: n - defp ensure_number(_), do: nil -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 From 4beda9f76f4da26a3dfa22736466119bdf821818 Mon Sep 17 00:00:00 2001 From: timujeen Date: Wed, 25 Mar 2026 17:59:15 +0000 Subject: [PATCH 6/8] Fix shop modules: remove billing struct patterns and fix nil clause ordering - Replace %Currency{} struct matches with plain variables (cross-package dep) - Replace %PaymentOption{} struct matches with %{} map patterns - Move nil clauses before catch-all in format_price/2 functions - Fix alias ordering for CountryData (Utils.* after Modules.*) - Cross-package Billing references will resolve when phoenix_kit_billing dep added --- lib/modules/shop/shop.ex | 3 +- lib/modules/shop/web/carts.ex | 8 ++-- lib/modules/shop/web/checkout_page.ex | 8 ++-- lib/modules/shop/web/helpers.ex | 8 ++-- lib/modules/shop/web/product_detail.ex | 10 ++-- lib/modules/shop/web/product_form.ex | 12 ++--- lib/modules/shop/web/products.ex | 8 ++-- lib/modules/shop/web/shipping_methods.ex | 8 ++-- lib/modules/shop/web/user_order_details.ex | 8 ++-- lib/modules/shop/web/user_orders.ex | 8 ++-- lib/phoenix_kit_web/live/modules.html.heex | 56 +++++++++++----------- 11 files changed, 68 insertions(+), 69 deletions(-) diff --git a/lib/modules/shop/shop.ex b/lib/modules/shop/shop.ex index 7c7a1602c..fa82dc332 100644 --- a/lib/modules/shop/shop.ex +++ b/lib/modules/shop/shop.ex @@ -36,7 +36,6 @@ defmodule PhoenixKit.Modules.Shop do 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 @@ -1850,7 +1849,7 @@ defmodule PhoenixKit.Modules.Shop do @doc """ Sets payment option for cart. """ - def set_cart_payment_option(%Cart{} = cart, %PaymentOption{} = option) do + def set_cart_payment_option(%Cart{} = cart, option) when is_map(option) do result = cart |> Cart.payment_changeset(%{ diff --git a/lib/modules/shop/web/carts.ex b/lib/modules/shop/web/carts.ex index 775f6caf5..91872e4d7 100644 --- a/lib/modules/shop/web/carts.ex +++ b/lib/modules/shop/web/carts.ex @@ -236,14 +236,14 @@ defmodule PhoenixKit.Modules.Shop.Web.Carts do 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(amount, currency) do + Currency.format_amount(amount, currency) + end + defp format_weight(grams) when grams >= 1000, do: "#{Float.round(grams / 1000, 1)} kg" defp format_weight(grams), do: "#{grams} g" diff --git a/lib/modules/shop/web/checkout_page.ex b/lib/modules/shop/web/checkout_page.ex index 981d9a1c5..36a3571b0 100644 --- a/lib/modules/shop/web/checkout_page.ex +++ b/lib/modules/shop/web/checkout_page.ex @@ -10,11 +10,11 @@ defmodule PhoenixKit.Modules.Shop.Web.CheckoutPage do 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 + alias PhoenixKit.Utils.CountryData import PhoenixKit.Modules.Shop.Web.Helpers, only: [ @@ -180,21 +180,21 @@ defmodule PhoenixKit.Modules.Shop.Web.CheckoutPage do defp payment_option_needs_billing?(nil, _is_guest, _profiles), do: true defp payment_option_needs_billing?( - %PaymentOption{requires_billing_profile: true}, + %{requires_billing_profile: true}, _is_guest, _profiles ), do: true defp payment_option_needs_billing?( - %PaymentOption{requires_billing_profile: false}, + %{requires_billing_profile: false}, true, _profiles ), do: true defp payment_option_needs_billing?( - %PaymentOption{requires_billing_profile: false}, + %{requires_billing_profile: false}, false, _profiles ), diff --git a/lib/modules/shop/web/helpers.ex b/lib/modules/shop/web/helpers.ex index 46274de74..462c72d2c 100644 --- a/lib/modules/shop/web/helpers.ex +++ b/lib/modules/shop/web/helpers.ex @@ -21,14 +21,14 @@ defmodule PhoenixKit.Modules.Shop.Web.Helpers do @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 + def format_price(price, currency) do + Currency.format_amount(price, currency) + end + # --------------------------------------------------------------------------- # Current user # --------------------------------------------------------------------------- diff --git a/lib/modules/shop/web/product_detail.ex b/lib/modules/shop/web/product_detail.ex index 53a4576bc..3dfdde311 100644 --- a/lib/modules/shop/web/product_detail.ex +++ b/lib/modules/shop/web/product_detail.ex @@ -694,14 +694,14 @@ defmodule PhoenixKit.Modules.Shop.Web.ProductDetail do 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 + defp format_price(price, currency) do + Currency.format_amount(price, currency) + end + # Get signed URL for Storage image (skip URLs - they are legacy Shopify images) defp get_storage_image_url("http" <> _ = _url, _variant), do: nil @@ -822,7 +822,7 @@ defmodule PhoenixKit.Modules.Shop.Web.ProductDetail do "#{Decimal.round(value, 0)}%" end - defp format_modifier(value, _type, %Currency{} = currency) do + defp format_modifier(value, _type, currency) do Currency.format_amount(value, currency) end diff --git a/lib/modules/shop/web/product_form.ex b/lib/modules/shop/web/product_form.ex index 64bf5491d..5aaf27634 100644 --- a/lib/modules/shop/web/product_form.ex +++ b/lib/modules/shop/web/product_form.ex @@ -1798,23 +1798,23 @@ defmodule PhoenixKit.Modules.Shop.Web.ProductForm do defp format_price(nil, _currency), do: "—" defp format_price("", _currency), do: "—" - defp format_price(price, %Currency{} = currency) when is_binary(price) do + defp format_price(price, 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 + defp format_price(price, currency) do + Currency.format_amount(price, currency) + end + # Get currency symbol for display - defp currency_symbol(%Currency{symbol: symbol}), do: symbol + defp currency_symbol(%{symbol: symbol}), do: symbol defp currency_symbol(_), do: "$" # Get modifier override from product metadata diff --git a/lib/modules/shop/web/products.ex b/lib/modules/shop/web/products.ex index 158cc5a67..ab86c7869 100644 --- a/lib/modules/shop/web/products.ex +++ b/lib/modules/shop/web/products.ex @@ -827,15 +827,15 @@ defmodule PhoenixKit.Modules.Shop.Web.Products do 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 + defp format_price(price, currency) do + Currency.format_amount(price, currency) + 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") diff --git a/lib/modules/shop/web/shipping_methods.ex b/lib/modules/shop/web/shipping_methods.ex index 1b4f20055..298442dad 100644 --- a/lib/modules/shop/web/shipping_methods.ex +++ b/lib/modules/shop/web/shipping_methods.ex @@ -185,14 +185,14 @@ defmodule PhoenixKit.Modules.Shop.Web.ShippingMethods do 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_price(amount, currency) do + Currency.format_amount(amount, currency) + 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/user_order_details.ex b/lib/modules/shop/web/user_order_details.ex index b13fd7acd..da54ddcdf 100644 --- a/lib/modules/shop/web/user_order_details.ex +++ b/lib/modules/shop/web/user_order_details.ex @@ -90,14 +90,14 @@ defmodule PhoenixKit.Modules.Shop.Web.UserOrderDetails do 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(amount, currency) do + Currency.format_amount(amount, currency) + 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}" diff --git a/lib/modules/shop/web/user_orders.ex b/lib/modules/shop/web/user_orders.ex index cd64fc693..5b060ff13 100644 --- a/lib/modules/shop/web/user_orders.ex +++ b/lib/modules/shop/web/user_orders.ex @@ -166,14 +166,14 @@ defmodule PhoenixKit.Modules.Shop.Web.UserOrders do 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(amount, currency) do + Currency.format_amount(amount, currency) + end + defp items_count(nil), do: 0 defp items_count([]), do: 0 diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex index 003a3926c..b954bb348 100644 --- a/lib/phoenix_kit_web/live/modules.html.heex +++ b/lib/phoenix_kit_web/live/modules.html.heex @@ -78,7 +78,7 @@ title="Referral Codes" description="Manage referral codes for user registration and monitoring" icon="🎫" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="referrals" > @@ -142,7 +142,7 @@ title="Emails" description="Track and analyze all outgoing emails with delivery events" icon="📧" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="emails" > @@ -218,7 +218,7 @@ title="Languages" description="Manage language module and translations" icon="🌐" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="languages" > @@ -280,7 +280,7 @@ gettext("Define custom content types with flexible fields — no migrations required") } icon="📦" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="entities" > @@ -332,7 +332,7 @@ title="Publishing" description="Publish date-based blog posts and company updates with multi-language support" icon="📰" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="publishing" > @@ -441,7 +441,7 @@ title="Posts" description="User-generated content with comments, likes, tags, and groups" icon="📝" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="posts" > @@ -506,7 +506,7 @@ title="Comments" description="Threaded comments with likes, moderation, and reactions across all content types" icon="💬" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="comments" > @@ -517,7 +517,7 @@ ]}> {if cfg[:enabled], do: "Enabled", else: "Disabled"} - <%= if cfg[:enabled] and cfg[:pending_comments] > 0 do %> + <%= if cfg[:enabled] && cfg[:pending_comments] > 0 do %> {cfg[:pending_comments]} pending @@ -577,7 +577,7 @@ title="Customer Service" description="Customer support ticketing system with internal notes and status tracking" icon="🎫" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="customer_service" > @@ -588,7 +588,7 @@ ]}> {if cfg[:enabled], do: "Enabled", else: "Disabled"} - <%= if cfg[:enabled] and cfg[:open_tickets] > 0 do %> + <%= if cfg[:enabled] && cfg[:open_tickets] > 0 do %> {cfg[:open_tickets]} open @@ -647,7 +647,7 @@ title="Connections" description="Social relationships with one-way follows and mutual connections" icon="🤝" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="connections" > @@ -753,7 +753,7 @@ title="Sitemap" description="Generate XML sitemaps for search engines with styled browser display" icon="🗺️" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="sitemap" > @@ -764,7 +764,7 @@ ]}> {if cfg[:enabled], do: "Enabled", else: "Disabled"} - <%= if cfg[:enabled] and cfg[:schedule_enabled] do %> + <%= if cfg[:enabled] && cfg[:schedule_enabled] do %> Auto-regenerate @@ -823,7 +823,7 @@ title="Billing" description="Orders, invoices, billing profiles and multi-currency support" icon="💰" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="billing" > @@ -904,7 +904,7 @@ title="AI" description="AI provider accounts and text processing with OpenRouter integration" icon="🤖" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="ai" > @@ -915,7 +915,7 @@ ]}> {if cfg[:enabled], do: "Enabled", else: "Disabled"} - <%= if cfg[:enabled] and cfg[:endpoints_count] > 0 do %> + <%= if cfg[:enabled] && cfg[:endpoints_count] > 0 do %> {cfg[:endpoints_count]} endpoint{if cfg[:endpoints_count] != 1, do: "s"} @@ -1029,7 +1029,7 @@ title="Sync" description="Sync data between PhoenixKit instances in real-time" icon="🔄" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="sync" > @@ -1040,7 +1040,7 @@ ]}> {if cfg[:enabled], do: "Enabled", else: "Disabled"} - <%= if cfg[:enabled] and cfg[:active_sessions] > 0 do %> + <%= if cfg[:enabled] && cfg[:active_sessions] > 0 do %> {cfg[:active_sessions]} active @@ -1081,7 +1081,7 @@ title="DB" description="Explore database tables and their contents" icon="🗃️" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="db" stats_title="Current Stats" @@ -1150,7 +1150,7 @@ title="Jobs" description="View-only dashboard for background job status and history" icon="⚙️" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="jobs" > @@ -1161,7 +1161,7 @@ ]}> {if cfg[:enabled], do: "Enabled", else: "Disabled"} - <%= if cfg[:enabled] and cfg[:stats].executing > 0 do %> + <%= if cfg[:enabled] && cfg[:stats].executing > 0 do %> {cfg[:stats].executing} running @@ -1215,7 +1215,7 @@ title="Legal" description="GDPR/CCPA compliance with consent tracking, cookie banners, and legal page generation" icon="⚖️" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="legal" > @@ -1226,7 +1226,7 @@ ]}> {if cfg[:enabled], do: "Enabled", else: "Disabled"} - <%= if cfg[:enabled] and cfg[:publishing_enabled] do %> + <%= if cfg[:enabled] && cfg[:publishing_enabled] do %> Publishing Ready <% end %> @@ -1263,11 +1263,11 @@ title="E-Commerce" description="Physical and digital products with cart and checkout" icon="🛒" - enabled={cfg[:enabled]} + enabled={cfg[:enabled] || false} toggle_event="toggle_module" toggle_key="shop" - toggle_disabled={not billing_cfg[:enabled]} - toggle_hint={if not billing_cfg[:enabled], do: "Billing Required"} + toggle_disabled={!billing_cfg[:enabled]} + toggle_hint={if !billing_cfg[:enabled], do: "Billing Required"} > <:status_badges> {if cfg[:enabled], do: "Enabled", else: "Disabled"} - <%= if not cfg[:enabled] and billing_cfg[:enabled] do %> + <%= if !cfg[:enabled] && billing_cfg[:enabled] do %> Billing Ready <% end %> @@ -1381,7 +1381,7 @@
- <%= if ext.enabled and ext.admin_links != [] do %> + <%= if ext.enabled && ext.admin_links != [] do %>
<%= for link <- ext.admin_links do %> <.link From 4283e346277eb1882dcb0ddfb45b2201a9942e53 Mon Sep 17 00:00:00 2001 From: timujeen Date: Thu, 26 Mar 2026 13:45:30 +0000 Subject: [PATCH 7/8] =?UTF-8?q?Add=20Estonian=20to=20backend=20languages,?= =?UTF-8?q?=20fix=20Chinese=20code=20zh-CN=20=E2=86=92=20zh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Estonian (et) to the default admin panel language list - Fix Chinese language code from zh-CN to zh for consistency (all backend language codes should be two-letter ISO 639-1) --- lib/modules/languages/languages.ex | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 From d624057bda784f4adc017f124b94bbd1db9ffc65 Mon Sep 17 00:00:00 2001 From: timujeen Date: Thu, 26 Mar 2026 22:57:32 +0000 Subject: [PATCH 8/8] Remove shop module from core (extracted to phoenix_kit_ecommerce package) - Delete lib/modules/shop/ (63 files), routes/shop.ex, mix task - Remove Shop from internal_modules in module_registry - Update integration.ex: conditional shop pipeline, safe_route_call for admin routes, Code.ensure_loaded? guards for public/authenticated routes - Update users/auth.ex: guard maybe_merge_guest_cart with ensure_loaded? - Add @compile no_warn_undefined for cross-package Shop references - Add admin_locale_routes to emails routes to avoid alias conflicts - Fix safe_route_call to silently handle :nofile and :unavailable - Add dialyzer ignore for extracted module references in integration.ex --- .dialyzer_ignore.exs | 6 +- lib/mix/tasks/shop.deduplicate_products.ex | 221 - lib/modules/shop/events.ex | 381 - lib/modules/shop/import/csv_analyzer.ex | 216 - lib/modules/shop/import/csv_parser.ex | 72 - lib/modules/shop/import/csv_validator.ex | 295 - lib/modules/shop/import/filter.ex | 201 - lib/modules/shop/import/format_detector.ex | 39 - lib/modules/shop/import/import_format.ex | 32 - lib/modules/shop/import/option_builder.ex | 280 - .../shop/import/product_transformer.ex | 473 -- lib/modules/shop/import/prom_ua_format.ex | 417 -- lib/modules/shop/import/shopify_csv.ex | 329 - lib/modules/shop/import/shopify_format.ex | 63 - .../shop/options/metadata_validator.ex | 340 - lib/modules/shop/options/option_types.ex | 434 -- lib/modules/shop/options/options.ex | 1361 ---- lib/modules/shop/schemas/cart.ex | 244 - lib/modules/shop/schemas/cart_item.ex | 234 - lib/modules/shop/schemas/category.ex | 358 - lib/modules/shop/schemas/import_config.ex | 236 - lib/modules/shop/schemas/import_log.ex | 170 - lib/modules/shop/schemas/product.ex | 297 - lib/modules/shop/schemas/shipping_method.ex | 271 - lib/modules/shop/schemas/shop_config.ex | 45 - lib/modules/shop/services/image_downloader.ex | 484 -- lib/modules/shop/services/image_migration.ex | 482 -- lib/modules/shop/shop.ex | 3529 ---------- lib/modules/shop/slug_resolver.ex | 666 -- lib/modules/shop/translations.ex | 387 -- lib/modules/shop/web/cart_page.ex | 521 -- lib/modules/shop/web/carts.ex | 255 - lib/modules/shop/web/catalog_category.ex | 458 -- lib/modules/shop/web/catalog_product.ex | 1312 ---- lib/modules/shop/web/categories.ex | 666 -- lib/modules/shop/web/category_form.ex | 1060 --- lib/modules/shop/web/checkout_complete.ex | 281 - lib/modules/shop/web/checkout_page.ex | 1197 ---- .../shop/web/components/catalog_sidebar.ex | 377 - .../shop/web/components/filter_helpers.ex | 238 - lib/modules/shop/web/components/shop_cards.ex | 143 - .../shop/web/components/shop_layouts.ex | 120 - .../shop/web/components/translation_tabs.ex | 443 -- lib/modules/shop/web/dashboard.ex | 194 - lib/modules/shop/web/helpers.ex | 195 - lib/modules/shop/web/import_configs.ex | 714 -- lib/modules/shop/web/import_show.ex | 270 - lib/modules/shop/web/imports.ex | 1622 ----- lib/modules/shop/web/option_state.ex | 445 -- lib/modules/shop/web/options_settings.ex | 885 --- lib/modules/shop/web/plugs/shop_session.ex | 59 - lib/modules/shop/web/product_detail.ex | 855 --- lib/modules/shop/web/product_form.ex | 2401 ------- lib/modules/shop/web/products.ex | 874 --- lib/modules/shop/web/settings.ex | 564 -- lib/modules/shop/web/shipping_method_form.ex | 416 -- lib/modules/shop/web/shipping_methods.ex | 198 - lib/modules/shop/web/shop_catalog.ex | 371 - lib/modules/shop/web/test_shop.ex | 372 - lib/modules/shop/web/user_order_details.ex | 127 - .../shop/web/user_order_details.html.heex | 219 - lib/modules/shop/web/user_orders.ex | 185 - lib/modules/shop/web/user_orders.html.heex | 146 - lib/modules/shop/workers/csv_import_worker.ex | 464 -- .../shop/workers/image_migration_worker.ex | 330 - lib/modules/sitemap/sources/shop.ex | 1 + lib/modules/storage/web/bucket_form.html.heex | 117 +- .../storage/web/dimension_form.html.heex | 80 +- lib/modules/storage/web/dimensions.html.heex | 130 +- lib/modules/storage/web/settings.html.heex | 206 +- lib/phoenix_kit/module_registry.ex | 1 - lib/phoenix_kit/users/auth.ex | 2 + lib/phoenix_kit/utils/country_data.ex | 1 + lib/phoenix_kit_web/integration.ex | 196 +- lib/phoenix_kit_web/live/settings.html.heex | 139 +- .../live/settings/authorization.html.heex | 318 +- .../live/settings/seo.html.heex | 28 +- .../live/settings/users.html.heex | 217 +- lib/phoenix_kit_web/routes/emails.ex | 39 + lib/phoenix_kit_web/routes/shop.ex | 72 - lib/phoenix_kit_web/users/auth.ex | 23 +- priv/gettext/default.pot | 6101 +++++++++------- priv/gettext/en/LC_MESSAGES/default.po | 6101 +++++++++------- priv/gettext/es/LC_MESSAGES/default.po | 6103 +++++++++------- priv/gettext/et/LC_MESSAGES/default.po | 6111 +++++++++------- priv/gettext/ru/LC_MESSAGES/default.po | 6137 ++++++++++------- 86 files changed, 19407 insertions(+), 44256 deletions(-) delete mode 100644 lib/mix/tasks/shop.deduplicate_products.ex delete mode 100644 lib/modules/shop/events.ex delete mode 100644 lib/modules/shop/import/csv_analyzer.ex delete mode 100644 lib/modules/shop/import/csv_parser.ex delete mode 100644 lib/modules/shop/import/csv_validator.ex delete mode 100644 lib/modules/shop/import/filter.ex delete mode 100644 lib/modules/shop/import/format_detector.ex delete mode 100644 lib/modules/shop/import/import_format.ex delete mode 100644 lib/modules/shop/import/option_builder.ex delete mode 100644 lib/modules/shop/import/product_transformer.ex delete mode 100644 lib/modules/shop/import/prom_ua_format.ex delete mode 100644 lib/modules/shop/import/shopify_csv.ex delete mode 100644 lib/modules/shop/import/shopify_format.ex delete mode 100644 lib/modules/shop/options/metadata_validator.ex delete mode 100644 lib/modules/shop/options/option_types.ex delete mode 100644 lib/modules/shop/options/options.ex delete mode 100644 lib/modules/shop/schemas/cart.ex delete mode 100644 lib/modules/shop/schemas/cart_item.ex delete mode 100644 lib/modules/shop/schemas/category.ex delete mode 100644 lib/modules/shop/schemas/import_config.ex delete mode 100644 lib/modules/shop/schemas/import_log.ex delete mode 100644 lib/modules/shop/schemas/product.ex delete mode 100644 lib/modules/shop/schemas/shipping_method.ex delete mode 100644 lib/modules/shop/schemas/shop_config.ex delete mode 100644 lib/modules/shop/services/image_downloader.ex delete mode 100644 lib/modules/shop/services/image_migration.ex delete mode 100644 lib/modules/shop/shop.ex delete mode 100644 lib/modules/shop/slug_resolver.ex delete mode 100644 lib/modules/shop/translations.ex delete mode 100644 lib/modules/shop/web/cart_page.ex delete mode 100644 lib/modules/shop/web/carts.ex delete mode 100644 lib/modules/shop/web/catalog_category.ex delete mode 100644 lib/modules/shop/web/catalog_product.ex delete mode 100644 lib/modules/shop/web/categories.ex delete mode 100644 lib/modules/shop/web/category_form.ex delete mode 100644 lib/modules/shop/web/checkout_complete.ex delete mode 100644 lib/modules/shop/web/checkout_page.ex delete mode 100644 lib/modules/shop/web/components/catalog_sidebar.ex delete mode 100644 lib/modules/shop/web/components/filter_helpers.ex delete mode 100644 lib/modules/shop/web/components/shop_cards.ex delete mode 100644 lib/modules/shop/web/components/shop_layouts.ex delete mode 100644 lib/modules/shop/web/components/translation_tabs.ex delete mode 100644 lib/modules/shop/web/dashboard.ex delete mode 100644 lib/modules/shop/web/helpers.ex delete mode 100644 lib/modules/shop/web/import_configs.ex delete mode 100644 lib/modules/shop/web/import_show.ex delete mode 100644 lib/modules/shop/web/imports.ex delete mode 100644 lib/modules/shop/web/option_state.ex delete mode 100644 lib/modules/shop/web/options_settings.ex delete mode 100644 lib/modules/shop/web/plugs/shop_session.ex delete mode 100644 lib/modules/shop/web/product_detail.ex delete mode 100644 lib/modules/shop/web/product_form.ex delete mode 100644 lib/modules/shop/web/products.ex delete mode 100644 lib/modules/shop/web/settings.ex delete mode 100644 lib/modules/shop/web/shipping_method_form.ex delete mode 100644 lib/modules/shop/web/shipping_methods.ex delete mode 100644 lib/modules/shop/web/shop_catalog.ex delete mode 100644 lib/modules/shop/web/test_shop.ex delete mode 100644 lib/modules/shop/web/user_order_details.ex delete mode 100644 lib/modules/shop/web/user_order_details.html.heex delete mode 100644 lib/modules/shop/web/user_orders.ex delete mode 100644 lib/modules/shop/web/user_orders.html.heex delete mode 100644 lib/modules/shop/workers/csv_import_worker.ex delete mode 100644 lib/modules/shop/workers/image_migration_worker.ex delete mode 100644 lib/phoenix_kit_web/routes/shop.ex diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 516b15f61..3e2b30930 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -138,5 +138,9 @@ # ExUnit internal functions — false positives when test/support is compiled in MIX_ENV=test # Dialyzer cannot resolve ExUnit private macros expanded at compile time {"test/support/conn_case.ex", :unknown_function}, - {"test/support/data_case.ex", :unknown_function} + {"test/support/data_case.ex", :unknown_function}, + + # 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} ] 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/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 2829484f5..000000000 --- a/lib/modules/shop/schemas/cart.ex +++ /dev/null @@ -1,244 +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.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 option from billing package (cross-package reference) - field :payment_option_uuid, 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 fa82dc332..000000000 --- a/lib/modules/shop/shop.ex +++ /dev/null @@ -1,3529 +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.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]) - - 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]) - |> 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, option) when is_map(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 91872e4d7..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, nil) do - "$#{Decimal.round(amount, 2)}" - end - - defp format_price(amount, currency) do - Currency.format_amount(amount, currency) - 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 b2b1a3d26..000000000 --- a/lib/modules/shop/web/catalog_category.ex +++ /dev/null @@ -1,458 +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 - - @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") - ) - |> assign(:admin_edit_url, Routes.path("/admin/shop/categories/#{category.uuid}/edit")) - |> assign(:admin_edit_label, "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 9042ad308..000000000 --- a/lib/modules/shop/web/catalog_product.ex +++ /dev/null @@ -1,1312 +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 - - # 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") - ) - |> assign(:admin_edit_url, Routes.path("/admin/shop/products/#{product.uuid}/edit")) - |> assign(:admin_edit_label, "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") - ) - |> assign(:admin_edit_url, Routes.path("/admin/shop/products/#{product.uuid}/edit")) - |> assign(:admin_edit_label, "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 36a3571b0..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.PaymentOption - alias PhoenixKit.Modules.Shop - alias PhoenixKit.Modules.Shop.Events - alias PhoenixKit.Modules.Shop.Web.Components.ShopLayouts - alias PhoenixKit.Utils.CountryData - - 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?( - %{requires_billing_profile: true}, - _is_guest, - _profiles - ), - do: true - - defp payment_option_needs_billing?( - %{requires_billing_profile: false}, - true, - _profiles - ), - do: true - - defp payment_option_needs_billing?( - %{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 462c72d2c..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, nil) do - "$#{Decimal.round(price, 2)}" - end - - def format_price(price, currency) do - Currency.format_amount(price, currency) - 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 3dfdde311..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, nil) do - "$#{Decimal.round(price, 2)}" - end - - defp format_price(price, currency) do - Currency.format_amount(price, currency) - 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) 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 5aaf27634..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) 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, nil) do - "$#{Decimal.round(price, 2)}" - end - - defp format_price(price, currency) do - Currency.format_amount(price, currency) - end - - # Get currency symbol for display - defp currency_symbol(%{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 ab86c7869..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, nil) do - # Fallback if no currency configured - "$#{Decimal.round(price, 2)}" - end - - defp format_price(price, currency) do - Currency.format_amount(price, currency) - 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 298442dad..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, nil) do - "$#{Decimal.round(amount || Decimal.new("0"), 2)}" - end - - defp format_price(amount, currency) do - Currency.format_amount(amount, currency) - 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 da54ddcdf..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, nil) do - "$#{Decimal.round(amount, 2)}" - end - - defp format_price(amount, currency) do - Currency.format_amount(amount, currency) - 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 5b060ff13..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, nil) do - "$#{Decimal.round(amount, 2)}" - end - - defp format_price(amount, currency) do - Currency.format_amount(amount, currency) - 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 %>