Merge branch 'main' into ai
This commit is contained in:
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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.
|
||||
|
||||
35
.github/ISSUE_TEMPLATE/other.md
vendored
35
.github/ISSUE_TEMPLATE/other.md
vendored
@@ -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.
|
||||
|
||||
10
.github/workflows/publish.yml
vendored
10
.github/workflows/publish.yml
vendored
@@ -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 }}
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -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 ./
|
||||
|
||||
77
Gemfile.lock
77
Gemfile.lock
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
14
app/controllers/concerns/periodable.rb
Normal file
14
app/controllers/concerns/periodable.rb
Normal 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
|
||||
@@ -35,6 +35,7 @@ class Import::ConfigurationsController < ApplicationController
|
||||
:notes_col_label,
|
||||
:currency_col_label,
|
||||
:date_format,
|
||||
:number_format,
|
||||
:signage_convention
|
||||
)
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
16
app/jobs/data_cache_clear_job.rb
Normal file
16
app/jobs/data_cache_clear_job.rb
Normal 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
|
||||
19
app/jobs/family_reset_job.rb
Normal file
19
app/jobs/family_reset_job.rb
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
35
app/models/account/balance/base_calculator.rb
Normal file
35
app/models/account/balance/base_calculator.rb
Normal 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
|
||||
28
app/models/account/balance/forward_calculator.rb
Normal file
28
app/models/account/balance/forward_calculator.rb
Normal 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
|
||||
32
app/models/account/balance/reverse_calculator.rb
Normal file
32
app/models/account/balance/reverse_calculator.rb
Normal 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
|
||||
46
app/models/account/balance/sync_cache.rb
Normal file
46
app/models/account/balance/sync_cache.rb
Normal 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
|
||||
69
app/models/account/balance/syncer.rb
Normal file
69
app/models/account/balance/syncer.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
12
app/models/account/enrichable.rb
Normal file
12
app/models/account/enrichable.rb
Normal 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
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account::Entry < ApplicationRecord
|
||||
include Monetizable
|
||||
include Monetizable, Provided
|
||||
|
||||
monetize :amount
|
||||
|
||||
|
||||
11
app/models/account/entry/provided.rb
Normal file
11
app/models/account/entry/provided.rb
Normal 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
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account::Holding < ApplicationRecord
|
||||
include Monetizable
|
||||
include Monetizable, Gapfillable
|
||||
|
||||
monetize :amount
|
||||
|
||||
|
||||
63
app/models/account/holding/base_calculator.rb
Normal file
63
app/models/account/holding/base_calculator.rb
Normal 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
|
||||
21
app/models/account/holding/forward_calculator.rb
Normal file
21
app/models/account/holding/forward_calculator.rb
Normal 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
|
||||
38
app/models/account/holding/gapfillable.rb
Normal file
38
app/models/account/holding/gapfillable.rb
Normal 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
|
||||
132
app/models/account/holding/portfolio_cache.rb
Normal file
132
app/models/account/holding/portfolio_cache.rb
Normal 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
|
||||
38
app/models/account/holding/reverse_calculator.rb
Normal file
38
app/models/account/holding/reverse_calculator.rb
Normal 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
|
||||
58
app/models/account/holding/syncer.rb
Normal file
58
app/models/account/holding/syncer.rb
Normal 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
|
||||
@@ -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
|
||||
18
app/models/account/linkable.rb
Normal file
18
app/models/account/linkable.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
37
app/models/concerns/synthable.rb
Normal file
37
app/models/concerns/synthable.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
28
app/models/security/provided.rb
Normal file
28
app/models/security/provided.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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)" } %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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| %>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user