- {model["name"] || model["id"]}
+ {model.name || model.id}
<%!-- Model ID --%>
- {model["id"]}
+ {model.id}
<%!-- Badges --%>
<%!-- Context Length --%>
- <%= if model["context_length"] do %>
+ <%= if model.context_length do %>
- {format_number(model["context_length"])} ctx
+ {format_number(model.context_length)} ctx
<% end %>
<%!-- Max Output --%>
- <%= if model["max_completion_tokens"] do %>
+ <%= if model.max_completion_tokens do %>
- {format_number(model["max_completion_tokens"])} out
+ {format_number(model.max_completion_tokens)} out
<% end %>
@@ -478,11 +478,11 @@
<.icon name="hero-information-circle" class="w-4 h-4" />
Context: {PhoenixKit.Modules.AI.Web.EndpointForm.format_number(
- @selected_model["context_length"]
+ @selected_model.context_length
)} tokens
- <%= if @selected_model["max_completion_tokens"] do %>
+ <%= if @selected_model.max_completion_tokens do %>
• Max output: {PhoenixKit.Modules.AI.Web.EndpointForm.format_number(
- @selected_model["max_completion_tokens"]
+ @selected_model.max_completion_tokens
)} tokens
<% end %>
diff --git a/lib/modules/billing/billing.ex b/lib/modules/billing/billing.ex
index c5ae4d91..bd709e62 100644
--- a/lib/modules/billing/billing.ex
+++ b/lib/modules/billing/billing.ex
@@ -3087,12 +3087,12 @@ defmodule PhoenixKit.Modules.Billing do
# Update invoice with checkout session info
invoice
|> Ecto.Changeset.change(%{
- checkout_session_id: session[:session_id],
- checkout_url: session[:url]
+ checkout_session_id: session.id,
+ checkout_url: session.url
})
|> repo().update()
- {:ok, session[:url]}
+ {:ok, session.url}
{:error, reason} ->
{:error, reason}
diff --git a/lib/modules/billing/providers/paypal.ex b/lib/modules/billing/providers/paypal.ex
index 8357cb81..7759cdf9 100644
--- a/lib/modules/billing/providers/paypal.ex
+++ b/lib/modules/billing/providers/paypal.ex
@@ -35,6 +35,14 @@ defmodule PhoenixKit.Modules.Billing.Providers.PayPal do
@behaviour PhoenixKit.Modules.Billing.Providers.Provider
+ alias PhoenixKit.Modules.Billing.Providers.Types.{
+ ChargeResult,
+ CheckoutSession,
+ RefundResult,
+ SetupSession,
+ WebhookEventData
+ }
+
alias PhoenixKit.Settings
require Logger
@@ -68,8 +76,8 @@ defmodule PhoenixKit.Modules.Billing.Providers.PayPal do
|> Enum.find(fn link -> link["rel"] == "approve" end)
{:ok,
- %{
- session_id: order["id"],
+ %CheckoutSession{
+ id: order["id"],
url: approve_link["href"],
provider: :paypal,
expires_at: nil
@@ -91,8 +99,8 @@ defmodule PhoenixKit.Modules.Billing.Providers.PayPal do
|> Enum.find(fn link -> link["rel"] == "approve" end)
{:ok,
- %{
- session_id: setup_token["id"],
+ %SetupSession{
+ id: setup_token["id"],
url: approve_link["href"],
provider: :paypal
}}
@@ -105,11 +113,10 @@ defmodule PhoenixKit.Modules.Billing.Providers.PayPal do
{:ok, order} <- create_order_with_vault(token, payment_method, amount, opts),
{:ok, capture} <- capture_order(token, order["id"]) do
{:ok,
- %{
- charge_id: capture["id"],
+ %ChargeResult{
+ id: capture["id"],
status: capture["status"],
- amount: amount,
- provider: :paypal
+ amount: amount
}}
end
end
@@ -150,11 +157,11 @@ defmodule PhoenixKit.Modules.Billing.Providers.PayPal do
with {:ok, token} <- get_access_token(),
{:ok, refund} <- do_create_refund(token, provider_transaction_id, amount, opts) do
{:ok,
- %{
- refund_id: refund["id"],
+ %RefundResult{
+ id: refund["id"],
+ provider_refund_id: refund["id"],
status: refund["status"],
- amount: amount,
- provider: :paypal
+ amount: amount
}}
end
end
@@ -369,7 +376,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.PayPal do
custom_id = get_custom_id(resource)
{:ok,
- %{
+ %WebhookEventData{
event_id: payload["id"],
type: "checkout.completed",
provider: :paypal,
@@ -389,7 +396,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.PayPal do
custom_id = get_custom_id_from_capture(resource)
{:ok,
- %{
+ %WebhookEventData{
event_id: payload["id"],
type: "payment.succeeded",
provider: :paypal,
@@ -407,7 +414,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.PayPal do
custom_id = get_custom_id_from_capture(resource)
{:ok,
- %{
+ %WebhookEventData{
event_id: payload["id"],
type: "payment.failed",
provider: :paypal,
@@ -425,7 +432,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.PayPal do
amount = resource["amount"]
{:ok,
- %{
+ %WebhookEventData{
event_id: payload["id"],
type: "refund.created",
provider: :paypal,
diff --git a/lib/modules/billing/providers/provider.ex b/lib/modules/billing/providers/provider.ex
index 4e7adf4b..849ff0cd 100644
--- a/lib/modules/billing/providers/provider.ex
+++ b/lib/modules/billing/providers/provider.ex
@@ -45,58 +45,21 @@ defmodule PhoenixKit.Modules.Billing.Providers.Provider do
end
"""
- @type checkout_session :: %{
- id: String.t(),
- url: String.t(),
- provider: atom(),
- expires_at: DateTime.t() | nil,
- metadata: map()
- }
-
- @type setup_session :: %{
- id: String.t(),
- url: String.t(),
- provider: atom(),
- metadata: map()
- }
-
- @type webhook_event :: %{
- type: String.t(),
- event_id: String.t(),
- data: map(),
- provider: atom(),
- raw_payload: map()
- }
-
- @type payment_method :: %{
- 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()
- }
-
- @type charge_result :: %{
- id: String.t(),
- provider_transaction_id: String.t(),
- amount: Decimal.t(),
- currency: String.t(),
- status: String.t(),
- metadata: map()
- }
-
- @type refund_result :: %{
- id: String.t(),
- provider_refund_id: String.t(),
- amount: Decimal.t(),
- status: String.t(),
- metadata: map()
- }
+ 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.
diff --git a/lib/modules/billing/providers/providers.ex b/lib/modules/billing/providers/providers.ex
index bf6575ee..b6df4056 100644
--- a/lib/modules/billing/providers/providers.ex
+++ b/lib/modules/billing/providers/providers.ex
@@ -27,6 +27,7 @@ defmodule PhoenixKit.Modules.Billing.Providers do
"""
alias PhoenixKit.Modules.Billing.Providers.Provider
+ alias PhoenixKit.Modules.Billing.Providers.Types.ProviderInfo
alias PhoenixKit.Settings
@providers %{
@@ -321,9 +322,9 @@ defmodule PhoenixKit.Modules.Billing.Providers do
iex> Providers.provider_info(:stripe)
%{name: "Stripe", icon: "stripe", color: "#635BFF"}
"""
- @spec provider_info(atom()) :: map()
+ @spec provider_info(atom()) :: ProviderInfo.t()
def provider_info(:stripe) do
- %{
+ %ProviderInfo{
name: "Stripe",
icon: "stripe",
color: "#635BFF",
@@ -332,7 +333,7 @@ defmodule PhoenixKit.Modules.Billing.Providers do
end
def provider_info(:paypal) do
- %{
+ %ProviderInfo{
name: "PayPal",
icon: "paypal",
color: "#003087",
@@ -341,7 +342,7 @@ defmodule PhoenixKit.Modules.Billing.Providers do
end
def provider_info(:razorpay) do
- %{
+ %ProviderInfo{
name: "Razorpay",
icon: "razorpay",
color: "#072654",
@@ -349,7 +350,9 @@ defmodule PhoenixKit.Modules.Billing.Providers do
}
end
- def provider_info(_), do: %{name: "Unknown", icon: "credit-card", color: "#6B7280"}
+ def provider_info(_) do
+ %ProviderInfo{name: "Unknown", icon: "credit-card", color: "#6B7280"}
+ end
# Private helpers
diff --git a/lib/modules/billing/providers/razorpay.ex b/lib/modules/billing/providers/razorpay.ex
index b8d1ab59..35e91ec2 100644
--- a/lib/modules/billing/providers/razorpay.ex
+++ b/lib/modules/billing/providers/razorpay.ex
@@ -40,6 +40,14 @@ defmodule PhoenixKit.Modules.Billing.Providers.Razorpay do
@behaviour PhoenixKit.Modules.Billing.Providers.Provider
+ alias PhoenixKit.Modules.Billing.Providers.Types.{
+ ChargeResult,
+ CheckoutSession,
+ PaymentMethodInfo,
+ RefundResult,
+ WebhookEventData
+ }
+
alias PhoenixKit.Settings
require Logger
@@ -67,8 +75,8 @@ defmodule PhoenixKit.Modules.Billing.Providers.Razorpay do
with {:ok, order} <- create_order(merged_opts),
{:ok, payment_link} <- create_payment_link(order, merged_opts) do
{:ok,
- %{
- session_id: order["id"],
+ %CheckoutSession{
+ id: order["id"],
url: payment_link["short_url"],
provider: :razorpay,
expires_at: payment_link["expire_by"] |> datetime_from_unix()
@@ -95,11 +103,10 @@ defmodule PhoenixKit.Modules.Billing.Providers.Razorpay do
with {:ok, order} <- create_order_for_recurring(amount, opts),
{:ok, payment} <- create_recurring_payment(order, token_id, customer_id, opts) do
{:ok,
- %{
- charge_id: payment["id"],
+ %ChargeResult{
+ id: payment["id"],
status: payment["status"],
- amount: amount,
- provider: :razorpay
+ amount: amount
}}
end
end
@@ -151,11 +158,11 @@ defmodule PhoenixKit.Modules.Billing.Providers.Razorpay do
def create_refund(provider_transaction_id, amount, opts) do
with {:ok, refund} <- do_create_refund(provider_transaction_id, amount, opts) do
{:ok,
- %{
- refund_id: refund["id"],
+ %RefundResult{
+ id: refund["id"],
+ provider_refund_id: refund["id"],
status: refund["status"],
- amount: refund["amount"],
- provider: :razorpay
+ amount: refund["amount"]
}}
end
end
@@ -165,7 +172,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Razorpay 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,
@@ -295,7 +302,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Razorpay do
notes = payment["notes"] || %{}
{:ok,
- %{
+ %WebhookEventData{
event_id: raw_payload["event_id"] || payment["id"],
type: "payment.succeeded",
provider: :razorpay,
@@ -316,7 +323,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Razorpay do
# 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,
@@ -336,7 +343,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Razorpay do
notes = payment["notes"] || %{}
{:ok,
- %{
+ %WebhookEventData{
event_id: raw_payload["event_id"] || payment["id"],
type: "payment.failed",
provider: :razorpay,
@@ -357,7 +364,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Razorpay do
notes = order["notes"] || %{}
{:ok,
- %{
+ %WebhookEventData{
event_id: raw_payload["event_id"] || order["id"],
type: "checkout.completed",
provider: :razorpay,
@@ -377,7 +384,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Razorpay do
refund = event_payload["refund"]["entity"]
{:ok,
- %{
+ %WebhookEventData{
event_id: raw_payload["event_id"] || refund["id"],
type: "refund.created",
provider: :razorpay,
@@ -395,7 +402,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Razorpay do
refund = event_payload["refund"]["entity"]
{:ok,
- %{
+ %WebhookEventData{
event_id: raw_payload["event_id"] || refund["id"],
type: "refund.completed",
provider: :razorpay,
diff --git a/lib/modules/billing/providers/stripe.ex b/lib/modules/billing/providers/stripe.ex
index 73401668..2febab23 100644
--- a/lib/modules/billing/providers/stripe.ex
+++ b/lib/modules/billing/providers/stripe.ex
@@ -43,6 +43,15 @@ defmodule PhoenixKit.Modules.Billing.Providers.Stripe do
@behaviour PhoenixKit.Modules.Billing.Providers.Provider
+ alias PhoenixKit.Modules.Billing.Providers.Types.{
+ ChargeResult,
+ CheckoutSession,
+ PaymentMethodInfo,
+ RefundResult,
+ SetupSession,
+ WebhookEventData
+ }
+
alias PhoenixKit.Settings
require Logger
@@ -101,7 +110,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Stripe do
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,
@@ -148,7 +157,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Stripe do
case stripe_request(:post, "/checkout/sessions", params, config) do
{:ok, %{"id" => id, "url" => url}} ->
{:ok,
- %{
+ %SetupSession{
id: id,
url: url,
provider: :stripe,
@@ -203,7 +212,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Stripe do
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,
@@ -280,7 +289,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Stripe do
case normalize_event(type, object) do
{:ok, normalized} ->
{:ok,
- %{
+ %WebhookEventData{
type: normalized.type,
event_id: event_id,
data: normalized.data,
@@ -335,7 +344,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Stripe do
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),
@@ -377,7 +386,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Stripe do
}
}} ->
{:ok,
- %{
+ %PaymentMethodInfo{
id: id,
provider: :stripe,
provider_payment_method_id: id,
@@ -392,7 +401,7 @@ defmodule PhoenixKit.Modules.Billing.Providers.Stripe do
{:ok, %{"id" => id, "type" => type}} ->
{:ok,
- %{
+ %PaymentMethodInfo{
id: id,
provider: :stripe,
provider_payment_method_id: id,
diff --git a/lib/modules/billing/providers/types/charge_result.ex b/lib/modules/billing/providers/types/charge_result.ex
new file mode 100644
index 00000000..ae2a8dbb
--- /dev/null
+++ b/lib/modules/billing/providers/types/charge_result.ex
@@ -0,0 +1,26 @@
+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
new file mode 100644
index 00000000..a5f2d7ba
--- /dev/null
+++ b/lib/modules/billing/providers/types/checkout_session.ex
@@ -0,0 +1,24 @@
+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
new file mode 100644
index 00000000..afdf98ba
--- /dev/null
+++ b/lib/modules/billing/providers/types/payment_method_info.ex
@@ -0,0 +1,47 @@
+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
new file mode 100644
index 00000000..e1fe5e87
--- /dev/null
+++ b/lib/modules/billing/providers/types/provider_info.ex
@@ -0,0 +1,22 @@
+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
new file mode 100644
index 00000000..d4268a0f
--- /dev/null
+++ b/lib/modules/billing/providers/types/refund_result.ex
@@ -0,0 +1,24 @@
+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
new file mode 100644
index 00000000..8653a96f
--- /dev/null
+++ b/lib/modules/billing/providers/types/setup_session.ex
@@ -0,0 +1,22 @@
+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
new file mode 100644
index 00000000..e4e81194
--- /dev/null
+++ b/lib/modules/billing/providers/types/webhook_event_data.ex
@@ -0,0 +1,26 @@
+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/utils/iban_data.ex b/lib/modules/billing/utils/iban_data.ex
index bee614ff..2b7c19bd 100644
--- a/lib/modules/billing/utils/iban_data.ex
+++ b/lib/modules/billing/utils/iban_data.ex
@@ -17,6 +17,14 @@ defmodule PhoenixKit.Modules.Billing.IbanData do
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},
@@ -164,10 +172,28 @@ defmodule PhoenixKit.Modules.Billing.IbanData do
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 their IBAN specifications.
+ Returns a map of country codes to `%IbanData{}` structs.
"""
- def all_specs, do: @iban_specs
+ def all_specs do
+ Map.new(@iban_specs, fn {code, %{length: length, sepa: sepa}} ->
+ {code, %__MODULE__{length: length, sepa: sepa}}
+ end)
+ end
end
diff --git a/lib/modules/billing/web/invoice_detail/actions.ex b/lib/modules/billing/web/invoice_detail/actions.ex
index 49487494..ae831d5c 100644
--- a/lib/modules/billing/web/invoice_detail/actions.ex
+++ b/lib/modules/billing/web/invoice_detail/actions.ex
@@ -74,7 +74,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Actions do
socket = Phoenix.Component.assign(socket, :checkout_loading, provider)
case Billing.create_checkout_session(invoice, provider, opts) do
- {:ok, %{url: checkout_url}} ->
+ {:ok, checkout_url} when is_binary(checkout_url) ->
{:noreply, redirect(socket, external: checkout_url)}
{:error, :provider_not_available} ->
diff --git a/lib/modules/billing/web/invoice_detail/helpers.ex b/lib/modules/billing/web/invoice_detail/helpers.ex
index 73499879..00ce9638 100644
--- a/lib/modules/billing/web/invoice_detail/helpers.ex
+++ b/lib/modules/billing/web/invoice_detail/helpers.ex
@@ -6,6 +6,8 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do
and other template-callable utilities.
"""
+ alias PhoenixKit.Modules.Billing.Web.InvoiceDetail.TimelineEvent
+
@doc """
Gets the default email address from invoice billing details or user.
"""
@@ -63,19 +65,19 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do
@doc """
Builds a sorted timeline of all invoice events.
- Returns a list of maps with :type, :datetime, and :data keys, sorted by datetime.
+ Returns a list of `%TimelineEvent{}` structs sorted by datetime.
"""
def build_timeline_events(invoice, transactions) do
events = []
# 1. Created event
- events = [%{type: :created, datetime: invoice.inserted_at, data: nil} | events]
+ 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
@@ -87,7 +89,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do
# Fallback for old invoices without send_history
events =
if invoice.sent_at && Enum.empty?(get_send_history(invoice)) do
- [%{type: :invoice_sent_legacy, datetime: invoice.sent_at, data: nil} | events]
+ [%TimelineEvent{type: :invoice_sent_legacy, datetime: invoice.sent_at} | events]
else
events
end
@@ -97,7 +99,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do
transactions
|> Enum.filter(&Decimal.positive?(&1.amount))
|> Enum.map(fn txn ->
- %{type: :payment, datetime: txn.inserted_at, data: txn}
+ %TimelineEvent{type: :payment, datetime: txn.inserted_at, data: txn}
end)
events = events ++ payment_events
@@ -105,7 +107,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do
# 4. Paid event (when fully paid)
events =
if invoice.paid_at do
- [%{type: :paid, datetime: invoice.paid_at, data: nil} | events]
+ [%TimelineEvent{type: :paid, datetime: invoice.paid_at} | events]
else
events
end
@@ -114,7 +116,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do
events =
if invoice.receipt_number do
[
- %{
+ %TimelineEvent{
type: :receipt_generated,
datetime: invoice.receipt_generated_at,
data: invoice.receipt_number
@@ -129,7 +131,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do
receipt_sends =
get_receipt_send_history(invoice)
|> Enum.map(fn entry ->
- %{
+ %TimelineEvent{
type: :receipt_sent,
datetime: parse_datetime(entry["sent_at"]),
data: entry
@@ -144,13 +146,13 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do
|> Enum.filter(&Decimal.negative?(&1.amount))
|> Enum.flat_map(fn txn ->
# Refund event itself
- refund_event = %{type: :refund, datetime: txn.inserted_at, data: txn}
+ 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)
@@ -165,7 +167,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do
# 8. Voided event
events =
if invoice.voided_at do
- [%{type: :voided, datetime: invoice.voided_at, data: nil} | events]
+ [%TimelineEvent{type: :voided, datetime: invoice.voided_at} | events]
else
events
end
diff --git a/lib/modules/billing/web/invoice_detail/timeline_event.ex b/lib/modules/billing/web/invoice_detail/timeline_event.ex
new file mode 100644
index 00000000..30513aec
--- /dev/null
+++ b/lib/modules/billing/web/invoice_detail/timeline_event.ex
@@ -0,0 +1,34 @@
+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/workers/subscription_renewal_worker.ex b/lib/modules/billing/workers/subscription_renewal_worker.ex
index 82164e99..ce2d67aa 100644
--- a/lib/modules/billing/workers/subscription_renewal_worker.ex
+++ b/lib/modules/billing/workers/subscription_renewal_worker.ex
@@ -184,7 +184,7 @@ defmodule PhoenixKit.Modules.Billing.Workers.SubscriptionRenewalWorker do
amount: invoice.total,
payment_method: pm.provider,
description: "Subscription renewal payment",
- provider_transaction_id: charge_result[:charge_id],
+ provider_transaction_id: charge_result.provider_transaction_id,
provider_data: charge_result
}
diff --git a/lib/modules/emails/email_log_data.ex b/lib/modules/emails/email_log_data.ex
new file mode 100644
index 00000000..c4f603d5
--- /dev/null
+++ b/lib/modules/emails/email_log_data.ex
@@ -0,0 +1,66 @@
+defmodule PhoenixKit.Modules.Emails.EmailLogData do
+ @moduledoc """
+ Struct representing extracted email data for logging.
+
+ Constructed in `Interceptor.extract_email_data/2` and passed to
+ `Emails.create_log/1` for persistence.
+
+ ## Fields
+
+ - `message_id` - Unique message identifier
+ - `to` - Primary recipient email address
+ - `from` - Sender email address
+ - `subject` - Email subject line
+ - `headers` - Extracted email headers
+ - `body_preview` - Truncated body preview
+ - `body_full` - Full email body (when email_save_body is enabled)
+ - `attachments_count` - Number of attachments
+ - `size_bytes` - Estimated email size in bytes
+ - `template_name` - Template identifier if applicable
+ - `campaign_id` - Campaign identifier if applicable
+ - `user_id` - Associated user integer ID
+ - `user_uuid` - Associated user UUID
+ - `provider` - Email delivery provider name
+ - `configuration_set` - AWS SES configuration set name
+ - `message_tags` - Map of message tags
+ """
+
+ @enforce_keys [:message_id, :to, :from, :subject]
+ defstruct [
+ :message_id,
+ :to,
+ :from,
+ :subject,
+ :headers,
+ :body_preview,
+ :body_full,
+ :attachments_count,
+ :size_bytes,
+ :template_name,
+ :campaign_id,
+ :user_id,
+ :user_uuid,
+ :provider,
+ :configuration_set,
+ :message_tags
+ ]
+
+ @type t :: %__MODULE__{
+ message_id: String.t(),
+ to: String.t(),
+ from: String.t(),
+ subject: String.t(),
+ headers: map() | nil,
+ body_preview: String.t() | nil,
+ body_full: String.t() | nil,
+ attachments_count: integer() | nil,
+ size_bytes: integer() | nil,
+ template_name: String.t() | nil,
+ campaign_id: String.t() | nil,
+ user_id: integer() | nil,
+ user_uuid: String.t() | nil,
+ provider: String.t() | nil,
+ configuration_set: String.t() | nil,
+ message_tags: map() | nil
+ }
+end
diff --git a/lib/modules/emails/interceptor.ex b/lib/modules/emails/interceptor.ex
index 24e7f100..d6c13a19 100644
--- a/lib/modules/emails/interceptor.ex
+++ b/lib/modules/emails/interceptor.ex
@@ -53,6 +53,7 @@ defmodule PhoenixKit.Modules.Emails.Interceptor do
require Logger
alias PhoenixKit.Modules.Emails
+ alias PhoenixKit.Modules.Emails.EmailLogData
alias PhoenixKit.Modules.Emails.Event
alias PhoenixKit.Modules.Emails.Log
alias Swoosh.Email
@@ -371,7 +372,7 @@ defmodule PhoenixKit.Modules.Emails.Interceptor do
user_id = Keyword.get(opts, :user_id)
user_uuid = Keyword.get(opts, :user_uuid) || resolve_user_uuid(user_id)
- %{
+ %EmailLogData{
message_id: generate_message_id(email, opts),
to: extract_primary_recipient(email.to),
from: extract_sender(email.from),
diff --git a/lib/modules/entities/field_type.ex b/lib/modules/entities/field_type.ex
new file mode 100644
index 00000000..43a56b3a
--- /dev/null
+++ b/lib/modules/entities/field_type.ex
@@ -0,0 +1,52 @@
+defmodule PhoenixKit.Modules.Entities.FieldType do
+ @moduledoc """
+ Struct representing an entity field type definition.
+
+ ## Fields
+
+ - `name` - Field type identifier (e.g., `"text"`, `"select"`)
+ - `label` - Human-readable label (e.g., `"Text"`, `"Select Dropdown"`)
+ - `description` - Short description of the field type
+ - `category` - Category atom (`:basic`, `:numeric`, `:boolean`, `:datetime`, `:choice`, `:advanced`)
+ - `icon` - Heroicon name for rendering
+ - `requires_options` - Whether the field type requires options to be defined
+ - `default_props` - Default properties for new fields of this type
+ """
+
+ @enforce_keys [:name, :label, :category]
+ defstruct [
+ :name,
+ :label,
+ :description,
+ :category,
+ :icon,
+ requires_options: false,
+ default_props: %{}
+ ]
+
+ @type t :: %__MODULE__{
+ name: String.t(),
+ label: String.t(),
+ description: String.t() | nil,
+ category: :basic | :numeric | :boolean | :datetime | :choice | :advanced,
+ icon: String.t() | nil,
+ requires_options: boolean(),
+ default_props: map()
+ }
+
+ @doc """
+ Converts a plain map to a `%FieldType{}` struct.
+ """
+ @spec from_map(map()) :: t()
+ def from_map(map) when is_map(map) do
+ %__MODULE__{
+ name: map[:name] || map["name"],
+ label: map[:label] || map["label"],
+ description: map[:description] || map["description"],
+ category: map[:category] || map["category"],
+ icon: map[:icon] || map["icon"],
+ requires_options: map[:requires_options] || map["requires_options"] || false,
+ default_props: map[:default_props] || map["default_props"] || %{}
+ }
+ end
+end
diff --git a/lib/modules/entities/field_types.ex b/lib/modules/entities/field_types.ex
index fc02d5c0..fe1c043c 100644
--- a/lib/modules/entities/field_types.ex
+++ b/lib/modules/entities/field_types.ex
@@ -43,6 +43,8 @@ defmodule PhoenixKit.Modules.Entities.FieldTypes do
PhoenixKit.Modules.Entities.FieldTypes.requires_options?("select") # => true
"""
+ alias PhoenixKit.Modules.Entities.FieldType
+
@type field_type :: String.t()
@type field_category ::
:basic | :numeric | :boolean | :datetime | :choice | :advanced
@@ -189,15 +191,16 @@ defmodule PhoenixKit.Modules.Entities.FieldTypes do
}
@doc """
- Returns all field types as a map.
+ Returns all field types as a map of `%FieldType{}` structs.
## Examples
iex> PhoenixKit.Modules.Entities.FieldTypes.all()
- %{"text" => %{name: "text", ...}, ...}
+ %{"text" => %FieldType{name: "text", ...}, ...}
"""
+ @spec all() :: %{String.t() => FieldType.t()}
def all do
- @field_types
+ Map.new(@field_types, fn {key, map} -> {key, FieldType.from_map(map)} end)
end
@doc """
@@ -220,13 +223,17 @@ defmodule PhoenixKit.Modules.Entities.FieldTypes do
## Examples
iex> PhoenixKit.Modules.Entities.FieldTypes.get_type("text")
- %{name: "text", label: "Text", ...}
+ %FieldType{name: "text", label: "Text", ...}
iex> PhoenixKit.Modules.Entities.FieldTypes.get_type("invalid")
nil
"""
+ @spec get_type(String.t()) :: FieldType.t() | nil
def get_type(type_name) when is_binary(type_name) do
- Map.get(@field_types, type_name)
+ case Map.get(@field_types, type_name) do
+ nil -> nil
+ map -> FieldType.from_map(map)
+ end
end
@doc """
@@ -250,12 +257,14 @@ defmodule PhoenixKit.Modules.Entities.FieldTypes do
## Examples
iex> PhoenixKit.Modules.Entities.FieldTypes.by_category(:basic)
- [%{name: "text", ...}, %{name: "textarea", ...}, ...]
+ [%FieldType{name: "text", ...}, %FieldType{name: "textarea", ...}, ...]
"""
+ @spec by_category(field_category()) :: [FieldType.t()]
def by_category(category) when is_atom(category) do
@field_types
|> Map.values()
|> Enum.filter(fn type -> type.category == category end)
+ |> Enum.map(&FieldType.from_map/1)
end
@doc """
@@ -273,6 +282,7 @@ defmodule PhoenixKit.Modules.Entities.FieldTypes do
def categories do
@field_types
|> Map.values()
+ |> Enum.map(&FieldType.from_map/1)
|> Enum.group_by(& &1.category)
end
diff --git a/lib/modules/languages/languages.ex b/lib/modules/languages/languages.ex
index f36af0bb..f07f0f3e 100644
--- a/lib/modules/languages/languages.ex
+++ b/lib/modules/languages/languages.ex
@@ -617,9 +617,11 @@ defmodule PhoenixKit.Modules.Languages do
def get_languages_grouped_by_continent do
get_available_languages()
|> Enum.flat_map(fn lang ->
+ base_map = normalize_language_map(lang)
+
# Create one entry per country for this language
Enum.map(lang.countries, fn country ->
- Map.put(lang, :country, country)
+ Map.put(base_map, :country, country)
end)
end)
|> Enum.group_by(& &1.country)
@@ -647,6 +649,9 @@ defmodule PhoenixKit.Modules.Languages do
end)
end
+ defp normalize_language_map(%Language{} = lang), do: Map.from_struct(lang)
+ defp normalize_language_map(lang) when is_map(lang), do: lang
+
# Filter languages based on country's languages_official and language_locales
# - languages_official: determines WHICH languages to show (e.g., ["fr"] for France)
# - language_locales: determines which SPECIFIC LOCALE to use (e.g., %{en: "en-GB"})
diff --git a/lib/modules/legal/legal.ex b/lib/modules/legal/legal.ex
index 0c8c84df..41d3c9d2 100644
--- a/lib/modules/legal/legal.ex
+++ b/lib/modules/legal/legal.ex
@@ -31,6 +31,8 @@ defmodule PhoenixKit.Modules.Legal do
PhoenixKit.Modules.Legal.generate_all_pages()
"""
+ alias PhoenixKit.Modules.Legal.LegalFramework
+ alias PhoenixKit.Modules.Legal.PageType
alias PhoenixKit.Modules.Legal.TemplateGenerator
alias PhoenixKit.Settings
@@ -231,14 +233,18 @@ defmodule PhoenixKit.Modules.Legal do
@doc """
Get available compliance frameworks.
"""
- @spec available_frameworks() :: map()
- def available_frameworks, do: @frameworks
+ @spec available_frameworks() :: %{String.t() => LegalFramework.t()}
+ def available_frameworks do
+ Map.new(@frameworks, fn {id, map} -> {id, LegalFramework.from_map(map)} end)
+ end
@doc """
Get available page types.
"""
- @spec available_page_types() :: map()
- def available_page_types, do: @page_types
+ @spec available_page_types() :: %{String.t() => PageType.t()}
+ def available_page_types do
+ Map.new(@page_types, fn {slug, map} -> {slug, PageType.from_map(map)} end)
+ end
@doc """
Get selected compliance frameworks.
@@ -815,7 +821,7 @@ defmodule PhoenixKit.Modules.Legal do
defp get_page_config(page_type) do
case Map.get(@page_types, page_type) do
nil -> {:error, :unknown_page_type}
- config -> {:ok, config}
+ map -> {:ok, PageType.from_map(map)}
end
end
diff --git a/lib/modules/legal/legal_framework.ex b/lib/modules/legal/legal_framework.ex
new file mode 100644
index 00000000..17828ea6
--- /dev/null
+++ b/lib/modules/legal/legal_framework.ex
@@ -0,0 +1,52 @@
+defmodule PhoenixKit.Modules.Legal.LegalFramework do
+ @moduledoc """
+ Struct representing a legal compliance framework.
+
+ ## Fields
+
+ - `id` - Framework identifier (e.g., "gdpr", "ccpa")
+ - `name` - Human-readable name (e.g., "GDPR (European Union)")
+ - `description` - Brief description of the framework
+ - `regions` - List of region codes where the framework applies
+ - `consent_model` - Consent approach (`:opt_in`, `:opt_out`, or `:notice`)
+ - `required_pages` - List of page slugs required by this framework
+ - `optional_pages` - List of optional page slugs
+ """
+
+ @enforce_keys [:id, :name, :consent_model, :required_pages]
+ defstruct [
+ :id,
+ :name,
+ :description,
+ regions: [],
+ consent_model: :notice,
+ required_pages: [],
+ optional_pages: []
+ ]
+
+ @type t :: %__MODULE__{
+ id: String.t(),
+ name: String.t(),
+ description: String.t() | nil,
+ regions: [String.t()],
+ consent_model: :opt_in | :opt_out | :notice,
+ required_pages: [String.t()],
+ optional_pages: [String.t()]
+ }
+
+ @doc """
+ Converts a plain map to a `%LegalFramework{}` struct.
+ """
+ @spec from_map(map()) :: t()
+ def from_map(map) when is_map(map) do
+ %__MODULE__{
+ id: map[:id] || map["id"],
+ name: map[:name] || map["name"],
+ description: map[:description] || map["description"],
+ regions: map[:regions] || map["regions"] || [],
+ consent_model: map[:consent_model] || map["consent_model"] || :notice,
+ required_pages: map[:required_pages] || map["required_pages"] || [],
+ optional_pages: map[:optional_pages] || map["optional_pages"] || []
+ }
+ end
+end
diff --git a/lib/modules/legal/page_type.ex b/lib/modules/legal/page_type.ex
new file mode 100644
index 00000000..fe291cca
--- /dev/null
+++ b/lib/modules/legal/page_type.ex
@@ -0,0 +1,35 @@
+defmodule PhoenixKit.Modules.Legal.PageType do
+ @moduledoc """
+ Struct representing a type of legal page that can be generated.
+
+ ## Fields
+
+ - `slug` - URL-safe identifier (e.g., "privacy-policy")
+ - `title` - Human-readable page title (e.g., "Privacy Policy")
+ - `template` - EEx template filename for generation
+ - `description` - Brief description of the page's purpose
+ """
+
+ @enforce_keys [:slug, :title, :template]
+ defstruct [:slug, :title, :template, :description]
+
+ @type t :: %__MODULE__{
+ slug: String.t(),
+ title: String.t(),
+ template: String.t(),
+ description: String.t() | nil
+ }
+
+ @doc """
+ Converts a plain map to a `%PageType{}` struct.
+ """
+ @spec from_map(map()) :: t()
+ def from_map(map) when is_map(map) do
+ %__MODULE__{
+ slug: map[:slug] || map["slug"],
+ title: map[:title] || map["title"],
+ template: map[:template] || map["template"],
+ description: map[:description] || map["description"]
+ }
+ end
+end
diff --git a/lib/modules/sitemap/generator.ex b/lib/modules/sitemap/generator.ex
index 386ddc35..4ecee3be 100644
--- a/lib/modules/sitemap/generator.ex
+++ b/lib/modules/sitemap/generator.ex
@@ -36,6 +36,7 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
alias PhoenixKit.Modules.Sitemap.FileStorage
alias PhoenixKit.Modules.Sitemap.HtmlGenerator
alias PhoenixKit.Modules.Sitemap.SchedulerWorker
+ alias PhoenixKit.Modules.Sitemap.SitemapFile
alias PhoenixKit.Modules.Sitemap.Sources.Source
alias PhoenixKit.Modules.Sitemap.UrlEntry
alias PhoenixKit.Utils.Routes
@@ -142,7 +143,9 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
{:ok,
%{
index_xml: xml,
- modules: [%{filename: "flat", url_count: total_urls, lastmod: DateTime.utc_now()}],
+ modules: [
+ %SitemapFile{filename: "flat", url_count: total_urls, lastmod: DateTime.utc_now()}
+ ],
total_urls: total_urls
}}
end
@@ -152,10 +155,10 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
@doc """
Generates sitemap file(s) for a single source module.
- Returns a list of module_info maps (one per file generated).
+ Returns a list of `%SitemapFile{}` structs (one per file generated).
Empty sources produce no files and return [].
"""
- @spec generate_module(module(), keyword()) :: [map()]
+ @spec generate_module(module(), keyword()) :: [SitemapFile.t()]
def generate_module(source_module, opts \\ []) do
if Source.valid_source?(source_module) and source_module.enabled?() do
do_generate_module(source_module, opts)
@@ -223,7 +226,7 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
FileStorage.save_module(numbered_filename, xml)
Cache.put_module(numbered_filename, xml)
- %{
+ %SitemapFile{
filename: numbered_filename,
url_count: length(chunk),
lastmod: latest_lastmod(chunk)
@@ -235,7 +238,7 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
Cache.put_module(filename, xml)
[
- %{
+ %SitemapFile{
filename: filename,
url_count: length(entries),
lastmod: latest_lastmod(entries)
@@ -248,9 +251,9 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
# ── Sitemapindex generation ────────────────────────────────────────
@doc """
- Builds `
` XML from a list of module_info maps.
+ Builds `` XML from a list of `%SitemapFile{}` structs.
"""
- @spec generate_index([map()], String.t(), String.t(), boolean()) :: String.t()
+ @spec generate_index([SitemapFile.t()], String.t(), String.t(), boolean()) :: String.t()
def generate_index(module_infos, base_url, xsl_style \\ "table", xsl_enabled \\ true) do
xsl_line = build_index_xsl_line(xsl_style, xsl_enabled)
normalized_base = String.trim_trailing(base_url, "/")
@@ -261,7 +264,7 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do
module_infos
|> Enum.map(fn info ->
loc = "#{normalized_base}#{normalized_prefix}/sitemaps/#{info.filename}.xml"
- lastmod_str = format_lastmod(info[:lastmod])
+ lastmod_str = format_lastmod(info.lastmod)
"""
diff --git a/lib/modules/sitemap/sitemap_file.ex b/lib/modules/sitemap/sitemap_file.ex
new file mode 100644
index 00000000..38bf36d1
--- /dev/null
+++ b/lib/modules/sitemap/sitemap_file.ex
@@ -0,0 +1,22 @@
+defmodule PhoenixKit.Modules.Sitemap.SitemapFile do
+ @moduledoc """
+ Struct representing a generated sitemap file's metadata.
+
+ Used by `Generator` to track per-module file info for sitemapindex generation.
+
+ ## Fields
+
+ - `filename` - Base filename without extension (e.g., "sitemap-posts-1")
+ - `url_count` - Number of URLs in this file
+ - `lastmod` - Most recent lastmod timestamp across all URLs in the file
+ """
+
+ @enforce_keys [:filename, :url_count]
+ defstruct [:filename, :url_count, :lastmod]
+
+ @type t :: %__MODULE__{
+ filename: String.t(),
+ url_count: non_neg_integer(),
+ lastmod: DateTime.t() | nil
+ }
+end
diff --git a/lib/modules/sync/column_info.ex b/lib/modules/sync/column_info.ex
new file mode 100644
index 00000000..11e2c72a
--- /dev/null
+++ b/lib/modules/sync/column_info.ex
@@ -0,0 +1,39 @@
+defmodule PhoenixKit.Modules.Sync.ColumnInfo do
+ @moduledoc """
+ Struct representing a single database column's metadata.
+
+ ## Fields
+
+ - `name` - Column name
+ - `type` - PostgreSQL data type (e.g., `"bigint"`, `"text"`)
+ - `nullable` - Whether the column allows NULL values
+ - `primary_key` - Whether the column is part of the primary key
+ - `default` - Default value expression or nil
+ - `max_length` - Maximum character length or nil
+ - `precision` - Numeric precision or nil
+ - `scale` - Numeric scale or nil
+ """
+
+ @enforce_keys [:name, :type]
+ defstruct [
+ :name,
+ :type,
+ :default,
+ :max_length,
+ :precision,
+ :scale,
+ nullable: false,
+ primary_key: false
+ ]
+
+ @type t :: %__MODULE__{
+ name: String.t(),
+ type: String.t(),
+ nullable: boolean(),
+ primary_key: boolean(),
+ default: String.t() | nil,
+ max_length: integer() | nil,
+ precision: integer() | nil,
+ scale: integer() | nil
+ }
+end
diff --git a/lib/modules/sync/data_importer.ex b/lib/modules/sync/data_importer.ex
index 8b0fca82..6077c698 100644
--- a/lib/modules/sync/data_importer.ex
+++ b/lib/modules/sync/data_importer.ex
@@ -253,11 +253,16 @@ defmodule PhoenixKit.Modules.Sync.DataImporter do
# Helpers
# ============================================================================
- defp get_primary_keys(schema) do
- schema
- |> Map.get("columns", [])
- |> Enum.filter(& &1["is_primary_key"])
- |> Enum.map(& &1["name"])
+ defp get_primary_keys(%{primary_key: pks}) when is_list(pks), do: pks
+
+ defp get_primary_keys(schema) when is_map(schema) do
+ columns = Map.get(schema, :columns) || Map.get(schema, "columns") || []
+
+ columns
+ |> Enum.filter(fn col ->
+ Map.get(col, :primary_key) || Map.get(col, "is_primary_key") || Map.get(col, "primary_key")
+ end)
+ |> Enum.map(fn col -> Map.get(col, :name) || Map.get(col, "name") end)
end
defp prepare_record(record) when is_map(record) do
diff --git a/lib/modules/sync/schema_inspector.ex b/lib/modules/sync/schema_inspector.ex
index 33853521..b7d84df0 100644
--- a/lib/modules/sync/schema_inspector.ex
+++ b/lib/modules/sync/schema_inspector.ex
@@ -32,6 +32,7 @@ defmodule PhoenixKit.Modules.Sync.SchemaInspector do
}}
"""
+ alias PhoenixKit.Modules.Sync.{ColumnInfo, TableSchema}
alias PhoenixKit.RepoHelper
# Tables to always exclude from sync
@@ -343,22 +344,20 @@ defmodule PhoenixKit.Modules.Sync.SchemaInspector do
{:ok, primary_key} <- get_primary_key(table_name, schema: schema) do
columns =
Enum.map(column_rows, fn [name, type, nullable, default, max_len, precision, scale] ->
- column = %{
+ %ColumnInfo{
name: name,
type: type,
nullable: nullable,
- primary_key: name in primary_key
+ primary_key: name in primary_key,
+ default: default,
+ max_length: max_len,
+ precision: precision,
+ scale: scale
}
-
- column
- |> maybe_add(:default, default)
- |> maybe_add(:max_length, max_len)
- |> maybe_add(:precision, precision)
- |> maybe_add(:scale, scale)
end)
{:ok,
- %{
+ %TableSchema{
table: table_name,
schema: schema,
columns: columns,
@@ -382,7 +381,4 @@ defmodule PhoenixKit.Modules.Sync.SchemaInspector do
false
end
end
-
- defp maybe_add(map, _key, nil), do: map
- defp maybe_add(map, key, value), do: Map.put(map, key, value)
end
diff --git a/lib/modules/sync/table_schema.ex b/lib/modules/sync/table_schema.ex
new file mode 100644
index 00000000..0f8a452e
--- /dev/null
+++ b/lib/modules/sync/table_schema.ex
@@ -0,0 +1,27 @@
+defmodule PhoenixKit.Modules.Sync.TableSchema do
+ @moduledoc """
+ Struct representing a database table's schema information.
+
+ Returned by `SchemaInspector.fetch_table_schema/2` and consumed by
+ `DataImporter`, `DataExporter`, sync LiveViews, and the wire protocol.
+
+ ## Fields
+
+ - `table` - Table name
+ - `schema` - PostgreSQL schema (e.g., `"public"`)
+ - `columns` - List of `ColumnInfo` structs
+ - `primary_key` - List of primary key column names
+ """
+
+ alias PhoenixKit.Modules.Sync.ColumnInfo
+
+ @enforce_keys [:table, :schema]
+ defstruct [:table, :schema, columns: [], primary_key: []]
+
+ @type t :: %__MODULE__{
+ table: String.t(),
+ schema: String.t(),
+ columns: [ColumnInfo.t()],
+ primary_key: [String.t()]
+ }
+end
diff --git a/lib/phoenix_kit/config/user_dashboard_categories.ex b/lib/phoenix_kit/config/user_dashboard_categories.ex
index caf1bcfb..1e067648 100644
--- a/lib/phoenix_kit/config/user_dashboard_categories.ex
+++ b/lib/phoenix_kit/config/user_dashboard_categories.ex
@@ -240,12 +240,14 @@ defmodule PhoenixKit.Config.UserDashboardCategories do
[%{id: :farm_management, label: "Farm Management", ...}, ...]
"""
- @spec to_groups(list()) :: list()
+ @spec to_groups(list()) :: [PhoenixKit.Dashboard.Group.t()]
def to_groups(categories) do
+ alias PhoenixKit.Dashboard.Group
+
categories
|> Enum.with_index()
|> Enum.map(fn {category, idx} ->
- %{
+ %Group{
id: category_to_group_id(category.title),
label: category.title,
icon: category.icon,
diff --git a/lib/phoenix_kit/dashboard/admin_tabs.ex b/lib/phoenix_kit/dashboard/admin_tabs.ex
index eef53925..28e78040 100644
--- a/lib/phoenix_kit/dashboard/admin_tabs.ex
+++ b/lib/phoenix_kit/dashboard/admin_tabs.ex
@@ -9,7 +9,7 @@ defmodule PhoenixKit.Dashboard.AdminTabs do
require Logger
- alias PhoenixKit.Dashboard.{Registry, Tab}
+ alias PhoenixKit.Dashboard.{Group, Registry, Tab}
alias PhoenixKit.Modules.Entities
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth.Scope
@@ -65,12 +65,12 @@ defmodule PhoenixKit.Dashboard.AdminTabs do
@doc """
Returns the default admin tab groups.
"""
- @spec default_groups() :: [map()]
+ @spec default_groups() :: [Group.t()]
def default_groups do
[
- %{id: :admin_main, label: nil, priority: 100},
- %{id: :admin_modules, label: nil, priority: 500},
- %{id: :admin_system, label: nil, priority: 900}
+ %Group{id: :admin_main, label: nil, priority: 100},
+ %Group{id: :admin_modules, label: nil, priority: 500},
+ %Group{id: :admin_system, label: nil, priority: 900}
]
end
diff --git a/lib/phoenix_kit/dashboard/dashboard.ex b/lib/phoenix_kit/dashboard/dashboard.ex
index 06f041c7..f416c1c2 100644
--- a/lib/phoenix_kit/dashboard/dashboard.ex
+++ b/lib/phoenix_kit/dashboard/dashboard.ex
@@ -125,7 +125,7 @@ defmodule PhoenixKit.Dashboard do
The sidebar will show "2 viewing" indicators.
"""
- alias PhoenixKit.Dashboard.{Badge, ContextSelector, Presence, Registry, Tab}
+ alias PhoenixKit.Dashboard.{Badge, ContextSelector, Group, Presence, Registry, Tab}
alias PhoenixKit.PubSubHelper
# ============================================================================
@@ -309,13 +309,13 @@ defmodule PhoenixKit.Dashboard do
%{id: :account, label: "Account", priority: 900}
])
"""
- @spec register_groups([map()]) :: :ok
+ @spec register_groups([Group.t() | map()]) :: :ok
defdelegate register_groups(groups), to: Registry
@doc """
Gets all registered tab groups.
"""
- @spec get_groups() :: [map()]
+ @spec get_groups() :: [Group.t()]
defdelegate get_groups(), to: Registry
# ============================================================================
diff --git a/lib/phoenix_kit/dashboard/group.ex b/lib/phoenix_kit/dashboard/group.ex
new file mode 100644
index 00000000..4432606a
--- /dev/null
+++ b/lib/phoenix_kit/dashboard/group.ex
@@ -0,0 +1,51 @@
+defmodule PhoenixKit.Dashboard.Group do
+ @moduledoc """
+ Struct representing a dashboard tab group.
+
+ Groups organize tabs in the dashboard sidebar. Each group has an ID,
+ an optional label, and a priority for ordering.
+
+ ## Fields
+
+ - `id` - Unique group identifier atom (e.g., `:admin_main`, `:shop`)
+ - `label` - Optional display label (nil for unlabeled groups)
+ - `priority` - Sort priority (lower = first, default: 100)
+ - `icon` - Optional heroicon name (e.g., `"hero-cube"`)
+ - `collapsible` - Whether the group can be collapsed in the sidebar
+ """
+
+ @enforce_keys [:id]
+ defstruct [:id, :label, :icon, priority: 100, collapsible: false]
+
+ @type t :: %__MODULE__{
+ id: atom(),
+ label: String.t() | nil,
+ priority: integer(),
+ icon: String.t() | nil,
+ collapsible: boolean()
+ }
+
+ @doc """
+ Creates a new group from a map or keyword list.
+ """
+ @spec new(map() | keyword()) :: t()
+ def new(attrs) when is_map(attrs) do
+ %__MODULE__{
+ id: attrs[:id] || attrs["id"],
+ label: attrs[:label] || attrs["label"],
+ priority: attrs[:priority] || attrs["priority"] || 100,
+ icon: attrs[:icon] || attrs["icon"],
+ collapsible: attrs[:collapsible] || attrs["collapsible"] || false
+ }
+ end
+
+ def new(attrs) when is_list(attrs) do
+ %__MODULE__{
+ id: Keyword.fetch!(attrs, :id),
+ label: Keyword.get(attrs, :label),
+ priority: Keyword.get(attrs, :priority, 100),
+ icon: Keyword.get(attrs, :icon),
+ collapsible: Keyword.get(attrs, :collapsible, false)
+ }
+ end
+end
diff --git a/lib/phoenix_kit/dashboard/registry.ex b/lib/phoenix_kit/dashboard/registry.ex
index c3d527e2..90756f89 100644
--- a/lib/phoenix_kit/dashboard/registry.ex
+++ b/lib/phoenix_kit/dashboard/registry.ex
@@ -62,7 +62,7 @@ defmodule PhoenixKit.Dashboard.Registry do
require Logger
- alias PhoenixKit.Dashboard.{AdminTabs, Badge, Tab}
+ alias PhoenixKit.Dashboard.{AdminTabs, Badge, Group, Tab}
alias PhoenixKit.PubSubHelper
alias PhoenixKit.Users.Permissions
@@ -300,7 +300,7 @@ defmodule PhoenixKit.Dashboard.Registry do
@doc """
Gets all registered groups, sorted by priority.
"""
- @spec get_groups() :: [map()]
+ @spec get_groups() :: [Group.t()]
def get_groups do
if initialized?() do
case :ets.lookup(@ets_table, :groups) do
@@ -323,7 +323,7 @@ defmodule PhoenixKit.Dashboard.Registry do
%{id: :account, label: "Account", priority: 900}
])
"""
- @spec register_groups([map()]) :: :ok
+ @spec register_groups([Group.t() | map()]) :: :ok
def register_groups(groups) when is_list(groups) do
GenServer.call(__MODULE__, {:register_groups, groups})
end
@@ -826,9 +826,9 @@ defmodule PhoenixKit.Dashboard.Registry do
# Default groups
groups = [
- %{id: :main, label: nil, priority: 100},
- %{id: :shop, label: nil, priority: 200},
- %{id: :account, label: nil, priority: 900}
+ %Group{id: :main, label: nil, priority: 100},
+ %Group{id: :shop, label: nil, priority: 200},
+ %Group{id: :account, label: nil, priority: 900}
]
Enum.each(defaults, fn tab ->
@@ -868,7 +868,13 @@ defmodule PhoenixKit.Dashboard.Registry do
:ok
groups when is_list(groups) ->
- :ets.insert(@ets_table, {:groups, groups})
+ converted =
+ Enum.map(groups, fn
+ %Group{} = g -> g
+ map when is_map(map) -> Group.new(map)
+ end)
+
+ :ets.insert(@ets_table, {:groups, converted})
end
end
diff --git a/lib/phoenix_kit/migrations/uuid_fk_columns.ex b/lib/phoenix_kit/migrations/uuid_fk_columns.ex
index 39d085f8..cb0ac795 100644
--- a/lib/phoenix_kit/migrations/uuid_fk_columns.ex
+++ b/lib/phoenix_kit/migrations/uuid_fk_columns.ex
@@ -457,7 +457,6 @@ defmodule PhoenixKit.Migrations.UUIDFKColumns do
index_name =
case prefix do
- nil -> index_name
"public" -> index_name
p -> "#{p}.#{index_name}"
end
diff --git a/lib/phoenix_kit/users/auth.ex b/lib/phoenix_kit/users/auth.ex
index bfaa6fd5..d1e9b940 100644
--- a/lib/phoenix_kit/users/auth.ex
+++ b/lib/phoenix_kit/users/auth.ex
@@ -888,7 +888,7 @@ defmodule PhoenixKit.Users.Auth do
## Options
- * `:fingerprint` - Optional session fingerprint map with `:ip_address` and `:user_agent_hash`
+ * `:fingerprint` - Optional `%SessionFingerprint{}` struct with `:ip_address` and `:user_agent_hash`
## Examples
diff --git a/lib/phoenix_kit/users/auth/user_token.ex b/lib/phoenix_kit/users/auth/user_token.ex
index bb304526..e1ec1db8 100644
--- a/lib/phoenix_kit/users/auth/user_token.ex
+++ b/lib/phoenix_kit/users/auth/user_token.ex
@@ -23,6 +23,7 @@ defmodule PhoenixKit.Users.Auth.UserToken do
use Ecto.Schema
import Ecto.Query
alias PhoenixKit.Users.Auth.UserToken
+ alias PhoenixKit.Utils.SessionFingerprint
@hash_algorithm :sha256
# 48 bytes = ~64 chars after base64 - enhanced security for passwordless auth
@@ -83,7 +84,7 @@ defmodule PhoenixKit.Users.Auth.UserToken do
## Options
- * `:fingerprint` - A map with `:ip_address` and `:user_agent_hash` keys
+ * `:fingerprint` - A `%SessionFingerprint{}` struct with `:ip_address` and `:user_agent_hash` fields
## Examples
@@ -91,26 +92,37 @@ defmodule PhoenixKit.Users.Auth.UserToken do
{token, user_token} = build_session_token(user)
# With fingerprinting
- fingerprint = %{ip_address: "192.168.1.1", user_agent_hash: "abc123"}
+ fingerprint = SessionFingerprint.create_fingerprint(conn)
{token, user_token} = build_session_token(user, fingerprint: fingerprint)
"""
def build_session_token(user, opts \\ []) do
token = :crypto.strong_rand_bytes(@rand_size)
fingerprint = Keyword.get(opts, :fingerprint)
+ {ip_address, user_agent_hash} = extract_fingerprint_attrs(fingerprint)
user_token = %UserToken{
token: token,
context: "session",
user_id: user.id,
user_uuid: user.uuid,
- ip_address: fingerprint && fingerprint[:ip_address],
- user_agent_hash: fingerprint && fingerprint[:user_agent_hash]
+ ip_address: ip_address,
+ user_agent_hash: user_agent_hash
}
{token, user_token}
end
+ defp extract_fingerprint_attrs(%SessionFingerprint{} = fp) do
+ {fp.ip_address, fp.user_agent_hash}
+ end
+
+ defp extract_fingerprint_attrs(%{} = fp) do
+ {fp[:ip_address] || fp["ip_address"], fp[:user_agent_hash] || fp["user_agent_hash"]}
+ end
+
+ defp extract_fingerprint_attrs(_), do: {nil, nil}
+
@doc """
Checks if the token is valid and returns its underlying lookup query.
diff --git a/lib/phoenix_kit/utils/session_fingerprint.ex b/lib/phoenix_kit/utils/session_fingerprint.ex
index 10b7c395..cfeb263e 100644
--- a/lib/phoenix_kit/utils/session_fingerprint.ex
+++ b/lib/phoenix_kit/utils/session_fingerprint.ex
@@ -40,19 +40,27 @@ defmodule PhoenixKit.Utils.SessionFingerprint do
@hash_algorithm :sha256
+ @enforce_keys [:ip_address, :user_agent_hash]
+ defstruct [:ip_address, :user_agent_hash]
+
+ @type t :: %__MODULE__{
+ ip_address: String.t(),
+ user_agent_hash: String.t()
+ }
+
@doc """
Creates a session fingerprint from a Plug.Conn connection.
- Returns a map with `:ip_address` and `:user_agent_hash` keys.
+ Returns a `%SessionFingerprint{}` struct with `:ip_address` and `:user_agent_hash` fields.
## Examples
iex> create_fingerprint(conn)
- %{ip_address: "192.168.1.1", user_agent_hash: "a1b2c3d4..."}
+ %SessionFingerprint{ip_address: "192.168.1.1", user_agent_hash: "a1b2c3d4..."}
"""
def create_fingerprint(conn) do
- %{
+ %__MODULE__{
ip_address: get_ip_address(conn),
user_agent_hash: hash_user_agent(conn)
}
diff --git a/lib/phoenix_kit_web/components/dashboard/admin_sidebar.ex b/lib/phoenix_kit_web/components/dashboard/admin_sidebar.ex
index a2abb95d..e1c244ef 100644
--- a/lib/phoenix_kit_web/components/dashboard/admin_sidebar.ex
+++ b/lib/phoenix_kit_web/components/dashboard/admin_sidebar.ex
@@ -98,13 +98,13 @@ defmodule PhoenixKitWeb.Components.Dashboard.AdminSidebar do
defp admin_tab_group(assigns) do
~H"""
- <%= if @group[:label] do %>
+ <%= if @group.label do %>
- <%= if @group[:icon] do %>
- <.icon name={@group[:icon]} class="w-3.5 h-3.5" />
+ <%= if @group.icon do %>
+ <.icon name={@group.icon} class="w-3.5 h-3.5" />
<% end %>
- {@group[:label]}
+ {@group.label}
<% end %>
diff --git a/lib/phoenix_kit_web/components/dashboard/sidebar.ex b/lib/phoenix_kit_web/components/dashboard/sidebar.ex
index f7489cfc..7556168e 100644
--- a/lib/phoenix_kit_web/components/dashboard/sidebar.ex
+++ b/lib/phoenix_kit_web/components/dashboard/sidebar.ex
@@ -199,23 +199,23 @@ defmodule PhoenixKitWeb.Components.Dashboard.Sidebar do
data-collapsed={@collapsed}
>
<%!-- Group Header (if labeled) --%>
- <%= if @group[:label] do %>
+ <%= if @group.label do %>
- <%= if @group[:icon] do %>
- <.icon name={@group[:icon]} class="w-3.5 h-3.5" />
+ <%= if @group.icon do %>
+ <.icon name={@group.icon} class="w-3.5 h-3.5" />
<% end %>
- {@group[:label]}
+ {@group.label}
- <%= if @group[:collapsible] do %>
+ <%= if @group.collapsible do %>
<.icon
name={if @collapsed, do: "hero-chevron-right-mini", else: "hero-chevron-down-mini"}
class="w-4 h-4"
diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex
index 933cebf1..81a2ee8d 100644
--- a/lib/phoenix_kit_web/live/modules.html.heex
+++ b/lib/phoenix_kit_web/live/modules.html.heex
@@ -217,7 +217,7 @@
<%= if @languages_enabled do %>
{if @languages_default,
- do: @languages_default["name"],
+ do: @languages_default.name,
else: "No Default"}
<% end %>
@@ -358,7 +358,7 @@
# Determine default language base code for URL display
default_lang_code =
if @languages_enabled && @languages_default do
- @languages_default["code"]
+ @languages_default.code
|> PhoenixKit.Modules.Languages.DialectMapper.extract_base()
else
nil
diff --git a/test/test_language_refactor.exs b/test/test_language_refactor.exs
index 6dc94dce..ae02de70 100644
--- a/test/test_language_refactor.exs
+++ b/test/test_language_refactor.exs
@@ -74,8 +74,28 @@ defmodule LanguageRefactorTest do
assert lang.code == "fr-FR"
assert lang.name == "French (France)"
- # Map access should NOT work (structs don't support string keys)
- assert lang["code"] == nil
- assert lang[:code] == nil
+ # Bracket access raises on structs (Access behaviour not implemented)
+ assert_raise UndefinedFunctionError, fn -> lang["code"] end
+ end
+
+ test "grouped languages converts structs before adding country metadata" do
+ grouped_languages = Languages.get_languages_grouped_by_continent()
+
+ assert is_list(grouped_languages)
+
+ {country, languages} =
+ grouped_languages
+ |> Enum.flat_map(fn {_continent, countries} ->
+ Enum.map(countries, fn {country, _flag, languages} -> {country, languages} end)
+ end)
+ |> Enum.find(fn {_country, languages} -> match?([_ | _], languages) end)
+
+ refute country == nil
+
+ language = hd(languages)
+
+ assert Map.get(language, :country) == country
+ assert is_binary(language.code)
+ assert is_binary(language.name)
end
end