Packages

phoenix_kit

1.7.10
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 phoenix_kit billing invoice.ex
Raw

lib/phoenix_kit/billing/invoice.ex

defmodule PhoenixKit.Billing.Invoice do
@moduledoc """
Invoice schema for PhoenixKit Billing system.
Invoices are generated from orders and sent to customers for payment.
They include receipt functionality once payment is confirmed.
## Schema Fields
### Identity & Relations
- `user_id`: Foreign key to the user
- `order_id`: Foreign key to the source order (optional)
- `invoice_number`: Unique invoice identifier (e.g., "INV-2024-0001")
- `status`: Invoice status workflow
### Financial
- `subtotal`, `tax_amount`, `tax_rate`, `total`: Financial amounts
- `currency`: ISO 4217 currency code
- `due_date`: Payment due date
### Billing Details
- `billing_details`: Full snapshot of billing profile
- `line_items`: Copy of order line items
- `payment_terms`: Payment terms text
- `bank_details`: Bank account for payment
### Receipt
- `receipt_number`: Receipt identifier (generated after payment)
- `receipt_generated_at`: When receipt was generated
- `receipt_data`: Additional receipt data (PDF URL, etc.)
## Status Workflow
```
draft → sent → paid
↘
overdue → paid
↘
void
```
## Usage Examples
# Generate invoice from order
{:ok, invoice} = Billing.create_invoice_from_order(order)
# Send invoice
{:ok, invoice} = Billing.send_invoice(invoice)
# Mark as paid (generates receipt)
{:ok, invoice} = Billing.mark_invoice_paid(invoice)
# Get receipt
receipt = Billing.get_receipt(invoice)
"""
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query, warn: false
alias PhoenixKit.Billing.Order
alias PhoenixKit.Billing.Transaction
alias PhoenixKit.Users.Auth.User
@primary_key {:id, :id, autogenerate: true}
@valid_statuses ~w(draft sent paid void overdue)
schema "phoenix_kit_invoices" do
field :invoice_number, :string
field :status, :string, default: "draft"
# 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 :total, :decimal
field :paid_amount, :decimal, default: Decimal.new("0")
field :currency, :string, default: "EUR"
field :due_date, :date
# Billing details (snapshot)
field :billing_details, :map, default: %{}
field :line_items, {:array, :map}, default: []
field :payment_terms, :string
field :bank_details, :map, default: %{}
field :notes, :string
field :metadata, :map, default: %{}
# Receipt (integrated)
field :receipt_number, :string
field :receipt_generated_at, :utc_datetime_usec
field :receipt_data, :map, default: %{}
# Timestamps
field :sent_at, :utc_datetime_usec
field :paid_at, :utc_datetime_usec
field :voided_at, :utc_datetime_usec
belongs_to :user, User
belongs_to :order, Order
has_many :transactions, Transaction
timestamps(type: :utc_datetime_usec)
end
@doc """
Creates a changeset for invoice creation.
"""
def changeset(invoice, attrs) do
invoice
|> cast(attrs, [
:user_id,
:order_id,
:invoice_number,
:status,
:subtotal,
:tax_amount,
:tax_rate,
:total,
:paid_amount,
:currency,
:due_date,
:billing_details,
:line_items,
:payment_terms,
:bank_details,
:notes,
:metadata,
:receipt_number,
:receipt_generated_at,
:receipt_data,
:sent_at,
:paid_at,
:voided_at
])
|> validate_required([:user_id, :total, :currency])
|> validate_inclusion(:status, @valid_statuses)
|> validate_length(:currency, is: 3)
|> validate_number(:total, greater_than_or_equal_to: 0)
|> validate_number(:paid_amount, greater_than_or_equal_to: 0)
|> unique_constraint(:invoice_number)
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:order_id)
end
@doc """
Changeset for status transitions.
"""
def status_changeset(invoice, new_status) do
changeset =
invoice
|> change(status: new_status)
|> validate_status_transition(invoice.status, new_status)
case new_status do
"sent" -> put_change(changeset, :sent_at, DateTime.utc_now())
"paid" -> put_change(changeset, :paid_at, DateTime.utc_now())
"void" -> put_change(changeset, :voided_at, DateTime.utc_now())
_ -> changeset
end
end
@doc """
Changeset for marking invoice as paid and generating receipt.
"""
def paid_changeset(invoice, receipt_number) do
now = DateTime.utc_now()
invoice
|> change(%{
status: "paid",
paid_at: now,
receipt_number: receipt_number,
receipt_generated_at: now,
receipt_data: %{
generated_at: DateTime.to_iso8601(now),
amount_paid: Decimal.to_string(invoice.total),
currency: invoice.currency
}
})
|> validate_status_transition(invoice.status, "paid")
end
defp validate_status_transition(changeset, from, to) do
valid_transitions = %{
"draft" => ~w(sent void),
"sent" => ~w(paid overdue void),
"overdue" => ~w(paid void),
"paid" => ~w(void),
"void" => []
}
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
@doc """
Creates an invoice from an order.
"""
def from_order(%Order{} = order, opts \\ []) do
due_days = Keyword.get(opts, :due_days, 14)
invoice_number = Keyword.get(opts, :invoice_number)
bank_details = Keyword.get(opts, :bank_details, %{})
payment_terms = Keyword.get(opts, :payment_terms)
%__MODULE__{
user_id: order.user_id,
order_id: order.id,
invoice_number: invoice_number,
status: "draft",
subtotal: order.subtotal,
tax_amount: order.tax_amount,
tax_rate: order.tax_rate,
total: order.total,
currency: order.currency,
due_date: Date.add(Date.utc_today(), due_days),
billing_details: order.billing_snapshot,
line_items: order.line_items,
payment_terms: payment_terms,
bank_details: bank_details,
notes: order.notes
}
end
@doc """
Checks if invoice can be edited.
"""
def editable?(%__MODULE__{status: "draft"}), do: true
def editable?(_), do: false
@doc """
Checks if invoice can be sent (first time - changes status to sent).
"""
def sendable?(%__MODULE__{status: "draft"}), do: true
def sendable?(_), do: false
@doc """
Checks if invoice can be resent (already sent, paid, or overdue).
"""
def resendable?(%__MODULE__{status: status}) when status in ~w(sent paid overdue), do: true
def resendable?(_), do: false
@doc """
Checks if invoice can be marked as paid.
"""
def payable?(%__MODULE__{status: status}) when status in ~w(sent overdue), do: true
def payable?(_), do: false
@doc """
Checks if invoice can be voided.
"""
def voidable?(%__MODULE__{status: status}) when status in ~w(draft sent overdue), do: true
def voidable?(_), do: false
@doc """
Checks if invoice has a receipt.
"""
def has_receipt?(%__MODULE__{receipt_number: nil}), do: false
def has_receipt?(%__MODULE__{receipt_number: _}), do: true
@doc """
Checks if invoice is overdue.
"""
def overdue?(%__MODULE__{status: "paid"}), do: false
def overdue?(%__MODULE__{status: "void"}), do: false
def overdue?(%__MODULE__{due_date: nil}), do: false
def overdue?(%__MODULE__{due_date: due_date}) do
Date.compare(due_date, Date.utc_today()) == :lt
end
@doc """
Returns human-readable status label.
"""
def status_label("draft"), do: "Draft"
def status_label("sent"), do: "Sent"
def status_label("paid"), do: "Paid"
def status_label("void"), do: "Void"
def status_label("overdue"), do: "Overdue"
def status_label(_), do: "Unknown"
@doc """
Returns status badge color class.
"""
def status_color("draft"), do: "badge-neutral"
def status_color("sent"), do: "badge-info"
def status_color("paid"), do: "badge-success"
def status_color("void"), do: "badge-error"
def status_color("overdue"), do: "badge-warning"
def status_color(_), do: "badge-ghost"
@doc """
Returns the billing name from billing_details snapshot.
"""
def billing_name(%__MODULE__{billing_details: %{"name" => name}}) when is_binary(name), do: name
def billing_name(%__MODULE__{billing_details: %{"company_name" => name}}) when is_binary(name),
do: name
def billing_name(%__MODULE__{billing_details: %{"first_name" => first, "last_name" => last}}) do
"#{first} #{last}" |> String.trim()
end
def billing_name(_), do: ""
@doc """
Returns the remaining amount to be paid.
"""
def remaining_amount(%__MODULE__{total: total, paid_amount: paid_amount}) do
Decimal.sub(total, paid_amount)
end
@doc """
Checks if invoice is fully paid (paid_amount >= total).
"""
def fully_paid?(%__MODULE__{total: total, paid_amount: paid_amount}) do
Decimal.compare(paid_amount, total) != :lt
end
@doc """
Checks if invoice has any payments (paid_amount > 0).
"""
def has_payments?(%__MODULE__{paid_amount: paid_amount}) do
Decimal.positive?(paid_amount)
end
@doc """
Checks if invoice can receive a refund (has payments).
"""
def refundable?(%__MODULE__{} = invoice) do
has_payments?(invoice)
end
@doc """
Changeset for updating paid_amount.
"""
def paid_amount_changeset(invoice, paid_amount) do
invoice
|> change(paid_amount: paid_amount)
|> validate_number(:paid_amount, greater_than_or_equal_to: 0)
end
# ============================================
# PAYMENT METHODS AGGREGATION
# ============================================
@doc """
Returns all unique payment methods used in transactions for this invoice.
Requires transactions to be preloaded.
## Examples
iex> Invoice.payment_methods(invoice_with_transactions)
["bank", "stripe"]
iex> Invoice.payment_methods(invoice_without_transactions)
[]
"""
def payment_methods(%__MODULE__{transactions: txns}) when is_list(txns) do
txns
|> Enum.map(& &1.payment_method)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
end
def payment_methods(_), do: []
@doc """
Returns the primary payment method (most used in positive transactions).
Useful for display when there are multiple payment methods.
Requires transactions to be preloaded.
## Examples
iex> Invoice.primary_payment_method(invoice)
"stripe"
iex> Invoice.primary_payment_method(invoice_without_transactions)
nil
"""
def primary_payment_method(%__MODULE__{transactions: txns}) when is_list(txns) do
txns
|> Enum.filter(&Decimal.positive?(&1.amount))
|> Enum.frequencies_by(& &1.payment_method)
|> Enum.max_by(fn {_method, count} -> count end, fn -> {nil, 0} end)
|> elem(0)
end
def primary_payment_method(_), do: nil
end