Packages
phoenix_kit
1.7.3
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/phoenix_kit/billing/order.ex
defmodule PhoenixKit.Billing.Order do
@moduledoc """
Order schema for PhoenixKit Billing system.
Manages orders with line items, amounts, and billing information.
Orders serve as the primary document for tracking what users purchased.
## Schema Fields
### Identity & Relations
- `user_id`: Foreign key to the user who placed the order
- `billing_profile_id`: Foreign key to the billing profile used
- `order_number`: Unique order identifier (e.g., "ORD-2024-0001")
- `status`: Order status workflow
### Payment
- `payment_method`: Payment method (Phase 1: "bank" only)
- `currency`: ISO 4217 currency code
### Line Items
- `line_items`: JSONB array of items purchased
### Financial
- `subtotal`: Sum of line items before tax/discount
- `tax_amount`: Calculated tax amount
- `tax_rate`: Applied tax rate (0.20 = 20%)
- `discount_amount`: Discount applied
- `discount_code`: Coupon/referral code used
- `total`: Final amount to be paid
### Snapshots & Notes
- `billing_snapshot`: Copy of billing profile at order time
- `notes`: Customer-visible notes
- `internal_notes`: Admin-only notes
## Status Workflow
```
draft → pending → confirmed → paid
↘ ↘
cancelled refunded
```
## Line Item Structure
```json
[
{
"name": "Pro Plan - Monthly",
"description": "Professional subscription plan",
"quantity": 1,
"unit_price": "99.00",
"total": "99.00",
"sku": "PLAN-PRO-M"
}
]
```
## Usage Examples
# Create an order
{:ok, order} = Billing.create_order(user, %{
billing_profile_id: profile.id,
currency: "EUR",
line_items: [
%{name: "Pro Plan", quantity: 1, unit_price: "99.00", total: "99.00"}
],
subtotal: "99.00",
total: "99.00"
})
# Confirm order
{:ok, order} = Billing.confirm_order(order)
# Mark as paid
{:ok, order} = Billing.mark_order_paid(order)
"""
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query, warn: false
alias PhoenixKit.Billing.BillingProfile
alias PhoenixKit.Billing.CountryData
alias PhoenixKit.Users.Auth.User
@primary_key {:id, :id, autogenerate: true}
@valid_statuses ~w(draft pending confirmed paid cancelled refunded)
@valid_payment_methods ~w(bank stripe paypal razorpay)
schema "phoenix_kit_orders" do
field :order_number, :string
field :status, :string, default: "draft"
field :payment_method, :string, default: "bank"
# Line items (JSONB)
field :line_items, {:array, :map}, default: []
# Financial
field :subtotal, :decimal, default: Decimal.new("0")
field :tax_amount, :decimal, default: Decimal.new("0")
field :tax_rate, :decimal, default: Decimal.new("0")
field :discount_amount, :decimal, default: Decimal.new("0")
field :discount_code, :string
field :total, :decimal
field :currency, :string, default: "EUR"
# Snapshots
field :billing_snapshot, :map, default: %{}
# Notes
field :notes, :string
field :internal_notes, :string
field :metadata, :map, default: %{}
# Timestamps
field :confirmed_at, :utc_datetime_usec
field :paid_at, :utc_datetime_usec
field :cancelled_at, :utc_datetime_usec
belongs_to :user, User
belongs_to :billing_profile, BillingProfile
has_many :invoices, PhoenixKit.Billing.Invoice
timestamps(type: :utc_datetime_usec)
end
@doc """
Creates a changeset for order creation.
"""
def changeset(order, attrs) do
order
|> cast(attrs, [
:user_id,
:billing_profile_id,
:order_number,
:status,
:payment_method,
:line_items,
:subtotal,
:tax_amount,
:tax_rate,
:discount_amount,
:discount_code,
:total,
:currency,
:billing_snapshot,
:notes,
:internal_notes,
:metadata,
:confirmed_at,
:paid_at,
:cancelled_at
])
|> validate_required([:user_id, :total, :currency])
|> validate_inclusion(:status, @valid_statuses)
|> validate_inclusion(:payment_method, @valid_payment_methods)
|> validate_length(:currency, is: 3)
|> validate_number(:total, greater_than_or_equal_to: 0)
|> validate_number(:subtotal, greater_than_or_equal_to: 0)
|> validate_number(:tax_amount, greater_than_or_equal_to: 0)
|> validate_number(:discount_amount, greater_than_or_equal_to: 0)
|> validate_line_items()
|> maybe_generate_order_number()
|> unique_constraint(:order_number)
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:billing_profile_id)
end
@doc """
Changeset for status transitions.
"""
def status_changeset(order, new_status) do
changeset =
order
|> change(status: new_status)
|> validate_status_transition(order.status, new_status)
case new_status do
"confirmed" -> put_change(changeset, :confirmed_at, DateTime.utc_now())
"paid" -> put_change(changeset, :paid_at, DateTime.utc_now())
"cancelled" -> put_change(changeset, :cancelled_at, DateTime.utc_now())
_ -> changeset
end
end
defp validate_status_transition(changeset, from, to) do
valid_transitions = %{
"draft" => ~w(pending confirmed cancelled),
"pending" => ~w(confirmed cancelled),
"confirmed" => ~w(paid cancelled refunded),
"paid" => ~w(refunded),
"cancelled" => [],
"refunded" => []
}
allowed = Map.get(valid_transitions, from, [])
if to in allowed do
changeset
else
add_error(changeset, :status, "cannot transition from #{from} to #{to}")
end
end
defp validate_line_items(changeset) do
items = get_field(changeset, :line_items) || []
errors =
items
|> Enum.with_index()
|> Enum.flat_map(fn {item, idx} ->
cond do
not is_map(item) ->
["Item #{idx + 1}: must be a map"]
not Map.has_key?(item, "name") and not Map.has_key?(item, :name) ->
["Item #{idx + 1}: missing name"]
true ->
[]
end
end)
if errors == [] do
changeset
else
add_error(changeset, :line_items, Enum.join(errors, "; "))
end
end
defp maybe_generate_order_number(changeset) do
if get_field(changeset, :order_number) do
changeset
else
# Will be set by context with proper prefix from settings
changeset
end
end
@doc """
Calculates totals from line items.
Returns `{subtotal, tax_amount, total}` as Decimals.
"""
def calculate_totals(line_items, tax_rate \\ Decimal.new("0"), discount \\ Decimal.new("0")) do
subtotal =
line_items
|> Enum.reduce(Decimal.new("0"), fn item, acc ->
item_total =
item
|> Map.get("total", Map.get(item, :total, "0"))
|> to_decimal()
Decimal.add(acc, item_total)
end)
taxable = Decimal.sub(subtotal, discount)
tax_amount = Decimal.mult(taxable, tax_rate) |> Decimal.round(2)
total = Decimal.add(taxable, tax_amount)
{subtotal, tax_amount, total}
end
@doc """
Calculates totals with automatic tax rate from country.
Uses standard VAT rate from BeamLabCountries based on the billing country.
Returns `{subtotal, tax_amount, total}` as Decimals.
## Examples
iex> items = [%{"total" => "100.00"}]
iex> {subtotal, tax, total} = Order.calculate_totals_for_country(items, "EE")
iex> Decimal.to_string(tax)
"20.00"
iex> Decimal.to_string(total)
"120.00"
"""
def calculate_totals_for_country(line_items, country_code, discount \\ Decimal.new("0")) do
tax_rate = CountryData.get_standard_vat_rate(country_code)
calculate_totals(line_items, tax_rate, discount)
end
@doc """
Gets the standard VAT rate for a country as a Decimal.
## Examples
iex> Order.get_country_tax_rate("EE")
#Decimal<0.20>
iex> Order.get_country_tax_rate("US")
#Decimal<0>
"""
def get_country_tax_rate(country_code) do
CountryData.get_standard_vat_rate(country_code)
end
defp to_decimal(%Decimal{} = d), do: d
defp to_decimal(n) when is_number(n), do: Decimal.from_float(n * 1.0)
defp to_decimal(s) when is_binary(s), do: Decimal.new(s)
@doc """
Checks if order can be edited (is in draft or pending status).
"""
def editable?(%__MODULE__{status: status}) when status in ~w(draft pending), do: true
def editable?(_), do: false
@doc """
Checks if order can be cancelled.
"""
def cancellable?(%__MODULE__{status: status}) when status in ~w(draft pending confirmed),
do: true
def cancellable?(_), do: false
@doc """
Checks if order can be marked as paid.
"""
def payable?(%__MODULE__{status: "confirmed"}), do: true
def payable?(_), do: false
@doc """
Returns human-readable status label.
"""
def status_label("draft"), do: "Draft"
def status_label("pending"), do: "Pending"
def status_label("confirmed"), do: "Confirmed"
def status_label("paid"), do: "Paid"
def status_label("cancelled"), do: "Cancelled"
def status_label("refunded"), do: "Refunded"
def status_label(_), do: "Unknown"
@doc """
Returns status badge color class.
"""
def status_color("draft"), do: "badge-neutral"
def status_color("pending"), do: "badge-warning"
def status_color("confirmed"), do: "badge-info"
def status_color("paid"), do: "badge-success"
def status_color("cancelled"), do: "badge-error"
def status_color("refunded"), do: "badge-secondary"
def status_color(_), do: "badge-ghost"
end