Packages

phoenix_kit

1.7.70
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
phoenix_kit lib modules shop schemas cart.ex
Raw

lib/modules/shop/schemas/cart.ex

defmodule PhoenixKit.Modules.Shop.Cart do
@moduledoc """
Shopping cart schema with support for guest and authenticated users.
## Status Lifecycle
- `active` - Cart is active and can be modified
- `merged` - Guest cart was merged into user cart after login
- `converted` - Cart was converted to an order
- `abandoned` - Cart was marked as abandoned (no activity)
- `expired` - Cart expired (past expires_at)
## Identity
Each cart has either `user_uuid` (for authenticated users) or `session_id` (for guests).
Guest carts have an `expires_at` timestamp (30 days by default).
When a guest logs in, their cart is either converted to a user cart or merged
with an existing user cart.
"""
use Ecto.Schema
import Ecto.Changeset
alias PhoenixKit.Modules.Billing.PaymentOption
alias PhoenixKit.Modules.Shop.CartItem
alias PhoenixKit.Modules.Shop.ShippingMethod
alias PhoenixKit.Users.Auth.User
alias PhoenixKit.Utils.Date, as: UtilsDate
@statuses ~w(active merged converted abandoned expired)
@primary_key {:uuid, UUIDv7, autogenerate: true}
schema "phoenix_kit_shop_carts" do
# Identity
belongs_to :user, User, foreign_key: :user_uuid, references: :uuid, type: UUIDv7
field :session_id, :string
# Status
field :status, :string, default: "active"
# Shipping
belongs_to :shipping_method, ShippingMethod,
foreign_key: :shipping_method_uuid,
references: :uuid,
type: UUIDv7
field :shipping_country, :string
# Payment
belongs_to :payment_option, PaymentOption,
foreign_key: :payment_option_uuid,
references: :uuid,
type: UUIDv7
# Totals (cached)
field :subtotal, :decimal, default: Decimal.new("0")
field :shipping_amount, :decimal, default: Decimal.new("0")
field :tax_amount, :decimal, default: Decimal.new("0")
field :discount_amount, :decimal, default: Decimal.new("0")
field :total, :decimal, default: Decimal.new("0")
field :currency, :string, default: "USD"
# Discount
field :discount_code, :string
# Calculated
field :total_weight_grams, :integer, default: 0
field :items_count, :integer, default: 0
# Metadata
field :metadata, :map, default: %{}
# Tracking
field :expires_at, :utc_datetime
field :converted_at, :utc_datetime
field :merged_into_cart_uuid, UUIDv7
has_many :items, CartItem, foreign_key: :cart_uuid, references: :uuid
timestamps(type: :utc_datetime)
end
@doc """
Changeset for cart creation and updates.
"""
def changeset(cart, attrs) do
cart
|> cast(attrs, [
:user_uuid,
:session_id,
:status,
:shipping_method_uuid,
:shipping_country,
:payment_option_uuid,
:subtotal,
:shipping_amount,
:tax_amount,
:discount_amount,
:total,
:currency,
:discount_code,
:total_weight_grams,
:items_count,
:metadata,
:expires_at,
:converted_at,
:merged_into_cart_uuid
])
|> validate_inclusion(:status, @statuses)
|> validate_length(:currency, is: 3)
|> validate_length(:shipping_country, max: 2)
|> validate_identity()
|> maybe_set_expires_at()
end
@doc """
Changeset for updating cart totals.
"""
def totals_changeset(cart, attrs) do
cart
|> cast(attrs, [
:subtotal,
:shipping_amount,
:tax_amount,
:discount_amount,
:total,
:total_weight_grams,
:items_count
])
end
@doc """
Changeset for setting shipping.
"""
def shipping_changeset(cart, attrs) do
cart
|> cast(attrs, [
:shipping_method_uuid,
:shipping_country,
:shipping_amount
])
end
@doc """
Changeset for setting payment option.
"""
def payment_changeset(cart, attrs) do
cart
|> cast(attrs, [:payment_option_uuid])
end
@doc """
Changeset for status transitions.
"""
def status_changeset(cart, new_status, extra_attrs \\ %{}) do
attrs = Map.merge(%{status: new_status}, extra_attrs)
cart
|> cast(attrs, [:status, :converted_at, :merged_into_cart_uuid])
|> validate_status_transition(cart.status, new_status)
end
@doc """
Returns true if cart is active.
"""
def active?(%__MODULE__{status: "active"}), do: true
def active?(_), do: false
@doc """
Returns true if cart is a guest cart (no user_uuid).
"""
def guest?(%__MODULE__{user_uuid: nil}), do: true
def guest?(_), do: false
@doc """
Returns true if cart is empty.
"""
def empty?(%__MODULE__{items_count: 0}), do: true
def empty?(%__MODULE__{items_count: nil}), do: true
def empty?(_), do: false
@doc """
Returns true if cart can be converted to order.
"""
def convertible?(%__MODULE__{status: "active", items_count: count}) when count > 0, do: true
def convertible?(_), do: false
@doc """
Returns true if cart has expired.
"""
def expired?(%__MODULE__{expires_at: nil}), do: false
def expired?(%__MODULE__{expires_at: expires_at}) do
DateTime.compare(UtilsDate.utc_now(), expires_at) == :gt
end
@doc """
Returns list of valid status values.
"""
def statuses, do: @statuses
# Private helpers
defp validate_identity(changeset) do
user_uuid = get_field(changeset, :user_uuid)
session_id = get_field(changeset, :session_id)
if is_nil(user_uuid) and is_nil(session_id) do
add_error(changeset, :base, "Either user_uuid or session_id must be set")
else
changeset
end
end
defp validate_status_transition(changeset, from, to) do
valid_transitions = %{
"active" => ~w(converting merged converted abandoned expired),
"converting" => ~w(converted active),
"merged" => [],
"converted" => [],
"abandoned" => ~w(active),
"expired" => []
}
allowed = Map.get(valid_transitions, from, [])
if to in allowed or from == to do
changeset
else
add_error(changeset, :status, "cannot transition from #{from} to #{to}")
end
end
defp maybe_set_expires_at(changeset) do
user_uuid = get_field(changeset, :user_uuid)
session_id = get_field(changeset, :session_id)
expires_at = get_field(changeset, :expires_at)
# Guest carts expire in 30 days
if is_nil(user_uuid) and not is_nil(session_id) and is_nil(expires_at) do
expires = UtilsDate.utc_now() |> DateTime.add(30, :day) |> DateTime.truncate(:second)
put_change(changeset, :expires_at, expires)
else
changeset
end
end
end