Merge branch 'main' into ai

This commit is contained in:
Josh Pigford
2025-03-10 13:46:52 -05:00
147 changed files with 2155 additions and 1235 deletions

View File

@@ -1,31 +1,61 @@
---
name: Bug report
about: Create a report to help us improve
about: Open a bug report when you experience broken functionality within the latest
version of the Maybe app
title: 'Bug: [Add descriptive title here]'
labels: ''
assignees: ''
---
**Where did this bug occur? (required)**
## Before you start (required)
- [ ] I am a self-hosted user reporting a bug from my self hosted app
- [ ] I am a user of Maybe's paid app
### General checklist
_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_
- [ ] I have removed personal / sensitive data from screenshots and logs
- [ ] I have searched [existing issues](https://github.com/maybe-finance/maybe/issues?q=is:issue) and [discussions](https://github.com/maybe-finance/maybe/discussions) to ensure this is not a duplicate issue
### How are you using Maybe?
- [ ] I am a paying Maybe customer (hosted version)
- Paying Maybe users can also open requests in Intercom (if there is sensitive info involved)
- [ ] I am a self-hosted user
### Self hoster checklist
_Paying, hosted users should delete this entire section._
If you are a self-hosted user, please complete all of the information below. Issues with incomplete information will be marked as `Needs Info` to help our small team prioritize bug fixes.
- Self hosted app commit SHA (find in user menu): [enter commit sha here]
- [ ] I have confirmed that my app's commit is the latest version of Maybe
- Where are you hosting?
- [ ] Render
- [ ] Docker Compose
- [ ] Umbrel
- [ ] Other (please specify)
---
## Bug description
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
### To Reproduce
Be as specific as possible so Maybe maintainers can quickly reproduce the bug you're experiencing.
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
### Expected behavior
**Screenshots / Recordings**
If applicable, add screenshots or short video recordings to help show the bug in more detail.
What is the intended behavior that you would expect?
### Screenshots and/or recordings
We highly recommend providing additional context with screenshots and/or screen recordings. This will _significantly_ improve the chances of the bug being addressed and fixed quickly.

View File

@@ -7,15 +7,36 @@ assignees: ''
---
**PLEASE READ before opening an issue:**
## Before you start (required)
- Is this a feature request? Please [open a feature request discussion](https://github.com/maybe-finance/maybe/discussions/new?category=feature-requests).
- Do you need help or have a question? Please [open a discussion](https://github.com/maybe-finance/maybe/discussions/new/choose) or [join our Discord](https://link.maybe.co/discord) and post to the "help" channel.
### Is this a bug?
----------------------
A bug is _broken functionality_ of the app (i.e. it prevents you from using the app). For bugs, please use the ["Bug Report" template](https://github.com/maybe-finance/maybe/issues) instead.
**Is this issue related to a problem? Please describe.**
### Is this a bug with _sensitive info_?
**Describe the work that needs to be done to address this issue**
If you are a _paying_ Maybe user, you can open a support request in Intercom.
**Additional context**
### Is this a feature request?
A feature request is functionality that you would like that is not already on our [Roadmap](https://github.com/maybe-finance/maybe/wiki/Roadmap).
All feature requests should be opened as Discussions here:
https://github.com/maybe-finance/maybe/discussions/categories/feature-requests
Be sure to search existing discussions prior to opening a new feature request.
### Is this related to Docker and/or hosting for self hosting?
If you are having a Docker configuration issue, please do not open a Github issue unless you've identified a bug in our Dockerfile. To get help with self hosting, there are several options:
- **First**: Read our [self hosting guides](https://github.com/maybe-finance/maybe/tree/main/docs/hosting) and follow them step-by-step
- Open a [General Discussion](https://github.com/maybe-finance/maybe/discussions/categories/general)
- Make a post in the "Self hosted" channel in our [Discord](https://link.maybe.co/discord)
---
## Issue description
If your issue does not fall into the categories above, please provide a **descriptive and complete** overview of your issue.

View File

@@ -1,6 +1,13 @@
name: Publish Docker image
on:
workflow_dispatch:
inputs:
ref:
description: 'Git ref (tag or commit SHA) to build'
required: true
type: string
default: 'main'
push:
tags:
- 'v*'
@@ -33,6 +40,8 @@ jobs:
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -73,3 +82,4 @@ jobs:
provenance: false
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=A multi-arch Docker image for the Maybe Rails app
build-args: BUILD_COMMIT_SHA=${{ github.sha }}

View File

@@ -9,19 +9,21 @@ WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libvips postgresql-client git
apt-get install --no-install-recommends -y curl libvips postgresql-client
# Set production environment
ARG BUILD_COMMIT_SHA
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development"
BUNDLE_WITHOUT="development" \
BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA}
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get install --no-install-recommends -y build-essential libpq-dev pkg-config
RUN apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config
# Install application gems
COPY .ruby-version Gemfile Gemfile.lock ./

View File

@@ -193,7 +193,7 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.9.0)
good_job (4.9.3)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
@@ -209,7 +209,7 @@ GEM
railties (>= 7.0.0)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.14)
i18n-tasks (1.0.15)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
erubi
@@ -218,6 +218,7 @@ GEM
parser (>= 3.2.2.1)
rails-i18n
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.8, >= 1.8.1)
terminal-table (>= 1.5.1)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
@@ -248,6 +249,7 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
lint_roller (1.1.0)
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
@@ -294,28 +296,28 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.2-aarch64-linux-gnu)
nokogiri (1.18.3-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.2-aarch64-linux-musl)
nokogiri (1.18.3-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.2-arm-linux-gnu)
nokogiri (1.18.3-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.2-arm-linux-musl)
nokogiri (1.18.3-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.2-arm64-darwin)
nokogiri (1.18.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-darwin)
nokogiri (1.18.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-linux-gnu)
nokogiri (1.18.3-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-linux-musl)
nokogiri (1.18.3-x86_64-linux-musl)
racc (~> 1.4)
octokit (9.2.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.3.3)
parallel (1.26.3)
parser (3.3.7.0)
parser (3.3.7.1)
ast (~> 2.4.1)
racc
pg (1.5.9)
@@ -342,7 +344,7 @@ GEM
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.10)
rack (3.1.11)
rack-mini-profiler (3.3.1)
rack (>= 1.2.0)
rack-session (2.1.0)
@@ -396,7 +398,7 @@ GEM
logger
rdoc (6.12.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
redcarpet (3.6.1)
regexp_parser (2.10.0)
reline (0.6.0)
io-console (~> 0.5)
@@ -406,34 +408,33 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rubocop (1.71.0)
rubocop (1.73.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.0)
rubocop-ast (1.38.1)
parser (>= 3.3.1.0)
rubocop-minitest (0.36.0)
rubocop (>= 1.61, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-performance (1.23.1)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.29.1)
rubocop-performance (1.24.0)
lint_roller (~> 1.1)
rubocop (>= 1.72.1, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.30.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails-omakase (1.0.0)
rubocop
rubocop-minitest
rubocop-performance
rubocop-rails
rubocop (>= 1.72.1, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-lsp (0.23.9)
language_server-protocol (~> 3.17.0)
prism (>= 1.2, < 2.0)
@@ -477,8 +478,8 @@ GEM
sorbet-runtime (0.5.11813)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.3)
stripe (13.4.1)
stringio (3.1.5)
stripe (13.5.0)
tailwindcss-rails (4.0.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
@@ -493,9 +494,9 @@ GEM
unicode-display_width (>= 1.1.1, < 4)
thor (1.3.2)
timeout (0.4.3)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
turbo-rails (2.0.13)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.1.4)
@@ -511,7 +512,7 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.25.0)
webmock (3.25.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -522,7 +523,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.1)
zeitwerk (2.7.2)
PLATFORMS
aarch64-linux

View File

@@ -331,29 +331,13 @@
details>summary {
@apply list-none;
}
select[multiple="multiple"] {
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
}
select[multiple="multiple"] option {
@apply py-2 rounded-md;
}
select[multiple="multiple"] option:checked {
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
}
select[multiple="multiple"] option:active,
select[multiple="multiple"] option:focus {
@apply bg-white;
}
}
@layer components {
/* Buttons */
.btn {
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
@apply transition-all duration-300;
}
.btn--primary {
@@ -380,6 +364,25 @@
.form-field {
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
@apply transition-all duration-300;
/* Add styles for multiple select within form fields */
select[multiple] {
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
option {
@apply py-2 rounded-md;
}
option:checked {
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
}
option:active,
option:focus {
@apply bg-white;
}
}
}
.form-field__label {
@@ -392,6 +395,7 @@
@apply placeholder-shown:opacity-50;
@apply disabled:text-gray-400;
@apply text-ellipsis overflow-hidden whitespace-nowrap;
@apply transition-opacity duration-300;
&select {
@apply pr-8;
@@ -410,6 +414,7 @@
.checkbox {
&[type='checkbox'] {
@apply rounded-sm;
@apply transition-colors duration-300;
}
}
@@ -440,8 +445,10 @@
/* Switches */
.switch {
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full;
@apply after:transition-transform after:duration-300 after:ease-in-out;
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
@apply transition-colors duration-300;
}
/* Tooltips */

View File

@@ -9,9 +9,12 @@ class Account::HoldingsController < ApplicationController
end
def destroy
@holding.destroy_holding_and_entries!
flash[:notice] = t(".success")
if @holding.account.plaid_account_id.present?
flash[:alert] = "You cannot delete this holding"
else
@holding.destroy_holding_and_entries!
flash[:notice] = t(".success")
end
respond_to do |format|
format.html { redirect_back_or_to account_path(@holding.account) }

View File

@@ -10,7 +10,7 @@ class Account::TradesController < ApplicationController
def create_entry_params
params.require(:account_entry).permit(
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
).tap do |params|
account_id = params.delete(:account_id)
params[:account] = Current.family.accounts.find(account_id)

View File

@@ -3,7 +3,7 @@ class Account::TransferMatchesController < ApplicationController
def new
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
@transfer_match_candidates = @entry.transfer_match_candidates
@transfer_match_candidates = @entry.account_transaction.transfer_match_candidates
end
def create

View File

@@ -1,5 +1,6 @@
class AccountsController < ApplicationController
before_action :set_account, only: %i[sync chart sparkline]
include Periodable
def index
@manual_accounts = family.accounts.manual.alphabetically
@@ -17,6 +18,7 @@ class AccountsController < ApplicationController
end
def chart
@chart_view = params[:chart_view] || "balance"
render layout: "application"
end

View File

@@ -2,7 +2,7 @@ module AccountableResource
extend ActiveSupport::Concern
included do
include ScrollFocusable
include ScrollFocusable, Periodable
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
before_action :set_link_token, only: :new
@@ -23,6 +23,7 @@ module AccountableResource
end
def show
@chart_view = params[:chart_view] || "balance"
@q = params.fetch(:q, {}).permit(:search)
entries = @account.entries.search(@q).reverse_chronological

View File

@@ -28,7 +28,13 @@ module Authentication
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_token])
cookie_value = cookies.signed[:session_token]
if cookie_value.present?
Session.find_by(id: cookie_value)
else
nil
end
end
def create_session_for(user)

View File

@@ -13,6 +13,7 @@ module AutoSync
def family_needs_auto_sync?
return false unless Current.family.present?
return false unless Current.family.accounts.active.any?
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
end

View File

@@ -0,0 +1,14 @@
module Periodable
extend ActiveSupport::Concern
included do
before_action :set_period
end
private
def set_period
@period = Period.from_key(params[:period] || Current.user&.default_period)
rescue Period::InvalidKeyError
@period = Period.last_30_days
end
end

View File

@@ -35,6 +35,7 @@ class Import::ConfigurationsController < ApplicationController
:notes_col_label,
:currency_col_label,
:date_format,
:number_format,
:signage_convention
)
end

View File

@@ -4,6 +4,10 @@ class Import::ConfirmsController < ApplicationController
before_action :set_import
def show
if @import.mapping_steps.empty?
return redirect_to import_path(@import)
end
redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned?
end

View File

@@ -2,9 +2,7 @@ class Import::RowsController < ApplicationController
before_action :set_import_row
def update
@row.assign_attributes(row_params)
@row.save!(validate: false)
@row.sync_mappings
@row.update_and_sync(row_params)
redirect_to import_row_path(@row.import, @row)
end

View File

@@ -8,10 +8,11 @@ class Import::UploadsController < ApplicationController
def update
if csv_valid?(csv_str)
@import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
@import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])
@import.save!(validate: false)
redirect_to import_configuration_path(@import), notice: "CSV uploaded successfully."
redirect_to import_configuration_path(@import, template_hint: true), notice: "CSV uploaded successfully."
else
flash.now[:alert] = "Must be valid CSV with headers and at least one row of data"
@@ -29,10 +30,8 @@ class Import::UploadsController < ApplicationController
end
def csv_valid?(str)
require "csv"
begin
csv = CSV.parse(str || "", headers: true, col_sep: upload_params[:col_sep])
csv = Import.parse_csv_str(str, col_sep: upload_params[:col_sep])
return false if csv.headers.empty?
return false if csv.count == 0
true

View File

@@ -1,5 +1,5 @@
class ImportsController < ApplicationController
before_action :set_import, only: %i[show publish destroy revert]
before_action :set_import, only: %i[show publish destroy revert apply_template]
def publish
@import.publish_later
@@ -18,7 +18,12 @@ class ImportsController < ApplicationController
end
def create
import = Current.family.imports.create! import_params
account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
import = Current.family.imports.create!(
type: import_params[:type],
account: account,
date_format: Current.family.date_format,
)
redirect_to import_upload_path(import)
end
@@ -36,6 +41,15 @@ class ImportsController < ApplicationController
redirect_to imports_path, notice: "Import is reverting in the background."
end
def apply_template
if @import.suggested_template
@import.apply_template!(@import.suggested_template)
redirect_to import_configuration_path(@import), notice: "Template applied."
else
redirect_to import_configuration_path(@import), alert: "No template found, please manually configure your import."
end
end
def destroy
@import.destroy

View File

@@ -20,7 +20,10 @@ class MfaController < ApplicationController
def verify
@user = User.find_by(id: session[:mfa_user_id])
redirect_to new_session_path unless @user
if @user.nil?
redirect_to new_session_path
end
end
def verify_code

View File

@@ -1,8 +1,8 @@
class PagesController < ApplicationController
skip_before_action :authenticate_user!, only: %i[early_access]
include Periodable
def dashboard
@period = Period.from_key(params[:period], fallback: true)
@balance_sheet = Current.family.balance_sheet
@accounts = Current.family.accounts.active.with_attached_logo

View File

@@ -22,7 +22,7 @@ class PlaidItemsController < ApplicationController
end
respond_to do |format|
format.html { redirect_to accounts_path }
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end

View File

@@ -1,10 +1,7 @@
class SecuritiesController < ApplicationController
def index
query = params[:q]
return render json: [] if query.blank? || query.length < 2 || query.length > 100
@securities = Security.search({
search: query,
@securities = Security.search_provider({
search: params[:q],
country: params[:country_code] == "US" ? "US" : nil
})
end

View File

@@ -2,6 +2,7 @@ class Settings::HostingsController < ApplicationController
layout "settings"
before_action :raise_if_not_self_hosted
before_action :ensure_admin, only: :clear_cache
def show
@synth_usage = Current.family.synth_usage
@@ -38,6 +39,11 @@ class Settings::HostingsController < ApplicationController
render :show, status: :unprocessable_entity
end
def clear_cache
DataCacheClearJob.perform_later(Current.family)
redirect_to settings_hosting_path, notice: t(".cache_cleared")
end
private
def hosting_params
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
@@ -46,4 +52,8 @@ class Settings::HostingsController < ApplicationController
def raise_if_not_self_hosted
raise "Settings not available on non-self-hosted instance" unless self_hosted?
end
def ensure_admin
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin?
end
end

View File

@@ -1,4 +1,6 @@
class SubscriptionsController < ApplicationController
before_action :redirect_to_root_if_self_hosted
def new
if Current.family.stripe_customer_id.blank?
customer = stripe_client.v1.customers.create(
@@ -44,4 +46,8 @@ class SubscriptionsController < ApplicationController
def stripe_client
@stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
end
def redirect_to_root_if_self_hosted
redirect_to root_path, alert: I18n.t("subscriptions.self_hosted_alert") if self_hosted?
end
end

View File

@@ -1,5 +1,6 @@
class UsersController < ApplicationController
before_action :set_user
before_action :ensure_admin, only: :reset
def update
@user = Current.user
@@ -26,6 +27,11 @@ class UsersController < ApplicationController
end
end
def reset
FamilyResetJob.perform_later(Current.family)
redirect_to settings_profile_path, notice: t(".success")
end
def destroy
if @user.deactivate
Current.session.destroy
@@ -60,7 +66,7 @@ class UsersController < ApplicationController
def user_params
params.require(:user).permit(
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :show_ai_sidebar,
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :show_ai_sidebar
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
)
end
@@ -68,4 +74,8 @@ class UsersController < ApplicationController
def set_user
@user = Current.user
end
def ensure_admin
redirect_to settings_profile_path, alert: I18n.t("users.reset.unauthorized") unless Current.user.admin?
end
end

View File

@@ -4,7 +4,7 @@ module SettingsHelper
{ name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path },
{ name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path },
{ name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path },
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path, condition: :not_self_hosted? },
{ name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path },
{ name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path },
{ name: I18n.t("settings.settings_nav.tags_label"), path: :tags_path },
@@ -45,4 +45,9 @@ module SettingsHelper
concat(next_setting)
end
end
private
def not_self_hosted?
!self_hosted?
end
end

View File

@@ -0,0 +1,16 @@
class DataCacheClearJob < ApplicationJob
queue_as :default
def perform(family)
ActiveRecord::Base.transaction do
ExchangeRate.delete_all
Security::Price.delete_all
family.accounts.each do |account|
account.balances.delete_all
account.holdings.delete_all
end
family.sync_later
end
end
end

View File

@@ -0,0 +1,19 @@
class FamilyResetJob < ApplicationJob
queue_as :default
def perform(family)
# Delete all family data except users
ActiveRecord::Base.transaction do
# Delete accounts and related data
family.accounts.destroy_all
family.categories.destroy_all
family.tags.destroy_all
family.merchants.destroy_all
family.plaid_items.destroy_all
family.imports.destroy_all
family.budgets.destroy_all
family.sync_later
end
end
end

View File

@@ -2,7 +2,7 @@ class FetchSecurityInfoJob < ApplicationJob
queue_as :latency_low
def perform(security_id)
return unless Security.security_info_provider.present?
return unless Security.provider.present?
security = Security.find(security_id)
@@ -12,7 +12,7 @@ class FetchSecurityInfoJob < ApplicationJob
params[:mic_code] = security.exchange_mic if security.exchange_mic.present?
params[:operating_mic] = security.exchange_operating_mic if security.exchange_operating_mic.present?
security_info_response = Security.security_info_provider.fetch_security_info(**params)
security_info_response = Security.provider.fetch_security_info(**params)
security.update(
name: security_info_response.info.dig("name")

View File

@@ -1,11 +1,10 @@
class Account < ApplicationRecord
include Syncable, Monetizable, Issuable, Chartable
include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable
validates :name, :balance, :currency, presence: true
belongs_to :family
belongs_to :import, optional: true
belongs_to :plaid_account, optional: true
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
@@ -75,7 +74,16 @@ class Account < ApplicationRecord
def sync_data(start_date: nil)
update!(last_synced_at: Time.current)
Syncer.new(self, start_date: start_date).run
Rails.logger.info("Auto-matching transfers")
family.auto_match_transfers!
Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})")
sync_balances
if enrichable?
Rails.logger.info("Enriching transaction data")
enrich_data
end
end
def post_sync
@@ -93,10 +101,6 @@ class Account < ApplicationRecord
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
end
def enrich_data
DataEnricher.new(self).run
end
def update_with_sync!(attributes)
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
@@ -123,11 +127,14 @@ class Account < ApplicationRecord
end
end
def sparkline_series
cache_key = family.build_cache_key("#{id}_sparkline")
Rails.cache.fetch(cache_key) do
balance_series
end
def start_date
first_entry_date = entries.minimum(:date) || Date.current
first_entry_date - 1.day
end
private
def sync_balances
strategy = linked? ? :reverse : :forward
Balance::Syncer.new(self, strategy: strategy).sync_balances
end
end

View File

@@ -0,0 +1,35 @@
class Account::Balance::BaseCalculator
attr_reader :account
def initialize(account)
@account = account
end
def calculate
Rails.logger.tagged(self.class.name) do
calculate_balances
end
end
private
def sync_cache
@sync_cache ||= Account::Balance::SyncCache.new(account)
end
def build_balance(date, cash_balance, holdings_value)
Account::Balance.new(
account_id: account.id,
date: date,
balance: holdings_value + cash_balance,
cash_balance: cash_balance,
currency: account.currency
)
end
def calculate_next_balance(prior_balance, transactions, direction: :forward)
flows = transactions.sum(&:amount)
negated = direction == :forward ? account.asset? : account.liability?
flows *= -1 if negated
prior_balance + flows
end
end

View File

@@ -0,0 +1,28 @@
class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator
private
def calculate_balances
current_cash_balance = 0
next_cash_balance = nil
@balances = []
account.start_date.upto(Date.current).each do |date|
entries = sync_cache.get_entries(date)
holdings = sync_cache.get_holdings(date)
holdings_value = holdings.sum(&:amount)
valuation = sync_cache.get_valuation(date)
next_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :forward)
end
@balances << build_balance(date, next_cash_balance, holdings_value)
current_cash_balance = next_cash_balance
end
@balances
end
end

View File

@@ -0,0 +1,32 @@
class Account::Balance::ReverseCalculator < Account::Balance::BaseCalculator
private
def calculate_balances
current_cash_balance = account.cash_balance
previous_cash_balance = nil
@balances = []
Date.current.downto(account.start_date).map do |date|
entries = sync_cache.get_entries(date)
holdings = sync_cache.get_holdings(date)
holdings_value = holdings.sum(&:amount)
valuation = sync_cache.get_valuation(date)
previous_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :reverse)
end
if valuation.present?
@balances << build_balance(date, previous_cash_balance, holdings_value)
else
@balances << build_balance(date, current_cash_balance, holdings_value)
end
current_cash_balance = previous_cash_balance
end
@balances
end
end

View File

@@ -0,0 +1,46 @@
class Account::Balance::SyncCache
def initialize(account)
@account = account
end
def get_valuation(date)
converted_entries.find { |e| e.date == date && e.account_valuation? }
end
def get_holdings(date)
converted_holdings.select { |h| h.date == date }
end
def get_entries(date)
converted_entries.select { |e| e.date == date && (e.account_transaction? || e.account_trade?) }
end
private
attr_reader :account
def converted_entries
@converted_entries ||= account.entries.order(:date).to_a.map do |e|
converted_entry = e.dup
converted_entry.amount = converted_entry.amount_money.exchange_to(
account.currency,
date: e.date,
fallback_rate: 1
).amount
converted_entry.currency = account.currency
converted_entry
end
end
def converted_holdings
@converted_holdings ||= account.holdings.map do |h|
converted_holding = h.dup
converted_holding.amount = converted_holding.amount_money.exchange_to(
account.currency,
date: h.date,
fallback_rate: 1
).amount
converted_holding.currency = account.currency
converted_holding
end
end
end

View File

@@ -0,0 +1,69 @@
class Account::Balance::Syncer
attr_reader :account, :strategy
def initialize(account, strategy:)
@account = account
@strategy = strategy
end
def sync_balances
Account::Balance.transaction do
sync_holdings
calculate_balances
Rails.logger.info("Persisting #{@balances.size} balances")
persist_balances
purge_stale_balances
if strategy == :forward
update_account_info
end
end
end
private
def sync_holdings
@holdings = Account::Holding::Syncer.new(account, strategy: strategy).sync_holdings
end
def update_account_info
calculated_balance = @balances.sort_by(&:date).last&.balance || 0
calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0
calculated_cash_balance = calculated_balance - calculated_holdings_value
Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}")
account.update!(
balance: calculated_balance,
cash_balance: calculated_cash_balance
)
end
def calculate_balances
@balances = calculator.calculate
end
def persist_balances
current_time = Time.now
account.balances.upsert_all(
@balances.map { |b| b.attributes
.slice("date", "balance", "cash_balance", "currency")
.merge("updated_at" => current_time) },
unique_by: %i[account_id date currency]
)
end
def purge_stale_balances
deleted_count = account.balances.delete_by("date < ?", account.start_date)
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
end
def calculator
if strategy == :reverse
Account::Balance::ReverseCalculator.new(account)
else
Account::Balance::ForwardCalculator.new(account)
end
end
end

View File

@@ -1,121 +0,0 @@
class Account::BalanceCalculator
def initialize(account, holdings: nil)
@account = account
@holdings = holdings || []
end
def calculate(reverse: false, start_date: nil)
cash_balances = reverse ? reverse_cash_balances : forward_cash_balances
cash_balances.map do |balance|
holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount)
balance.balance = balance.balance + holdings_value
balance
end.compact
end
private
attr_reader :account, :holdings
def oldest_date
converted_entries.first ? converted_entries.first.date - 1.day : Date.current
end
def reverse_cash_balances
prior_balance = account.cash_balance
Date.current.downto(oldest_date).map do |date|
entries_for_date = converted_entries.select { |e| e.date == date }
holdings_for_date = converted_holdings.select { |h| h.date == date }
valuation = entries_for_date.find { |e| e.account_valuation? }
current_balance = if valuation
# To get this to a cash valuation, we back out holdings value on day
valuation.amount - holdings_for_date.sum(&:amount)
else
transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
calculate_balance(prior_balance, transactions)
end
balance_record = Account::Balance.new(
account: account,
date: date,
balance: valuation ? current_balance : prior_balance,
cash_balance: valuation ? current_balance : prior_balance,
currency: account.currency
)
prior_balance = current_balance
balance_record
end
end
def forward_cash_balances
prior_balance = 0
current_balance = nil
oldest_date.upto(Date.current).map do |date|
entries_for_date = converted_entries.select { |e| e.date == date }
holdings_for_date = converted_holdings.select { |h| h.date == date }
valuation = entries_for_date.find { |e| e.account_valuation? }
current_balance = if valuation
# To get this to a cash valuation, we back out holdings value on day
valuation.amount - holdings_for_date.sum(&:amount)
else
transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
calculate_balance(prior_balance, transactions, inverse: true)
end
balance_record = Account::Balance.new(
account: account,
date: date,
balance: current_balance,
cash_balance: current_balance,
currency: account.currency
)
prior_balance = current_balance
balance_record
end
end
def converted_entries
@converted_entries ||= @account.entries.order(:date).to_a.map do |e|
converted_entry = e.dup
converted_entry.amount = converted_entry.amount_money.exchange_to(
account.currency,
date: e.date,
fallback_rate: 1
).amount
converted_entry.currency = account.currency
converted_entry
end
end
def converted_holdings
@converted_holdings ||= holdings.map do |h|
converted_holding = h.dup
converted_holding.amount = converted_holding.amount_money.exchange_to(
account.currency,
date: h.date,
fallback_rate: 1
).amount
converted_holding.currency = account.currency
converted_holding
end
end
def calculate_balance(prior_balance, transactions, inverse: false)
flows = transactions.sum(&:amount)
negated = inverse ? account.asset? : account.liability?
flows *= -1 if negated
prior_balance + flows
end
end

View File

@@ -2,7 +2,9 @@ module Account::Chartable
extend ActiveSupport::Concern
class_methods do
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up")
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance)
raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
balances = Account::Balance.find_by_sql([
balance_series_query,
{
@@ -14,14 +16,15 @@ module Account::Chartable
])
balances = gapfill_balances(balances)
balances = invert_balances(balances) if favorable_direction == "down"
values = [ nil, *balances ].each_cons(2).map do |prev, curr|
Series::Value.new(
date: curr.date,
date_formatted: I18n.l(curr.date, format: :long),
trend: Trend.new(
current: Money.new(curr.balance, currency),
previous: prev.nil? ? nil : Money.new(prev.balance, currency),
current: Money.new(balance_value_for(curr, view), currency),
previous: prev.nil? ? nil : Money.new(balance_value_for(prev, view), currency),
favorable_direction: favorable_direction
)
)
@@ -32,8 +35,8 @@ module Account::Chartable
end_date: period.end_date,
interval: period.interval,
trend: Trend.new(
current: Money.new(balances.last&.balance || 0, currency),
previous: Money.new(balances.first&.balance || 0, currency),
current: Money.new(balance_value_for(balances.last, view) || 0, currency),
previous: Money.new(balance_value_for(balances.first, view) || 0, currency),
favorable_direction: favorable_direction
),
values: values
@@ -51,6 +54,8 @@ module Account::Chartable
SELECT
d.date,
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance ELSE -ab.balance END * COALESCE(er.rate, 1)) as balance,
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.cash_balance ELSE -ab.cash_balance END * COALESCE(er.rate, 1)) as cash_balance,
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance - ab.cash_balance ELSE 0 END * COALESCE(er.rate, 1)) as holdings_balance,
COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates
FROM dates d
LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql})
@@ -69,19 +74,46 @@ module Account::Chartable
SQL
end
def balance_value_for(balance_record, view)
return 0 if balance_record.nil?
case view.to_sym
when :balance then balance_record.balance
when :cash_balance then balance_record.cash_balance
when :holdings_balance then balance_record.holdings_balance
else
raise ArgumentError, "Invalid view type: #{view}"
end
end
def invert_balances(balances)
balances.map do |balance|
balance.balance = -balance.balance
balance.cash_balance = -balance.cash_balance
balance.holdings_balance = -balance.holdings_balance
balance
end
end
def gapfill_balances(balances)
gapfilled = []
prev = nil
prev_balance = nil
[ nil, *balances ].each_cons(2).each_with_index do |(prev, curr), index|
if index == 0 && curr.balance.nil?
curr.balance = 0 # Ensure all series start with a non-nil balance
elsif curr.balance.nil?
curr.balance = prev.balance
balances.each do |curr|
if prev.nil?
# Initialize first record with zeros if nil
curr.balance ||= 0
curr.cash_balance ||= 0
curr.holdings_balance ||= 0
else
# Copy previous values for nil fields
curr.balance ||= prev.balance
curr.cash_balance ||= prev.cash_balance
curr.holdings_balance ||= prev.holdings_balance
end
gapfilled << curr
prev = curr
end
gapfilled
@@ -92,11 +124,20 @@ module Account::Chartable
classification == "asset" ? "up" : "down"
end
def balance_series(period: Period.last_30_days)
def balance_series(period: Period.last_30_days, view: :balance)
self.class.where(id: self.id).balance_series(
currency: currency,
period: period,
view: view,
favorable_direction: favorable_direction
)
end
def sparkline_series
cache_key = family.build_cache_key("#{id}_sparkline")
Rails.cache.fetch(cache_key) do
balance_series
end
end
end

View File

@@ -1,6 +1,4 @@
class Account::DataEnricher
include Providable
attr_reader :account
def initialize(account)
@@ -37,7 +35,7 @@ class Account::DataEnricher
candidates.each do |entry|
begin
info = self.class.synth_provider.enrich_transaction(entry.name).info
info = entry.fetch_enrichment_info
next unless info.present?

View File

@@ -0,0 +1,12 @@
module Account::Enrichable
extend ActiveSupport::Concern
def enrich_data
DataEnricher.new(self).run
end
private
def enrichable?
family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?)
end
end

View File

@@ -1,5 +1,5 @@
class Account::Entry < ApplicationRecord
include Monetizable
include Monetizable, Provided
monetize :amount

View File

@@ -0,0 +1,11 @@
module Account::Entry::Provided
extend ActiveSupport::Concern
include Synthable
def fetch_enrichment_info
return nil unless synth_client.present?
synth_client.enrich_transaction(name).info
end
end

View File

@@ -1,5 +1,5 @@
class Account::Holding < ApplicationRecord
include Monetizable
include Monetizable, Gapfillable
monetize :amount

View File

@@ -0,0 +1,63 @@
class Account::Holding::BaseCalculator
attr_reader :account
def initialize(account)
@account = account
end
def calculate
Rails.logger.tagged(self.class.name) do
holdings = calculate_holdings
Account::Holding.gapfill(holdings)
end
end
private
def portfolio_cache
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
end
def empty_portfolio
securities = portfolio_cache.get_securities
securities.each_with_object({}) { |security, hash| hash[security.id] = 0 }
end
def generate_starting_portfolio
empty_portfolio
end
def transform_portfolio(previous_portfolio, trade_entries, direction: :forward)
new_quantities = previous_portfolio.dup
trade_entries.each do |trade_entry|
trade = trade_entry.entryable
security_id = trade.security_id
qty_change = trade.qty
qty_change = qty_change * -1 if direction == :reverse
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
end
new_quantities
end
def build_holdings(portfolio, date)
portfolio.map do |security_id, qty|
price = portfolio_cache.get_price(security_id, date)
if price.nil?
Rails.logger.warn "No price found for security #{security_id} on #{date}"
next
end
Account::Holding.new(
account_id: account.id,
security_id: security_id,
date: date,
qty: qty,
price: price.price,
currency: price.currency,
amount: qty * price.price
)
end.compact
end
end

View File

@@ -0,0 +1,21 @@
class Account::Holding::ForwardCalculator < Account::Holding::BaseCalculator
private
def portfolio_cache
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
end
def calculate_holdings
current_portfolio = generate_starting_portfolio
next_portfolio = {}
holdings = []
account.start_date.upto(Date.current).each do |date|
trades = portfolio_cache.get_trades(date: date)
next_portfolio = transform_portfolio(current_portfolio, trades, direction: :forward)
holdings += build_holdings(next_portfolio, date)
current_portfolio = next_portfolio
end
holdings
end
end

View File

@@ -0,0 +1,38 @@
module Account::Holding::Gapfillable
extend ActiveSupport::Concern
class_methods do
def gapfill(holdings)
filled_holdings = []
holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
next if security_holdings.empty?
sorted = security_holdings.sort_by(&:date)
previous_holding = sorted.first
sorted.first.date.upto(Date.current) do |date|
holding = security_holdings.find { |h| h.date == date }
if holding
filled_holdings << holding
previous_holding = holding
else
# Create a new holding based on the previous day's data
filled_holdings << Account::Holding.new(
account: previous_holding.account,
security: previous_holding.security,
date: date,
qty: previous_holding.qty,
price: previous_holding.price,
currency: previous_holding.currency,
amount: previous_holding.amount
)
end
end
end
filled_holdings
end
end
end

View File

@@ -0,0 +1,132 @@
class Account::Holding::PortfolioCache
attr_reader :account, :use_holdings
class SecurityNotFound < StandardError
def initialize(security_id, account_id)
super("Security id=#{security_id} not found in portfolio cache for account #{account_id}. This should not happen unless securities were preloaded incorrectly.")
end
end
def initialize(account, use_holdings: false)
@account = account
@use_holdings = use_holdings
load_prices
end
def get_trades(date: nil)
if date.blank?
trades
else
trades.select { |t| t.date == date }
end
end
def get_price(security_id, date)
security = @security_cache[security_id]
raise SecurityNotFound.new(security_id, account.id) unless security
price = security[:prices].select { |p| p.price.date == date }.min_by(&:priority)&.price
return nil unless price
price_money = Money.new(price.price, price.currency)
converted_amount = price_money.exchange_to(account.currency, fallback_rate: 1).amount
Security::Price.new(
security_id: security_id,
date: price.date,
price: converted_amount,
currency: account.currency
)
end
def get_securities
@security_cache.map { |_, v| v[:security] }
end
private
PriceWithPriority = Data.define(:price, :priority)
def trades
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
end
def holdings
@holdings ||= account.holdings.chronological.to_a
end
def collect_unique_securities
unique_securities_from_trades = trades.map(&:entryable).map(&:security).uniq
return unique_securities_from_trades unless use_holdings
unique_securities_from_holdings = holdings.map(&:security).uniq
(unique_securities_from_trades + unique_securities_from_holdings).uniq
end
# Loads all known prices for all securities in the account with priority based on source:
# 1 - DB or provider prices
# 2 - Trade prices
# 3 - Holding prices
def load_prices
@security_cache = {}
securities = collect_unique_securities
Rails.logger.info "Preloading #{securities.size} securities for account #{account.id}"
securities.each do |security|
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
# Highest priority prices
db_or_provider_prices = Security::Price.find_prices(
security: security,
start_date: account.start_date,
end_date: Date.current
).map do |price|
PriceWithPriority.new(
price: price,
priority: 1
)
end
# Medium priority prices from trades
trade_prices = trades
.select { |t| t.entryable.security_id == security.id }
.map do |trade|
PriceWithPriority.new(
price: Security::Price.new(
security: security,
price: trade.entryable.price,
currency: trade.entryable.currency,
date: trade.date
),
priority: 2
)
end
# Low priority prices from holdings (if applicable)
holding_prices = if use_holdings
holdings.select { |h| h.security_id == security.id }.map do |holding|
PriceWithPriority.new(
price: Security::Price.new(
security: security,
price: holding.price,
currency: holding.currency,
date: holding.date
),
priority: 3
)
end
else
[]
end
@security_cache[security.id] = {
security: security,
prices: db_or_provider_prices + trade_prices + holding_prices
}
end
end
end

View File

@@ -0,0 +1,38 @@
class Account::Holding::ReverseCalculator < Account::Holding::BaseCalculator
private
# Reverse calculators will use the existing holdings as a source of security ids and prices
# since it is common for a provider to supply "current day" holdings but not all the historical
# trades that make up those holdings.
def portfolio_cache
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account, use_holdings: true)
end
def calculate_holdings
current_portfolio = generate_starting_portfolio
previous_portfolio = {}
holdings = []
Date.current.downto(account.start_date).each do |date|
today_trades = portfolio_cache.get_trades(date: date)
previous_portfolio = transform_portfolio(current_portfolio, today_trades, direction: :reverse)
holdings += build_holdings(current_portfolio, date)
current_portfolio = previous_portfolio
end
holdings
end
# Since this is a reverse sync, we start with today's holdings
def generate_starting_portfolio
holding_quantities = empty_portfolio
todays_holdings = account.holdings.where(date: Date.current)
todays_holdings.each do |holding|
holding_quantities[holding.security_id] = holding.qty
end
holding_quantities
end
end

View File

@@ -0,0 +1,58 @@
class Account::Holding::Syncer
def initialize(account, strategy:)
@account = account
@strategy = strategy
end
def sync_holdings
calculate_holdings
Rails.logger.info("Persisting #{@holdings.size} holdings")
persist_holdings
if strategy == :forward
purge_stale_holdings
end
@holdings
end
private
attr_reader :account, :strategy
def calculate_holdings
@holdings = calculator.calculate
end
def persist_holdings
current_time = Time.now
account.holdings.upsert_all(
@holdings.map { |h| h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.merge("account_id" => account.id, "updated_at" => current_time) },
unique_by: %i[account_id security_id date currency]
)
end
def purge_stale_holdings
portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq
# If there are no securities in the portfolio, delete all holdings
if portfolio_security_ids.empty?
Rails.logger.info("Clearing all holdings (no securities)")
account.holdings.delete_all
else
deleted_count = account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account.start_date, portfolio_security_ids)
Rails.logger.info("Purged #{deleted_count} stale holdings") if deleted_count > 0
end
end
def calculator
if strategy == :reverse
Account::Holding::ReverseCalculator.new(account)
else
Account::Holding::ForwardCalculator.new(account)
end
end
end

View File

@@ -1,183 +0,0 @@
class Account::HoldingCalculator
def initialize(account)
@account = account
@securities_cache = {}
end
def calculate(reverse: false)
preload_securities
calculated_holdings = reverse ? reverse_holdings : forward_holdings
gapfill_holdings(calculated_holdings)
end
private
attr_reader :account, :securities_cache
def reverse_holdings
current_holding_quantities = load_current_holding_quantities
prior_holding_quantities = {}
holdings = []
Date.current.downto(portfolio_start_date).map do |date|
today_trades = trades.select { |t| t.date == date }
prior_holding_quantities = calculate_portfolio(current_holding_quantities, today_trades)
holdings += generate_holding_records(current_holding_quantities, date)
current_holding_quantities = prior_holding_quantities
end
holdings
end
def forward_holdings
prior_holding_quantities = load_empty_holding_quantities
current_holding_quantities = {}
holdings = []
portfolio_start_date.upto(Date.current).map do |date|
today_trades = trades.select { |t| t.date == date }
current_holding_quantities = calculate_portfolio(prior_holding_quantities, today_trades, inverse: true)
holdings += generate_holding_records(current_holding_quantities, date)
prior_holding_quantities = current_holding_quantities
end
holdings
end
def generate_holding_records(portfolio, date)
Rails.logger.info "[HoldingCalculator] Generating holdings for #{portfolio.size} securities on #{date}"
portfolio.map do |security_id, qty|
security = securities_cache[security_id]
if security.blank?
Rails.logger.error "[HoldingCalculator] Security #{security_id} not found in cache for account #{account.id}"
next
end
price = security.dig(:prices)&.find { |p| p.date == date }
if price.blank?
Rails.logger.info "[HoldingCalculator] No price found for security #{security_id} on #{date}"
next
end
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
account.holdings.build(
security: security.dig(:security),
date: date,
qty: qty,
price: converted_price,
currency: account.currency,
amount: qty * converted_price
)
end.compact
end
def gapfill_holdings(holdings)
filled_holdings = []
holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
next if security_holdings.empty?
sorted = security_holdings.sort_by(&:date)
previous_holding = sorted.first
sorted.first.date.upto(Date.current) do |date|
holding = security_holdings.find { |h| h.date == date }
if holding
filled_holdings << holding
previous_holding = holding
else
# Create a new holding based on the previous day's data
filled_holdings << account.holdings.build(
security: previous_holding.security,
date: date,
qty: previous_holding.qty,
price: previous_holding.price,
currency: previous_holding.currency,
amount: previous_holding.amount
)
end
end
end
filled_holdings
end
def trades
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
end
def portfolio_start_date
trades.first ? trades.first.date - 1.day : Date.current
end
def preload_securities
# Get securities from trades and current holdings
securities = trades.map(&:entryable).map(&:security).uniq
securities += account.holdings.where(date: Date.current).map(&:security)
securities.uniq!
Rails.logger.info "[HoldingCalculator] Preloading #{securities.size} securities for account #{account.id}"
securities.each do |security|
begin
Rails.logger.info "[HoldingCalculator] Loading security: ID=#{security.id} Ticker=#{security.ticker}"
prices = Security::Price.find_prices(
security: security,
start_date: portfolio_start_date,
end_date: Date.current
)
Rails.logger.info "[HoldingCalculator] Found #{prices.size} prices for security #{security.id}"
@securities_cache[security.id] = {
security: security,
prices: prices
}
rescue => e
Rails.logger.error "[HoldingCalculator] Error processing security #{security.id}: #{e.message}"
Rails.logger.error "[HoldingCalculator] Security details: #{security.attributes}"
Rails.logger.error e.backtrace.join("\n")
next # Skip this security and continue with others
end
end
end
def calculate_portfolio(holding_quantities, today_trades, inverse: false)
new_quantities = holding_quantities.dup
today_trades.each do |trade|
security_id = trade.entryable.security_id
qty_change = inverse ? trade.entryable.qty : -trade.entryable.qty
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
end
new_quantities
end
def load_empty_holding_quantities
holding_quantities = {}
trades.map { |t| t.entryable.security_id }.uniq.each do |security_id|
holding_quantities[security_id] = 0
end
holding_quantities
end
def load_current_holding_quantities
holding_quantities = load_empty_holding_quantities
account.holdings.where(date: Date.current, currency: account.currency).map do |holding|
holding_quantities[holding.security_id] = holding.qty
end
holding_quantities
end
end

View File

@@ -0,0 +1,18 @@
module Account::Linkable
extend ActiveSupport::Concern
included do
belongs_to :plaid_account, optional: true
end
# A "linked" account gets transaction and balance data from a third party like Plaid
def linked?
plaid_account_id.present?
end
# An "offline" or "unlinked" account is one where the user tracks values and
# adds transactions manually, without the help of a data provider
def unlinked?
!linked?
end
end

View File

@@ -1,134 +0,0 @@
class Account::Syncer
def initialize(account, start_date: nil)
@account = account
@start_date = start_date
end
def run
account.family.auto_match_transfers!
holdings = sync_holdings
balances = sync_balances(holdings)
account.reload
update_account_info(balances, holdings) unless account.plaid_account_id.present?
convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency
# Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app
if account.family.data_enrichment_enabled? || (account.plaid_account_id.present? && Rails.application.config.app_mode.hosted?)
account.enrich_data
else
Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}")
end
end
private
attr_reader :account, :start_date
def account_start_date
@account_start_date ||= (account.entries.chronological.first&.date || Date.current) - 1.day
end
def update_account_info(balances, holdings)
new_balance = balances.sort_by(&:date).last.balance
new_holdings_value = holdings.select { |h| h.date == Date.current }.sum(&:amount)
new_cash_balance = new_balance - new_holdings_value
account.update!(
balance: new_balance,
cash_balance: new_cash_balance
)
end
def sync_holdings
calculator = Account::HoldingCalculator.new(account)
calculated_holdings = calculator.calculate(reverse: account.plaid_account_id.present?)
current_time = Time.now
Account.transaction do
load_holdings(calculated_holdings)
# Purge outdated holdings
account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id))
end
calculated_holdings
end
def sync_balances(holdings)
calculator = Account::BalanceCalculator.new(account, holdings: holdings)
calculated_balances = calculator.calculate(reverse: account.plaid_account_id.present?, start_date: start_date)
Account.transaction do
load_balances(calculated_balances)
# Purge outdated balances
account.balances.delete_by("date < ?", account_start_date)
end
calculated_balances
end
def convert_records_to_family_currency(balances, holdings)
from_currency = account.currency
to_currency = account.family.currency
exchange_rates = ExchangeRate.find_rates(
from: from_currency,
to: to_currency,
start_date: balances.min_by(&:date).date
)
converted_balances = balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
next unless exchange_rate.present?
account.balances.build(
date: balance.date,
balance: exchange_rate.rate * balance.balance,
currency: to_currency
)
end.compact
converted_holdings = holdings.map do |holding|
exchange_rate = exchange_rates.find { |er| er.date == holding.date }
next unless exchange_rate.present?
account.holdings.build(
security: holding.security,
date: holding.date,
qty: holding.qty,
price: exchange_rate.rate * holding.price,
amount: exchange_rate.rate * holding.amount,
currency: to_currency
)
end.compact
Account.transaction do
load_balances(converted_balances)
load_holdings(converted_holdings)
end
end
def load_balances(balances = [])
current_time = Time.now
account.balances.upsert_all(
balances.map { |b| b.attributes
.slice("date", "balance", "cash_balance", "currency")
.merge("updated_at" => current_time) },
unique_by: %i[account_id date currency]
)
end
def load_holdings(holdings = [])
current_time = Time.now
account.holdings.upsert_all(
holdings.map { |h| h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.merge("updated_at" => current_time) },
unique_by: %i[account_id security_id date currency]
)
end
end

View File

@@ -2,7 +2,7 @@ class Account::TradeBuilder
include ActiveModel::Model
attr_accessor :account, :date, :amount, :currency, :qty,
:price, :ticker, :type, :transfer_account_id
:price, :ticker, :manual_ticker, :type, :transfer_account_id
attr_reader :buildable
@@ -110,8 +110,9 @@ class Account::TradeBuilder
account.family
end
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
def security
ticker_symbol, exchange_operating_mic = ticker.split("|")
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
Security.find_or_create_by(ticker: ticker_symbol, exchange_operating_mic: exchange_operating_mic) do |s|
FetchSecurityInfoJob.perform_later(s.id)

View File

@@ -54,7 +54,7 @@ class Budget < ApplicationRecord
end
def period
Period.new(start_date: start_date, end_date: end_date)
Period.custom(start_date: start_date, end_date: end_date)
end
def to_param

View File

@@ -1,35 +0,0 @@
# `Providable` serves as an extension point for integrating multiple providers.
# For an example of a multi-provider, multi-concept implementation,
# see: https://github.com/maybe-finance/maybe/pull/561
module Providable
extend ActiveSupport::Concern
class_methods do
def security_prices_provider
synth_provider
end
def security_info_provider
synth_provider
end
def exchange_rates_provider
synth_provider
end
def git_repository_provider
Provider::Github.new
end
def synth_provider
api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"]
api_key.present? ? Provider::Synth.new(api_key) : nil
end
private
def self_hosted?
Rails.application.config.app_mode.self_hosted?
end
end
end

View File

@@ -0,0 +1,37 @@
module Synthable
extend ActiveSupport::Concern
class_methods do
def synth_usage
synth_client&.usage
end
def synth_overage?
synth_usage&.usage&.utilization.to_i >= 100
end
def synth_healthy?
synth_client&.healthy?
end
def synth_client
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
return nil unless api_key.present?
Provider::Synth.new(api_key)
end
end
def synth_client
self.class.synth_client
end
def synth_usage
self.class.synth_usage
end
def synth_overage?
self.class.synth_overage?
end
end

View File

@@ -1,19 +1,18 @@
module ExchangeRate::Provided
extend ActiveSupport::Concern
include Providable
include Synthable
class_methods do
def provider_healthy?
exchange_rates_provider.present? && exchange_rates_provider.healthy?
def provider
synth_client
end
private
def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false)
return [] unless exchange_rates_provider.present?
return [] unless provider.present?
response = exchange_rates_provider.fetch_exchange_rates \
response = provider.fetch_exchange_rates \
from: from,
to: to,
start_date: start_date,
@@ -38,9 +37,9 @@ module ExchangeRate::Provided
end
def fetch_rate_from_provider(from:, to:, date:, cache: false)
return nil unless exchange_rates_provider.present?
return nil unless provider.present?
response = exchange_rates_provider.fetch_exchange_rate \
response = provider.fetch_exchange_rate \
from: from,
to: to,
date: date

View File

@@ -1,5 +1,5 @@
class Family < ApplicationRecord
include Providable, Plaidable, Syncable, AutoTransferMatchable
include Synthable, Plaidable, Syncable, AutoTransferMatchable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
@@ -92,22 +92,25 @@ class Family < ApplicationRecord
).link_token
end
def synth_usage
self.class.synth_provider&.usage
end
def synth_overage?
self.class.synth_provider&.usage&.utilization.to_i >= 100
end
def synth_valid?
self.class.synth_provider&.healthy?
end
def subscribed?
stripe_subscription_status == "active"
end
def requires_data_provider?
# If family has any trades, they need a provider for historical prices
return true if trades.any?
# If family has any accounts not denominated in the family's currency, they need a provider for historical exchange rates
return true if accounts.where.not(currency: self.currency).any?
# If family has any entries in different currencies, they need a provider for historical exchange rates
uniq_currencies = entries.pluck(:currency).uniq
return true if uniq_currencies.count > 1
return true if uniq_currencies.count > 0 && uniq_currencies.first != self.currency
false
end
def primary_user
users.order(:created_at).first
end

View File

@@ -1,6 +1,7 @@
class Import < ApplicationRecord
TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze
SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative]
SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze
NUMBER_FORMATS = {
"1,234.56" => { separator: ".", delimiter: "," }, # US/UK/Asia
@@ -10,6 +11,7 @@ class Import < ApplicationRecord
}.freeze
belongs_to :family
belongs_to :account, optional: true
before_validation :set_default_number_format
@@ -25,7 +27,7 @@ class Import < ApplicationRecord
}, validate: true, default: "pending"
validates :type, inclusion: { in: TYPES }
validates :col_sep, inclusion: { in: [ ",", ";" ] }
validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) }
validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }
validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys }
@@ -34,6 +36,18 @@ class Import < ApplicationRecord
has_many :accounts, dependent: :destroy
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
class << self
def parse_csv_str(csv_str, col_sep: ",")
CSV.parse(
(csv_str || "").strip,
headers: true,
col_sep: col_sep,
converters: [ ->(str) { str&.strip } ],
liberal_parsing: true
)
end
end
def publish_later
raise "Import is not publishable" unless publishable?
@@ -86,12 +100,17 @@ class Import < ApplicationRecord
end
def dry_run
{
mappings = {
transactions: rows.count,
accounts: Import::AccountMapping.for_import(self).creational.count,
categories: Import::CategoryMapping.for_import(self).creational.count,
tags: Import::TagMapping.for_import(self).creational.count
}
mappings.merge(
accounts: Import::AccountMapping.for_import(self).creational.count,
) if account.nil?
mappings
end
def required_column_keys
@@ -127,8 +146,20 @@ class Import < ApplicationRecord
end
def sync_mappings
mapping_steps.each do |mapping|
mapping.sync(self)
transaction do
mapping_steps.each do |mapping_class|
mappables_by_key = mapping_class.mappables_by_key(self)
updated_mappings = mappables_by_key.map do |key, mappable|
mapping = mappings.find_or_initialize_by(key: key, import: self, type: mapping_class.name)
mapping.mappable = mappable
mapping.create_when_empty = key.present? && mappable.nil?
mapping
end
updated_mappings.each { |m| m.save(validate: false) }
mapping_class.where.not(id: updated_mappings.map(&:id)).destroy_all
end
end
end
@@ -164,6 +195,28 @@ class Import < ApplicationRecord
family.accounts.empty? && has_unassigned_account?
end
# Used to optionally pre-fill the configuration for the current import
def suggested_template
family.imports
.complete
.where(account: account, type: type)
.order(created_at: :desc)
.first
end
def apply_template!(import_template)
update!(
import_template.attributes.slice(
"date_col_label", "amount_col_label", "name_col_label",
"category_col_label", "tags_col_label", "account_col_label",
"qty_col_label", "ticker_col_label", "price_col_label",
"entity_type_col_label", "notes_col_label", "currency_col_label",
"date_format", "signage_convention", "number_format",
"exchange_operating_mic_col_label"
)
)
end
private
def import!
# no-op, subclasses can implement for customization of algorithm
@@ -178,12 +231,7 @@ class Import < ApplicationRecord
end
def parsed_csv
@parsed_csv ||= CSV.parse(
(raw_file_str || "").strip,
headers: true,
col_sep: col_sep,
converters: [ ->(str) { str&.strip } ]
)
@parsed_csv ||= self.class.parse_csv_str(raw_file_str, col_sep: col_sep)
end
def sanitize_number(value)

View File

@@ -1,9 +1,12 @@
class Import::AccountMapping < Import::Mapping
validates :mappable, presence: true, if: -> { key.blank? || !create_when_empty }
validates :mappable, presence: true, if: :requires_mapping?
class << self
def mapping_values(import)
import.rows.map(&:account).uniq
def mappables_by_key(import)
unique_values = import.rows.map(&:account).uniq
accounts = import.family.accounts.where(name: unique_values).index_by(&:name)
unique_values.index_with { |value| accounts[value] }
end
end
@@ -42,4 +45,9 @@ class Import::AccountMapping < Import::Mapping
self.mappable = account
save!
end
private
def requires_mapping?
(key.blank? || !create_when_empty) && import.account.nil?
end
end

View File

@@ -2,8 +2,8 @@ class Import::AccountTypeMapping < Import::Mapping
validates :value, presence: true
class << self
def mapping_values(import)
import.rows.map(&:entity_type).uniq
def mappables_by_key(import)
import.rows.map(&:entity_type).uniq.index_with { nil }
end
end

View File

@@ -1,7 +1,10 @@
class Import::CategoryMapping < Import::Mapping
class << self
def mapping_values(import)
import.rows.map(&:category).uniq
def mappables_by_key(import)
unique_values = import.rows.map(&:category).uniq
categories = import.family.categories.where(name: unique_values).index_by(&:name)
unique_values.index_with { |value| categories[value] }
end
end

View File

@@ -18,19 +18,8 @@ class Import::Mapping < ApplicationRecord
find_by(key: key)&.mappable
end
def sync(import)
unique_values = mapping_values(import).uniq
unique_values.each do |value|
mapping = find_or_initialize_by(key: value, import: import, create_when_empty: value.present?)
mapping.save(validate: false) if mapping.new_record?
end
where(import: import).where.not(key: unique_values).destroy_all
end
def mapping_values(import)
raise NotImplementedError, "Subclass must implement mapping_values"
def mappables_by_key(import)
raise NotImplementedError, "Subclass must implement mappables_by_key"
end
end

View File

@@ -30,11 +30,10 @@ class Import::Row < ApplicationRecord
end
end
def sync_mappings
Import::CategoryMapping.sync(import) if import.column_keys.include?(:category)
Import::TagMapping.sync(import) if import.column_keys.include?(:tags)
Import::AccountMapping.sync(import) if import.column_keys.include?(:account)
Import::AccountTypeMapping.sync(import) if import.column_keys.include?(:entity_type)
def update_and_sync(params)
assign_attributes(params)
save!(validate: false)
import.sync_mappings
end
private

View File

@@ -1,7 +1,11 @@
class Import::TagMapping < Import::Mapping
class << self
def mapping_values(import)
import.rows.map(&:tags_list).flatten.uniq
def mappables_by_key(import)
unique_values = import.rows.map(&:tags_list).flatten.uniq
tags = import.family.tags.where(name: unique_values).index_by(&:name)
unique_values.index_with { |value| tags[value] }
end
end

View File

@@ -1,9 +1,12 @@
class Period
include ActiveModel::Validations, Comparable
attr_reader :start_date, :end_date
class InvalidKeyError < StandardError; end
validates :start_date, :end_date, presence: true
attr_reader :key, :start_date, :end_date
validates :start_date, :end_date, presence: true, if: -> { PERIODS[key].nil? }
validates :key, presence: true, if: -> { start_date.nil? || end_date.nil? }
validate :must_be_valid_date_range
PERIODS = {
@@ -64,18 +67,18 @@ class Period
}
class << self
def default
from_key("last_30_days")
def from_key(key)
unless PERIODS.key?(key)
raise InvalidKeyError, "Invalid period key: #{key}"
end
start_date, end_date = PERIODS[key].fetch(:date_range)
new(key: key, start_date: start_date, end_date: end_date)
end
def from_key(key, fallback: false)
if PERIODS[key].present?
start_date, end_date = PERIODS[key].fetch(:date_range)
new(start_date: start_date, end_date: end_date)
else
return default if fallback
raise ArgumentError, "Invalid period key: #{key}"
end
def custom(start_date:, end_date:)
new(start_date: start_date, end_date: end_date)
end
def all
@@ -85,12 +88,12 @@ class Period
PERIODS.each do |key, period|
define_singleton_method(key) do
start_date, end_date = period.fetch(:date_range)
new(start_date: start_date, end_date: end_date)
from_key(key)
end
end
def initialize(start_date:, end_date:, date_format: "%b %d, %Y")
def initialize(start_date: nil, end_date: nil, key: nil, date_format: "%b %d, %Y")
@key = key
@start_date = start_date
@end_date = end_date
@date_format = date_format
@@ -114,44 +117,40 @@ class Period
end
def interval
if days > 90
"1 month"
if days > 366
"1 week"
else
"1 day"
end
end
def key
PERIODS.find { |_, period| period.fetch(:date_range) == [ start_date, end_date ] }&.first
end
def label
if known?
PERIODS[key].fetch(:label)
if key_metadata
key_metadata.fetch(:label)
else
"Custom Period"
end
end
def label_short
if known?
PERIODS[key].fetch(:label_short)
if key_metadata
key_metadata.fetch(:label_short)
else
"CP"
"Custom"
end
end
def comparison_label
if known?
PERIODS[key].fetch(:comparison_label)
if key_metadata
key_metadata.fetch(:comparison_label)
else
"#{start_date.strftime(@date_format)} to #{end_date.strftime(@date_format)}"
end
end
private
def known?
key.present?
def key_metadata
@key_metadata ||= PERIODS[key]
end
def must_be_valid_date_range

View File

@@ -20,7 +20,7 @@ class PlaidAccount < ApplicationRecord
find_or_create_by!(plaid_id: plaid_data.account_id) do |a|
a.account = family.accounts.new(
name: plaid_data.name,
balance: plaid_data.balances.current,
balance: plaid_data.balances.current || plaid_data.balances.available,
currency: plaid_data.balances.iso_currency_code,
accountable: TYPE_MAPPING[plaid_data.type].new
)

View File

@@ -41,8 +41,16 @@ class PlaidItem < ApplicationRecord
update!(last_synced_at: Time.current)
begin
Rails.logger.info("Fetching and loading Plaid data")
plaid_data = fetch_and_load_plaid_data
update!(status: :good) if requires_update?
# Schedule account syncs
accounts.each do |account|
account.sync_later(start_date: start_date)
end
Rails.logger.info("Plaid data fetched and loaded")
plaid_data
rescue Plaid::ApiError => e
handle_plaid_error(e)
@@ -83,12 +91,17 @@ class PlaidItem < ApplicationRecord
private
def fetch_and_load_plaid_data
data = {}
# Log what we're about to fetch
Rails.logger.info "Starting Plaid data fetch (accounts, transactions, investments, liabilities)"
item = plaid_provider.get_item(access_token).item
update!(available_products: item.available_products, billed_products: item.billed_products)
# Fetch and store institution details
# Institution details
if item.institution_id.present?
begin
Rails.logger.info "Fetching Plaid institution details for #{item.institution_id}"
institution = plaid_provider.get_institution(item.institution_id)
update!(
institution_id: item.institution_id,
@@ -96,12 +109,14 @@ class PlaidItem < ApplicationRecord
institution_color: institution.institution.primary_color
)
rescue Plaid::ApiError => e
Rails.logger.warn("Error fetching institution details for item #{id}: #{e.message}")
Rails.logger.warn "Failed to fetch Plaid institution details: #{e.message}"
end
end
# Accounts
fetched_accounts = plaid_provider.get_item_accounts(self).accounts
data[:accounts] = fetched_accounts || []
Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})"
internal_plaid_accounts = fetched_accounts.map do |account|
internal_plaid_account = plaid_accounts.find_or_create_from_plaid_data!(account, family)
@@ -109,10 +124,12 @@ class PlaidItem < ApplicationRecord
internal_plaid_account
end
# Transactions
fetched_transactions = safe_fetch_plaid_data(:get_item_transactions)
data[:transactions] = fetched_transactions || []
if fetched_transactions
Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})"
transaction do
internal_plaid_accounts.each do |internal_plaid_account|
added = fetched_transactions.added.select { |t| t.account_id == internal_plaid_account.plaid_id }
@@ -126,10 +143,12 @@ class PlaidItem < ApplicationRecord
end
end
# Investments
fetched_investments = safe_fetch_plaid_data(:get_item_investments)
data[:investments] = fetched_investments || []
if fetched_investments
Rails.logger.info "Processing Plaid investments (transactions: #{fetched_investments.transactions.size}, holdings: #{fetched_investments.holdings.size}, securities: #{fetched_investments.securities.size})"
transaction do
internal_plaid_accounts.each do |internal_plaid_account|
transactions = fetched_investments.transactions.select { |t| t.account_id == internal_plaid_account.plaid_id }
@@ -141,10 +160,12 @@ class PlaidItem < ApplicationRecord
end
end
# Liabilities
fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities)
data[:liabilities] = fetched_liabilities || []
if fetched_liabilities
Rails.logger.info "Processing Plaid liabilities (credit: #{fetched_liabilities.credit&.size || 0}, mortgage: #{fetched_liabilities.mortgage&.size || 0}, student: #{fetched_liabilities.student&.size || 0})"
transaction do
internal_plaid_accounts.each do |internal_plaid_account|
credit = fetched_liabilities.credit&.find { |l| l.account_id == internal_plaid_account.plaid_id }

View File

@@ -1,5 +1,6 @@
class Security < ApplicationRecord
include Providable
include Provided
before_save :upcase_ticker
has_many :trades, dependent: :nullify, class_name: "Account::Trade"
@@ -8,17 +9,6 @@ class Security < ApplicationRecord
validates :ticker, presence: true
validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false }
class << self
def search(query)
security_prices_provider.search_securities(
query: query[:search],
dataset: "limited",
country_code: query[:country],
exchange_operating_mic: query[:exchange_operating_mic]
).securities.map { |attrs| new(**attrs) }
end
end
def current_price
@current_price ||= Security::Price.find_price(security: self, date: Date.current)
return nil if @current_price.nil?

View File

@@ -1,18 +1,21 @@
module Security::Price::Provided
extend ActiveSupport::Concern
include Providable
include Synthable
class_methods do
private
def provider
synth_client
end
private
def fetch_price_from_provider(security:, date:, cache: false)
return nil unless security_prices_provider.present?
return nil unless provider.present?
return nil unless security.has_prices?
response = security_prices_provider.fetch_security_prices \
response = provider.fetch_security_prices \
ticker: security.ticker,
mic_code: security.exchange_mic,
mic_code: security.exchange_operating_mic,
start_date: date,
end_date: date
@@ -31,13 +34,13 @@ module Security::Price::Provided
end
def fetch_prices_from_provider(security:, start_date:, end_date:, cache: false)
return [] unless security_prices_provider.present?
return [] unless provider.present?
return [] unless security
return [] unless security.has_prices?
response = security_prices_provider.fetch_security_prices \
response = provider.fetch_security_prices \
ticker: security.ticker,
mic_code: security.exchange_mic,
mic_code: security.exchange_operating_mic,
start_date: start_date,
end_date: end_date

View File

@@ -0,0 +1,28 @@
module Security::Provided
extend ActiveSupport::Concern
include Synthable
class_methods do
def provider
synth_client
end
def search_provider(query)
return [] if query[:search].blank? || query[:search].length < 2
response = provider.search_securities(
query: query[:search],
dataset: "limited",
country_code: query[:country],
exchange_operating_mic: query[:exchange_operating_mic]
)
if response.success?
response.securities.map { |attrs| new(**attrs) }
else
[]
end
end
end
end

View File

@@ -6,38 +6,47 @@ class Sync < ApplicationRecord
scope :ordered, -> { order(created_at: :desc) }
def perform
start!
Rails.logger.tagged("Sync", id, syncable_type, syncable_id) do
start!
begin
data = syncable.sync_data(start_date: start_date)
update!(data: data) if data
complete!
rescue StandardError => error
fail! error
raise error if Rails.env.development?
ensure
syncable.post_sync
begin
data = syncable.sync_data(start_date: start_date)
update!(data: data) if data
complete!
rescue StandardError => error
fail! error
raise error if Rails.env.development?
ensure
Rails.logger.info("Sync completed, starting post-sync")
syncable.post_sync
Rails.logger.info("Post-sync completed")
end
end
end
private
def start!
Rails.logger.info("Starting sync")
update! status: :syncing
end
def complete!
Rails.logger.info("Sync completed")
update! status: :completed, last_ran_at: Time.current
end
def fail!(error)
Rails.logger.error("Sync failed: #{error.message}")
Sentry.capture_exception(error) do |scope|
scope.set_context("sync", { id: id })
scope.set_context("sync", { id: id, syncable_type: syncable_type, syncable_id: syncable_id })
end
update!(
status: :failed,
error: error.message,
error_backtrace: error.backtrace&.first(10),
last_ran_at: Time.current
)
end

View File

@@ -4,7 +4,11 @@ class TradeImport < Import
mappings.each(&:create_mappable!)
rows.each do |row|
account = mappings.accounts.mappable_for(row.account)
mapped_account = if account
account
else
mappings.accounts.mappable_for(row.account)
end
# Try to find or create security with ticker only
security = find_or_create_security(
@@ -12,15 +16,15 @@ class TradeImport < Import
exchange_operating_mic: row.exchange_operating_mic
)
entry = account.entries.build \
entry = mapped_account.entries.build \
date: row.date_iso,
amount: row.signed_amount,
name: row.name,
currency: row.currency.presence || account.currency,
currency: row.currency.presence || mapped_account.currency,
entryable: Account::Trade.new(
security: security,
qty: row.qty,
currency: row.currency.presence || account.currency,
currency: row.currency.presence || mapped_account.currency,
price: row.price
),
import: self
@@ -31,7 +35,9 @@ class TradeImport < Import
end
def mapping_steps
[ Import::AccountMapping ]
base = []
base << Import::AccountMapping if account.nil?
base
end
def required_column_keys
@@ -39,14 +45,19 @@ class TradeImport < Import
end
def column_keys
%i[date ticker exchange_operating_mic currency qty price account name]
base = %i[date ticker exchange_operating_mic currency qty price name]
base.unshift(:account) if account.nil?
base
end
def dry_run
{
transactions: rows.count,
mappings = { transactions: rows.count }
mappings.merge(
accounts: Import::AccountMapping.for_import(self).creational.count
}
) if account.nil?
mappings
end
def csv_template
@@ -57,7 +68,9 @@ class TradeImport < Import
05/17/2024,TSLA,XNAS,USD,2,700.50,Retirement Account,Tesla Inc. Purchase
CSV
CSV.parse(template, headers: true)
csv = CSV.parse(template, headers: true)
csv.delete("account") if account.present?
csv
end
private
@@ -75,10 +88,7 @@ class TradeImport < Import
return internal_security if internal_security.present?
# If security prices provider isn't properly configured or available, create with nil exchange_operating_mic
provider = Security.security_prices_provider
unless provider.present? && provider.respond_to?(:search_securities)
return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil)
end
return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) unless Security.provider.present?
# Cache provider responses so that when we're looping through rows and importing,
# we only hit our provider for the unique combinations of ticker / exchange_operating_mic
@@ -86,18 +96,10 @@ class TradeImport < Import
@provider_securities_cache ||= {}
provider_security = @provider_securities_cache[cache_key] ||= begin
response = provider.search_securities(
Security.search_provider(
query: ticker,
exchange_operating_mic: exchange_operating_mic
)
if !response || !response.success? || !response.securities || response.securities.empty?
nil
else
response.securities.first
end
rescue => e
nil
).first
end
return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) if provider_security.nil?

View File

@@ -4,11 +4,16 @@ class TransactionImport < Import
mappings.each(&:create_mappable!)
rows.each do |row|
account = mappings.accounts.mappable_for(row.account)
mapped_account = if account
account
else
mappings.accounts.mappable_for(row.account)
end
category = mappings.categories.mappable_for(row.category)
tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact
entry = account.entries.build \
entry = mapped_account.entries.build \
date: row.date_iso,
amount: row.signed_amount,
name: row.name,
@@ -27,11 +32,15 @@ class TransactionImport < Import
end
def column_keys
%i[date amount name currency category tags account notes]
base = %i[date amount name currency category tags notes]
base.unshift(:account) if account.nil?
base
end
def mapping_steps
[ Import::CategoryMapping, Import::TagMapping, Import::AccountMapping ]
base = [ Import::CategoryMapping, Import::TagMapping ]
base << Import::AccountMapping if account.nil?
base
end
def csv_template
@@ -42,6 +51,8 @@ class TransactionImport < Import
05/17/2024,-12.50,Coffee Shop,,,coffee,,
CSV
CSV.parse(template, headers: true)
csv = CSV.parse(template, headers: true)
csv.delete("account") if account.present?
csv
end
end

View File

@@ -1,11 +1,14 @@
module Upgrader::Provided
extend ActiveSupport::Concern
include Providable
class_methods do
private
def fetch_latest_upgrade_candidates_from_provider
git_repository_provider.fetch_latest_upgrade_candidates
end
def git_repository_provider
Provider::Github.new
end
end
end

View File

@@ -10,6 +10,7 @@ class User < ApplicationRecord
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validate :ensure_valid_profile_image
validates :default_period, inclusion: { in: Period::PERIODS.keys }
normalizes :email, with: ->(email) { email.strip.downcase }
normalizes :unconfirmed_email, with: ->(email) { email&.strip&.downcase }

View File

@@ -21,12 +21,12 @@
</div>
<div class="rounded-lg bg-white shadow-border-xs">
<%= render "account/holdings/cash", account: @account %>
<%= render "account/holdings/ruler" %>
<% if @account.current_holdings.any? %>
<%= render "account/holdings/cash", account: @account %>
<%= render "account/holdings/ruler" %>
<%= render partial: "account/holdings/holding", collection: @account.current_holdings, spacer_template: "ruler" %>
<% else %>
<p class="text-secondary text-sm p-4"><%= t(".no_holdings") %></p>
<% end %>
</div>
</div>

View File

@@ -73,7 +73,7 @@
".trade_history_entry",
qty: trade_entry.account_trade.qty,
security: trade_entry.account_trade.security.ticker,
price: format_money(trade_entry.account_trade.price)
price: trade_entry.account_trade.price_money.format
) %></p>
</div>
</li>
@@ -87,26 +87,28 @@
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-secondary bg-gray-25 focus-visible:outline-hidden">
<h4><%= t(".settings") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
</summary>
<% unless @holding.account.plaid_account_id.present? %>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-secondary bg-gray-25 focus-visible:outline-hidden">
<h4><%= t(".settings") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
</summary>
<div class="pb-4">
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".delete_title") %></h4>
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
</div>
<div class="pb-4">
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".delete_title") %></h4>
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
</div>
<%= button_to t(".delete"),
<%= button_to t(".delete"),
account_holding_path(@holding),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
data: { turbo_confirm: true } %>
</div>
</div>
</div>
</details>
</details>
<% end %>
</div>
<% end %>

View File

@@ -27,9 +27,18 @@
}} %>
<% if %w[buy sell].include?(type) %>
<div class="form-field combobox">
<%= form.combobox :ticker, securities_path(country_code: Current.family.country), label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %>
</div>
<% if Security.provider.present? %>
<div class="form-field combobox">
<%= form.combobox :ticker,
securities_path(country_code: Current.family.country),
name_when_new: "account_entry[manual_ticker]",
label: t(".holding"),
placeholder: t(".ticker_placeholder"),
required: true %>
</div>
<% else %>
<%= form.text_field :manual_ticker, label: "Ticker", placeholder: "AAPL", required: true %>
<% end %>
<% end %>
<%= form.date_field :date, label: true, value: Date.current, required: true %>

View File

@@ -1,12 +1,12 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" do %>
<dialog data-controller="modal"
data-action="click->modal#clickOutside"
data-action="mousedown->modal#clickOutside"
class="bg-white shadow-border-xs rounded-2xl max-h-[calc(100vh-32px)] h-full max-w-[480px] w-full mt-4 mr-4 ml-auto">
<%= styled_form_with url: bulk_update_account_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
<div class="flex h-full flex-col justify-between p-4 gap-4">
<div>
<div class="flex h-9 items-center justify-end">
<div data-action="click->modal#close" class="cursor-pointer">
<div data-action="mousedown->modal#close" class="cursor-pointer">
<%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
</div>
</div>
@@ -49,12 +49,12 @@
</div>
<div class="flex justify-end items-center gap-2">
<%= link_to t(".cancel"), transactions_path, class: "text-sm font-medium text-primary px-3 py-2" %>
<%= link_to t(".cancel"), transactions_path, class: "btn btn--ghost" %>
<%= tag.button t(".save"),
type: "button",
data: { "bulk-select-scope-param": "bulk_update", action: "bulk-select#submitBulkRequest" },
class: "px-3 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg" %>
class: "btn btn--primary" %>
</div>
</div>
<% end %>

View File

@@ -1,5 +1,25 @@
<%# locals: (family:) %>
<% if family.requires_data_provider? && family.synth_client.nil? %>
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
<summary class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<%= icon "triangle-alert", size: "sm" %>
<p class="font-medium">Missing historical data</p>
</div>
<%= lucide_icon "chevron-down", class: "text-yellow-600 group-open:transform group-open:rotate-180 w-5" %>
</summary>
<div class="text-xs py-2 space-y-2">
<p>Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.</p>
<p>
<%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %>
</p>
</div>
</details>
<% end %>
<div
class="space-y-3"
data-controller="tabs"

View File

@@ -28,7 +28,7 @@
</div>
<div class="ml-auto text-right grow h-10">
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary" %>
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
<div class="flex items-center w-8 h-5 ml-auto">

View File

@@ -1,5 +1,4 @@
<% period = Period.from_key(params[:period], fallback: true) %>
<% series = @account.balance_series(period: period) %>
<% series = @account.balance_series(period: @period, view: @chart_view) %>
<% trend = series.trend %>
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
@@ -13,7 +12,7 @@
<% end %>
<% end %>
<%= tag.span period.comparison_label, class: "text-secondary" %>
<%= tag.span @period.comparison_label, class: "text-secondary" %>
</div>
<div class="h-64 pb-4">

View File

@@ -1,6 +1,6 @@
<%# locals: (account:, title: nil, tooltip: nil, **args) %>
<%# locals: (account:, title: nil, tooltip: nil, chart_view: nil, **args) %>
<% period = Period.from_key(params[:period], fallback: true) %>
<% period = @period || Period.last_30_days %>
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg space-y-2">
@@ -15,11 +15,21 @@
</div>
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
<%= period_select form: form, selected: period %>
<div class="flex items-center gap-2">
<% if chart_view.present? %>
<%= form.select :chart_view,
[["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]],
{ selected: chart_view },
class: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0",
data: { "auto-submit-form-target": "auto" } %>
<% end %>
<%= period_select form: form, selected: period %>
</div>
<% end %>
</div>
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key) do %>
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key, chart_view: chart_view) do %>
<%= render "accounts/chart_loader" %>
<% end %>
</div>

View File

@@ -20,8 +20,8 @@
<% end %>
<% unless account.crypto? %>
<%= link_to new_import_path,
data: { turbo_frame: :modal },
<%= button_to imports_path({ import: { type: account.investment? ? "TradeImport" : "TransactionImport", account_id: account.id } }),
data: { turbo_frame: :_top },
class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg" do %>
<%= lucide_icon "download", class: "w-5 h-5 text-secondary" %>

View File

@@ -38,7 +38,7 @@
<% param_key = Budget.date_to_param(date) %>
<% if Budget.budget_date_valid?(date, family: family) %>
<%= link_to month_name, budget_path(param_key), data: { turbo_frame: "_top" }, class: "block px-3 py-2 text-sm text-primary hover:bg-gray-100 rounded-md" %>
<%= link_to month_name, budget_path(param_key), data: { turbo_frame: "_top" }, class: "btn btn--ghost" %>
<% else %>
<span class="px-3 py-2 text-subdued rounded-md"><%= month_name %></span>
<% end %>

View File

@@ -1,6 +1,6 @@
<%# locals: (import:) %>
<div class="flex items-center justify-between border border-secondary rounded-lg bg-green-500/5 p-5 gap-4">
<div class="flex items-center justify-between border border-secondary rounded-lg bg-green-500/5 p-5 gap-4 mb-4">
<%= lucide_icon("check-circle", class: "w-5 h-5 shrink-0 text-green-500") %>
<p class="text-sm text-primary italic">We have pre-configured your Mint import for you. Please proceed to the next step.</p>
</div>
@@ -21,7 +21,10 @@
<%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
</div>
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" }, disabled: import.complete? %>
<% unless import.account.present? %>
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" }, disabled: import.complete? %>
<% end %>
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" }, disabled: import.complete? %>
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" }, disabled: import.complete? %>
<%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" }, disabled: import.complete? %>

View File

@@ -20,14 +20,17 @@
<%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Ticker" } %>
<%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Exchange Operating MIC" } %>
<%= form.select :price_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Price" } %>
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
<% unless import.account.present? %>
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
<% end %>
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
<% if Security.security_prices_provider.nil? %>
<% unless Security.provider %>
<div class="alert alert-warning">
<p>
<strong>Note:</strong> The Synth provider is not configured. Exchange validation is disabled.
Securities will be created without exchange validation, and price history will not be available.
<strong>Note:</strong> The security prices provider is not configured. Your trade imports will work, but Maybe will not backfill price history. Please go to your settings to configure this.
</p>
</div>
<% end %>

View File

@@ -16,7 +16,10 @@
<%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
</div>
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
<% unless import.account.present? %>
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
<% end %>
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" } %>
<%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" } %>

View File

@@ -4,14 +4,33 @@
<%= content_for :previous_path, import_upload_path(@import) %>
<div>
<% if @import.suggested_template.present? && params[:template_hint] == "true" %>
<div class="py-12">
<div class="shadow-border-xs rounded-lg p-4 max-w-lg mx-auto">
<h3 class="text-sm font-medium mb-2 flex items-center gap-2">
<span class="text-success">
<%= icon "sparkles" %>
</span>
Template configuration found
</h3>
<p class="text-sm text-secondary">We found a configuration from a previous import for this account. Would you like to apply it to this import?</p>
<div class="mt-4 flex gap-2 items-center">
<%= link_to "Manually configure", import_configuration_path(@import), class: "btn btn--outline" %>
<%= button_to "Apply template", apply_template_import_path(@import), class: "btn btn--primary", method: :put, data: { turbo_frame: :_top } %>
</div>
</div>
</div>
<% else %>
<div class="space-y-4">
<div class="text-center space-y-2">
<h1 class="text-3xl text-primary font-medium"><%= t(".title") %></h1>
<p class="text-secondary text-sm"><%= t(".description") %></p>
</div>
<div class="mx-auto max-w-lg">
<div class="mx-auto max-w-lg space-y-3">
<%= render partial: permitted_import_configuration_path(@import), locals: { import: @import } %>
</div>
</div>
@@ -19,4 +38,4 @@
<div class="mx-auto max-w-5xl my-12">
<%= render "imports/table", headers: @import.csv_headers, rows: @import.csv_sample, caption: "Sample data from your uploaded CSV" %>
</div>
</div>
<% end %>

View File

@@ -3,7 +3,7 @@
<% mappings = mapping_class.for_import(import) %>
<% is_last_step = step_idx == import.mapping_steps.count - 1 %>
<% if mapping_class == Import::AccountMapping %>
<% if mapping_class == Import::AccountMapping && import.account.nil? %>
<% if import.requires_account? %>
<div class="flex items-center justify-between p-4 mb-4 gap-4 text-secondary bg-red-100 border border-red-200 rounded-lg w-[650px]">
<%= tag.p t(".no_accounts"), class: "text-sm" %>

View File

@@ -19,33 +19,33 @@
</div>
</div>
<div data-tabs-target="tab" id="csv-paste-tab">
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
<%= form.select :col_sep, [["Comma (,)", ","], ["Semicolon (;)", ";"]], label: true %>
<%= form.text_area :raw_file_str,
<% ["csv-paste-tab", "csv-upload-tab"].each do |tab| %>
<%= tag.div id: tab, data: { tabs_target: "tab" }, class: tab == "csv-upload-tab" ? "hidden" : "" do %>
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
<%= form.select :col_sep, Import::SEPARATORS, label: true %>
<% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
<%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<% end %>
<% if tab == "csv-paste-tab" %>
<%= form.text_area :raw_file_str,
rows: 10,
required: true,
placeholder: "Paste your CSV file contents here",
"data-auto-submit-form-target": "auto" %>
<% else %>
<label for="import_csv_file" class="flex flex-col items-center justify-center w-full h-56 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<%= form.file_field :csv_file, class: "ml-32", "data-auto-submit-form-target": "auto" %>
</div>
</label>
<% end %>
<%= form.submit "Upload CSV", disabled: @import.complete? %>
<%= form.submit "Upload CSV", disabled: @import.complete? %>
<% end %>
<% end %>
</div>
<div data-tabs-target="tab" id="csv-upload-tab" class="hidden">
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
<%= form.select :col_sep, [["Comma (,)", ","], ["Semicolon (;)", ";"]], label: true %>
<label for="import_csv_file" class="flex flex-col items-center justify-center w-full h-56 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<%= form.file_field :csv_file, class: "ml-32", "data-auto-submit-form-target": "auto" %>
</div>
</label>
<%= form.submit "Upload CSV", disabled: @import.complete? %>
<% end %>
</div>
<% end %>
</div>
</div>

View File

@@ -2,6 +2,10 @@
<div class="flex items-center gap-2 mb-1">
<%= link_to import_path(import), class: "text-sm text-primary hover:underline" do %>
<% if import.account.present? %>
<%= import.account.name + " " %>
<% end %>
<%= t(".label", type: import.type.titleize, datetime: import.updated_at.strftime("%b %-d, %Y at %l:%M %p")) %>
<% end %>

View File

@@ -6,7 +6,7 @@
{ name: "Clean", path: import_clean_path(import), is_complete: import.cleaned?, step_number: 3 },
{ name: "Map", path: import_confirm_path(import), is_complete: import.publishable?, step_number: 4 },
{ name: "Confirm", path: import_path(import), is_complete: import.complete?, step_number: 5 }
] %>
].reject { |step| step[:name] == "Map" && import.mapping_steps.empty? } %>
<ul class="flex items-center gap-2">
<% steps.each_with_index do |step, idx| %>

View File

@@ -7,6 +7,7 @@
<%= render "accounts/show/chart",
account: @account,
title: t(".chart_title"),
chart_view: @chart_view,
tooltip: render(
"investments/value_tooltip",
balance: @account.balance_money,

View File

@@ -6,8 +6,8 @@
</head>
<body class="h-full antialiased" data-controller="sidebar" data-sidebar-user-id-value="<%= Current.user&.id %>">
<div class="fixed z-50 bottom-6 left-6">
<div id="notification-tray" class="space-y-1">
<div class="fixed z-50 bottom-6 left-24 w-80">
<div id="notification-tray" class="space-y-1 w-full">
<%= render_flash_notifications %>
<% if Current.family&.syncing? %>

View File

@@ -57,7 +57,7 @@
placeholder: t(".code_placeholder") %>
<div class="flex justify-end mt-4">
<%= f.submit t(".verify_button"), class: "bg-gray-900 hover:bg-gray-700 cursor-pointer text-white rounded-lg px-3 py-2" %>
<%= f.submit t(".verify_button"), class: "btn btn--primary" %>
</div>
</div>
<% end %>

View File

@@ -3,7 +3,7 @@
header_description t(".description")
%>
<%= styled_form_with url: verify_mfa_path, method: :post, class: "space-y-4" do |form| %>
<%= styled_form_with url: verify_mfa_path, method: :post, class: "space-y-4", data: { turbo: false } do |form| %>
<%= form.text_field :code,
required: true,
autofocus: true,

View File

@@ -12,7 +12,7 @@
<div class="bg-white p-4 rounded-lg flex gap-8 shadow-border-xs">
<div class="space-y-2">
<%= tag.p t(".example"), class: "text-secondary text-sm" %>
<%= tag.p "$2,323.25", class: "text-primary font-medium text-2xl" %>
<%= tag.p format_money(Money.new(2325.25, params[:currency] || @user.family.currency)), class: "text-primary font-medium text-2xl" %>
<p class="text-sm">
<span class="text-green-500 font-medium">+<%= format_money(Money.new(78.90, params[:currency] || @user.family.currency)) %></span>
<span class="text-green-500 font-medium">(+<%= format_money(Money.new(6.39, params[:currency] || @user.family.currency)) %>)</span>

View File

@@ -2,7 +2,7 @@
header_title t(".title")
%>
<%= styled_form_with url: sessions_path, class: "space-y-4" do |form| %>
<%= styled_form_with url: sessions_path, class: "space-y-4", data: { turbo: false } do |form| %>
<%= form.email_field :email, label: t(".email"), autofocus: false, autocomplete: "email", required: "required", placeholder: t(".email_placeholder") %>
<%= form.password_field :password, label: t(".password"), required: "required" %>

Some files were not shown because too many files have changed in this diff Show More