1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-02 20:15:22 +02:00

Basic Plaid Integration (#1433)
Some checks are pending
Publish Docker image / ci (push) Waiting to run
Publish Docker image / Build docker image (push) Blocked by required conditions

* Basic plaid data model and linking

* Remove institutions, add plaid items

* Improve schema and Plaid provider

* Add webhook verification sketch

* Webhook verification

* Item accounts and balances sync setup

* Provide test encryption keys

* Fix test

* Only provide encryption keys in prod

* Try defining keys in test env

* Consolidate account sync logic

* Add back plaid account initialization

* Plaid transaction sync

* Sync UI overhaul for Plaid

* Add liability and investment syncing

* Handle investment webhooks and process current day holdings

* Remove logs

* Remove "all" period select for performance

* fix amount calc

* Remove todo comment

* Coming soon for investment historical data

* Document Plaid configuration

* Listen for holding updates
This commit is contained in:
Zach Gollwitzer 2024-11-15 13:49:37 -05:00 committed by GitHub
parent 3bc9da4105
commit cbba2ba675
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 1537 additions and 841 deletions

View file

@ -2,23 +2,25 @@
<div class="bg-white p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between mb-4">
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
<div data-controller="menu" data-testid="activity-menu">
<button class="btn btn--secondary flex items-center gap-2" data-menu-target="button">
<%= lucide_icon("plus", class: "w-4 h-4") %>
<%= tag.span t(".new") %>
</button>
<div data-menu-target="content" class="z-10 hidden bg-white rounded-lg border border-alpha-black-25 shadow-xs p-1">
<%= link_to new_account_valuation_path(@account), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("circle-dollar-sign", class: "text-gray-500 w-5 h-5") %>
<%= tag.span t(".new_balance"), class: "text-sm" %>
<% end %>
<% unless @account.plaid_account_id.present? %>
<div data-controller="menu" data-testid="activity-menu">
<button class="btn btn--secondary flex items-center gap-2" data-menu-target="button">
<%= lucide_icon("plus", class: "w-4 h-4") %>
<%= tag.span t(".new") %>
</button>
<div data-menu-target="content" class="z-10 hidden bg-white rounded-lg border border-alpha-black-25 shadow-xs p-1">
<%= link_to new_account_valuation_path(@account), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("circle-dollar-sign", class: "text-gray-500 w-5 h-5") %>
<%= tag.span t(".new_balance"), class: "text-sm" %>
<% end %>
<%= link_to @account.investment? ? new_account_trade_path(@account) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %>
<%= tag.span t(".new_transaction"), class: "text-sm" %>
<% end %>
<%= link_to @account.investment? ? new_account_trade_path(@account) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %>
<%= tag.span t(".new_transaction"), class: "text-sm" %>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<div>

View file

@ -23,11 +23,6 @@
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<% if @holdings.any? %>
<%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %>
<% elsif @account.needs_sync? || true %>
<div class="flex flex-col justify-center items-center pt-4 pb-8">
<p class="text-gray-500 p-4"><%= t(".needs_sync") %></p>
<%= button_to "Sync holding prices", sync_account_path(@account), class: "bg-gray-900 text-white text-sm rounded-lg px-3 py-2" %>
</div>
<% else %>
<p class="text-gray-500 text-sm p-4"><%= t(".no_holdings") %></p>
<% end %>

View file

@ -3,7 +3,7 @@
<% trade, account = entry.account_trade, entry.account %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="pr-10 flex items-center gap-4 col-span-6">
<div class="col-span-8 flex items-center gap-4">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
class: "maybe-checkbox maybe-checkbox--light",
@ -30,21 +30,11 @@
</div>
</div>
<div class="flex items-center justify-end gap-1 col-span-3">
<% if entry.account_transaction? && entry.marked_as_transfer? %>
<%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %>
<% elsif entry.account_transaction? %>
<%= tag.p entry.inflow? ? t(".inflow") : t(".outflow") %>
<% else %>
<%= tag.p trade.buy? ? t(".buy") : t(".sell") %>
<% end %>
<div class="col-span-2 justify-self-end font-medium text-sm">
<%= tag.span format_money(entry.amount_money) %>
</div>
<div class="col-span-3 flex items-center justify-end">
<% if entry.account_transaction? %>
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": entry.inflow? } %>
<% else %>
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
<% end %>
<div class="col-span-2 justify-self-end">
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
</div>
</div>

View file

@ -68,7 +68,11 @@
<% if show_balance %>
<div class="col-span-2 justify-self-end">
<%= tag.p format_money(entry.trend.current), class: "font-medium text-sm text-gray-900" %>
<% if entry.account.investment? %>
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
<% else %>
<%= tag.p format_money(entry.trend.current), class: "font-medium text-sm text-gray-900" %>
<% end %>
</div>
<% end %>
</div>

View file

@ -26,8 +26,8 @@
</section>
<section class="space-y-2">
<%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
<%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %>
<%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
<%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %>
<%= f.money_field :amount, label: t(".amount"), required: true, hide_currency: true %>
<%= f.date_field :date, value: transfer.date, label: t(".date"), required: true, max: Date.current %>
</section>

View file

@ -1,6 +1,6 @@
<%# locals: (accountable:) %>
<%= link_to new_polymorphic_path(accountable, institution_id: params[:institution_id], step: "method_select", return_to: params[:return_to]),
<%= link_to new_polymorphic_path(accountable, step: "method_select", return_to: params[:return_to]),
class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-alpha-black-25 hover:bg-alpha-black-25 border border-transparent block px-2 rounded-lg p-2" do %>
<span style="background-color: color-mix(in srgb, <%= accountable.color %> 10%, white);" class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-alpha-black-25">
<%= lucide_icon(accountable.icon, style: "color: #{accountable.color}", class: "w-5 h-5") %>

View file

@ -5,12 +5,6 @@
<%= form.hidden_field :accountable_type %>
<%= form.hidden_field :return_to, value: params[:return_to] %>
<% if account.new_record? %>
<%= form.hidden_field :institution_id %>
<% else %>
<%= form.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
<% end %>
<%= form.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label") %>
<%= form.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %>

View file

@ -1,4 +0,0 @@
<%= button_to sync_all_accounts_path, class: "btn btn--outline flex items-center gap-2", title: t(".sync") do %>
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
<span><%= t(".sync") %></span>
<% end %>

View file

@ -7,19 +7,14 @@
<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>
<%= button_to sync_all_accounts_path,
disabled: Current.family.syncing?,
class: "btn btn--outline flex items-center gap-2",
title: t(".sync") do %>
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
<span><%= t(".sync") %></span>
<% end %>
<%= render "sync_all_button" %>
<%= link_to new_account_path(return_to: accounts_path),
data: { turbo_frame: "modal" },
class: "btn btn--primary flex items-center gap-1" do %>
@ -30,16 +25,16 @@
</div>
</header>
<% if @accounts.empty? && @institutions.empty? %>
<% if @manual_accounts.empty? && @plaid_items.empty? %>
<%= render "empty" %>
<% else %>
<div class="space-y-2">
<% @institutions.each do |institution| %>
<%= render "accounts/index/institution_accounts", institution: %>
<% if @plaid_items.any? %>
<%= render @plaid_items.sort_by(&:created_at) %>
<% end %>
<% if @accounts.any? %>
<%= render "accounts/index/institutionless_accounts", accounts: @accounts %>
<% if @manual_accounts.any? %>
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %>
<% end %>
</div>
<% end %>

View file

@ -1,6 +1,6 @@
<%# locals: (accounts:) %>
<% accounts.group_by(&:accountable_type).each do |group, accounts| %>
<% accounts.group_by(&:accountable_type).sort_by { |group, _| group }.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>

View file

@ -1,91 +0,0 @@
<%# locals: (institution:) %>
<details open class="group bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<summary class="flex items-center justify-between gap-2 focus-visible:outline-none">
<div class="flex items-center gap-2">
<%= 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>
<div class="pl-1 text-sm">
<%= link_to institution.name, edit_institution_path(institution), data: { turbo_frame: :modal }, class: "font-medium text-gray-900 hover:underline" %>
<% if institution.has_issues? %>
<div class="flex items-center gap-1 text-error">
<%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %>
<%= tag.span t(".has_issues") %>
</div>
<% elsif institution.syncing? %>
<div class="text-gray-500 flex items-center gap-1">
<%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %>
<%= tag.span t(".syncing") %>
</div>
<% else %>
<p class="text-gray-500"><%= institution.last_synced_at ? t(".status", last_synced_at: time_ago_in_words(institution.last_synced_at)) : t(".status_never") %></p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<%= button_to sync_institution_path(institution), method: :post, class: "text-gray-900 flex hover:text-gray-800 items-center text-sm font-medium hover:underline" do %>
<%= lucide_icon "refresh-cw", class: "w-4 h-4" %>
<% end %>
<%= 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, return_to: accounts_path),
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 %>
</div>
</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, return_to: accounts_path), 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

@ -12,6 +12,6 @@
</summary>
<div class="space-y-4 mt-4">
<%= render "accountable_group", accounts: accounts %>
<%= render "accounts/index/account_groups", accounts: accounts %>
</div>
</details>

View file

@ -1,4 +1,4 @@
<%# locals: (path:) %>
<%# locals: (path:, link_token: nil) %>
<%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
<div class="text-sm">
@ -9,11 +9,13 @@
<%= t("accounts.new.method_selector.manual_entry") %>
<% end %>
<span class="flex items-center w-full gap-4 p-2 px-2 text-center border border-transparent rounded-lg cursor-not-allowed focus:outline-none focus:bg-gray-50 focus:border focus:border-gray-200 text-gray-400">
<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("link-2", class: "text-gray-500 w-5 h-5") %>
</span>
<%= t("accounts.new.method_selector.connected_entry") %>
</span>
<% if link_token.present? %>
<button data-controller="plaid" data-action="plaid#open modal#close" data-plaid-link-token-value="<%= @link_token %>" 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">
<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("link-2", class: "text-gray-500 w-5 h-5") %>
</span>
<%= t("accounts.new.method_selector.connected_entry") %>
</button>
<% end %>
</div>
<% end %>

View file

@ -20,8 +20,10 @@
<% end %>
<div class="flex items-center gap-3 ml-auto">
<%= 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-500 hover:text-gray-400" %>
<% unless account.plaid_account_id.present? %>
<%= button_to sync_account_path(account), disabled: account.syncing?, class: "flex items-center gap-2", title: "Sync Account" do %>
<%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %>
<% end %>
<% end %>
<%= render "accounts/show/menu", account: account %>

View file

@ -2,23 +2,32 @@
<%= 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),
<% if account.plaid_account_id.present? %>
<%= link_to accounts_path,
data: { turbo_frame: :_top },
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(".manage") %></span>
<% end %>
<% else %>
<%= 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" %>
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".edit") %></span>
<% end %>
<span><%= t(".edit") %></span>
<% end %>
<%= link_to new_import_path,
<%= link_to new_import_path,
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 "download", class: "w-5 h-5 text-gray-500" %>
<%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %>
<span><%= t(".import") %></span>
<% end %>
<span><%= t(".import") %></span>
<% end %>
<%= button_to account_path(account),
<%= button_to account_path(account),
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: {
@ -29,7 +38,8 @@
accept: t(".confirm_accept", name: account.name)
}
} do %>
<%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account
<%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account
<% end %>
<% end %>
</div>
<% end %>

View file

@ -1,5 +1,5 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector", path: new_credit_card_path(institution_id: params[:institution_id], return_to: params[:return_to]) %>
<%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]), link_token: @link_token %>
<% else %>
<%= modal_form_wrapper title: t(".title") do %>
<%= render "credit_cards/form", account: @account, url: credit_cards_path %>

View file

@ -1,5 +1,5 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector", path: new_crypto_path(institution_id: params[:institution_id], return_to: params[:return_to]) %>
<%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]), link_token: @link_token %>
<% else %>
<%= modal_form_wrapper title: t(".title") do %>
<%= render "cryptos/form", account: @account, url: cryptos_path %>

View file

@ -1,5 +1,5 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector", path: new_depository_path(institution_id: params[:institution_id], return_to: params[:return_to]) %>
<%= render "accounts/new/method_selector", path: new_depository_path(return_to: params[:return_to]), link_token: @link_token %>
<% else %>
<%= modal_form_wrapper title: t(".title") do %>
<%= render "depositories/form", account: @account, url: depositories_path %>

View file

@ -1,26 +0,0 @@
<%= styled_form_with model: institution, class: "space-y-4", 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

@ -1,3 +0,0 @@
<%= modal_form_wrapper title: t(".edit", institution: @institution.name) do %>
<%= render "form", institution: @institution %>
<% end %>

View file

@ -1,3 +0,0 @@
<%= modal_form_wrapper title: t(".new_institution") do %>
<%= render "form", institution: @institution %>
<% end %>

View file

@ -1 +1,25 @@
<%# locals: (account:) %>
<% period = Period.from_param(params[:period]) %>
<% series = account.series(period: period) %>
<% trend = series.trend %>
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between">
<div class="space-y-2">
<div class="flex items-center gap-1">
<%= tag.p t(".value"), class: "text-sm font-medium text-gray-500" %>
</div>
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %>
</div>
</div>
<div class="relative h-64 flex items-center justify-center">
<%= image_tag "placeholder-graph.svg", class: "w-full h-full object-cover rounded-bl-lg rounded-br-lg opacity-50" %>
<div class="absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center space-y-1">
<p class="text-gray-900 text-sm">Historical investment data coming soon.</p>
<p class="text-gray-500 text-sm">We're working to bring you the full picture.</p>
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector", path: new_investment_path(institution_id: params[:institution_id], return_to: params[:return_to]) %>
<%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]), link_token: @link_token %>
<% else %>
<%= modal_form_wrapper title: t(".title") do %>
<%= render "investments/form", account: @account, url: investments_path %>

View file

@ -4,7 +4,10 @@
<%= tag.div class: "space-y-4" do %>
<%= render "accounts/show/header", account: @account %>
<%= render "accounts/show/chart",
<% if @account.plaid_account_id.present? %>
<%= render "investments/chart", account: @account %>
<% else %>
<%= render "accounts/show/chart",
account: @account,
title: t(".chart_title"),
tooltip: render(
@ -12,6 +15,7 @@
value: @account.value,
cash: @account.balance_money
) %>
<% end %>
<div class="min-h-[800px]">
<%= render "accounts/show/tabs", account: @account, tabs: [

View file

@ -9,6 +9,7 @@
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %>
<%= combobox_style_tag %>
<%= javascript_importmap_tags %>
@ -30,9 +31,13 @@
<%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? %>
<%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %>
<div class="fixed z-50 space-y-1 top-6 right-10">
<div id="notification-tray">
<div class="fixed z-50 bottom-6 left-6">
<div id="notification-tray" class="space-y-1">
<%= render_flash_notifications %>
<% if Current.family&.syncing? %>
<%= render "shared/notification", id: "syncing-notification", type: :processing, message: t(".syncing") %>
<% end %>
</div>
</div>

View file

@ -1,5 +1,5 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector", path: new_loan_path(institution_id: params[:institution_id], return_to: params[:return_to]) %>
<%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]), link_token: @link_token %>
<% else %>
<%= modal_form_wrapper title: t(".title") do %>
<%= render "loans/form", account: @account, url: loans_path %>

View file

@ -0,0 +1,76 @@
<%# locals: (plaid_item:) %>
<%= tag.div id: dom_id(plaid_item) do %>
<details open class="group bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<summary class="flex items-center justify-between gap-2 focus-visible:outline-none">
<div class="flex items-center gap-2">
<%= 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 plaid_item.logo.attached? %>
<%= image_tag plaid_item.logo, class: "rounded-full h-full w-full" %>
<% else %>
<div class="flex items-center justify-center">
<%= tag.p plaid_item.name.first.upcase, class: "text-blue-600 text-xs font-medium" %>
</div>
<% end %>
</div>
<div class="pl-1 text-sm">
<%= tag.p plaid_item.name, class: "font-medium text-gray-900" %>
<% if plaid_item.syncing? %>
<div class="text-gray-500 flex items-center gap-1">
<%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %>
<%= tag.span t(".syncing") %>
</div>
<% elsif plaid_item.sync_error.present? %>
<div class="text-gray-500 flex items-center gap-1">
<%= lucide_icon "alert-circle", class: "w-4 h-4 text-red-500" %>
<%= tag.span t(".error"), class: "text-red-500" %>
</div>
<% else %>
<p class="text-gray-500">
<%= plaid_item.last_synced_at ? t(".status", timestamp: time_ago_in_words(plaid_item.last_synced_at)) : t(".status_never") %>
</p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<%= button_to sync_plaid_item_path(plaid_item), disabled: plaid_item.syncing?, class: "disabled:text-gray-400 text-gray-900 flex hover:text-gray-800 items-center text-sm font-medium hover:underline" do %>
<%= lucide_icon "refresh-cw", class: "w-4 h-4" %>
<% end %>
<%= 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">
<%= button_to plaid_item_path(plaid_item),
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 %>
</div>
</summary>
<div class="space-y-4 mt-4">
<% if plaid_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: plaid_item.accounts %>
<% else %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-gray-900 font-medium text-sm"><%= t(".no_accounts_title") %></p>
<p class="text-gray-500 text-sm"><%= t(".no_accounts_description") %></p>
</div>
<% end %>
</div>
</details>
<% end %>

View file

@ -4,7 +4,7 @@
<% action = "animationend->element-removal#remove" if type == :notice %>
<%= tag.div class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25",
id: id,
id: type == :processing ? "syncing-notification" : id,
data: {
controller: "element-removal",
action: action

View file

@ -12,7 +12,7 @@
<section class="space-y-2 overflow-hidden">
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
<%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
<%= f.money_field :amount, label: t(".amount"), required: true %>
<%= f.hidden_field :entryable_type, value: "Account::Transaction" %>
<%= f.fields_for :entryable do |ef| %>