Skip to content
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
21 changes: 21 additions & 0 deletions lib/spendable_web/components/apple_mark.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
defmodule SpendableWeb.Components.AppleMark do
@moduledoc false
use SpendableWeb, :html

@doc """
Apple's own mark, standing in as the institution logo for the accounts read out of Wallet.

The outline is the U+F8FF glyph from the system font rather than a shape library's drawing of an
apple. The phone renders that codepoint as text, which a browser on anything but an Apple device
would not, so here it is the same outline inlined.
"""
attr :class, :string, default: "size-5"

def apple_mark(assigns) do
~H"""
<svg viewBox="0 0 1261 1551" fill="currentColor" aria-hidden="true" class={@class}>
<path d="M914 375Q936 375 989.0 382.0Q1042 389 1105.5 421.5Q1169 454 1221 529Q1218 532 1192.0 550.5Q1166 569 1134.0 604.5Q1102 640 1078.0 694.5Q1054 749 1054 824Q1054 910 1084.5 970.0Q1115 1030 1155.5 1066.5Q1196 1103 1227.5 1120.0Q1259 1137 1261 1138Q1260 1142 1235.5 1209.0Q1211 1276 1155 1358Q1106 1429 1049.5 1489.0Q993 1549 914 1549Q861 1549 827.0 1533.5Q793 1518 757.0 1502.5Q721 1487 660 1487Q601 1487 561.5 1503.0Q522 1519 486.5 1535.0Q451 1551 403 1551Q330 1551 275.0 1493.0Q220 1435 162 1354Q95 1258 47.5 1119.5Q0 981 0 840Q0 689 57.0 586.5Q114 484 203.5 431.5Q293 379 389 379Q440 379 485.0 395.5Q530 412 569.5 429.0Q609 446 641 446Q672 446 713.0 428.0Q754 410 805.0 392.5Q856 375 914 375ZM859 248Q820 295 761.0 326.5Q702 358 649 358Q638 358 628 356Q627 353 626.0 345.0Q625 337 625 328Q625 268 651.0 211.5Q677 155 710 118Q752 68 816.0 35.0Q880 2 938 0Q941 13 941 31Q941 91 918.0 147.5Q895 204 859 248Z" />
</svg>
"""
end
end
1 change: 1 addition & 0 deletions lib/spendable_web/components/core_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ defmodule SpendableWeb.CoreComponents do
alias Phoenix.LiveView.JS
use Gettext, backend: SpendableWeb.Gettext

defdelegate apple_mark(assigns), to: SpendableWeb.Components.AppleMark
defdelegate auth_backdrop(assigns), to: SpendableWeb.Components.AuthBackdrop
defdelegate bulk_actions(assigns), to: SpendableWeb.Components.BulkActions

Expand Down
12 changes: 10 additions & 2 deletions lib/spendable_web/live/banks.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
defmodule SpendableWeb.Live.Banks do
use SpendableWeb, :live_view

import SpendableWeb.Utils.AccountLabel
import SpendableWeb.Utils.FormOptions

alias Spendable.Banks
Expand Down Expand Up @@ -43,7 +44,14 @@ defmodule SpendableWeb.Live.Banks do
</div>
<div class="min-w-0">
<div class="flex items-center">
<img src={~p"/banks/#{bank_member.id}/logo"} alt="bank logo" class="h-8 mr-2" />
<!-- Wallet is not an institution Plaid has a logo for, so Apple's own mark stands in. -->
<.apple_mark :if={bank_member.provider == "FinanceKit"} class="h-8 mr-2 text-white" />
<img
:if={bank_member.provider != "FinanceKit"}
src={~p"/banks/#{bank_member.id}/logo"}
alt="bank logo"
class="h-8 mr-2"
/>
<h2 class="min-w-0 text-sm font-semibold leading-6 text-white">
<span class="truncate">{bank_member.name}</span>
</h2>
Expand Down Expand Up @@ -91,7 +99,7 @@ defmodule SpendableWeb.Live.Banks do
if(bank_account.sync, do: "text-white", else: "text-gray-500"),
"min-w-0 text-sm font-semibold leading-6 flex flex-col"
]}>
<span class="truncate">{bank_account.name} *{bank_account.number}</span>
<span class="truncate">{account_label(bank_account.name, bank_account.number)}</span>
<span class="truncate uppercase mt-1 text-xs text-gray-400">{bank_account.sub_type}</span>
</h2>
</div>
Expand Down
28 changes: 28 additions & 0 deletions lib/spendable_web/live/banks_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,34 @@ defmodule SpendableWeb.Live.BanksTest do
assert html =~ "Tartan Bank"
end

# Wallet is not an institution Plaid has a logo for, so the logo endpoint has nothing to serve.
test "stands Apple's mark in for the Wallet connection", %{conn: conn, scope: scope} do
{:ok, wallet} =
Repo.insert(%BankMember{
user_id: scope.user.id,
external_id: "finance_kit",
name: "Apple",
provider: "FinanceKit",
status: "CONNECTED"
})

{:ok, _view, html} = live(conn, ~p"/banks")

assert html =~ "Apple"
refute html =~ "/banks/#{wallet.id}/logo"
end

# An Apple Cash balance has no number to print, and dots with nothing after them say less than
# the name on its own.
test "reads an account with no number as just its name", %{conn: conn, member: member} do
{:ok, view, _html} = live(conn, ~p"/banks")

html = render_click(view, "select_bank_member", %{"id" => member.id})

assert html =~ "Checking"
refute html =~ "••••"
end

test "pushes a link token when opening Plaid Link", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/banks")

Expand Down
56 changes: 41 additions & 15 deletions lib/spendable_web/live/budgets.ex
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,14 @@ defmodule SpendableWeb.Live.Budgets do
<div class="flex items-center justify-between gap-x-2">
<h2 class="truncate text-sm font-semibold leading-6 text-white">{card.budget.name}</h2>
<div class="flex flex-none items-center gap-x-2">
<span class={["rounded-full py-1 px-2 text-xs font-medium ring-1 ring-inset", card.pill_class]}>
<span
:if={card.pill}
class={["rounded-full py-1 px-2 text-xs font-medium ring-1 ring-inset", card.pill_class]}
>
{card.pill}
</span>
<button
:if={card.editable?}
type="button"
aria-label={"Edit #{card.budget.name}"}
phx-click={JS.push("select_budget") |> show_details()}
Expand All @@ -114,14 +118,16 @@ defmodule SpendableWeb.Live.Budgets do
</button>
</div>
</div>
<div class="mt-4 flex items-baseline gap-x-2">
<span class={[
<!-- What the figure is stands under it rather than beside it, so the eye reads the
number first and the word only if it needs to. -->
<div class="mt-4">
<p class={[
"text-3xl font-semibold",
if(Decimal.negative?(card.amount), do: "text-red-400", else: "text-white")
]}>
{Utils.format_currency(card.amount)}
</span>
<span class="text-xs uppercase tracking-wide text-gray-400">{card.label}</span>
</p>
<p class="text-xs uppercase tracking-wide text-gray-400">{card.label}</p>
</div>
<div :if={card.percent} class="mt-4 h-1 w-full rounded-full bg-white/10">
<div class={["h-1 rounded-full", bar_class(card.bar)]} style={"width: #{card.percent}%"} />
Expand Down Expand Up @@ -261,7 +267,7 @@ defmodule SpendableWeb.Live.Budgets do
scope = socket.assigns.current_scope
selected_month = socket.assigns[:selected_month] || Date.beginning_of_month(Date.utc_today())
summary = Budgets.calculate_month_summary(scope, selected_month, search: socket.assigns[:search])
listed = maybe_add_credit_cards(summary.budgets, scope, summary.current_month)
listed = listed_budgets(summary.budgets, scope, summary.current_month)

socket
|> assign(:spendable, summary.spendable)
Expand All @@ -276,29 +282,49 @@ defmodule SpendableWeb.Live.Budgets do
|> assign(:changeset, nil)
end

# Card debt is not a budget, but it reads as one on this page: a negative balance to cover.
# It only makes sense against the current month, since it is what is owed right now.
defp maybe_add_credit_cards(budgets, _scope, false = _current_month_is_selected), do: budgets
# A past month has no Spendable figure above the list, so the budget is the only place left to
# read what came out of it.
defp listed_budgets(budgets, _scope, false = _current_month_is_selected), do: by_type(budgets)

defp listed_budgets([], _scope, _current_month_is_selected), do: []

defp maybe_add_credit_cards([spendable | budgets], scope, _current_month_is_selected) do
# Card debt is not a budget, but it reads as one on this page: a negative balance to cover, and
# no id because there is no row behind it. Spendable is the figure the page opens with, so
# listing it again only says the same word twice about two different numbers.
defp listed_budgets(budgets, scope, _current_month_is_selected) do
credit_cards = %Budget{
name: "Credit Cards",
type: :envelope,
balance: scope |> Banks.calculate_credit_card_balance() |> Decimal.negate()
}

[spendable, credit_cards | budgets]
[credit_cards | budgets |> Enum.reject(&(&1.name == "Spendable")) |> by_type()]
end

defp maybe_add_credit_cards([], _scope, _current_month_is_selected), do: []
# Envelopes, then what is only tracked, alphabetical inside each. Grouping them by what they are
# does the work a heading over each group would, without the headings. Goals go last: a goal is
# money going in rather than out, so it is not what the month is about.
defp by_type(budgets), do: Enum.sort_by(budgets, &{type_order(&1.type), &1.name})

defp type_order(:envelope), do: 0
defp type_order(:tracking), do: 1
defp type_order(:goal), do: 2

defp build_cards(budgets, spent, current_month_is_selected) do
Enum.map(budgets, fn budget ->
spent_here = spent |> Map.get(budget.id, Decimal.new(0)) |> Decimal.abs()
credit_cards? = is_nil(budget.id)
card = build_budget_card(budget, spent_here, current_month_is_selected)

budget
|> build_budget_card(spent_here, current_month_is_selected)
|> Map.merge(%{budget: budget, pill: pill(budget.type), pill_class: pill_class(budget.type)})
# Card debt is not an envelope with something left in it, it is what is owed right now, and
# the pill calling it one is only there to satisfy the card it is built from.
Map.merge(card, %{
budget: budget,
label: if(credit_cards?, do: "BALANCE", else: card.label),
pill: if(credit_cards?, do: nil, else: pill(budget.type)),
pill_class: pill_class(budget.type),
editable?: not credit_cards? and budget.name != "Spendable"
})
end)
end

Expand Down
61 changes: 61 additions & 0 deletions lib/spendable_web/live/budgets_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,67 @@ defmodule SpendableWeb.Live.BudgetsTest do
refute html =~ "Credit Cards"
end

# Spendable is the figure the page opens with, so a card saying it again is the same word twice
# about two different numbers.
test "leaves the Spendable card off the current month", %{conn: conn, scope: scope} do
{:ok, _transaction} =
Transactions.create_transaction(scope, %{
"amount" => "-20.00",
"date" => Date.utc_today(),
"name" => "Groceries"
})

{:ok, view, html} = live(conn, ~p"/budgets")

assert html =~ "Spendable"
refute has_element?(view, "h2", "Spendable")
end

# A past month has no Spendable figure above the list, so the budget is the only place left.
test "keeps the Spendable card on a past month", %{conn: conn, scope: scope} do
{:ok, _transaction} =
Transactions.create_transaction(scope, %{
"amount" => "-20.00",
"date" => Date.utc_today(),
"name" => "Groceries"
})

last_month = Date.utc_today() |> Date.beginning_of_month() |> Date.add(-1)

{:ok, view, _html} = live(conn, ~p"/budgets")

render_click(view, "select_month", %{"month" => Date.to_iso8601(last_month)})

assert has_element?(view, "h2", "Spendable")
end

# Card debt reads the bank accounts and Spendable is whatever is left over. Neither is a card
# anyone edits.
test "offers no edit on the credit card total", %{conn: conn, scope: scope} do
{:ok, _budget} = Budgets.create_budget(scope, %{"name" => "Groceries"})

{:ok, view, html} = live(conn, ~p"/budgets")

assert html =~ "BALANCE"
assert has_element?(view, ~s(button[aria-label="Edit Groceries"]))
refute has_element?(view, ~s(button[aria-label="Edit Credit Cards"]))
end

# Envelopes, then what is only tracked, then goals - the grouping does the work a heading would.
test "orders the cards by type, with goals last", %{conn: conn, scope: scope} do
{:ok, _goal} = Budgets.create_budget(scope, %{"name" => "Vacation", "type" => "goal"})
{:ok, _tracking} = Budgets.create_budget(scope, %{"name" => "Amazon", "type" => "tracking"})
{:ok, _envelope} = Budgets.create_budget(scope, %{"name" => "Rent", "type" => "envelope"})

{:ok, _view, html} = live(conn, ~p"/budgets")

assert [rent, amazon, vacation] =
Enum.map(["Rent", "Amazon", "Vacation"], &(:binary.match(html, &1) |> elem(0)))

assert rent < amazon
assert amazon < vacation
end

test "filters the list by the search box", %{conn: conn, scope: scope} do
{:ok, _groceries} = Budgets.create_budget(scope, %{"name" => "Groceries"})
{:ok, _rent} = Budgets.create_budget(scope, %{"name" => "Rent"})
Expand Down
21 changes: 10 additions & 11 deletions lib/spendable_web/live/transactions.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
defmodule SpendableWeb.Live.Transactions do
use SpendableWeb, :live_view

import SpendableWeb.Utils.AccountLabel
import SpendableWeb.Utils.FormOptions

alias Spendable.Budgets
Expand Down Expand Up @@ -75,7 +76,7 @@ defmodule SpendableWeb.Live.Transactions do
:for={{id, transaction} <- @streams.transactions}
id={id}
class={[
if(transaction.excluded or transaction.transfer_id, do: "opacity-40"),
if(transaction.excluded, do: "opacity-40"),
"group flex flex-row items-center gap-x-3 p-2"
]}
>
Expand Down Expand Up @@ -111,10 +112,7 @@ defmodule SpendableWeb.Live.Transactions do
class="h-5 w-5 shrink-0 rounded-sm"
/>
<span :if={bank_account(transaction)} class="truncate text-xs text-gray-400">
{bank_account(transaction).name}
</span>
<span :if={bank_account(transaction)} class="shrink-0 text-xs text-gray-500">
{mask(bank_account(transaction).number)}
{account_label(bank_account(transaction).name, bank_account(transaction).number)}
</span>
</div>
<span class="w-24 shrink-0 text-right text-sm font-semibold tabular-nums text-white">
Expand Down Expand Up @@ -589,8 +587,13 @@ defmodule SpendableWeb.Live.Transactions do
(socket.assigns.show_excluded or not transaction.excluded)
end

# Saying where the whole of a transaction was spent is the decision the review queue is asking
# for, so making it is what finishes the row.
defp whole_amount_to(transaction, budget_id) do
%{"budget_allocations" => [%{"amount" => transaction.amount, "budget_id" => budget_id}]}
%{
"budget_allocations" => [%{"amount" => transaction.amount, "budget_id" => budget_id}],
"reviewed" => true
}
end

# A transaction with one allocation splits nothing, so the whole amount is spent from that
Expand All @@ -607,12 +610,8 @@ defmodule SpendableWeb.Live.Transactions do
defp bank_account(%{bank_transaction: %{bank_account: account}}), do: account
defp bank_account(_transaction), do: nil

# The dots stand in for the digits the bank does not give us, so the number reads as an account
# rather than as a footnote.
defp mask(number), do: "••••#{number}"

defp transfer_label(%{bank_transaction: %{bank_account: account}}) do
"#{account.name} #{mask(account.number)}"
account_label(account.name, account.number)
end

defp transfer_label(transfer), do: transfer.name
Expand Down
39 changes: 39 additions & 0 deletions lib/spendable_web/live/transactions_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,45 @@ defmodule SpendableWeb.Live.TransactionsTest do
assert Decimal.eq?(amount, "-5.00")
end

# Saying where the whole of a transaction went is the decision the queue is asking for, so
# making it is what finishes the row.
test "marks a transaction reviewed once the row says what it was spent from", %{
conn: conn,
scope: scope,
budget: budget,
attrs: attrs
} do
{:ok, transaction} = Transactions.create_transaction(scope, Map.put(attrs, "name", "Coffee"))

refute transaction.reviewed

{:ok, view, _html} = live(conn, ~p"/transactions")

view
|> element("#spend-from-#{transaction.id}")
|> render_change(%{"budget_id" => budget.id})

assert {:ok, %{reviewed: true}} = Transactions.get_transaction(scope, id: transaction.id)
end

# A transfer is settled rather than set aside, so it reads like any other finished row.
test "does not dim a transaction that is part of a transfer", %{conn: conn, scope: scope, attrs: attrs} do
{:ok, out} = Transactions.create_transaction(scope, Map.put(attrs, "name", "To savings"))

{:ok, into} =
Transactions.create_transaction(
scope,
attrs |> Map.put("name", "From checking") |> Map.put("amount", "5.00")
)

{:ok, _pair} = Transactions.mark_as_transfer(scope, out, into)

{:ok, view, _html} = live(conn, ~p"/transactions")

assert has_element?(view, "#transactions-#{out.id}")
refute has_element?(view, "#transactions-#{out.id}.opacity-40")
end

# A split has no single budget to offer, so the row sends the user to the drawer instead.
test "offers no select for a split transaction", %{
conn: conn,
Expand Down
12 changes: 12 additions & 0 deletions lib/spendable_web/utils/account_label.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule SpendableWeb.Utils.AccountLabel do
@moduledoc "Import this module rather than aliasing it."

@doc """
An account reads as its name and the last few digits of its number.

Not every account has one - an Apple Cash balance has nothing to print - and dots with nothing
after them say less than the name on its own. Mirrors `accountLabel` in the Flutter app.
"""
def account_label(name, number) when number in [nil, ""], do: name
def account_label(name, number), do: "#{name} ••••#{number}"
end
Loading
Loading