Packages
phoenix_kit
1.7.39
1.7.216
1.7.215
1.7.214
1.7.213
1.7.212
1.7.211
1.7.210
1.7.209
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
Current section
Files
lib/modules/shop/schemas/cart_item.ex
defmodule PhoenixKit.Modules.Shop.CartItem do
@moduledoc """
Cart item schema with price snapshot for consistency.
When a product is added to the cart, we snapshot the current price and product
details. This ensures that:
1. Price changes after adding don't affect the cart total unexpectedly
2. If the product is deleted, we still have the title and other info
3. We can show users when prices have changed since they added items
## Fields
- `cart_id` - Reference to the cart (required)
- `product_id` - Reference to the product (nullable, ON DELETE SET NULL)
- `product_title` - Product title snapshot (required)
- `product_slug` - Product slug snapshot
- `product_sku` - Product SKU snapshot
- `product_image` - Product image URL snapshot
- `unit_price` - Price per unit at time of adding (required)
- `compare_at_price` - Original price for showing discounts
- `quantity` - Number of items (required, > 0)
- `line_total` - Calculated: unit_price * quantity
- `weight_grams` - Weight for shipping calculation
- `taxable` - Whether item is taxable
- `selected_specs` - JSON object for specification-based pricing (e.g., {"material": "PETG", "color": "Gold"})
"""
use Ecto.Schema
import Ecto.Changeset
alias PhoenixKit.Modules.Shop.Cart
alias PhoenixKit.Modules.Shop.Product
@primary_key {:uuid, UUIDv7, autogenerate: true}
schema "phoenix_kit_shop_cart_items" do
field :id, :integer, read_after_writes: true
# legacy
field :cart_id, :integer
belongs_to :cart, Cart, foreign_key: :cart_uuid, references: :uuid, type: UUIDv7
# legacy
field :product_id, :integer
belongs_to :product, Product, foreign_key: :product_uuid, references: :uuid, type: UUIDv7
# Snapshot
field :product_title, :string
field :product_slug, :string
field :product_sku, :string
field :product_image, :string
# Pricing (snapshot)
field :unit_price, :decimal
field :compare_at_price, :decimal
field :currency, :string, default: "USD"
# Quantity
field :quantity, :integer, default: 1
# Calculated
field :line_total, :decimal
# Physical
field :weight_grams, :integer, default: 0
field :taxable, :boolean, default: true
# Specification-based pricing
field :selected_specs, :map, default: %{}
field :metadata, :map, default: %{}
timestamps(type: :utc_datetime)
end
@doc """
Changeset for cart item creation and updates.
"""
def changeset(item, attrs) do
item
|> cast(attrs, [
:cart_id,
:cart_uuid,
:product_id,
:product_uuid,
:product_title,
:product_slug,
:product_sku,
:product_image,
:unit_price,
:compare_at_price,
:currency,
:quantity,
:line_total,
:weight_grams,
:taxable,
:selected_specs,
:metadata
])
|> validate_required([:cart_uuid, :product_title, :unit_price, :quantity])
|> validate_number(:quantity, greater_than: 0)
|> validate_number(:unit_price, greater_than_or_equal_to: 0)
|> validate_length(:currency, is: 3)
|> calculate_line_total()
|> foreign_key_constraint(:cart_uuid)
|> foreign_key_constraint(:product_uuid)
end
@doc """
Creates changeset attributes from a product.
## Parameters
- `product` - The Product struct
- `quantity` - Number of items (default: 1)
- `language` - Language code for localized fields (default: system default)
## Examples
iex> from_product(product, 2)
%{
product_id: 1,
product_title: "Widget",
product_slug: "widget",
unit_price: Decimal.new("19.99"),
quantity: 2,
...
}
iex> from_product(product, 1, "ru")
%{product_title: "Виджет", product_slug: "vidzhet", ...}
"""
def from_product(%Product{} = product, quantity \\ 1, language \\ nil) do
lang = language || default_language()
%{
product_id: product.id,
product_uuid: product.uuid,
product_title: get_localized_string(product.title, lang),
product_slug: get_localized_string(product.slug, lang),
product_image: get_product_image_url(product),
unit_price: product.price,
compare_at_price: product.compare_at_price,
currency: product.currency,
quantity: quantity,
weight_grams: product.weight_grams || 0,
taxable: product.taxable
}
end
# Get product image URL, preferring new Storage system over legacy
defp get_product_image_url(%Product{featured_image_id: id}) when is_binary(id) do
alias PhoenixKit.Modules.Storage.URLSigner
try do
URLSigner.signed_url(id, "medium")
rescue
_ -> nil
end
end
defp get_product_image_url(%Product{featured_image: url}) when is_binary(url), do: url
defp get_product_image_url(_), do: nil
# Extract string from localized JSONB map field
defp get_localized_string(nil, _lang), do: nil
defp get_localized_string(value, _lang) when is_binary(value), do: value
defp get_localized_string(map, lang) when is_map(map) do
map[lang] || map[default_language()] || first_value(map)
end
defp get_localized_string(_value, _lang), do: nil
defp first_value(map) when map == %{}, do: nil
defp first_value(map), do: map |> Map.values() |> List.first()
defp default_language do
alias PhoenixKit.Modules.Shop.Translations
Translations.default_language()
end
@doc """
Returns true if product data has changed since the item was added.
Useful for showing price change warnings.
"""
def product_changed?(%__MODULE__{product_uuid: nil, product_id: nil}, _product), do: true
def product_changed?(%__MODULE__{} = item, %Product{} = product) do
Decimal.compare(item.unit_price, product.price) != :eq
end
@doc """
Returns the price difference if the product price has changed.
Positive = price increased, Negative = price decreased.
"""
def price_difference(%__MODULE__{} = item, %Product{} = product) do
Decimal.sub(product.price, item.unit_price)
end
@doc """
Returns true if this item is on sale (has compare_at_price > unit_price).
"""
def on_sale?(%__MODULE__{compare_at_price: nil}), do: false
def on_sale?(%__MODULE__{compare_at_price: compare, unit_price: price}) do
Decimal.compare(compare, price) == :gt
end
@doc """
Returns discount percentage for sale items.
"""
def discount_percentage(%__MODULE__{} = item) do
if on_sale?(item) do
diff = Decimal.sub(item.compare_at_price, item.unit_price)
diff
|> Decimal.div(item.compare_at_price)
|> Decimal.mult(100)
|> Decimal.round(0)
|> Decimal.to_integer()
else
0
end
end
@doc """
Returns true if the product has been deleted (product_id is nil after SET NULL).
"""
def product_deleted?(%__MODULE__{product_uuid: nil, product_id: nil}), do: true
def product_deleted?(_), do: false
# Private helpers
defp calculate_line_total(changeset) do
quantity = get_field(changeset, :quantity) || 1
unit_price = get_field(changeset, :unit_price) || Decimal.new("0")
line_total = Decimal.mult(unit_price, quantity)
put_change(changeset, :line_total, line_total)
end
end