1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +02:00

Add institution management and account editing controls (#868)

* Add institution management

* Allow user to select institution on create or edit

* Improve redirect behavior

* Final cleanup

* i18n normalization
This commit is contained in:
Zach Gollwitzer 2024-06-13 14:37:27 -04:00 committed by GitHub
parent 8c1a7af37f
commit 9956a9540e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 456 additions and 68 deletions

View file

@ -2,11 +2,12 @@ class AccountsController < ApplicationController
layout "with_sidebar"
include Filterable
before_action :set_account, only: %i[ show destroy sync update ]
before_action :set_account, only: %i[ edit show destroy sync update ]
after_action :sync_account, only: :create
def index
@accounts = Current.family.accounts
@institutions = Current.family.institutions
@accounts = Current.family.accounts.ungrouped.alphabetically
end
def summary
@ -26,6 +27,10 @@ class AccountsController < ApplicationController
balance: nil,
accountable: Accountable.from_type(params[:type])&.new
)
if params[:institution_id]
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
end
end
def show
@ -33,6 +38,9 @@ class AccountsController < ApplicationController
@valuation_series = @account.valuations.to_series
end
def edit
end
def update
@account.update! account_params.except(:accountable_type)
redirect_back_or_to account_path(@account), notice: t(".success")
@ -46,7 +54,7 @@ class AccountsController < ApplicationController
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
redirect_to account_path(@account), notice: t(".success")
redirect_back_or_to account_path(@account), notice: t(".success")
end
def destroy
@ -80,7 +88,7 @@ class AccountsController < ApplicationController
end
def account_params
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active)
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
end
def sync_account

View file

@ -0,0 +1,35 @@
class InstitutionsController < ApplicationController
before_action :set_institution, except: %i[ new create ]
def new
@institution = Institution.new
end
def create
Current.family.institutions.create!(institution_params)
redirect_to accounts_path, notice: t(".success")
end
def edit
end
def update
@institution.update!(institution_params)
redirect_to accounts_path, notice: t(".success")
end
def destroy
@institution.destroy!
redirect_to accounts_path, notice: t(".success")
end
private
def institution_params
params.require(:institution).permit(:name, :logo)
end
def set_institution
@institution = Current.family.institutions.find(params[:id])
end
end

View file

@ -0,0 +1,5 @@
module InstitutionsHelper
def institution_logo(institution)
institution.logo.attached? ? institution.logo : institution.logo_url
end
end

View file

@ -8,7 +8,7 @@ export default class extends Controller {
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-24 h-24 rounded-full object-cover" />`;
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-full h-full rounded-full object-cover" />`;
this.templateTarget.classList.add("hidden");
this.clearBtnTarget.classList.remove("hidden");
};

View file

@ -2,10 +2,12 @@ class Account < ApplicationRecord
include Syncable
include Monetizable
broadcasts_refreshes
validates :family, presence: true
broadcasts_refreshes
belongs_to :family
belongs_to :institution, optional: true
has_many :balances, dependent: :destroy
has_many :valuations, dependent: :destroy
has_many :transactions, dependent: :destroy
@ -19,6 +21,7 @@ class Account < ApplicationRecord
scope :assets, -> { where(classification: "asset") }
scope :liabilities, -> { where(classification: "liability") }
scope :alphabetically, -> { order(:name) }
scope :ungrouped, -> { where(institution_id: nil) }
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy

View file

@ -2,6 +2,7 @@ class Family < ApplicationRecord
has_many :users, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :institutions, dependent: :destroy
has_many :transactions, through: :accounts
has_many :imports, through: :accounts
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"

View file

@ -0,0 +1,7 @@
class Institution < ApplicationRecord
belongs_to :family
has_many :accounts, dependent: :nullify
has_one_attached :logo
scope :alphabetically, -> { order(name: :asc) }
end

View file

@ -1,10 +1,14 @@
<%= turbo_frame_tag dom_id(account) do %>
<div class="p-4 flex items-center justify-between gap-3">
<div class="p-4 flex items-center justify-between gap-3 group/account">
<div class="flex items-center gap-3">
<div class="w-8 h-8 flex items-center justify-center rounded-full text-xs font-medium <%= account.is_active ? "bg-blue-500/10 text-blue-500" : "bg-gray-500/10 text-gray-500" %>">
<%= account.name[0].upcase %>
</div>
<%= link_to account.name, account, class: [(account.is_active ? "text-gray-900" : "text-gray-400"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
<%= link_to edit_account_path(account), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %>
<%= lucide_icon "pencil-line", class: "w-4 h-4 text-gray-500" %>
<% end %>
</div>
<div class="flex items-center gap-8">
<p class="text-sm font-medium <%= account.is_active ? "text-gray-900" : "text-gray-400" %>">

View file

@ -1,4 +1,4 @@
<%= link_to new_account_path(step: "method", type: type.class.name.demodulize), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-25 border border-transparent focus:border focus:border-gray-200 block px-2 hover:bg-gray-25 rounded-lg p-2" do %>
<%= link_to new_account_path(step: "method", type: type.class.name.demodulize, institution_id: params[:institution_id]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-25 border border-transparent focus:border focus:border-gray-200 block px-2 hover:bg-gray-25 rounded-lg p-2" do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg <%= bg_color %> border border-alpha-black-25">
<%= lucide_icon(icon, class: "#{text_color} w-5 h-5") %>
</span>

View file

@ -0,0 +1,17 @@
<%# locals: (accounts:) %>
<% accounts.group_by(&:accountable_type).each do |group, accounts| %>
<div class="bg-gray-25 p-1 rounded-xl">
<div class="flex items-center px-4 py-2 text-xs font-medium text-gray-500">
<p><%= to_accountable_title(Accountable.from_type(group)) %></p>
<span class="text-gray-400 mx-2">&middot;</span>
<p><%= accounts.count %></p>
<p class="ml-auto"><%= format_money accounts.sum(&:balance_money) %></p>
</div>
<div class="bg-white">
<% accounts.each do |account| %>
<%= render account %>
<% end %>
</div>
</div>
<% end %>

View file

@ -0,0 +1,11 @@
<div class="flex justify-center items-center h-[800px] text-sm">
<div class="text-center flex flex-col items-center max-w-[300px]">
<%= tag.p t(".no_accounts"), class: "text-gray-900 mb-1 font-medium" %>
<%= tag.p t(".empty_message"), class: "text-gray-500 mb-4" %>
<%= link_to new_account_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new_account") %></span>
<% end %>
</div>
</div>

View file

@ -6,7 +6,7 @@
<%= text %>
</span>
<% else %>
<%= link_to new_account_path(type: type.class.name.demodulize), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %>
<%= link_to new_account_path(type: type.class.name.demodulize, institution_id: params[:institution_id]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon(icon, class: "text-gray-500 w-5 h-5") %>
</span>

View file

@ -0,0 +1,69 @@
<%# locals: (institution:) %>
<details open class="group bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<summary class="flex items-center gap-2 focus-visible:outline-none">
<%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %>
<div class="flex items-center justify-center h-8 w-8 bg-blue-600/10 rounded-full bg-black/5">
<% if institution_logo(institution) %>
<%= image_tag institution_logo(institution), class: "rounded-full h-full w-full" %>
<% else %>
<div class="flex items-center justify-center">
<%= tag.p institution.name.first.upcase, class: "text-blue-600 text-xs font-medium" %>
</div>
<% end %>
</div>
<%= link_to institution.name, edit_institution_path(institution), data: { turbo_frame: :modal }, class: "text-sm font-medium text-gray-900 ml-1 mr-auto hover:underline" %>
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to new_account_path(institution_id: institution.id),
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon "plus", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".add_account_to_institution") %></span>
<% end %>
<%= link_to edit_institution_path(institution),
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".edit") %></span>
<% end %>
<%= button_to institution_path(institution),
method: :delete,
class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
data: {
turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body"),
accept: t(".confirm_accept")
}
} do %>
<%= lucide_icon "trash-2", class: "w-5 h-5" %>
<span><%= t(".delete") %></span>
<% end %>
</div>
<% end %>
</summary>
<div class="space-y-4 mt-4">
<% if institution.accounts.any? %>
<%= render "accountable_group", accounts: institution.accounts %>
<% else %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-gray-500 text-sm">There are no accounts in this financial institution</p>
<%= link_to new_account_path(institution_id: institution.id), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-1.5 pr-2", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-4 h-4") %>
<span><%= t(".new_account") %></span>
<% end %>
</div>
<% end %>
</div>
</details>

View file

@ -0,0 +1,17 @@
<%# locals: (accounts:) %>
<details open class="group bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<summary class="flex items-center gap-2 focus-visible:outline-none">
<%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %>
<div class="flex items-center justify-center h-8 w-8 rounded-full bg-black/5">
<%= lucide_icon("folder-pen", class: "w-5 h-5 text-gray-500") %>
</div>
<span class="mr-auto text-sm font-medium text-gray-900"><%= t(".other_accounts") %></span>
</summary>
<div class="space-y-4 mt-4">
<%= render "accountable_group", accounts: accounts %>
</div>
</details>

View file

@ -0,0 +1,21 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".edit", account: @account.name) %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= form_with model: @account, data: { turbo_frame: "_top" } do |f| %>
<%= f.text_field :name, label: "Name" %>
<div class="relative">
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
<%= link_to new_institution_path do %>
<%= lucide_icon "plus", class: "text-gray-700 hover:text-gray-500 w-4 h-4 absolute right-3 top-2" %>
<% end %>
</div>
<%= f.submit %>
<% end %>
</article>
<% end %>

View file

@ -1,62 +1,45 @@
<% content_for :sidebar do %>
<%= render "settings/nav" %>
<% end %>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-medium text-gray-900">Accounts</h1>
<%= link_to new_account_path, class: "flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new_account") %></span>
<% end %>
</div>
<% if @accounts.empty? %>
<div class="flex justify-center items-center h-[800px] text-sm">
<div class="text-center flex flex-col items-center max-w-[300px]">
<p class="text-gray-900 mb-1 font-medium">No accounts yet</p>
<p class="text-gray-500 mb-4">Add an account either via connection, importing or entering manually.</p>
<%= link_to new_account_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
<header class="flex justify-between items-center text-gray-900 font-medium">
<h1 class="text-xl"><%= t(".accounts") %></h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to new_institution_path,
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal",
data: { turbo_frame: "modal" } do %>
<%= lucide_icon "building-2", class: "w-5 h-5 text-gray-500" %>
<span class="text-black"><%= t(".add_institution") %></span>
<% end %>
</div>
<% end %>
<%= link_to new_account_path,
data: { turbo_frame: "modal" },
class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2" do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new_account") %></span>
<p class="text-sm font-medium"><%= t(".new_account") %></p>
<% end %>
</div>
</div>
</header>
<% if @accounts.empty? && @institutions.empty? %>
<%= render "empty" %>
<% else %>
<div>
<% @accounts.by_provider.each do |item| %>
<details open class="bg-white group p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<summary class="flex items-center gap-2">
<%= lucide_icon("chevron-down", class: "hidden group-open:block w-5 h-5 text-gray-500") %>
<%= lucide_icon("chevron-right", class: "group-open:hidden w-5 h-5 text-gray-500") %>
<% if item[:name] == "Manual accounts" %>
<div class="flex items-center justify-center h-8 w-8 rounded-full bg-black/5">
<%= lucide_icon("folder-pen", class: "w-5 h-5 text-gray-500") %>
</div>
<% end %>
<span class="text-sm font-medium text-gray-900">
<%= item[:name] %>
</span>
</summary>
<div class="space-y-4 mt-4">
<% item[:accounts].each do |group, accounts| %>
<div class="bg-gray-25 p-1 rounded-xl">
<div class="flex items-center px-4 py-2 text-xs font-medium text-gray-500">
<p><%= to_accountable_title(Accountable.from_type(group)) %></p>
<span class="text-gray-400 mx-2">&middot;</span>
<p><%= accounts.count %></p>
<p class="ml-auto"><%= format_money accounts.sum(&:balance_money) %></p>
</div>
<div class="bg-white">
<% accounts.each do |account| %>
<%= render account %>
<% end %>
</div>
</div>
<% end %>
</div>
</details>
<div class="space-y-2">
<% @institutions.each do |institution| %>
<%= render "institution_accounts", institution: %>
<% end %>
<%= render "institutionless_accounts", accounts: @accounts %>
</div>
<% end %>
<div class="flex justify-between gap-4">
<% if self_hosted? %>
<%= previous_setting("Self-Hosting", settings_hosting_path) %>

View file

@ -76,6 +76,7 @@
<div class="space-y-4 grow">
<%= f.hidden_field :accountable_type %>
<%= f.text_field :name, placeholder: t(".name.placeholder"), required: "required", label: t(".name.label"), autofocus: true %>
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
<%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
<%= f.money_field :balance_money, label: t(".balance"), required: "required" %>
@ -83,7 +84,7 @@
<%= check_box_tag :add_start_values, class: "maybe-checkbox maybe-checkbox--light peer mb-1" %>
<%= label_tag :add_start_values, t(".optional_start_balance_message"), class: "pl-1 text-sm text-gray-500" %>
<div class="hidden peer-checked:flex items-center gap-2 mt-3">
<div class="hidden peer-checked:flex items-center gap-2 mt-3 mb-6">
<div class="w-1/2"><%= f.date_field :start_date, label: t(".start_date"), max: Date.current %></div>
<div class="w-1/2"><%= f.number_field :start_balance, label: t(".start_balance") %></div>
</div>

View file

@ -9,15 +9,17 @@
<%= button_to sync_account_path(@account), method: :post, class: "flex items-center gap-2", title: "Sync Account" do %>
<%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-900 hover:text-gray-500" %>
<% end %>
<div class="relative cursor-not-allowed">
<div class="flex items-center gap-2 px-3 py-2">
<span class="text-gray-900"><%= @account.balance_money.currency.iso_code %> <%= @account.balance_money.currency.symbol %></span>
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
</div>
</div>
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to edit_account_path(@account),
data: { turbo_frame: :modal },
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".edit") %></span>
<% end %>
<%= link_to new_import_path(account_id: @account.id),
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %>

View file

@ -0,0 +1,27 @@
<%= form_with model: institution, data: { turbo_frame: "_top", controller: "profile-image-preview" } do |f| %>
<div class="flex justify-center items-center py-4">
<%= f.label :logo do %>
<div class="relative cursor-pointer hover:opacity-80 w-16 h-16 rounded-full bg-gray-50">
<% persisted_logo = institution_logo(institution) %>
<% if persisted_logo %>
<%= image_tag persisted_logo, class: "absolute inset-0 rounded-full w-full h-full object-cover" %>
<% end %>
<div data-profile-image-preview-target="imagePreview" class="absolute inset-0 h-full w-full flex items-center justify-center">
<% unless persisted_logo %>
<%= lucide_icon "image-plus", class: "w-5 h-5 text-gray-500 cursor-pointer", data: { profile_image_preview_target: "template" } %>
<% end %>
</div>
</div>
<% end %>
</div>
<%= f.file_field :logo,
accept: "image/png, image/jpeg",
class: "hidden",
data: { profile_image_preview_target: "fileField", action: "profile-image-preview#preview" } %>
<%= f.text_field :name, label: t(".name") %>
<%= f.submit %>
<% end %>

View file

@ -0,0 +1,10 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".edit", institution: @institution.name) %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", institution: @institution %>
</article>
<% end %>

View file

@ -0,0 +1,10 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".new_institution") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", institution: @institution %>
</article>
<% end %>

View file

@ -8,7 +8,7 @@
<%= form_with model: Current.user, url: settings_profile_path, html: {data: { controller: "profile-image-preview" }} do |form| %>
<div class="flex items-center gap-4">
<div class="relative flex justify-center items-center bg-gray-50 w-24 h-24 rounded-full border border-alpha-black-25">
<div data-profile-image-preview-target="imagePreview">
<div data-profile-image-preview-target="imagePreview" class="h-full w-full flex justify-center items-center">
<% profile_image_attached = Current.user.profile_image.attached? %>
<% if profile_image_attached %>
<div class="h-24 w-24">

View file

@ -1,6 +1,6 @@
<%# locals: (content:, classes:) -%>
<%= turbo_frame_tag "modal" do %>
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[648px] max-w-[580px] w-min-content shadow-xs h-fit <%= classes %>" data-controller="modal" data-action="click->modal#clickOutside">
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-w-[580px] w-min-content shadow-xs h-fit <%= classes %>" data-controller="modal" data-action="click->modal#clickOutside">
<div class="flex flex-col">
<%= content %>
</div>

View file

@ -12,17 +12,40 @@ en:
success: New account created successfully
destroy:
success: Account deleted successfully
edit:
edit: Edit %{account}
institution: Financial institution
ungrouped: "(none)"
empty:
empty_message: Add an account either via connection, importing or entering manually.
new_account: New account
no_accounts: No accounts yet
header:
accounts: Accounts
manage: Manage accounts
new: New account
index:
accounts: Accounts
add_institution: Add institution
new_account: New account
institution_accounts:
add_account_to_institution: Add new account
confirm_accept: Delete institution
confirm_body: Don't worry, none of the accounts within this institution will
be affected by this deletion. Accounts will be ungrouped and all historical
data will remain intact.
confirm_title: Delete financial institution?
delete: Delete institution
edit: Edit institution
new_account: Add account
institutionless_accounts:
other_accounts: Other accounts
new:
balance: Current balance
currency:
all_others: All Others
popular: Popular
institution: Financial institution
name:
label: Account name
placeholder: Example account name
@ -31,6 +54,7 @@ en:
start_balance: Start balance (optional)
start_date: Start date (optional)
title: Add an account
ungrouped: "(none)"
show:
confirm_accept: Delete "%{name}"
confirm_body_html: "<p>By deleting this account, you will erase its value history,
@ -39,6 +63,7 @@ en:
/> <p>After deletion, there is no way you'll be able to restore the account
information because you'll need to add it as a new account.</p>"
confirm_title: Delete account?
edit: Edit
import: Import transactions
sync_message_missing_rates: Since exchange rates haven't been synced, balance
graphs may not reflect accurate values.

View file

@ -0,0 +1,15 @@
---
en:
institutions:
create:
success: Institution created
destroy:
success: Institution deleted
edit:
edit: Edit %{institution}
form:
name: Financial institution name
new:
new_institution: New financial institution
update:
success: Institution updated

View file

@ -71,6 +71,8 @@ Rails.application.routes.draw do
resources :valuations
end
resources :institutions, except: %i[ index show ]
# For managing self-hosted upgrades and release notifications
resources :upgrades, only: [] do
member do

View file

@ -0,0 +1,11 @@
class CreateInstitutions < ActiveRecord::Migration[7.2]
def change
create_table :institutions, id: :uuid do |t|
t.string :name, null: false
t.string :logo_url
t.references :family, null: false, foreign_key: true, type: :uuid
t.timestamps
end
end
end

View file

@ -0,0 +1,5 @@
class AddInstitutionToAccounts < ActiveRecord::Migration[7.2]
def change
add_reference :accounts, :institution, foreign_key: true, type: :uuid
end
end

15
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_05_24_203959) do
ActiveRecord::Schema[7.2].define(version: 2024_06_12_164944) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -93,8 +93,10 @@ ActiveRecord::Schema[7.2].define(version: 2024_05_24_203959) do
t.jsonb "sync_warnings", default: [], null: false
t.jsonb "sync_errors", default: [], null: false
t.date "last_sync_date"
t.uuid "institution_id"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["family_id"], name: "index_accounts_on_family_id"
t.index ["institution_id"], name: "index_accounts_on_institution_id"
end
create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -234,6 +236,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_05_24_203959) do
t.index ["account_id"], name: "index_imports_on_account_id"
end
create_table "institutions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "name", null: false
t.string "logo_url"
t.uuid "family_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["family_id"], name: "index_institutions_on_family_id"
end
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "token", null: false
t.datetime "created_at", null: false
@ -334,9 +345,11 @@ ActiveRecord::Schema[7.2].define(version: 2024_05_24_203959) do
add_foreign_key "account_balances", "accounts", on_delete: :cascade
add_foreign_key "accounts", "families"
add_foreign_key "accounts", "institutions"
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "imports", "accounts"
add_foreign_key "institutions", "families"
add_foreign_key "taggings", "tags"
add_foreign_key "tags", "families"
add_foreign_key "transaction_categories", "families"

View file

@ -6,6 +6,15 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
@account = accounts(:checking)
end
test "gets accounts list" do
get accounts_url
assert_response :success
@user.family.accounts.each do |account|
assert_dom "#" + dom_id(account), count: 1
end
end
test "new" do
get new_account_path
assert_response :ok
@ -19,7 +28,9 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
test "should update account" do
patch account_url(@account), params: {
account: {
is_active: "0"
name: "Updated name",
is_active: "0",
institution_id: institutions(:chase).id
}
}
@ -33,7 +44,8 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
account: {
accountable_type: "Account::Depository",
balance: 200,
subtype: "checking"
subtype: "checking",
institution_id: institutions(:chase).id
}
}
@ -49,6 +61,7 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
accountable_type: "Account::Depository",
balance: 200,
subtype: "checking",
institution_id: institutions(:chase).id,
start_balance: 100,
start_date: 10.days.ago
}

View file

@ -0,0 +1,55 @@
require "test_helper"
class InstitutionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@institution = institutions(:chase)
end
test "should get new" do
get new_institution_url
assert_response :success
end
test "can create institution" do
assert_difference("Institution.count", 1) do
post institutions_url, params: {
institution: {
name: "New institution"
}
}
end
assert_redirected_to accounts_url
assert_equal "Institution created", flash[:notice]
end
test "should get edit" do
get edit_institution_url(@institution)
assert_response :success
end
test "should update institution" do
patch institution_url(@institution), params: {
institution: {
name: "New Institution Name",
logo: file_fixture_upload("square-placeholder.png", "image/png", :binary)
}
}
assert_redirected_to accounts_url
assert_equal "Institution updated", flash[:notice]
end
test "can destroy institution without destroying accounts" do
assert @institution.accounts.count > 0
assert_difference -> { Institution.count } => -1, -> { Account.count } => 0 do
delete institution_url(@institution)
end
assert_redirected_to accounts_url
assert_equal "Institution deleted", flash[:notice]
end
end

View file

@ -13,6 +13,7 @@ checking:
balance: 5000
accountable_type: Account::Depository
accountable_id: "123e4567-e89b-12d3-a456-426614174000"
institution: chase
# Account with both transactions and valuations
savings_with_valuation_overrides:
@ -21,6 +22,7 @@ savings_with_valuation_overrides:
balance: 20000
accountable_type: Account::Depository
accountable_id: "123e4567-e89b-12d3-a456-426614174001"
institution: chase
# Liability account
credit_card:
@ -29,6 +31,7 @@ credit_card:
balance: 1000
accountable_type: Account::Credit
accountable_id: "123e4567-e89b-12d3-a456-426614174003"
institution: chase
eur_checking:
family: dylan_family
@ -37,6 +40,7 @@ eur_checking:
balance: 12000
accountable_type: Account::Depository
accountable_id: "123e4567-e89b-12d3-a456-426614174004"
institution: revolut
# Multi-currency account (e.g. Wise, Revolut, etc.)
multi_currency:
@ -46,3 +50,4 @@ multi_currency:
balance: 10000
accountable_type: Account::Depository
accountable_id: "123e4567-e89b-12d3-a456-426614174005"
institution: revolut

View file

@ -0,0 +1,4 @@
chase_logo_attachment:
name: logo
record: chase (Institution)
blob: square_placeholder_blob

View file

@ -0,0 +1 @@
square_placeholder_blob: <%= ActiveStorage::FixtureSet.blob filename: "square-placeholder.png" %>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

8
test/fixtures/institutions.yml vendored Normal file
View file

@ -0,0 +1,8 @@
chase:
name: Chase
family: dylan_family
revolut:
name: Revolut
family: dylan_family
logo_url: <%= "file://" + Rails.root.join('test/fixtures/files/square-placeholder.png').to_s %>