Compare commits

...

62 Commits

Author SHA1 Message Date
Zach Gollwitzer
3f9858a67f Add streaming chat 2025-03-12 14:06:42 -04:00
Zach Gollwitzer
d1b83541c1 AI chat layout skeleton 2025-03-12 12:39:16 -04:00
Zach Gollwitzer
dd75cadebc Fix transaction filters when transfers are present (#1986)
* Proper filtering of transfers in search

* Fix transaction search
2025-03-11 15:38:45 -04:00
Josh Pigford
ed55ef624b Update billing settings view and locales 2025-03-11 13:00:34 -05:00
Zach Gollwitzer
f363fd4a4e Fix incorrect totals calculation when family has loan payments (#1984)
* Fix income totals calculation error when loan payments exist

* Include transaction totals in totals query
2025-03-11 12:37:57 -04:00
Zach Gollwitzer
b8a3ca7732 Fetch exchange rates for accounts that require conversion for net worth rollups (#1983)
* Sync required exchange rates for accounts

* Refactor into concern
2025-03-11 10:10:28 -04:00
Zach Gollwitzer
7b751ac7ca Do not prompt upgrades until user is logged in
Fixes #1982
2025-03-11 09:11:40 -04:00
Josh Pigford
15d59959cf Fix issue of syncing notice covering up user menu 2025-03-10 11:20:58 -05:00
dependabot[bot]
c66401dc0f Bump stripe from 13.4.1 to 13.5.0 (#1970)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.4.1 to 13.5.0.
- [Release notes](https://github.com/stripe/stripe-ruby/releases)
- [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-ruby/compare/v13.4.1...v13.5.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:34:15 -04:00
dependabot[bot]
9dcb9e8ed2 Bump webmock from 3.25.0 to 3.25.1 (#1968)
Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.0 to 3.25.1.
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.0...v3.25.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:16:01 -04:00
dependabot[bot]
045fa1931c Bump good_job from 4.9.0 to 4.9.3 (#1969)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.9.0 to 4.9.3.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.9.0...v4.9.3)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:15:51 -04:00
dependabot[bot]
3f8351abfe Bump rubocop-rails-omakase from 1.0.0 to 1.1.0 (#1971)
Bumps [rubocop-rails-omakase](https://github.com/rails/rubocop-rails-omakase) from 1.0.0 to 1.1.0.
- [Release notes](https://github.com/rails/rubocop-rails-omakase/releases)
- [Commits](https://github.com/rails/rubocop-rails-omakase/compare/v1.0.0...v1.1.0)

---
updated-dependencies:
- dependency-name: rubocop-rails-omakase
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:13:38 -04:00
dependabot[bot]
dc44da6c00 Bump redcarpet from 3.6.0 to 3.6.1 (#1972)
Bumps [redcarpet](https://github.com/vmg/redcarpet) from 3.6.0 to 3.6.1.
- [Release notes](https://github.com/vmg/redcarpet/releases)
- [Changelog](https://github.com/vmg/redcarpet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vmg/redcarpet/compare/v3.6.0...v3.6.1)

---
updated-dependencies:
- dependency-name: redcarpet
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:13:25 -04:00
dependabot[bot]
2e4180fbf0 Bump turbo-rails from 2.0.11 to 2.0.13 (#1973)
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.11 to 2.0.13.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.11...v2.0.13)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:13:15 -04:00
dependabot[bot]
4b19ca50eb Bump i18n-tasks from 1.0.14 to 1.0.15 (#1974)
Bumps [i18n-tasks](https://github.com/glebm/i18n-tasks) from 1.0.14 to 1.0.15.
- [Release notes](https://github.com/glebm/i18n-tasks/releases)
- [Changelog](https://github.com/glebm/i18n-tasks/blob/main/CHANGES.md)
- [Commits](https://github.com/glebm/i18n-tasks/compare/v1.0.14...v1.0.15)

---
updated-dependencies:
- dependency-name: i18n-tasks
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:13:05 -04:00
Zach Gollwitzer
a3cd5f4f1d Format money for trade history in holdings drawer (#1961)
* Format money for trade history in holdings drawer

* Fix broken tests

* Lint fix
2025-03-07 19:09:54 -05:00
Zach Gollwitzer
86bf47a32e Ensure holdings are normalized to account currency 2025-03-07 18:02:08 -05:00
Zach Gollwitzer
5f8a3c9f50 Search securities with correct exchange mic 2025-03-07 17:48:26 -05:00
Zach Gollwitzer
eac5d5e663 Populate holdings for "offline" securities properly (#1958)
* Placeholder logic for missing prices

* Generate holdings properly for "offline" securities

* Separate forward and reverse calculators for holdings and balances

* Remove unnecessary currency conversion during sync

* Clearer sync process

* Move price caching logic to dedicated model

* Base holding calculator

* Base calculator for balances

* Finish balance calculators

* Better naming

* Logs cleanup

* Remove stale data type

* Remove stale test

* Fix price lookup logic for holdings sync

* Fix Plaid item sync regression

* Remove temp logging

* Calculate cash and holdings series

* Add holdings, cash, and balance series dropdown for investments
2025-03-07 17:35:55 -05:00
Nikhil Badyal
26762477a3 Preference to set default_period (#1941) 2025-03-07 10:05:54 -05:00
Zach Gollwitzer
372b64ffea Fix Plaid sync error when current balance is null 2025-03-05 16:02:07 -05:00
Zach Gollwitzer
9627a6bf6f Add tagged logging to sync process (#1956)
* Add tagged logging to sync process

* Reduce logging in syncer

* Typo
2025-03-05 15:38:31 -05:00
Josh Pigford
cffafd23f0 Logger cleanup 2025-03-05 13:44:56 -06:00
Josh Pigford
f7fa8fa085 Disable turbo on login forms 2025-03-05 13:32:53 -06:00
Josh Pigford
28bfcda50a Temporary additional logging to continue debugging MFA issues 2025-03-05 13:20:36 -06:00
Josh Pigford
e49bda4a2e Another attempt at fixing MFA issues 2025-03-05 13:10:53 -06:00
Josh Pigford
071ad52c7f Potential fix for MFA login issues 2025-03-05 13:04:45 -06:00
Zach Gollwitzer
381e39bea8 Fix: Purge stale holdings from accounts during sync (#1954)
* Fix: Purge stale holdings from accounts during sync

* Fix typo

* Prevent Plaid holding deletions
2025-03-05 12:21:17 -05:00
Zach Gollwitzer
eaa1b6abe0 Update issue templates 2025-03-05 11:01:07 -05:00
Zach Gollwitzer
e384369cfb Adjust graph intervals to show more data
Fixes #1948
2025-03-05 10:20:02 -05:00
Zach Gollwitzer
8d0509fda0 Conditionally show commit sha
Fixes #1951
2025-03-05 10:15:12 -05:00
Zach Gollwitzer
d66c37939a Update bug_report.md
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-03-05 10:07:29 -05:00
Zach Gollwitzer
cf59fe45e7 Fix ticker filling when Synth is connected (#1950) 2025-03-05 09:30:47 -05:00
Zach Gollwitzer
0544089710 Account-level import configuration templates (#1946)
* Account-level import configuration templates

* Default import to family's preferred date format
2025-03-04 13:10:01 -05:00
Zach Gollwitzer
5b2fa3d707 Fix commit resolution for Docker builds 2025-03-04 07:50:21 -05:00
Bryan McKnight
cf0e573533 Fix modal closing on color picker drag #1869 (#1931)
* Replaced data-action click event with data-action mousedown to prevent the modal from hiding on mouse up whenever mouse down starts within the modal

* Changed click events to mousedown within dialog elements to trigger the closing of the element
2025-03-03 16:37:12 -05:00
Zach Gollwitzer
4e96ca8376 Add manual Docker publishing trigger in GH action workflow 2025-03-03 14:34:56 -05:00
Zach Gollwitzer
c5da8ea550 Allow CSV imports to be configured with single or multi-account mode (#1943)
* Allow CSV imports to be configured to a single account or multiple accounts

* Initialize import directly from accounts page

* Fix brakeman warnings

* Fix schema

* Fix Synth check
2025-03-03 12:47:30 -05:00
Zach Gollwitzer
e907b073ed Fix time period key conflicts (#1944) 2025-03-03 12:47:20 -05:00
Tony Vincent
4c4a4026c4 fix: Bug - Transcation Matching Dialog isn't Opening (#1942) 2025-03-03 11:34:03 -05:00
Zach Gollwitzer
c95bb082a9 Bump to v0.4.3
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-02-28 15:11:41 -05:00
Zach Gollwitzer
4d0df9b950 Escape quotations in CSV imports properly (#1929)
* Parse quotes in imports

* Update invalid CSV for test
2025-02-28 12:21:07 -05:00
Zach Gollwitzer
7c66f16750 Invert liability graphs to have correct signage (#1928) 2025-02-28 12:17:25 -05:00
Zach Gollwitzer
fa0248056d Show UI warning to user when they need provider data but have not setup Synth yet (#1926)
* Simplify provider concerns

* Update tests

* Add UI warning for missing Synth key if family requires external data
2025-02-28 11:35:10 -05:00
Tony Vincent
624faa10d0 fix: Don't show Billings on settings navbar when self-hosted (#1912)
* Do not show billing settings navbar item when self hosted

* Do not show billing settings navbar item when self hosted

* Add condition to settings helper

* Let Stripe::AuthenticationError bubble up
2025-02-28 09:35:00 -05:00
Zach Gollwitzer
9138bd2b76 Allow offline trade tickers (#1925) 2025-02-28 09:34:14 -05:00
Zach Gollwitzer
882857fcf0 Add transitions to buttons and other common design system elements (#1924) 2025-02-28 09:29:07 -05:00
Zach Gollwitzer
d6793dec05 Fix import configuration form so number format is applied (#1923)
* Fix number format form error when loading import

* Add test to verify import configuration was properly applied
2025-02-28 08:36:57 -05:00
Zach Gollwitzer
e771c8c1df Fix value wrapping on account balance in sidebar (#1922) 2025-02-28 08:35:14 -05:00
Zach Gollwitzer
58cc09f5ae Fix bad link in bug template
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-02-28 08:25:20 -05:00
Zach Gollwitzer
98c842d3b8 Add note about self hosted versions prior to opening bugs 2025-02-28 08:23:46 -05:00
Tony Vincent
fae781e1be Make tags scrollable again (#1921) 2025-02-28 07:53:05 -05:00
Tony Vincent
8208722247 Feat: Data "reset" button (#1913)
* feat: Allow admins to delete family data

* feat: Allow self-hosting users to delete cached data

* Remove system tests
2025-02-28 07:49:12 -05:00
Harshit Chaudhary
f7064fd4dd fixed example account balance (#1910) 2025-02-26 15:13:51 -05:00
Zach Gollwitzer
c610b0ba4b Dashboard design fixes (#1898)
* Dashboard design fixes

* Update dashboard greeting

* Remove sidebar toggle from settings breadcrumbs

* Autofocus and outlines for category dropdowns

* Lint fixes
2025-02-25 17:28:40 -05:00
Josh Pigford
a4874815a6 Add breadcrumbs support across application (#1897)
* Add breadcrumbs support across application

Fixes #1896

* Potential fix for tests

* Simplify breadcrumbs implementation

Remove complex breadcrumbs logic from controllers and concern, replacing with a simpler default approach that sets a basic breadcrumb based on the current controller name

* Refactor page header and breadcrumbs rendering

Remove complex breadcrumbs helper method and update layout to use more flexible content_for approach for page headers and breadcrumbs

* Add fallback breadcrumbs rendering to settings layout
2025-02-25 10:14:07 -06:00
Josh Pigford
763e222cdd Add Sentry user context to authentication concern 2025-02-25 08:48:26 -06:00
Josh Pigford
e8390a68d8 Reduce Sentry sampling rates for performance monitoring 2025-02-25 08:44:13 -06:00
Josh Pigford
0e76d753bd Replace StackProf with Vernier for performance profiling 2025-02-25 08:37:51 -06:00
Zach Gollwitzer
f5ff5332d5 Fix parent category sums in budget (#1894) 2025-02-24 12:51:13 -05:00
Zach Gollwitzer
0dea36ec7d Fix bulk edit drawer height 2025-02-24 12:48:03 -05:00
Syed Bariman Jan
95989a6c9b Add new category flow (#1857)
* resolve git issue

* Add new category flow

* Improve contrast checker

* make error message small

* update ui to match figma design

* realign color picker

* changes

* rename color picker controller to new category controller

* cleanup code

* cleanup code

* resize and realign icon avatar

* Fix js lint errors

Signed-off-by: Syed Bariman Jan <syedbarimanjan@gmail.com>

---------

Signed-off-by: Syed Bariman Jan <syedbarimanjan@gmail.com>
2025-02-24 11:08:05 -05:00
217 changed files with 3313 additions and 1422 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ gem "hotwire_combobox", github: "josefarias/hotwire_combobox", ref: "b827048a830
gem "good_job"
# Error logging
gem "stackprof"
gem "vernier"
gem "rack-mini-profiler"
gem "sentry-ruby"
gem "sentry-rails"
@@ -57,6 +57,7 @@ gem "intercom-rails"
gem "plaid"
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 2.2"
gem "ruby-openai"
group :development, :test do
gem "debug", platforms: %i[mri windows]

View File

@@ -167,6 +167,7 @@ GEM
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
event_stream_parser (1.0.0)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.12.2)
@@ -192,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)
@@ -208,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
@@ -217,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)
@@ -247,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)
@@ -293,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)
@@ -341,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)
@@ -395,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)
@@ -405,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)
@@ -440,6 +442,10 @@ GEM
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.4.0)
ruby-lsp (>= 0.23.0, < 0.24.0)
ruby-openai (7.4.0)
event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
ruby-vips (2.2.3)
ffi (~> 1.12)
@@ -470,11 +476,10 @@ GEM
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11813)
stackprof (0.2.27)
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)
@@ -489,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)
@@ -501,12 +506,13 @@ GEM
useragent (0.16.11)
vcr (6.3.1)
base64
vernier (1.5.0)
web-console (4.2.1)
actionview (>= 6.0.0)
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)
@@ -517,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
@@ -575,17 +581,18 @@ DEPENDENCIES
rqrcode (~> 2.2)
rubocop-rails-omakase
ruby-lsp-rails
ruby-openai
selenium-webdriver
sentry-rails
sentry-ruby
simplecov
stackprof
stimulus-rails
stripe
tailwindcss-rails
turbo-rails
tzinfo-data
vcr
vernier
web-console
webmock

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,35 @@
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@import "../stylesheets/simonweb_pickr.css";
@layer components {
.pcr-app{
position: static !important;
background: none !important;
box-shadow: none !important;
padding: 0 !important;
width: 100% !important;
}
.pcr-color-palette{
height: 12em !important;
width: 21.5rem !important;
}
.pcr-palette{
border-radius: 10px !important;
}
.pcr-palette:before{
border-radius: 10px !important;
}
.pcr-color-chooser{
height: 1.5em !important;
}
.pcr-picker{
height: 20px !important;
width: 20px !important;
}
}
.combobox {
.hw-combobox__main__wrapper,
.hw-combobox__input {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable
include Pagy::Backend
helper_method :require_upgrade?, :subscription_pending?

View File

@@ -25,6 +25,7 @@ class BudgetsController < ApplicationController
end
private
def budget_create_params
params.require(:budget).permit(:start_date)
end

View File

@@ -0,0 +1,17 @@
class ChatsController < ApplicationController
def index
Current.user.update!(current_chat: nil)
@chats = Current.user.chats.ordered
end
def create
@chat = Current.user.chats.create_with_defaults!
redirect_to chat_path(@chat)
end
def show
@chat = Current.user.chats.find(params[:id])
Current.user.update!(current_chat: @chat)
end
end

View File

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

View File

@@ -4,11 +4,13 @@ module Authentication
included do
before_action :set_request_details
before_action :authenticate_user!
before_action :set_sentry_user
end
class_methods do
def skip_authentication(**options)
skip_before_action :authenticate_user!, **options
skip_before_action :set_sentry_user, **options
end
end
@@ -26,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)
@@ -43,4 +51,17 @@ module Authentication
Current.user_agent = request.user_agent
Current.ip_address = request.ip
end
def set_sentry_user
return unless defined?(Sentry) && ENV["SENTRY_DSN"].present?
if Current.user
Sentry.set_user(
id: Current.user.id,
email: Current.user.email,
username: Current.user.display_name,
ip_address: Current.ip_address
)
end
end
end

View File

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

View File

@@ -0,0 +1,13 @@
module Breadcrumbable
extend ActiveSupport::Concern
included do
before_action :set_breadcrumbs
end
private
# The default, unless specific controller or action explicitly overrides
def set_breadcrumbs
@breadcrumbs = [ [ "Home", root_path ], [ controller_name.titleize, nil ] ]
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
class MessagesController < ApplicationController
before_action :set_chat
def create
@message = @chat.messages.create!(message_params.merge(role: "user"))
AiResponseJob.perform_later(@message)
respond_to do |format|
format.turbo_stream
format.html { redirect_to chat_path(@chat) }
end
end
private
def set_chat
@chat = Current.user.chats.find(params[:chat_id])
end
def message_params
params.require(:message).permit(:content)
end
end

View File

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

View File

@@ -1,10 +1,12 @@
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
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
end
def changelog

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ class TransactionsController < ApplicationController
end
private
def search_params
cleaned_params = params.fetch(:q, {})
.permit(

View File

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

View File

@@ -1,6 +1,24 @@
module Account::EntriesHelper
def entries_by_date(entries, totals: false)
entries.group_by(&:date).map do |date, grouped_entries|
transfer_groups = entries.group_by do |entry|
# Only check for transfer if it's a transaction
next nil unless entry.entryable_type == "Account::Transaction"
entry.entryable.transfer&.id
end
# For a more intuitive UX, we do not want to show the same transfer twice in the list
deduped_entries = transfer_groups.flat_map do |transfer_id, grouped_entries|
if transfer_id.nil? || grouped_entries.size == 1
grouped_entries
else
grouped_entries.reject do |e|
e.entryable_type == "Account::Transaction" &&
e.entryable.transfer_as_inflow.present?
end
end
end
deduped_entries.group_by(&:date).map do |date, grouped_entries|
content = capture do
yield grouped_entries
end

View File

@@ -17,7 +17,7 @@ module FormsHelper
end
end
def period_select(form:, selected:, classes: "border border-tertiary shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
def period_select(form:, selected:, classes: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
periods_for_select = Period.all.map { |period| [ period.label_short, period.key ] }
form.select(:period, periods_for_select, { selected: selected.key }, class: classes, data: { "auto-submit-form-target": "auto" })

View File

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

View File

@@ -1,6 +1,7 @@
module UpgradesHelper
def get_upgrade_for_notification(user, upgrades_mode)
return nil unless ENV["UPGRADES_ENABLED"] == "true"
return nil unless user.present?
completed_upgrade = Upgrader.completed_upgrade
return completed_upgrade if completed_upgrade && user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha

View File

@@ -0,0 +1,206 @@
import { Controller } from "@hotwired/stimulus"
import Pickr from '@simonwep/pickr'
export default class extends Controller {
static targets = ["pickerBtn", "colorInput", "colorsSection", "paletteSection", "pickerSection", "colorPreview", "avatar", "details", "icon","validationMessage","selection","colorPickerRadioBtn"];
static values = {
presetColors: Array,
};
initialize() {
this.pickerBtnTarget.addEventListener('click', () => {
this.showPaletteSection();
});
this.colorInputTarget.addEventListener('input', (e) => {
this.picker.setColor(e.target.value);
});
this.detailsTarget.addEventListener('toggle', (e) => {
if (!this.colorInputTarget.checkValidity()) {
e.preventDefault();
this.colorInputTarget.reportValidity();
e.target.open = true;
}
});
this.selectedIcon = null;
if (!this.presetColorsValue.includes(this.colorInputTarget.value)) {
this.colorPickerRadioBtnTarget.checked = true;
}
}
initPicker() {
const pickerContainer = document.createElement("div");
pickerContainer.classList.add("pickerContainer");
this.pickerSectionTarget.append(pickerContainer);
this.picker = Pickr.create({
el: this.pickerBtnTarget,
theme: 'monolith',
container: ".pickerContainer",
useAsButton: true,
showAlways: true,
default: this.colorInputTarget.value,
components: {
hue: true,
},
});
this.picker.on('change', (color) => {
const hexColor = color.toHEXA().toString();
const rgbacolor = color.toRGBA();
this.updateAvatarColors(hexColor);
this.updateSelectedIconColor(hexColor);
const backgroundColor = this.backgroundColor(rgbacolor, 10);
const contrastRatio = this.contrast(rgbacolor, backgroundColor);
this.colorInputTarget.value = hexColor;
this.colorInputTarget.dataset.colorPickerColorValue = hexColor;
this.colorPreviewTarget.style.backgroundColor = hexColor;
this.handleContrastValidation(contrastRatio);
});
}
updateAvatarColors(color) {
this.avatarTarget.style.backgroundColor = `${this.#backgroundColor(color)}`;
this.avatarTarget.style.color = color;
}
handleIconColorChange(e) {
const selectedIcon = e.target;
this.selectedIcon = selectedIcon;
const currentColor = this.colorInputTarget.value;
this.iconTargets.forEach(icon => {
const iconWrapper = icon.nextElementSibling;
iconWrapper.style.removeProperty("background-color")
iconWrapper.style.color = "black";
});
this.updateSelectedIconColor(currentColor);
}
handleIconChange(e) {
const iconSVG = e.currentTarget.closest('label').querySelector('svg').cloneNode(true);
this.avatarTarget.innerHTML = '';
iconSVG.style.padding = "0px"
iconSVG.classList.add("w-8","h-8")
this.avatarTarget.appendChild(iconSVG);
}
updateSelectedIconColor(color) {
if (this.selectedIcon) {
const iconWrapper = this.selectedIcon.nextElementSibling;
iconWrapper.style.backgroundColor = `${this.#backgroundColor(color)}`;
iconWrapper.style.color = color;
}
}
handleColorChange(e) {
const color = e.currentTarget.value;
this.colorInputTarget.value = color;
this.colorPreviewTarget.style.backgroundColor = color;
this.updateAvatarColors(color);
this.updateSelectedIconColor(color);
}
handleContrastValidation(contrastRatio) {
if (contrastRatio < 4.5) {
this.colorInputTarget.setCustomValidity("Poor contrast, choose darker color or auto-adjust.");
this.validationMessageTarget.classList.remove("hidden");
} else {
this.colorInputTarget.setCustomValidity("");
this.validationMessageTarget.classList.add("hidden");
}
}
autoAdjust(e){
const currentRGBA = this.picker.getColor();
const adjustedRGBA = this.darkenColor(currentRGBA).toString();
this.picker.setColor(adjustedRGBA);
}
handleParentChange(e) {
const parent = e.currentTarget.value;
const display = typeof parent === "string" && parent !== "" ? "none" : "flex";
this.selectionTarget.style.display = display;
}
backgroundColor([r,g,b,a], percentage) {
const mixedR = Math.round((r * (percentage / 100)) + (255 * (1 - percentage / 100)));
const mixedG = Math.round((g * (percentage / 100)) + (255 * (1 - percentage / 100)));
const mixedB = Math.round((b * (percentage / 100)) + (255 * (1 - percentage / 100)));
return [mixedR, mixedG, mixedB];
}
luminance([r,g,b]) {
const toLinear = c => {
const scaled = c / 255;
return scaled <= 0.04045
? scaled / 12.92
: ((scaled + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
contrast(foregroundColor, backgroundColor) {
const fgLum = this.luminance(foregroundColor);
const bgLum = this.luminance(backgroundColor);
const [l1, l2] = [Math.max(fgLum, bgLum), Math.min(fgLum, bgLum)];
return (l1 + 0.05) / (l2 + 0.05);
}
darkenColor(color) {
let darkened = color.toRGBA();
const backgroundColor = this.backgroundColor(darkened, 10);
let contrastRatio = this.contrast(darkened, backgroundColor);
while (contrastRatio < 4.5 && (darkened[0] > 0 || darkened[1] > 0 || darkened[2] > 0)) {
darkened = [
Math.max(0, darkened[0] - 10),
Math.max(0, darkened[1] - 10),
Math.max(0, darkened[2] - 10),
darkened[3]
];
contrastRatio = this.contrast(darkened, backgroundColor);
}
return `rgba(${darkened.join(", ")})`;
}
showPaletteSection() {
this.initPicker();
this.colorsSectionTarget.classList.add('hidden');
this.paletteSectionTarget.classList.remove('hidden');
this.pickerSectionTarget.classList.remove('hidden');
this.picker.show();
}
showColorsSection() {
this.colorsSectionTarget.classList.remove('hidden');
this.paletteSectionTarget.classList.add('hidden');
this.pickerSectionTarget.classList.add('hidden');
if (this.picker) {
this.picker.destroyAndRemove();
}
}
toggleSections() {
if (this.colorsSectionTarget.classList.contains('hidden')) {
this.showColorsSection();
} else {
this.showPaletteSection();
}
}
#backgroundColor(color) {
return `color-mix(in oklab, ${color} 10%, transparent)`;
}
}

View File

@@ -0,0 +1,32 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="chat-scroll"
export default class extends Controller {
static targets = ["form", "messages"];
connect() {
this.scrollToBottom();
this.observer = new MutationObserver(() => {
this.scrollToBottom();
this.clearInput();
});
this.observer.observe(this.messagesTarget, {
childList: true,
subtree: true,
});
}
disconnect() {
this.observer.disconnect();
}
scrollToBottom() {
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight;
}
clearInput() {
this.formTarget.querySelector("textarea").value = "";
}
}

View File

@@ -21,14 +21,8 @@ export default class extends Controller {
handleColorChange(e) {
const color = e.currentTarget.value;
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 5%, white)`;
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, white)`;
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
this.avatarTarget.style.color = color;
}
handleParentChange(e) {
const parent = e.currentTarget.value;
const visibility = typeof parent === "string" && parent !== "" ? "hidden" : "visible"
this.selectionTarget.style.visibility = visibility
}
}

View File

@@ -4,6 +4,10 @@ import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["input", "list", "emptyMessage"];
connect() {
this.inputTarget.focus();
}
filter() {
const filterValue = this.inputTarget.value.toLowerCase();
const items = this.listTarget.querySelectorAll(".filterable-item");

View File

@@ -8,7 +8,7 @@ export default class extends Controller {
toggle() {
this.panelTarget.classList.toggle("w-0");
this.panelTarget.classList.toggle("opacity-0");
this.panelTarget.classList.toggle("w-[260px]");
this.panelTarget.classList.toggle("w-80");
this.panelTarget.classList.toggle("opacity-100");
this.contentTarget.classList.toggle("max-w-4xl");
this.contentTarget.classList.toggle("max-w-5xl");

View File

@@ -446,7 +446,7 @@ export default class extends Controller {
get _margin() {
if (this.useLabelsValue) {
return { top: 20, right: 0, bottom: 30, left: 0 };
return { top: 20, right: 0, bottom: 10, left: 0 };
}
return { top: 0, right: 0, bottom: 0, left: 0 };
}

View File

@@ -0,0 +1,7 @@
class AiResponseJob < ApplicationJob
queue_as :default
def perform(message)
message.chat.generate_next_ai_response
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
module Account::Convertible
extend ActiveSupport::Concern
def sync_required_exchange_rates
unless requires_exchange_rates?
Rails.logger.info("No exchange rate sync needed for account #{id}")
return
end
rates = ExchangeRate.find_rates(
from: currency,
to: target_currency,
start_date: start_date,
cache: true # caches from provider to DB
)
Rails.logger.info("Synced #{rates.count} exchange rates for account #{id}")
end
private
def target_currency
family.currency
end
def requires_exchange_rates?
currency != target_currency
end
end

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,20 +31,6 @@ class Account::EntrySearch
query
end
def apply_type_filter(scope, types)
return scope if types.blank?
query = scope
if types.include?("income") && !types.include?("expense")
query = query.where("account_entries.amount < 0")
elsif types.include?("expense") && !types.include?("income")
query = query.where("account_entries.amount >= 0")
end
query
end
def apply_amount_filter(scope, amount, amount_operator)
return scope if amount.blank? || amount_operator.blank?
@@ -76,7 +62,6 @@ class Account::EntrySearch
query = scope.joins(:account)
query = self.class.apply_search_filter(query, search)
query = self.class.apply_date_filters(query, start_date, end_date)
query = self.class.apply_type_filter(query, types)
query = self.class.apply_amount_filter(query, amount, amount_operator)
query = self.class.apply_accounts_filter(query, accounts, account_ids)
query

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,39 +14,88 @@ class Account::TransactionSearch
attribute :merchants, array: true
attribute :tags, array: true
# Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry
def build_query(scope)
query = scope.joins(entry: :account)
.joins(transfer_join)
if types.present? && types.exclude?("transfer")
query = query.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_entries.id OR transfers.outflow_transaction_id = account_entries.id")
.where("transfers.id IS NULL")
end
if categories.present?
if categories.exclude?("Uncategorized")
query = query
.joins(:category)
.where(categories: { name: categories })
else
query = query
.left_joins(:category)
.where(categories: { name: categories })
.or(query.where(category_id: nil))
end
end
query = query.joins(:merchant).where(merchants: { name: merchants }) if merchants.present?
query = query.joins(:tags).where(tags: { name: tags }) if tags.present?
# Apply common entry search filters
query = apply_category_filter(query, categories)
query = apply_type_filter(query, types)
query = apply_merchant_filter(query, merchants)
query = apply_tag_filter(query, tags)
query = Account::EntrySearch.apply_search_filter(query, search)
query = Account::EntrySearch.apply_date_filters(query, start_date, end_date)
query = Account::EntrySearch.apply_type_filter(query, types)
query = Account::EntrySearch.apply_amount_filter(query, amount, amount_operator)
query = Account::EntrySearch.apply_accounts_filter(query, accounts, account_ids)
query
end
private
def transfer_join
<<~SQL
LEFT JOIN (
SELECT t.*, t.id as transfer_id, a.accountable_type
FROM transfers t
JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id
AND ae.entryable_type = 'Account::Transaction'
JOIN accounts a ON a.id = ae.account_id
) transfer_info ON (
transfer_info.inflow_transaction_id = account_transactions.id OR
transfer_info.outflow_transaction_id = account_transactions.id
)
SQL
end
def apply_category_filter(query, categories)
return query unless categories.present?
query = query.left_joins(:category).where(
"categories.name IN (?) OR (
categories.id IS NULL AND (transfer_info.transfer_id IS NULL OR transfer_info.accountable_type = 'Loan')
)",
categories
)
if categories.exclude?("Uncategorized")
query = query.where.not(category_id: nil)
end
query
end
def apply_type_filter(query, types)
return query unless types.present?
return query if types.sort == [ "expense", "income", "transfer" ]
transfer_condition = "transfer_info.transfer_id IS NOT NULL"
expense_condition = "account_entries.amount >= 0"
income_condition = "account_entries.amount <= 0"
condition = case types.sort
when [ "transfer" ]
transfer_condition
when [ "expense" ]
Arel.sql("#{expense_condition} AND NOT (#{transfer_condition})")
when [ "income" ]
Arel.sql("#{income_condition} AND NOT (#{transfer_condition})")
when [ "expense", "transfer" ]
Arel.sql("#{expense_condition} OR #{transfer_condition}")
when [ "income", "transfer" ]
Arel.sql("#{income_condition} OR #{transfer_condition}")
when [ "expense", "income" ]
Arel.sql("NOT (#{transfer_condition})")
end
query.where(condition)
end
def apply_merchant_filter(query, merchants)
return query unless merchants.present?
query.joins(:merchant).where(merchants: { name: merchants })
end
def apply_tag_filter(query, tags)
return query unless tags.present?
query.joins(:tags).where(tags: { name: tags })
end
end

View File

@@ -54,7 +54,7 @@ class Budget < ApplicationRecord
end
def period
Period.new(start_date: start_date, end_date: end_date)
Period.custom(start_date: start_date, end_date: end_date)
end
def to_param
@@ -100,11 +100,11 @@ class Budget < ApplicationRecord
end
def income_category_totals
income_totals.category_totals.reject { |ct| ct.category.subcategory? }.sort_by(&:weight).reverse
income_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse
end
def expense_category_totals
expense_totals.category_totals.reject { |ct| ct.category.subcategory? }.sort_by(&:weight).reverse
expense_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse
end
def current?

View File

@@ -8,13 +8,13 @@ class Category < ApplicationRecord
has_many :subcategories, class_name: "Category", foreign_key: :parent_id, dependent: :nullify
belongs_to :parent, class_name: "Category", optional: true
validates :name, :color, :family, presence: true
validates :name, :color, :lucide_icon, :family, presence: true
validates :name, uniqueness: { scope: :family_id }
validate :category_level_limit
validate :nested_category_matches_parent_classification
before_create :inherit_color_from_parent
before_save :inherit_color_from_parent
scope :alphabetically, -> { order(:name) }
scope :roots, -> { where(parent_id: nil) }

61
app/models/chat.rb Normal file
View File

@@ -0,0 +1,61 @@
class Chat < ApplicationRecord
belongs_to :user
has_many :messages, dependent: :destroy
has_one :user_current_chat, class_name: "User", foreign_key: :current_chat_id, dependent: :nullify
validates :title, presence: true
scope :ordered, -> { order(created_at: :desc) }
class << self
def create_with_defaults!
create!(
title: "New chat #{Time.current.strftime("%Y-%m-%d %H:%M:%S")}",
messages: [
Message.new(
role: "system",
content: "You are a helpful personal finance assistant.",
)
]
)
end
end
def generate_next_ai_response
if messages.conversation.ordered.last&.role == "assistant"
Rails.logger.info("Skipping response because last message was an assistant message")
return
end
openai.chat(
parameters: {
model: "gpt-4o-mini",
stream: streamer,
n: 1,
messages: messages.conversation.order(:created_at).map do |message|
{
role: message.role,
content: message.content
}
end
}
)
end
private
def openai
OpenAI::Client.new(access_token: ENV["OPENAI_ACCESS_TOKEN"])
end
def streamer
message = messages.create!(
role: "assistant",
content: ""
)
proc do |chunk, _bytesize|
new_content = chunk.dig("choices", 0, "delta", "content")
message.update(content: message.content + new_content) if new_content
end
end
end

View File

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

View File

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

View File

@@ -61,30 +61,83 @@ class Demo::Generator
puts "Demo data loaded successfully!"
end
def generate_multi_currency_data!
def generate_basic_budget_data!(family_names)
puts "Clearing existing data..."
destroy_everything!
puts "Data cleared"
create_family_and_user!("Demo Family 1", "user@maybe.local", currency: "EUR")
family = Family.find_by(name: "Demo Family 1")
family_names.each_with_index do |family_name, index|
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local")
end
puts "Users reset"
usd_checking = family.accounts.create!(name: "USD Checking", currency: "USD", balance: 10000, accountable: Depository.new)
eur_checking = family.accounts.create!(name: "EUR Checking", currency: "EUR", balance: 4900, accountable: Depository.new)
family_names.each do |family_name|
family = Family.find_by(name: family_name)
puts "Accounts created"
ActiveRecord::Base.transaction do
# Create parent categories
food = family.categories.create!(name: "Food & Drink", color: COLORS.sample, classification: "expense")
transport = family.categories.create!(name: "Transportation", color: COLORS.sample, classification: "expense")
create_transaction!(account: usd_checking, amount: -11000, currency: "USD", name: "USD income Transaction")
create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction")
create_transaction!(account: eur_checking, amount: -5000, currency: "EUR", name: "EUR income Transaction")
create_transaction!(account: eur_checking, amount: 100, currency: "EUR", name: "EUR expense Transaction")
# Create subcategory
restaurants = family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense")
puts "Transactions created"
# Create checking account
checking = family.accounts.create!(
accountable: Depository.new,
name: "Demo Checking",
balance: 3000,
currency: "USD"
)
# Create one transaction for each category
create_transaction!(account: checking, amount: 100, name: "Grocery Store", category: food, date: 2.days.ago)
create_transaction!(account: checking, amount: 50, name: "Restaurant Meal", category: restaurants, date: 1.day.ago)
create_transaction!(account: checking, amount: 20, name: "Gas Station", category: transport, date: Date.current)
end
puts "Basic budget data created for #{family_name}"
end
puts "Demo data loaded successfully!"
end
def generate_multi_currency_data!(family_names)
puts "Clearing existing data..."
destroy_everything!
puts "Data cleared"
family_names.each_with_index do |family_name, index|
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", currency: "EUR")
end
puts "Users reset"
family_names.each do |family_name|
puts "Generating demo data for #{family_name}"
family = Family.find_by(name: family_name)
usd_checking = family.accounts.create!(name: "USD Checking", currency: "USD", balance: 10000, accountable: Depository.new)
eur_checking = family.accounts.create!(name: "EUR Checking", currency: "EUR", balance: 4900, accountable: Depository.new)
eur_credit_card = family.accounts.create!(name: "EUR Credit Card", currency: "EUR", balance: 2300, accountable: CreditCard.new)
create_transaction!(account: eur_credit_card, amount: 1000, currency: "EUR", name: "EUR cc expense 1")
create_transaction!(account: eur_credit_card, amount: 1000, currency: "EUR", name: "EUR cc expense 2")
create_transaction!(account: eur_credit_card, amount: 300, currency: "EUR", name: "EUR cc expense 3")
create_transaction!(account: usd_checking, amount: -11000, currency: "USD", name: "USD income Transaction")
create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction")
create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction")
create_transaction!(account: eur_checking, amount: -5000, currency: "EUR", name: "EUR income Transaction")
create_transaction!(account: eur_checking, amount: 100, currency: "EUR", name: "EUR expense Transaction")
puts "Transactions created for #{family_name}"
end
puts "Demo data loaded successfully!"
end
@@ -142,9 +195,9 @@ class Demo::Generator
family.categories.bootstrap_defaults
food = family.categories.find_by(name: "Food & Drink")
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense")
family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, classification: "expense")
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense")
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, lucide_icon: "utensils", classification: "expense")
family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, lucide_icon: "shopping-cart", classification: "expense")
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, lucide_icon: "beer", classification: "expense")
end
def create_merchants!(family)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ class IncomeStatement
total_expense = result.select { |t| t.classification == "expense" }.sum(&:total)
ScopeTotals.new(
transactions_count: transactions_scope.count,
transactions_count: result.sum(&:transactions_count),
income_money: Money.new(total_income, family.currency),
expense_money: Money.new(total_expense, family.currency),
missing_exchange_rates?: result.any?(&:missing_exchange_rates?)
@@ -66,21 +66,25 @@ class IncomeStatement
totals = totals_query(transactions_scope: family.transactions.active.in_period(period)).select { |t| t.classification == classification }
classification_total = totals.sum(&:total)
category_totals = totals.map do |ct|
# If parent category is nil, it's a top-level category. This means we need to
# sum itself + SUM(children) to get the overall category total
children_totals = if ct.parent_category_id.nil? && ct.category_id.present?
totals.select { |t| t.parent_category_id == ct.category_id }.sum(&:total)
else
uncategorized_category = family.categories.uncategorized
category_totals = [ *categories, uncategorized_category ].map do |category|
subcategory = categories.find { |c| c.id == category.parent_id }
parent_category_total = totals.select { |t| t.category_id == category.id }&.sum(&:total) || 0
children_totals = if category == uncategorized_category
0
else
totals.select { |t| t.parent_category_id == category.id }&.sum(&:total) || 0
end
category_total = ct.total + children_totals
category_total = parent_category_total + children_totals
weight = (category_total.zero? ? 0 : category_total.to_f / classification_total) * 100
CategoryTotal.new(
category: categories.find { |c| c.id == ct.category_id } || family.categories.uncategorized,
category: category,
total: category_total,
currency: family.currency,
weight: weight,

View File

@@ -8,6 +8,7 @@ module IncomeStatement::BaseQuery
date_trunc(:interval, ae.date) as date,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
SUM(ae.amount * COALESCE(er.rate, 1)) as total,
COUNT(ae.id) as transactions_count,
BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates
FROM (#{transactions_scope.to_sql}) at
JOIN account_entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Account::Transaction'
@@ -29,7 +30,7 @@ module IncomeStatement::BaseQuery
)
WHERE (
transfer_info.transfer_id IS NULL OR
(ae.amount < 0 AND transfer_info.accountable_type = 'Loan')
(ae.amount > 0 AND transfer_info.accountable_type = 'Loan')
)
GROUP BY 1, 2, 3, 4
SQL

View File

@@ -13,13 +13,14 @@ class IncomeStatement::Totals
category_id: row["category_id"],
classification: row["classification"],
total: row["total"],
transactions_count: row["transactions_count"],
missing_exchange_rates?: row["missing_exchange_rates"]
)
end
end
private
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :missing_exchange_rates?)
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :missing_exchange_rates?)
def query_sql
base_sql = base_query_sql(family: @family, interval: "day", transactions_scope: @transactions_scope)
@@ -33,7 +34,8 @@ class IncomeStatement::Totals
category_id,
classification,
ABS(SUM(total)) as total,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
BOOL_OR(missing_exchange_rates) as missing_exchange_rates,
SUM(transactions_count) as transactions_count
FROM base_totals
GROUP BY 1, 2, 3;
SQL

33
app/models/message.rb Normal file
View File

@@ -0,0 +1,33 @@
class Message < ApplicationRecord
belongs_to :chat
enum :role, { user: "user", assistant: "assistant", system: "system" }
validates :content, presence: true, allow_blank: true
validates :role, presence: true
scope :conversation, -> { where(debug_mode: false, role: [ :user, :assistant ]) }
scope :ordered, -> { order(created_at: :asc) }
after_create_commit :broadcast_to_chat
after_update_commit :broadcast_update_to_chat
private
def broadcast_to_chat
broadcast_append_to(
chat,
partial: "messages/message",
locals: { message: self },
target: "chat_#{chat.id}_messages"
)
end
def broadcast_update_to_chat
broadcast_update_to(
chat,
partial: "messages/message",
locals: { message: self },
target: "message_#{self.id}"
)
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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