Packages

phoenix_kit

1.7.25
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules shop shop.ex
Raw

lib/modules/shop/shop.ex

defmodule PhoenixKit.Modules.Shop do
@moduledoc """
E-commerce Shop Module for PhoenixKit.
Provides comprehensive e-commerce functionality including products, categories,
variants, inventory, and cart management.
## Features
- **Products**: Physical and digital products with JSONB flexibility
- **Categories**: Hierarchical product categories
- **Variants**: Product variants (size, color, etc.) with individual pricing
- **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.
"""
import Ecto.Query, warn: false
alias PhoenixKit.Modules.Billing
alias PhoenixKit.Modules.Shop.Cart
alias PhoenixKit.Modules.Shop.CartItem
alias PhoenixKit.Modules.Shop.Category
alias PhoenixKit.Modules.Shop.Product
alias PhoenixKit.Modules.Shop.ShippingMethod
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth
alias PhoenixKit.Utils.Routes
# ============================================
# SYSTEM ENABLE/DISABLE
# ============================================
@doc """
Checks if the shop system is enabled.
"""
def enabled? do
Settings.get_setting_cached("shop_enabled", "false") == "true"
end
@doc """
Enables the shop system.
"""
def enable_system do
Settings.update_setting("shop_enabled", "true")
refresh_dashboard_tabs()
end
@doc """
Disables the shop system.
"""
def disable_system do
Settings.update_setting("shop_enabled", "false")
refresh_dashboard_tabs()
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
@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",
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
# ============================================
# PRODUCTS
# ============================================
@doc """
Lists all products with optional filters.
## Options
- `:status` - Filter by status (draft, active, archived)
- `:product_type` - Filter by type (physical, digital)
- `:category_id` - 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, :id)
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 """
Gets a product by ID.
"""
def get_product(id, opts \\ []) do
Product
|> maybe_preload(Keyword.get(opts, :preload))
|> repo().get(id)
end
@doc """
Gets a product by ID, raises if not found.
"""
def get_product!(id, opts \\ []) do
Product
|> maybe_preload(Keyword.get(opts, :preload))
|> repo().get!(id)
end
@doc """
Gets a product by slug.
"""
def get_product_by_slug(slug, opts \\ []) do
Product
|> where([p], p.slug == ^slug)
|> maybe_preload(Keyword.get(opts, :preload))
|> repo().one()
end
@doc """
Creates a new product.
"""
def create_product(attrs) do
%Product{}
|> Product.changeset(attrs)
|> repo().insert()
end
@doc """
Updates a product.
"""
def update_product(%Product{} = product, attrs) do
product
|> Product.changeset(attrs)
|> repo().update()
end
@doc """
Deletes a product.
"""
def delete_product(%Product{} = product) do
repo().delete(product)
end
@doc """
Returns a changeset for product form.
"""
def change_product(%Product{} = product, attrs \\ %{}) do
Product.changeset(product, attrs)
end
# ============================================
# CATEGORIES
# ============================================
@doc """
Lists all categories.
## Options
- `:parent_id` - Filter by parent (nil for root categories)
- `: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 """
Lists root categories (no parent).
"""
def list_root_categories(opts \\ []) do
list_categories(Keyword.put(opts, :parent_id, nil))
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, :id)
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.
"""
def get_category(id, opts \\ []) do
Category
|> maybe_preload(Keyword.get(opts, :preload))
|> repo().get(id)
end
@doc """
Gets a category by ID, raises if not found.
"""
def get_category!(id, opts \\ []) do
Category
|> maybe_preload(Keyword.get(opts, :preload))
|> repo().get!(id)
end
@doc """
Gets a category by slug.
"""
def get_category_by_slug(slug, opts \\ []) do
Category
|> where([c], c.slug == ^slug)
|> maybe_preload(Keyword.get(opts, :preload))
|> repo().one()
end
@doc """
Creates a new category.
"""
def create_category(attrs) do
%Category{}
|> Category.changeset(attrs)
|> repo().insert()
end
@doc """
Updates a category.
"""
def update_category(%Category{} = category, attrs) do
category
|> Category.changeset(attrs)
|> repo().update()
end
@doc """
Deletes a category.
"""
def delete_category(%Category{} = category) do
repo().delete(category)
end
@doc """
Returns a changeset for category form.
"""
def change_category(%Category{} = category, attrs \\ %{}) do
Category.changeset(category, attrs)
end
@doc """
Returns categories as options for select input.
"""
def category_options do
Category
|> order_by([c], [c.position, c.name])
|> select([c], {c.name, c.id})
|> repo().all()
end
# ============================================
# 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.
"""
def get_shipping_method(id) do
repo().get(ShippingMethod, id)
end
@doc """
Gets a shipping method by ID, raises if not found.
"""
def get_shipping_method!(id) do
repo().get!(ShippingMethod, id)
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_id` - User ID (for authenticated users)
- `:session_id` - Session ID (for guests)
"""
def get_or_create_cart(opts) do
user_id = Keyword.get(opts, :user_id)
session_id = Keyword.get(opts, :session_id)
case find_active_cart(user_id: user_id, session_id: session_id) do
nil -> create_cart(user_id: user_id, session_id: session_id)
cart -> {:ok, cart}
end
end
@doc """
Finds active cart by user_id or session_id.
Search priority:
1. If user_id is provided, search by user_id 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_id
"""
def find_active_cart(opts) do
user_id = Keyword.get(opts, :user_id)
session_id = Keyword.get(opts, :session_id)
base_query =
Cart
|> where([c], c.status == "active")
|> preload([:items, :shipping_method, :payment_option])
cond do
not is_nil(user_id) ->
# First try to find by user_id
case base_query |> where([c], c.user_id == ^user_id) |> 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_id))
|> repo().one()
true ->
# No identity provided
nil
end
end
@doc """
Creates a new cart.
"""
def create_cart(opts) do
attrs = %{
user_id: Keyword.get(opts, :user_id),
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 with items preloaded.
"""
def get_cart(id) do
Cart
|> preload([:items, :shipping_method, :payment_option])
|> repo().get(id)
end
@doc """
Gets a cart by ID, raises if not found.
"""
def get_cart!(id) do
Cart
|> preload([:items, :shipping_method, :payment_option])
|> repo().get!(id)
end
@doc """
Adds item to cart.
"""
def add_to_cart(%Cart{} = cart, %Product{} = product, quantity \\ 1) do
repo().transaction(fn ->
# Check if product already in cart
existing = find_cart_item(cart.id, product.id)
case existing do
nil ->
# Create new item
attrs = CartItem.from_product(product, quantity) |> Map.put(:cart_id, cart.id)
%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
recalculate_cart_totals!(cart)
end)
end
@doc """
Updates item quantity in cart.
"""
def update_cart_item(%CartItem{} = item, quantity) when quantity > 0 do
repo().transaction(fn ->
item
|> CartItem.changeset(%{quantity: quantity})
|> repo().update!()
cart = repo().get!(Cart, item.cart_id)
recalculate_cart_totals!(cart)
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
repo().transaction(fn ->
cart_id = item.cart_id
repo().delete!(item)
cart = repo().get!(Cart, cart_id)
recalculate_cart_totals!(cart)
end)
end
@doc """
Clears all items from cart.
"""
def clear_cart(%Cart{} = cart) do
repo().transaction(fn ->
CartItem
|> where([i], i.cart_id == ^cart.id)
|> repo().delete_all()
recalculate_cart_totals!(cart)
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"))
repo().transaction(fn ->
updated_cart =
cart
|> Cart.shipping_changeset(%{
shipping_method_id: method.id,
shipping_country: country,
shipping_amount: shipping_cost
})
|> repo().update!()
recalculate_cart_totals!(updated_cart)
end)
end
@doc """
Sets payment option for cart.
"""
def set_cart_payment_option(%Cart{} = cart, payment_option_id)
when is_integer(payment_option_id) do
cart
|> Cart.payment_changeset(%{payment_option_id: payment_option_id})
|> repo().update()
end
def set_cart_payment_option(%Cart{} = cart, nil) do
cart
|> Cart.payment_changeset(%{payment_option_id: nil})
|> repo().update()
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_id) ->
{: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.id)
# 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_id) ->
{: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.
"""
def merge_guest_cart(session_id, user_id) do
guest_cart = find_active_cart(session_id: session_id)
user_cart = find_active_cart(user_id: user_id)
case {guest_cart, user_cart} do
{nil, _} ->
{:ok, user_cart}
{guest, nil} ->
# Convert guest cart to user cart
guest
|> Cart.changeset(%{user_id: user_id, session_id: nil, expires_at: nil})
|> repo().update()
{guest, user} ->
# Merge items into user cart
repo().transaction(fn ->
# Move items from guest to user cart
for item <- guest.items do
existing = find_cart_item(user.id, item.product_id)
case existing do
nil ->
attrs =
Map.from_struct(item)
|> Map.drop([:__meta__, :id, :uuid, :cart, :product, :inserted_at, :updated_at])
|> Map.put(:cart_id, user.id)
%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
# Mark guest cart as merged
guest
|> Cart.status_changeset("merged", %{merged_into_cart_id: user.id})
|> repo().update!()
# Recalculate user cart
recalculate_cart_totals!(user)
repo().get!(Cart, user.id)
|> repo().preload([:items, :shipping_method, :payment_option])
end)
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, :id)
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 = DateTime.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 = DateTime.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, :id)
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_id or direct billing_data)
- Cart marked as "converted"
For guest checkout (no user_id 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_id: id` - 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.id)
with :ok <- validate_cart_convertible(cart),
{:ok, user_id, 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_id, order_attrs),
{:ok, _cart} <- mark_cart_converted(cart, order.id),
:ok <- maybe_send_guest_confirmation(user_id) do
{:ok, order}
end
end
# Legacy support: convert_cart_to_order(cart, billing_profile_id)
def convert_cart_to_order(%Cart{} = cart, billing_profile_id)
when is_integer(billing_profile_id) do
convert_cart_to_order(cart, billing_profile_id: billing_profile_id)
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_id) -> {: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" => item.product_slug,
"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_id = Keyword.get(opts, :billing_profile_id)
billing_data = Keyword.get(opts, :billing_data)
# Get shipping country from billing data or cart
shipping_country = get_shipping_country(billing_profile_id, 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_id" => cart.id,
"shipping_country" => shipping_country,
"shipping_method_id" => cart.shipping_method_id
}
}
cond do
# Logged-in user with billing profile
not is_nil(billing_profile_id) ->
Map.put(base_attrs, "billing_profile_id", billing_profile_id)
# 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_id, _billing_data, cart)
when not is_nil(billing_profile_id) do
case Billing.get_billing_profile(billing_profile_id) do
%{country: country} when is_binary(country) -> country
_ -> cart.shipping_country
end
end
defp get_shipping_country(_billing_profile_id, billing_data, cart) when is_map(billing_data) do
billing_data["country"] || cart.shipping_country
end
defp get_shipping_country(_billing_profile_id, _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_id: user_id} = cart, _opts) when not is_nil(user_id) do
# Cart already has a user (logged-in checkout)
{:ok, user_id, cart}
end
defp resolve_checkout_user(%Cart{user_id: nil} = cart, opts) do
# Check if logged-in user_id was passed in opts (user is logged in but has guest cart)
case Keyword.get(opts, :user_id) do
user_id when not is_nil(user_id) ->
resolve_logged_in_user_with_guest_cart(cart, user_id)
nil ->
resolve_guest_checkout(cart, opts)
end
end
defp resolve_logged_in_user_with_guest_cart(cart, user_id) do
case assign_cart_to_user(cart, user_id) do
{:ok, updated_cart} -> {:ok, user_id, updated_cart}
{:error, _} -> {:ok, user_id, 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.id)
{:error, :email_exists_unconfirmed, user} ->
assign_cart_and_return(cart, user.id)
{:error, :email_exists_confirmed} ->
{:error, :email_already_registered}
{:error, changeset} ->
{:error, changeset}
end
end
defp assign_cart_and_return(cart, user_id) do
case assign_cart_to_user(cart, user_id) do
{:ok, updated_cart} -> {:ok, user_id, updated_cart}
{:error, _} -> {:ok, user_id, cart}
end
end
# Assign cart to user (for guest -> user conversion)
defp assign_cart_to_user(%Cart{} = cart, user_id) do
cart
|> Cart.changeset(%{user_id: user_id, 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_id, order_attrs) do
Billing.create_order(user_id, order_attrs)
end
# Send confirmation email to guest users
defp maybe_send_guest_confirmation(nil), do: :ok
defp maybe_send_guest_confirmation(user_id) do
case Auth.get_user(user_id) 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
defp mark_cart_converted(%Cart{} = cart, order_id) do
cart
|> Cart.status_changeset("converted", %{
converted_at: DateTime.utc_now(),
metadata: Map.put(cart.metadata || %{}, "order_id", order_id)
})
|> repo().update()
end
# ============================================
# PRIVATE HELPERS
# ============================================
defp count_products do
Product |> repo().aggregate(:count, :id)
rescue
_ -> 0
end
defp count_products_by_status(status) do
Product
|> where([p], p.status == ^status)
|> repo().aggregate(:count, :id)
rescue
_ -> 0
end
defp count_products_by_type(product_type) do
Product
|> where([p], p.product_type == ^product_type)
|> repo().aggregate(:count, :id)
rescue
_ -> 0
end
defp count_categories do
Category |> repo().aggregate(:count, :id)
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_id))
|> filter_by_product_search(Keyword.get(opts, :search))
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, id), do: where(query, [p], p.category_id == ^id)
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}%"
where(
query,
[p],
ilike(p.title, ^search_term) or ilike(p.description, ^search_term)
)
end
defp apply_category_filters(query, opts) do
query
|> filter_by_parent(Keyword.get(opts, :parent_id, :skip))
|> filter_by_category_search(Keyword.get(opts, :search))
end
defp filter_by_parent(query, :skip), do: query
defp filter_by_parent(query, nil), do: where(query, [c], is_nil(c.parent_id))
defp filter_by_parent(query, id), do: where(query, [c], c.parent_id == ^id)
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}%"
where(query, [c], ilike(c.name, ^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
defp find_cart_item(cart_id, product_id) do
CartItem
|> where([i], i.cart_id == ^cart_id and i.product_id == ^product_id)
|> where([i], is_nil(i.variant_id))
|> repo().one()
end
defp recalculate_cart_totals!(%Cart{} = cart) do
items = CartItem |> where([i], i.cart_id == ^cart.id) |> 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)
# Recalculate shipping if method selected
shipping_amount =
if cart.shipping_method_id do
case repo().get(ShippingMethod, cart.shipping_method_id) 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
# 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 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()
end