Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Budget improvements #762

Merged
merged 8 commits into from
Apr 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading