<.modal
:if={@live_action == :edit}
diff --git a/lib/split_app_web/live/live_helpers.ex b/lib/split_app_web/live/live_helpers.ex
new file mode 100644
index 0000000..3b6e9c5
--- /dev/null
+++ b/lib/split_app_web/live/live_helpers.ex
@@ -0,0 +1,46 @@
+defmodule SplitAppWeb.LiveHelpers do
+ @moduledoc """
+ Shared helper functions for LiveView modules.
+ """
+
+ @doc """
+ Formats a Money struct into a readable string.
+
+ ## Examples
+
+ iex> format_money(%Money{amount: 1250, currency: "USD"})
+ "12.50 USD"
+ """
+ def format_money(%Money{amount: amount_cents, currency: currency}) do
+ amount_float = amount_cents / 100
+ "#{:erlang.float_to_binary(amount_float, decimals: 2)} #{currency}"
+ end
+
+ def format_money(_), do: "-"
+
+ @doc """
+ Formats a date/datetime into a readable string format.
+
+ ## Examples
+
+ iex> format_date(~D[2024-01-15])
+ "Jan 15, 2024"
+ """
+ def format_date(%DateTime{} = datetime) do
+ datetime
+ |> DateTime.to_date()
+ |> Calendar.strftime("%b %d, %Y")
+ end
+
+ def format_date(%NaiveDateTime{} = naive_datetime) do
+ naive_datetime
+ |> NaiveDateTime.to_date()
+ |> Calendar.strftime("%b %d, %Y")
+ end
+
+ def format_date(%Date{} = date) do
+ Calendar.strftime(date, "%b %d, %Y")
+ end
+
+ def format_date(_), do: "-"
+end
diff --git a/lib/split_app_web/router.ex b/lib/split_app_web/router.ex
index ad140fa..7af6fa3 100644
--- a/lib/split_app_web/router.ex
+++ b/lib/split_app_web/router.ex
@@ -75,6 +75,12 @@ defmodule SplitAppWeb.Router do
live "/groups/:id", GroupLive.Show, :show
live "/groups/:id/show/edit", GroupLive.Show, :edit
+ live "/expenses", ExpenseLive.Index, :index
+ live "/expenses/new", ExpenseLive.Index, :new
+ live "/expenses/:id/edit", ExpenseLive.Index, :edit
+ live "/expenses/:id", ExpenseLive.Show, :show
+ live "/expenses/:id/show/edit", ExpenseLive.Show, :edit
+
live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
end
diff --git a/priv/repo/migrations/20250824203547_create_expenses.exs b/priv/repo/migrations/20250824203547_create_expenses.exs
new file mode 100644
index 0000000..2e07514
--- /dev/null
+++ b/priv/repo/migrations/20250824203547_create_expenses.exs
@@ -0,0 +1,34 @@
+defmodule SplitApp.Repo.Migrations.CreateExpenses do
+ use Ecto.Migration
+
+ def change do
+ create table(:expenses) do
+ add :title, :string, null: false
+ add :description, :text
+ add :amount, :map, null: false
+ add :created_by_id, references(:users, on_delete: :delete_all), null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:expenses, [:created_by_id])
+
+ create table(:expense_assignments) do
+ add :expense_id, references(:expenses, on_delete: :delete_all), null: false
+ add :user_id, references(:users, on_delete: :delete_all), null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create unique_index(:expense_assignments, [:expense_id, :user_id])
+
+ create table(:expense_groups) do
+ add :expense_id, references(:expenses, on_delete: :delete_all), null: false
+ add :group_id, references(:groups, on_delete: :delete_all), null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create unique_index(:expense_groups, [:expense_id, :group_id])
+ end
+end
diff --git a/test/split_app_web/live/expense_live_test.exs b/test/split_app_web/live/expense_live_test.exs
new file mode 100644
index 0000000..8123102
--- /dev/null
+++ b/test/split_app_web/live/expense_live_test.exs
@@ -0,0 +1,123 @@
+defmodule SplitAppWeb.ExpenseLiveTest do
+ use SplitAppWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+ import SplitApp.ExpensesFixtures
+
+ @create_attrs %{
+ description: "some description",
+ title: "some title",
+ amount_display: "25.50",
+ currency: "USD"
+ }
+ @update_attrs %{
+ description: "some updated description",
+ title: "some updated title",
+ amount_display: "50.00",
+ currency: "USD"
+ }
+ @invalid_attrs %{description: nil, title: nil, amount_display: nil}
+
+ defp create_expense(%{user: user}) do
+ expense = expense_fixture(%{created_by_id: user.id})
+ %{expense: expense}
+ end
+
+ describe "Index" do
+ setup [:register_and_log_in_user, :create_expense]
+
+ test "lists all expenses", %{conn: conn, expense: expense} do
+ {:ok, _index_live, html} = live(conn, ~p"/expenses")
+
+ assert html =~ "Your Expenses"
+ assert html =~ expense.description
+ end
+
+ test "saves new expense", %{conn: conn} do
+ {:ok, index_live, _html} = live(conn, ~p"/expenses")
+
+ assert index_live |> element("a", "New Expense") |> render_click() =~
+ "New Expense"
+
+ assert_patch(index_live, ~p"/expenses/new")
+
+ assert index_live
+ |> form("#expense-form", expense: @invalid_attrs)
+ |> render_change() =~ "can't be blank"
+
+ assert index_live
+ |> form("#expense-form", expense: @create_attrs)
+ |> render_submit()
+
+ assert_patch(index_live, ~p"/expenses")
+
+ html = render(index_live)
+ assert html =~ "Expense created successfully"
+ assert html =~ "some description"
+ end
+
+ test "updates expense in listing", %{conn: conn, expense: expense} do
+ {:ok, index_live, _html} = live(conn, ~p"/expenses")
+
+ assert index_live |> element("a", "Edit") |> render_click() =~
+ "Edit Expense"
+
+ assert_patch(index_live, ~p"/expenses/#{expense}/edit")
+
+ assert index_live
+ |> form("#expense-form", expense: @invalid_attrs)
+ |> render_change() =~ "can't be blank"
+
+ assert index_live
+ |> form("#expense-form", expense: @update_attrs)
+ |> render_submit()
+
+ assert_patch(index_live, ~p"/expenses")
+
+ html = render(index_live)
+ assert html =~ "Expense updated successfully"
+ assert html =~ "some updated description"
+ end
+
+ test "deletes expense in listing", %{conn: conn, expense: expense} do
+ {:ok, index_live, _html} = live(conn, ~p"/expenses")
+
+ assert index_live |> element("a", "Delete") |> render_click()
+ refute has_element?(index_live, "li", expense.title)
+ end
+ end
+
+ describe "Show" do
+ setup [:register_and_log_in_user, :create_expense]
+
+ test "displays expense", %{conn: conn, expense: expense} do
+ {:ok, _show_live, html} = live(conn, ~p"/expenses/#{expense}")
+
+ assert html =~ expense.title
+ assert html =~ expense.description
+ end
+
+ test "updates expense within modal", %{conn: conn, expense: expense} do
+ {:ok, show_live, _html} = live(conn, ~p"/expenses/#{expense}")
+
+ assert show_live |> element("a", "Edit") |> render_click() =~
+ "Edit Expense"
+
+ assert_patch(show_live, ~p"/expenses/#{expense}/show/edit")
+
+ assert show_live
+ |> form("#expense-form", expense: @invalid_attrs)
+ |> render_change() =~ "can't be blank"
+
+ assert show_live
+ |> form("#expense-form", expense: @update_attrs)
+ |> render_submit()
+
+ assert_patch(show_live, ~p"/expenses/#{expense}")
+
+ html = render(show_live)
+ assert html =~ "Expense updated successfully"
+ assert html =~ "some updated description"
+ end
+ end
+end
diff --git a/test/split_app_web/live/group_live_test.exs b/test/split_app_web/live/group_live_test.exs
index 2ab7508..0038909 100644
--- a/test/split_app_web/live/group_live_test.exs
+++ b/test/split_app_web/live/group_live_test.exs
@@ -19,7 +19,7 @@ defmodule SplitAppWeb.GroupLiveTest do
test "lists all groups", %{conn: conn, group: group} do
{:ok, _index_live, html} = live(conn, ~p"/groups")
- assert html =~ "Listing Groups"
+ assert html =~ "Your Groups"
assert html =~ group.name
end
@@ -49,7 +49,7 @@ defmodule SplitAppWeb.GroupLiveTest do
test "updates group in listing", %{conn: conn, group: group} do
{:ok, index_live, _html} = live(conn, ~p"/groups")
- assert index_live |> element("#groups-#{group.id} a", "Edit") |> render_click() =~
+ assert index_live |> element("a", "Edit") |> render_click() =~
"Edit Group"
assert_patch(index_live, ~p"/groups/#{group}/edit")
@@ -72,8 +72,8 @@ defmodule SplitAppWeb.GroupLiveTest do
test "deletes group in listing", %{conn: conn, group: group} do
{:ok, index_live, _html} = live(conn, ~p"/groups")
- assert index_live |> element("#groups-#{group.id} a", "Delete") |> render_click()
- refute has_element?(index_live, "#groups-#{group.id}")
+ assert index_live |> element("a", "Delete") |> render_click()
+ refute has_element?(index_live, "li", group.name)
end
end
@@ -83,7 +83,6 @@ defmodule SplitAppWeb.GroupLiveTest do
test "displays group", %{conn: conn, group: group} do
{:ok, _show_live, html} = live(conn, ~p"/groups/#{group}")
- assert html =~ "Show Group"
assert html =~ group.name
end
diff --git a/test/support/fixtures/expenses_fixtures.ex b/test/support/fixtures/expenses_fixtures.ex
index d91c9c5..9b33427 100644
--- a/test/support/fixtures/expenses_fixtures.ex
+++ b/test/support/fixtures/expenses_fixtures.ex
@@ -18,9 +18,8 @@ defmodule SplitApp.ExpensesFixtures do
end
def expense_fixture(attrs \\ %{}) do
- attrs
- |> valid_expense_attributes()
- |> SplitApp.Expenses.Expense.changeset(%SplitApp.Expenses.Expense{})
+ %SplitApp.Expenses.Expense{}
+ |> SplitApp.Expenses.Expense.changeset(valid_expense_attributes(attrs))
|> SplitApp.Repo.insert!()
end
end
From 3d744cd3bba74b11d91f959b34f9c0b3d35971e4 Mon Sep 17 00:00:00 2001
From: lourw <56288712+lourw@users.noreply.github.com>
Date: Sun, 24 Aug 2025 13:56:40 -0700
Subject: [PATCH 4/7] chore(claude): modify docs command
---
.claude/commands/update-doc-logs.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.claude/commands/update-doc-logs.md b/.claude/commands/update-doc-logs.md
index 3985096..3e8cb68 100644
--- a/.claude/commands/update-doc-logs.md
+++ b/.claude/commands/update-doc-logs.md
@@ -1 +1,5 @@
Do the following. If the instructions are not relevant, do nothing. Check any files that were created in the recent commits that part within the `docs/logs` folder and update any relevant content that is out of date with the code changes.
+
+If there are major changes to an existing log file. Instead of editing what is there already, add a new section in the same file with a record of the datetime of the change.
+
+If the changes are related to a different domain/topic, feel free to create a new log file for that entity with frontmatter.
From 276213aea7739fec251b0fc29030e5159482afb5 Mon Sep 17 00:00:00 2001
From: lourw <56288712+lourw@users.noreply.github.com>
Date: Sun, 24 Aug 2025 13:59:54 -0700
Subject: [PATCH 5/7] chore(docs): update docs on expenses
---
.claude/commands/update-doc-logs.md | 8 +-
docs/logs/add-expenses-feature.md | 193 ++++++++++++++++++++++++++++
2 files changed, 198 insertions(+), 3 deletions(-)
create mode 100644 docs/logs/add-expenses-feature.md
diff --git a/.claude/commands/update-doc-logs.md b/.claude/commands/update-doc-logs.md
index 3e8cb68..1df0ab9 100644
--- a/.claude/commands/update-doc-logs.md
+++ b/.claude/commands/update-doc-logs.md
@@ -1,5 +1,7 @@
-Do the following. If the instructions are not relevant, do nothing. Check any files that were created in the recent commits that part within the `docs/logs` folder and update any relevant content that is out of date with the code changes.
+Do the following TODO. If the instructions are not relevant, do nothing.
-If there are major changes to an existing log file. Instead of editing what is there already, add a new section in the same file with a record of the datetime of the change.
+1. Check any files that were created in the recent commits that part within the `docs/logs` folder.
-If the changes are related to a different domain/topic, feel free to create a new log file for that entity with frontmatter.
+2. If the changes are related to a different domain or topics altogether, create a new log file with similar front matter to record the topic.
+
+3. Update any existing logs that have relevant content that is out of date with the code changes. If there are major changes to an existing log file. Instead of editing what is there already, add a new section in the same file with a record of the datetime of the change.
diff --git a/docs/logs/add-expenses-feature.md b/docs/logs/add-expenses-feature.md
new file mode 100644
index 0000000..b63b8c2
--- /dev/null
+++ b/docs/logs/add-expenses-feature.md
@@ -0,0 +1,193 @@
+---
+title: Adding Expenses Feature to SplitApp
+date: 2025-08-24
+time: 20:35:00
+author: Claude
+tags: [expenses, feature, money, liveview, associations]
+---
+
+# Adding Expenses Feature to SplitApp
+
+## Overview
+This document outlines the implementation of the Expenses feature, which allows users to create, track, and manage expenses. The feature integrates with the existing Groups system and includes proper money handling with multi-currency support.
+
+## Database Changes
+
+### 1. Expenses Table
+Created a new `expenses` table with the following fields:
+- `id` - Primary key
+- `title` - String field for expense title (required)
+- `description` - Text field for expense description (optional)
+- `amount` - Map field storing Money type with amount and currency (required)
+- `created_by_id` - Foreign key to users table (required)
+- `inserted_at` - Timestamp
+- `updated_at` - Timestamp
+
+**Migration file:** `priv/repo/migrations/20250824203547_create_expenses.exs`
+
+### 2. Expense Assignments Join Table
+Created an `expense_assignments` join table for many-to-many relationship between expenses and users:
+- `expense_id` - Foreign key to expenses table
+- `user_id` - Foreign key to users table
+- `inserted_at` - Timestamp
+- `updated_at` - Timestamp
+- Composite unique index on `[expense_id, user_id]`
+
+### 3. Expense Groups Join Table
+Created an `expense_groups` join table for many-to-many relationship between expenses and groups:
+- `expense_id` - Foreign key to expenses table
+- `group_id` - Foreign key to groups table
+- `inserted_at` - Timestamp
+- `updated_at` - Timestamp
+- Composite unique index on `[expense_id, group_id]`
+
+## Code Changes
+
+### 1. Money Library Integration (`mix.exs`)
+Added `money` library dependency for proper currency handling:
+```elixir
+{:money, "~> 1.12"}
+```
+
+### 2. Context and Schemas
+
+#### Expenses Context (`lib/split_app/expenses.ex`)
+Generated using Phoenix context generator with functions:
+- `list_expenses/0` - List all expenses
+- `list_expenses_by_user/1` - List expenses created by specific user
+- `get_expense!/1` - Get a single expense
+- `create_expense/1` - Create a new expense
+- `update_expense/2` - Update an expense
+- `delete_expense/1` - Delete an expense
+- `change_expense/2` - Generate changeset for forms
+
+#### Expense Schema (`lib/split_app/expenses/expense.ex`)
+- Fields: `title`, `description`, `amount` (Money type)
+- Associations:
+ - `belongs_to :created_by` (User who created the expense)
+ - `many_to_many :assigned_users` through `expense_assignments`
+ - `many_to_many :groups` through `expense_groups`
+- Custom validation for Money type ensuring:
+ - Amount is greater than 0
+ - Currency is present
+ - Valid Money struct format
+
+### 3. Web Layer
+
+#### Expense LiveViews (Generated)
+- `lib/split_app_web/live/expense_live/index.ex` - List user's expenses
+- `lib/split_app_web/live/expense_live/show.ex` - Show single expense
+- `lib/split_app_web/live/expense_live/form_component.ex` - Create/edit expenses
+ - Custom money input handling
+ - Currency selection support
+ - Integration with current user context
+
+#### Dashboard Updates (`lib/split_app_web/live/dashboard_live.ex`)
+Enhanced dashboard to show user's recent expenses alongside groups.
+
+#### Live Helpers (`lib/split_app_web/live/live_helpers.ex`)
+Added utility functions for LiveView components and common patterns.
+
+### 4. Router Updates (`lib/split_app_web/router.ex`)
+
+Added authenticated routes for expenses:
+```elixir
+live "/expenses", ExpenseLive.Index, :index
+live "/expenses/new", ExpenseLive.Index, :new
+live "/expenses/:id/edit", ExpenseLive.Index, :edit
+live "/expenses/:id", ExpenseLive.Show, :show
+live "/expenses/:id/show/edit", ExpenseLive.Show, :edit
+```
+
+### 5. Group Integration
+
+#### Group LiveViews Updates
+Updated group show and index pages to display related expenses and provide links to create expenses within group context.
+
+## Features Implemented
+
+### Money Handling
+- **Multi-Currency Support**: Uses Money library for proper currency handling
+- **Validation**: Ensures positive amounts and valid currency codes
+- **Display**: Proper formatting of money amounts in UI
+- **Input**: Custom form inputs for amount and currency selection
+
+### Expense Management
+- **Create Expenses**: Users can create expenses with title, description, and amount
+- **Edit/Delete**: Full CRUD operations for user's own expenses
+- **User Context**: Expenses are automatically associated with creator
+- **Filtering**: Users see only their own expenses by default
+
+### Associations
+- **User Assignment**: Expenses can be assigned to multiple users (foundation for splitting)
+- **Group Integration**: Expenses can be associated with groups
+- **Creator Tracking**: System tracks who created each expense
+
+### UI/UX Features
+- **Expense List**: Clean table view of user's expenses with amounts, titles, and dates
+- **Money Display**: Proper currency formatting (e.g., "$25.50 USD")
+- **Form Validation**: Real-time validation of money amounts and required fields
+- **Integration**: Seamless navigation between expenses, groups, and dashboard
+
+## Testing
+
+### Test Files Created
+- `test/split_app/expenses/expense_test.exs` - Unit tests for Expense schema
+- `test/split_app_web/live/expense_live_test.exs` - Integration tests for LiveViews
+- `test/support/fixtures/expenses_fixtures.ex` - Test data fixtures
+
+### Test Coverage
+- Schema validations for Money type
+- CRUD operations in context
+- LiveView interactions and form submissions
+- Money formatting and display
+
+## Migration Commands
+
+```bash
+# Add money library
+# (Added to mix.exs manually)
+
+# Generate the Expenses context and schema
+mix phx.gen.context Expenses Expense expenses title:string description:text amount:map created_by_id:references:users
+
+# Generate LiveView pages
+mix phx.gen.live Expenses Expense expenses title:string description:text amount:map created_by_id:references:users --no-context --no-schema
+
+# Run migrations
+mix ecto.migrate
+
+# Install new dependencies
+mix deps.get
+```
+
+## Future Enhancements
+
+This foundation enables future expense splitting functionality:
+- **Expense Splitting**: Calculate splits between assigned users
+- **Settlement Tracking**: Track who owes what to whom
+- **Group Expense Views**: Show all expenses within a group
+- **Balance Calculations**: Calculate user balances across groups
+- **Payment Tracking**: Mark when debts are settled
+- **Expense Categories**: Categorize expenses (food, transport, etc.)
+- **Receipt Uploads**: Attach receipt images to expenses
+- **Recurring Expenses**: Support for recurring/scheduled expenses
+
+## Technical Notes
+
+### Money Type Implementation
+The Money library stores currency amounts as a map in the database:
+```elixir
+%{amount: 2550, currency: :USD} # Represents $25.50 USD
+```
+
+This ensures:
+- Precise decimal arithmetic (no floating point errors)
+- Multi-currency support
+- Consistent currency handling across the application
+- Proper serialization/deserialization from database
+
+### Performance Considerations
+- Indexes on `created_by_id` for efficient user expense queries
+- Composite unique indexes on join tables prevent duplicates
+- Ordered queries for chronological expense display
\ No newline at end of file
From 4e553bafea9f4e421507a993747b2f2cad52dfa4 Mon Sep 17 00:00:00 2001
From: lourw <56288712+lourw@users.noreply.github.com>
Date: Sun, 24 Aug 2025 16:55:06 -0700
Subject: [PATCH 6/7] fix(expenses): ensure that cancelling model populates
list
---
lib/split_app_web/live/expense_live/show.html.heex | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/lib/split_app_web/live/expense_live/show.html.heex b/lib/split_app_web/live/expense_live/show.html.heex
index 4200578..068dd5d 100644
--- a/lib/split_app_web/live/expense_live/show.html.heex
+++ b/lib/split_app_web/live/expense_live/show.html.heex
@@ -44,12 +44,7 @@
-<.modal
- :if={@live_action == :edit}
- id="expense-modal"
- show
- on_cancel={JS.patch(~p"/expenses/#{@expense}")}
->
+<.modal :if={@live_action == :edit} id="expense-modal" show on_cancel={JS.patch(~p"/expenses")}>
<.live_component
module={SplitAppWeb.ExpenseLive.FormComponent}
id={@expense.id}
From a3ecb75af1422f2ffd3d5a5794c9f6de46b1d9f9 Mon Sep 17 00:00:00 2001
From: lourw <56288712+lourw@users.noreply.github.com>
Date: Sun, 24 Aug 2025 17:01:12 -0700
Subject: [PATCH 7/7] fix(expenses): ensure apply action refreshes data
---
lib/split_app_web/live/expense_live/index.ex | 5 +++++
lib/split_app_web/live/group_live/index.ex | 5 +++++
2 files changed, 10 insertions(+)
diff --git a/lib/split_app_web/live/expense_live/index.ex b/lib/split_app_web/live/expense_live/index.ex
index abc8d56..0c325f2 100644
--- a/lib/split_app_web/live/expense_live/index.ex
+++ b/lib/split_app_web/live/expense_live/index.ex
@@ -33,9 +33,14 @@ defmodule SplitAppWeb.ExpenseLive.Index do
end
defp apply_action(socket, :index, _params) do
+ current_user = socket.assigns.current_user
+ expenses = if current_user, do: Expenses.list_expenses_by_user(current_user.id), else: []
+
socket
|> assign(:page_title, "Listing Expenses")
|> assign(:expense, nil)
+ |> stream(:expenses, expenses, reset: true)
+ |> assign(:expenses_count, length(expenses))
end
@impl true
diff --git a/lib/split_app_web/live/group_live/index.ex b/lib/split_app_web/live/group_live/index.ex
index beda712..df64658 100644
--- a/lib/split_app_web/live/group_live/index.ex
+++ b/lib/split_app_web/live/group_live/index.ex
@@ -33,9 +33,14 @@ defmodule SplitAppWeb.GroupLive.Index do
end
defp apply_action(socket, :index, _params) do
+ user = socket.assigns.current_user
+ groups = Groups.list_user_groups(user)
+
socket
|> assign(:page_title, "Listing Groups")
|> assign(:group, nil)
+ |> stream(:groups, groups, reset: true)
+ |> assign(:groups_count, length(groups))
end
@impl true