mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Initial commit of new old codebase
This commit is contained in:
commit
1f69793928
332 changed files with 8841 additions and 0 deletions
39
.dockerignore
Normal file
39
.dockerignore
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files.
|
||||||
|
|
||||||
|
# Ignore git directory.
|
||||||
|
/.git/
|
||||||
|
|
||||||
|
# Ignore bundler config.
|
||||||
|
/.bundle
|
||||||
|
|
||||||
|
# Ignore all default key files.
|
||||||
|
/config/master.key
|
||||||
|
/config/credentials/*.key
|
||||||
|
|
||||||
|
# Ignore all environment files.
|
||||||
|
/.env*
|
||||||
|
!/.env.example
|
||||||
|
|
||||||
|
# Ignore all logfiles and tempfiles.
|
||||||
|
/log/*
|
||||||
|
/tmp/*
|
||||||
|
!/log/.keep
|
||||||
|
!/tmp/.keep
|
||||||
|
|
||||||
|
# Ignore pidfiles, but keep the directory.
|
||||||
|
/tmp/pids/*
|
||||||
|
!/tmp/pids/
|
||||||
|
!/tmp/pids/.keep
|
||||||
|
|
||||||
|
# Ignore storage (uploaded files in development and any SQLite databases).
|
||||||
|
/storage/*
|
||||||
|
!/storage/.keep
|
||||||
|
/tmp/storage/*
|
||||||
|
!/tmp/storage/
|
||||||
|
!/tmp/storage/.keep
|
||||||
|
|
||||||
|
# Ignore assets.
|
||||||
|
/node_modules/
|
||||||
|
/app/assets/builds/*
|
||||||
|
!/app/assets/builds/.keep
|
||||||
|
/public/assets
|
15
.env.example
Normal file
15
.env.example
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
PLAID_CLIENT_ID=
|
||||||
|
PLAID_SECRET=
|
||||||
|
PLAID_ENVIRONMENT=
|
||||||
|
PLAID_REDIRECT_URI=
|
||||||
|
OPENAI_ACCESS_TOKEN=
|
||||||
|
TWELVEDATA_KEY=
|
||||||
|
NTROPY_KEY=
|
||||||
|
POSTMARK_API_TOKEN=
|
||||||
|
READONLY_DATABASE_URL=
|
||||||
|
STRIPE_PUBLIC_KEY=
|
||||||
|
STRIPE_PRIVATE_KEY=
|
||||||
|
STRIPE_SIGNING_SECRET=
|
||||||
|
STRIPE_PRICE_ID=
|
||||||
|
POLYGON_KEY=
|
||||||
|
SCRAPING_BEE_KEY=
|
9
.gitattributes
vendored
Normal file
9
.gitattributes
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# See https://git-scm.com/docs/gitattributes for more about git attribute files.
|
||||||
|
|
||||||
|
# Mark the database schema as having been generated.
|
||||||
|
db/schema.rb linguist-generated
|
||||||
|
|
||||||
|
# Mark any vendored files as having been vendored.
|
||||||
|
vendor/* linguist-vendored
|
||||||
|
config/credentials/*.yml.enc diff=rails_credentials
|
||||||
|
config/credentials.yml.enc diff=rails_credentials
|
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
|
||||||
|
#
|
||||||
|
# If you find yourself ignoring temporary files generated by your text editor
|
||||||
|
# or operating system, you probably want to add a global ignore instead:
|
||||||
|
# git config --global core.excludesfile '~/.gitignore_global'
|
||||||
|
|
||||||
|
# Ignore bundler config.
|
||||||
|
/.bundle
|
||||||
|
|
||||||
|
# Ignore all logfiles and tempfiles.
|
||||||
|
/log/*
|
||||||
|
/tmp/*
|
||||||
|
!/log/.keep
|
||||||
|
!/tmp/.keep
|
||||||
|
|
||||||
|
# Ignore pidfiles, but keep the directory.
|
||||||
|
/tmp/pids/*
|
||||||
|
!/tmp/pids/
|
||||||
|
!/tmp/pids/.keep
|
||||||
|
|
||||||
|
# Ignore storage (uploaded files in development and any SQLite databases).
|
||||||
|
/storage/*
|
||||||
|
!/storage/.keep
|
||||||
|
/tmp/storage/*
|
||||||
|
!/tmp/storage/
|
||||||
|
!/tmp/storage/.keep
|
||||||
|
|
||||||
|
/public/assets
|
||||||
|
|
||||||
|
# Ignore master key for decrypting credentials and more.
|
||||||
|
/config/master.key
|
||||||
|
|
||||||
|
/app/assets/builds/*
|
||||||
|
!/app/assets/builds/.keep
|
||||||
|
.env
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
SCRATCH.*
|
1
.ruby-version
Normal file
1
.ruby-version
Normal file
|
@ -0,0 +1 @@
|
||||||
|
3.1.3
|
63
Dockerfile
Normal file
63
Dockerfile
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
# syntax = docker/dockerfile:1
|
||||||
|
|
||||||
|
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
|
||||||
|
ARG RUBY_VERSION=3.1.3
|
||||||
|
FROM ruby:$RUBY_VERSION-slim as base
|
||||||
|
|
||||||
|
# Rails app lives here
|
||||||
|
WORKDIR /rails
|
||||||
|
|
||||||
|
# Set production environment
|
||||||
|
ENV RAILS_ENV="production" \
|
||||||
|
BUNDLE_DEPLOYMENT="1" \
|
||||||
|
BUNDLE_PATH="/usr/local/bundle" \
|
||||||
|
BUNDLE_WITHOUT="development"
|
||||||
|
|
||||||
|
|
||||||
|
# Throw-away build stage to reduce size of final image
|
||||||
|
FROM base as build
|
||||||
|
|
||||||
|
# Install packages needed to build gems
|
||||||
|
RUN apt-get update -qq && \
|
||||||
|
apt-get install --no-install-recommends -y build-essential default-libmysqlclient-dev git libpq-dev libvips pkg-config
|
||||||
|
|
||||||
|
# Install application gems
|
||||||
|
COPY Gemfile Gemfile.lock ./
|
||||||
|
RUN bundle install && \
|
||||||
|
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
|
||||||
|
bundle exec bootsnap precompile --gemfile
|
||||||
|
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Precompile bootsnap code for faster boot times
|
||||||
|
RUN bundle exec bootsnap precompile app/ lib/
|
||||||
|
|
||||||
|
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
|
||||||
|
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
||||||
|
|
||||||
|
|
||||||
|
# Final stage for app image
|
||||||
|
FROM base
|
||||||
|
|
||||||
|
# Install packages needed for deployment
|
||||||
|
RUN apt-get update -qq && \
|
||||||
|
apt-get install --no-install-recommends -y default-mysql-client libsqlite3-0 libvips postgresql-client && \
|
||||||
|
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||||
|
|
||||||
|
# Copy built artifacts: gems, application
|
||||||
|
COPY --from=build /usr/local/bundle /usr/local/bundle
|
||||||
|
COPY --from=build /rails /rails
|
||||||
|
|
||||||
|
# Run and own only the runtime files as a non-root user for security
|
||||||
|
RUN useradd rails --home /rails --shell /bin/bash && \
|
||||||
|
chown -R rails:rails db log storage tmp
|
||||||
|
USER rails:rails
|
||||||
|
|
||||||
|
# Entrypoint prepares the database.
|
||||||
|
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
|
||||||
|
|
||||||
|
# Start the server by default, this can be overwritten at runtime
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["./bin/rails", "server"]
|
99
Gemfile
Normal file
99
Gemfile
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
source "https://rubygems.org"
|
||||||
|
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
|
||||||
|
|
||||||
|
ruby "3.1.3"
|
||||||
|
|
||||||
|
# Use main development branch of Rails
|
||||||
|
gem "rails", github: "rails/rails", branch: "main"
|
||||||
|
|
||||||
|
# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
|
||||||
|
gem "sprockets-rails"
|
||||||
|
|
||||||
|
# Use postgresql as the database for Active Record
|
||||||
|
gem "pg", "~> 1.1"
|
||||||
|
|
||||||
|
# Use the Puma web server [https://github.com/puma/puma]
|
||||||
|
gem "puma", ">= 5.0"
|
||||||
|
|
||||||
|
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
|
||||||
|
gem "importmap-rails"
|
||||||
|
|
||||||
|
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
|
||||||
|
gem "turbo-rails"
|
||||||
|
|
||||||
|
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
|
||||||
|
gem "stimulus-rails"
|
||||||
|
|
||||||
|
# Use Tailwind CSS [https://github.com/rails/tailwindcss-rails]
|
||||||
|
gem "tailwindcss-rails"
|
||||||
|
|
||||||
|
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
|
||||||
|
gem "jbuilder"
|
||||||
|
|
||||||
|
# Use Redis adapter to run Action Cable in production
|
||||||
|
gem "redis", ">= 4.0.1"
|
||||||
|
|
||||||
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
|
|
||||||
|
# Reduces boot times through caching; required in config/boot.rb
|
||||||
|
gem "bootsnap", require: false
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
gem 'devise'
|
||||||
|
gem 'omniauth'
|
||||||
|
gem 'omniauth-rails_csrf_protection'
|
||||||
|
gem 'wicked'
|
||||||
|
|
||||||
|
# Data
|
||||||
|
gem 'plaid'
|
||||||
|
gem 'money'
|
||||||
|
|
||||||
|
# Background jobs
|
||||||
|
gem 'sidekiq'
|
||||||
|
|
||||||
|
# External API
|
||||||
|
gem 'faraday'
|
||||||
|
gem 'geocoder'
|
||||||
|
|
||||||
|
# AI
|
||||||
|
gem "ruby-openai"
|
||||||
|
|
||||||
|
# Content
|
||||||
|
gem 'redcarpet'
|
||||||
|
|
||||||
|
# Messaging
|
||||||
|
gem 'postmark-rails'
|
||||||
|
|
||||||
|
# Error reporting
|
||||||
|
gem "sentry-ruby"
|
||||||
|
gem "sentry-rails"
|
||||||
|
gem "sentry-sidekiq"
|
||||||
|
|
||||||
|
# Billing
|
||||||
|
gem "pay", "~> 6.0"
|
||||||
|
gem "stripe", "~> 9.0"
|
||||||
|
|
||||||
|
# Miscellanous
|
||||||
|
gem "country_select"
|
||||||
|
gem "currency_select"
|
||||||
|
|
||||||
|
group :development, :test do
|
||||||
|
gem "debug", platforms: %i[ mri windows ]
|
||||||
|
gem 'dotenv-rails'
|
||||||
|
#gem 'devise-tailwindcssed' # Use devise views with tailwindcss
|
||||||
|
end
|
||||||
|
|
||||||
|
group :development do
|
||||||
|
gem "web-console"
|
||||||
|
gem 'solargraph'
|
||||||
|
gem "hotwire-livereload"
|
||||||
|
gem "error_highlight", ">= 0.4.0", platforms: [:ruby]
|
||||||
|
end
|
||||||
|
|
||||||
|
group :test do
|
||||||
|
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||||
|
gem "capybara"
|
||||||
|
gem "selenium-webdriver"
|
||||||
|
gem "webdrivers"
|
||||||
|
end
|
453
Gemfile.lock
Normal file
453
Gemfile.lock
Normal file
|
@ -0,0 +1,453 @@
|
||||||
|
GIT
|
||||||
|
remote: https://github.com/rails/rails.git
|
||||||
|
revision: f6f6b0542fd9ae6aebe3529baba358d521d64638
|
||||||
|
branch: main
|
||||||
|
specs:
|
||||||
|
actioncable (7.2.0.alpha)
|
||||||
|
actionpack (= 7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
nio4r (~> 2.0)
|
||||||
|
websocket-driver (>= 0.6.1)
|
||||||
|
zeitwerk (~> 2.6)
|
||||||
|
actionmailbox (7.2.0.alpha)
|
||||||
|
actionpack (= 7.2.0.alpha)
|
||||||
|
activejob (= 7.2.0.alpha)
|
||||||
|
activerecord (= 7.2.0.alpha)
|
||||||
|
activestorage (= 7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
mail (>= 2.7.1)
|
||||||
|
net-imap
|
||||||
|
net-pop
|
||||||
|
net-smtp
|
||||||
|
actionmailer (7.2.0.alpha)
|
||||||
|
actionpack (= 7.2.0.alpha)
|
||||||
|
actionview (= 7.2.0.alpha)
|
||||||
|
activejob (= 7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
mail (~> 2.5, >= 2.5.4)
|
||||||
|
net-imap
|
||||||
|
net-pop
|
||||||
|
net-smtp
|
||||||
|
rails-dom-testing (~> 2.2)
|
||||||
|
actionpack (7.2.0.alpha)
|
||||||
|
actionview (= 7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
nokogiri (>= 1.8.5)
|
||||||
|
racc
|
||||||
|
rack (>= 2.2.4)
|
||||||
|
rack-session (>= 1.0.1)
|
||||||
|
rack-test (>= 0.6.3)
|
||||||
|
rails-dom-testing (~> 2.2)
|
||||||
|
rails-html-sanitizer (~> 1.6)
|
||||||
|
useragent (~> 0.16)
|
||||||
|
actiontext (7.2.0.alpha)
|
||||||
|
actionpack (= 7.2.0.alpha)
|
||||||
|
activerecord (= 7.2.0.alpha)
|
||||||
|
activestorage (= 7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
globalid (>= 0.6.0)
|
||||||
|
nokogiri (>= 1.8.5)
|
||||||
|
actionview (7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
builder (~> 3.1)
|
||||||
|
erubi (~> 1.11)
|
||||||
|
rails-dom-testing (~> 2.2)
|
||||||
|
rails-html-sanitizer (~> 1.6)
|
||||||
|
activejob (7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
globalid (>= 0.3.6)
|
||||||
|
activemodel (7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
activerecord (7.2.0.alpha)
|
||||||
|
activemodel (= 7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
timeout (>= 0.4.0)
|
||||||
|
activestorage (7.2.0.alpha)
|
||||||
|
actionpack (= 7.2.0.alpha)
|
||||||
|
activejob (= 7.2.0.alpha)
|
||||||
|
activerecord (= 7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
marcel (~> 1.0)
|
||||||
|
activesupport (7.2.0.alpha)
|
||||||
|
base64
|
||||||
|
bigdecimal
|
||||||
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
connection_pool (>= 2.2.5)
|
||||||
|
drb
|
||||||
|
i18n (>= 1.6, < 2)
|
||||||
|
minitest (>= 5.1)
|
||||||
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
|
rails (7.2.0.alpha)
|
||||||
|
actioncable (= 7.2.0.alpha)
|
||||||
|
actionmailbox (= 7.2.0.alpha)
|
||||||
|
actionmailer (= 7.2.0.alpha)
|
||||||
|
actionpack (= 7.2.0.alpha)
|
||||||
|
actiontext (= 7.2.0.alpha)
|
||||||
|
actionview (= 7.2.0.alpha)
|
||||||
|
activejob (= 7.2.0.alpha)
|
||||||
|
activemodel (= 7.2.0.alpha)
|
||||||
|
activerecord (= 7.2.0.alpha)
|
||||||
|
activestorage (= 7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
bundler (>= 1.15.0)
|
||||||
|
railties (= 7.2.0.alpha)
|
||||||
|
railties (7.2.0.alpha)
|
||||||
|
actionpack (= 7.2.0.alpha)
|
||||||
|
activesupport (= 7.2.0.alpha)
|
||||||
|
irb
|
||||||
|
rackup (>= 1.0.0)
|
||||||
|
rake (>= 12.2)
|
||||||
|
thor (~> 1.0, >= 1.2.2)
|
||||||
|
zeitwerk (~> 2.6)
|
||||||
|
|
||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
addressable (2.8.6)
|
||||||
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
|
ast (2.4.2)
|
||||||
|
backport (1.2.0)
|
||||||
|
base64 (0.2.0)
|
||||||
|
bcrypt (3.1.20)
|
||||||
|
benchmark (0.3.0)
|
||||||
|
bigdecimal (3.1.5)
|
||||||
|
bindex (0.8.1)
|
||||||
|
bootsnap (1.17.0)
|
||||||
|
msgpack (~> 1.2)
|
||||||
|
builder (3.2.4)
|
||||||
|
capybara (3.39.2)
|
||||||
|
addressable
|
||||||
|
matrix
|
||||||
|
mini_mime (>= 0.1.3)
|
||||||
|
nokogiri (~> 1.8)
|
||||||
|
rack (>= 1.6.0)
|
||||||
|
rack-test (>= 0.6.3)
|
||||||
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
|
xpath (~> 3.2)
|
||||||
|
concurrent-ruby (1.2.2)
|
||||||
|
connection_pool (2.4.1)
|
||||||
|
countries (5.7.1)
|
||||||
|
unaccent (~> 0.3)
|
||||||
|
country_select (8.0.3)
|
||||||
|
countries (~> 5.0)
|
||||||
|
crass (1.0.6)
|
||||||
|
currency_select (6.0.0)
|
||||||
|
actionview (>= 6.1.0, < 7.2)
|
||||||
|
money (~> 6.0)
|
||||||
|
date (3.3.4)
|
||||||
|
debug (1.9.1)
|
||||||
|
irb (~> 1.10)
|
||||||
|
reline (>= 0.3.8)
|
||||||
|
devise (4.9.3)
|
||||||
|
bcrypt (~> 3.0)
|
||||||
|
orm_adapter (~> 0.1)
|
||||||
|
railties (>= 4.1.0)
|
||||||
|
responders
|
||||||
|
warden (~> 1.2.3)
|
||||||
|
diff-lcs (1.5.0)
|
||||||
|
dotenv (2.8.1)
|
||||||
|
dotenv-rails (2.8.1)
|
||||||
|
dotenv (= 2.8.1)
|
||||||
|
railties (>= 3.2)
|
||||||
|
drb (2.2.0)
|
||||||
|
ruby2_keywords
|
||||||
|
e2mmap (0.1.0)
|
||||||
|
error_highlight (0.6.0)
|
||||||
|
erubi (1.12.0)
|
||||||
|
event_stream_parser (1.0.0)
|
||||||
|
faraday (2.8.1)
|
||||||
|
base64
|
||||||
|
faraday-net_http (>= 2.0, < 3.1)
|
||||||
|
ruby2_keywords (>= 0.0.4)
|
||||||
|
faraday-multipart (1.0.4)
|
||||||
|
multipart-post (~> 2)
|
||||||
|
faraday-net_http (3.0.2)
|
||||||
|
ffi (1.16.3)
|
||||||
|
geocoder (1.8.2)
|
||||||
|
globalid (1.2.1)
|
||||||
|
activesupport (>= 6.1)
|
||||||
|
hashie (5.0.0)
|
||||||
|
hotwire-livereload (1.3.1)
|
||||||
|
actioncable (>= 6.0.0)
|
||||||
|
listen (>= 3.0.0)
|
||||||
|
railties (>= 6.0.0)
|
||||||
|
i18n (1.14.1)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
importmap-rails (2.0.1)
|
||||||
|
actionpack (>= 6.0.0)
|
||||||
|
activesupport (>= 6.0.0)
|
||||||
|
railties (>= 6.0.0)
|
||||||
|
io-console (0.7.1)
|
||||||
|
irb (1.11.0)
|
||||||
|
rdoc
|
||||||
|
reline (>= 0.3.8)
|
||||||
|
jaro_winkler (1.5.6)
|
||||||
|
jbuilder (2.11.5)
|
||||||
|
actionview (>= 5.0.0)
|
||||||
|
activesupport (>= 5.0.0)
|
||||||
|
json (2.7.1)
|
||||||
|
kramdown (2.4.0)
|
||||||
|
rexml
|
||||||
|
kramdown-parser-gfm (1.1.0)
|
||||||
|
kramdown (~> 2.0)
|
||||||
|
language_server-protocol (3.17.0.3)
|
||||||
|
listen (3.8.0)
|
||||||
|
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||||
|
rb-inotify (~> 0.9, >= 0.9.10)
|
||||||
|
loofah (2.22.0)
|
||||||
|
crass (~> 1.0.2)
|
||||||
|
nokogiri (>= 1.12.0)
|
||||||
|
mail (2.8.1)
|
||||||
|
mini_mime (>= 0.1.1)
|
||||||
|
net-imap
|
||||||
|
net-pop
|
||||||
|
net-smtp
|
||||||
|
marcel (1.0.2)
|
||||||
|
matrix (0.4.2)
|
||||||
|
mini_mime (1.1.5)
|
||||||
|
minitest (5.20.0)
|
||||||
|
money (6.16.0)
|
||||||
|
i18n (>= 0.6.4, <= 2)
|
||||||
|
msgpack (1.7.2)
|
||||||
|
multipart-post (2.3.0)
|
||||||
|
net-imap (0.4.9)
|
||||||
|
date
|
||||||
|
net-protocol
|
||||||
|
net-pop (0.1.2)
|
||||||
|
net-protocol
|
||||||
|
net-protocol (0.2.2)
|
||||||
|
timeout
|
||||||
|
net-smtp (0.4.0)
|
||||||
|
net-protocol
|
||||||
|
nio4r (2.7.0)
|
||||||
|
nokogiri (1.16.0-aarch64-linux)
|
||||||
|
racc (~> 1.4)
|
||||||
|
nokogiri (1.16.0-arm64-darwin)
|
||||||
|
racc (~> 1.4)
|
||||||
|
nokogiri (1.16.0-x86_64-linux)
|
||||||
|
racc (~> 1.4)
|
||||||
|
omniauth (2.1.2)
|
||||||
|
hashie (>= 3.4.6)
|
||||||
|
rack (>= 2.2.3)
|
||||||
|
rack-protection
|
||||||
|
omniauth-rails_csrf_protection (1.0.1)
|
||||||
|
actionpack (>= 4.2)
|
||||||
|
omniauth (~> 2.0)
|
||||||
|
orm_adapter (0.5.0)
|
||||||
|
parallel (1.24.0)
|
||||||
|
parser (3.2.2.4)
|
||||||
|
ast (~> 2.4.1)
|
||||||
|
racc
|
||||||
|
pay (6.8.1)
|
||||||
|
rails (>= 6.0.0)
|
||||||
|
pg (1.5.4)
|
||||||
|
plaid (24.3.0)
|
||||||
|
faraday (>= 1.0.1, < 3.0)
|
||||||
|
faraday-multipart (>= 1.0.1, < 2.0)
|
||||||
|
postmark (1.25.0)
|
||||||
|
json
|
||||||
|
postmark-rails (0.22.1)
|
||||||
|
actionmailer (>= 3.0.0)
|
||||||
|
postmark (>= 1.21.3, < 2.0)
|
||||||
|
psych (5.1.2)
|
||||||
|
stringio
|
||||||
|
public_suffix (5.0.4)
|
||||||
|
puma (6.4.1)
|
||||||
|
nio4r (~> 2.0)
|
||||||
|
racc (1.7.3)
|
||||||
|
rack (3.0.8)
|
||||||
|
rack-protection (3.0.6)
|
||||||
|
rack
|
||||||
|
rack-session (2.0.0)
|
||||||
|
rack (>= 3.0.0)
|
||||||
|
rack-test (2.1.0)
|
||||||
|
rack (>= 1.3)
|
||||||
|
rackup (2.1.0)
|
||||||
|
rack (>= 3)
|
||||||
|
webrick (~> 1.8)
|
||||||
|
rails-dom-testing (2.2.0)
|
||||||
|
activesupport (>= 5.0.0)
|
||||||
|
minitest
|
||||||
|
nokogiri (>= 1.6)
|
||||||
|
rails-html-sanitizer (1.6.0)
|
||||||
|
loofah (~> 2.21)
|
||||||
|
nokogiri (~> 1.14)
|
||||||
|
rainbow (3.1.1)
|
||||||
|
rake (13.1.0)
|
||||||
|
rb-fsevent (0.11.2)
|
||||||
|
rb-inotify (0.10.1)
|
||||||
|
ffi (~> 1.0)
|
||||||
|
rbs (2.8.4)
|
||||||
|
rdoc (6.6.2)
|
||||||
|
psych (>= 4.0.0)
|
||||||
|
redcarpet (3.6.0)
|
||||||
|
redis (5.0.8)
|
||||||
|
redis-client (>= 0.17.0)
|
||||||
|
redis-client (0.19.1)
|
||||||
|
connection_pool
|
||||||
|
regexp_parser (2.8.3)
|
||||||
|
reline (0.4.1)
|
||||||
|
io-console (~> 0.5)
|
||||||
|
responders (3.1.1)
|
||||||
|
actionpack (>= 5.2)
|
||||||
|
railties (>= 5.2)
|
||||||
|
reverse_markdown (2.1.1)
|
||||||
|
nokogiri
|
||||||
|
rexml (3.2.6)
|
||||||
|
rubocop (1.59.0)
|
||||||
|
json (~> 2.3)
|
||||||
|
language_server-protocol (>= 3.17.0)
|
||||||
|
parallel (~> 1.10)
|
||||||
|
parser (>= 3.2.2.4)
|
||||||
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
|
regexp_parser (>= 1.8, < 3.0)
|
||||||
|
rexml (>= 3.2.5, < 4.0)
|
||||||
|
rubocop-ast (>= 1.30.0, < 2.0)
|
||||||
|
ruby-progressbar (~> 1.7)
|
||||||
|
unicode-display_width (>= 2.4.0, < 3.0)
|
||||||
|
rubocop-ast (1.30.0)
|
||||||
|
parser (>= 3.2.1.0)
|
||||||
|
ruby-openai (6.3.1)
|
||||||
|
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||||
|
faraday (>= 1)
|
||||||
|
faraday-multipart (>= 1)
|
||||||
|
ruby-progressbar (1.13.0)
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
|
rubyzip (2.3.2)
|
||||||
|
selenium-webdriver (4.10.0)
|
||||||
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
|
rubyzip (>= 1.2.2, < 3.0)
|
||||||
|
websocket (~> 1.0)
|
||||||
|
sentry-rails (5.15.2)
|
||||||
|
railties (>= 5.0)
|
||||||
|
sentry-ruby (~> 5.15.2)
|
||||||
|
sentry-ruby (5.15.2)
|
||||||
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
sentry-sidekiq (5.15.2)
|
||||||
|
sentry-ruby (~> 5.15.2)
|
||||||
|
sidekiq (>= 3.0)
|
||||||
|
sidekiq (7.2.0)
|
||||||
|
concurrent-ruby (< 2)
|
||||||
|
connection_pool (>= 2.3.0)
|
||||||
|
rack (>= 2.2.4)
|
||||||
|
redis-client (>= 0.14.0)
|
||||||
|
solargraph (0.50.0)
|
||||||
|
backport (~> 1.2)
|
||||||
|
benchmark
|
||||||
|
bundler (~> 2.0)
|
||||||
|
diff-lcs (~> 1.4)
|
||||||
|
e2mmap
|
||||||
|
jaro_winkler (~> 1.5)
|
||||||
|
kramdown (~> 2.3)
|
||||||
|
kramdown-parser-gfm (~> 1.1)
|
||||||
|
parser (~> 3.0)
|
||||||
|
rbs (~> 2.0)
|
||||||
|
reverse_markdown (~> 2.0)
|
||||||
|
rubocop (~> 1.38)
|
||||||
|
thor (~> 1.0)
|
||||||
|
tilt (~> 2.0)
|
||||||
|
yard (~> 0.9, >= 0.9.24)
|
||||||
|
sprockets (4.2.1)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
rack (>= 2.2.4, < 4)
|
||||||
|
sprockets-rails (3.4.2)
|
||||||
|
actionpack (>= 5.2)
|
||||||
|
activesupport (>= 5.2)
|
||||||
|
sprockets (>= 3.0.0)
|
||||||
|
stimulus-rails (1.3.3)
|
||||||
|
railties (>= 6.0.0)
|
||||||
|
stringio (3.1.0)
|
||||||
|
stripe (9.4.0)
|
||||||
|
tailwindcss-rails (2.1.0-aarch64-linux)
|
||||||
|
railties (>= 6.0.0)
|
||||||
|
tailwindcss-rails (2.1.0-arm64-darwin)
|
||||||
|
railties (>= 6.0.0)
|
||||||
|
tailwindcss-rails (2.1.0-x86_64-linux)
|
||||||
|
railties (>= 6.0.0)
|
||||||
|
thor (1.3.0)
|
||||||
|
tilt (2.3.0)
|
||||||
|
timeout (0.4.1)
|
||||||
|
turbo-rails (1.5.0)
|
||||||
|
actionpack (>= 6.0.0)
|
||||||
|
activejob (>= 6.0.0)
|
||||||
|
railties (>= 6.0.0)
|
||||||
|
tzinfo (2.0.6)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
unaccent (0.4.0)
|
||||||
|
unicode-display_width (2.5.0)
|
||||||
|
useragent (0.16.10)
|
||||||
|
warden (1.2.9)
|
||||||
|
rack (>= 2.0.9)
|
||||||
|
web-console (4.2.1)
|
||||||
|
actionview (>= 6.0.0)
|
||||||
|
activemodel (>= 6.0.0)
|
||||||
|
bindex (>= 0.4.0)
|
||||||
|
railties (>= 6.0.0)
|
||||||
|
webdrivers (5.3.1)
|
||||||
|
nokogiri (~> 1.6)
|
||||||
|
rubyzip (>= 1.3.0)
|
||||||
|
selenium-webdriver (~> 4.0, < 4.11)
|
||||||
|
webrick (1.8.1)
|
||||||
|
websocket (1.2.10)
|
||||||
|
websocket-driver (0.7.6)
|
||||||
|
websocket-extensions (>= 0.1.0)
|
||||||
|
websocket-extensions (0.1.5)
|
||||||
|
wicked (2.0.0)
|
||||||
|
railties (>= 3.0.7)
|
||||||
|
xpath (3.2.0)
|
||||||
|
nokogiri (~> 1.8)
|
||||||
|
yard (0.9.34)
|
||||||
|
zeitwerk (2.6.12)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
aarch64-linux
|
||||||
|
arm64-darwin-22
|
||||||
|
x86_64-linux
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
bootsnap
|
||||||
|
capybara
|
||||||
|
country_select
|
||||||
|
currency_select
|
||||||
|
debug
|
||||||
|
devise
|
||||||
|
dotenv-rails
|
||||||
|
error_highlight (>= 0.4.0)
|
||||||
|
faraday
|
||||||
|
geocoder
|
||||||
|
hotwire-livereload
|
||||||
|
importmap-rails
|
||||||
|
jbuilder
|
||||||
|
money
|
||||||
|
omniauth
|
||||||
|
omniauth-rails_csrf_protection
|
||||||
|
pay (~> 6.0)
|
||||||
|
pg (~> 1.1)
|
||||||
|
plaid
|
||||||
|
postmark-rails
|
||||||
|
puma (>= 5.0)
|
||||||
|
rails!
|
||||||
|
redcarpet
|
||||||
|
redis (>= 4.0.1)
|
||||||
|
ruby-openai
|
||||||
|
selenium-webdriver
|
||||||
|
sentry-rails
|
||||||
|
sentry-ruby
|
||||||
|
sentry-sidekiq
|
||||||
|
sidekiq
|
||||||
|
solargraph
|
||||||
|
sprockets-rails
|
||||||
|
stimulus-rails
|
||||||
|
stripe (~> 9.0)
|
||||||
|
tailwindcss-rails
|
||||||
|
turbo-rails
|
||||||
|
tzinfo-data
|
||||||
|
web-console
|
||||||
|
webdrivers
|
||||||
|
wicked
|
||||||
|
|
||||||
|
RUBY VERSION
|
||||||
|
ruby 3.1.3p185
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.4.10
|
3
Procfile.dev
Normal file
3
Procfile.dev
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
web: bin/rails server -p 5000
|
||||||
|
css: bin/rails tailwindcss:watch
|
||||||
|
worker: bundle exec sidekiq -C config/sidekiq.yml
|
0
README.md
Normal file
0
README.md
Normal file
6
Rakefile
Normal file
6
Rakefile
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
# Add your own tasks in files placed in lib/tasks ending in .rake,
|
||||||
|
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
|
||||||
|
|
||||||
|
require_relative "config/application"
|
||||||
|
|
||||||
|
Rails.application.load_tasks
|
0
app/assets/builds/.keep
Normal file
0
app/assets/builds/.keep
Normal file
5
app/assets/config/manifest.js
Normal file
5
app/assets/config/manifest.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
//= link_tree ../images
|
||||||
|
//= link_directory ../stylesheets .css
|
||||||
|
//= link_tree ../../javascript .js
|
||||||
|
//= link_tree ../../../vendor/javascript .js
|
||||||
|
//= link_tree ../builds
|
BIN
app/assets/fonts/generalsans/GeneralSans-Variable.woff2
Normal file
BIN
app/assets/fonts/generalsans/GeneralSans-Variable.woff2
Normal file
Binary file not shown.
0
app/assets/images/.keep
Normal file
0
app/assets/images/.keep
Normal file
22
app/assets/images/afternoon-gradient.svg
Normal file
22
app/assets/images/afternoon-gradient.svg
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<svg width="375" height="812" viewBox="0 0 375 812" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1436_31242)">
|
||||||
|
<rect width="375" height="812" fill="#F9FAFB"/>
|
||||||
|
<g filter="url(#filter0_f_1436_31242)">
|
||||||
|
<path d="M43.7084 -143.171C67.5702 -114.601 95.9413 -85.5873 124.955 -57.4564C159.689 -23.7787 196.073 10.0672 235.569 42.6982C253.852 57.803 273.319 72.9187 294.698 86.4657C303.187 91.8449 274.65 48.0808 282.046 45.6195C219.572 77.1945 332.128 90.923 334.421 86.9542C338.451 79.9784 341.303 72.4334 343.328 64.472C346.799 50.818 347.861 36.1042 351.887 22.698C353.121 18.5879 354.577 14.5603 356.233 10.6526C357.038 8.75431 357.613 5.98079 359.906 4.90077C363.933 3.00334 374.214 15.0198 375.824 16.7479C385.338 26.9601 393.837 37.1571 404.401 47.286C410.664 53.292 417.727 60.0542 425.643 65.5658C438.772 74.7063 442.505 60.9918 444.676 57.6325C457.282 38.1253 464.074 14.7046 482.732 -1.37946C485.153 -3.46668 487.948 -7.43699 494.091 -6.34644" stroke="url(#paint0_linear_1436_31242)" stroke-width="29" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_f_1436_31242" x="-138.792" y="-325.671" width="815.386" height="595.793" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feGaussianBlur stdDeviation="84" result="effect1_foregroundBlur_1436_31242"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_1436_31242" x1="175" y1="-2.00001" x2="308" y2="82" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#FD9F6E"/>
|
||||||
|
<stop offset="1" stop-color="#FE6719"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_1436_31242">
|
||||||
|
<rect width="375" height="812" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
22
app/assets/images/evening-gradient.svg
Normal file
22
app/assets/images/evening-gradient.svg
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<svg width="375" height="812" viewBox="0 0 375 812" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1556_35503)">
|
||||||
|
<rect width="375" height="812" fill="#F9FAFB"/>
|
||||||
|
<g filter="url(#filter0_f_1556_35503)">
|
||||||
|
<path d="M43.7084 -143.171C67.5702 -114.601 95.9413 -85.5873 124.955 -57.4564C159.689 -23.7787 196.073 10.0672 235.569 42.6982C253.852 57.803 273.319 72.9187 294.698 86.4657C303.187 91.8449 274.65 48.0808 282.046 45.6195C219.572 77.1945 332.128 90.923 334.421 86.9542C338.451 79.9784 341.303 72.4334 343.328 64.472C346.799 50.818 347.861 36.1042 351.887 22.698C353.121 18.5879 354.577 14.5603 356.233 10.6526C357.038 8.75431 357.613 5.98079 359.906 4.90077C363.933 3.00334 374.214 15.0198 375.824 16.7479C385.338 26.9601 393.837 37.1571 404.401 47.286C410.664 53.292 417.727 60.0542 425.643 65.5658C438.772 74.7063 442.505 60.9918 444.676 57.6325C457.282 38.1253 464.074 14.7046 482.732 -1.37946C485.153 -3.46668 487.948 -7.43699 494.091 -6.34644" stroke="url(#paint0_linear_1556_35503)" stroke-width="29" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_f_1556_35503" x="-138.792" y="-325.671" width="815.386" height="595.793" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feGaussianBlur stdDeviation="84" result="effect1_foregroundBlur_1556_35503"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_1556_35503" x1="175" y1="-2.00001" x2="308" y2="82" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#8E9DCD"/>
|
||||||
|
<stop offset="1" stop-color="#3E48A2"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_1556_35503">
|
||||||
|
<rect width="375" height="812" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
19
app/assets/images/logomark.svg
Normal file
19
app/assets/images/logomark.svg
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<svg width="371" height="233" viewBox="0 0 371 233" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_2_280)">
|
||||||
|
<path d="M133.619 43.4224H84.7752C72.7844 43.4224 63.064 33.702 63.064 21.7115C63.064 9.72063 72.7844 -0.000171661 84.7752 -0.000171661H133.619C145.61 -0.000171661 155.33 9.72063 155.33 21.7115C155.33 33.702 145.61 43.4224 133.619 43.4224Z" fill="#19181D"/>
|
||||||
|
<path d="M285.312 43.4224H236.468C224.477 43.4224 214.757 33.702 214.757 21.7115C214.757 9.72063 224.477 -0.000171661 236.468 -0.000171661H285.312C297.303 -0.000171661 307.023 9.72063 307.023 21.7115C307.023 33.702 297.303 43.4224 285.312 43.4224Z" fill="#19181D"/>
|
||||||
|
<path d="M156.562 105.917H64.0199C52.029 105.917 42.3086 96.1961 42.3086 84.2056C42.3086 72.2148 52.029 62.494 64.0199 62.494H156.562C168.553 62.494 178.273 72.2148 178.273 84.2056C178.273 96.1961 168.553 105.917 156.562 105.917Z" fill="#19181D"/>
|
||||||
|
<path d="M219.509 105.917H306.135C318.126 105.917 327.846 96.1961 327.846 84.2056C327.846 72.2148 318.126 62.494 306.135 62.494H219.509C207.518 62.494 197.797 72.2148 197.797 84.2056C197.797 96.1961 207.518 105.917 219.509 105.917Z" fill="#19181D"/>
|
||||||
|
<path d="M72.3277 232.781H21.7109C9.72041 232.781 0 223.06 0 211.069C0 199.079 9.72041 189.358 21.7109 189.358H72.3277C84.3186 189.358 94.039 199.079 94.039 211.069C94.039 223.06 84.3186 232.781 72.3277 232.781Z" fill="#19181D"/>
|
||||||
|
<path d="M298.672 232.781H349.289C361.279 232.781 371 223.06 371 211.069C371 199.079 361.279 189.358 349.289 189.358H298.672C286.681 189.358 276.96 199.079 276.96 211.069C276.96 223.06 286.681 232.781 298.672 232.781Z" fill="#19181D"/>
|
||||||
|
<path d="M205.961 232.641H164.761C152.77 232.641 143.05 222.921 143.05 210.93C143.05 198.939 152.77 189.219 164.761 189.219H205.961C217.952 189.219 227.672 198.939 227.672 210.93C227.672 222.921 217.952 232.641 205.961 232.641Z" fill="#19181D"/>
|
||||||
|
<path d="M91.1608 170.293H42.4272C30.4367 170.293 20.7163 160.573 20.7163 148.583C20.7163 136.592 30.4367 126.871 42.4272 126.871H91.1608C103.152 126.871 112.872 136.592 112.872 148.583C112.872 160.573 103.152 170.293 91.1608 170.293Z" fill="#19181D"/>
|
||||||
|
<path d="M278.994 170.293H327.728C339.718 170.293 349.438 160.573 349.438 148.583C349.438 136.592 339.718 126.871 327.728 126.871H278.994C267.003 126.871 257.283 136.592 257.283 148.583C257.283 160.573 267.003 170.293 278.994 170.293Z" fill="#19181D"/>
|
||||||
|
<path d="M221.622 170.155H149.099C137.109 170.155 127.388 160.434 127.388 148.444C127.388 136.453 137.109 126.732 149.099 126.732H221.622C233.612 126.732 243.333 136.453 243.333 148.444C243.333 160.434 233.612 170.155 221.622 170.155Z" fill="#19181D"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_2_280">
|
||||||
|
<rect width="371" height="233" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
22
app/assets/images/morning-gradient.svg
Normal file
22
app/assets/images/morning-gradient.svg
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<svg width="375" height="812" viewBox="0 0 375 812" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1355_24052)">
|
||||||
|
<rect width="375" height="812" fill="#F9FAFB"/>
|
||||||
|
<g filter="url(#filter0_f_1355_24052)">
|
||||||
|
<path d="M43.7084 -143.171C67.5702 -114.601 95.9413 -85.5873 124.955 -57.4564C159.689 -23.7787 196.073 10.0672 235.569 42.6982C253.852 57.803 273.319 72.9187 294.698 86.4657C303.187 91.8449 274.65 48.0808 282.046 45.6195C219.572 77.1945 332.128 90.923 334.421 86.9542C338.451 79.9784 341.303 72.4334 343.328 64.472C346.799 50.818 347.861 36.1042 351.887 22.698C353.121 18.5879 354.577 14.5603 356.233 10.6526C357.038 8.75431 357.613 5.98079 359.906 4.90077C363.933 3.00334 374.214 15.0198 375.824 16.7479C385.338 26.9601 393.837 37.1571 404.401 47.286C410.664 53.292 417.727 60.0542 425.643 65.5658C438.772 74.7063 442.505 60.9918 444.676 57.6325C457.282 38.1253 464.074 14.7046 482.732 -1.37946C485.153 -3.46668 487.948 -7.43699 494.091 -6.34644" stroke="url(#paint0_linear_1355_24052)" stroke-width="28" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_f_1355_24052" x="-138.293" y="-325.171" width="814.387" height="594.793" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feGaussianBlur stdDeviation="84" result="effect1_foregroundBlur_1355_24052"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_1355_24052" x1="115.988" y1="-107.682" x2="455.646" y2="67.9884" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#FFB200"/>
|
||||||
|
<stop offset="1" stop-color="#FF8500"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_1355_24052">
|
||||||
|
<rect width="375" height="812" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
15
app/assets/stylesheets/application.css
Normal file
15
app/assets/stylesheets/application.css
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
||||||
|
* listed below.
|
||||||
|
*
|
||||||
|
* Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's
|
||||||
|
* vendor/assets/stylesheets directory can be referenced here using a relative path.
|
||||||
|
*
|
||||||
|
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
||||||
|
* compiled file so the styles you add here take precedence over styles defined in any other CSS
|
||||||
|
* files in this directory. Styles in this file should be added after the last require_* statement.
|
||||||
|
* It is generally better to create a new file per style scope.
|
||||||
|
*
|
||||||
|
*= require_tree .
|
||||||
|
*= require_self
|
||||||
|
*/
|
135
app/assets/stylesheets/application.tailwind.css
Normal file
135
app/assets/stylesheets/application.tailwind.css
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
.prose table {
|
||||||
|
@apply divide-y divide-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose tr {
|
||||||
|
@apply divide-x divide-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th {
|
||||||
|
@apply whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose tbody {
|
||||||
|
@apply divide-y divide-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose td {
|
||||||
|
@apply px-2 py-2 text-sm text-gray-500 whitespace-nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
@apply relative p-4 bg-gray-100 border border-gray-200 rounded-2xl focus-within:bg-white focus-within:drop-shadow-form focus-within:opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
@apply block text-sm font-medium text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
@apply p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-black {
|
||||||
|
background: linear-gradient(180deg, #242629 0%, #0E0F10 100%);
|
||||||
|
box-shadow: 0px 4px 8px -2px rgba(52, 54, 60, 0.1),
|
||||||
|
0px 0px 0px 0.5px #484C51,
|
||||||
|
0px 1px 0px rgba(36, 38, 41, 0.1),
|
||||||
|
0px 1px 2px rgba(36, 38, 41, 0.05),
|
||||||
|
0px 2px 4px rgba(36, 38, 41, 0.04),
|
||||||
|
0px 4px 8px rgba(36, 38, 41, 0.03),
|
||||||
|
0px 2px 12px rgba(36, 38, 41, 0.02),
|
||||||
|
inset 0px 0px 2px 1px #242629;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
@apply flex items-center justify-center w-12 h-12 mb-2 bg-black rounded-full hover:bg-gray-600 shrink-0 grow-0 ;
|
||||||
|
box-shadow: 0px 1px 2px rgba(52, 54, 60, 0.05), inset 0px -4px 4px rgba(173, 181, 189, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
@font-face {
|
||||||
|
font-family: 'GeneralSans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('generalsans/GeneralSans-Variable.woff2') format("woff2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-ellipsis {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
@apply w-1.5 h-1.5 mx-1 bg-gray-400 rounded-full;
|
||||||
|
animation: ellipsis 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot:nth-child(2) {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot:nth-child(3) {
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ellipsis {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.masked-overflow {
|
||||||
|
/* scroll bar width, for use in mask calculations */
|
||||||
|
--scrollbar-width: 8px;
|
||||||
|
|
||||||
|
/* mask fade distance, for use in mask calculations */
|
||||||
|
--mask-height: 32px;
|
||||||
|
|
||||||
|
/* Need to make sure container has bottom space,
|
||||||
|
otherwise content at the bottom is always faded out */
|
||||||
|
padding-top: 15px;
|
||||||
|
padding-bottom: var(--mask-height);
|
||||||
|
|
||||||
|
/* The CSS mask */
|
||||||
|
|
||||||
|
/* The content mask is a linear gradient from top to bottom */
|
||||||
|
--mask-image-content: linear-gradient(to bottom,
|
||||||
|
transparent,
|
||||||
|
black var(--mask-height),
|
||||||
|
black calc(100% - var(--mask-height)),
|
||||||
|
transparent);
|
||||||
|
|
||||||
|
/* Here we scale the content gradient to the width of the container
|
||||||
|
minus the scrollbar width. The height is the full container height */
|
||||||
|
--mask-size-content: calc(100% - var(--scrollbar-width)) 100%;
|
||||||
|
|
||||||
|
/* The scrollbar mask is a black pixel */
|
||||||
|
--mask-image-scrollbar: linear-gradient(black, black);
|
||||||
|
|
||||||
|
/* The width of our black pixel is the width of the scrollbar.
|
||||||
|
The height is the full container height */
|
||||||
|
--mask-size-scrollbar: var(--scrollbar-width) 100%;
|
||||||
|
|
||||||
|
/* Apply the mask image and mask size variables */
|
||||||
|
mask-image: var(--mask-image-content), var(--mask-image-scrollbar);
|
||||||
|
mask-size: var(--mask-size-content), var(--mask-size-scrollbar);
|
||||||
|
|
||||||
|
/* Position the content gradient in the top left, and the
|
||||||
|
scroll gradient in the top right */
|
||||||
|
mask-position: 0 0, 100% 0;
|
||||||
|
|
||||||
|
/* We don't repeat our mask images */
|
||||||
|
mask-repeat: no-repeat, no-repeat;
|
||||||
|
}
|
4
app/channels/application_cable/channel.rb
Normal file
4
app/channels/application_cable/channel.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
module ApplicationCable
|
||||||
|
class Channel < ActionCable::Channel::Base
|
||||||
|
end
|
||||||
|
end
|
4
app/channels/application_cable/connection.rb
Normal file
4
app/channels/application_cable/connection.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
module ApplicationCable
|
||||||
|
class Connection < ActionCable::Connection::Base
|
||||||
|
end
|
||||||
|
end
|
176
app/controllers/accounts_controller.rb
Normal file
176
app/controllers/accounts_controller.rb
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
class AccountsController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
|
||||||
|
def index
|
||||||
|
# If errors, generate a new link token for the connection
|
||||||
|
if current_family.connections.error.present?
|
||||||
|
# For each error, generate a new link token
|
||||||
|
@link_tokens = []
|
||||||
|
current_family.connections.error.each do |connection|
|
||||||
|
# Create the link_token with all of your configurations
|
||||||
|
link_token_create_request = Plaid::LinkTokenCreateRequest.new({
|
||||||
|
:user => { :client_user_id => connection.id.to_s },
|
||||||
|
:client_name => 'Maybe',
|
||||||
|
:access_token => connection.access_token,
|
||||||
|
:products => ['transactions'],
|
||||||
|
:country_codes => ['US', 'CA'], #, 'GB', 'DE', 'FR', 'NL', 'IE', 'ES', 'SE', 'DK'],
|
||||||
|
:language => 'en',
|
||||||
|
redirect_uri: ENV['PLAID_REDIRECT_URI']
|
||||||
|
})
|
||||||
|
|
||||||
|
link_token_response = $plaid_api_client.link_token_create(
|
||||||
|
link_token_create_request
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pass the result to your client-side app to initialize Link
|
||||||
|
# and retrieve a public_token
|
||||||
|
link_token = link_token_response.link_token
|
||||||
|
|
||||||
|
# Add the link_token along with connection ID to the link_tokens array
|
||||||
|
@link_tokens << { connection_id: connection.id, link_token: link_token }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get list of all net_worth entries from metrics, order by date, limit to 30, uniq by date
|
||||||
|
@net_worths = current_family.metrics.where(kind: 'net_worth').order(date: :asc).limit(30).uniq(&:date)
|
||||||
|
|
||||||
|
@chart_data = {
|
||||||
|
labels: @net_worths.map(&:date),
|
||||||
|
datasets: [{
|
||||||
|
label: '',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderColor: '#34D399',
|
||||||
|
pointStyle: false,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderJoinStyle: 'round',
|
||||||
|
borderCapStyle: 'round',
|
||||||
|
data: @net_worths.map(&:amount),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
@chart_options = {
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: false,
|
||||||
|
display: false,
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
# ticks: {
|
||||||
|
# callback: function(value, index, ticks) {
|
||||||
|
# return '$' + value;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
display: false,
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def assets
|
||||||
|
end
|
||||||
|
|
||||||
|
def cash
|
||||||
|
end
|
||||||
|
|
||||||
|
def investments
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def credit
|
||||||
|
end
|
||||||
|
|
||||||
|
def debts
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
# Create a new account
|
||||||
|
@account = Account.new(account_params)
|
||||||
|
@account.family = current_family
|
||||||
|
|
||||||
|
# Save the account
|
||||||
|
if @account.save
|
||||||
|
GenerateBalanceJob.perform_async(@account.id)
|
||||||
|
GenerateMetricsJob.perform_in(15.seconds, current_family.id)
|
||||||
|
|
||||||
|
if @account.kind == 'property' and @account.subkind == 'real_estate'
|
||||||
|
SyncPropertyValuesJob.perform_async(@account.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# If the account saved, redirect to the accounts page
|
||||||
|
redirect_to accounts_path
|
||||||
|
else
|
||||||
|
# If the account didn't save, render the new account page
|
||||||
|
render :new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
render layout: 'simple'
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_bank
|
||||||
|
render layout: 'simple'
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_bank_manual
|
||||||
|
# Find or create a new "Manual Bank" connection
|
||||||
|
@connection = Connection.find_or_create_by(user: current_user, family: current_family, name: "Manual", source: "manual")
|
||||||
|
@account = Account.new
|
||||||
|
|
||||||
|
render layout: 'simple'
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_investment
|
||||||
|
render layout: 'simple'
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_real_estate
|
||||||
|
@connection = Connection.find_or_create_by(user: current_user, family: current_family, name: "Manual", source: "manual")
|
||||||
|
@account = Account.new
|
||||||
|
render layout: 'simple'
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_investment_position
|
||||||
|
@connection = Connection.find_or_create_by(user: current_user, family: current_family, name: "Manual", source: "manual")
|
||||||
|
@account = @connection.accounts.find_or_create_by(name: "Manual", source: "manual")
|
||||||
|
@holding = Holding.new
|
||||||
|
|
||||||
|
render layout: 'simple'
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_credit
|
||||||
|
render layout: 'simple'
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_credit_manual
|
||||||
|
# Find or create a new "Manual Bank" connection
|
||||||
|
@connection = Connection.find_or_create_by(user: current_user, family: current_family, name: "Manual", source: "manual")
|
||||||
|
@account = Account.new
|
||||||
|
|
||||||
|
render layout: 'simple'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def account_params
|
||||||
|
|
||||||
|
property_details = params.require(:account)[:property_details]
|
||||||
|
parsed_property_details = property_details.nil? ? nil : JSON.parse(property_details)
|
||||||
|
|
||||||
|
params.require(:account).permit(:name, :current_balance, :connection_id, :kind, :subkind, :currency_code, :credit_limit, :property_details, :auto_valuation, :official_name).merge(property_details: parsed_property_details)
|
||||||
|
end
|
||||||
|
end
|
58
app/controllers/api/plaid_controller.rb
Normal file
58
app/controllers/api/plaid_controller.rb
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
class Api::PlaidController < ApplicationController
|
||||||
|
skip_before_action :verify_authenticity_token
|
||||||
|
|
||||||
|
def exchange_public_token
|
||||||
|
public_token = params[:public_token]
|
||||||
|
|
||||||
|
exchange_token_request = Plaid::ItemPublicTokenExchangeRequest.new({
|
||||||
|
:public_token => public_token
|
||||||
|
})
|
||||||
|
|
||||||
|
exchange_token_response = $plaid_api_client.item_public_token_exchange(
|
||||||
|
exchange_token_request
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = exchange_token_response.access_token
|
||||||
|
item_id = exchange_token_response.item_id
|
||||||
|
|
||||||
|
item_get_request = Plaid::ItemGetRequest.new({ access_token: access_token})
|
||||||
|
item_response = $plaid_api_client.item_get(item_get_request)
|
||||||
|
aggregator_id = item_response.item.institution_id
|
||||||
|
consent_expiration = item_response.item.consent_expiration_time
|
||||||
|
|
||||||
|
institutions_get_by_id_request = Plaid::InstitutionsGetByIdRequest.new(
|
||||||
|
{
|
||||||
|
institution_id: item_response.item.institution_id,
|
||||||
|
country_codes: ['US', 'CA']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
institution_response = $plaid_api_client.institutions_get_by_id(institutions_get_by_id_request)
|
||||||
|
|
||||||
|
user = current_user
|
||||||
|
|
||||||
|
user.connections.find_or_initialize_by(source: 'plaid', item_id: item_id).update(
|
||||||
|
access_token: access_token,
|
||||||
|
aggregator_id: aggregator_id,
|
||||||
|
consent_expiration: consent_expiration,
|
||||||
|
name: institution_response.institution.name,
|
||||||
|
status: 'ok',
|
||||||
|
error: nil,
|
||||||
|
family: user.family
|
||||||
|
)
|
||||||
|
user.save!
|
||||||
|
|
||||||
|
SyncPlaidItemAccountsJob.perform_async(item_id)
|
||||||
|
|
||||||
|
# SyncPlaidHoldingsJob.perform_async(item_id)
|
||||||
|
# SyncPlaidInvestmentTransactionsJob.perform_async(item_id)
|
||||||
|
|
||||||
|
GenerateMetricsJob.perform_in(1.minute, user.family.id)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
status: 200,
|
||||||
|
message: 'Successfully exchanged public token for access token',
|
||||||
|
access_token: access_token,
|
||||||
|
item_id: item_id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
28
app/controllers/application_controller.rb
Normal file
28
app/controllers/application_controller.rb
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
class ApplicationController < ActionController::Base
|
||||||
|
#before_action :set_checkout_session
|
||||||
|
#before_action :set_portal_session
|
||||||
|
|
||||||
|
helper_method :current_family
|
||||||
|
|
||||||
|
def set_portal_session
|
||||||
|
if current_user.present?
|
||||||
|
@portal_session = current_user.payment_processor.billing_portal
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_checkout_session
|
||||||
|
if current_user.present?
|
||||||
|
current_user.payment_processor.customer
|
||||||
|
|
||||||
|
@session = current_user.payment_processor.checkout(
|
||||||
|
mode: "subscription",
|
||||||
|
line_items: ENV['STRIPE_PRICE_ID'],
|
||||||
|
allow_promotion_codes: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_family
|
||||||
|
current_user.family if current_user
|
||||||
|
end
|
||||||
|
end
|
0
app/controllers/concerns/.keep
Normal file
0
app/controllers/concerns/.keep
Normal file
40
app/controllers/concerns/plaid_token.rb
Normal file
40
app/controllers/concerns/plaid_token.rb
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
module PlaidToken
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
before_action :set_plaid_link_token
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_plaid_link_token
|
||||||
|
# If current_user is present and plaid_link_token_expires_at is in the past,
|
||||||
|
# return create a new token
|
||||||
|
if current_user.present? && ((current_user.plaid_link_token_expires_at.present? && current_user.plaid_link_token_expires_at < Time.now) or current_user.plaid_link_token_expires_at.nil?)
|
||||||
|
user = current_user
|
||||||
|
client_user_id = user.id
|
||||||
|
|
||||||
|
# Create the link_token with all of your configurations
|
||||||
|
link_token_create_request = Plaid::LinkTokenCreateRequest.new({
|
||||||
|
:user => { :client_user_id => client_user_id.to_s },
|
||||||
|
:client_name => 'Maybe',
|
||||||
|
:products => ['transactions'],
|
||||||
|
:country_codes => ['US', 'CA'], #, 'GB', 'DE', 'FR', 'NL', 'IE', 'ES', 'SE', 'DK'],
|
||||||
|
:language => 'en',
|
||||||
|
redirect_uri: ENV['PLAID_REDIRECT_URI']
|
||||||
|
})
|
||||||
|
|
||||||
|
link_token_response = $plaid_api_client.link_token_create(
|
||||||
|
link_token_create_request
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pass the result to your client-side app to initialize Link
|
||||||
|
# and retrieve a public_token
|
||||||
|
link_token = link_token_response.link_token
|
||||||
|
|
||||||
|
user.plaid_link_token = link_token
|
||||||
|
user.plaid_link_token_expires_at = Time.now + 3.hour
|
||||||
|
user.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
17
app/controllers/connections_controller.rb
Normal file
17
app/controllers/connections_controller.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
class ConnectionsController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
|
||||||
|
def index
|
||||||
|
# Connections where source is not manual
|
||||||
|
@connections = current_family.connections.where.not(source: :manual)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@connection = current_family.connections.find(params[:id])
|
||||||
|
@connection.destroy
|
||||||
|
|
||||||
|
GenerateMetricsJob.perform_async(current_family.id)
|
||||||
|
|
||||||
|
redirect_to connections_path
|
||||||
|
end
|
||||||
|
end
|
62
app/controllers/conversations_controller.rb
Normal file
62
app/controllers/conversations_controller.rb
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
class ConversationsController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
|
||||||
|
def index
|
||||||
|
@conversations = current_user.conversations.order(updated_at: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@conversation = current_user.conversations.find_by(id: params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
# Create a new conversation and redirect to it
|
||||||
|
@conversation = Conversation.new
|
||||||
|
@conversation.user = current_user
|
||||||
|
@conversation.title = "New Conversation"
|
||||||
|
@conversation.save
|
||||||
|
|
||||||
|
redirect_to @conversation
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
# Conversation is already created, so find it based on params id and current_user
|
||||||
|
@conversation = Conversation.find_by(id: params[:id], user: current_user)
|
||||||
|
|
||||||
|
if params[:conversation].present?
|
||||||
|
@message = @conversation.messages.new
|
||||||
|
@message.content = params[:conversation][:content]
|
||||||
|
@message.user = current_user
|
||||||
|
@message.role = "user"
|
||||||
|
|
||||||
|
@conversation.save
|
||||||
|
|
||||||
|
# Stub reply from bot
|
||||||
|
reply = @conversation.messages.new
|
||||||
|
reply.content = "..."
|
||||||
|
reply.user = nil
|
||||||
|
reply.role = "assistant"
|
||||||
|
reply.save
|
||||||
|
|
||||||
|
AskQuestionJob.perform_async(@conversation.id, reply.id)
|
||||||
|
|
||||||
|
#conversation.broadcast_append_to "conversation_area", partial: "conversations/message", locals: { message: reply }, target: "conversation_area_#{conversation.id}"
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.turbo_stream do
|
||||||
|
render turbo_stream: [
|
||||||
|
turbo_stream.append("conversation_area_#{@conversation.id}", partial: 'conversations/message', locals: { message: @message }),
|
||||||
|
turbo_stream.append("conversation_area_#{@conversation.id}", partial: 'conversations/message', locals: { message: reply }),
|
||||||
|
]
|
||||||
|
end
|
||||||
|
format.html { redirect_to @conversation }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def conversation_params
|
||||||
|
params.require(:conversation).permit(:conversation_params)
|
||||||
|
end
|
||||||
|
end
|
3
app/controllers/families_controller.rb
Normal file
3
app/controllers/families_controller.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
class FamiliesController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
end
|
3
app/controllers/holdings_controller.rb
Normal file
3
app/controllers/holdings_controller.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
class HoldingsController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
end
|
52
app/controllers/onboarding_controller.rb
Normal file
52
app/controllers/onboarding_controller.rb
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
class OnboardingController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
|
||||||
|
layout 'simple'
|
||||||
|
include Wicked::Wizard
|
||||||
|
|
||||||
|
steps :name, :birthday, :location, :currency, :family, :household, :risk, :goals, :agreements, :upgrade, :welcome
|
||||||
|
|
||||||
|
def show
|
||||||
|
@user = current_user
|
||||||
|
@user.family
|
||||||
|
|
||||||
|
case step
|
||||||
|
when :upgrade
|
||||||
|
#if current_user.payment_processor.subscribed?
|
||||||
|
jump_to(:welcome)
|
||||||
|
# else
|
||||||
|
# @user.payment_processor.customer
|
||||||
|
|
||||||
|
# @session = @user.payment_processor.checkout(
|
||||||
|
# mode: "subscription",
|
||||||
|
# line_items: ENV['STRIPE_PRICE_ID'],
|
||||||
|
# allow_promotion_codes: true
|
||||||
|
# )
|
||||||
|
# end
|
||||||
|
end
|
||||||
|
|
||||||
|
render_wizard
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@user = current_user
|
||||||
|
|
||||||
|
case step
|
||||||
|
when :agreements
|
||||||
|
@user.family.agreed = true
|
||||||
|
@user.family.agreed_at = Time.now
|
||||||
|
@user.family.agreements = {}
|
||||||
|
@user.save
|
||||||
|
else
|
||||||
|
@user.update(user_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
render_wizard @user
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def user_params
|
||||||
|
params.require(:user)
|
||||||
|
.permit(:first_name, :last_name, :birthday, family_attributes: [:id, :country, :region, :currency, :name, :household, :risk, :goals, :agreed, :agreed_at, :agreements])
|
||||||
|
end
|
||||||
|
end
|
51
app/controllers/pages_controller.rb
Normal file
51
app/controllers/pages_controller.rb
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
class PagesController < ApplicationController
|
||||||
|
include PlaidToken
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
|
||||||
|
def index
|
||||||
|
if current_user.family.agreed == false
|
||||||
|
redirect_to onboarding_path(:name)
|
||||||
|
elsif !current_user.payment_processor.subscribed?
|
||||||
|
#redirect_to onboarding_path(:upgrade)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new conversation for the current user if "kind" of "daily_review" for today is not found
|
||||||
|
@conversation = Conversation.find_or_create_by(user: current_user, kind: "daily_review", created_at: Date.today.beginning_of_day..Date.today.end_of_day) do |conversation|
|
||||||
|
conversation.title = Date.today.strftime("%B %-d, %Y")
|
||||||
|
conversation.role = "system"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def upgrade
|
||||||
|
if current_user.present?
|
||||||
|
current_user.payment_processor.customer
|
||||||
|
|
||||||
|
@session = current_user.payment_processor.checkout(
|
||||||
|
mode: "subscription",
|
||||||
|
line_items: ENV['STRIPE_PRICE_ID'],
|
||||||
|
allow_promotion_codes: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if current_user.payment_processor.subscribed?
|
||||||
|
redirect_to root_path
|
||||||
|
else
|
||||||
|
render layout: 'simple'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def settings
|
||||||
|
@user = current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def settings_update
|
||||||
|
@user = current_user
|
||||||
|
|
||||||
|
if @user.update(user_params)
|
||||||
|
redirect_to settings_path, notice: "Settings updated successfully."
|
||||||
|
else
|
||||||
|
render :settings
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
12
app/controllers/prompts_controller.rb
Normal file
12
app/controllers/prompts_controller.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
class PromptsController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
|
||||||
|
def index
|
||||||
|
if params[:category].present?
|
||||||
|
# Categories is an array column in the prompts table
|
||||||
|
@prompts = Prompt.where("categories @> ARRAY[?]::varchar[]", params[:category])
|
||||||
|
else
|
||||||
|
@prompts = Prompt.all
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
30
app/controllers/users/confirmations_controller.rb
Normal file
30
app/controllers/users/confirmations_controller.rb
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::ConfirmationsController < Devise::ConfirmationsController
|
||||||
|
# GET /resource/confirmation/new
|
||||||
|
# def new
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# POST /resource/confirmation
|
||||||
|
# def create
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# GET /resource/confirmation?confirmation_token=abcdef
|
||||||
|
# def show
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# protected
|
||||||
|
|
||||||
|
# The path used after resending confirmation instructions.
|
||||||
|
# def after_resending_confirmation_instructions_path_for(resource_name)
|
||||||
|
# super(resource_name)
|
||||||
|
# end
|
||||||
|
|
||||||
|
# The path used after confirmation.
|
||||||
|
# def after_confirmation_path_for(resource_name, resource)
|
||||||
|
# super(resource_name, resource)
|
||||||
|
# end
|
||||||
|
end
|
30
app/controllers/users/omniauth_callbacks_controller.rb
Normal file
30
app/controllers/users/omniauth_callbacks_controller.rb
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||||
|
# You should configure your model like this:
|
||||||
|
# devise :omniauthable, omniauth_providers: [:twitter]
|
||||||
|
|
||||||
|
# You should also create an action method in this controller like this:
|
||||||
|
# def twitter
|
||||||
|
# end
|
||||||
|
|
||||||
|
# More info at:
|
||||||
|
# https://github.com/heartcombo/devise#omniauth
|
||||||
|
|
||||||
|
# GET|POST /resource/auth/twitter
|
||||||
|
# def passthru
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# GET|POST /users/auth/twitter/callback
|
||||||
|
# def failure
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# protected
|
||||||
|
|
||||||
|
# The path used when OmniAuth fails
|
||||||
|
# def after_omniauth_failure_path_for(scope)
|
||||||
|
# super(scope)
|
||||||
|
# end
|
||||||
|
end
|
34
app/controllers/users/passwords_controller.rb
Normal file
34
app/controllers/users/passwords_controller.rb
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::PasswordsController < Devise::PasswordsController
|
||||||
|
# GET /resource/password/new
|
||||||
|
# def new
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# POST /resource/password
|
||||||
|
# def create
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# GET /resource/password/edit?reset_password_token=abcdef
|
||||||
|
# def edit
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# PUT /resource/password
|
||||||
|
# def update
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# protected
|
||||||
|
|
||||||
|
# def after_resetting_password_path_for(resource)
|
||||||
|
# super(resource)
|
||||||
|
# end
|
||||||
|
|
||||||
|
# The path used after sending reset password instructions
|
||||||
|
# def after_sending_reset_password_instructions_path_for(resource_name)
|
||||||
|
# super(resource_name)
|
||||||
|
# end
|
||||||
|
end
|
68
app/controllers/users/registrations_controller.rb
Normal file
68
app/controllers/users/registrations_controller.rb
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::RegistrationsController < Devise::RegistrationsController
|
||||||
|
# before_action :configure_sign_up_params, only: [:create]
|
||||||
|
# before_action :configure_account_update_params, only: [:update]
|
||||||
|
|
||||||
|
# GET /resource/sign_up
|
||||||
|
# def new
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# POST /resource
|
||||||
|
def create
|
||||||
|
super do |resource|
|
||||||
|
family = Family.create
|
||||||
|
family.save
|
||||||
|
|
||||||
|
resource.family = family
|
||||||
|
resource.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /resource/edit
|
||||||
|
# def edit
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# PUT /resource
|
||||||
|
# def update
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# DELETE /resource
|
||||||
|
# def destroy
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# GET /resource/cancel
|
||||||
|
# Forces the session data which is usually expired after sign
|
||||||
|
# in to be expired now. This is useful if the user wants to
|
||||||
|
# cancel oauth signing in/up in the middle of the process,
|
||||||
|
# removing all OAuth session data.
|
||||||
|
# def cancel
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# protected
|
||||||
|
|
||||||
|
# If you have extra params to permit, append them to the sanitizer.
|
||||||
|
# def configure_sign_up_params
|
||||||
|
# devise_parameter_sanitizer.permit(:sign_up, keys: [:attribute])
|
||||||
|
# end
|
||||||
|
|
||||||
|
# If you have extra params to permit, append them to the sanitizer.
|
||||||
|
# def configure_account_update_params
|
||||||
|
# devise_parameter_sanitizer.permit(:account_update, keys: [:attribute])
|
||||||
|
# end
|
||||||
|
|
||||||
|
# The path used after sign up.
|
||||||
|
def after_sign_up_path_for(resource)
|
||||||
|
onboarding_path(:name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# The path used after sign up for inactive accounts.
|
||||||
|
# def after_inactive_sign_up_path_for(resource)
|
||||||
|
# super(resource)
|
||||||
|
# end
|
||||||
|
end
|
27
app/controllers/users/sessions_controller.rb
Normal file
27
app/controllers/users/sessions_controller.rb
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::SessionsController < Devise::SessionsController
|
||||||
|
# before_action :configure_sign_in_params, only: [:create]
|
||||||
|
|
||||||
|
# GET /resource/sign_in
|
||||||
|
# def new
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# POST /resource/sign_in
|
||||||
|
# def create
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# DELETE /resource/sign_out
|
||||||
|
# def destroy
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# protected
|
||||||
|
|
||||||
|
# If you have extra params to permit, append them to the sanitizer.
|
||||||
|
# def configure_sign_in_params
|
||||||
|
# devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute])
|
||||||
|
# end
|
||||||
|
end
|
30
app/controllers/users/unlocks_controller.rb
Normal file
30
app/controllers/users/unlocks_controller.rb
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::UnlocksController < Devise::UnlocksController
|
||||||
|
# GET /resource/unlock/new
|
||||||
|
# def new
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# POST /resource/unlock
|
||||||
|
# def create
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# GET /resource/unlock?unlock_token=abcdef
|
||||||
|
# def show
|
||||||
|
# super
|
||||||
|
# end
|
||||||
|
|
||||||
|
# protected
|
||||||
|
|
||||||
|
# The path used after sending unlock password instructions
|
||||||
|
# def after_sending_unlock_instructions_path_for(resource)
|
||||||
|
# super(resource)
|
||||||
|
# end
|
||||||
|
|
||||||
|
# The path used after unlocking the resource
|
||||||
|
# def after_unlock_path_for(resource)
|
||||||
|
# super(resource)
|
||||||
|
# end
|
||||||
|
end
|
2
app/helpers/accounts_helper.rb
Normal file
2
app/helpers/accounts_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module AccountsHelper
|
||||||
|
end
|
2
app/helpers/api/plaid_helper.rb
Normal file
2
app/helpers/api/plaid_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module Api::PlaidHelper
|
||||||
|
end
|
80
app/helpers/application_helper.rb
Normal file
80
app/helpers/application_helper.rb
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
module ApplicationHelper
|
||||||
|
def title(page_title)
|
||||||
|
content_for(:title) { page_title }
|
||||||
|
end
|
||||||
|
|
||||||
|
def header_title(page_title)
|
||||||
|
content_for(:header_title) { page_title }
|
||||||
|
end
|
||||||
|
|
||||||
|
def description(page_description)
|
||||||
|
content_for(:description) { page_description }
|
||||||
|
end
|
||||||
|
|
||||||
|
def meta_image(meta_image)
|
||||||
|
content_for(:meta_image) { meta_image }
|
||||||
|
end
|
||||||
|
|
||||||
|
def header_content(&block)
|
||||||
|
content_for(:header_content, &block)
|
||||||
|
end
|
||||||
|
|
||||||
|
def header_content?
|
||||||
|
content_for?(:header_content)
|
||||||
|
end
|
||||||
|
|
||||||
|
def header_action(&block)
|
||||||
|
content_for(:header_action, &block)
|
||||||
|
end
|
||||||
|
|
||||||
|
def header_action?
|
||||||
|
content_for?(:header_action)
|
||||||
|
end
|
||||||
|
|
||||||
|
def abbreviated_currency(amount)
|
||||||
|
number_to_currency number_to_human(amount, precision: 3, format: '%n%u', units: { unit: '', thousand: 'k', million: 'm', billion: 'b', trillion: 't' })
|
||||||
|
end
|
||||||
|
|
||||||
|
def gravatar(user, size: 180)
|
||||||
|
gravatar_id = Digest::MD5::hexdigest(user.email.downcase)
|
||||||
|
gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
|
||||||
|
gravatar_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def markdown(text)
|
||||||
|
@@parser ||= Redcarpet::Markdown.new(Redcarpet::Render::HTML, tables: true)
|
||||||
|
|
||||||
|
@@parser.render(text).html_safe
|
||||||
|
end
|
||||||
|
|
||||||
|
def mobile?
|
||||||
|
request.user_agent.include?('MaybeiOS')
|
||||||
|
end
|
||||||
|
|
||||||
|
def institution_avatar(connection)
|
||||||
|
if connection.institution.present? && connection.institution.url.present?
|
||||||
|
website_domain = URI.parse(connection.institution.url).host
|
||||||
|
img_str = "https://logo.clearbit.com/#{website_domain}"
|
||||||
|
|
||||||
|
image_tag(img_str, class: 'w-10 h-10 mr-2 rounded-xl')
|
||||||
|
else
|
||||||
|
"<span class='flex w-10 h-10 shrink-0 grow-0 items-center justify-center rounded-xl bg-[#EAF4FF] mr-2'>
|
||||||
|
<i class='fa-regular fa-building-columns text-[#3492FB] text-base'></i>
|
||||||
|
</span>".html_safe
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def timeago(date, format: :long)
|
||||||
|
return if date.blank?
|
||||||
|
|
||||||
|
content = I18n.l(date, format: format)
|
||||||
|
|
||||||
|
tag.time(content,
|
||||||
|
title: content,
|
||||||
|
data: {
|
||||||
|
controller: 'timeago',
|
||||||
|
timeago_refresh_interval_value: 30000,
|
||||||
|
timeago_datetime_value: date.iso8601
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
2
app/helpers/connections_helper.rb
Normal file
2
app/helpers/connections_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module ConnectionsHelper
|
||||||
|
end
|
2
app/helpers/conversations_helper.rb
Normal file
2
app/helpers/conversations_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module ConversationsHelper
|
||||||
|
end
|
55
app/helpers/devise_helper.rb
Normal file
55
app/helpers/devise_helper.rb
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||||
|
# devise helper
|
||||||
|
module DeviseHelper
|
||||||
|
def devise_error_messages!
|
||||||
|
return if resource.errors.empty?
|
||||||
|
|
||||||
|
messages = resource.errors.full_messages.map { |msg| content_tag(:p, "- #{msg}.") }
|
||||||
|
.join
|
||||||
|
sentence = I18n.t(
|
||||||
|
"errors.messages.not_saved",
|
||||||
|
count: resource.errors.count,
|
||||||
|
resource: resource.class.model_name.human.downcase
|
||||||
|
)
|
||||||
|
|
||||||
|
html = <<-HTML
|
||||||
|
<div class="bg-red-100 border-l-4 border-red-500 mb-4 p-4 text-red-700" role="alert">
|
||||||
|
<p class="font-bold">Oops!</p>
|
||||||
|
<p>#{sentence}</p>#{messages}
|
||||||
|
</div>
|
||||||
|
HTML
|
||||||
|
|
||||||
|
html.html_safe
|
||||||
|
end
|
||||||
|
|
||||||
|
def devise_simple_error_messages!
|
||||||
|
return if resource.errors.empty?
|
||||||
|
|
||||||
|
sentence = "Ooops!"
|
||||||
|
if resource.errors.count == 1
|
||||||
|
message = resource.errors.full_messages[0]
|
||||||
|
html = <<-HTML
|
||||||
|
<div class="bg-red-lightest border-l-4 border-red text-orange-dark p-4" role="alert">
|
||||||
|
<p class="font-bold">#{sentence}</p>
|
||||||
|
<p> #{message}.</p>
|
||||||
|
</div>
|
||||||
|
HTML
|
||||||
|
else
|
||||||
|
messages = resource.errors.full_messages.map { |msg| content_tag(:li, "#{msg}.") }
|
||||||
|
.join
|
||||||
|
html = <<-HTML
|
||||||
|
<div class="bg-red-100 border-l-4 border-red-500 mb-4 p-4 text-red-700" role="alert">
|
||||||
|
<p class="font-bold">#{sentence}</p>
|
||||||
|
<ul class="list-disc">
|
||||||
|
#{messages}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
html.html_safe
|
||||||
|
end
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
2
app/helpers/families_helper.rb
Normal file
2
app/helpers/families_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module FamiliesHelper
|
||||||
|
end
|
35
app/helpers/holdings_helper.rb
Normal file
35
app/helpers/holdings_helper.rb
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
module HoldingsHelper
|
||||||
|
def holding_logo(security, size = 10, rounded_size = nil)
|
||||||
|
rounded_size ||= size < 10 ? 'rounded-full' : 'rounded-xl'
|
||||||
|
padding = size < 10 ? 1 : 2
|
||||||
|
|
||||||
|
return get_svg_logo(security, size, rounded_size, padding) if security.logo_svg.present?
|
||||||
|
return get_polygon_logo(security, size, rounded_size, padding) if security.logo.present? && security.logo_source == 'polygon'
|
||||||
|
return get_image_logo(security, size, rounded_size) if security.logo.present?
|
||||||
|
|
||||||
|
get_text_logo(security, size, rounded_size, padding)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_svg_logo(security, size, rounded_size, padding)
|
||||||
|
logo = process_svg(security.logo_svg)
|
||||||
|
raw "<div class='flex items-center justify-center w-#{size} h-#{size} #{rounded_size}' style='background-color:#{security.logo_colors['color1']}'><div class='p-#{padding + 1}'>#{logo}</div></div>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_polygon_logo(security, size, rounded_size, padding)
|
||||||
|
logo = image_tag("#{security.logo}?apiKey=#{ENV['POLYGON_KEY']}", class: 'object-contain w-full h-full')
|
||||||
|
raw "<div class='flex items-center justify-center bg-gray-200 w-#{size} h-#{size} #{rounded_size}'><div class='p-#{padding}'>#{logo}</div></div>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_image_logo(security, size, rounded_size)
|
||||||
|
logo = image_tag(security.logo, class: 'object-contain w-full h-full')
|
||||||
|
raw "<div class='flex items-center justify-center overflow-clip bg-gray-200 w-#{size} h-#{size} #{rounded_size}'>#{logo}</div>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_text_logo(security, size, rounded_size, padding)
|
||||||
|
raw "<span class='flex items-center justify-center w-#{size} h-#{size} text-2xs text-center font-bold text-gray-400 bg-gray-200 #{rounded_size}'><div class='p-#{padding}'>#{security.symbol}</div></span>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_svg(svg)
|
||||||
|
svg.gsub(/<svg /, '<svg class="w-full h-full" ')
|
||||||
|
end
|
||||||
|
end
|
2
app/helpers/onboarding_helper.rb
Normal file
2
app/helpers/onboarding_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module OnboardingHelper
|
||||||
|
end
|
2
app/helpers/pages_helper.rb
Normal file
2
app/helpers/pages_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module PagesHelper
|
||||||
|
end
|
2
app/helpers/prompts_helper.rb
Normal file
2
app/helpers/prompts_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module PromptsHelper
|
||||||
|
end
|
3
app/javascript/application.js
Normal file
3
app/javascript/application.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||||
|
import "@hotwired/turbo-rails"
|
||||||
|
import "controllers"
|
9
app/javascript/controllers/application.js
Normal file
9
app/javascript/controllers/application.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { Application } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
const application = Application.start()
|
||||||
|
|
||||||
|
// Configure Stimulus development experience
|
||||||
|
application.debug = false
|
||||||
|
window.Stimulus = application
|
||||||
|
|
||||||
|
export { application }
|
48
app/javascript/controllers/conversation_form_controller.js
Normal file
48
app/javascript/controllers/conversation_form_controller.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
// Connects to data-controller="conversation-form"
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["content", "form"];
|
||||||
|
submitting = false; // Add a flag to track submission state
|
||||||
|
lastSubmittedContent = null; // Add a new property to store the last submitted content
|
||||||
|
|
||||||
|
handleKeydown(event) {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey && !this.submitting) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const contentValue = this.contentTarget.value.trim(); // Get the trimmed value of the content field
|
||||||
|
|
||||||
|
// Check if content field has content and it's different from the last submitted content
|
||||||
|
if (this.formTarget.checkValidity() && contentValue && contentValue !== this.lastSubmittedContent) {
|
||||||
|
this.formTarget.requestSubmit();
|
||||||
|
this.disableForm(); // Disable the form after submitting
|
||||||
|
this.submitting = true; // Set the flag to true
|
||||||
|
this.lastSubmittedContent = contentValue; // Update the lastSubmittedContent property
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disableForm() {
|
||||||
|
this.contentTarget.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
enableForm() {
|
||||||
|
this.contentTarget.disabled = false;
|
||||||
|
this.submitting = false; // Reset the flag after enabling the form
|
||||||
|
}
|
||||||
|
|
||||||
|
clearContent(event) {
|
||||||
|
if (event.detail.success) {
|
||||||
|
this.contentTarget.value = "";
|
||||||
|
this.enableForm(); // Enable the form after broadcast_append_to is done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.element.addEventListener("turbo:submit-end", this.clearContent.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.element.removeEventListener("turbo:submit-end", this.clearContent.bind(this));
|
||||||
|
}
|
||||||
|
}
|
30
app/javascript/controllers/conversation_stream_controller.js
Normal file
30
app/javascript/controllers/conversation_stream_controller.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
// Connects to data-controller="conversation-stream"
|
||||||
|
export default class extends Controller {
|
||||||
|
connect() {
|
||||||
|
console.log("ConversationStreamController connected");
|
||||||
|
this.scrollToBottom()
|
||||||
|
this.observeChatStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
console.log("scrollToBottom");
|
||||||
|
console.log(this.element)
|
||||||
|
console.log(this.element.scrollHeight)
|
||||||
|
|
||||||
|
this.element.scrollTo({ top: this.element.scrollHeight, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
observeChatStream() {
|
||||||
|
console.log("observeChatStream");
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
this.scrollToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(this.element, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
28
app/javascript/controllers/country_controller.js
Normal file
28
app/javascript/controllers/country_controller.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
// Connects to data-controller="country"
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["country", "regionWrapper", "regionLabel"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.updateRegionWrapperVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRegionWrapperVisibility() {
|
||||||
|
if (this.countryTarget.value) {
|
||||||
|
this.regionWrapperTarget.classList.remove("hidden")
|
||||||
|
this.updateRegionLabel()
|
||||||
|
} else {
|
||||||
|
this.regionWrapperTarget.classList.add("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRegionLabel() {
|
||||||
|
this.regionLabelTarget.textContent =
|
||||||
|
this.countryTarget.value === "US" ? "State" : "Region or City"
|
||||||
|
}
|
||||||
|
|
||||||
|
onCountryChange() {
|
||||||
|
this.updateRegionWrapperVisibility()
|
||||||
|
}
|
||||||
|
}
|
47
app/javascript/controllers/index.js
Normal file
47
app/javascript/controllers/index.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
// Import and register all your controllers from the importmap under controllers/*
|
||||||
|
|
||||||
|
import { application } from "controllers/application"
|
||||||
|
|
||||||
|
// Eager load all controllers defined in the import map under controllers/**/*_controller
|
||||||
|
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
||||||
|
eagerLoadControllersFrom("controllers", application)
|
||||||
|
|
||||||
|
// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
|
||||||
|
// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
|
||||||
|
// lazyLoadControllersFrom("controllers", application)
|
||||||
|
|
||||||
|
import Clipboard from 'stimulus-clipboard'
|
||||||
|
application.register('clipboard', Clipboard)
|
||||||
|
|
||||||
|
import Timeago from 'stimulus-timeago'
|
||||||
|
application.register('timeago', Timeago)
|
||||||
|
|
||||||
|
import { Turbo } from "@hotwired/turbo-rails"
|
||||||
|
window.Turbo = Turbo
|
||||||
|
|
||||||
|
import { AddressLookup } from "@addresszen/address-lookup";
|
||||||
|
document.addEventListener("turbo:load", () => {
|
||||||
|
const inputField = document.getElementById("full_address");
|
||||||
|
|
||||||
|
if (inputField) {
|
||||||
|
AddressLookup.setup({
|
||||||
|
apiKey: "ak_lgpf8sd217tzr1llewdg3ucAEVMaT",
|
||||||
|
removeOrganisation: true,
|
||||||
|
inputField: "#full_address",
|
||||||
|
onAddressRetrieved: (address) => {
|
||||||
|
const result = [
|
||||||
|
address.line_1,
|
||||||
|
address.line_2,
|
||||||
|
address.city,
|
||||||
|
address.state,
|
||||||
|
address.zip_plus_4_code
|
||||||
|
]
|
||||||
|
.filter((elem) => elem !== "")
|
||||||
|
.join(", ");
|
||||||
|
document.getElementById("full_address").value = result;
|
||||||
|
document.getElementById("dataset").value = JSON.stringify(address);
|
||||||
|
document.getElementById("name").value = address.line_1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
39
app/javascript/controllers/radio_button_controller.js
Normal file
39
app/javascript/controllers/radio_button_controller.js
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
// Connects to data-controller="radio-button"
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["label"];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.updateActiveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveState() {
|
||||||
|
this.labelTargets.forEach((label) => {
|
||||||
|
const input = label.querySelector("input[type='radio']");
|
||||||
|
const checkmark = label.querySelector(".checkmark");
|
||||||
|
const borderSpan = label.querySelector("span[aria-hidden='true']");
|
||||||
|
|
||||||
|
if (input.checked) {
|
||||||
|
label.classList.add("bg-white", "border", "shadow-sm");
|
||||||
|
label.classList.remove("bg-gray-100", "border-transparent", "shadow-none");
|
||||||
|
checkmark.classList.remove("invisible");
|
||||||
|
borderSpan.classList.add("border-transparent");
|
||||||
|
borderSpan.classList.remove("border-1");
|
||||||
|
} else {
|
||||||
|
label.classList.add("bg-gray-100", "border-transparent", "shadow-none");
|
||||||
|
label.classList.remove("bg-white", "border", "shadow-sm");
|
||||||
|
checkmark.classList.add("invisible");
|
||||||
|
borderSpan.classList.add("border-1");
|
||||||
|
borderSpan.classList.remove("border-transparent");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = event.currentTarget.querySelector("input[type='radio']");
|
||||||
|
input.checked = !input.checked;
|
||||||
|
this.updateActiveState();
|
||||||
|
}
|
||||||
|
}
|
7
app/jobs/application_job.rb
Normal file
7
app/jobs/application_job.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
class ApplicationJob < ActiveJob::Base
|
||||||
|
# Automatically retry jobs that encountered a deadlock
|
||||||
|
# retry_on ActiveRecord::Deadlocked
|
||||||
|
|
||||||
|
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||||
|
# discard_on ActiveJob::DeserializationError
|
||||||
|
end
|
4
app/mailers/application_mailer.rb
Normal file
4
app/mailers/application_mailer.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
class ApplicationMailer < ActionMailer::Base
|
||||||
|
default from: "hello@maybe.co"
|
||||||
|
layout "mailer"
|
||||||
|
end
|
35
app/models/account.rb
Normal file
35
app/models/account.rb
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
class Account < ApplicationRecord
|
||||||
|
belongs_to :connection
|
||||||
|
belongs_to :family
|
||||||
|
has_many :transactions, dependent: :destroy
|
||||||
|
has_many :balances, dependent: :destroy
|
||||||
|
has_many :holdings, dependent: :destroy
|
||||||
|
has_many :investment_transactions, dependent: :destroy
|
||||||
|
|
||||||
|
enum sync_status: { idle: 0, pending: 1, syncing: 2 }
|
||||||
|
enum source: { plaid: 0, manual: 1 }
|
||||||
|
|
||||||
|
after_update :log_changes
|
||||||
|
|
||||||
|
scope :depository, -> { where(kind: 'depository') }
|
||||||
|
scope :investment, -> { where(kind: 'investment') }
|
||||||
|
scope :credit, -> { where(kind: 'credit') }
|
||||||
|
scope :property, -> { where(kind: 'property') }
|
||||||
|
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def log_changes
|
||||||
|
ignored_attributes = ['updated_at', 'subkind', 'current_balance_date']
|
||||||
|
|
||||||
|
saved_changes.except(*ignored_attributes).each do |attr, (old_val, new_val)|
|
||||||
|
ChangeLog.create(
|
||||||
|
record_type: self.class.name,
|
||||||
|
record_id: id,
|
||||||
|
attribute_name: attr,
|
||||||
|
old_value: old_val,
|
||||||
|
new_value: new_val
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
3
app/models/application_record.rb
Normal file
3
app/models/application_record.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
class ApplicationRecord < ActiveRecord::Base
|
||||||
|
primary_abstract_class
|
||||||
|
end
|
5
app/models/balance.rb
Normal file
5
app/models/balance.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class Balance < ApplicationRecord
|
||||||
|
belongs_to :account
|
||||||
|
belongs_to :security, optional: true
|
||||||
|
belongs_to :family
|
||||||
|
end
|
2
app/models/change_log.rb
Normal file
2
app/models/change_log.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
class ChangeLog < ApplicationRecord
|
||||||
|
end
|
0
app/models/concerns/.keep
Normal file
0
app/models/concerns/.keep
Normal file
20
app/models/connection.rb
Normal file
20
app/models/connection.rb
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
class Connection < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :family
|
||||||
|
has_many :accounts, dependent: :destroy
|
||||||
|
belongs_to :institution, optional: true, foreign_key: :aggregator_id, primary_key: :provider_id
|
||||||
|
|
||||||
|
enum source: { plaid: 0, manual: 1 }
|
||||||
|
enum status: { ok: 0, error: 1, disconnected: 2 }
|
||||||
|
enum sync_status: { idle: 0, pending: 1, syncing: 2 }
|
||||||
|
|
||||||
|
scope :error, -> { where(status: :error) }
|
||||||
|
|
||||||
|
def has_investments?
|
||||||
|
plaid_products.include? 'investments'
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_transactions?
|
||||||
|
plaid_products.include? 'transactions'
|
||||||
|
end
|
||||||
|
end
|
4
app/models/conversation.rb
Normal file
4
app/models/conversation.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
class Conversation < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
has_many :messages, dependent: :destroy
|
||||||
|
end
|
63
app/models/family.rb
Normal file
63
app/models/family.rb
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
class Family < ApplicationRecord
|
||||||
|
has_many :users, dependent: :destroy
|
||||||
|
has_many :accounts, dependent: :destroy
|
||||||
|
has_many :transactions, through: :accounts
|
||||||
|
has_many :connections, dependent: :destroy
|
||||||
|
has_many :transactions, dependent: :destroy
|
||||||
|
has_many :holdings, dependent: :destroy
|
||||||
|
has_many :metrics, dependent: :destroy
|
||||||
|
has_many :balances, dependent: :destroy
|
||||||
|
|
||||||
|
def metrics
|
||||||
|
Metric.where(family_id: self.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def net_worth
|
||||||
|
metrics.where(kind: 'net_worth').order(date: :desc).first&.amount || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_assets
|
||||||
|
metrics.where(kind: 'total_assets').order(date: :desc).first&.amount || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_debts
|
||||||
|
metrics.where(kind: 'total_debts').order(date: :desc).first&.amount || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def cash_balance
|
||||||
|
metrics.where(kind: 'depository_balance').order(date: :desc).first&.amount || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def credit_balance
|
||||||
|
# If no metrics exist, return 0
|
||||||
|
metrics.where(kind: 'credit_balance').order(date: :desc).first&.amount || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def investment_balance
|
||||||
|
metrics.where(kind: 'investment_balance').order(date: :desc).first&.amount || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def property_balance
|
||||||
|
metrics.where(kind: 'property_balance').order(date: :desc).first&.amount || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Demographics JSONB sample
|
||||||
|
# {
|
||||||
|
# "spouse_1_age": 35,
|
||||||
|
# "spouse_2_age": 30,
|
||||||
|
# "dependents": 2,
|
||||||
|
# "dependents_ages": [5, 10],
|
||||||
|
# "gross_income": 100000,
|
||||||
|
# "income_types": ["salary"], // or "self-employed", "retired", "other"
|
||||||
|
# "tax_status": "married filing jointly", // or "single", "married filing separately", "head of household"
|
||||||
|
# "risk_tolerance": "conservative", // or "moderate", "aggressive"
|
||||||
|
# "investment_horizon": "short", // or "medium", "long"
|
||||||
|
# "investment_objective": "retirement", // or "college", "emergency", "other"
|
||||||
|
# "investment_experience": "beginner", // or "intermediate", "advanced"
|
||||||
|
# "investment_knowledge": "beginner", // or "intermediate", "advanced"
|
||||||
|
# "investment_style": "passive", // or "active"
|
||||||
|
# "investment_strategy": "index", // or "active"
|
||||||
|
# "investment_frequency": "monthly", // or "quarterly", "semi-annually", "annually"
|
||||||
|
# "goals": ['retire by 65', 'buy a house by 30', 'save for college', 'learn to invest']
|
||||||
|
# }
|
||||||
|
end
|
23
app/models/holding.rb
Normal file
23
app/models/holding.rb
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
class Holding < ApplicationRecord
|
||||||
|
belongs_to :account
|
||||||
|
belongs_to :security
|
||||||
|
belongs_to :family
|
||||||
|
|
||||||
|
after_update :log_changes
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def log_changes
|
||||||
|
ignored_attributes = ['updated_at']
|
||||||
|
|
||||||
|
saved_changes.except(*ignored_attributes).each do |attr, (old_val, new_val)|
|
||||||
|
ChangeLog.create(
|
||||||
|
record_type: self.class.name,
|
||||||
|
record_id: id,
|
||||||
|
attribute_name: attr,
|
||||||
|
old_value: old_val,
|
||||||
|
new_value: new_val
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
2
app/models/institution.rb
Normal file
2
app/models/institution.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
class Institution < ApplicationRecord
|
||||||
|
end
|
4
app/models/investment_transaction.rb
Normal file
4
app/models/investment_transaction.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
class InvestmentTransaction < ApplicationRecord
|
||||||
|
belongs_to :account
|
||||||
|
belongs_to :security
|
||||||
|
end
|
7
app/models/message.rb
Normal file
7
app/models/message.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
class Message < ApplicationRecord
|
||||||
|
belongs_to :conversation
|
||||||
|
belongs_to :user, optional: true
|
||||||
|
|
||||||
|
# Scope to only show messages that are not hidden
|
||||||
|
scope :visible, -> { where(hidden: false) }
|
||||||
|
end
|
4
app/models/metric.rb
Normal file
4
app/models/metric.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
class Metric < ApplicationRecord
|
||||||
|
belongs_to :user, optional: true
|
||||||
|
belongs_to :family
|
||||||
|
end
|
5
app/models/prompt.rb
Normal file
5
app/models/prompt.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class Prompt < ApplicationRecord
|
||||||
|
def self.unique_categories
|
||||||
|
pluck(:categories).flatten.uniq
|
||||||
|
end
|
||||||
|
end
|
12
app/models/security.rb
Normal file
12
app/models/security.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
class Security < ApplicationRecord
|
||||||
|
has_many :security_prices
|
||||||
|
has_many :holdings
|
||||||
|
has_many :balances
|
||||||
|
|
||||||
|
# After creating a security, sync the price history
|
||||||
|
after_create :sync_price_history
|
||||||
|
|
||||||
|
def sync_price_history
|
||||||
|
SyncSecurityHistoryJob.perform_async(self.id)
|
||||||
|
end
|
||||||
|
end
|
3
app/models/security_price.rb
Normal file
3
app/models/security_price.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
class SecurityPrice < ApplicationRecord
|
||||||
|
belongs_to :security
|
||||||
|
end
|
12
app/models/transaction.rb
Normal file
12
app/models/transaction.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
class Transaction < ApplicationRecord
|
||||||
|
belongs_to :account
|
||||||
|
belongs_to :family
|
||||||
|
|
||||||
|
def inflow?
|
||||||
|
amount > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def outflow?
|
||||||
|
amount < 0
|
||||||
|
end
|
||||||
|
end
|
60
app/models/user.rb
Normal file
60
app/models/user.rb
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
class User < ApplicationRecord
|
||||||
|
pay_customer stripe_attributes: :stripe_attributes, default_payment_processor: :stripe
|
||||||
|
|
||||||
|
belongs_to :family
|
||||||
|
has_many :connections, dependent: :destroy
|
||||||
|
has_many :accounts, through: :connections
|
||||||
|
has_many :messages
|
||||||
|
has_many :conversations
|
||||||
|
has_many :metrics
|
||||||
|
|
||||||
|
accepts_nested_attributes_for :family
|
||||||
|
|
||||||
|
devise :database_authenticatable, :registerable,
|
||||||
|
:recoverable, :rememberable, :validatable,
|
||||||
|
:confirmable, :lockable, :trackable, :omniauthable
|
||||||
|
|
||||||
|
def stripe_attributes(pay_customer)
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
pay_customer_id: pay_customer.id,
|
||||||
|
user_id: id, # or pay_customer.owner_id
|
||||||
|
family_id: self.family.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_time_of_day
|
||||||
|
# Should output Morning, Afternoon or Evening based on the user's local time, which is deteremined by the user's timezone
|
||||||
|
|
||||||
|
location = Geocoder.search(current_sign_in_ip).first
|
||||||
|
|
||||||
|
if location
|
||||||
|
timezone_identifier = location.data['timezone']
|
||||||
|
|
||||||
|
if timezone_identifier
|
||||||
|
timezone = ActiveSupport::TimeZone[timezone_identifier]
|
||||||
|
|
||||||
|
# Get the user's local time
|
||||||
|
local_time = Time.now.in_time_zone(timezone)
|
||||||
|
|
||||||
|
# Get the hour of the day
|
||||||
|
hour = local_time.hour
|
||||||
|
|
||||||
|
# Return the appropriate greeting
|
||||||
|
if hour >= 3 && hour < 12
|
||||||
|
"morning"
|
||||||
|
elsif hour >= 12 && hour < 18
|
||||||
|
"afternoon"
|
||||||
|
else
|
||||||
|
"evening"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
"morning"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
"morning"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
15
app/services/replica_query_service.rb
Normal file
15
app/services/replica_query_service.rb
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
class ReplicaQueryService
|
||||||
|
class ReplicaConnection < ActiveRecord::Base
|
||||||
|
self.abstract_class = true
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.execute(query)
|
||||||
|
ReplicaConnection.establish_connection(ENV['READONLY_DATABASE_URL'])
|
||||||
|
result = ReplicaConnection.connection.execute(query)
|
||||||
|
|
||||||
|
# Close the connection when done
|
||||||
|
ReplicaConnection.connection_pool.disconnect!
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
298
app/sidekiq/ask_question_job.rb
Normal file
298
app/sidekiq/ask_question_job.rb
Normal file
|
@ -0,0 +1,298 @@
|
||||||
|
class AskQuestionJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(conversation_id, reply_id)
|
||||||
|
conversation = Conversation.find(conversation_id)
|
||||||
|
return if conversation.nil?
|
||||||
|
|
||||||
|
reply = Message.find(reply_id)
|
||||||
|
openai = OpenAI::Client.new(access_token: ENV['OPENAI_ACCESS_TOKEN'])
|
||||||
|
viability = determine_viability(openai, conversation)
|
||||||
|
|
||||||
|
if !viability["content_contains_answer"]
|
||||||
|
handle_non_viable_conversation(openai, conversation, reply, viability)
|
||||||
|
else
|
||||||
|
generate_response_content(openai, conversation, reply)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def handle_non_viable_conversation(openai, conversation, reply, viability)
|
||||||
|
if viability["user_intent"] == "system"
|
||||||
|
reply.update(content: "I'm sorry, I'm not able to help with that right now.")
|
||||||
|
update_conversation(reply, conversation)
|
||||||
|
else
|
||||||
|
reply.update(status: 'data')
|
||||||
|
update_conversation(reply, conversation)
|
||||||
|
|
||||||
|
build_sql(openai, conversation, viability["user_intent"])
|
||||||
|
viability = determine_viability(openai, conversation)
|
||||||
|
|
||||||
|
if viability["content_contains_answer"]
|
||||||
|
reply.update(status: 'processing')
|
||||||
|
update_conversation(reply, conversation)
|
||||||
|
|
||||||
|
generate_response_content(openai, conversation, reply)
|
||||||
|
else
|
||||||
|
reply.update(content: "I'm sorry, I wasn't able to find the necessary information in the database.")
|
||||||
|
update_conversation(reply, conversation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_response_content(openai, conversation, reply)
|
||||||
|
response_content = query_openai(openai, conversation, reply)
|
||||||
|
reply.update(content: response_content, status: 'done')
|
||||||
|
update_conversation(reply, conversation)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_conversation(reply, conversation)
|
||||||
|
conversation.broadcast_append_to "conversation_area", partial: "conversations/message", locals: { message: reply }, target: "conversation_area_#{conversation.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_sql(openai, conversation, sql_intent)
|
||||||
|
# Load schema file from config/llmschema.yml
|
||||||
|
schema = YAML.load_file(Rails.root.join('config', 'llmschema.yml'))
|
||||||
|
sql_scopes = YAML.load_file(Rails.root.join('config', 'llmsql.yml'))
|
||||||
|
scope = sql_scopes['intent'].find { |intent| intent['name'] == sql_intent }['scope']
|
||||||
|
core = sql_scopes['core'].first['scope']
|
||||||
|
|
||||||
|
family_id = conversation.user.family_id
|
||||||
|
accounts_ids = conversation.user.accounts.pluck(:id)
|
||||||
|
|
||||||
|
# Get the most recent user message
|
||||||
|
message = conversation.messages.where(role: "user").order(created_at: :asc).last
|
||||||
|
|
||||||
|
# Get the last log message from the assistant and get the 'resolve' value (log should be converted to a hash from JSON)
|
||||||
|
last_log = conversation.messages.where(role: "log").where.not(log: nil).order(created_at: :desc).first
|
||||||
|
last_log_json = JSON.parse(last_log.log)
|
||||||
|
resolve_value = last_log_json["resolve"]
|
||||||
|
|
||||||
|
sql = openai.chat(
|
||||||
|
parameters: {
|
||||||
|
model: "gpt-4-1106-preview",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You are an expert in SQL and Postgres."},
|
||||||
|
{ role: "assistant", content: <<-ASSISTANT.strip_heredoc },
|
||||||
|
#{schema}
|
||||||
|
|
||||||
|
family_id = #{family_id}
|
||||||
|
account_ids = #{accounts_ids}
|
||||||
|
|
||||||
|
Given the preceding Postgres database schemas and variables, write an SQL query that answers the question '#{message.content}'.
|
||||||
|
|
||||||
|
According to the last log message, this is what is needed to answer the question: '#{resolve_value}'.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
#{core}
|
||||||
|
#{scope}
|
||||||
|
|
||||||
|
Respond exclusively with the SQL query, no preamble or explanation, beginning with 'SELECT' and ending with a semicolon.
|
||||||
|
|
||||||
|
Do NOT include "```sql" or "```" in your response.
|
||||||
|
ASSISTANT
|
||||||
|
],
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: 2048
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
sql_content = sql.dig("choices", 0, "message", "content")
|
||||||
|
|
||||||
|
markdown_reply = conversation.messages.new
|
||||||
|
markdown_reply.log = sql_content
|
||||||
|
markdown_reply.user = nil
|
||||||
|
markdown_reply.role = "assistant"
|
||||||
|
markdown_reply.hidden = true
|
||||||
|
markdown_reply.save
|
||||||
|
|
||||||
|
Rails.logger.warn sql_content
|
||||||
|
|
||||||
|
results = ReplicaQueryService.execute(sql_content)
|
||||||
|
|
||||||
|
# Convert results to markdown
|
||||||
|
markdown = "| #{results.fields.join(' | ')} |\n| #{results.fields.map { |f| '-' * f.length }.join(' | ')} |\n"
|
||||||
|
results.each do |row|
|
||||||
|
markdown << "| #{row.values.join(' | ')} |\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
if results.first.nil?
|
||||||
|
response_content = "I wasn't able to find any relevant information in the database."
|
||||||
|
markdown_reply.update(content: response_content)
|
||||||
|
else
|
||||||
|
markdown_reply.update(content: markdown)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def determine_viability(openai, conversation)
|
||||||
|
conversation_history = conversation.messages.where.not(content: [nil, ""]).where.not(content: "...").where.not(role: 'log').order(:created_at)
|
||||||
|
|
||||||
|
messages = conversation_history.map do |message|
|
||||||
|
{ role: message.role, content: message.content }
|
||||||
|
end
|
||||||
|
|
||||||
|
total_content_length = messages.sum { |message| message[:content]&.length.to_i }
|
||||||
|
|
||||||
|
while total_content_length > 10000
|
||||||
|
oldest_message = messages.shift
|
||||||
|
total_content_length -= oldest_message[:content]&.length.to_i
|
||||||
|
|
||||||
|
if total_content_length <= 8000
|
||||||
|
messages.unshift(oldest_message) # Put the message back if the total length is within the limit
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Remove the last message, as it is the one we are trying to answer
|
||||||
|
messages.pop if messages.last[:role] == "user"
|
||||||
|
|
||||||
|
message = conversation.messages.where(role: "user").order(created_at: :asc).last
|
||||||
|
|
||||||
|
response = openai.chat(
|
||||||
|
parameters: {
|
||||||
|
model: "gpt-4-1106-preview",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You are a highly intelligent certified financial advisor tasked with helping the customer make wise financial decisions based on real data.\n\nHere's some contextual information:\n#{messages}"},
|
||||||
|
{ role: "assistant", content: <<-ASSISTANT.strip_heredoc },
|
||||||
|
Instructions: First, determine the user's intent from the following prioritized list:
|
||||||
|
1. reply: the user is replying to a previous message
|
||||||
|
2. education: the user is trying to learn more about personal finance, but is not asking specific questions about their own finances
|
||||||
|
3. metrics: the user wants to know the value of specific financial metrics (we already have metrics for net worth, depository balance, investment balance, total assets, total debts, and categorical spending). does NOT include merchant-specific metrics. for example, you will NOT find Amazon spending, but you will find all spending in the 'shopping' category. if asking about a specific merchant, then the intent is transactional.
|
||||||
|
4. transactional: the user wants to know about a specific transactions. this includes reccurring and subscription transactions.
|
||||||
|
5. investing: the user has a specific question about investing and needs real-time data
|
||||||
|
6. accounts: the user has a specific question about their accounts
|
||||||
|
7. system: the user wants to know how to do something within the product
|
||||||
|
|
||||||
|
Second, remember to keep these things in mind regarding how to resolve:
|
||||||
|
- We have access to both historical and real-time data we can query, so we can answer questions about the user's accounts. But if we need to get that data, then content_contains_answer should be false.
|
||||||
|
- If the user is asking for metrics, then resolution should be to query the metrics table.
|
||||||
|
- If the user asks about a specific stock/security, always make sure data for that specific security is available, otherwise content_contains_answer should be false.
|
||||||
|
|
||||||
|
Third, respond exclusively with in JSON format:
|
||||||
|
{
|
||||||
|
"user_intent": string, // The user's intent
|
||||||
|
"intent_reasoning": string, // Why you think the user's intent is what you think it is.
|
||||||
|
"metric_name": lowercase string, // The human name of the metric the user is asking about. Only include if intent is 'metrics'.
|
||||||
|
"content_contains_answer": boolean, // true or false. Whether the information in the content is sufficient to resolve the issue. If intent is 'education' there's a high chance this should be true. If the intent is 'reply' this should be true.
|
||||||
|
"justification": string, // Why the content you found is or is not sufficient to resolve the issue.
|
||||||
|
"resolve": string, // The specific data needed to resolve the issue, succinctly. Focus on actionable, exact information.
|
||||||
|
}
|
||||||
|
ASSISTANT
|
||||||
|
{ role: "user", content: "User inquiry: #{message.content}" },
|
||||||
|
],
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: 500,
|
||||||
|
response_format: { type: "json_object" }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_response = response.dig("choices", 0, "message", "content")
|
||||||
|
|
||||||
|
justification_reply = conversation.messages.new
|
||||||
|
justification_reply.log = raw_response
|
||||||
|
justification_reply.user = nil
|
||||||
|
justification_reply.role = "log"
|
||||||
|
justification_reply.hidden = true
|
||||||
|
justification_reply.save
|
||||||
|
|
||||||
|
JSON.parse(raw_response)
|
||||||
|
end
|
||||||
|
|
||||||
|
def query_openai(openai, conversation, reply)
|
||||||
|
conversation_history = conversation.messages.where.not(content: [nil, ""]).where.not(content: "...").where.not(role: 'log').order(:created_at)
|
||||||
|
|
||||||
|
messages = conversation_history.map do |message|
|
||||||
|
{ role: message.role, content: message.content }
|
||||||
|
end
|
||||||
|
|
||||||
|
total_content_length = messages.sum { |message| message[:content]&.length.to_i }
|
||||||
|
|
||||||
|
while total_content_length > 10000
|
||||||
|
oldest_message = messages.shift
|
||||||
|
total_content_length -= oldest_message[:content]&.length.to_i
|
||||||
|
|
||||||
|
if total_content_length <= 8000
|
||||||
|
messages.unshift(oldest_message) # Put the message back if the total length is within the limit
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
message = conversation.messages.where(role: "user").order(created_at: :asc).last
|
||||||
|
|
||||||
|
# Get the last log message from the assistant and get the 'resolve' value (log should be converted to a hash from JSON)
|
||||||
|
last_log = conversation.messages.where(role: "log").where.not(log: nil).order(created_at: :desc).first
|
||||||
|
last_log_json = JSON.parse(last_log.log)
|
||||||
|
resolve_value = last_log_json["resolve"]
|
||||||
|
|
||||||
|
text_string = ''
|
||||||
|
|
||||||
|
response = openai.chat(
|
||||||
|
parameters: {
|
||||||
|
model: "gpt-4-1106-preview",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You are a highly intelligent certified financial advisor/teacher/mentor tasked with helping the customer make wise financial decisions based on real data. You generally respond in the Socratic style. Try to ask just the right question to help educate the user and get them thinking critically about their finances. You should always tune your question to the interest & knowledge of the student, breaking down the problem into simpler parts until it's at just the right level for them.\n\nUse only the information in the conversation to construct your response."},
|
||||||
|
{ role: "assistant", content: <<-ASSISTANT.strip_heredoc },
|
||||||
|
Here is information about the user and their financial situation, so you understand them better:
|
||||||
|
- Location: #{conversation.user.family.region}, #{conversation.user.family.country}
|
||||||
|
- Age: #{conversation.user.birthday ? (Date.today.year - conversation.user.birthday.year) : "Unknown"}
|
||||||
|
- Risk tolerance: #{conversation.user.family.risk}
|
||||||
|
- Household: #{conversation.user.family.household}
|
||||||
|
- Financial Goals: #{conversation.user.family.goals}
|
||||||
|
- Investment horizon: 20 years
|
||||||
|
- Income: $10,000 per month
|
||||||
|
- Expenses: $9,000 per month
|
||||||
|
- Family size: 2 adults, 2 children, 2 dogs
|
||||||
|
- Net worth: #{conversation.user.family.net_worth}
|
||||||
|
- Total assets: #{conversation.user.family.total_assets}
|
||||||
|
- Total debts: #{conversation.user.family.total_debts}
|
||||||
|
- Cash balance: #{conversation.user.family.cash_balance}
|
||||||
|
- Investment balance: #{conversation.user.family.investment_balance}
|
||||||
|
- Credit balance: #{conversation.user.family.credit_balance}
|
||||||
|
- Property balance: #{conversation.user.family.property_balance}
|
||||||
|
|
||||||
|
Follow these rules as you create your answer:
|
||||||
|
- Keep responses very brief and to the point, unless the user asks for more details.
|
||||||
|
- Response should be in markdown format, adding bold or italics as needed.
|
||||||
|
- If you output a formula, wrap it in backticks.
|
||||||
|
- Do not output any SQL, IDs or UUIDs.
|
||||||
|
- Data should be human readable.
|
||||||
|
- Dates should be long form.
|
||||||
|
- If there is no data for the requested date, say there isn't enough data.
|
||||||
|
- Don't include pleasantries.
|
||||||
|
- Favor putting lists in tabular markdown format, especially if they're long.
|
||||||
|
- Currencies should be output with two decimal places and a dollar sign.
|
||||||
|
- Use full names for financial products, not abbreviations.
|
||||||
|
- Answer truthfully and be specific.
|
||||||
|
- If you are doing a calculation, show the formula.
|
||||||
|
- If you don't have certain industry data, use the S&P 500 as a proxy.
|
||||||
|
- Remember, "accounts" and "transactions" are different things.
|
||||||
|
- If you are not absolutely sure what the user is asking, ask them to clarify. Clarity is key.
|
||||||
|
- Unless the user explicitly asks for "pending" transactions, you should ignore all transactions where is_pending is true.
|
||||||
|
|
||||||
|
According to the last log message, this is what is needed to answer the question: '#{resolve_value}'.
|
||||||
|
|
||||||
|
Be sure to output what data you are using to answer the question, and why you are using it.
|
||||||
|
|
||||||
|
ASSISTANT
|
||||||
|
*messages
|
||||||
|
],
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: 1200,
|
||||||
|
stream: proc do |chunks, _bytesize|
|
||||||
|
conversation.broadcast_remove_to "conversation_area", target: "message_content_loader_#{reply.id}"
|
||||||
|
|
||||||
|
if chunks.dig("choices")[0]["delta"].present?
|
||||||
|
content = chunks.dig("choices", 0, "delta", "content")
|
||||||
|
text_string += content unless content.nil?
|
||||||
|
|
||||||
|
conversation.broadcast_append_to "conversation_area", partial: "conversations/stream", locals: { text: content }, target: "message_content_#{reply.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
text_string
|
||||||
|
end
|
||||||
|
end
|
56
app/sidekiq/enrich_transactions_job.rb
Normal file
56
app/sidekiq/enrich_transactions_job.rb
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
class EnrichTransactionsJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform
|
||||||
|
enrichment = Faraday.new(
|
||||||
|
url: 'https://api.ntropy.com/v2/transactions/sync',
|
||||||
|
headers: {
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'X-API-KEY' => ENV['NTROPY_KEY']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Select transactions that have not been enriched and then batch them in groups
|
||||||
|
Transaction.where(enriched_at: nil).group_by { |t| [t.account_id, t.date.beginning_of_month] }.each do |(account_id, date), transaction_group|
|
||||||
|
user_id = Account.find(account_id).connection.user_id
|
||||||
|
|
||||||
|
transactions = transaction_group.map do |transaction|
|
||||||
|
{
|
||||||
|
description: transaction.name,
|
||||||
|
entry_type: transaction.amount.negative? ? 'incoming' : 'outgoing',
|
||||||
|
amount: transaction.amount.abs.to_f,
|
||||||
|
iso_currency_code: transaction.currency_code,
|
||||||
|
date: transaction.date.strftime('%Y-%m-%d'),
|
||||||
|
transaction_id: transaction.source_transaction_id,
|
||||||
|
account_holder_id: user_id,
|
||||||
|
account_holder_type: 'consumer'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
response = enrichment.post do |req|
|
||||||
|
req.body = transactions.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
if response.status == 200
|
||||||
|
JSON.parse(response.body).each do |enriched_transaction|
|
||||||
|
transaction = Transaction.find_by(source_transaction_id: enriched_transaction['transaction_id'])
|
||||||
|
transaction.update(
|
||||||
|
enrichment_intermediaries: enriched_transaction['intermediaries'],
|
||||||
|
enrichment_label_group: enriched_transaction['label_group'],
|
||||||
|
enrichment_label: enriched_transaction['labels'].first,
|
||||||
|
enrichment_location: enriched_transaction['location'],
|
||||||
|
enrichment_logo: enriched_transaction['logo'],
|
||||||
|
enrichment_mcc: enriched_transaction['mcc'],
|
||||||
|
enrichment_merchant_name: enriched_transaction['merchant'],
|
||||||
|
enrichment_merchant_id: enriched_transaction['merchant_id'],
|
||||||
|
enrichment_merchant_website: enriched_transaction['website'],
|
||||||
|
enrichment_person: enriched_transaction['person'],
|
||||||
|
enrichment_recurrence: enriched_transaction['recurrence'],
|
||||||
|
enrichment_recurrence_group: enriched_transaction['recurrence_group'],
|
||||||
|
enriched_at: Time.now
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
25
app/sidekiq/generate_balance_job.rb
Normal file
25
app/sidekiq/generate_balance_job.rb
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
class GenerateBalanceJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(account_id)
|
||||||
|
account = Account.find(account_id)
|
||||||
|
|
||||||
|
return if account.nil?
|
||||||
|
|
||||||
|
# Calculate change since last balance
|
||||||
|
last_balance = Balance.where(account_id: account_id, security_id: nil).order(date: :desc).limit(2).last&.balance
|
||||||
|
|
||||||
|
# Get current balance and save it to Balance model. Update based on account and date. Don't add last_balance if it's nil.
|
||||||
|
Balance.find_or_initialize_by(account_id: account_id, security_id: nil, date: Date.today, kind: 'account', family_id: account.family.id).update(balance: account.current_balance, change: last_balance.nil? ? 0 : account.current_balance - last_balance)
|
||||||
|
|
||||||
|
# Check if there holdings
|
||||||
|
if account.holdings.any?
|
||||||
|
# Get current holdings value and save it to Balance model. Update based on account, security and date.
|
||||||
|
account.holdings.each do |holding|
|
||||||
|
last_holding_balance = Balance.where(account_id: account_id, security_id: holding.security_id).order(date: :desc).limit(2).last&.balance
|
||||||
|
|
||||||
|
Balance.find_or_initialize_by(account_id: account_id, security_id: holding.security_id, date: Date.today, kind: 'security', family_id: account.family.id).update(balance: holding.value, cost_basis: holding.cost_basis_source, quantity: holding.quantity, change: last_holding_balance.nil? ? 0 : holding.value - last_holding_balance)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
34
app/sidekiq/generate_categorical_metrics_job.rb
Normal file
34
app/sidekiq/generate_categorical_metrics_job.rb
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
class GenerateCategoricalMetricsJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(family_id)
|
||||||
|
family = Family.find(family_id)
|
||||||
|
|
||||||
|
# Get all transactions for the family
|
||||||
|
transactions = family.transactions
|
||||||
|
|
||||||
|
# Group all transactions by enrichement_label and date
|
||||||
|
transactions_by_label = transactions.group_by { |transaction| [transaction.enrichment_label, transaction.date] }
|
||||||
|
|
||||||
|
# Iterate over each group, the first element of the group is an array with the label and the date, the second element is an array of transactions
|
||||||
|
transactions_by_label.each do |details, transactions|
|
||||||
|
# Get the label and date from the first element of the group
|
||||||
|
label = details.first
|
||||||
|
date = details.second
|
||||||
|
|
||||||
|
# Get the sum of all transactions in the group
|
||||||
|
amount = transactions.sum(&:amount)
|
||||||
|
|
||||||
|
# Create a categorical_spending metric for the label and date
|
||||||
|
Metric.find_or_create_by!(kind: 'categorical_spending', subkind: label, family: family, date: date).update(amount: amount)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
# Create monthly roundup by enrichement_label using the categorical_spending metric
|
||||||
|
Metric.where(kind: 'categorical_spending', family: family).group_by { |metric| [metric.subkind, metric.date.end_of_month] }.each do |label, metrics|
|
||||||
|
amount = metrics.sum(&:amount)
|
||||||
|
|
||||||
|
Metric.find_or_create_by(kind: 'categorical_spending_monthly', subkind: label.first, family: family, date: label.second).update(amount: amount)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
31
app/sidekiq/generate_metrics_job.rb
Normal file
31
app/sidekiq/generate_metrics_job.rb
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
class GenerateMetricsJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(family_id)
|
||||||
|
family = Family.find(family_id)
|
||||||
|
|
||||||
|
accounts = family.accounts
|
||||||
|
|
||||||
|
depository_accounts_balance = accounts.depository.sum { |account| account.current_balance }
|
||||||
|
|
||||||
|
investment_accounts_balance = accounts.investment.sum { |account| account.current_balance }
|
||||||
|
|
||||||
|
credit_accounts_balance = accounts.credit.sum { |account| account.current_balance }
|
||||||
|
|
||||||
|
property_accounts_balance = accounts.property.sum { |account| account.current_balance }
|
||||||
|
|
||||||
|
total_assets = depository_accounts_balance + investment_accounts_balance + property_accounts_balance
|
||||||
|
|
||||||
|
total_debts = credit_accounts_balance
|
||||||
|
|
||||||
|
net_worth = total_assets - total_debts
|
||||||
|
|
||||||
|
Metric.find_or_create_by(kind: 'depository_balance', family: family, date: Date.today).update(amount: depository_accounts_balance)
|
||||||
|
Metric.find_or_create_by(kind: 'investment_balance', family: family, date: Date.today).update(amount: investment_accounts_balance)
|
||||||
|
Metric.find_or_create_by(kind: 'total_assets', family: family, date: Date.today).update(amount: total_assets)
|
||||||
|
Metric.find_or_create_by(kind: 'total_debts', family: family, date: Date.today).update(amount: total_debts)
|
||||||
|
Metric.find_or_create_by(kind: 'net_worth', family: family, date: Date.today).update(amount: net_worth)
|
||||||
|
Metric.find_or_create_by(kind: 'credit_balance', family: family, date: Date.today).update(amount: credit_accounts_balance)
|
||||||
|
Metric.find_or_create_by(kind: 'property_balance', family: family, date: Date.today).update(amount: property_accounts_balance)
|
||||||
|
end
|
||||||
|
end
|
18
app/sidekiq/real_time_sync_job.rb
Normal file
18
app/sidekiq/real_time_sync_job.rb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
class RealTimeSyncJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(security_id)
|
||||||
|
security = Security.find(security_id)
|
||||||
|
|
||||||
|
return if security.nil?
|
||||||
|
|
||||||
|
prices_connection = Faraday.get("https://api.twelvedata.com/price?apikey=#{ENV['TWELVEDATA_KEY']}&symbol=#{security.symbol}")
|
||||||
|
|
||||||
|
price = JSON.parse(prices_connection.body)['price']
|
||||||
|
|
||||||
|
return if price.nil?
|
||||||
|
|
||||||
|
# Update the security real time price
|
||||||
|
security.update(real_time_price: price, real_time_price_updated_at: DateTime.now)
|
||||||
|
end
|
||||||
|
end
|
79
app/sidekiq/sync_plaid_holdings_job.rb
Normal file
79
app/sidekiq/sync_plaid_holdings_job.rb
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
class SyncPlaidHoldingsJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(item_id)
|
||||||
|
connection = Connection.find_by(source: 'plaid', item_id: item_id)
|
||||||
|
|
||||||
|
return if connection.nil? or connection.status == 'error' or !connection.has_investments?
|
||||||
|
|
||||||
|
access_token = connection.access_token
|
||||||
|
accounts = connection.accounts
|
||||||
|
|
||||||
|
# Create a hash of account ids with matching source ids
|
||||||
|
account_ids = accounts.map { |account| [account.source_id, account.id] }.to_h
|
||||||
|
|
||||||
|
holdings_request = Plaid::InvestmentsHoldingsGetRequest.new({
|
||||||
|
access_token: access_token
|
||||||
|
})
|
||||||
|
|
||||||
|
# Rescue status code 400
|
||||||
|
begin
|
||||||
|
holdings_response = $plaid_api_client.investments_holdings_get(holdings_request)
|
||||||
|
rescue Plaid::ApiError => e
|
||||||
|
if e.code == 400
|
||||||
|
if JSON.parse(e.response_body)['error_code'] != 'PRODUCTS_NOT_SUPPORTED' or JSON.parse(e.response_body)['error_code'] != 'NO_INVESTMENT_ACCOUNTS'
|
||||||
|
# Update connection status to error and store the respoonse body in the error_message column
|
||||||
|
connection.update(status: 'error', error: JSON.parse(e.response_body))
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Process all securities first
|
||||||
|
securities = holdings_response.securities
|
||||||
|
|
||||||
|
# upsert_all securities
|
||||||
|
all_securities = []
|
||||||
|
|
||||||
|
securities.each do |security|
|
||||||
|
all_securities << {
|
||||||
|
source_id: security.security_id,
|
||||||
|
name: security.name,
|
||||||
|
symbol: security.ticker_symbol,
|
||||||
|
source: 'plaid',
|
||||||
|
source_type: security.type,
|
||||||
|
currency_code: security.iso_currency_code,
|
||||||
|
cusip: security.cusip,
|
||||||
|
isin: security.isin
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
Security.upsert_all(all_securities, unique_by: :index_securities_on_source_id)
|
||||||
|
|
||||||
|
# Process all holdings
|
||||||
|
holdings = holdings_response.holdings
|
||||||
|
|
||||||
|
# upsert_all holdings
|
||||||
|
all_holdings = []
|
||||||
|
|
||||||
|
holdings.each do |holding|
|
||||||
|
next if account_ids[holding.account_id].nil?
|
||||||
|
next if holding.quantity <= 0
|
||||||
|
|
||||||
|
all_holdings << {
|
||||||
|
account_id: account_ids[holding.account_id],
|
||||||
|
security_id: Security.find_by(source_id: holding.security_id).id,
|
||||||
|
quantity: holding.quantity,
|
||||||
|
value: holding.institution_value,
|
||||||
|
currency_code: holding.iso_currency_code,
|
||||||
|
cost_basis_source: holding.cost_basis,
|
||||||
|
source: 'plaid',
|
||||||
|
family_id: connection.family.id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
Holding.upsert_all(all_holdings, unique_by: :index_holdings_on_account_id_and_security_id)
|
||||||
|
|
||||||
|
SyncPlaidInvestmentTransactionsJob.perform_async(item_id)
|
||||||
|
end
|
||||||
|
end
|
40
app/sidekiq/sync_plaid_institutions_job.rb
Normal file
40
app/sidekiq/sync_plaid_institutions_job.rb
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
class SyncPlaidInstitutionsJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform
|
||||||
|
# Get all institutions from Plaid, which includes paginating through all pages
|
||||||
|
offset = 0
|
||||||
|
while true
|
||||||
|
institutions = []
|
||||||
|
|
||||||
|
institutions_get_request = Plaid::InstitutionsGetRequest.new({
|
||||||
|
offset: offset,
|
||||||
|
count: 500,
|
||||||
|
country_codes: ['US', 'CA'],
|
||||||
|
options: {
|
||||||
|
include_optional_metadata: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
response = $plaid_api_client.institutions_get(institutions_get_request)
|
||||||
|
institutions += response.institutions
|
||||||
|
|
||||||
|
# Upsert institutions in our database
|
||||||
|
all_institutions = []
|
||||||
|
institutions.each do |institution|
|
||||||
|
all_institutions << {
|
||||||
|
name: institution.name,
|
||||||
|
provider: 'plaid',
|
||||||
|
provider_id: institution.institution_id,
|
||||||
|
logo: institution.logo,
|
||||||
|
color: institution.primary_color,
|
||||||
|
url: institution.url,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
Institution.upsert_all(all_institutions, unique_by: :provider_id)
|
||||||
|
|
||||||
|
offset += 500
|
||||||
|
break if response.institutions.length < 500
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
126
app/sidekiq/sync_plaid_investment_transactions_job.rb
Normal file
126
app/sidekiq/sync_plaid_investment_transactions_job.rb
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
class SyncPlaidInvestmentTransactionsJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(item_id)
|
||||||
|
connection = Connection.find_by(source: 'plaid', item_id: item_id)
|
||||||
|
|
||||||
|
return if connection.nil? or connection.status == 'error' or !connection.has_investments?
|
||||||
|
|
||||||
|
access_token = connection.access_token
|
||||||
|
accounts = connection.accounts
|
||||||
|
|
||||||
|
# Create a hash of account ids with matching source ids
|
||||||
|
account_ids = accounts.map { |account| [account.source_id, account.id] }.to_h
|
||||||
|
|
||||||
|
start_date = (connection.investments_last_synced_at || Date.today - 2.years).to_date
|
||||||
|
|
||||||
|
request = Plaid::InvestmentsTransactionsGetRequest.new(
|
||||||
|
{
|
||||||
|
access_token: access_token,
|
||||||
|
start_date: start_date,
|
||||||
|
end_date: Date.today,
|
||||||
|
options: {
|
||||||
|
count: 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rescue status code 400
|
||||||
|
begin
|
||||||
|
response = $plaid_api_client.investments_transactions_get(request)
|
||||||
|
rescue Plaid::ApiError => e
|
||||||
|
if e.code == 400
|
||||||
|
if JSON.parse(e.response_body)['error_code'] != 'PRODUCTS_NOT_SUPPORTED' or JSON.parse(e.response_body)['error_code'] != 'NO_INVESTMENT_ACCOUNTS'
|
||||||
|
# Update connection status to error and store the respoonse body in the error_message column
|
||||||
|
connection.update(status: 'error', error: JSON.parse(e.response_body))
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
# Process all securities first
|
||||||
|
securities = response.securities
|
||||||
|
|
||||||
|
# upsert_all securities
|
||||||
|
all_securities = []
|
||||||
|
|
||||||
|
securities.each do |security|
|
||||||
|
all_securities << {
|
||||||
|
source_id: security.security_id,
|
||||||
|
name: security.name,
|
||||||
|
symbol: security.ticker_symbol,
|
||||||
|
source: 'plaid',
|
||||||
|
source_type: security.type,
|
||||||
|
currency_code: security.iso_currency_code,
|
||||||
|
cusip: security.cusip,
|
||||||
|
isin: security.isin
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
Security.upsert_all(all_securities, unique_by: :index_securities_on_source_id)
|
||||||
|
|
||||||
|
investmentTransactions = response.investment_transactions
|
||||||
|
|
||||||
|
# Manipulate the offset parameter to paginate transactions
|
||||||
|
# and retrieve all available data
|
||||||
|
while investmentTransactions.length() < response.total_investment_transactions
|
||||||
|
request = Plaid::InvestmentsTransactionsGetRequest.new(
|
||||||
|
{
|
||||||
|
access_token: access_token,
|
||||||
|
start_date: start_date,
|
||||||
|
end_date: Date.today,
|
||||||
|
options: {
|
||||||
|
count: 500,
|
||||||
|
offset: investmentTransactions.length()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = $plaid_api_client.investments_transactions_get(request)
|
||||||
|
investmentTransactions += response.investment_transactions
|
||||||
|
end
|
||||||
|
|
||||||
|
if investmentTransactions.any?
|
||||||
|
investmentTransactions_hash = investmentTransactions.map do |transaction|
|
||||||
|
security = Security.find_by(source_id: transaction.security_id)
|
||||||
|
|
||||||
|
next if security.blank?
|
||||||
|
{
|
||||||
|
account_id: account_ids[transaction.account_id],
|
||||||
|
security_id: security.id,
|
||||||
|
date: transaction.date,
|
||||||
|
name: transaction.name,
|
||||||
|
amount: transaction.amount,
|
||||||
|
quantity: transaction.quantity,
|
||||||
|
price: transaction.price,
|
||||||
|
fees: transaction.fees,
|
||||||
|
currency_code: transaction.iso_currency_code,
|
||||||
|
source_transaction_id: transaction.investment_transaction_id,
|
||||||
|
source_type: transaction.type,
|
||||||
|
source_subtype: transaction.subtype
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check hash for duplicate source_transaction_ids
|
||||||
|
# If there are duplicates, remove the duplicate
|
||||||
|
investmentTransactions_hash.compact.each_with_index do |transaction, index|
|
||||||
|
next unless transaction[:source_transaction_id]
|
||||||
|
|
||||||
|
if investmentTransactions_hash.count { |t| t && t[:source_transaction_id] == transaction[:source_transaction_id] } > 1
|
||||||
|
investmentTransactions_hash.delete_at(index)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
investmentTransactions_hash.compact!
|
||||||
|
|
||||||
|
InvestmentTransaction.upsert_all(investmentTransactions_hash, unique_by: :index_investment_transactions_on_source_transaction_id)
|
||||||
|
|
||||||
|
# Update investments_last_synced_at to the current time
|
||||||
|
connection.update(investments_last_synced_at: DateTime.now)
|
||||||
|
end
|
||||||
|
|
||||||
|
accounts.each do |account|
|
||||||
|
GenerateBalanceJob.perform_async(account.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
49
app/sidekiq/sync_plaid_item_accounts_job.rb
Normal file
49
app/sidekiq/sync_plaid_item_accounts_job.rb
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
class SyncPlaidItemAccountsJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(item_id)
|
||||||
|
connection = Connection.find_by(source: 'plaid', item_id: item_id)
|
||||||
|
|
||||||
|
return if connection.nil? or connection.status == 'error'
|
||||||
|
|
||||||
|
# Rescue status code 400
|
||||||
|
begin
|
||||||
|
connection_accounts = $plaid_api_client.accounts_get(Plaid::AccountsGetRequest.new({ access_token: connection.access_token }))
|
||||||
|
rescue Plaid::ApiError => e
|
||||||
|
if e.code == 400
|
||||||
|
# Update connection status to error and store the respoonse body in the error_message column
|
||||||
|
connection.update(status: 'error', error: JSON.parse(e.response_body))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
connection.update(plaid_products: connection_accounts.item.products)
|
||||||
|
|
||||||
|
connection_accounts.accounts.each do |account|
|
||||||
|
connection_account = Account.find_or_initialize_by(source: 'plaid', source_id: account.account_id)
|
||||||
|
connection_account.assign_attributes(
|
||||||
|
name: account.name,
|
||||||
|
official_name: account.official_name,
|
||||||
|
kind: account.type,
|
||||||
|
subkind: account.subtype,
|
||||||
|
available_balance: account.balances.available,
|
||||||
|
current_balance: account.balances.current,
|
||||||
|
current_balance_date: Date.today,
|
||||||
|
credit_limit: account.balances.limit,
|
||||||
|
currency_code: account.balances.iso_currency_code,
|
||||||
|
sync_status: 'pending',
|
||||||
|
mask: account.mask,
|
||||||
|
connection_id: connection.id,
|
||||||
|
family_id: connection.family_id
|
||||||
|
)
|
||||||
|
connection_account.save
|
||||||
|
|
||||||
|
#GenerateBalanceJob.perform_async(connection_account.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
connection.update(sync_status: 'idle')
|
||||||
|
|
||||||
|
SyncPlaidTransactionsJob.perform_async(item_id)
|
||||||
|
SyncPlaidHoldingsJob.perform_async(item_id)
|
||||||
|
end
|
||||||
|
end
|
101
app/sidekiq/sync_plaid_transactions_job.rb
Normal file
101
app/sidekiq/sync_plaid_transactions_job.rb
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
class SyncPlaidTransactionsJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(item_id)
|
||||||
|
connection = Connection.find_by(source: 'plaid', item_id: item_id)
|
||||||
|
|
||||||
|
return if connection.nil?
|
||||||
|
|
||||||
|
access_token = connection.access_token
|
||||||
|
accounts = connection.accounts
|
||||||
|
cursor = connection.cursor
|
||||||
|
|
||||||
|
# Create a hash of account ids with matching source ids
|
||||||
|
account_ids = accounts.map { |account| [account.source_id, account.id] }.to_h
|
||||||
|
|
||||||
|
added_transactions = []
|
||||||
|
modified_transactions = []
|
||||||
|
removed_transactions = []
|
||||||
|
has_more = true
|
||||||
|
|
||||||
|
while has_more
|
||||||
|
transactions_request = Plaid::TransactionsSyncRequest.new({
|
||||||
|
access_token: access_token,
|
||||||
|
cursor: cursor,
|
||||||
|
count: 500,
|
||||||
|
options: {
|
||||||
|
include_personal_finance_category: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
transactions_response = $plaid_api_client.transactions_sync(transactions_request)
|
||||||
|
|
||||||
|
added_transactions += transactions_response.added
|
||||||
|
modified_transactions += transactions_response.modified
|
||||||
|
removed_transactions += transactions_response.removed
|
||||||
|
|
||||||
|
has_more = transactions_response.has_more
|
||||||
|
cursor = transactions_response.next_cursor
|
||||||
|
end
|
||||||
|
|
||||||
|
connection.update(cursor: cursor)
|
||||||
|
|
||||||
|
if added_transactions.any?
|
||||||
|
added_transactions_hash = added_transactions.map do |transaction|
|
||||||
|
{
|
||||||
|
name: transaction.name,
|
||||||
|
amount: transaction.amount,
|
||||||
|
is_pending: transaction.pending,
|
||||||
|
date: transaction.date,
|
||||||
|
account_id: account_ids[transaction.account_id],
|
||||||
|
currency_code: transaction.iso_currency_code,
|
||||||
|
categories: transaction.category,
|
||||||
|
source_transaction_id: transaction.transaction_id,
|
||||||
|
source_category_id: transaction.category_id,
|
||||||
|
source_type: transaction.transaction_type,
|
||||||
|
merchant_name: transaction.merchant_name,
|
||||||
|
payment_channel: transaction.payment_channel,
|
||||||
|
flow: transaction.amount > 0 ? 1 : 0,
|
||||||
|
excluded: false,
|
||||||
|
family_id: connection.family.id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
Transaction.upsert_all(added_transactions_hash, unique_by: %i(source_transaction_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
if modified_transactions.any?
|
||||||
|
modified_transactions_hash = modified_transactions.map do |transaction|
|
||||||
|
{
|
||||||
|
name: transaction.name,
|
||||||
|
amount: transaction.amount,
|
||||||
|
is_pending: transaction.pending,
|
||||||
|
date: transaction.date,
|
||||||
|
account_id: account_ids[transaction.account_id],
|
||||||
|
currency_code: transaction.iso_currency_code,
|
||||||
|
categories: transaction.category,
|
||||||
|
source_transaction_id: transaction.transaction_id,
|
||||||
|
source_category_id: transaction.category_id,
|
||||||
|
source_type: transaction.transaction_type,
|
||||||
|
merchant_name: transaction.merchant_name,
|
||||||
|
payment_channel: transaction.payment_channel,
|
||||||
|
flow: transaction.amount < 0 ? 1 : 0,
|
||||||
|
excluded: false,
|
||||||
|
family_id: connection.family.id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
Transaction.upsert_all(modified_transactions_hash, unique_by: %i(source_transaction_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
if removed_transactions.any?
|
||||||
|
Transaction.where(source_transaction_id: removed_transactions).destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
EnrichTransactionsJob.perform_async
|
||||||
|
|
||||||
|
accounts.each do |account|
|
||||||
|
GenerateBalanceJob.perform_async(account.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
18
app/sidekiq/sync_property_values_job.rb
Normal file
18
app/sidekiq/sync_property_values_job.rb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
class SyncPropertyValuesJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(account_id)
|
||||||
|
account = Account.find(account_id)
|
||||||
|
|
||||||
|
# Check if auto_valuation is enabled and that current_balance_date is either nil or over 30 days old
|
||||||
|
if account.auto_valuation && (account.current_balance_date.nil? || account.current_balance_date < 30.days.ago)
|
||||||
|
url_formatted_address = "#{account.property_details['line_1'].gsub(' ','-')}-#{account.property_details['city']}-#{account.property_details['state_abbreviation']}-#{account.property_details['zip_code']}_rb"
|
||||||
|
scraper = Faraday.get("https://app.scrapingbee.com/api/v1/?api_key=#{ENV['SCRAPING_BEE_KEY']}&url=https%3A%2F%2Fwww.zillow.com%2Fhomes%2F#{url_formatted_address}%2F&render_js=false&extract_rules=%7B%22value%22%3A'%2F%2Fspan%5B%40data-testid%3D%22zestimate-text%22%5D%2Fspan%2Fspan'%7D")
|
||||||
|
|
||||||
|
# If the scraper returns a 200 status code, parse the response body and update the account
|
||||||
|
if scraper.status == 200 and JSON.parse(scraper.body)['value'].present?
|
||||||
|
account.update(current_balance: JSON.parse(scraper.body)['value'].gsub('$','').gsub(',','').to_i, current_balance_date: Date.today)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
92
app/sidekiq/sync_security_details_job.rb
Normal file
92
app/sidekiq/sync_security_details_job.rb
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
class SyncSecurityDetailsJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(security_id)
|
||||||
|
security = Security.find_by(id: security_id)
|
||||||
|
return unless security
|
||||||
|
|
||||||
|
update_security_details(security)
|
||||||
|
update_security_logo(security)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_security_details(security)
|
||||||
|
details = fetch_twelvedata_details(security.symbol)
|
||||||
|
return unless details
|
||||||
|
|
||||||
|
website = extract_website(details)
|
||||||
|
security.update(
|
||||||
|
industry: details['industry'],
|
||||||
|
sector: details['sector'],
|
||||||
|
website: website
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_twelvedata_details(symbol)
|
||||||
|
response = Faraday.get("https://api.twelvedata.com/profile?symbol=#{symbol}&apikey=#{ENV['TWELVEDATA_KEY']}")
|
||||||
|
JSON.parse(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_website(details)
|
||||||
|
return unless details['website'].present?
|
||||||
|
|
||||||
|
URI.parse(details['website']).host.gsub('www.', '')
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_security_logo(security)
|
||||||
|
logo_url, logo_source = fetch_logo(security.symbol, security.website, security.name)
|
||||||
|
security.update(logo: logo_url, logo_source: logo_source) if logo_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_logo(symbol, website, name)
|
||||||
|
logo_url, logo_source = fetch_polygon_logo(symbol)
|
||||||
|
logo_url, logo_source = fetch_twelvedata_logo(symbol) unless logo_url
|
||||||
|
logo_url, logo_source = fetch_clearbit_logo(website) unless logo_url
|
||||||
|
logo_url, logo_source = fetch_gpt_clearbit_logo(symbol, name) unless logo_url
|
||||||
|
|
||||||
|
[logo_url, logo_source]
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_polygon_logo(symbol)
|
||||||
|
response = Faraday.get("https://api.polygon.io/v3/reference/tickers/#{symbol}?apiKey=#{ENV['POLYGON_KEY']}")
|
||||||
|
results = JSON.parse(response.body)['results']
|
||||||
|
return unless results.present? && results['branding'].present?
|
||||||
|
|
||||||
|
[results['branding']['logo_url'], 'polygon']
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_twelvedata_logo(symbol)
|
||||||
|
response = Faraday.get("https://api.twelvedata.com/logo?symbol=#{symbol}&apikey=#{ENV['TWELVEDATA_KEY']}")
|
||||||
|
url = JSON.parse(response.body)['url']
|
||||||
|
return unless url.present?
|
||||||
|
|
||||||
|
[url, 'twelvedata']
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_clearbit_logo(website)
|
||||||
|
return unless website.present?
|
||||||
|
|
||||||
|
["https://logo.clearbit.com/#{website}", 'clearbit']
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_gpt_clearbit_logo(symbol, security_name)
|
||||||
|
openai = OpenAI::Client.new(access_token: ENV['OPENAI_ACCESS_TOKEN'])
|
||||||
|
gpt_response = openai.chat(
|
||||||
|
parameters: {
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You are tasked with finding the domain for a company. You are given the company name and the ticker symbol. You are given the following information:\n\nSecurity Name: #{security_name}\nSecurity Symbol: #{symbol}\n\nYou exclusively respond with only the domain and absolutely nothing else." },
|
||||||
|
{ role: "user", content: "What is the domain for this company?" },
|
||||||
|
],
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: 200
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
domain = gpt_response.dig("choices", 0, "message", "content")
|
||||||
|
return unless domain.present?
|
||||||
|
|
||||||
|
["https://logo.clearbit.com/#{domain}", 'clearbit']
|
||||||
|
end
|
||||||
|
end
|
47
app/sidekiq/sync_security_history_job.rb
Normal file
47
app/sidekiq/sync_security_history_job.rb
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
class SyncSecurityHistoryJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform(security_id)
|
||||||
|
security = Security.find(security_id)
|
||||||
|
|
||||||
|
return if security.nil?
|
||||||
|
|
||||||
|
earliest_date_connection = Faraday.get("https://api.twelvedata.com/earliest_timestamp?symbol=#{security.symbol}&interval=1day&apikey=#{ENV['TWELVEDATA_KEY']}")
|
||||||
|
|
||||||
|
earliest_date = JSON.parse(earliest_date_connection.body)['datetime']
|
||||||
|
|
||||||
|
prices_connection = Faraday.get("https://api.twelvedata.com/time_series?apikey=#{ENV['TWELVEDATA_KEY']}&interval=1day&symbol=#{security.symbol}&start_date=#{earliest_date}&outputsize=5000")
|
||||||
|
|
||||||
|
prices = JSON.parse(prices_connection.body)['values']
|
||||||
|
|
||||||
|
return if prices.nil?
|
||||||
|
|
||||||
|
meta = JSON.parse(prices_connection.body)['meta']
|
||||||
|
currency = meta['currency'] || 'USD'
|
||||||
|
exchange = meta['exchange'] || nil
|
||||||
|
kind = meta['type'] || nil
|
||||||
|
|
||||||
|
all_prices = []
|
||||||
|
|
||||||
|
prices.each do |price|
|
||||||
|
all_prices << {
|
||||||
|
security_id: security.id,
|
||||||
|
date: price['datetime'],
|
||||||
|
open: price['open'],
|
||||||
|
high: price['high'],
|
||||||
|
low: price['low'],
|
||||||
|
close: price['close'],
|
||||||
|
currency: currency,
|
||||||
|
exchange: exchange,
|
||||||
|
kind: kind
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# remove duplicate dates
|
||||||
|
all_prices.uniq! { |price| price[:date] }
|
||||||
|
|
||||||
|
SecurityPrice.upsert_all(all_prices, unique_by: :index_security_prices_on_security_id_and_date)
|
||||||
|
|
||||||
|
security.update(last_synced_at: DateTime.now)
|
||||||
|
end
|
||||||
|
end
|
4
app/views/accounts/assets.html.erb
Normal file
4
app/views/accounts/assets.html.erb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Accounts#assets</h1>
|
||||||
|
<p>Find me in app/views/accounts/assets.html.erb</p>
|
||||||
|
</div>
|
4
app/views/accounts/cash.html.erb
Normal file
4
app/views/accounts/cash.html.erb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Accounts#cash</h1>
|
||||||
|
<p>Find me in app/views/accounts/cash.html.erb</p>
|
||||||
|
</div>
|
4
app/views/accounts/credit.html.erb
Normal file
4
app/views/accounts/credit.html.erb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Accounts#credit</h1>
|
||||||
|
<p>Find me in app/views/accounts/credit.html.erb</p>
|
||||||
|
</div>
|
4
app/views/accounts/debts.html.erb
Normal file
4
app/views/accounts/debts.html.erb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Accounts#debts</h1>
|
||||||
|
<p>Find me in app/views/accounts/debts.html.erb</p>
|
||||||
|
</div>
|
183
app/views/accounts/index.html.erb
Normal file
183
app/views/accounts/index.html.erb
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
<div class="flex items-center justify-between mt-2 mb-2">
|
||||||
|
<h1 class="text-3xl font-semibold font-display">Accounts</h1>
|
||||||
|
<%= link_to "<i class='fa-solid fa-building-columns'></i> Your connections".html_safe, connections_path, class: 'bg-gray-200 hover:bg-gray-300 px-4 py-1 rounded-full text-sm m-0' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed z-50 bottom-24 right-6">
|
||||||
|
<%= link_to new_account_path, class: "px-3 py-2 text-xs font-normal text-center text-gray-500 rounded-md hover:text-gray-700" do %>
|
||||||
|
<span class="flex items-center justify-center w-12 h-12 mx-auto text-white bg-black rounded-full shrink-0 grow-0">
|
||||||
|
<i class="text-sm fa-kit fa-new"></i>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if current_family.accounts.blank? or current_family.metrics.blank? %>
|
||||||
|
<div>Before we can give you a full picture of your finances, you'll need to connect your bank accounts!</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<%= link_to 'Connect a bank account', new_account_path, class: 'bg-black rounded-full text-white px-4 py-2' %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="pt-5 mt-2 mb-8 text-center bg-white shadow-sm rounded-xl">
|
||||||
|
<h3 class="mb-2 text-gray-500">Net worth</h3>
|
||||||
|
<p class="text-lg font-semibold"><%= number_to_currency current_family.net_worth %></p>
|
||||||
|
<canvas
|
||||||
|
style="height:12vh; width:100%"
|
||||||
|
class="mt-2"
|
||||||
|
id="net_worth"
|
||||||
|
></canvas>
|
||||||
|
<script>
|
||||||
|
const ctx = document.getElementById('net_worth').getContext('2d'), gradient = ctx.createLinearGradient(0, 0, 0, 120);
|
||||||
|
|
||||||
|
gradient.addColorStop(0, 'rgba(52, 211, 153, 0.2)');
|
||||||
|
gradient.addColorStop(1, 'rgba(52, 211, 153, 0)');
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: <%= raw @net_worths.map { |net_worth| net_worth.date.strftime('%Y-%m-%d') } %>,
|
||||||
|
datasets: [{
|
||||||
|
label: '',
|
||||||
|
data: <%= raw @net_worths.map { |net_worth| net_worth.amount.to_f } %>,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderColor: '#34D399',
|
||||||
|
pointStyle: false,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderJoinStyle: 'round',
|
||||||
|
borderCapStyle: 'round',
|
||||||
|
backgroundColor: gradient,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
layout: {
|
||||||
|
autoPadding: false,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: false,
|
||||||
|
display: false,
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
display: false,
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
filler: {
|
||||||
|
propagate: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if current_family.connections.error.present? %>
|
||||||
|
<div class="relative px-5 py-4 mb-4 text-red-700 bg-red-100 rounded-lg" role="alert">
|
||||||
|
<p class="font-bold">These account connections have errors and need to be reconnected:</p>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
|
<% current_family.connections.error.each do |connection| %>
|
||||||
|
<li>
|
||||||
|
<a href="#" class="flex items-center px-4 py-2 text-white bg-red-500 rounded-lg link-button-<%= connection.id %> hover:bg-red-600">
|
||||||
|
<%= institution_avatar(connection) %> <%= connection.name %>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
(async function($) {
|
||||||
|
var handler = Plaid.create({
|
||||||
|
token: '<%= @link_tokens.select { |link_token| link_token[:connection_id] == connection.id }.first[:link_token] %>',
|
||||||
|
onLoad: function() {},
|
||||||
|
onSuccess: function(public_token, metadata) {
|
||||||
|
$.post('/api/plaid/exchange_public_token', {
|
||||||
|
public_token: public_token,
|
||||||
|
});
|
||||||
|
// Refresh the page
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
onExit: function(err, metadata) {},
|
||||||
|
onEvent: function(eventName, metadata) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.link-button-<%= connection.id %>').on('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
handler.open();
|
||||||
|
});
|
||||||
|
})(jQuery);
|
||||||
|
</script>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-semibold font-display">Cash</h2>
|
||||||
|
<h3 class="mt-1 mb-4 text-sm text-gray-500"><%= number_to_currency current_family.cash_balance %></h3>
|
||||||
|
|
||||||
|
<% current_family.accounts.depository.each do |account| %>
|
||||||
|
<div class="flex items-center justify-between px-3 py-3 mb-2 bg-white shadow-sm rounded-xl">
|
||||||
|
<div class="flex items-center text-sm">
|
||||||
|
<%= institution_avatar(account.connection) %>
|
||||||
|
<%= account.name %> <% if account.mask.present? %> <span class="mx-1 text-xs">•</span> <span class="text-gray-500"><%= account.mask %></span><% end %>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-right">
|
||||||
|
<span class="block mb-1"><%= number_to_currency account.current_balance %></span>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h2 class="mt-8 text-2xl font-semibold font-display">Investments</h2>
|
||||||
|
<h3 class="mt-1 mb-4 text-sm text-gray-500"><%= number_to_currency current_family.investment_balance %></h3>
|
||||||
|
|
||||||
|
<% current_family.accounts.investment.each do |account| %>
|
||||||
|
<div class="flex items-center justify-between px-3 py-3 mb-2 bg-white shadow-sm rounded-xl">
|
||||||
|
<div class="flex items-center text-sm">
|
||||||
|
<%= institution_avatar(account.connection) %>
|
||||||
|
<%= account.name %><% if account.mask.present? %> <span class="mx-1 text-xs">•</span> <span class="text-gray-500"><%= account.mask %></span><% end %>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-right">
|
||||||
|
<span class="block mb-1"><%= number_to_currency account.current_balance %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h2 class="mt-8 text-2xl font-semibold font-display">Credit Cards</h2>
|
||||||
|
<h3 class="mt-1 mb-4 text-sm text-gray-500"><%= number_to_currency current_family.credit_balance %></h3>
|
||||||
|
|
||||||
|
<% current_family.accounts.credit.each do |account| %>
|
||||||
|
<div class="flex items-center justify-between px-3 py-3 mb-2 bg-white shadow-sm rounded-xl">
|
||||||
|
<div class="flex items-center text-sm">
|
||||||
|
<%= institution_avatar(account.connection) %>
|
||||||
|
<%= account.name %> <% if account.mask.present? %> <span class="mx-1 text-xs">•</span> <span class="text-gray-500"><%= account.mask %></span><% end %>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-right">
|
||||||
|
<span class="block mb-1"><%= number_to_currency account.current_balance %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h2 class="mt-8 text-2xl font-semibold font-display">Property</h2>
|
||||||
|
<h3 class="mt-1 mb-4 text-sm text-gray-500"><%= number_to_currency current_family.property_balance %></h3>
|
||||||
|
|
||||||
|
<% current_family.accounts.property.each do |account| %>
|
||||||
|
<div class="flex items-center justify-between px-3 py-3 mb-2 bg-white shadow-sm rounded-xl">
|
||||||
|
<div class="flex items-center text-sm">
|
||||||
|
<%= institution_avatar(account.connection) %>
|
||||||
|
<%= account.name %> <% if account.mask.present? %> <span class="mx-1 text-xs">•</span> <span class="text-gray-500"><%= account.mask %></span><% end %>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-right">
|
||||||
|
<span class="block mb-1"><%= number_to_currency account.current_balance %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
4
app/views/accounts/investments.html.erb
Normal file
4
app/views/accounts/investments.html.erb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Accounts#investments</h1>
|
||||||
|
<p>Find me in app/views/accounts/investments.html.erb</p>
|
||||||
|
</div>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue