This guide provides comprehensive patterns and conventions for AI agents working on the Full Circle ERP codebase. It supplements CLAUDE.md with detailed implementation patterns and architectural knowledge.
- Quick Reference
- Architecture Deep Dive
- Schema Patterns
- Context Module Patterns
- LiveView Patterns
- Testing Patterns
- Authorization Patterns
- Common Tasks
- Gotchas and Pitfalls
Elixir 1.19.5, OTP 28.3.1, Phoenix 1.8.3, LiveView 1.1.x
Use `mise exec --` prefix for all mix/elixir commands
| What | Where |
|---|---|
| Custom schema base | lib/schema.ex |
| StdInterface (CRUD) | lib/full_circle/std_interface.ex |
| Authorization | lib/full_circle/authorization.ex |
| Helpers | lib/full_circle/helpers.ex |
| Core components | lib/full_circle_web/components/core_components.ex |
| Layouts | lib/full_circle_web/components/layouts/ |
| Router | lib/full_circle_web/router.ex |
| JS hooks | assets/js/app.js |
| Test support | test/support/ |
| Fixtures | test/support/fixtures/ |
| Context File | Schemas | Purpose |
|---|---|---|
billing.ex |
Invoice, InvoiceDetail, PurInvoice, PurInvoiceDetail | Sales & purchase invoices |
receive_fund.ex |
Receipt, ReceiptDetail, ReceivedCheque | Cash receipts |
bill_pay.ex |
Payment, PaymentDetail | Payments to suppliers |
debcre.ex |
CreditNote, CreditNoteDetail, DebitNote, DebitNoteDetail | Debit/credit notes |
cheque.ex |
Deposit, ReturnCheque | Cheque deposits & returns |
accounting.ex |
Account, Contact, TaxCode, Transaction, TransactionMatcher, FixedAsset, Journal | GL & master data |
hr.ex |
Employee, SalaryType, EmployeeSalaryType, TimeAttend, Advance, SalaryNote, PaySlip | Payroll & HR |
product.ex |
Good, Packaging, Delivery, Order, Load | Inventory & logistics |
layer.ex |
House, Flock, Harvest, HarvestDetail, Movement, HouseHarvestWage | Agriculture |
e_inv_metas.ex |
EInvoice, EInvMeta | Malaysia e-invoice (LHDN) |
reporting.ex |
(queries only) | Reports |
sys.ex |
Company, CompanyUser, Log, GaplessDocId, UserSetting | System administration |
user_accounts.ex |
User, UserToken | Authentication |
seeding.ex |
(no schemas) | Data import/seeding |
journal_entry.ex |
(uses Journal + Transaction) | Journal entries |
pay_run.ex |
(uses PaySlip) | Payroll batch processing |
tagged_bill.ex |
(uses Invoice/PurInvoice) | Tag-based billing queries |
lib/full_circle/accounting/account.ex
lib/full_circle/accounting/contact.ex
lib/full_circle/accounting/fixed_asset.ex
lib/full_circle/accounting/fixed_asset_depreciation.ex
lib/full_circle/accounting/fixed_asset_disposal.ex
lib/full_circle/accounting/journal.ex
lib/full_circle/accounting/seed_transaction_matcher.ex
lib/full_circle/accounting/tax_code.ex
lib/full_circle/accounting/transaction.ex
lib/full_circle/accounting/transaction_matcher.ex
lib/full_circle/billing/invoice.ex
lib/full_circle/billing/invoice_detail.ex
lib/full_circle/billing/pur_invoice.ex
lib/full_circle/billing/pur_invoice_detail.ex
lib/full_circle/bill_pay/payment.ex
lib/full_circle/bill_pay/payment_detail.ex
lib/full_circle/cheque/deposit.ex
lib/full_circle/cheque/return_cheque.ex
lib/full_circle/debcre/credit_note.ex
lib/full_circle/debcre/credit_note_detail.ex
lib/full_circle/debcre/debit_note.ex
lib/full_circle/debcre/debit_note_detail.ex
lib/full_circle/e_inv_metas/e_inv_meta.ex
lib/full_circle/e_inv_metas/e_invoice.ex
lib/full_circle/HR/advance.ex
lib/full_circle/HR/employee.ex
lib/full_circle/HR/employee_photo.ex
lib/full_circle/HR/employee_salary_type.ex
lib/full_circle/HR/holiday.ex
lib/full_circle/HR/pay_slip.ex
lib/full_circle/HR/recurring.ex
lib/full_circle/HR/salary_note.ex
lib/full_circle/HR/salary_type.ex
lib/full_circle/HR/timeattend.ex
lib/full_circle/layer/flocks.ex
lib/full_circle/layer/harvest_details.ex
lib/full_circle/layer/harvests.ex
lib/full_circle/layer/house_harvest_wages.ex
lib/full_circle/layer/houses.ex
lib/full_circle/layer/movements.ex
lib/full_circle/product/deliver_detail.ex
lib/full_circle/product/delivery.ex
lib/full_circle/product/good.ex
lib/full_circle/product/load.ex
lib/full_circle/product/load_detail.ex
lib/full_circle/product/order.ex
lib/full_circle/product/order_detail.ex
lib/full_circle/product/packaging.ex
lib/full_circle/receive_funds/receipt.ex
lib/full_circle/receive_funds/receipt_detail.ex
lib/full_circle/receive_funds/received_cheque.ex
lib/full_circle/sys/company.ex
lib/full_circle/sys/company_user.ex
lib/full_circle/sys/gapless_doc_id.ex
lib/full_circle/sys/log.ex
lib/full_circle/sys/user_setting.ex
lib/full_circle/user_accounts/user.ex
lib/full_circle/user_accounts/user_token.ex
lib/full_circle/user_queries/query.ex
lib/full_circle/weight_bridge/weighings.ex
Every entity belongs to a Company. The isolation chain:
- Route scope:
/companies/:company_id/*scopes all company-specific routes - Plug:
set_active_companyverifies user has access to company viaCompanyUser - LiveView mount:
assign_active_companyputs@current_company,@current_user,@current_roleinto socket assigns - Query isolation:
Sys.user_company(company, user)subquery join ensures data isolation
# This pattern is used EVERYWHERE for query isolation
from obj in klass,
join: com in subquery(Sys.user_company(company, user)),
on: com.id == obj.company_id,
select: obj| Repo | User | Purpose |
|---|---|---|
FullCircle.Repo |
full_circle |
Primary read/write |
FullCircle.QueryRepo |
full_circle_query |
Read-only reporting (no test config, expect harmless errors) |
# lib/schema.ex - ALL schemas use this instead of Ecto.Schema
defmodule FullCircle.Schema do
defmacro __using__(_) do
quote do
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
end
end
endAll IDs are UUIDs (binary_id). Always use use FullCircle.Schema in new schemas.
Documents (invoices, receipts, etc.) use gapless sequential numbering per company:
# Pattern from helpers.ex
get_gapless_doc_id(multi, name, "Invoice", "INV", com)
# Generates: INV-000001, INV-000002, etc.The GaplessDocId table maintains a counter per document type per company. This is managed in Ecto.Multi chains to ensure atomicity.
Every financial document creates GL Transaction records:
- Invoice: Negates detail lines (credit revenue accounts), keeps header positive (debit receivables)
- PurInvoice: Keeps detail lines positive (debit expense accounts), negates header (credit payables)
- Receipt/Payment: Creates matching transactions with
TransactionMatcher
The @invoice_txn_opts / @pur_invoice_txn_opts pattern declares sign behavior:
@invoice_txn_opts [
doc_type: "Invoice",
control_account: "Account Receivables",
detail_key: :invoice_details,
negate_line: true, # Credit revenue lines
negate_header: false # Debit receivables header
]Payments are matched against invoices via TransactionMatcher:
SeedTransactionMatcher— for imported/seeded historical dataTransactionMatcher— for live application matches- Balance = original amount + sum(seed matches) + sum(live matches)
Most financial documents follow a header-detail pattern:
# Header schema (e.g., Invoice)
schema "invoices" do
field :invoice_no, :string
field :invoice_date, :date
belongs_to :company, FullCircle.Sys.Company
belongs_to :contact, FullCircle.Accounting.Contact
has_many :invoice_details, InvoiceDetail, on_replace: :delete
# Virtual fields for computed values
field :contact_name, :string, virtual: true
field :invoice_amount, :decimal, virtual: true, default: Decimal.new(0)
timestamps(type: :utc_datetime)
end
# Detail schema (e.g., InvoiceDetail)
schema "invoice_details" do
field :_persistent_id, :integer # For ordering
belongs_to :invoice, Invoice
belongs_to :good, Good
belongs_to :account, Account
belongs_to :tax_code, TaxCode
# Computed virtual fields
field :good_amount, :decimal, virtual: true, default: Decimal.new(0)
field :tax_amount, :decimal, virtual: true, default: Decimal.new(0)
enddef changeset(invoice, attrs) do
invoice
|> cast(attrs, [...fields...])
|> validate_required([...required_fields...])
|> cast_assoc(:invoice_details, with: &InvoiceDetail.changeset/2)
|> compute_fields()
endAllows editing fields that normal users cannot (e.g., document numbers):
def admin_changeset(invoice, attrs) do
invoice
|> cast(attrs, [...fields... ++ [:invoice_no, :e_inv_internal_id]])
|> validate_required([...])
|> cast_assoc(:invoice_details, with: &InvoiceDetail.changeset/2)
|> compute_fields()
endDetail lines use map-indexed attributes (not lists):
# CORRECT - map-indexed format for cast_assoc
%{
"invoice_details" => %{
"0" => %{"good_id" => "abc", "quantity" => "10", "unit_price" => "5.00"},
"1" => %{"good_id" => "def", "quantity" => "20", "unit_price" => "3.00"}
}
}Most documents have compute_fields/1 (changeset) and compute_struct_fields/1 (loaded struct):
def compute_fields(changeset) do
changeset
|> compute_detail_fields() # Compute per-line amounts
|> sum_field_to(:invoice_details, :good_amount, :invoice_good_amount)
|> sum_field_to(:invoice_details, :tax_amount, :invoice_tax_amount)
|> compute_total()
end
def compute_struct_fields(invoice) do
invoice
|> sum_struct_field_to(:invoice_details, :good_amount, :invoice_good_amount)
|> sum_struct_field_to(:invoice_details, :tax_amount, :invoice_tax_amount)
|> compute_struct_total()
endFor simple entities (Account, Contact, TaxCode, etc.):
# Creating
StdInterface.create(Account, "account", attrs, company, user)
# Updating
StdInterface.update(Account, "account", account, attrs, company, user)
# Deleting
StdInterface.delete(Account, "account", account, company, user)
# Querying with pagination and fuzzy search
StdInterface.filter(Account, [:name, :account_type], terms, company, user,
page: page, per_page: per_page)
# Getting a changeset (for forms)
StdInterface.changeset(Account, %Account{}, attrs, company)
StdInterface.changeset(Account, account, attrs, company, :admin_changeset)StdInterface automatically:
- Checks authorization via
can?(user, :create_account, company) - Creates audit logs via
Sys.insert_log_for/5 - Scopes queries via
Sys.user_company/2
Complex documents (invoices, receipts, etc.) use Multi chains:
def create_invoice(attrs, com, user) do
case can?(user, :create_invoice, com) do
true ->
Multi.new()
|> get_gapless_doc_id(gapless_name, "Invoice", "INV", com) # Step 1: Get doc number
|> Multi.insert(:create_invoice, fn %{^gapless_name => doc} -> # Step 2: Insert document
make_changeset(Invoice, %Invoice{},
Map.merge(attrs, %{"invoice_no" => doc}), com, user)
end)
|> Multi.insert("create_invoice_log", fn %{:create_invoice => entity} -> # Step 3: Audit log
Sys.log_changeset(:create_invoice, entity, attrs, com, user)
end)
|> create_doc_transactions(:create_invoice, com, user, @invoice_txn_opts) # Step 4: GL transactions
|> Repo.transaction()
false ->
:not_authorise
end
enddefp update_doc_multi(multi, step_name, schema, doc, doc_no, attrs, com, user, txn_opts) do
multi
|> Multi.update(step_name, fn _ ->
make_changeset(schema, doc, attrs, com, user)
end)
|> Multi.delete_all(:delete_transaction, ...) # Delete old GL transactions
|> Sys.insert_log_for(step_name, attrs, com, user)
|> create_doc_transactions(step_name, com, user, txn_opts) # Recreate GL transactions
enddefp make_changeset(module, struct, attrs, com, user) do
if user_role_in_company(user.id, com.id) == "admin" do
StdInterface.changeset(module, struct, attrs, com, :admin_changeset)
else
StdInterface.changeset(module, struct, attrs, com)
end
enddefp apply_index_filters(qry, terms, date_from, due_date_from, bal, opts) do
search_fields = Keyword.fetch!(opts, :search_fields)
date_field = Keyword.fetch!(opts, :date_field)
unpaid_op = Keyword.fetch!(opts, :unpaid_op)
qry
|> maybe_apply_search(terms, search_fields)
|> maybe_apply_date_filter(date_from, date_field)
|> maybe_apply_due_date_filter(due_date_from)
|> maybe_apply_balance_filter(bal, unpaid_op)
endRefactored contexts use Multi.insert_all with builder functions instead of Multi.run + Repo.insert!:
multi
|> Multi.insert_all(:create_transactions, Transaction, fn %{^name => doc} ->
build_transactions(doc, com, opts)
end)defmodule FullCircleWeb.InvoiceLive.Form do
use FullCircleWeb, :live_view
@impl true
def mount(params, _session, socket) do
# Load settings, setup socket
socket = if params["id"], do: mount_edit(socket, params), else: mount_new(socket)
{:ok, socket}
end
defp mount_new(socket) do
changeset = StdInterface.changeset(Invoice, %Invoice{}, %{}, socket.assigns.current_company)
socket |> assign(live_action: :new) |> assign_form(changeset)
end
defp mount_edit(socket, %{"invoice_id" => id}) do
obj = Billing.get_invoice!(id, socket.assigns.current_company, socket.assigns.current_user)
changeset = StdInterface.changeset(Invoice, obj, %{}, socket.assigns.current_company)
socket |> assign(live_action: :edit, id: id) |> assign_form(changeset)
end
@impl true
def handle_event("validate", %{"invoice" => params}, socket) do
changeset = StdInterface.changeset(Invoice, socket.assigns.obj, params, socket.assigns.current_company)
{:noreply, assign_form(socket, changeset)}
end
@impl true
def handle_event("save", %{"invoice" => params}, socket) do
save(socket, socket.assigns.live_action, params)
end
defp save(socket, :new, params) do
case Billing.create_invoice(params, socket.assigns.current_company, socket.assigns.current_user) do
{:ok, %{create_invoice: obj}} ->
{:noreply, socket |> push_navigate(to: ~p"/companies/#{socket.assigns.current_company.id}/Invoice/#{obj.id}/edit")}
{:error, _, changeset, _} ->
{:noreply, assign_form(socket, changeset)}
:not_authorise ->
{:noreply, put_flash(socket, :error, "Not Authorised")}
end
end
enddefmodule FullCircleWeb.InvoiceLive.Index do
use FullCircleWeb, :live_view
@per_page 15
@impl true
def handle_params(params, _url, socket) do
# Extract search params, filter, stream results
objects = Billing.invoice_index_query(terms, date, due_date, bal, com, user,
page: page, per_page: @per_page)
{:noreply, socket |> stream(:objects, objects, reset: true)}
end
@impl true
def handle_event("next-page", _, socket) do
# Increment page, append to stream
{:noreply, socket |> stream(:objects, objects, reset: false)}
end
enddefmodule FullCircleWeb.InvoiceLive.DetailComponent do
use FullCircleWeb, :live_component
@impl true
def render(assigns) do
~H"""
<.inputs_for :let={dtl} field={@form[@detail_name]}>
<div class={["flex flex-row", if(dtl[:delete].value == true, do: "hidden")]}>
<.input field={dtl[:good_name]} phx-hook="tributeAutoComplete" url={...} />
<.input type="hidden" field={dtl[:good_id]} />
<.input field={dtl[:quantity]} phx-hook="calculatorInput" />
<.input field={dtl[:unit_price]} phx-hook="calculatorInput" />
<!-- ... -->
</div>
</.inputs_for>
"""
end
enddefmodule FullCircleWeb.InvoiceLive.Print do
use FullCircleWeb, :live_view
@impl true
def mount(%{"id" => id, "pre_print" => pre_print}, _, socket) do
# Load document data for printing
# Calculate page breaks based on detail line count
# Chunk details across pages
end
# Uses print_root layout (no nav, A4 sizing)
# Supports pre_print mode (data only, no letterhead)
end| Hook | Purpose | Usage |
|---|---|---|
tributeAutoComplete |
Autocomplete input | phx-hook="tributeAutoComplete" + url attribute |
tributeTagText |
Tag autocomplete (#tag) | phx-hook="tributeTagText" |
calculatorInput |
Math expressions in inputs | phx-hook="calculatorInput" |
clipCopy |
Copy to clipboard | phx-hook="clipCopy" |
copyAndOpen |
Copy + open URL | For e-invoice links |
FaceID |
Face recognition | Biometric attendance |
takePhoto |
Photo capture | Employee photos |
punchCamera |
QR scanning | QR attendance |
<.input
field={@form[:contact_name]}
phx-hook="tributeAutoComplete"
url={"/list/companies/#{@current_company.id}/#{@current_user.id}/autocomplete?schema=contact&name="}
/>Available schemas for autocomplete: contact, account, employee, good, house
| Module | Purpose | Location |
|---|---|---|
FullCircle.DataCase |
Domain context tests | test/support/data_case.ex |
FullCircleWeb.ConnCase |
LiveView & controller tests | test/support/conn_case.ex |
defmodule FullCircle.BillingTest do
use FullCircle.DataCase
import FullCircle.BillingFixtures
import FullCircle.AccountingFixtures
setup do
%{admin: admin, company: company} = billing_setup()
%{admin: admin, company: company}
end
describe "billing authorization" do
test_authorise_to(:create_invoice,
["admin", "manager", "supervisor", "clerk", "cashier"])
test_authorise_to(:update_invoice,
["admin", "manager", "supervisor", "clerk", "cashier"])
end
describe "create_invoice/3" do
test "creates valid invoice", %{admin: admin, company: company} do
# Setup fixtures
good = good_fixture(admin, company)
contact = contact_fixture(admin, company)
attrs = invoice_attrs(good, contact, admin, company)
# Execute
assert {:ok, %{create_invoice: inv}} = Billing.create_invoice(attrs, company, admin)
# Verify
assert inv.invoice_no =~ "INV-"
assert Decimal.eq?(inv.invoice_amount, expected_amount)
end
end
enddefmodule FullCircle.BillingFixtures do
import FullCircle.UserAccountsFixtures
import FullCircle.SysFixtures
def billing_setup do
admin = user_fixture()
company = company_fixture(admin, %{})
%{admin: admin, company: company}
end
def invoice_attrs(good, contact, user, com) do
%{
"invoice_date" => Date.utc_today() |> Date.to_string(),
"due_date" => Date.utc_today() |> Date.to_string(),
"contact_name" => contact.name,
"contact_id" => contact.id,
"invoice_details" => %{
"0" => %{
"good_id" => good.id,
"account_id" => good_account_id,
"quantity" => "10",
"unit_price" => "5.00",
"unit_multiplier" => "0", # Makes compute_fields use quantity directly
"discount" => "0",
"tax_rate" => "0",
"tax_code_id" => tax_code_id,
"package_id" => package_id
}
}
}
end
enddefmodule FullCircleWeb.AccountLiveTest do
use FullCircleWeb.ConnCase
import Phoenix.LiveViewTest
setup %{conn: conn} do
user = user_fixture()
comp = company_fixture(user, %{})
ac = account_fixture(%{name: "TESTACCOUNT"}, user, comp)
%{conn: log_in_user(conn, user), user: user, comp: comp, ac: ac}
end
describe "Edit" do
test "save valid account", %{conn: conn, comp: comp, ac: ac} do
{:ok, lv, _html} = live(conn, ~p"/companies/#{comp.id}/accounts/#{ac.id}/edit")
{:ok, _, html} =
lv
|> form("#account", account: %{name: "new_name"})
|> render_submit()
|> follow_redirect(conn)
assert html =~ "new_name"
end
end
# Custom test macros
describe "data validation" do
test_input_feedback("account", "name", "", "can't be blank")
test_input_feedback("account", "name", "TESTACCOUNT", "has already been taken")
end
describe "data value" do
test_input_value("account", "input", :text, "name")
test_input_value("account", "select", :text, "account_type")
end
end| Macro | Module | Purpose |
|---|---|---|
test_authorise_to/2 |
DataCase |
Tests roles that ARE allowed |
test_not_authorise_to/2 |
DataCase |
Tests roles that are NOT allowed |
test_input_value/4 |
ConnCase |
Verifies form field values |
test_input_feedback/4 |
ConnCase |
Verifies validation error messages |
user_fixture() -> User
|
company_fixture(user) -> Company (with seeded accounts like "Account Receivables")
|
+-- contact_fixture(user, company)
+-- good_fixture(user, company) -> Good + Packaging + TaxCodes
+-- account_fixture(attrs, user, company)
+-- funds_account_fixture(user, company) -> "Cash or Equivalent" account
+-- bank_account_fixture(user, company) -> "Bank" account
Important: Default seeded accounts do NOT include cash accounts. Use funds_account_fixture to create one for receipt/payment tests.
Roles (from authorization.ex): admin, manager, supervisor, cashier, clerk, auditor, punch_camera, guest, disable
allow_roles - Whitelist: only listed roles can perform the action
def can?(user, :create_invoice, company),
do: allow_roles(~w(admin manager supervisor clerk cashier), company, user)forbid_roles - Blacklist: all roles EXCEPT listed ones can perform the action
def can?(user, :create_contact, company),
do: forbid_roles(~w(auditor guest), company, user)# For allow_roles - list the allowed roles
test_authorise_to(:create_invoice,
["admin", "manager", "supervisor", "clerk", "cashier"])
# For forbid_roles - list ALL roles NOT in the forbid list
# forbid_roles(~w(auditor guest)) means everyone EXCEPT auditor and guest is allowed
# So the allowed list includes disable and punch_camera too!
test_authorise_to(:create_contact,
["admin", "manager", "supervisor", "cashier", "clerk", "disable", "punch_camera"])CRITICAL: When authorization uses forbid_roles, the disable and punch_camera roles are allowed unless explicitly forbidden. Include them in the test's allowed list.
- Create schema in
lib/full_circle/<context>/<entity>.ex - Add
changeset/2and optionallyadmin_changeset/2 - Add
can?clauses inauthorization.exfor:create_<entity>,:update_<entity>,:delete_<entity> - Use
StdInterfacefor CRUD in the context module - Create LiveView files:
index.ex,form.ex,index_component.ex - Add routes in
router.exunder the company-scoped live_session - Create test file and fixture
- Create header schema with
has_manydetails - Create detail schema with
belongs_toheader - Add
changeset/2,admin_changeset/2,compute_fields/1,compute_struct_fields/1 - Create context module with:
@txn_optsfor GL transaction configurationcreate_<doc>/3withEcto.Multichainupdate_<doc>/4with transaction deletion + recreationget_<doc>!/3with preloads andcompute_struct_fields/1- Index query function with
apply_index_filters/6orapply_simple_filters/4
- Add authorization
can?clauses - Create LiveView: form.ex, index.ex, detail_component.ex, print.ex, index_component.ex
- Add routes (regular + print)
- Create test file and fixture file
- Add query function in
reporting.exor relevant context - Create LiveView in
lib/full_circle_web/live/report_live/ - Add route under company-scoped live_session
- Optionally create print version with
print_rootlayout
-
mise exec --prefix: Allmix/elixircommands must use this prefix for correct runtime versions -
QueryRepo test errors:
missing :database keyerrors forFullCircle.QueryRepoin test output are harmless noise - QueryRepo has no test configuration -
uploads_dirin test config:config/test.exsmust haveuploads_dir: System.tmp_dir!()forSys.create_company/2to work -
unit_multiplier: "0"in test fixtures: This makescompute_fieldsuse quantity directly (avoids package quantity multiplication) -
remove_field_if_new_flag: Update operations must passe_inv_internal_idandinvoice_no(or equivalent doc number fields) in attrs, otherwise they get stripped -
test_authorise_toneeds explicit list: Use["admin", "manager"]not~w(admin manager)- the macro doesn't work with sigils -
Dev DB has production data: Useful for checking real account names, schema structure, and data patterns
-
Seeded accounts: When a company is created, certain accounts are seeded (like "Account Receivables", "Account Payables", "Sales Tax Payable", etc.) but NOT cash accounts
-
Phoenix 1.8 requirement: Config must include
listeners: [Phoenix.CodeReloader] -
Always test-first: Write tests BEFORE implementing behavioral changes (established project convention)
- Always use
Decimal.new("0")not0for default values - Use
Decimal.eq?/2for comparisons, not== - Use
Decimal.round(val, 2)for monetary amounts Decimal.negate/1for sign reversal in bookkeeping
- Check
changeset.valid?andchangeset.errorsfor validation issues - Detail changesets: check
Ecto.Changeset.get_change(changeset, :invoice_details)for nested errors - Use
errors_on/1helper in tests:assert %{name: ["can't be blank"]} = errors_on(changeset)