1
0
Fork 0
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:
Josh Pigford 2024-01-04 12:03:16 -06:00
commit 1f69793928
332 changed files with 8841 additions and 0 deletions

39
.dockerignore Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
3.1.3

63
Dockerfile Normal file
View 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
View 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
View 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
View 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
View file

6
Rakefile Normal file
View 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
View file

View file

@ -0,0 +1,5 @@
//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../../javascript .js
//= link_tree ../../../vendor/javascript .js
//= link_tree ../builds

0
app/assets/images/.keep Normal file
View file

View 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

View 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

View 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

View 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

View 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
*/

View 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;
}

View file

@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View file

@ -0,0 +1,4 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end

View 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

View 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

View 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

View file

View 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

View 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

View 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

View file

@ -0,0 +1,3 @@
class FamiliesController < ApplicationController
before_action :authenticate_user!
end

View file

@ -0,0 +1,3 @@
class HoldingsController < ApplicationController
before_action :authenticate_user!
end

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -0,0 +1,2 @@
module AccountsHelper
end

View file

@ -0,0 +1,2 @@
module Api::PlaidHelper
end

View 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

View file

@ -0,0 +1,2 @@
module ConnectionsHelper
end

View file

@ -0,0 +1,2 @@
module ConversationsHelper
end

View 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

View file

@ -0,0 +1,2 @@
module FamiliesHelper
end

View 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

View file

@ -0,0 +1,2 @@
module OnboardingHelper
end

View file

@ -0,0 +1,2 @@
module PagesHelper
end

View file

@ -0,0 +1,2 @@
module PromptsHelper
end

View 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"

View 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 }

View 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));
}
}

View 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
})
}
}

View 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()
}
}

View 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;
}
});
}
});

View 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();
}
}

View 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

View file

@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "hello@maybe.co"
layout "mailer"
end

35
app/models/account.rb Normal file
View 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

View file

@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end

5
app/models/balance.rb Normal file
View 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
View file

@ -0,0 +1,2 @@
class ChangeLog < ApplicationRecord
end

View file

20
app/models/connection.rb Normal file
View 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

View file

@ -0,0 +1,4 @@
class Conversation < ApplicationRecord
belongs_to :user
has_many :messages, dependent: :destroy
end

63
app/models/family.rb Normal file
View 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
View 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

View file

@ -0,0 +1,2 @@
class Institution < ApplicationRecord
end

View file

@ -0,0 +1,4 @@
class InvestmentTransaction < ApplicationRecord
belongs_to :account
belongs_to :security
end

7
app/models/message.rb Normal file
View 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
View file

@ -0,0 +1,4 @@
class Metric < ApplicationRecord
belongs_to :user, optional: true
belongs_to :family
end

5
app/models/prompt.rb Normal file
View 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
View 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

View file

@ -0,0 +1,3 @@
class SecurityPrice < ApplicationRecord
belongs_to :security
end

12
app/models/transaction.rb Normal file
View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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>

View 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>

View 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>

View 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>

View 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">&bull;</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">&bull;</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">&bull;</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">&bull;</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 %>

View 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