Skip to content

Commit

Permalink
Budget improvements (#762)
Browse files Browse the repository at this point in the history
* setup monthly amount for budget

* add credit cards

* excluded transactions

* add budget type

* allow assigning bank account to budget

* fix tests

* show bank balance

* ignore budgets connected to bank for spendable
  • Loading branch information
michaelst authored Apr 7, 2024
1 parent 3053c3f commit 0def6b5
Show file tree
Hide file tree
Showing 33 changed files with 1,824 additions and 263 deletions.
24 changes: 2 additions & 22 deletions lib/spendable/broadway/sync_member/sync_member_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule Spendable.Broadway.SyncMemberTest do
_pid = start_supervised!({SyncMember, name: __MODULE__})

TeslaMock
|> expect(:call, 7, fn
|> expect(:call, 4, fn
%{method: :post, url: "https://sandbox.plaid.com/item/get"}, _opts ->
TeslaHelper.response(body: TestData.item())

Expand Down Expand Up @@ -62,17 +62,6 @@ defmodule Spendable.Broadway.SyncMemberTest do
bank_accounts =
from(BankAccount, where: [bank_member_id: ^member.id], order_by: :id) |> Repo.all()

assert [
%{sync: false},
%{sync: false},
%{sync: false},
%{sync: false},
%{sync: false},
%{sync: false},
%{sync: false},
%{sync: false}
] = bank_accounts

bank_account =
Enum.find(bank_accounts, &(&1.external_id == "zyBMmKBpeZcDVZgqEx3ACKveJjvwmBHomPbyP"))

Expand All @@ -83,21 +72,12 @@ defmodule Spendable.Broadway.SyncMemberTest do
name: "Plaid Gold Standard 0% Interest Checking",
number: "0000",
sub_type: "checking",
sync: false,
sync: true,
type: "depository"
} = bank_account

assert "100" |> Decimal.new() |> Decimal.equal?(balance)

assert 0 == from(BankTransaction, where: [user_id: ^user.id]) |> Repo.aggregate(:count, :id)

bank_account
|> Ash.Changeset.for_update(:update, %{sync: true})
|> Api.update!()

ref = Broadway.test_message(__MODULE__, data, metadata: %{test_process: self()})
assert_receive {:ack, ^ref, [_] = _successful, []}, 1000

# there are 8 transactions in test data but one is a pending that gets replaced
assert 7 == from(BankTransaction, where: [user_id: ^user.id]) |> Repo.aggregate(:count, :id)

Expand Down
12 changes: 10 additions & 2 deletions lib/spendable/resources/bank_account.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ defmodule Spendable.BankAccount do
attribute :name, :string, allow_nil?: false
attribute :number, :string
attribute :sub_type, :string, allow_nil?: false
attribute :sync, :boolean, allow_nil?: false, default: false
attribute :sync, :boolean, allow_nil?: false, default: true
attribute :type, :string, allow_nil?: false

timestamps()
Expand All @@ -34,10 +34,18 @@ defmodule Spendable.BankAccount do
relationships do
belongs_to :user, Spendable.User, allow_nil?: false
belongs_to :bank_member, Spendable.BankMember, allow_nil?: false
belongs_to :budget, Spendable.Budget, allow_nil?: true
end

actions do
defaults [:read, :create, :update]
defaults [:read, :create]

update :update do
primary? true

argument :budget_id, :string
change manage_relationship(:budget_id, :budget, type: :append_and_remove)
end
end

policies do
Expand Down
10 changes: 10 additions & 0 deletions lib/spendable/resources/bank_member/storage.ex
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
defmodule Spendable.BankMember.Storage do
alias Spendable.Api
alias Spendable.BankAccount
alias Spendable.BankMember

import Ecto.Query

require Ash.Query

def list(user_id, opts \\ []) do
BankMember
|> Ash.Query.filter(user_id == ^user_id)
|> maybe_search(opts[:search])
|> Ash.Query.sort(:name)
|> Ash.Query.load(:bank_accounts)
|> Api.read!()
end

Expand All @@ -17,4 +21,10 @@ defmodule Spendable.BankMember.Storage do
end

defp maybe_search(query, _search), do: query

def credit_card_balance(user_id) do
query = from(b in BankAccount, where: b.user_id == ^user_id, where: b.sub_type == "credit card" and b.sync)

Spendable.Repo.aggregate(query, :sum, :balance) || Decimal.new("-0.00")
end
end
6 changes: 4 additions & 2 deletions lib/spendable/resources/budget.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ defmodule Spendable.Budget do
data_layer: AshPostgres.DataLayer,
extensions: [AshArchival.Resource]

require Ash.Resource.Preparation.Builtins
alias Spendable.Budget.SpentByMonth
alias Spendable.Budget.Storage
alias Spendable.Resources.Budget.BudgetType

require Ash.Resource.Preparation.Builtins
require Ash.Query

postgres do
Expand All @@ -23,8 +24,9 @@ defmodule Spendable.Budget do
uuid_primary_key :id

attribute :adjustment, :decimal, allow_nil?: false, default: Decimal.new("0.00")
attribute :budgeted_amount, :decimal
attribute :name, :ci_string, allow_nil?: false
attribute :track_spending_only, :boolean, allow_nil?: false, default: false
attribute :type, BudgetType, allow_nil?: false, default: :envelope

timestamps()
end
Expand Down
3 changes: 3 additions & 0 deletions lib/spendable/resources/budget/budget_type.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
defmodule Spendable.Resources.Budget.BudgetType do
use Ash.Type.Enum, values: [:tracking, :envelope, :goal]
end
20 changes: 17 additions & 3 deletions lib/spendable/resources/budget/calculations/balance.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@ defmodule Spendable.Budget.Calculations.Balance do

import Ecto.Query

alias Spendable.BankAccount
alias Spendable.BudgetAllocation
alias Spendable.Repo

@impl Ash.Calculation
def calculate(budgets, _opts, _context) do
budget_ids = Enum.map(budgets, & &1.id)

bank_balances =
from(ba in BankAccount,
select: {ba.budget_id, sum(ba.balance)},
group_by: ba.budget_id,
where: ba.budget_id in ^budget_ids
)
|> Repo.all()
|> Map.new()

allocated =
from(a in BudgetAllocation,
select: {a.budget_id, sum(a.amount)},
Expand All @@ -21,9 +31,13 @@ defmodule Spendable.Budget.Calculations.Balance do

balances =
Enum.map(budgets, fn budget ->
allocated
|> Map.get(budget.id, Decimal.new("0"))
|> Decimal.add(budget.adjustment)
allocated_for_budget =
allocated
|> Map.get(budget.id, Decimal.new("0"))
|> Decimal.add(budget.adjustment)

# use bank balance if assigned, otherwise transactions allocated
Map.get(bank_balances, budget.id, allocated_for_budget)
end)

{:ok, balances}
Expand Down
1 change: 1 addition & 0 deletions lib/spendable/resources/budget/calculations/spent.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ defmodule Spendable.Budget.Calculations.Spent do
where: ba.budget_id in ^budget_ids,
where: t.date >= ^start_date,
where: t.date <= ^end_date,
where: not t.excluded,
where: ba.amount < 0,
group_by: :budget_id

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ defmodule Spendable.Budget.Calculations.SpentByMonth do
where: ba.budget_id == ^budget.id,
where: ba.amount < 0,
where: t.date >= ^start_date,
where: not t.excluded,
group_by: fragment("TO_CHAR(?, 'YYYY-MM-01')", t.date)

data = Repo.all(query)
Expand Down
1 change: 1 addition & 0 deletions lib/spendable/resources/transaction.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ defmodule Spendable.Transaction do
attribute :name, :ci_string, allow_nil?: false
attribute :note, :ci_string
attribute :reviewed, :boolean, allow_nil?: false
attribute :excluded, :boolean, allow_nil?: false, default: false

timestamps()
end
Expand Down
19 changes: 9 additions & 10 deletions lib/spendable/resources/user/calculations/spendable.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,11 @@ defmodule Spendable.User.Calculations.Spendable do
def calculate([user], _opts, _resolution) do
balance =
from(ba in BankAccount,
select:
fragment(
"SUM(CASE WHEN ? = 'credit' THEN -? ELSE ? END)",
ba.type,
ba.balance,
ba.balance
),
where: ba.user_id == ^user.id and ba.sync
where: ba.user_id == ^user.id,
where: ba.sync,
where: is_nil(ba.budget_id)
)
|> Repo.one()
|> Repo.aggregate(:sum, :balance)
|> Kernel.||("0.00")

allocations_query =
Expand All @@ -38,10 +33,14 @@ defmodule Spendable.User.Calculations.Spendable do
from(a in subquery(allocations_query),
full_join: b in Budget,
on: a.budget_id == b.id,
left_join: ba in BankAccount,
on: b.id == ba.budget_id,
select: fragment("SUM(ABS(COALESCE(?, 0) + ?))", a.allocated, b.adjustment),
where: b.user_id == ^user.id,
# ignore budgets that are only used to track spending
where: b.track_spending_only == false
where: b.type != :tracking,
# ignore budgets that are allocated from a bank account balance
where: is_nil(ba.id)
)
|> Repo.one()
|> Kernel.||("0.00")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ defmodule Spendable.User.Calculations.SpendableTest do
Factory.budget_allocation(budget, transaction, amount: 10)

# budget that only tracks spending should be ignored
budget = Factory.budget(user, track_spending_only: true)
budget = Factory.budget(user, type: :tracking)

Factory.budget_allocation(budget, transaction, amount: 10)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ defmodule Spendable.User.Calculations.SpentByMonth do
},
where: t.user_id == ^user.id,
where: t.amount < 0,
where: not t.excluded,
group_by: fragment("TO_CHAR(?, 'YYYY-MM-01')", t.date),
order_by: [desc: fragment("TO_CHAR(?, 'YYYY-MM-01')", t.date)]

Expand Down
4 changes: 3 additions & 1 deletion lib/spendable/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ defmodule Spendable.Utils do
:create,
%{
name: "Spendable",
track_spending_only: true
type: :tracking
},
actor: user
)
Expand All @@ -26,6 +26,8 @@ defmodule Spendable.Utils do
budget.id
end

def format_currency(nil), do: "$0.00"

def format_currency(decimal) do
negative? = Decimal.negative?(decimal)

Expand Down
58 changes: 47 additions & 11 deletions lib/spendable_web/components/core_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,50 @@ defmodule SpendableWeb.CoreComponents do
alias Phoenix.LiveView.JS
import SpendableWeb.Gettext

attr :id, :string, required: true
attr :enabled, :boolean, default: false
attr :on_toggle, :string

def switch(assigns) do
~H"""
<button
id={"#{@id}-toggle"}
phx-click={JS.push(@on_toggle) |> toggle_switch(@id)}
phx-value-id={@id}
type="button"
class="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
role="switch"
aria-checked="false"
>
<span aria-hidden="true" class="pointer-events-none absolute h-full w-full rounded-md"></span>
<span
id={"#{@id}-bg"}
aria-hidden="true"
class={[
if(@enabled, do: "bg-sky-600", else: "bg-gray-600"),
"pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out"
]}
>
</span>
<span
id={"#{@id}-circle"}
aria-hidden="true"
class={[
if(@enabled, do: "translate-x-5", else: "translate-x-0"),
"pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-600 bg-gray-400 shadow ring-0 transition-transform duration-200 ease-in-out"
]}
>
</span>
</button>
"""
end

def toggle_switch(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.toggle_class("bg-sky-600 bg-gray-600", to: "##{id}-bg")
|> JS.toggle_class("translate-x-5 translate-x-0", to: "##{id}-circle")
end

@doc """
Renders a modal.
Expand Down Expand Up @@ -292,23 +336,15 @@ defmodule SpendableWeb.CoreComponents do
|> input()
end

def input(%{type: "checkbox", value: value} = assigns) do
def input(%{type: "switch", value: value} = assigns) do
assigns =
assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end)

~H"""
<div phx-feedback-for={@name}>
<label class="flex items-center gap-4 text-sm leading-6 text-white">
<input type="hidden" name={@name} value="false" />
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class="rounded border-white/10 bg-white/5 text-white/5"
{@rest}
/>
<input type="checkbox" id={@id} name={@name} value="true" checked={@checked} class="sr-only peer" {@rest} />
<%= @label %>
</label>
<.error :for={msg <- @errors}><%= msg %></.error>
Expand All @@ -323,7 +359,7 @@ defmodule SpendableWeb.CoreComponents do
<select
id={@id}
name={@name}
class="mt-2 block w-full rounded-md text-white border-0 bg-white/5 ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-gray-500 sm:text-sm"
class="block w-full rounded-md text-white border-0 bg-white/5 ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-gray-500 sm:text-sm"
multiple={@multiple}
{@rest}
>
Expand Down
Loading

0 comments on commit 0def6b5

Please sign in to comment.