From 1f69793928113ac5bb2a7b87e9f2a7412a9adc1e Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 4 Jan 2024 12:03:16 -0600 Subject: [PATCH] Initial commit of new old codebase --- .dockerignore | 39 ++ .env.example | 15 + .gitattributes | 9 + .gitignore | 39 ++ .ruby-version | 1 + Dockerfile | 63 +++ Gemfile | 99 ++++ Gemfile.lock | 453 ++++++++++++++++++ Procfile.dev | 3 + README.md | 0 Rakefile | 6 + app/assets/builds/.keep | 0 app/assets/config/manifest.js | 5 + .../generalsans/GeneralSans-Variable.woff2 | Bin 0 -> 38132 bytes app/assets/images/.keep | 0 app/assets/images/afternoon-gradient.svg | 22 + app/assets/images/evening-gradient.svg | 22 + app/assets/images/logomark.svg | 19 + app/assets/images/morning-gradient.svg | 22 + app/assets/stylesheets/application.css | 15 + .../stylesheets/application.tailwind.css | 135 ++++++ app/channels/application_cable/channel.rb | 4 + app/channels/application_cable/connection.rb | 4 + app/controllers/accounts_controller.rb | 176 +++++++ app/controllers/api/plaid_controller.rb | 58 +++ app/controllers/application_controller.rb | 28 ++ app/controllers/concerns/.keep | 0 app/controllers/concerns/plaid_token.rb | 40 ++ app/controllers/connections_controller.rb | 17 + app/controllers/conversations_controller.rb | 62 +++ app/controllers/families_controller.rb | 3 + app/controllers/holdings_controller.rb | 3 + app/controllers/onboarding_controller.rb | 52 ++ app/controllers/pages_controller.rb | 51 ++ app/controllers/prompts_controller.rb | 12 + .../users/confirmations_controller.rb | 30 ++ .../users/omniauth_callbacks_controller.rb | 30 ++ app/controllers/users/passwords_controller.rb | 34 ++ .../users/registrations_controller.rb | 68 +++ app/controllers/users/sessions_controller.rb | 27 ++ app/controllers/users/unlocks_controller.rb | 30 ++ app/helpers/accounts_helper.rb | 2 + app/helpers/api/plaid_helper.rb | 2 + app/helpers/application_helper.rb | 80 ++++ app/helpers/connections_helper.rb | 2 + app/helpers/conversations_helper.rb | 2 + app/helpers/devise_helper.rb | 55 +++ app/helpers/families_helper.rb | 2 + app/helpers/holdings_helper.rb | 35 ++ app/helpers/onboarding_helper.rb | 2 + app/helpers/pages_helper.rb | 2 + app/helpers/prompts_helper.rb | 2 + app/javascript/application.js | 3 + app/javascript/controllers/application.js | 9 + .../conversation_form_controller.js | 48 ++ .../conversation_stream_controller.js | 30 ++ .../controllers/country_controller.js | 28 ++ app/javascript/controllers/index.js | 47 ++ .../controllers/radio_button_controller.js | 39 ++ app/jobs/application_job.rb | 7 + app/mailers/application_mailer.rb | 4 + app/models/account.rb | 35 ++ app/models/application_record.rb | 3 + app/models/balance.rb | 5 + app/models/change_log.rb | 2 + app/models/concerns/.keep | 0 app/models/connection.rb | 20 + app/models/conversation.rb | 4 + app/models/family.rb | 63 +++ app/models/holding.rb | 23 + app/models/institution.rb | 2 + app/models/investment_transaction.rb | 4 + app/models/message.rb | 7 + app/models/metric.rb | 4 + app/models/prompt.rb | 5 + app/models/security.rb | 12 + app/models/security_price.rb | 3 + app/models/transaction.rb | 12 + app/models/user.rb | 60 +++ app/services/replica_query_service.rb | 15 + app/sidekiq/ask_question_job.rb | 298 ++++++++++++ app/sidekiq/enrich_transactions_job.rb | 56 +++ app/sidekiq/generate_balance_job.rb | 25 + .../generate_categorical_metrics_job.rb | 34 ++ app/sidekiq/generate_metrics_job.rb | 31 ++ app/sidekiq/real_time_sync_job.rb | 18 + app/sidekiq/sync_plaid_holdings_job.rb | 79 +++ app/sidekiq/sync_plaid_institutions_job.rb | 40 ++ .../sync_plaid_investment_transactions_job.rb | 126 +++++ app/sidekiq/sync_plaid_item_accounts_job.rb | 49 ++ app/sidekiq/sync_plaid_transactions_job.rb | 101 ++++ app/sidekiq/sync_property_values_job.rb | 18 + app/sidekiq/sync_security_details_job.rb | 92 ++++ app/sidekiq/sync_security_history_job.rb | 47 ++ app/views/accounts/assets.html.erb | 4 + app/views/accounts/cash.html.erb | 4 + app/views/accounts/credit.html.erb | 4 + app/views/accounts/debts.html.erb | 4 + app/views/accounts/index.html.erb | 183 +++++++ app/views/accounts/investments.html.erb | 4 + app/views/accounts/new.html.erb | 109 +++++ app/views/accounts/new_bank.html.erb | 55 +++ app/views/accounts/new_bank_manual.html.erb | 35 ++ app/views/accounts/new_credit.html.erb | 55 +++ app/views/accounts/new_credit_manual.html.erb | 39 ++ app/views/accounts/new_investment.html.erb | 69 +++ .../accounts/new_investment_position.html.erb | 47 ++ app/views/accounts/new_real_estate.html.erb | 42 ++ app/views/accounts/select_holding.html.erb | 21 + app/views/accounts/show.html.erb | 4 + app/views/connections/index.html.erb | 20 + app/views/conversations/_message.html.erb | 40 ++ app/views/conversations/_stream.html.erb | 1 + app/views/conversations/index.html.erb | 20 + app/views/conversations/show.html.erb | 17 + app/views/devise/confirmations/new.html.erb | 35 ++ .../mailer/confirmation_instructions.html.erb | 5 + .../devise/mailer/password_change.html.erb | 3 + .../reset_password_instructions.html.erb | 8 + .../mailer/unlock_instructions.html.erb | 7 + app/views/devise/passwords/edit.html.erb | 41 ++ app/views/devise/passwords/new.html.erb | 25 + app/views/devise/registrations/edit.html.erb | 59 +++ app/views/devise/registrations/new.html.erb | 27 ++ app/views/devise/sessions/new.html.erb | 36 ++ app/views/devise/shared/_form_footer.html.erb | 3 + app/views/devise/shared/_links.html.erb | 38 ++ app/views/devise/unlocks/new.html.erb | 34 ++ app/views/layouts/application.html.erb | 85 ++++ app/views/layouts/devise.html.erb | 77 +++ app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/layouts/simple.html.erb | 29 ++ app/views/onboarding/agreements.html.erb | 59 +++ app/views/onboarding/birthday.html.erb | 26 + app/views/onboarding/currency.html.erb | 27 ++ app/views/onboarding/family.html.erb | 28 ++ app/views/onboarding/goals.html.erb | 28 ++ app/views/onboarding/household.html.erb | 89 ++++ app/views/onboarding/location.html.erb | 33 ++ app/views/onboarding/name.html.erb | 34 ++ app/views/onboarding/notifications.html.erb | 0 app/views/onboarding/recap.html.erb | 0 app/views/onboarding/risk.html.erb | 63 +++ app/views/onboarding/upgrade.html.erb | 23 + app/views/onboarding/welcome.html.erb | 36 ++ app/views/pages/advisor.html.erb | 5 + app/views/pages/index.html.erb | 28 ++ app/views/pages/settings.html.erb | 4 + app/views/pages/upgrade.html.erb | 16 + app/views/prompts/index.html.erb | 49 ++ app/views/shared/_logo.html.erb | 1 + bin/bundle | 109 +++++ bin/dev | 8 + bin/docker-entrypoint | 8 + bin/importmap | 4 + bin/rails | 4 + bin/rake | 4 + bin/render-build.sh | 9 + bin/setup | 33 ++ config.ru | 6 + config/application.rb | 31 ++ config/boot.rb | 4 + config/cable.yml | 11 + config/credentials.yml.enc | 1 + config/database.yml | 85 ++++ config/environment.rb | 5 + config/environments/development.rb | 80 ++++ config/environments/production.rb | 99 ++++ config/environments/test.rb | 64 +++ config/importmap.rb | 11 + config/initializers/assets.rb | 12 + .../initializers/content_security_policy.rb | 25 + config/initializers/devise.rb | 313 ++++++++++++ .../initializers/filter_parameter_logging.rb | 8 + config/initializers/generators.rb | 3 + config/initializers/inflections.rb | 16 + config/initializers/pay.rb | 3 + config/initializers/permissions_policy.rb | 13 + config/initializers/plaid.rb | 8 + config/initializers/sentry.rb | 14 + config/llmschema.yml | 230 +++++++++ config/llmsql.yml | 44 ++ config/locales/devise.en.yml | 65 +++ config/locales/en.yml | 33 ++ config/puma.rb | 34 ++ config/routes.rb | 66 +++ config/sidekiq.yml | 0 config/storage.yml | 34 ++ config/tailwind.config.js | 45 ++ db/migrate/20230403142845_enable_uuid.rb | 5 + .../20230403144425_devise_create_users.rb | 44 ++ db/migrate/20230403144751_create_families.rb | 8 + .../20230403163745_add_family_to_users.rb | 5 + .../20230403183541_create_connections.rb | 21 + ...0403183853_add_plaid_link_token_to_user.rb | 6 + db/migrate/20230403184330_create_accounts.rb | 21 + .../20230403190242_create_transactions.rb | 24 + ...403190736_add_official_name_to_accounts.rb | 5 + .../20230403190831_rename_account_type.rb | 6 + ...30403191012_add_indexes_to_transactions.rb | 5 + .../20230403195103_create_securities.rb | 21 + db/migrate/20230403195224_create_holdings.rb | 19 + ...03195350_create_investment_transactions.rb | 23 + .../20230403203112_create_change_logs.rb | 13 + db/migrate/20230403204827_create_metrics.rb | 12 + .../20230404143258_create_security_prices.rb | 19 + ...dd_prices_last_updated_at_to_securities.rb | 5 + ...30404153525_add_real_time_to_securities.rb | 6 + .../20230405163403_create_conversations.rb | 15 + db/migrate/20230405163427_create_messages.rb | 12 + db/migrate/20230406161422_create_balances.rb | 16 + ..._add_enrichment_details_to_transactions.rb | 17 + db/migrate/20230406192506_create_prompts.rb | 10 + .../20230409001334_add_hidden_to_messages.rb | 5 + .../20230410142620_add_log_to_messages.rb | 5 + .../20230410144655_create_institutions.rb | 16 + ...230411223153_add_demographics_to_family.rb | 5 + .../20230412155030_create_pay_tables.pay.rb | 81 ++++ ...0230413184709_add_details_to_securities.rb | 9 + ...30414134419_add_logo_bits_to_securities.rb | 6 + ...0420135327_add_daily_change_to_balances.rb | 5 + .../20230420153137_add_limit_to_accounts.rb | 5 + ...55825_add_plaid_products_to_connections.rb | 5 + ...180312_add_property_details_to_accounts.rb | 5 + ...230420194311_add_auto_value_to_accounts.rb | 5 + ...0230420202618_add_uniq_index_on_metrics.rb | 14 + .../20230421165705_add_kind_to_balances.rb | 9 + .../20230424135903_add_status_to_messages.rb | 5 + .../20230424163137_add_family_to_metrics.rb | 16 + .../20230424163504_add_subkind_to_metrics.rb | 6 + .../20230424172744_add_family_to_accounts.rb | 13 + ...0230424173230_add_family_to_connections.rb | 13 + .../20230424174313_add_family_to_holdings.rb | 13 + ...230424174608_add_family_to_transactions.rb | 13 + ...230425162414_add_unique_keys_for_family.rb | 13 + .../20230426141050_add_name_to_users.rb | 6 + .../20230426152640_add_birthday_to_users.rb | 5 + .../20230426153241_add_locations_to_family.rb | 6 + ...20230426164251_add_currency_to_families.rb | 5 + .../20230426165640_add_family_fields.rb | 10 + .../20230426182351_add_agreed_to_families.rb | 7 + ...0728_add_unique_index_for_metrics_again.rb | 15 + ...0230501163248_add_family_id_to_balances.rb | 11 + ...501164317_add_unique_index_for_balances.rb | 5 + db/schema.rb | 436 +++++++++++++++++ db/seeds.rb | 9 + lib/assets/.keep | 0 lib/tasks/.keep | 0 lib/tasks/maintenance.rake | 68 +++ log/.keep | 0 public/404.html | 67 +++ public/422.html | 67 +++ public/500.html | 66 +++ public/apple-touch-icon-precomposed.png | 0 public/apple-touch-icon.png | 0 public/favicon.ico | 0 public/manifest.json | 11 + public/robots.txt | 1 + storage/.keep | 0 test/application_system_test_case.rb | 5 + .../application_cable/connection_test.rb | 13 + test/controllers/.keep | 0 test/controllers/accounts_controller_test.rb | 38 ++ test/controllers/api/plaid_controller_test.rb | 7 + .../connections_controller_test.rb | 7 + .../conversations_controller_test.rb | 7 + test/controllers/families_controller_test.rb | 7 + test/controllers/holdings_controller_test.rb | 7 + .../controllers/onboarding_controller_test.rb | 7 + test/controllers/pages_controller_test.rb | 8 + test/controllers/prompts_controller_test.rb | 8 + test/fixtures/accounts.yml | 29 ++ test/fixtures/balances.yml | 17 + test/fixtures/change_logs.yml | 15 + test/fixtures/connections.yml | 31 ++ test/fixtures/conversations.yml | 19 + test/fixtures/embeddings.yml | 11 + test/fixtures/families.yml | 7 + test/fixtures/files/.keep | 0 test/fixtures/holdings.yml | 23 + test/fixtures/institutions.yml | 13 + test/fixtures/investment_transactions.yml | 31 ++ test/fixtures/messages.yml | 13 + test/fixtures/metrics.yml | 11 + test/fixtures/prompts.yml | 9 + test/fixtures/securities.yml | 25 + test/fixtures/security_prices.yml | 23 + test/fixtures/transactions.yml | 37 ++ test/fixtures/users.yml | 11 + test/helpers/.keep | 0 test/integration/.keep | 0 test/mailers/.keep | 0 test/models/.keep | 0 test/models/account_test.rb | 7 + test/models/balance_test.rb | 7 + test/models/change_log_test.rb | 7 + test/models/connection_test.rb | 7 + test/models/conversation_test.rb | 7 + test/models/embedding_test.rb | 7 + test/models/family_test.rb | 7 + test/models/holding_test.rb | 7 + test/models/institution_test.rb | 7 + test/models/investment_transaction_test.rb | 7 + test/models/message_test.rb | 7 + test/models/metric_test.rb | 7 + test/models/prompt_test.rb | 7 + test/models/security_price_test.rb | 7 + test/models/security_test.rb | 7 + test/models/transaction_test.rb | 7 + test/models/user_test.rb | 7 + test/sidekiq/ask_question_job_test.rb | 6 + test/sidekiq/enrich_transactions_job_test.rb | 6 + test/sidekiq/generate_balance_job_test.rb | 6 + .../generate_categorical_metrics_job_test.rb | 6 + test/sidekiq/generate_metrics_job_test.rb | 6 + test/sidekiq/real_time_sync_job_test.rb | 6 + test/sidekiq/sync_plaid_holdings_job_test.rb | 6 + .../sync_plaid_institutions_job_test.rb | 6 + ..._plaid_investment_transactions_job_test.rb | 6 + .../sync_plaid_item_accounts_job_test.rb | 6 + .../sync_plaid_transactions_job_test.rb | 6 + test/sidekiq/sync_property_values_job_test.rb | 6 + .../sidekiq/sync_security_details_job_test.rb | 6 + .../sidekiq/sync_security_history_job_test.rb | 6 + test/system/.keep | 0 test/test_helper.rb | 15 + tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vendor/javascript/.keep | 0 332 files changed, 8841 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .ruby-version create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Procfile.dev create mode 100644 README.md create mode 100644 Rakefile create mode 100644 app/assets/builds/.keep create mode 100644 app/assets/config/manifest.js create mode 100644 app/assets/fonts/generalsans/GeneralSans-Variable.woff2 create mode 100644 app/assets/images/.keep create mode 100644 app/assets/images/afternoon-gradient.svg create mode 100644 app/assets/images/evening-gradient.svg create mode 100644 app/assets/images/logomark.svg create mode 100644 app/assets/images/morning-gradient.svg create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/assets/stylesheets/application.tailwind.css create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/controllers/accounts_controller.rb create mode 100644 app/controllers/api/plaid_controller.rb create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/concerns/plaid_token.rb create mode 100644 app/controllers/connections_controller.rb create mode 100644 app/controllers/conversations_controller.rb create mode 100644 app/controllers/families_controller.rb create mode 100644 app/controllers/holdings_controller.rb create mode 100644 app/controllers/onboarding_controller.rb create mode 100644 app/controllers/pages_controller.rb create mode 100644 app/controllers/prompts_controller.rb create mode 100644 app/controllers/users/confirmations_controller.rb create mode 100644 app/controllers/users/omniauth_callbacks_controller.rb create mode 100644 app/controllers/users/passwords_controller.rb create mode 100644 app/controllers/users/registrations_controller.rb create mode 100644 app/controllers/users/sessions_controller.rb create mode 100644 app/controllers/users/unlocks_controller.rb create mode 100644 app/helpers/accounts_helper.rb create mode 100644 app/helpers/api/plaid_helper.rb create mode 100644 app/helpers/application_helper.rb create mode 100644 app/helpers/connections_helper.rb create mode 100644 app/helpers/conversations_helper.rb create mode 100644 app/helpers/devise_helper.rb create mode 100644 app/helpers/families_helper.rb create mode 100644 app/helpers/holdings_helper.rb create mode 100644 app/helpers/onboarding_helper.rb create mode 100644 app/helpers/pages_helper.rb create mode 100644 app/helpers/prompts_helper.rb create mode 100644 app/javascript/application.js create mode 100644 app/javascript/controllers/application.js create mode 100644 app/javascript/controllers/conversation_form_controller.js create mode 100644 app/javascript/controllers/conversation_stream_controller.js create mode 100644 app/javascript/controllers/country_controller.js create mode 100644 app/javascript/controllers/index.js create mode 100644 app/javascript/controllers/radio_button_controller.js create mode 100644 app/jobs/application_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/models/account.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/balance.rb create mode 100644 app/models/change_log.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/connection.rb create mode 100644 app/models/conversation.rb create mode 100644 app/models/family.rb create mode 100644 app/models/holding.rb create mode 100644 app/models/institution.rb create mode 100644 app/models/investment_transaction.rb create mode 100644 app/models/message.rb create mode 100644 app/models/metric.rb create mode 100644 app/models/prompt.rb create mode 100644 app/models/security.rb create mode 100644 app/models/security_price.rb create mode 100644 app/models/transaction.rb create mode 100644 app/models/user.rb create mode 100644 app/services/replica_query_service.rb create mode 100644 app/sidekiq/ask_question_job.rb create mode 100644 app/sidekiq/enrich_transactions_job.rb create mode 100644 app/sidekiq/generate_balance_job.rb create mode 100644 app/sidekiq/generate_categorical_metrics_job.rb create mode 100644 app/sidekiq/generate_metrics_job.rb create mode 100644 app/sidekiq/real_time_sync_job.rb create mode 100644 app/sidekiq/sync_plaid_holdings_job.rb create mode 100644 app/sidekiq/sync_plaid_institutions_job.rb create mode 100644 app/sidekiq/sync_plaid_investment_transactions_job.rb create mode 100644 app/sidekiq/sync_plaid_item_accounts_job.rb create mode 100644 app/sidekiq/sync_plaid_transactions_job.rb create mode 100644 app/sidekiq/sync_property_values_job.rb create mode 100644 app/sidekiq/sync_security_details_job.rb create mode 100644 app/sidekiq/sync_security_history_job.rb create mode 100644 app/views/accounts/assets.html.erb create mode 100644 app/views/accounts/cash.html.erb create mode 100644 app/views/accounts/credit.html.erb create mode 100644 app/views/accounts/debts.html.erb create mode 100644 app/views/accounts/index.html.erb create mode 100644 app/views/accounts/investments.html.erb create mode 100644 app/views/accounts/new.html.erb create mode 100644 app/views/accounts/new_bank.html.erb create mode 100644 app/views/accounts/new_bank_manual.html.erb create mode 100644 app/views/accounts/new_credit.html.erb create mode 100644 app/views/accounts/new_credit_manual.html.erb create mode 100644 app/views/accounts/new_investment.html.erb create mode 100644 app/views/accounts/new_investment_position.html.erb create mode 100644 app/views/accounts/new_real_estate.html.erb create mode 100644 app/views/accounts/select_holding.html.erb create mode 100644 app/views/accounts/show.html.erb create mode 100644 app/views/connections/index.html.erb create mode 100644 app/views/conversations/_message.html.erb create mode 100644 app/views/conversations/_stream.html.erb create mode 100644 app/views/conversations/index.html.erb create mode 100644 app/views/conversations/show.html.erb create mode 100644 app/views/devise/confirmations/new.html.erb create mode 100644 app/views/devise/mailer/confirmation_instructions.html.erb create mode 100644 app/views/devise/mailer/password_change.html.erb create mode 100644 app/views/devise/mailer/reset_password_instructions.html.erb create mode 100644 app/views/devise/mailer/unlock_instructions.html.erb create mode 100644 app/views/devise/passwords/edit.html.erb create mode 100644 app/views/devise/passwords/new.html.erb create mode 100644 app/views/devise/registrations/edit.html.erb create mode 100644 app/views/devise/registrations/new.html.erb create mode 100644 app/views/devise/sessions/new.html.erb create mode 100644 app/views/devise/shared/_form_footer.html.erb create mode 100644 app/views/devise/shared/_links.html.erb create mode 100644 app/views/devise/unlocks/new.html.erb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/devise.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/layouts/simple.html.erb create mode 100644 app/views/onboarding/agreements.html.erb create mode 100644 app/views/onboarding/birthday.html.erb create mode 100644 app/views/onboarding/currency.html.erb create mode 100644 app/views/onboarding/family.html.erb create mode 100644 app/views/onboarding/goals.html.erb create mode 100644 app/views/onboarding/household.html.erb create mode 100644 app/views/onboarding/location.html.erb create mode 100644 app/views/onboarding/name.html.erb create mode 100644 app/views/onboarding/notifications.html.erb create mode 100644 app/views/onboarding/recap.html.erb create mode 100644 app/views/onboarding/risk.html.erb create mode 100644 app/views/onboarding/upgrade.html.erb create mode 100644 app/views/onboarding/welcome.html.erb create mode 100644 app/views/pages/advisor.html.erb create mode 100644 app/views/pages/index.html.erb create mode 100644 app/views/pages/settings.html.erb create mode 100644 app/views/pages/upgrade.html.erb create mode 100644 app/views/prompts/index.html.erb create mode 100644 app/views/shared/_logo.html.erb create mode 100755 bin/bundle create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/importmap create mode 100755 bin/rails create mode 100755 bin/rake create mode 100644 bin/render-build.sh create mode 100755 bin/setup create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/cable.yml create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/importmap.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/devise.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/generators.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/pay.rb create mode 100644 config/initializers/permissions_policy.rb create mode 100644 config/initializers/plaid.rb create mode 100644 config/initializers/sentry.rb create mode 100644 config/llmschema.yml create mode 100644 config/llmsql.yml create mode 100644 config/locales/devise.en.yml create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/routes.rb create mode 100644 config/sidekiq.yml create mode 100644 config/storage.yml create mode 100644 config/tailwind.config.js create mode 100644 db/migrate/20230403142845_enable_uuid.rb create mode 100644 db/migrate/20230403144425_devise_create_users.rb create mode 100644 db/migrate/20230403144751_create_families.rb create mode 100644 db/migrate/20230403163745_add_family_to_users.rb create mode 100644 db/migrate/20230403183541_create_connections.rb create mode 100644 db/migrate/20230403183853_add_plaid_link_token_to_user.rb create mode 100644 db/migrate/20230403184330_create_accounts.rb create mode 100644 db/migrate/20230403190242_create_transactions.rb create mode 100644 db/migrate/20230403190736_add_official_name_to_accounts.rb create mode 100644 db/migrate/20230403190831_rename_account_type.rb create mode 100644 db/migrate/20230403191012_add_indexes_to_transactions.rb create mode 100644 db/migrate/20230403195103_create_securities.rb create mode 100644 db/migrate/20230403195224_create_holdings.rb create mode 100644 db/migrate/20230403195350_create_investment_transactions.rb create mode 100644 db/migrate/20230403203112_create_change_logs.rb create mode 100644 db/migrate/20230403204827_create_metrics.rb create mode 100644 db/migrate/20230404143258_create_security_prices.rb create mode 100644 db/migrate/20230404150207_add_prices_last_updated_at_to_securities.rb create mode 100644 db/migrate/20230404153525_add_real_time_to_securities.rb create mode 100644 db/migrate/20230405163403_create_conversations.rb create mode 100644 db/migrate/20230405163427_create_messages.rb create mode 100644 db/migrate/20230406161422_create_balances.rb create mode 100644 db/migrate/20230406172009_add_enrichment_details_to_transactions.rb create mode 100644 db/migrate/20230406192506_create_prompts.rb create mode 100644 db/migrate/20230409001334_add_hidden_to_messages.rb create mode 100644 db/migrate/20230410142620_add_log_to_messages.rb create mode 100644 db/migrate/20230410144655_create_institutions.rb create mode 100644 db/migrate/20230411223153_add_demographics_to_family.rb create mode 100644 db/migrate/20230412155030_create_pay_tables.pay.rb create mode 100644 db/migrate/20230413184709_add_details_to_securities.rb create mode 100644 db/migrate/20230414134419_add_logo_bits_to_securities.rb create mode 100644 db/migrate/20230420135327_add_daily_change_to_balances.rb create mode 100644 db/migrate/20230420153137_add_limit_to_accounts.rb create mode 100644 db/migrate/20230420155825_add_plaid_products_to_connections.rb create mode 100644 db/migrate/20230420180312_add_property_details_to_accounts.rb create mode 100644 db/migrate/20230420194311_add_auto_value_to_accounts.rb create mode 100644 db/migrate/20230420202618_add_uniq_index_on_metrics.rb create mode 100644 db/migrate/20230421165705_add_kind_to_balances.rb create mode 100644 db/migrate/20230424135903_add_status_to_messages.rb create mode 100644 db/migrate/20230424163137_add_family_to_metrics.rb create mode 100644 db/migrate/20230424163504_add_subkind_to_metrics.rb create mode 100644 db/migrate/20230424172744_add_family_to_accounts.rb create mode 100644 db/migrate/20230424173230_add_family_to_connections.rb create mode 100644 db/migrate/20230424174313_add_family_to_holdings.rb create mode 100644 db/migrate/20230424174608_add_family_to_transactions.rb create mode 100644 db/migrate/20230425162414_add_unique_keys_for_family.rb create mode 100644 db/migrate/20230426141050_add_name_to_users.rb create mode 100644 db/migrate/20230426152640_add_birthday_to_users.rb create mode 100644 db/migrate/20230426153241_add_locations_to_family.rb create mode 100644 db/migrate/20230426164251_add_currency_to_families.rb create mode 100644 db/migrate/20230426165640_add_family_fields.rb create mode 100644 db/migrate/20230426182351_add_agreed_to_families.rb create mode 100644 db/migrate/20230501160728_add_unique_index_for_metrics_again.rb create mode 100644 db/migrate/20230501163248_add_family_id_to_balances.rb create mode 100644 db/migrate/20230501164317_add_unique_index_for_balances.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 lib/assets/.keep create mode 100644 lib/tasks/.keep create mode 100644 lib/tasks/maintenance.rake create mode 100644 log/.keep create mode 100644 public/404.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/apple-touch-icon-precomposed.png create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon.ico create mode 100644 public/manifest.json create mode 100644 public/robots.txt create mode 100644 storage/.keep create mode 100644 test/application_system_test_case.rb create mode 100644 test/channels/application_cable/connection_test.rb create mode 100644 test/controllers/.keep create mode 100644 test/controllers/accounts_controller_test.rb create mode 100644 test/controllers/api/plaid_controller_test.rb create mode 100644 test/controllers/connections_controller_test.rb create mode 100644 test/controllers/conversations_controller_test.rb create mode 100644 test/controllers/families_controller_test.rb create mode 100644 test/controllers/holdings_controller_test.rb create mode 100644 test/controllers/onboarding_controller_test.rb create mode 100644 test/controllers/pages_controller_test.rb create mode 100644 test/controllers/prompts_controller_test.rb create mode 100644 test/fixtures/accounts.yml create mode 100644 test/fixtures/balances.yml create mode 100644 test/fixtures/change_logs.yml create mode 100644 test/fixtures/connections.yml create mode 100644 test/fixtures/conversations.yml create mode 100644 test/fixtures/embeddings.yml create mode 100644 test/fixtures/families.yml create mode 100644 test/fixtures/files/.keep create mode 100644 test/fixtures/holdings.yml create mode 100644 test/fixtures/institutions.yml create mode 100644 test/fixtures/investment_transactions.yml create mode 100644 test/fixtures/messages.yml create mode 100644 test/fixtures/metrics.yml create mode 100644 test/fixtures/prompts.yml create mode 100644 test/fixtures/securities.yml create mode 100644 test/fixtures/security_prices.yml create mode 100644 test/fixtures/transactions.yml create mode 100644 test/fixtures/users.yml create mode 100644 test/helpers/.keep create mode 100644 test/integration/.keep create mode 100644 test/mailers/.keep create mode 100644 test/models/.keep create mode 100644 test/models/account_test.rb create mode 100644 test/models/balance_test.rb create mode 100644 test/models/change_log_test.rb create mode 100644 test/models/connection_test.rb create mode 100644 test/models/conversation_test.rb create mode 100644 test/models/embedding_test.rb create mode 100644 test/models/family_test.rb create mode 100644 test/models/holding_test.rb create mode 100644 test/models/institution_test.rb create mode 100644 test/models/investment_transaction_test.rb create mode 100644 test/models/message_test.rb create mode 100644 test/models/metric_test.rb create mode 100644 test/models/prompt_test.rb create mode 100644 test/models/security_price_test.rb create mode 100644 test/models/security_test.rb create mode 100644 test/models/transaction_test.rb create mode 100644 test/models/user_test.rb create mode 100644 test/sidekiq/ask_question_job_test.rb create mode 100644 test/sidekiq/enrich_transactions_job_test.rb create mode 100644 test/sidekiq/generate_balance_job_test.rb create mode 100644 test/sidekiq/generate_categorical_metrics_job_test.rb create mode 100644 test/sidekiq/generate_metrics_job_test.rb create mode 100644 test/sidekiq/real_time_sync_job_test.rb create mode 100644 test/sidekiq/sync_plaid_holdings_job_test.rb create mode 100644 test/sidekiq/sync_plaid_institutions_job_test.rb create mode 100644 test/sidekiq/sync_plaid_investment_transactions_job_test.rb create mode 100644 test/sidekiq/sync_plaid_item_accounts_job_test.rb create mode 100644 test/sidekiq/sync_plaid_transactions_job_test.rb create mode 100644 test/sidekiq/sync_property_values_job_test.rb create mode 100644 test/sidekiq/sync_security_details_job_test.rb create mode 100644 test/sidekiq/sync_security_history_job_test.rb create mode 100644 test/system/.keep create mode 100644 test/test_helper.rb create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep create mode 100644 vendor/javascript/.keep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..9dc7e6a2 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..b74a1923 --- /dev/null +++ b/.env.example @@ -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= \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..8dc43234 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5f912845 --- /dev/null +++ b/.gitignore @@ -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.* \ No newline at end of file diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..ff365e06 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.1.3 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..5cd6bd25 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..96c791c0 --- /dev/null +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..87988e68 --- /dev/null +++ b/Gemfile.lock @@ -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 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 00000000..c1f7b6a4 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,3 @@ +web: bin/rails server -p 5000 +css: bin/rails tailwindcss:watch +worker: bundle exec sidekiq -C config/sidekiq.yml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..e69de29b diff --git a/Rakefile b/Rakefile new file mode 100644 index 00000000..9a5ea738 --- /dev/null +++ b/Rakefile @@ -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 diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js new file mode 100644 index 00000000..b06fc42a --- /dev/null +++ b/app/assets/config/manifest.js @@ -0,0 +1,5 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css +//= link_tree ../../javascript .js +//= link_tree ../../../vendor/javascript .js +//= link_tree ../builds diff --git a/app/assets/fonts/generalsans/GeneralSans-Variable.woff2 b/app/assets/fonts/generalsans/GeneralSans-Variable.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..55e906ba3b2d415015f354c0627fbf2462353af4 GIT binary patch literal 38132 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 KdbtLDYykk)k1Ij| literal 0 HcmV?d00001 diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/assets/images/afternoon-gradient.svg b/app/assets/images/afternoon-gradient.svg new file mode 100644 index 00000000..803fbdce --- /dev/null +++ b/app/assets/images/afternoon-gradient.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/evening-gradient.svg b/app/assets/images/evening-gradient.svg new file mode 100644 index 00000000..e66a27b5 --- /dev/null +++ b/app/assets/images/evening-gradient.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/logomark.svg b/app/assets/images/logomark.svg new file mode 100644 index 00000000..ac572615 --- /dev/null +++ b/app/assets/images/logomark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/morning-gradient.svg b/app/assets/images/morning-gradient.svg new file mode 100644 index 00000000..2d3a18d2 --- /dev/null +++ b/app/assets/images/morning-gradient.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 00000000..288b9ab7 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -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 + */ diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css new file mode 100644 index 00000000..cef6fa90 --- /dev/null +++ b/app/assets/stylesheets/application.tailwind.css @@ -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; +} \ No newline at end of file diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 00000000..d6726972 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 00000000..0ff5442f --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb new file mode 100644 index 00000000..dcca2c6c --- /dev/null +++ b/app/controllers/accounts_controller.rb @@ -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 diff --git a/app/controllers/api/plaid_controller.rb b/app/controllers/api/plaid_controller.rb new file mode 100644 index 00000000..596f6af8 --- /dev/null +++ b/app/controllers/api/plaid_controller.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 00000000..b8ae278a --- /dev/null +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/controllers/concerns/plaid_token.rb b/app/controllers/concerns/plaid_token.rb new file mode 100644 index 00000000..e90182fc --- /dev/null +++ b/app/controllers/concerns/plaid_token.rb @@ -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 + + diff --git a/app/controllers/connections_controller.rb b/app/controllers/connections_controller.rb new file mode 100644 index 00000000..ef2c532e --- /dev/null +++ b/app/controllers/connections_controller.rb @@ -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 diff --git a/app/controllers/conversations_controller.rb b/app/controllers/conversations_controller.rb new file mode 100644 index 00000000..06661cd7 --- /dev/null +++ b/app/controllers/conversations_controller.rb @@ -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 diff --git a/app/controllers/families_controller.rb b/app/controllers/families_controller.rb new file mode 100644 index 00000000..1cd51c1e --- /dev/null +++ b/app/controllers/families_controller.rb @@ -0,0 +1,3 @@ +class FamiliesController < ApplicationController + before_action :authenticate_user! +end diff --git a/app/controllers/holdings_controller.rb b/app/controllers/holdings_controller.rb new file mode 100644 index 00000000..3630bbb5 --- /dev/null +++ b/app/controllers/holdings_controller.rb @@ -0,0 +1,3 @@ +class HoldingsController < ApplicationController + before_action :authenticate_user! +end diff --git a/app/controllers/onboarding_controller.rb b/app/controllers/onboarding_controller.rb new file mode 100644 index 00000000..e1817837 --- /dev/null +++ b/app/controllers/onboarding_controller.rb @@ -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 diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb new file mode 100644 index 00000000..efa56f78 --- /dev/null +++ b/app/controllers/pages_controller.rb @@ -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 diff --git a/app/controllers/prompts_controller.rb b/app/controllers/prompts_controller.rb new file mode 100644 index 00000000..1b6518d9 --- /dev/null +++ b/app/controllers/prompts_controller.rb @@ -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 diff --git a/app/controllers/users/confirmations_controller.rb b/app/controllers/users/confirmations_controller.rb new file mode 100644 index 00000000..fa535c0a --- /dev/null +++ b/app/controllers/users/confirmations_controller.rb @@ -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 diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb new file mode 100644 index 00000000..593f547d --- /dev/null +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -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 diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb new file mode 100644 index 00000000..259dbb08 --- /dev/null +++ b/app/controllers/users/passwords_controller.rb @@ -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 diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb new file mode 100644 index 00000000..c5a1d76c --- /dev/null +++ b/app/controllers/users/registrations_controller.rb @@ -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 diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb new file mode 100644 index 00000000..a0f9b48e --- /dev/null +++ b/app/controllers/users/sessions_controller.rb @@ -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 diff --git a/app/controllers/users/unlocks_controller.rb b/app/controllers/users/unlocks_controller.rb new file mode 100644 index 00000000..2c410dc0 --- /dev/null +++ b/app/controllers/users/unlocks_controller.rb @@ -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 diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb new file mode 100644 index 00000000..17850b4b --- /dev/null +++ b/app/helpers/accounts_helper.rb @@ -0,0 +1,2 @@ +module AccountsHelper +end diff --git a/app/helpers/api/plaid_helper.rb b/app/helpers/api/plaid_helper.rb new file mode 100644 index 00000000..ee1d8f60 --- /dev/null +++ b/app/helpers/api/plaid_helper.rb @@ -0,0 +1,2 @@ +module Api::PlaidHelper +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 00000000..47709af9 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -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 + " + + ".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 diff --git a/app/helpers/connections_helper.rb b/app/helpers/connections_helper.rb new file mode 100644 index 00000000..a01e5e8f --- /dev/null +++ b/app/helpers/connections_helper.rb @@ -0,0 +1,2 @@ +module ConnectionsHelper +end diff --git a/app/helpers/conversations_helper.rb b/app/helpers/conversations_helper.rb new file mode 100644 index 00000000..fe30c450 --- /dev/null +++ b/app/helpers/conversations_helper.rb @@ -0,0 +1,2 @@ +module ConversationsHelper +end diff --git a/app/helpers/devise_helper.rb b/app/helpers/devise_helper.rb new file mode 100644 index 00000000..57f3a771 --- /dev/null +++ b/app/helpers/devise_helper.rb @@ -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 + + 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 + + HTML + else + messages = resource.errors.full_messages.map { |msg| content_tag(:li, "#{msg}.") } + .join + html = <<-HTML + + HTML + end + + html.html_safe + end +end +# rubocop:enable Metrics/AbcSize, Metrics/MethodLength diff --git a/app/helpers/families_helper.rb b/app/helpers/families_helper.rb new file mode 100644 index 00000000..b2ef66f2 --- /dev/null +++ b/app/helpers/families_helper.rb @@ -0,0 +1,2 @@ +module FamiliesHelper +end diff --git a/app/helpers/holdings_helper.rb b/app/helpers/holdings_helper.rb new file mode 100644 index 00000000..50484adf --- /dev/null +++ b/app/helpers/holdings_helper.rb @@ -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 "
    #{logo}
    " + 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 "
    #{logo}
    " + end + + def get_image_logo(security, size, rounded_size) + logo = image_tag(security.logo, class: 'object-contain w-full h-full') + raw "
    #{logo}
    " + end + + def get_text_logo(security, size, rounded_size, padding) + raw "
    #{security.symbol}
    " + end + + def process_svg(svg) + svg.gsub(/ { + this.scrollToBottom() + }) + + observer.observe(this.element, { + childList: true, + subtree: true + }) + } +} diff --git a/app/javascript/controllers/country_controller.js b/app/javascript/controllers/country_controller.js new file mode 100644 index 00000000..8aaad2a1 --- /dev/null +++ b/app/javascript/controllers/country_controller.js @@ -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() + } +} \ No newline at end of file diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 00000000..85fc010c --- /dev/null +++ b/app/javascript/controllers/index.js @@ -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; + } + }); + } +}); \ No newline at end of file diff --git a/app/javascript/controllers/radio_button_controller.js b/app/javascript/controllers/radio_button_controller.js new file mode 100644 index 00000000..766dd6d7 --- /dev/null +++ b/app/javascript/controllers/radio_button_controller.js @@ -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(); + } +} diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 00000000..d394c3d1 --- /dev/null +++ b/app/jobs/application_job.rb @@ -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 diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 00000000..6057a032 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "hello@maybe.co" + layout "mailer" +end diff --git a/app/models/account.rb b/app/models/account.rb new file mode 100644 index 00000000..cc92d149 --- /dev/null +++ b/app/models/account.rb @@ -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 diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 00000000..b63caeb8 --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/balance.rb b/app/models/balance.rb new file mode 100644 index 00000000..beaef512 --- /dev/null +++ b/app/models/balance.rb @@ -0,0 +1,5 @@ +class Balance < ApplicationRecord + belongs_to :account + belongs_to :security, optional: true + belongs_to :family +end diff --git a/app/models/change_log.rb b/app/models/change_log.rb new file mode 100644 index 00000000..0662ef79 --- /dev/null +++ b/app/models/change_log.rb @@ -0,0 +1,2 @@ +class ChangeLog < ApplicationRecord +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/models/connection.rb b/app/models/connection.rb new file mode 100644 index 00000000..0c2d1c48 --- /dev/null +++ b/app/models/connection.rb @@ -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 diff --git a/app/models/conversation.rb b/app/models/conversation.rb new file mode 100644 index 00000000..67c41006 --- /dev/null +++ b/app/models/conversation.rb @@ -0,0 +1,4 @@ +class Conversation < ApplicationRecord + belongs_to :user + has_many :messages, dependent: :destroy +end diff --git a/app/models/family.rb b/app/models/family.rb new file mode 100644 index 00000000..d8b1a74a --- /dev/null +++ b/app/models/family.rb @@ -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 diff --git a/app/models/holding.rb b/app/models/holding.rb new file mode 100644 index 00000000..b1b861b0 --- /dev/null +++ b/app/models/holding.rb @@ -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 diff --git a/app/models/institution.rb b/app/models/institution.rb new file mode 100644 index 00000000..87d391dc --- /dev/null +++ b/app/models/institution.rb @@ -0,0 +1,2 @@ +class Institution < ApplicationRecord +end diff --git a/app/models/investment_transaction.rb b/app/models/investment_transaction.rb new file mode 100644 index 00000000..dea96f69 --- /dev/null +++ b/app/models/investment_transaction.rb @@ -0,0 +1,4 @@ +class InvestmentTransaction < ApplicationRecord + belongs_to :account + belongs_to :security +end diff --git a/app/models/message.rb b/app/models/message.rb new file mode 100644 index 00000000..0c09aee4 --- /dev/null +++ b/app/models/message.rb @@ -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 diff --git a/app/models/metric.rb b/app/models/metric.rb new file mode 100644 index 00000000..10fe11e6 --- /dev/null +++ b/app/models/metric.rb @@ -0,0 +1,4 @@ +class Metric < ApplicationRecord + belongs_to :user, optional: true + belongs_to :family +end diff --git a/app/models/prompt.rb b/app/models/prompt.rb new file mode 100644 index 00000000..63f6895d --- /dev/null +++ b/app/models/prompt.rb @@ -0,0 +1,5 @@ +class Prompt < ApplicationRecord + def self.unique_categories + pluck(:categories).flatten.uniq + end +end diff --git a/app/models/security.rb b/app/models/security.rb new file mode 100644 index 00000000..95ca7195 --- /dev/null +++ b/app/models/security.rb @@ -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 diff --git a/app/models/security_price.rb b/app/models/security_price.rb new file mode 100644 index 00000000..77512411 --- /dev/null +++ b/app/models/security_price.rb @@ -0,0 +1,3 @@ +class SecurityPrice < ApplicationRecord + belongs_to :security +end diff --git a/app/models/transaction.rb b/app/models/transaction.rb new file mode 100644 index 00000000..13c9d39a --- /dev/null +++ b/app/models/transaction.rb @@ -0,0 +1,12 @@ +class Transaction < ApplicationRecord + belongs_to :account + belongs_to :family + + def inflow? + amount > 0 + end + + def outflow? + amount < 0 + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 00000000..153bb73f --- /dev/null +++ b/app/models/user.rb @@ -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 diff --git a/app/services/replica_query_service.rb b/app/services/replica_query_service.rb new file mode 100644 index 00000000..9a08524b --- /dev/null +++ b/app/services/replica_query_service.rb @@ -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 diff --git a/app/sidekiq/ask_question_job.rb b/app/sidekiq/ask_question_job.rb new file mode 100644 index 00000000..ecce1e42 --- /dev/null +++ b/app/sidekiq/ask_question_job.rb @@ -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 diff --git a/app/sidekiq/enrich_transactions_job.rb b/app/sidekiq/enrich_transactions_job.rb new file mode 100644 index 00000000..ead67a66 --- /dev/null +++ b/app/sidekiq/enrich_transactions_job.rb @@ -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 diff --git a/app/sidekiq/generate_balance_job.rb b/app/sidekiq/generate_balance_job.rb new file mode 100644 index 00000000..3be0b8ec --- /dev/null +++ b/app/sidekiq/generate_balance_job.rb @@ -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 diff --git a/app/sidekiq/generate_categorical_metrics_job.rb b/app/sidekiq/generate_categorical_metrics_job.rb new file mode 100644 index 00000000..2f39f10c --- /dev/null +++ b/app/sidekiq/generate_categorical_metrics_job.rb @@ -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 diff --git a/app/sidekiq/generate_metrics_job.rb b/app/sidekiq/generate_metrics_job.rb new file mode 100644 index 00000000..4ca4d5a3 --- /dev/null +++ b/app/sidekiq/generate_metrics_job.rb @@ -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 diff --git a/app/sidekiq/real_time_sync_job.rb b/app/sidekiq/real_time_sync_job.rb new file mode 100644 index 00000000..9dc20875 --- /dev/null +++ b/app/sidekiq/real_time_sync_job.rb @@ -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 diff --git a/app/sidekiq/sync_plaid_holdings_job.rb b/app/sidekiq/sync_plaid_holdings_job.rb new file mode 100644 index 00000000..de3740e7 --- /dev/null +++ b/app/sidekiq/sync_plaid_holdings_job.rb @@ -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 diff --git a/app/sidekiq/sync_plaid_institutions_job.rb b/app/sidekiq/sync_plaid_institutions_job.rb new file mode 100644 index 00000000..ea5b0acf --- /dev/null +++ b/app/sidekiq/sync_plaid_institutions_job.rb @@ -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 diff --git a/app/sidekiq/sync_plaid_investment_transactions_job.rb b/app/sidekiq/sync_plaid_investment_transactions_job.rb new file mode 100644 index 00000000..4819ca73 --- /dev/null +++ b/app/sidekiq/sync_plaid_investment_transactions_job.rb @@ -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 diff --git a/app/sidekiq/sync_plaid_item_accounts_job.rb b/app/sidekiq/sync_plaid_item_accounts_job.rb new file mode 100644 index 00000000..7eca98e4 --- /dev/null +++ b/app/sidekiq/sync_plaid_item_accounts_job.rb @@ -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 diff --git a/app/sidekiq/sync_plaid_transactions_job.rb b/app/sidekiq/sync_plaid_transactions_job.rb new file mode 100644 index 00000000..77690671 --- /dev/null +++ b/app/sidekiq/sync_plaid_transactions_job.rb @@ -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 diff --git a/app/sidekiq/sync_property_values_job.rb b/app/sidekiq/sync_property_values_job.rb new file mode 100644 index 00000000..a72a5f38 --- /dev/null +++ b/app/sidekiq/sync_property_values_job.rb @@ -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 \ No newline at end of file diff --git a/app/sidekiq/sync_security_details_job.rb b/app/sidekiq/sync_security_details_job.rb new file mode 100644 index 00000000..52c3e170 --- /dev/null +++ b/app/sidekiq/sync_security_details_job.rb @@ -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 \ No newline at end of file diff --git a/app/sidekiq/sync_security_history_job.rb b/app/sidekiq/sync_security_history_job.rb new file mode 100644 index 00000000..2edfb2a7 --- /dev/null +++ b/app/sidekiq/sync_security_history_job.rb @@ -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 diff --git a/app/views/accounts/assets.html.erb b/app/views/accounts/assets.html.erb new file mode 100644 index 00000000..aec156d0 --- /dev/null +++ b/app/views/accounts/assets.html.erb @@ -0,0 +1,4 @@ +
    +

    Accounts#assets

    +

    Find me in app/views/accounts/assets.html.erb

    +
    diff --git a/app/views/accounts/cash.html.erb b/app/views/accounts/cash.html.erb new file mode 100644 index 00000000..f4a8cd81 --- /dev/null +++ b/app/views/accounts/cash.html.erb @@ -0,0 +1,4 @@ +
    +

    Accounts#cash

    +

    Find me in app/views/accounts/cash.html.erb

    +
    diff --git a/app/views/accounts/credit.html.erb b/app/views/accounts/credit.html.erb new file mode 100644 index 00000000..d721e772 --- /dev/null +++ b/app/views/accounts/credit.html.erb @@ -0,0 +1,4 @@ +
    +

    Accounts#credit

    +

    Find me in app/views/accounts/credit.html.erb

    +
    diff --git a/app/views/accounts/debts.html.erb b/app/views/accounts/debts.html.erb new file mode 100644 index 00000000..49a7b045 --- /dev/null +++ b/app/views/accounts/debts.html.erb @@ -0,0 +1,4 @@ +
    +

    Accounts#debts

    +

    Find me in app/views/accounts/debts.html.erb

    +
    diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb new file mode 100644 index 00000000..6ef41964 --- /dev/null +++ b/app/views/accounts/index.html.erb @@ -0,0 +1,183 @@ +
    +

    Accounts

    + <%= link_to " Your connections".html_safe, connections_path, class: 'bg-gray-200 hover:bg-gray-300 px-4 py-1 rounded-full text-sm m-0' %> +
    + +
    + <%= 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 %> + + + + <% end %> +
    + +<% if current_family.accounts.blank? or current_family.metrics.blank? %> +
    Before we can give you a full picture of your finances, you'll need to connect your bank accounts!
    + +
    + <%= link_to 'Connect a bank account', new_account_path, class: 'bg-black rounded-full text-white px-4 py-2' %> +
    +<% else %> +
    +

    Net worth

    +

    <%= number_to_currency current_family.net_worth %>

    + + +
    + + <% if current_family.connections.error.present? %> + + <% end %> + +

    Cash

    +

    <%= number_to_currency current_family.cash_balance %>

    + + <% current_family.accounts.depository.each do |account| %> +
    +
    + <%= institution_avatar(account.connection) %> + <%= account.name %> <% if account.mask.present? %> <%= account.mask %><% end %> +
    +

    + <%= number_to_currency account.current_balance %> + +

    +
    + <% end %> + +

    Investments

    +

    <%= number_to_currency current_family.investment_balance %>

    + + <% current_family.accounts.investment.each do |account| %> +
    +
    + <%= institution_avatar(account.connection) %> + <%= account.name %><% if account.mask.present? %> <%= account.mask %><% end %> +
    +

    + <%= number_to_currency account.current_balance %> +

    +
    + <% end %> + +

    Credit Cards

    +

    <%= number_to_currency current_family.credit_balance %>

    + + <% current_family.accounts.credit.each do |account| %> +
    +
    + <%= institution_avatar(account.connection) %> + <%= account.name %> <% if account.mask.present? %> <%= account.mask %><% end %> +
    +

    + <%= number_to_currency account.current_balance %> +

    +
    + <% end %> + +

    Property

    +

    <%= number_to_currency current_family.property_balance %>

    + + <% current_family.accounts.property.each do |account| %> +
    +
    + <%= institution_avatar(account.connection) %> + <%= account.name %> <% if account.mask.present? %> <%= account.mask %><% end %> +
    +

    + <%= number_to_currency account.current_balance %> +

    +
    + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/accounts/investments.html.erb b/app/views/accounts/investments.html.erb new file mode 100644 index 00000000..8fb5bf3d --- /dev/null +++ b/app/views/accounts/investments.html.erb @@ -0,0 +1,4 @@ +
    +

    Accounts#investments

    +

    Find me in app/views/accounts/investments.html.erb

    +
    diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb new file mode 100644 index 00000000..6f3b7a2f --- /dev/null +++ b/app/views/accounts/new.html.erb @@ -0,0 +1,109 @@ +
    +

    What will you add?

    + +
    + +
    +
    + <%= link_to new_bank_path, class: "flex flex-col items-center justify-center w-full text-center focus:outline-none" do %> + + + + + Bank accounts + <% end %> +
    + +
    + <%= link_to new_investment_path, class: "flex flex-col items-center justify-center w-full text-center focus:outline-none" do %> + + + + + Investments + <% end %> +
    + +
    + <%= link_to new_credit_path, class: "flex flex-col items-center justify-center w-full text-center focus:outline-none" do %> + + + + + Credit cards + <% end %> +
    + +
    + <%= link_to new_real_estate_path, class: "flex flex-col items-center justify-center w-full text-center focus:outline-none" do %> + + + + + Real estate + <% end %> +
    +
    +<%# + %> \ No newline at end of file diff --git a/app/views/accounts/new_bank.html.erb b/app/views/accounts/new_bank.html.erb new file mode 100644 index 00000000..9edbd4ff --- /dev/null +++ b/app/views/accounts/new_bank.html.erb @@ -0,0 +1,55 @@ +
    + <%= link_to new_account_path, class: "absolute left-0" do %> + + <% end %> +

    Add a bank account

    +
    + +
    +
    + + <%= link_to '#', class: "link-button flex flex-col w-full focus:outline-none" do %> + + + + +

    Link account

    +

    Link your bank account and import balances and transactions securely.

    + <% end %> + +
    + +
    + + <%= link_to new_bank_manual_path, class: "flex flex-col w-full focus:outline-none" do %> + + + + +

    Enter manually

    +

    Add your checking, savings or money market accounts manually.

    + <% end %> + +
    +
    + + \ No newline at end of file diff --git a/app/views/accounts/new_bank_manual.html.erb b/app/views/accounts/new_bank_manual.html.erb new file mode 100644 index 00000000..bd7d9ea7 --- /dev/null +++ b/app/views/accounts/new_bank_manual.html.erb @@ -0,0 +1,35 @@ +
    + <%= link_to new_bank_path, class: "absolute left-0" do %> + + <% end %> +

    Enter manual bank account

    +
    + +<%= form_with model: @account, html: { class: "space-y-4" } do |f| %> + <%= f.hidden_field :kind, value: "depository" %> + <%= f.hidden_field :connection_id, value: @connection.id %> + +
    + <%# Optional %> + + <%= f.text_field :name, placeholder: "Account name", required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +
    + +
    + + <%= f.select :subkind, options_for_select([["Checking", "checking"], ["Savings", "savings"]], selected: ""), {}, class: "block w-full p-0 mt-1 bg-transparent border-none focus:outline-none focus:ring-0" %> +
    + +
    + +
    + <%= f.number_field :current_balance, placeholder: "$0.00", in: 0.00..100000000.00, required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +
    +
    + +
    + +
    +<% end %> diff --git a/app/views/accounts/new_credit.html.erb b/app/views/accounts/new_credit.html.erb new file mode 100644 index 00000000..05ed7136 --- /dev/null +++ b/app/views/accounts/new_credit.html.erb @@ -0,0 +1,55 @@ +
    + <%= link_to new_account_path, class: "absolute left-0" do %> + + <% end %> +

    Add a credit card

    +
    + +
    +
    + + <%= link_to '#', class: "link-button flex flex-col w-full focus:outline-none" do %> + + + + +

    Link account

    +

    Link your credit card account by selecting the issuing bank and import balances securely.

    + <% end %> + +
    + +
    + + <%= link_to new_credit_manual_path, class: "flex flex-col w-full focus:outline-none" do %> + + + + +

    Add credit card manually

    +

    Enter your credit card details manually. Your account will remain static.

    + <% end %> + +
    +
    + + \ No newline at end of file diff --git a/app/views/accounts/new_credit_manual.html.erb b/app/views/accounts/new_credit_manual.html.erb new file mode 100644 index 00000000..56bf5ec0 --- /dev/null +++ b/app/views/accounts/new_credit_manual.html.erb @@ -0,0 +1,39 @@ +
    + <%= link_to new_bank_path, class: "absolute left-0" do %> + + <% end %> +

    Add credit card manually

    +
    + +<%= form_with model: @account, html: { class: "space-y-4" } do |f| %> + <%= f.hidden_field :kind, value: "credit" %> + <%= f.hidden_field :subkind, value: "credit card" %> + <%= f.hidden_field :connection_id, value: @connection.id %> + +
    + <%# Optional %> + + <%= f.text_field :name, placeholder: "Card name", required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +
    + +
    + +
    + <%= f.number_field :current_balance, placeholder: "$0.00", in: 0.00..100000000.00, step: '0.01', required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +
    +
    + +
    + Optional + +
    + <%= f.number_field :credit_limit, placeholder: "$0.00", in: 0.00..100000000.00, class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +
    +
    + +
    + +
    +<% end %> diff --git a/app/views/accounts/new_investment.html.erb b/app/views/accounts/new_investment.html.erb new file mode 100644 index 00000000..78a5e353 --- /dev/null +++ b/app/views/accounts/new_investment.html.erb @@ -0,0 +1,69 @@ +
    + <%= link_to new_account_path, class: "absolute left-0" do %> + + <% end %> +

    Add an investment

    +
    + +
    +
    + + <%= link_to '#', class: "link-button flex flex-col w-full focus:outline-none" do %> + + + + +

    Link account

    +

    Link your brokerage account and import balances and transactions securely.

    + <% end %> + +
    + +
    + + \ No newline at end of file diff --git a/app/views/accounts/new_investment_position.html.erb b/app/views/accounts/new_investment_position.html.erb new file mode 100644 index 00000000..7400d80a --- /dev/null +++ b/app/views/accounts/new_investment_position.html.erb @@ -0,0 +1,47 @@ +
    + <%= link_to new_investment_path, class: "absolute left-0" do %> + + <% end %> +

    Enter indivudal position

    +
    + +<%= form_with model: @holding, html: { class: "space-y-4" } do |f| %> + <%= f.hidden_field :kind, value: "investment" %> + <%= f.hidden_field :account_id, value: @account.id %> + +
    + <%= link_to select_holding_path, class: "absolute inset-0 z-50 w-full" do %> + + <% end %> + + <%= f.text_field :name, placeholder: "Select a holding", required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +
    + +
    + <%= link_to select_holding_path, class: "absolute inset-0 z-50 w-full" do %> + + <% end %> + + <%= f.text_field :name, placeholder: "Select a portfolio", required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +
    + +
    + +
    + <%= f.number_field :quantity, placeholder: "0", in: 0.00..100000000.00, required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +
    +
    + +
    + +
    + <%= f.number_field :cost_basis_source, placeholder: "$0.00", in: 0.00..100000000.00, required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +
    +
    + +
    + +
    +<% end %> diff --git a/app/views/accounts/new_real_estate.html.erb b/app/views/accounts/new_real_estate.html.erb new file mode 100644 index 00000000..2734a52d --- /dev/null +++ b/app/views/accounts/new_real_estate.html.erb @@ -0,0 +1,42 @@ +
    + <%= link_to new_account_path, class: "absolute left-0" do %> + + <% end %> +

    Enter address

    +
    + +<%= form_with model: @account, html: { class: "space-y-4" } do |f| %> + <%= f.hidden_field :kind, value: "property" %> + <%= f.hidden_field :subkind, value: "real_estate" %> + <%= f.hidden_field :property_details, id: 'dataset' %> + <%= f.hidden_field :connection_id, value: @connection.id %> + <%= f.hidden_field :name, id: 'name' %> + +
    + <%# Optional %> + + <%= f.text_field :official_name, placeholder: "Street Address", required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100 w-full", id: 'full_address', autocomplete: "off" %> +
    + +
    + +
    + <%= f.number_field :current_balance, placeholder: "$0.00", in: 0.00..100000000.00, required: 'required', class: "p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100" %> +
    +
    + +
    +
    + <%= f.check_box :auto_valuation, class: 'w-4 h-4 text-gray-600 border-gray-300 rounded focus:ring-gray-500' %> + <%= f.label :auto_valuation, 'Update valuations using 3rd-party sources?', class: 'block ml-2 text-sm text-gray-900' %> +
    +
    + + + +
    + +
    +<% end %> diff --git a/app/views/accounts/select_holding.html.erb b/app/views/accounts/select_holding.html.erb new file mode 100644 index 00000000..fcf91293 --- /dev/null +++ b/app/views/accounts/select_holding.html.erb @@ -0,0 +1,21 @@ +
    + <%= link_to new_investment_position_path, class: "absolute left-0" do %> + + <% end %> +

    Select holding

    +
    + +
      + <% Security.all.order(name: :asc).each do |security| %> +
    • + <%= link_to '', new_investment_position_path(security_id: security.id), class: "absolute inset-0 w-full" %> + <%= holding_logo(security) %> + +
      +

      <%= security.name %>

      +

      <%= security.symbol %> • <%= security.source_type.upcase %>

      +
      +
    • + <% end %> + +
    \ No newline at end of file diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb new file mode 100644 index 00000000..9942d261 --- /dev/null +++ b/app/views/accounts/show.html.erb @@ -0,0 +1,4 @@ +
    +

    Accounts#show

    +

    Find me in app/views/accounts/show.html.erb

    +
    diff --git a/app/views/connections/index.html.erb b/app/views/connections/index.html.erb new file mode 100644 index 00000000..5c19a552 --- /dev/null +++ b/app/views/connections/index.html.erb @@ -0,0 +1,20 @@ +
    +

    Connections

    + +
    + +
      + <% @connections.each do |connection| %> +
      +
      + <%= institution_avatar(connection) %> + <%= connection.name %> +
      +

      + + <%= link_to "Disconnect", connection_path(connection), method: :delete, class: "text-sm text-red-500 hover:text-red-600", data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %> + +

      +
      + <% end %> +
    \ No newline at end of file diff --git a/app/views/conversations/_message.html.erb b/app/views/conversations/_message.html.erb new file mode 100644 index 00000000..1c2bed49 --- /dev/null +++ b/app/views/conversations/_message.html.erb @@ -0,0 +1,40 @@ +
  • +
    +
    + <% if message.role == 'user' %> + <%= image_tag gravatar(message.user), class: 'w-4 h-4 rounded-full inline' %> + You + <% elsif message.role == 'assistant' %> + + Maybe AI + <% end %> + <%= timeago message.created_at %> ago +
    +
    + + <% if message.content == '...' %> +
    +
    +
    +
    +
    +
    +
    + <% if message.status == 'pending' %> + Determing the best route to answer your question. + <% elsif message.status == 'data' %> + Grabbing the necessary information to answer your question. + <% elsif message.status == 'processing' %> + Writing the answer to your question. + <% else %> + Depending on the question, it may take a few minutes to respond. + <% end %> +
    +
    + <% else %> + <%= markdown message.content %> + <% end %> + +
    +
    +
  • \ No newline at end of file diff --git a/app/views/conversations/_stream.html.erb b/app/views/conversations/_stream.html.erb new file mode 100644 index 00000000..c634e512 --- /dev/null +++ b/app/views/conversations/_stream.html.erb @@ -0,0 +1 @@ +<%= text %> \ No newline at end of file diff --git a/app/views/conversations/index.html.erb b/app/views/conversations/index.html.erb new file mode 100644 index 00000000..f6aed93a --- /dev/null +++ b/app/views/conversations/index.html.erb @@ -0,0 +1,20 @@ +
    +

    Conversations

    + <%= link_to new_conversation_path, class: "px-3 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg shadow-md hover:bg-blue-700" do %> + New Conversation + <% end %> +
    + +<% @conversations.each do |conversation| %> + <%= link_to conversation_path(conversation), class: 'relative' do %> + +
    +
    + <%= conversation.title %> +
    +

    + +

    +
    + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/conversations/show.html.erb b/app/views/conversations/show.html.erb new file mode 100644 index 00000000..2c78fad9 --- /dev/null +++ b/app/views/conversations/show.html.erb @@ -0,0 +1,17 @@ +
    +

    <%= @conversation.title %>

    + +
    +
      + <%= turbo_stream_from "conversation_area" %> + + <%= turbo_frame_tag "conversation_area_#{@conversation.id}" do %> + <% if @conversation.messages.present? %> + <% @conversation.messages.visible.order('created_at ASC').each do |message| %> + <%= render partial: "conversations/message", locals: { message: message } %> + <% end %> + <% end %> + <% end %> +
    +
    +
    \ No newline at end of file diff --git a/app/views/devise/confirmations/new.html.erb b/app/views/devise/confirmations/new.html.erb new file mode 100644 index 00000000..f93b8d23 --- /dev/null +++ b/app/views/devise/confirmations/new.html.erb @@ -0,0 +1,35 @@ +
    +

    Resend Confirmation Instructions

    + + <%= form_for(resource, + as: resource_name, + url: confirmation_path(resource_name), + html: { + method: :post, + class: "bg-white mb-4 px-8 pt-6 pb-8 rounded shadow-md" + }) do |f| %> + + <%#= render "devise/shared/error_messages", resource: resource %> + <%= devise_error_messages! %> + +
    + <%= f.label :email, class: "block font-bold mb-2 text-gray-700 text-sm" %> + <%= f.email_field :email, + autofocus: true, + autocomplete: "email", + value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email), + class: "appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none shadow focus:shadow-outline" + %> +
    + +
    + <%= f.submit "Resend Confirmation Info", + class: "button bg-blue-500 hover:bg-blue-700 font-bold text-white focus:outline-none py-2 px-4 rounded focus:shadow-outline w-full" + %> +
    + + <%= render "devise/shared/links" %> + <% end %> + + <%= render "devise/shared/form_footer" %> +
    diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb new file mode 100644 index 00000000..dc55f64f --- /dev/null +++ b/app/views/devise/mailer/confirmation_instructions.html.erb @@ -0,0 +1,5 @@ +

    Welcome <%= @email %>!

    + +

    You can confirm your account email through the link below:

    + +

    <%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>

    diff --git a/app/views/devise/mailer/password_change.html.erb b/app/views/devise/mailer/password_change.html.erb new file mode 100644 index 00000000..b41daf47 --- /dev/null +++ b/app/views/devise/mailer/password_change.html.erb @@ -0,0 +1,3 @@ +

    Hello <%= @resource.email %>!

    + +

    We're contacting you to notify you that your password has been changed.

    diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb new file mode 100644 index 00000000..f667dc12 --- /dev/null +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -0,0 +1,8 @@ +

    Hello <%= @resource.email %>!

    + +

    Someone has requested a link to change your password. You can do this through the link below.

    + +

    <%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>

    + +

    If you didn't request this, please ignore this email.

    +

    Your password won't change until you access the link above and create a new one.

    diff --git a/app/views/devise/mailer/unlock_instructions.html.erb b/app/views/devise/mailer/unlock_instructions.html.erb new file mode 100644 index 00000000..41e148bf --- /dev/null +++ b/app/views/devise/mailer/unlock_instructions.html.erb @@ -0,0 +1,7 @@ +

    Hello <%= @resource.email %>!

    + +

    Your account has been locked due to an excessive number of unsuccessful sign in attempts.

    + +

    Click the link below to unlock your account:

    + +

    <%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>

    diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb new file mode 100644 index 00000000..2c3541a4 --- /dev/null +++ b/app/views/devise/passwords/edit.html.erb @@ -0,0 +1,41 @@ +<% + header_title "Change your password" +%> + + +<%= form_for(resource, + as: resource_name, + url: password_path(resource_name), + html: { + method: :put, + class: "space-y-6" + }) do |f| %> + <%= f.hidden_field :reset_password_token %> + +
    + <%= f.label :password, "New password", class: 'block text-sm font-medium text-gray-700' %> +
    + <%= f.password_field :password, + autofocus: true, + autocomplete: "new-password", + class: "block w-full px-4 py-3 placeholder-gray-400 border-none rounded-full bg-gray-200 focus:ring-2 appearance-none focus:outline-none focus:ring-gray-300 text-sm" + %> +
    +
    +
    + <%= f.label :password_confirmation, "Confirm new password", class: 'block text-sm font-medium text-gray-700' %> +
    + <%= f.password_field :password_confirmation, + autofocus: true, + autocomplete: "off", + class: "block w-full px-4 py-3 placeholder-gray-400 border-none rounded-full bg-gray-200 focus:ring-2 appearance-none focus:outline-none focus:ring-gray-300 text-sm" + %> +
    +
    + + +
    + <%= f.submit "Change my password", class: 'flex justify-center w-full px-4 py-3 text-sm font-medium text-white bg-black rounded-full hover:bg-black focus:outline-none focus:ring-2 focus:ring-gray-200' %> +
    + +<% end %> diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb new file mode 100644 index 00000000..72b05013 --- /dev/null +++ b/app/views/devise/passwords/new.html.erb @@ -0,0 +1,25 @@ +<% + header_title "Reset your password" +%> + +<%= form_for(resource, + as: resource_name, + url: password_path(resource_name), + html: { + method: :post, + class: "space-y-6" + }) do |f| %> + +
    + <%= f.label :email, "Email address", class: 'block text-sm font-medium text-gray-700' %> +
    + <%= f.email_field :email, autofocus: true, autocomplete: "email", required: 'required', class: 'block w-full px-4 py-3 placeholder-gray-400 border-none rounded-full bg-gray-200 focus:ring-2 appearance-none focus:outline-none focus:ring-gray-300 text-sm' %> +
    +
    + + +
    + <%= f.submit "Send password reset info", class: 'flex justify-center w-full px-4 py-3 text-sm font-medium text-white bg-black rounded-full hover:bg-black focus:outline-none focus:ring-2 focus:ring-gray-200' %> +
    + +<% end %> \ No newline at end of file diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb new file mode 100644 index 00000000..7a67a1fb --- /dev/null +++ b/app/views/devise/registrations/edit.html.erb @@ -0,0 +1,59 @@ +
    +

    Edit <%= resource_name.to_s.humanize %>

    + + <%= form_for(resource, + as: resource_name, + url: registration_path(resource_name), + html: { + method: :put, + class: "bg-white mb-4 px-8 pt-6 pb-8 rounded shadow-md" + } + ) do |f| %> + + <%#= render "devise/shared/error_messages", resource: resource %> + <%= devise_error_messages! %> + +
    + <%= f.label :email, class: "block font-bold mb-2 text-gray-700 text-sm" %>
    + <%= f.email_field :email, + autofocus: true, + autocomplete: "email", + class: "appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none shadow focus:shadow-outline" %> +
    + + <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %> +
    Currently waiting confirmation for: <%= resource.unconfirmed_email %>
    + <% end %> + +
    + <%= f.label :password, class: "block font-bold mb-2 text-gray-700 text-sm" %> (leave blank if you don't want to change it)
    + <%= f.password_field :password, autocomplete: "new-password", class: "shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" %> + +
    + +
    + <%= f.label :password_confirmation, class: "block font-bold mb-2 text-gray-700 text-sm" %> + <%= f.password_field :password_confirmation, + autocomplete: "new-password", + class: "shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" + %> +
    + +
    + <%= f.label :current_password, class: "block font-bold mb-2 text-gray-700 text-sm" %> + <%= f.password_field :current_password, + autocomplete: "current-password", + class: "shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" + + %> +
    + +
    + <%= f.submit "Update", class: "button bg-blue-500 hover:bg-blue-700 font-bold text-white focus:outline-none py-2 px-4 rounded focus:shadow-outline w-full" %> +
    + <% end %> + +

    Unhappy? <%= button_to "Delete my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete %>

    + + <%= link_to "Back", :back %> +
    diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb new file mode 100644 index 00000000..2ceb957c --- /dev/null +++ b/app/views/devise/registrations/new.html.erb @@ -0,0 +1,27 @@ +<% + header_title "Create an account" +%> + +<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: {class: 'space-y-6'}) do |f| %> + +
    + <%= f.label :email, "Email address", class: 'block text-sm font-medium text-gray-700' %> + <%= f.email_field :email, autofocus: false, autocomplete: "email", required: 'required', placeholder: 'you@example.com', class: 'p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100' %> +
    + + +
    + <%= f.label :password, "Password", class: 'block text-sm font-medium text-gray-700' %> + <%= f.password_field :password, autocomplete: "new-password", required: 'required', class: 'p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100' %> +
    + +
    + <%= f.label :password_confirmation, "Password confirmation", class: 'block text-sm font-medium text-gray-700' %> + <%= f.password_field :password_confirmation, autocomplete: "new-password", required: 'required', class: 'p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100' %> +
    + +
    + <%= f.submit "Continue", class: 'flex justify-center w-full px-4 py-3 text-sm font-medium text-white bg-black rounded-xl hover:bg-black focus:outline-none focus:ring-2 focus:ring-gray-200 shadow' %> +
    + +<% end %> \ No newline at end of file diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb new file mode 100644 index 00000000..c5c57034 --- /dev/null +++ b/app/views/devise/sessions/new.html.erb @@ -0,0 +1,36 @@ +<% + header_title "Sign in to your account" +%> + +<%= form_for(resource, as: resource_name, url: session_path(resource_name), html: {class: 'space-y-6'}) do |f| %> + +
    + <%= f.label :email, "Email address", class: 'block text-sm font-medium text-gray-700' %> +
    + <%= f.email_field :email, autofocus: true, autocomplete: "email", required: 'required', class: 'block w-full px-4 py-3 placeholder-gray-400 border-none rounded-full bg-gray-200 focus:ring-2 appearance-none focus:outline-none focus:ring-gray-300 text-sm' %> +
    +
    + +
    + <%= f.label :password, class: 'block text-sm font-medium text-gray-700' %> +
    + <%= f.password_field :password, autocomplete: "current-password", required: 'required', class: 'block w-full px-4 py-3 placeholder-gray-400 border-none rounded-full bg-gray-200 focus:ring-2 appearance-none focus:outline-none focus:ring-gray-300 text-sm' %> +
    +
    + +
    +
    + <%= f.check_box :remember_me, class: 'w-4 h-4 text-gray-600 border-gray-300 rounded focus:ring-gray-500' %> + <%= f.label :remember_me, class: 'block ml-2 text-sm text-gray-900' %> +
    + +
    + <%= link_to "Forgot your password?", new_password_path(resource_name), class: 'font-medium text-gray-600 hover:text-gray-500' %> +
    +
    + +
    + <%= f.submit "Log in", class: 'flex justify-center w-full px-4 py-3 text-sm font-medium text-white bg-black rounded-full hover:bg-black focus:outline-none focus:ring-2 focus:ring-gray-200' %> +
    + +<% end %> \ No newline at end of file diff --git a/app/views/devise/shared/_form_footer.html.erb b/app/views/devise/shared/_form_footer.html.erb new file mode 100644 index 00000000..61e6b96b --- /dev/null +++ b/app/views/devise/shared/_form_footer.html.erb @@ -0,0 +1,3 @@ +

    + ©2019 Acme Corp. All rights reserved. +

    diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb new file mode 100644 index 00000000..f075087f --- /dev/null +++ b/app/views/devise/shared/_links.html.erb @@ -0,0 +1,38 @@ +<%- if controller_name != 'sessions' %> + <%= link_to "Log in", new_session_path(resource_name), + class: "inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800" + %>
    +<% end %> + +<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> + <%= link_to "Forgot Password?", new_user_password_path(resource_name), + class: "inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800" + %>
    +<% end %> + + <%- if devise_mapping.registerable? && controller_name != 'registrations' %> + <%= link_to "Sign up", new_registration_path(resource_name), + class: "inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800" + %>
    +<% end %> + + +<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> + <%= link_to "Didn't receive confirmation info?", new_user_confirmation_path(resource_name), + class: "inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800" + %>
    +<% end %> + +<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> + <%= link_to "Didn't receive unlock info?", new_user_unlock_path(resource_name), + class: "inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800" + %>
    +<% end %> + +<%- if devise_mapping.omniauthable? %> + <%- resource_class.omniauth_providers.each do |provider| %> + <%= link_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), + class: "inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800" + %>
    + <% end %> +<% end %> diff --git a/app/views/devise/unlocks/new.html.erb b/app/views/devise/unlocks/new.html.erb new file mode 100644 index 00000000..7400ddf6 --- /dev/null +++ b/app/views/devise/unlocks/new.html.erb @@ -0,0 +1,34 @@ +
    +

    Resend Unlock Info

    + + <%= form_for(resource, + as: resource_name, + url: unlock_path(resource_name), + html: { + method: :post, + class: "bg-white mb-4 px-8 pt-6 pb-8 rounded shadow-md" + } + ) do |f| %> + + <%= devise_error_messages! %> + +
    + <%= f.label :email, class: "block font-bold mb-2 text-gray-700 text-sm" %> + <%= f.email_field :email, + autofocus: true, + autocomplete: "email", + class: "appearance-none border leading-tight focus:outline-none px-3 py-2 rounded shadow focus:shadow-outline text-gray-700 w-full" + %> +
    + +
    + <%= f.submit "Resend unlock instructions", + class: "button bg-blue-500 hover:bg-blue-700 font-bold text-white focus:outline-none py-2 px-4 rounded focus:shadow-outline w-full" + %> +
    + + <%= render "devise/shared/links" %> +<% end %> + + <%= render "devise/shared/form_footer" %> +
    diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 00000000..1c3ad2b5 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,85 @@ + + + + Maybe + + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + + + + <%= hotwire_livereload_tags if Rails.env.development? %> + + + +
    + <% if request.path == root_path or request.path == advisor_path %> + + <% end %> + +
    data-controller="conversation-stream"<% end %>> + <%= yield %> +
    + + <% if request.path == root_path or request.path == settings_path or request.path == accounts_path or request.path == advisor_path or request.path == conversations_path %> + + <% end %> +
    + + diff --git a/app/views/layouts/devise.html.erb b/app/views/layouts/devise.html.erb new file mode 100644 index 00000000..b6c31e7b --- /dev/null +++ b/app/views/layouts/devise.html.erb @@ -0,0 +1,77 @@ + + + + Maybe + + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + <%= hotwire_livereload_tags if Rails.env.development? %> + + + +
    + +
    + <%= render "shared/logo" %> + +

    + <%= content_for?(:header_title) ? yield(:header_title).html_safe : "Your account" %> +

    + + <% if params[:controller] == "devise/sessions" && params[:action] == "new" %> +

    + or <%= link_to "create an account", new_user_registration_path, class: 'font-medium text-candlelight-600 hover:text-candlelight-500' %> +

    + <% elsif params[:controller] == "devise/registrations" && params[:action] == "new" %> +

    + or <%= link_to "sign in to your account", new_user_session_path, class: 'font-medium text-candlelight-600 hover:text-candlelight-500' %> +

    + <% end %> + +
    + +
    + <% if resource.errors.any? %> +
    +
    +
    + +
    +
    +

    + There <%= resource.errors.count == 1 ? "was an error" : "were #{pluralize(resource.errors.count, 'error')}" %> +

    +
    +
      + <% resource.errors.full_messages.each do |message| %> +
    • <%= message %>
    • + <% end %> +
    +
    +
    +
    +
    + <% end %> + + <%= yield %> +
    + + + +
    + + + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 00000000..3aac9002 --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 00000000..37f0bddb --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/layouts/simple.html.erb b/app/views/layouts/simple.html.erb new file mode 100644 index 00000000..e23212f5 --- /dev/null +++ b/app/views/layouts/simple.html.erb @@ -0,0 +1,29 @@ + + + + Maybe + + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + + + <%= hotwire_livereload_tags if Rails.env.development? %> + + + +
    + <%= yield %> +
    + + diff --git a/app/views/onboarding/agreements.html.erb b/app/views/onboarding/agreements.html.erb new file mode 100644 index 00000000..076580e1 --- /dev/null +++ b/app/views/onboarding/agreements.html.erb @@ -0,0 +1,59 @@ +
    + <%= link_to previous_wizard_path do %> + + <% end %> + +
    +
    +
    +
    + +

    Almost there, just a few agreements

    +

    By agreeing below, you acknowledge the documents shown and consent to electronic delivery of communications.

    + +<%= form_for @user, method: :put, url: wizard_path, html: {class: 'space-y-2'}, data: {controller: 'radio-button'} do |f| %> + + + + + Terms and conditions + + + + + + + + Privacy policy + + + + + + + + Limited Scope Advisory Agreement + + + + + + + + Form CRS relationship summary + + + + + + + + Form ADV brochure + + + + +
    + <%= f.submit "I agree", class: 'flex justify-center w-full px-4 py-3 text-sm font-medium text-white rounded-xl focus:outline-none focus:ring-2 focus:ring-gray-200 btn-black' %> +
    +<% end %> \ No newline at end of file diff --git a/app/views/onboarding/birthday.html.erb b/app/views/onboarding/birthday.html.erb new file mode 100644 index 00000000..97598e99 --- /dev/null +++ b/app/views/onboarding/birthday.html.erb @@ -0,0 +1,26 @@ +
    + <%= link_to previous_wizard_path do %> + + <% end %> + +
    +
    +
    +
    + + +

    When were you born?

    +

    We only ask your age so that we can personalize your experience.

    +<%= form_for @user, method: :put, url: wizard_path, html: {class: 'space-y-6'} do |f| %> + +
    + <%= f.label :birthday, "Birthday", class: 'input-label' %> + <%= f.date_field :birthday, autocomplete: "birthday", required: 'required', placeholder: 'Birthday', class: 'input-field' %> +
    + +
    + +
    +<% end %> \ No newline at end of file diff --git a/app/views/onboarding/currency.html.erb b/app/views/onboarding/currency.html.erb new file mode 100644 index 00000000..721c22ea --- /dev/null +++ b/app/views/onboarding/currency.html.erb @@ -0,0 +1,27 @@ +
    + <%= link_to previous_wizard_path do %> + + <% end %> + +
    +
    +
    +
    + + +

    What currency will you use?

    +<%= form_for @user, method: :put, url: wizard_path, html: {class: 'space-y-6'} do |f| %> + + <%= f.fields_for :family do |family_fields| %> +
    + <%= family_fields.label :currency, "Currency", class: 'input-label' %> + <%= family_fields.currency_select :currency, ["USD", "EUR", "CAD"], {}, required: 'required', class: 'input-field w-11/12' %> +
    + <% end %> + +
    + +
    +<% end %> \ No newline at end of file diff --git a/app/views/onboarding/family.html.erb b/app/views/onboarding/family.html.erb new file mode 100644 index 00000000..ce318cea --- /dev/null +++ b/app/views/onboarding/family.html.erb @@ -0,0 +1,28 @@ +
    + <%= link_to previous_wizard_path do %> + + <% end %> + +
    +
    +
    +
    + + +

    What should we call your family?

    + +<%= form_for @user, method: :put, url: wizard_path, html: {class: 'space-y-6'} do |f| %> + + <%= f.fields_for :family do |family_fields| %> +
    + <%= family_fields.label :currency, "Family Name", class: 'input-label' %> + <%= family_fields.text_field :name, value: "The #{@user.last_name}s", required: 'required', class: 'input-field' %> +
    + <% end %> + +
    + +
    +<% end %> \ No newline at end of file diff --git a/app/views/onboarding/goals.html.erb b/app/views/onboarding/goals.html.erb new file mode 100644 index 00000000..9850b0f5 --- /dev/null +++ b/app/views/onboarding/goals.html.erb @@ -0,0 +1,28 @@ +
    + <%= link_to previous_wizard_path do %> + + <% end %> + +
    +
    +
    +
    + + +

    What are your financial goals?

    + +<%= form_for @user, method: :put, url: wizard_path, html: {class: 'space-y-4'}, data: {controller: 'radio-button'} do |f| %> + + <%= f.fields_for :family do |family_fields| %> +
    + <%= family_fields.label :goals, "Goals", class: 'input-label' %> + <%= family_fields.text_area :goals, required: 'required', placeholder: 'Your financial goals', class: 'input-field' %> +
    + <% end %> + +
    + +
    +<% end %> \ No newline at end of file diff --git a/app/views/onboarding/household.html.erb b/app/views/onboarding/household.html.erb new file mode 100644 index 00000000..96f09e2b --- /dev/null +++ b/app/views/onboarding/household.html.erb @@ -0,0 +1,89 @@ +
    + <%= link_to previous_wizard_path do %> + + <% end %> + +
    +
    +
    +
    + + +

    Which best describes your household?

    +<%= form_for @user, method: :put, url: wizard_path, html: {class: 'space-y-4'}, data: {controller: 'radio-button'} do |f| %> + + <%= f.fields_for :family do |family_fields| %> + + + + + + + + + + + + <% end %> + +
    + +
    +<% end %> \ No newline at end of file diff --git a/app/views/onboarding/location.html.erb b/app/views/onboarding/location.html.erb new file mode 100644 index 00000000..43860e73 --- /dev/null +++ b/app/views/onboarding/location.html.erb @@ -0,0 +1,33 @@ +
    + <%= link_to previous_wizard_path do %> + + <% end %> + +
    +
    +
    +
    + + +

    Where are you from?

    +

    We only ask for this to tailor any advice or financial services we offer to your region.

    +<%= form_for @user, method: :put, url: wizard_path, html: {class: 'space-y-6'}, data: {controller: 'country'} do |f| %> + + <%= f.fields_for :family do |family_fields| %> +
    + <%= family_fields.label :country, "Country", class: 'input-label' %> + <%= family_fields.country_select :country, {priority_countries: ["CA", "GB", "US"]}, required: 'required', class: 'input-field w-11/12', data: { country_target: "country", action: "change->country#onCountryChange" } %> +
    + + + <% end %> + +
    + +
    +<% end %> \ No newline at end of file diff --git a/app/views/onboarding/name.html.erb b/app/views/onboarding/name.html.erb new file mode 100644 index 00000000..aaf76584 --- /dev/null +++ b/app/views/onboarding/name.html.erb @@ -0,0 +1,34 @@ +
    + <%= link_to new_user_registration_path do %> + + <% end %> + +
    +
    +
    + + <%= link_to new_user_registration_path do %> + + <% end %> +
    + + +

    What's your name?

    +<%= form_for @user, method: :put, url: wizard_path, html: {class: 'space-y-6'} do |f| %> + +
    + <%= f.label :first_name, "First name", class: 'input-label' %> + <%= f.text_field :first_name, autocomplete: "given-name", required: 'required', placeholder: 'First name', class: 'input-field' %> +
    + +
    + <%= f.label :last_name, "Last name", class: 'input-label' %> + <%= f.text_field :last_name, autocomplete: "family-name", required: 'required', placeholder: 'Last name', class: 'input-field' %> +
    + +
    + +
    +<% end %> \ No newline at end of file diff --git a/app/views/onboarding/notifications.html.erb b/app/views/onboarding/notifications.html.erb new file mode 100644 index 00000000..e69de29b diff --git a/app/views/onboarding/recap.html.erb b/app/views/onboarding/recap.html.erb new file mode 100644 index 00000000..e69de29b diff --git a/app/views/onboarding/risk.html.erb b/app/views/onboarding/risk.html.erb new file mode 100644 index 00000000..805bf983 --- /dev/null +++ b/app/views/onboarding/risk.html.erb @@ -0,0 +1,63 @@ +
    + <%= link_to previous_wizard_path do %> + + <% end %> + +
    +
    +
    +
    + + +

    What would you say your risk tolerance is?

    +

    This relates to decision making around your finances such as investing. We’ll learn more about your risk profile the more you use with Maybe.

    +<%= form_for @user, method: :put, url: wizard_path, html: {class: 'space-y-4'}, data: {controller: 'radio-button'} do |f| %> + + <%= f.fields_for :family do |family_fields| %> + + + + + + + <% end %> + +
    + +
    +<% end %> \ No newline at end of file diff --git a/app/views/onboarding/upgrade.html.erb b/app/views/onboarding/upgrade.html.erb new file mode 100644 index 00000000..cf4214d1 --- /dev/null +++ b/app/views/onboarding/upgrade.html.erb @@ -0,0 +1,23 @@ +
    + <%= link_to previous_wizard_path do %> + + <% end %> + +
    +
    +
    +
    + +

    Subscribe to get started

    + +
    +

    Hi there! Thanks for joining Maybe, where we're eager to help you manage your finances and grow your wealth! As we're in the early stages, we're looking for feedback primarily from paying customers.

    +

    So, to get started, you'll need to subscribe for $9/month.

    +

    Don't worry, if Maybe isn't the right fit, we'll refund 100%, no questions asked. Ready to start? Subscribe now!

    +

    + <%= link_to @session.url, class: "w-full text-center px-4 py-2 font-normal text-white border border-transparent rounded-full shadow-sm text-md bg-black hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 no-underline" do %> + Subscribe for $9/month + <% end %> +

    +

    Thanks again!
    Josh, Travis & Justin

    +
    \ No newline at end of file diff --git a/app/views/onboarding/welcome.html.erb b/app/views/onboarding/welcome.html.erb new file mode 100644 index 00000000..5aa3d99c --- /dev/null +++ b/app/views/onboarding/welcome.html.erb @@ -0,0 +1,36 @@ +

    Welcome to Maybe, <%= current_user.first_name %>! How'd you like to get started?

    + + +
    +
    + + <%= link_to new_account_path, class: "flex items-start justify-center w-full text-center focus:outline-none" do %> + + + + + + +
    + Add your first account +

    Track your accounts manually or automatically in one place.

    +
    + <% end %> +
    + +
    + + <%= link_to root_path, class: "flex items-start justify-center w-full text-center focus:outline-none" do %> + + + + + + +
    + Talk with the AI +

    Ask a question or have a conversation with Maybe’s AI around your finances.

    +
    + <% end %> +
    +
    \ No newline at end of file diff --git a/app/views/pages/advisor.html.erb b/app/views/pages/advisor.html.erb new file mode 100644 index 00000000..58a36d88 --- /dev/null +++ b/app/views/pages/advisor.html.erb @@ -0,0 +1,5 @@ +
    +

    Ask Your Advisor

    +
    + +

    Chatting directly with a human advisor isn't quite ready yet!

    \ No newline at end of file diff --git a/app/views/pages/index.html.erb b/app/views/pages/index.html.erb new file mode 100644 index 00000000..fa96b193 --- /dev/null +++ b/app/views/pages/index.html.erb @@ -0,0 +1,28 @@ +
    +

    Good <%= current_user.local_time_of_day %>! + <% + case current_user.local_time_of_day + when "morning" %> +

    + <% when "afternoon" %> + + <% when "evening" %> + + <% end %> + +

    It's <%= Date.today.strftime("%A, %B %-d") %>

    + +
    +
      + <%= turbo_stream_from "conversation_area" %> + + <%= turbo_frame_tag "conversation_area_#{@conversation.id}" do %> + <% if @conversation.messages.present? %> + <% @conversation.messages.visible.order('created_at ASC').each do |message| %> + <%= render partial: "conversations/message", locals: { message: message } %> + <% end %> + <% end %> + <% end %> +
    +
    +
    \ No newline at end of file diff --git a/app/views/pages/settings.html.erb b/app/views/pages/settings.html.erb new file mode 100644 index 00000000..8a796c57 --- /dev/null +++ b/app/views/pages/settings.html.erb @@ -0,0 +1,4 @@ +
    +

    Account settings

    +
    + diff --git a/app/views/pages/upgrade.html.erb b/app/views/pages/upgrade.html.erb new file mode 100644 index 00000000..f400ce84 --- /dev/null +++ b/app/views/pages/upgrade.html.erb @@ -0,0 +1,16 @@ +
    +
    +

    Subscribe to get started

    +
    +
    +

    Hi there! Thanks for joining Maybe, where we're eager to help you manage your finances and grow your wealth! As we're in the early stages, we're looking for feedback primarily from paying customers.

    +

    So, to get started, you'll need to subscribe for $9/month.

    +

    Don't worry, if Maybe isn't the right fit, we'll refund 100%, no questions asked. Ready to start? Subscribe now!

    +

    + <%= link_to @session.url, class: "w-full text-center px-4 py-2 font-normal text-white border border-transparent rounded-full shadow-sm text-md bg-black hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 no-underline" do %> + Subscribe for $9/month + <% end %> +

    +

    Thanks again!
    Josh, Travis & Justin

    +
    +
    \ No newline at end of file diff --git a/app/views/prompts/index.html.erb b/app/views/prompts/index.html.erb new file mode 100644 index 00000000..73f27cf8 --- /dev/null +++ b/app/views/prompts/index.html.erb @@ -0,0 +1,49 @@ +

    Prompts

    + +
      +
    • + <%= link_to 'All', prompts_path, class: 'text-gray-800 hover:text-gray-700 px-4 py-2 rounded-full bg-gray-200/70 font-medium text-sm' %> +
    • + <% Prompt.unique_categories.each do |category| %> +
    • + <%= link_to category, prompts_path(category: category), class: 'text-gray-800 hover:text-gray-700 px-4 py-2 rounded-full bg-gray-200/70 font-medium text-sm' %> +
    • + <% end %> +
    + +
      + <% @prompts.each do |prompt| %> +
    • +
      +
      +
      +

      <%= prompt.content %>

      +
      +

      + <% prompt.categories.each do |category| %> + <%= category %> + <% end %> +

      +
      +
      +
      +
      +
      + + + + Copy + + +
      + +
      +
      +
    • + <% end %> +
    diff --git a/app/views/shared/_logo.html.erb b/app/views/shared/_logo.html.erb new file mode 100644 index 00000000..434e6558 --- /dev/null +++ b/app/views/shared/_logo.html.erb @@ -0,0 +1 @@ +<%= link_to image_tag("logomark.svg", class: 'w-auto h-12 mx-auto'), root_path, data: { turbo: false} %> \ No newline at end of file diff --git a/bin/bundle b/bin/bundle new file mode 100755 index 00000000..ee73929e --- /dev/null +++ b/bin/bundle @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "rubygems" + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV["BUNDLER_VERSION"] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV["BUNDLE_GEMFILE"] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path("../Gemfile", __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + Regexp.last_match(1) + end + + def bundler_requirement + @bundler_requirement ||= + env_var_version || + cli_arg_version || + bundler_requirement_for(lockfile_version) + end + + def bundler_requirement_for(version) + return "#{Gem::Requirement.default}.a" unless version + + bundler_gem_version = Gem::Version.new(version) + + bundler_gem_version.approximate_recommendation + end + + def load_bundler! + ENV["BUNDLE_GEMFILE"] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem "bundler", bundler_requirement + end + return if gem_error.nil? + require_error = activation_error_handling do + require "bundler/version" + end + return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +if m.invoked_as_script? + load Gem.bin_path("bundler", "bundle") +end diff --git a/bin/dev b/bin/dev new file mode 100755 index 00000000..74ade166 --- /dev/null +++ b/bin/dev @@ -0,0 +1,8 @@ +#!/usr/bin/env sh + +if ! gem list foreman -i --silent; then + echo "Installing foreman..." + gem install foreman +fi + +exec foreman start -f Procfile.dev "$@" diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 00000000..dffd4ba9 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,8 @@ +#!/bin/bash -e + +# If running the rails server then create or migrate existing database +if [ "${*}" == "./bin/rails server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 00000000..36502ab1 --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/bin/rails b/bin/rails new file mode 100755 index 00000000..efc03774 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 00000000..4fbf10b9 --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/render-build.sh b/bin/render-build.sh new file mode 100644 index 00000000..ab016fa8 --- /dev/null +++ b/bin/render-build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# exit on error +set -o errexit + +bundle install +npm install +bundle exec rake assets:precompile +bundle exec rake assets:clean +bundle exec rake db:migrate \ No newline at end of file diff --git a/bin/setup b/bin/setup new file mode 100755 index 00000000..3cd5a9d7 --- /dev/null +++ b/bin/setup @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby +require "fileutils" + +# path to your application root. +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + puts "\n== Restarting application server ==" + system! "bin/rails restart" +end diff --git a/config.ru b/config.ru new file mode 100644 index 00000000..4a3c09a6 --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 00000000..ae3a6053 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,31 @@ +require_relative "boot" + +require "rails/all" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Maybe + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 7.1 + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + + config.action_mailer.delivery_method = :postmark + + config.action_mailer.postmark_settings = { + api_token: ENV['POSTMARK_API_TOKEN'] + } + + config.active_record.writing_role = :primary + config.active_record.reading_role = :primary_replica + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 00000000..988a5ddc --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 00000000..536f3007 --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,11 @@ +development: + adapter: redis + url: redis://localhost:6379/1 + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: maybe_production diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 00000000..9faa5f2a --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +3UvrAR/kP1qsFqWEHAusPOPbGVaVQuJvU8VlFoxigXKeCX80vD+aKH734KH5AlVnaWzmAig8wGQzA6B8UyjFgAsGFLTmxH43upKvG8J2HaQMzlDafDix00d+RUsQcV0RfwjTghAL48VKy6rIJXIJzRJvj7JO6/+I4h3vpEZkgn3wPrL3Fp2dOOY5qoRtEgj/wydXH9qnwMUIppXlcmMZE69bvUEZAuiO0NGFhIIOf5B4CZpRMwkIiT+U/Ch4rGAxyjroVru4SabQfZb4DNtAqIztdW69vSb5U4Alf2E2OliMf3CVllALHRHyshn0CMRjMcnbZCu7IgwT68NBQc7sYx3+vs/TBdd6mBISroPkkUcoS1UbDbdtZBm7JQyKtNzIk+Pto0+aCpAoF/t9KkMzmyS5qFi2--f7uLvM1yEEOjYkeM--2UpbsiaMIlLyDlvIoAybAg== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 00000000..421fd08f --- /dev/null +++ b/config/database.yml @@ -0,0 +1,85 @@ +# PostgreSQL. Versions 9.3 and up are supported. +# +# Install the pg driver: +# gem install pg +# On macOS with Homebrew: +# gem install pg -- --with-pg-config=/usr/local/bin/pg_config +# On Windows: +# gem install pg +# Choose the win32 build. +# Install PostgreSQL and put its /bin directory on your path. +# +# Configure Using Gemfile +# gem "pg" +# +default: &default + adapter: postgresql + encoding: unicode + # For details on connection pooling, see Rails configuration guide + # https://guides.rubyonrails.org/configuring.html#database-pooling + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + +development: &development + <<: *default + database: maybe_ai_development + adapter: postgresql + + # The specified database role being used to connect to postgres. + # To create additional roles in postgres see `$ createuser --help`. + # When left blank, postgres will use the default role. This is + # the same name as the operating system user running Rails. + #username: maybe + + # The password associated with the postgres role (username). + #password: + + # Connect on a TCP socket. Omitted by default since the client uses a + # domain socket that doesn't need configuration. Windows does not have + # domain sockets, so uncomment these lines. + #host: localhost + + # The TCP port the server listens on. Defaults to 5432. + # If your server runs on a different port number, change accordingly. + #port: 5432 + + # Schema search path. The server defaults to $user,public + #schema_search_path: myapp,sharedapp,public + + # Minimum log levels, in increasing order: + # debug5, debug4, debug3, debug2, debug1, + # log, notice, warning, error, fatal, and panic + # Defaults to warning. + #min_messages: notice + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: maybe_ai_test + +# As with config/credentials.yml, you never want to store sensitive information, +# like your database password, in your source code. If your source code is +# ever seen by anyone, they now have access to your database. +# +# Instead, provide the password or a full connection URL as an environment +# variable when you boot the app. For example: +# +# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" +# +# If the connection URL is provided in the special DATABASE_URL environment +# variable, Rails will automatically merge its configuration values on top of +# the values provided in this file. Alternatively, you can specify a connection +# URL environment variable explicitly: +# +# production: +# url: <%= ENV["MY_APP_DATABASE_URL"] %> +# +# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database +# for a full overview on how database connection configuration can be specified. +# +production: &production + <<: *default + database: maybe_production + username: maybe + password: <%= ENV["MAYBE_DATABASE_PASSWORD"] %> diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 00000000..cac53157 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 00000000..ff5419ed --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,80 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing + config.server_timing = true + + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + + config.cache_store = :memory_store + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false + + config.action_mailer.default_url_options = { host: 'maybeapp.ngrok.io' } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + config.hosts << "maybeapp.ngrok.io" + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 00000000..682ed32f --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,99 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] + # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + + # Enable static file serving from the `/public` folder (turn off if using NGINX/Apache for it). + config.public_file_server.enabled = true + + # Compress CSS using a preprocessor. + # config.assets.css_compressor = :sass + + # Do not fallback to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache + # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Mount Action Cable outside main process or domain. + # config.action_cable.mount_path = nil + # config.action_cable.url = "wss://example.com/cable" + # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Log to STDOUT by default + config.logger = ActiveSupport::Logger.new(STDOUT) + .tap { |logger| logger.formatter = ::Logger::Formatter.new } + .then { |logger| ActiveSupport::TaggedLogging.new(logger) } + + # Prepend all log lines with the following tags. + config.log_tags = [ :request_id ] + + # Info include generic and useful information about system operation, but avoids logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). Use "debug" + # for everything. + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment). + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "maybe_production" + + config.action_mailer.perform_caching = false + + config.action_mailer.default_url_options = { host: 'ai.maybe.co' } + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 00000000..ed2ea083 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,64 @@ +require "active_support/core_ext/integer/time" + +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.enabled = true + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{1.hour.to_i}" + } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :null_store + + # Raise exceptions instead of rendering exception templates. + config.action_dispatch.show_exceptions = false + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 00000000..a93e2bff --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,11 @@ +# Pin npm packages by running ./bin/importmap + +pin "application", preload: true +pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true +pin_all_from "app/javascript/controllers", under: "controllers" +pin "stimulus-clipboard", to: "https://ga.jspm.io/npm:stimulus-clipboard@3.3.0/dist/stimulus-clipboard.mjs" +pin "@hotwired/stimulus", to: "https://ga.jspm.io/npm:@hotwired/stimulus@3.2.1/dist/stimulus.js" +pin "@addresszen/address-lookup", to: "https://ga.jspm.io/npm:@addresszen/address-lookup@2.0.0/dist/address-lookup.esm.js" +pin "stimulus-timeago", to: "https://ga.jspm.io/npm:stimulus-timeago@4.1.0/dist/stimulus-timeago.mjs" +pin "date-fns", to: "https://ga.jspm.io/npm:date-fns@2.29.3/esm/index.js" diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 00000000..2eeef966 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,12 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path + +# Precompile additional assets. +# application.js, application.css, and all non-JS/CSS in the app/assets +# folder are already added. +# Rails.application.config.assets.precompile += %w( admin.js admin.css ) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 00000000..b3076b38 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,25 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb new file mode 100644 index 00000000..63253107 --- /dev/null +++ b/config/initializers/devise.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +# Assuming you have not yet modified this file, each configuration option below +# is set to its default value. Note that some are commented out while others +# are not: uncommented lines are intended to protect your configuration from +# breaking changes in upgrades (i.e., in the event that future versions of +# Devise change the default values for those options). +# +# Use this hook to configure devise mailer, warden hooks and so forth. +# Many of these configuration options can be set straight in your model. +Devise.setup do |config| + # The secret key used by Devise. Devise uses this key to generate + # random tokens. Changing this key will render invalid all existing + # confirmation, reset password and unlock tokens in the database. + # Devise will use the `secret_key_base` as its `secret_key` + # by default. You can change it below and use your own secret key. + # config.secret_key = 'b89804c2faeca0b7a6aff992611377f5d690d53a787052821a8e71fc28caf7b26e682eeb03ba5d6c22c3a26a69b239a3df60cec8114e6b104194b7f9c601ba5e' + + # ==> Controller configuration + # Configure the parent class to the devise controllers. + # config.parent_controller = 'DeviseController' + + # ==> Mailer Configuration + # Configure the e-mail address which will be shown in Devise::Mailer, + # note that it will be overwritten if you use your own mailer class + # with default "from" parameter. + config.mailer_sender = 'hello@maybe.co' + + # Configure the class responsible to send e-mails. + # config.mailer = 'Devise::Mailer' + + # Configure the parent class responsible to send e-mails. + # config.parent_mailer = 'ActionMailer::Base' + + # ==> ORM configuration + # Load and configure the ORM. Supports :active_record (default) and + # :mongoid (bson_ext recommended) by default. Other ORMs may be + # available as additional gems. + require 'devise/orm/active_record' + + # ==> Configuration for any authentication mechanism + # Configure which keys are used when authenticating a user. The default is + # just :email. You can configure it to use [:username, :subdomain], so for + # authenticating a user, both parameters are required. Remember that those + # parameters are used only when authenticating and not when retrieving from + # session. If you need permissions, you should implement that in a before filter. + # You can also supply a hash where the value is a boolean determining whether + # or not authentication should be aborted when the value is not present. + # config.authentication_keys = [:email] + + # Configure parameters from the request object used for authentication. Each entry + # given should be a request method and it will automatically be passed to the + # find_for_authentication method and considered in your model lookup. For instance, + # if you set :request_keys to [:subdomain], :subdomain will be used on authentication. + # The same considerations mentioned for authentication_keys also apply to request_keys. + # config.request_keys = [] + + # Configure which authentication keys should be case-insensitive. + # These keys will be downcased upon creating or modifying a user and when used + # to authenticate or find a user. Default is :email. + config.case_insensitive_keys = [:email] + + # Configure which authentication keys should have whitespace stripped. + # These keys will have whitespace before and after removed upon creating or + # modifying a user and when used to authenticate or find a user. Default is :email. + config.strip_whitespace_keys = [:email] + + # Tell if authentication through request.params is enabled. True by default. + # It can be set to an array that will enable params authentication only for the + # given strategies, for example, `config.params_authenticatable = [:database]` will + # enable it only for database (email + password) authentication. + # config.params_authenticatable = true + + # Tell if authentication through HTTP Auth is enabled. False by default. + # It can be set to an array that will enable http authentication only for the + # given strategies, for example, `config.http_authenticatable = [:database]` will + # enable it only for database authentication. + # For API-only applications to support authentication "out-of-the-box", you will likely want to + # enable this with :database unless you are using a custom strategy. + # The supported strategies are: + # :database = Support basic authentication with authentication key + password + # config.http_authenticatable = false + + # If 401 status code should be returned for AJAX requests. True by default. + # config.http_authenticatable_on_xhr = true + + # The realm used in Http Basic Authentication. 'Application' by default. + # config.http_authentication_realm = 'Application' + + # It will change confirmation, password recovery and other workflows + # to behave the same regardless if the e-mail provided was right or wrong. + # Does not affect registerable. + config.paranoid = false + + # By default Devise will store the user in session. You can skip storage for + # particular strategies by setting this option. + # Notice that if you are skipping storage for all authentication paths, you + # may want to disable generating routes to Devise's sessions controller by + # passing skip: :sessions to `devise_for` in your config/routes.rb + config.skip_session_storage = [:http_auth] + + # By default, Devise cleans up the CSRF token on authentication to + # avoid CSRF token fixation attacks. This means that, when using AJAX + # requests for sign in and sign up, you need to get a new CSRF token + # from the server. You can disable this option at your own risk. + # config.clean_up_csrf_token_on_authentication = true + + # When false, Devise will not attempt to reload routes on eager load. + # This can reduce the time taken to boot the app but if your application + # requires the Devise mappings to be loaded during boot time the application + # won't boot properly. + # config.reload_routes = true + + # ==> Configuration for :database_authenticatable + # For bcrypt, this is the cost for hashing the password and defaults to 12. If + # using other algorithms, it sets how many times you want the password to be hashed. + # The number of stretches used for generating the hashed password are stored + # with the hashed password. This allows you to change the stretches without + # invalidating existing passwords. + # + # Limiting the stretches to just one in testing will increase the performance of + # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use + # a value less than 10 in other environments. Note that, for bcrypt (the default + # algorithm), the cost increases exponentially with the number of stretches (e.g. + # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). + config.stretches = Rails.env.test? ? 1 : 12 + + # Set up a pepper to generate the hashed password. + # config.pepper = '48af952a136cf14f9f36ca2d0d17b30a059b75f7e0b7108ed02c56003819126dc08e537781f606a09341e1a5274cc8d59e5172841f1dd4564d64db599c06bf8c' + + # Send a notification to the original email when the user's email is changed. + config.send_email_changed_notification = false + + # Send a notification email when the user's password is changed. + config.send_password_change_notification = false + + # ==> Configuration for :confirmable + # A period that the user is allowed to access the website even without + # confirming their account. For instance, if set to 2.days, the user will be + # able to access the website for two days without confirming their account, + # access will be blocked just in the third day. + # You can also set it to nil, which will allow the user to access the website + # without confirming their account. + # Default is 0.days, meaning the user cannot access the website without + # confirming their account. + config.allow_unconfirmed_access_for = 1.day + + # A period that the user is allowed to confirm their account before their + # token becomes invalid. For example, if set to 3.days, the user can confirm + # their account within 3 days after the mail was sent, but on the fourth day + # their account can't be confirmed with the token any more. + # Default is nil, meaning there is no restriction on how long a user can take + # before confirming their account. + config.confirm_within = 3.days + + # If true, requires any email changes to be confirmed (exactly the same way as + # initial account confirmation) to be applied. Requires additional unconfirmed_email + # db field (see migrations). Until confirmed, new email is stored in + # unconfirmed_email column, and copied to email column on successful confirmation. + config.reconfirmable = true + + # Defines which key will be used when confirming an account + # config.confirmation_keys = [:email] + + # ==> Configuration for :rememberable + # The time the user will be remembered without asking for credentials again. + config.remember_for = 3.months + + # Invalidates all the remember me tokens when the user signs out. + config.expire_all_remember_me_on_sign_out = true + + # If true, extends the user's remember period when remembered via cookie. + config.extend_remember_period = true + + # Options to be passed to the created cookie. For instance, you can set + # secure: true in order to force SSL only cookies. + config.rememberable_options = {secure: true} + + # ==> Configuration for :validatable + # Range for password length. + config.password_length = 6..128 + + # Email regex used to validate email formats. It simply asserts that + # one (and only one) @ exists in the given string. This is mainly + # to give user feedback and not to assert the e-mail validity. + config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ + + # ==> Configuration for :timeoutable + # The time you want to timeout the user session without activity. After this + # time the user will be asked for credentials again. Default is 30 minutes. + # config.timeout_in = 30.minutes + + # ==> Configuration for :lockable + # Defines which strategy will be used to lock an account. + # :failed_attempts = Locks an account after a number of failed attempts to sign in. + # :none = No lock strategy. You should handle locking by yourself. + config.lock_strategy = :failed_attempts + + # Defines which key will be used when locking and unlocking an account + # config.unlock_keys = [:email] + + # Defines which strategy will be used to unlock an account. + # :email = Sends an unlock link to the user email + # :time = Re-enables login after a certain amount of time (see :unlock_in below) + # :both = Enables both strategies + # :none = No unlock strategy. You should handle unlocking by yourself. + # config.unlock_strategy = :both + + # Number of authentication tries before locking an account if lock_strategy + # is failed attempts. + config.maximum_attempts = 10 + + # Time interval to unlock the account if :time is enabled as unlock_strategy. + config.unlock_in = 1.hour + + # Warn on the last attempt before the account is locked. + config.last_attempt_warning = true + + # ==> Configuration for :recoverable + # + # Defines which key will be used when recovering the password for an account + # config.reset_password_keys = [:email] + + # Time interval you can reset your password with a reset password key. + # Don't put a too small interval or your users won't have the time to + # change their passwords. + config.reset_password_within = 6.hours + + # When set to false, does not sign a user in automatically after their password is + # reset. Defaults to true, so a user is signed in automatically after a reset. + # config.sign_in_after_reset_password = true + + # ==> Configuration for :encryptable + # Allow you to use another hashing or encryption algorithm besides bcrypt (default). + # You can use :sha1, :sha512 or algorithms from others authentication tools as + # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20 + # for default behavior) and :restful_authentication_sha1 (then you should set + # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper). + # + # Require the `devise-encryptable` gem when using anything other than bcrypt + # config.encryptor = :sha512 + + # ==> Scopes configuration + # Turn scoped views on. Before rendering "sessions/new", it will first check for + # "users/sessions/new". It's turned off by default because it's slower if you + # are using only default views. + # config.scoped_views = false + + # Configure the default scope given to Warden. By default it's the first + # devise role declared in your routes (usually :user). + # config.default_scope = :user + + # Set this configuration to false if you want /users/sign_out to sign out + # only the current scope. By default, Devise signs out all scopes. + # config.sign_out_all_scopes = true + + # ==> Navigation configuration + # Lists the formats that should be treated as navigational. Formats like + # :html should redirect to the sign in page when the user does not have + # access, but formats like :xml or :json, should return 401. + # + # If you have any extra navigational formats, like :iphone or :mobile, you + # should add them to the navigational formats lists. + # + # The "*/*" below is required to match Internet Explorer requests. + # config.navigational_formats = ['*/*', :html, :turbo_stream] + + # The default HTTP method used to sign out a resource. Default is :delete. + config.sign_out_via = :get + + # ==> OmniAuth + # Add a new OmniAuth provider. Check the wiki for more information on setting + # up on your models and hooks. + # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' + + # ==> Warden configuration + # If you want to use other strategies, that are not supported by Devise, or + # change the failure app, you can configure them inside the config.warden block. + # + # config.warden do |manager| + # manager.intercept_401 = false + # manager.default_strategies(scope: :user).unshift :some_external_strategy + # end + + # ==> Mountable engine configurations + # When using Devise inside an engine, let's call it `MyEngine`, and this engine + # is mountable, there are some extra configurations to be taken into account. + # The following options are available, assuming the engine is mounted as: + # + # mount MyEngine, at: '/my_engine' + # + # The router that invoked `devise_for`, in the example above, would be: + # config.router_name = :my_engine + # + # When using OmniAuth, Devise cannot automatically set OmniAuth path, + # so you need to do it manually. For the users scope, it would be: + # config.omniauth_path_prefix = '/my_engine/users/auth' + + # ==> Hotwire/Turbo configuration + # When using Devise with Hotwire/Turbo, the http status for error responses + # and some redirects must match the following. The default in Devise for existing + # apps is `200 OK` and `302 Found respectively`, but new apps are generated with + # these new defaults that match Hotwire/Turbo behavior. + # Note: These might become the new default in future versions of Devise. + config.responder.error_status = :unprocessable_entity + config.responder.redirect_status = :see_other + + # ==> Configuration for :registerable + + # When set to false, does not sign a user in automatically after their password is + # changed. Defaults to true, so a user is signed in automatically after changing a password. + # config.sign_in_after_change_password = true +end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 00000000..adc6568c --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be filtered from the log file. Use this to limit dissemination of +# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported +# notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn +] diff --git a/config/initializers/generators.rb b/config/initializers/generators.rb new file mode 100644 index 00000000..9dc40bc2 --- /dev/null +++ b/config/initializers/generators.rb @@ -0,0 +1,3 @@ +Rails.application.config.generators do |g| + g.orm :active_record, primary_key_type: :uuid +end \ No newline at end of file diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 00000000..3860f659 --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/initializers/pay.rb b/config/initializers/pay.rb new file mode 100644 index 00000000..7a38696c --- /dev/null +++ b/config/initializers/pay.rb @@ -0,0 +1,3 @@ +Pay.setup do |config| + config.support_email = "hello@maybe.co" +end \ No newline at end of file diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb new file mode 100644 index 00000000..7db3b957 --- /dev/null +++ b/config/initializers/permissions_policy.rb @@ -0,0 +1,13 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide HTTP permissions policy. For further +# information see: https://developers.google.com/web/updates/2018/06/feature-policy + +# Rails.application.config.permissions_policy do |policy| +# policy.camera :none +# policy.gyroscope :none +# policy.microphone :none +# policy.usb :none +# policy.fullscreen :self +# policy.payment :self, "https://secure.example.com" +# end diff --git a/config/initializers/plaid.rb b/config/initializers/plaid.rb new file mode 100644 index 00000000..e2f80d26 --- /dev/null +++ b/config/initializers/plaid.rb @@ -0,0 +1,8 @@ +require 'plaid' + +configuration = Plaid::Configuration.new +configuration.server_index = Plaid::Configuration::Environment[ENV['PLAID_ENVIRONMENT']] +configuration.api_key["PLAID-CLIENT-ID"] = ENV['PLAID_CLIENT_ID'] +configuration.api_key["PLAID-SECRET"] = ENV['PLAID_SECRET'] + +$plaid_api_client = Plaid::PlaidApi.new(Plaid::ApiClient.new(configuration)) \ No newline at end of file diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb new file mode 100644 index 00000000..9d24de9e --- /dev/null +++ b/config/initializers/sentry.rb @@ -0,0 +1,14 @@ +Sentry.init do |config| + config.dsn = 'https://1789da52e499454c8eb0b2d570a7cb56@o675109.ingest.sentry.io/4504980729364480' + config.breadcrumbs_logger = [:active_support_logger, :http_logger] + config.enabled_environments = %w[production] + + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for performance monitoring. + # We recommend adjusting this value in production. + config.traces_sample_rate = 1.0 + # or + config.traces_sampler = lambda do |context| + true + end +end \ No newline at end of file diff --git a/config/llmschema.yml b/config/llmschema.yml new file mode 100644 index 00000000..ce4588c3 --- /dev/null +++ b/config/llmschema.yml @@ -0,0 +1,230 @@ +tables: + - name: accounts + description: Accounts references the accounts within a given connection. For example, you may have both a checking account and savings account within the same connection. + columns: + - name: id + description: The unique identifier for the account. + type: uuid + primary_key: true + - name: family_id + description: The unique identifier for the family. + type: uuid + foreign_key: + table: families + column: id + - name: connection_id + description: The unique identifier for the connection. + type: uuid + foreign_key: + table: connections + column: id + - name: name + description: The nickname of the account, provided by either the user or the institution. + type: string + - name: kind + description: The high-level type of account such as "investment", "depository", "credit", "property" etc. + type: string + - name: subkind + description: The sub-type of account such as "brokerage", "checking", "savings", "ira", "roth", "real_estate", etc. It's a more specific version of the kind. + type: string + - name: mask + description: The 4-digit account number mask. + type: string + - name: official_name + description: The official name of the account as provided by the institution. + type: string + - name: current_balance + description: The most recent/current balance of the account. + type: decimal + - name: current_balance_date + description: The date of the most recent/current balance of the account. + type: date + - name: credit_limit + description: The credit limit of the account. Only used for credit cards. + type: decimal + - name: property_details + description: The address details of the account. Only used for real estate/property accounts. + type: jsonb + - name: balances + description: Balances tracks the historical balances of both accounts and holdings. This is a good table to get cost basis information. + columns: + - name: id + description: The unique identifier for the balance. + type: uuid + primary_key: true + - name: account_id + description: The unique identifier for the account. + type: uuid + foreign_key: + table: accounts + column: id + - name: family_id + description: The unique identifier for the family. + type: uuid + foreign_key: + table: families + column: id + - name: security_id + description: The unique identifier for the security. + type: uuid + foreign_key: + table: securities + column: id + - name: balance + description: The balance of the account. This is the "value" of the account/holding for that date. Should NOT be SUM'd. + type: decimal + - name: quantity + description: Only used for holdings. The quantity of the security held. + type: decimal + - name: cost_basis + description: Only used for holdings. The cost basis of the security held. It's in comparison to the balance. It is not the cost basis of a single share. + type: decimal + - name: date + description: The date of the balance. + type: date + - name: change + description: The change in balance from the previous day. Good for calculating daily gains/losses. + type: decimal + - name: kind + description: The kind of balance. Possible values are "account", "security". "Security" is for holdings/stocks. + type: string + - name: connections + description: Connections references the connections between a user and an institution. + columns: + - name: id + description: The unique identifier for the connection. + type: uuid + primary_key: true + - name: name + description: The name of the institution. + type: string + - name: family_id + description: The unique identifier for the family. + type: uuid + foreign_key: + table: families + column: id + - name: metrics + description: Metrics track the calculated or aggregated metrics for an account. + columns: + - name: id + description: The unique identifier for the metric. + type: uuid + primary_key: true + - name: kind + description: The kind of metric. Possible values are net_worth, depository_balance, investment_balance, total_assets, total_debts, property_balance (value of all real estate/property, categorical_spending, categorical_spending_monthly). + type: string + - name: amount + description: The amount of the metric. + type: decimal + - name: date + description: The date of the metric. + type: date + - name: subkind + description: The sub-kind of metric. Used to give context to the kind. For example, if the kind is "categorical_spending", the subkind may be "food". + type: string + - name: family_id + description: The unique identifier for the family/user. + type: uuid + foreign_key: + table: families + column: id + - name: securities + description: Securities are the root of any holding and may be referenced by multiple accounts. It's where all information about a security is stored. + columns: + - name: id + description: The unique identifier for the security. + type: uuid + primary_key: true + - name: symbol + description: The symbol of the security. + type: string + - name: source_type + description: The kind of security such as "stock", "mutual_fund", "bond", "etf", "equity", etc. + type: string + - name: security_prices + description: SecurityPrices track the historical prices of securities. + columns: + - name: id + description: The unique identifier for the security price. + type: uuid + primary_key: true + - name: security_id + description: The unique identifier for the security. + type: uuid + foreign_key: + table: securities + column: id + - name: open + description: The opening price of the security. + type: decimal + - name: high + description: The high price of the security. + type: decimal + - name: low + description: The low price of the security. + type: decimal + - name: close + description: The closing price of the security. + type: decimal + - name: exchange + description: The exchange the security is traded on. + type: string + - name: date + description: The date of the security price. + type: date + - name: transactions + description: Transactions track the credits and debits of a depository account, such as checkings and savings. + columns: + - name: id + description: The unique identifier for the transaction. + type: uuid + primary_key: true + - name: family_id + description: The unique identifier for the family. + type: uuid + foreign_key: + table: families + column: id + - name: account_id + description: The unique identifier for the account. + type: uuid + foreign_key: + table: accounts + column: id + - name: amount + description: The amount of the transaction. Positive values are debits and negative values are credits. + type: decimal + - name: date + description: The date the transactions occurred. + type: date + - name: name + description: Bank provided name of the transaction. + type: string + - name: currency_code + description: The ISO 4217 currency code of the transaction. + type: string + - name: is_pending + description: Whether the transaction is pending or not. + type: boolean + - name: enrichment_label_group + description: Higher level category that groups together related labels. "Earned Income", "Essential Expenses", "Non-Essential Expenses", "Other Incoming Transactions", "Other Outgoing Transactions", "Passive Income" + type: string + - name: enrichment_label + description: The label of the transaction. This is the customer-facing category. + type: string + - name: enrichment_location + description: The physical location of the transaction. + type: string + - name: enrichment_merchant_name + description: The name of the merchant or store. + type: string + - name: enrichment_merchant_website + description: The website of the merchant or store. + type: string + - name: enrichment_person + description: The name of the person associated with the transaction. + type: string + - name: enrichment_recurrence + description: Indicates whether a transaction is a one-time transfer, e.g. purchasing a mattress (one-off), regularly repeats with personalized pricing, e.g. utilities, mortgage (recurring), regularly repeats with fixed pricing (subscription). if someone asks about recurring or subscription transactions, they are asking about transactions that are not one-off and should include both in the sql. + type: string diff --git a/config/llmsql.yml b/config/llmsql.yml new file mode 100644 index 00000000..7c7ae6cb --- /dev/null +++ b/config/llmsql.yml @@ -0,0 +1,44 @@ +# Create multiple "scope" options based on user intent +core: + - scope: | + - Output SQL only with no comments. + - Take care to write proper SQL for use in a Postgres database, paying special attention to GROUP BY clauses and aggregate functions, especially around dates and ids. + - Include relevant dates, if helpful. + - Under no circumstance should you change the account_ids or family_id variables. + - 'date' columns are the most important for determining when something occurred + - Limit the output to 150 rows + - If no date is provided, assume the current date + - If something needs multiple dates (such as growth or change over time), use the current date as the end date and the 30 days prior as the start date +intent: + - name: "metrics" + scope: | + - The "metrics" table is the primary table to use. + - Possible subkind for categorical spending: "Freelance", "Paycheck", "Rideshare and delivery", "Auto lease payment", "Auto loan repayment", "Childcare", "Contribution to reserve fund", "Council tax", "Credit card bill", "Credit card fee", "Credit report", "Debt collection", "Drugstores and pharmacies", "Education", "Fuel", "Funerals and bequests", "Government", "Groceries", "Insurance", "Interest", "Loan repayment", "Medical bill", "Mortgage", "Other transport", "Pets", "Public transport", "Rent and property management fee", "Retirement contributions", "Ridesharing and taxis", "Student loan repayment", "Taxes", "Utilities", "Vehicle maintenance", "Intra account transfer", "Inter account transfer", "App stores", "ATM/bank withdrawal", "Bank fee", "Books, newsletters, newspapers", "Buy now, pay later", "Clothing", "Cafes and coffee shops", "Convenience stores", "Department and discount stores", "Donation", "Electronics", "Entertainment and recreation", "Firearms", "Food and Drink", "Gambling", "Gifts", "Sport and fitness", "Home improvements and maintenance services", "Investment", "Laundry", "Rent to own", "Legal services", "Liquor", "Hotels and lodging", "Media", "Other consumer services", "Other non-essential", "Pawn shops", "Peer to peer transfer", "Recreational goods", "SaaS tools", "Self care", "Toll charge", "Towing companies", "Trading (crypto)", "Trading (non-crypto)", "eCommerce purchase", "ATM/bank deposit", "Cashback", "Ecommerce", "Grants and stipends", "Loans", "Other", "Refund", "Reversal / adjustment", "Tax refund", "Benefits", "Interest / dividend", "Property rental" + - You may group similar subkinds/categories together if it makes sense (such as Groceries, Food, Restaurants, etc) + - For 'subkind' use ILIKE to match a string and wrap in % to match partial strings. + - name: "transactional" + scope: | + - The "transactions" table is the primary table to use. + - Possible categories: "Freelance", "Paycheck", "Rideshare and delivery", "Auto lease payment", "Auto loan repayment", "Childcare", "Contribution to reserve fund", "Council tax", "Credit card bill", "Credit card fee", "Credit report", "Debt collection", "Drugstores and pharmacies", "Education", "Fuel", "Funerals and bequests", "Government", "Groceries", "Insurance", "Interest", "Loan repayment", "Medical bill", "Mortgage", "Other transport", "Pets", "Public transport", "Rent and property management fee", "Retirement contributions", "Ridesharing and taxis", "Student loan repayment", "Taxes", "Utilities", "Vehicle maintenance", "Intra account transfer", "Inter account transfer", "App stores", "ATM/bank withdrawal", "Bank fee", "Books, newsletters, newspapers", "Buy now, pay later", "Clothing", "Cafes and coffee shops", "Convenience stores", "Department and discount stores", "Donation", "Electronics", "Entertainment and recreation", "Firearms", "Food and Drink", "Gambling", "Gifts", "Sport and fitness", "Home improvements and maintenance services", "Investment", "Laundry", "Rent to own", "Legal services", "Liquor", "Hotels and lodging", "Media", "Other consumer services", "Other non-essential", "Pawn shops", "Peer to peer transfer", "Recreational goods", "SaaS tools", "Self care", "Toll charge", "Towing companies", "Trading (crypto)", "Trading (non-crypto)", "eCommerce purchase", "ATM/bank deposit", "Cashback", "Ecommerce", "Grants and stipends", "Loans", "Other", "Refund", "Reversal / adjustment", "Tax refund", "Benefits", "Interest / dividend", "Property rental" + - For categories/enrichment_label use ILIKE to match a string and wrap in % to match partial strings. + - You may group similar categories together if it makes sense (such as Groceries, Food, Restaurants, etc) + - Include encrichment_merchant_name when possible + - name: "accounts" + scope: | + - + - name: "education" + scope: | + - + - name: "reply" + scope: | + - + - name: "investing" + scope: | + - The "balances" table is the primary table to use. + - Only do very basic math in the SQL, such as summing up the values. + - Leave complex math to future steps. Just focus on getting the parts needed to do the math. + - "Performance" refers to the performance of the account, not the performance of the investments (unless that is explicitly requested). + - "Performance" is the change in balance/value of the account between the start and end date. + - Explicitly exclude rows where "security_id" is NOT NULL unless a specific security/holding is requested. + - Do NOT SUM the balance column. + - You need to know security_id to get security_prices. diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml new file mode 100644 index 00000000..260e1c4b --- /dev/null +++ b/config/locales/devise.en.yml @@ -0,0 +1,65 @@ +# Additional translations at https://github.com/heartcombo/devise/wiki/I18n + +en: + devise: + confirmations: + confirmed: "Your email address has been successfully confirmed." + send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." + failure: + already_authenticated: "You are already signed in." + inactive: "Your account is not activated yet." + invalid: "Invalid %{authentication_keys} or password." + locked: "Your account is locked." + last_attempt: "You have one more attempt before your account is locked." + not_found_in_database: "Invalid %{authentication_keys} or password." + timeout: "Your session expired. Please sign in again to continue." + unauthenticated: "You need to sign in or sign up before continuing." + unconfirmed: "You have to confirm your email address before continuing." + mailer: + confirmation_instructions: + subject: "Confirmation instructions" + reset_password_instructions: + subject: "Reset password instructions" + unlock_instructions: + subject: "Unlock instructions" + email_changed: + subject: "Email Changed" + password_change: + subject: "Password Changed" + omniauth_callbacks: + failure: "Could not authenticate you from %{kind} because \"%{reason}\"." + success: "Successfully authenticated from %{kind} account." + passwords: + no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." + send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." + updated: "Your password has been changed successfully. You are now signed in." + updated_not_active: "Your password has been changed successfully." + registrations: + destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." + signed_up: "Welcome! You have signed up successfully." + signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." + signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." + signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." + update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." + updated: "Your account has been updated successfully." + updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." + sessions: + signed_in: "Signed in successfully." + signed_out: "Signed out successfully." + already_signed_out: "Signed out successfully." + unlocks: + send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." + send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." + unlocked: "Your account has been unlocked successfully. Please sign in to continue." + errors: + messages: + already_confirmed: "was already confirmed, please try signing in" + confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" + expired: "has expired, please request a new one" + not_found: "not found" + not_locked: "was not locked" + not_saved: + one: "1 error prohibited this %{resource} from being saved:" + other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 00000000..8ca56fc7 --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,33 @@ +# Files in the config/locales directory are used for internationalization +# and are automatically loaded by Rails. If you want to use locales other +# than English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# The following keys must be escaped otherwise they will not be retrieved by +# the default I18n backend: +# +# true, false, on, off, yes, no +# +# Instead, surround them with single quotes. +# +# en: +# "true": "foo" +# +# To learn more, please read the Rails Internationalization guide +# available at https://guides.rubyonrails.org/i18n.html. + +en: + hello: "Hello world" diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 00000000..a22a13ec --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,34 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. + +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } +min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } +threads min_threads_count, max_threads_count + +# Specifies that the worker count should equal the number of processors in production. +# if ENV["RAILS_ENV"] == "production" +# worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) +# workers worker_count if worker_count > 1 +# end + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT") { 3000 } + +# Specifies the `environment` that Puma will run in. +environment ENV.fetch("RAILS_ENV") { "development" } + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 00000000..3f38a1d4 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,66 @@ +require 'sidekiq/web' + +Rails.application.routes.draw do + mount Sidekiq::Web => "/sidekiq" + + # Routes for accounts + resources :accounts do + collection do + get 'assets' + get 'cash' + get 'investments' + get 'debts' + get 'net_worth' + get 'credit' + end + end + + resources :onboarding + + scope 'accounts/new' do + scope 'bank' do + get '', to: 'accounts#new_bank', as: 'new_bank' + get 'manual', to: 'accounts#new_bank_manual', as: 'new_bank_manual' + end + + scope 'investment' do + get '', to: 'accounts#new_investment', as: 'new_investment' + get 'position', to: 'accounts#new_investment_position', as: 'new_investment_position' + get 'balance', to: 'accounts#new_investment_balance', as: 'new_investment_balance' + get 'select_holding', to: 'accounts#select_holding', as: 'select_holding' + end + + scope 'credit' do + get '', to: 'accounts#new_credit', as: 'new_credit' + get 'manual', to: 'accounts#new_credit_manual', as: 'new_credit_manual' + end + + scope 'real-estate' do + get '', to: 'accounts#new_real_estate', as: 'new_real_estate' + end + end + + resources :connections + resources :conversations + resources :prompts + resources :families + resources :holdings + + get 'settings', to: 'pages#settings', as: 'settings' + get 'upgrade', to: 'pages#upgrade', as: 'upgrade' + get 'advisor', to: 'pages#advisor', as: 'advisor' + + devise_for :users, controllers: { registrations: 'users/registrations' } + + # Routes for api + namespace :api do + post 'plaid/exchange_public_token', to: 'plaid#exchange_public_token' + end + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # Defines the root path route ("/") + root "pages#index" +end diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 00000000..e69de29b diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 00000000..4942ab66 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,34 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/config/tailwind.config.js b/config/tailwind.config.js new file mode 100644 index 00000000..d628e79f --- /dev/null +++ b/config/tailwind.config.js @@ -0,0 +1,45 @@ +const defaultTheme = require('tailwindcss/defaultTheme') + +module.exports = { + content: [ + './public/*.html', + './app/helpers/**/*.rb', + './app/javascript/**/*.js', + './app/views/**/*.{erb,haml,html,slim}' + ], + safelist: [ + 'bg-morning', + 'bg-afternoon', + 'bg-evening', + ], + theme: { + extend: { + fontFamily: { + sans: ['Inter var', ...defaultTheme.fontFamily.sans], + display: ['GeneralSans, sans-serif'], + }, + fontSize: { + '2xs': '.625rem', + }, + colors: { + black: '#242629', + offwhite: '#F9FAFB', + white: '#fff', + }, + backgroundImage: { + 'morning': "url('morning-gradient.svg')", + 'afternoon': "url('afternoon-gradient.svg')", + 'evening': "url('evening-gradient.svg')", + }, + dropShadow: { + 'form': '0px 4px 10px rgba(52, 54, 60, 0.08)', + } + }, + }, + plugins: [ + require('@tailwindcss/forms'), + require('@tailwindcss/aspect-ratio'), + require('@tailwindcss/typography'), + require('@tailwindcss/container-queries'), + ] +} \ No newline at end of file diff --git a/db/migrate/20230403142845_enable_uuid.rb b/db/migrate/20230403142845_enable_uuid.rb new file mode 100644 index 00000000..e9c56d22 --- /dev/null +++ b/db/migrate/20230403142845_enable_uuid.rb @@ -0,0 +1,5 @@ +class EnableUuid < ActiveRecord::Migration[7.1] + def change + enable_extension 'pgcrypto' + end +end diff --git a/db/migrate/20230403144425_devise_create_users.rb b/db/migrate/20230403144425_devise_create_users.rb new file mode 100644 index 00000000..da23e827 --- /dev/null +++ b/db/migrate/20230403144425_devise_create_users.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class DeviseCreateUsers < ActiveRecord::Migration[7.1] + def change + create_table :users, id: :uuid do |t| + ## Database authenticatable + t.string :email, null: false, default: "" + t.string :encrypted_password, null: false, default: "" + + ## Recoverable + t.string :reset_password_token + t.datetime :reset_password_sent_at + + ## Rememberable + t.datetime :remember_created_at + + ## Trackable + t.integer :sign_in_count, default: 0, null: false + t.datetime :current_sign_in_at + t.datetime :last_sign_in_at + t.string :current_sign_in_ip + t.string :last_sign_in_ip + + ## Confirmable + t.string :confirmation_token + t.datetime :confirmed_at + t.datetime :confirmation_sent_at + t.string :unconfirmed_email # Only if using reconfirmable + + ## Lockable + t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts + t.string :unlock_token # Only if unlock strategy is :email or :both + t.datetime :locked_at + + + t.timestamps null: false + end + + add_index :users, :email, unique: true + add_index :users, :reset_password_token, unique: true + # add_index :users, :confirmation_token, unique: true + # add_index :users, :unlock_token, unique: true + end +end diff --git a/db/migrate/20230403144751_create_families.rb b/db/migrate/20230403144751_create_families.rb new file mode 100644 index 00000000..c017c181 --- /dev/null +++ b/db/migrate/20230403144751_create_families.rb @@ -0,0 +1,8 @@ +class CreateFamilies < ActiveRecord::Migration[7.1] + def change + create_table :families, id: :uuid do |t| + t.string :name, null: true + t.timestamps + end + end +end diff --git a/db/migrate/20230403163745_add_family_to_users.rb b/db/migrate/20230403163745_add_family_to_users.rb new file mode 100644 index 00000000..d1fb25c0 --- /dev/null +++ b/db/migrate/20230403163745_add_family_to_users.rb @@ -0,0 +1,5 @@ +class AddFamilyToUsers < ActiveRecord::Migration[7.1] + def change + add_reference :users, :family, null: false, foreign_key: true, type: :uuid + end +end diff --git a/db/migrate/20230403183541_create_connections.rb b/db/migrate/20230403183541_create_connections.rb new file mode 100644 index 00000000..025042c2 --- /dev/null +++ b/db/migrate/20230403183541_create_connections.rb @@ -0,0 +1,21 @@ +class CreateConnections < ActiveRecord::Migration[7.1] + def change + create_table :connections, id: :uuid do |t| + t.string :name + t.integer :source, default: 0 + t.references :user, null: false, foreign_key: true, type: :uuid + t.integer :status, default: 0 + t.integer :sync_status, default: 0 + t.jsonb :error + t.boolean :new_accounts_available, default: false + t.datetime :consent_expiration + t.string :aggregator_id + t.string :item_id + t.string :access_token + t.string :cursor + t.datetime :investments_last_synced_at + + t.timestamps + end + end +end diff --git a/db/migrate/20230403183853_add_plaid_link_token_to_user.rb b/db/migrate/20230403183853_add_plaid_link_token_to_user.rb new file mode 100644 index 00000000..de36ba27 --- /dev/null +++ b/db/migrate/20230403183853_add_plaid_link_token_to_user.rb @@ -0,0 +1,6 @@ +class AddPlaidLinkTokenToUser < ActiveRecord::Migration[7.1] + def change + add_column :users, :plaid_link_token, :string + add_column :users, :plaid_link_token_expires_at, :datetime + end +end diff --git a/db/migrate/20230403184330_create_accounts.rb b/db/migrate/20230403184330_create_accounts.rb new file mode 100644 index 00000000..a8fe2629 --- /dev/null +++ b/db/migrate/20230403184330_create_accounts.rb @@ -0,0 +1,21 @@ +class CreateAccounts < ActiveRecord::Migration[7.1] + def change + create_table :accounts, id: :uuid do |t| + t.string :name + t.string :source_id + t.boolean :is_active, default: true + t.string :type + t.string :subtype + t.references :connection, null: false, foreign_key: true, type: :uuid + t.decimal :available_balance, precision: 19, scale: 4 + t.decimal :current_balance, precision: 19, scale: 4 + t.string :currency_code + t.integer :sync_status, default: 0 + t.string :mask + t.integer :source + t.date :current_balance_date + + t.timestamps + end + end +end diff --git a/db/migrate/20230403190242_create_transactions.rb b/db/migrate/20230403190242_create_transactions.rb new file mode 100644 index 00000000..8b7a877d --- /dev/null +++ b/db/migrate/20230403190242_create_transactions.rb @@ -0,0 +1,24 @@ +class CreateTransactions < ActiveRecord::Migration[7.1] + def change + create_table :transactions, id: :uuid do |t| + t.string :name + t.decimal :amount, precision: 19, scale: 2 + t.boolean :is_pending, default: false + t.date :date + t.references :account, null: false, foreign_key: true, type: :uuid + t.string :currency_code + t.string :source_transaction_id + t.string :source_category_id + t.string :source_type + t.jsonb :categories + t.string :merchant_name + t.integer :flow, default: 0 + t.boolean :excluded, default: false + t.string :payment_channel + t.jsonb :enrichment, default: {} + t.datetime :enriched_at + + t.timestamps + end + end +end diff --git a/db/migrate/20230403190736_add_official_name_to_accounts.rb b/db/migrate/20230403190736_add_official_name_to_accounts.rb new file mode 100644 index 00000000..17c3fc4f --- /dev/null +++ b/db/migrate/20230403190736_add_official_name_to_accounts.rb @@ -0,0 +1,5 @@ +class AddOfficialNameToAccounts < ActiveRecord::Migration[7.1] + def change + add_column :accounts, :official_name, :string + end +end diff --git a/db/migrate/20230403190831_rename_account_type.rb b/db/migrate/20230403190831_rename_account_type.rb new file mode 100644 index 00000000..bbd9f0d2 --- /dev/null +++ b/db/migrate/20230403190831_rename_account_type.rb @@ -0,0 +1,6 @@ +class RenameAccountType < ActiveRecord::Migration[7.1] + def change + rename_column :accounts, :type, :kind + rename_column :accounts, :subtype, :subkind + end +end diff --git a/db/migrate/20230403191012_add_indexes_to_transactions.rb b/db/migrate/20230403191012_add_indexes_to_transactions.rb new file mode 100644 index 00000000..50a4e6e8 --- /dev/null +++ b/db/migrate/20230403191012_add_indexes_to_transactions.rb @@ -0,0 +1,5 @@ +class AddIndexesToTransactions < ActiveRecord::Migration[7.1] + def change + add_index :transactions, :source_transaction_id, unique: true + end +end diff --git a/db/migrate/20230403195103_create_securities.rb b/db/migrate/20230403195103_create_securities.rb new file mode 100644 index 00000000..0d83d5bb --- /dev/null +++ b/db/migrate/20230403195103_create_securities.rb @@ -0,0 +1,21 @@ +class CreateSecurities < ActiveRecord::Migration[7.1] + def change + create_table :securities, id: :uuid do |t| + t.string :name + t.string :symbol + t.string :cusip + t.string :isin + t.string :currency_code + t.string :source, null: false + t.string :source_id + t.string :source_type + t.decimal :shares_per_contract, precision: 36, scale: 19 + t.boolean :is_cash_equivalent, default: false + + t.timestamps + end + + add_index :securities, :source_id, unique: true + add_index :securities, [:source, :source_id], unique: true + end +end diff --git a/db/migrate/20230403195224_create_holdings.rb b/db/migrate/20230403195224_create_holdings.rb new file mode 100644 index 00000000..ce970f41 --- /dev/null +++ b/db/migrate/20230403195224_create_holdings.rb @@ -0,0 +1,19 @@ +class CreateHoldings < ActiveRecord::Migration[7.1] + def change + create_table :holdings, id: :uuid do |t| + t.references :account, null: false, foreign_key: true, type: :uuid + t.references :security, null: false, foreign_key: true, type: :uuid + t.decimal :value, precision: 19, scale: 4 + t.decimal :quantity, precision: 36, scale: 18 + t.decimal :cost_basis_source, precision: 23, scale: 8 + t.string :currency_code + t.string :source_id + t.boolean :excluded, default: false + t.string :source, null: false + + t.timestamps + end + + add_index :holdings, [:account_id, :security_id], unique: true + end +end diff --git a/db/migrate/20230403195350_create_investment_transactions.rb b/db/migrate/20230403195350_create_investment_transactions.rb new file mode 100644 index 00000000..53f1342d --- /dev/null +++ b/db/migrate/20230403195350_create_investment_transactions.rb @@ -0,0 +1,23 @@ +class CreateInvestmentTransactions < ActiveRecord::Migration[7.1] + def change + create_table :investment_transactions, id: :uuid do |t| + t.references :account, null: false, foreign_key: true, type: :uuid + t.references :security, null: false, foreign_key: true, type: :uuid + t.date :date + t.string :name + t.decimal :amount, precision: 19, scale: 4 + t.decimal :quantity, precision: 36, scale: 18 + t.decimal :price, precision: 23, scale: 8 + t.string :currency_code + t.string :source_transaction_id + t.string :source_type + t.string :source_subtype + t.decimal :fees, precision: 19, scale: 4 + t.string :category + + t.timestamps + end + + add_index :investment_transactions, :source_transaction_id, unique: true + end +end diff --git a/db/migrate/20230403203112_create_change_logs.rb b/db/migrate/20230403203112_create_change_logs.rb new file mode 100644 index 00000000..20fe58b7 --- /dev/null +++ b/db/migrate/20230403203112_create_change_logs.rb @@ -0,0 +1,13 @@ +class CreateChangeLogs < ActiveRecord::Migration[7.1] + def change + create_table :change_logs, id: :uuid do |t| + t.string :record_type + t.uuid :record_id + t.string :attribute_name + t.decimal :old_value, precision: 36, scale: 18 + t.decimal :new_value, precision: 36, scale: 18 + + t.timestamps + end + end +end diff --git a/db/migrate/20230403204827_create_metrics.rb b/db/migrate/20230403204827_create_metrics.rb new file mode 100644 index 00000000..180fd327 --- /dev/null +++ b/db/migrate/20230403204827_create_metrics.rb @@ -0,0 +1,12 @@ +class CreateMetrics < ActiveRecord::Migration[7.1] + def change + create_table :metrics, id: :uuid do |t| + t.string :kind, null: false + t.decimal :amount, precision: 19, scale: 2 + t.references :user, null: false, foreign_key: true, type: :uuid + t.date :date, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20230404143258_create_security_prices.rb b/db/migrate/20230404143258_create_security_prices.rb new file mode 100644 index 00000000..24982954 --- /dev/null +++ b/db/migrate/20230404143258_create_security_prices.rb @@ -0,0 +1,19 @@ +class CreateSecurityPrices < ActiveRecord::Migration[7.1] + def change + create_table :security_prices, id: :uuid do |t| + t.references :security, null: false, foreign_key: true, type: :uuid + t.date :date, null: false + t.decimal :open, precision: 20, scale: 11 + t.decimal :high, precision: 20, scale: 11 + t.decimal :low, precision: 20, scale: 11 + t.decimal :close, precision: 20, scale: 11 + t.string :currency, default: 'USD' + t.string :exchange + t.string :kind + + t.timestamps + end + + add_index :security_prices, [:security_id, :date], unique: true + end +end diff --git a/db/migrate/20230404150207_add_prices_last_updated_at_to_securities.rb b/db/migrate/20230404150207_add_prices_last_updated_at_to_securities.rb new file mode 100644 index 00000000..2e319d12 --- /dev/null +++ b/db/migrate/20230404150207_add_prices_last_updated_at_to_securities.rb @@ -0,0 +1,5 @@ +class AddPricesLastUpdatedAtToSecurities < ActiveRecord::Migration[7.1] + def change + add_column :securities, :last_synced_at, :datetime, default: nil + end +end diff --git a/db/migrate/20230404153525_add_real_time_to_securities.rb b/db/migrate/20230404153525_add_real_time_to_securities.rb new file mode 100644 index 00000000..f531b5c8 --- /dev/null +++ b/db/migrate/20230404153525_add_real_time_to_securities.rb @@ -0,0 +1,6 @@ +class AddRealTimeToSecurities < ActiveRecord::Migration[7.1] + def change + add_column :securities, :real_time_price, :decimal, precision: 10, scale: 2 + add_column :securities, :real_time_price_updated_at, :datetime, default: nil + end +end diff --git a/db/migrate/20230405163403_create_conversations.rb b/db/migrate/20230405163403_create_conversations.rb new file mode 100644 index 00000000..d8efd495 --- /dev/null +++ b/db/migrate/20230405163403_create_conversations.rb @@ -0,0 +1,15 @@ +class CreateConversations < ActiveRecord::Migration[7.1] + def change + create_table :conversations, id: :uuid do |t| + t.string :title + t.text :summary + t.references :user, null: false, foreign_key: true, type: :uuid + t.string :status + t.string :role + t.string :kind + t.string :subkind + + t.timestamps + end + end +end diff --git a/db/migrate/20230405163427_create_messages.rb b/db/migrate/20230405163427_create_messages.rb new file mode 100644 index 00000000..3f6fbc0f --- /dev/null +++ b/db/migrate/20230405163427_create_messages.rb @@ -0,0 +1,12 @@ +class CreateMessages < ActiveRecord::Migration[7.1] + def change + create_table :messages, id: :uuid do |t| + t.references :conversation, null: false, foreign_key: true, type: :uuid + t.references :user, null: true, foreign_key: true, type: :uuid + t.string :role + t.text :content + + t.timestamps + end + end +end diff --git a/db/migrate/20230406161422_create_balances.rb b/db/migrate/20230406161422_create_balances.rb new file mode 100644 index 00000000..3a42c47b --- /dev/null +++ b/db/migrate/20230406161422_create_balances.rb @@ -0,0 +1,16 @@ +class CreateBalances < ActiveRecord::Migration[7.1] + def change + create_table :balances, id: :uuid do |t| + t.references :account, null: false, foreign_key: true, type: :uuid + t.references :security, null: true, foreign_key: true, type: :uuid + t.decimal :balance, precision: 23, scale: 8 + t.decimal :quantity, precision: 36, scale: 18 + t.decimal :cost_basis, precision: 23, scale: 8 + t.date :date, null: false + + t.timestamps + end + + add_index :balances, [:account_id, :security_id, :date], unique: true, name: 'index_balances_on_account_id_and_security_id_and_date' + end +end diff --git a/db/migrate/20230406172009_add_enrichment_details_to_transactions.rb b/db/migrate/20230406172009_add_enrichment_details_to_transactions.rb new file mode 100644 index 00000000..f81939ba --- /dev/null +++ b/db/migrate/20230406172009_add_enrichment_details_to_transactions.rb @@ -0,0 +1,17 @@ +class AddEnrichmentDetailsToTransactions < ActiveRecord::Migration[7.1] + def change + add_column :transactions, :enrichment_country, :string + add_column :transactions, :enrichment_intermediaries, :jsonb, default: {} + add_column :transactions, :enrichment_label_group, :string + add_column :transactions, :enrichment_label, :string + add_column :transactions, :enrichment_location, :string + add_column :transactions, :enrichment_logo, :string + add_column :transactions, :enrichment_mcc, :integer + add_column :transactions, :enrichment_merchant_name, :string + add_column :transactions, :enrichment_merchant_id, :string + add_column :transactions, :enrichment_merchant_website, :string + add_column :transactions, :enrichment_person, :string + add_column :transactions, :enrichment_recurrence, :string + add_column :transactions, :enrichment_recurrence_group, :jsonb, default: {} + end +end diff --git a/db/migrate/20230406192506_create_prompts.rb b/db/migrate/20230406192506_create_prompts.rb new file mode 100644 index 00000000..53c3f12f --- /dev/null +++ b/db/migrate/20230406192506_create_prompts.rb @@ -0,0 +1,10 @@ +class CreatePrompts < ActiveRecord::Migration[7.1] + def change + create_table :prompts, id: :uuid do |t| + t.string :content + t.string :categories, array: true, default: [] + + t.timestamps + end + end +end diff --git a/db/migrate/20230409001334_add_hidden_to_messages.rb b/db/migrate/20230409001334_add_hidden_to_messages.rb new file mode 100644 index 00000000..3785ebf1 --- /dev/null +++ b/db/migrate/20230409001334_add_hidden_to_messages.rb @@ -0,0 +1,5 @@ +class AddHiddenToMessages < ActiveRecord::Migration[7.1] + def change + add_column :messages, :hidden, :boolean, default: false + end +end diff --git a/db/migrate/20230410142620_add_log_to_messages.rb b/db/migrate/20230410142620_add_log_to_messages.rb new file mode 100644 index 00000000..bb7904b5 --- /dev/null +++ b/db/migrate/20230410142620_add_log_to_messages.rb @@ -0,0 +1,5 @@ +class AddLogToMessages < ActiveRecord::Migration[7.1] + def change + add_column :messages, :log, :text + end +end diff --git a/db/migrate/20230410144655_create_institutions.rb b/db/migrate/20230410144655_create_institutions.rb new file mode 100644 index 00000000..618c2684 --- /dev/null +++ b/db/migrate/20230410144655_create_institutions.rb @@ -0,0 +1,16 @@ +class CreateInstitutions < ActiveRecord::Migration[7.1] + def change + create_table :institutions, id: :uuid do |t| + t.string :name + t.text :logo + t.string :color + t.string :url + t.string :provider + t.string :provider_id + + t.timestamps + end + + add_index :institutions, :provider_id, unique: true + end +end diff --git a/db/migrate/20230411223153_add_demographics_to_family.rb b/db/migrate/20230411223153_add_demographics_to_family.rb new file mode 100644 index 00000000..41b25189 --- /dev/null +++ b/db/migrate/20230411223153_add_demographics_to_family.rb @@ -0,0 +1,5 @@ +class AddDemographicsToFamily < ActiveRecord::Migration[7.1] + def change + add_column :families, :demographics, :jsonb, default: {} + end +end diff --git a/db/migrate/20230412155030_create_pay_tables.pay.rb b/db/migrate/20230412155030_create_pay_tables.pay.rb new file mode 100644 index 00000000..247420b7 --- /dev/null +++ b/db/migrate/20230412155030_create_pay_tables.pay.rb @@ -0,0 +1,81 @@ +# This migration comes from pay (originally 1) +class CreatePayTables < ActiveRecord::Migration[6.0] + def change + create_table :pay_customers, id: :uuid do |t| + t.belongs_to :owner, polymorphic: true, index: false, type: :uuid + t.string :processor, null: false + t.string :processor_id + t.boolean :default + t.public_send Pay::Adapter.json_column_type, :data + t.datetime :deleted_at + t.timestamps + end + add_index :pay_customers, [:owner_type, :owner_id, :deleted_at, :default], name: :pay_customer_owner_index + add_index :pay_customers, [:processor, :processor_id], unique: true + + create_table :pay_merchants, id: :uuid do |t| + t.belongs_to :owner, polymorphic: true, index: false, type: :uuid + t.string :processor, null: false + t.string :processor_id + t.boolean :default + t.public_send Pay::Adapter.json_column_type, :data + t.timestamps + end + add_index :pay_merchants, [:owner_type, :owner_id, :processor] + + create_table :pay_payment_methods, id: :uuid do |t| + t.belongs_to :customer, foreign_key: {to_table: :pay_customers}, null: false, index: false, type: :uuid + t.string :processor_id, null: false + t.boolean :default + t.string :type + t.public_send Pay::Adapter.json_column_type, :data + t.timestamps + end + add_index :pay_payment_methods, [:customer_id, :processor_id], unique: true + + create_table :pay_subscriptions, id: :uuid do |t| + t.belongs_to :customer, foreign_key: {to_table: :pay_customers}, null: false, index: false, type: :uuid + t.string :name, null: false + t.string :processor_id, null: false + t.string :processor_plan, null: false + t.integer :quantity, default: 1, null: false + t.string :status, null: false + t.datetime :current_period_start + t.datetime :current_period_end + t.datetime :trial_ends_at + t.datetime :ends_at + t.boolean :metered + t.string :pause_behavior + t.datetime :pause_starts_at + t.datetime :pause_resumes_at + t.decimal :application_fee_percent, precision: 8, scale: 2 + t.public_send Pay::Adapter.json_column_type, :metadata + t.public_send Pay::Adapter.json_column_type, :data + t.timestamps + end + add_index :pay_subscriptions, [:customer_id, :processor_id], unique: true + add_index :pay_subscriptions, [:metered] + add_index :pay_subscriptions, [:pause_starts_at] + + create_table :pay_charges, id: :uuid do |t| + t.belongs_to :customer, foreign_key: {to_table: :pay_customers}, null: false, index: false, type: :uuid + t.belongs_to :subscription, foreign_key: {to_table: :pay_subscriptions}, null: true, type: :uuid + t.string :processor_id, null: false + t.integer :amount, null: false + t.string :currency + t.integer :application_fee_amount + t.integer :amount_refunded + t.public_send Pay::Adapter.json_column_type, :metadata + t.public_send Pay::Adapter.json_column_type, :data + t.timestamps + end + add_index :pay_charges, [:customer_id, :processor_id], unique: true + + create_table :pay_webhooks, id: :uuid do |t| + t.string :processor + t.string :event_type + t.public_send Pay::Adapter.json_column_type, :event + t.timestamps + end + end +end diff --git a/db/migrate/20230413184709_add_details_to_securities.rb b/db/migrate/20230413184709_add_details_to_securities.rb new file mode 100644 index 00000000..0de56482 --- /dev/null +++ b/db/migrate/20230413184709_add_details_to_securities.rb @@ -0,0 +1,9 @@ +class AddDetailsToSecurities < ActiveRecord::Migration[7.1] + def change + add_column :securities, :logo, :string + add_column :securities, :logo_source, :string + add_column :securities, :sector, :string + add_column :securities, :industry, :string + add_column :securities, :website, :string + end +end diff --git a/db/migrate/20230414134419_add_logo_bits_to_securities.rb b/db/migrate/20230414134419_add_logo_bits_to_securities.rb new file mode 100644 index 00000000..ea706500 --- /dev/null +++ b/db/migrate/20230414134419_add_logo_bits_to_securities.rb @@ -0,0 +1,6 @@ +class AddLogoBitsToSecurities < ActiveRecord::Migration[7.1] + def change + add_column :securities, :logo_svg, :text + add_column :securities, :logo_colors, :jsonb, default: [] + end +end diff --git a/db/migrate/20230420135327_add_daily_change_to_balances.rb b/db/migrate/20230420135327_add_daily_change_to_balances.rb new file mode 100644 index 00000000..a67b7e0c --- /dev/null +++ b/db/migrate/20230420135327_add_daily_change_to_balances.rb @@ -0,0 +1,5 @@ +class AddDailyChangeToBalances < ActiveRecord::Migration[7.1] + def change + add_column :balances, :change, :decimal, precision: 23, scale: 8, default: 0 + end +end diff --git a/db/migrate/20230420153137_add_limit_to_accounts.rb b/db/migrate/20230420153137_add_limit_to_accounts.rb new file mode 100644 index 00000000..4d4c133d --- /dev/null +++ b/db/migrate/20230420153137_add_limit_to_accounts.rb @@ -0,0 +1,5 @@ +class AddLimitToAccounts < ActiveRecord::Migration[7.1] + def change + add_column :accounts, :credit_limit, :decimal, precision: 10, scale: 2 + end +end diff --git a/db/migrate/20230420155825_add_plaid_products_to_connections.rb b/db/migrate/20230420155825_add_plaid_products_to_connections.rb new file mode 100644 index 00000000..63efd13d --- /dev/null +++ b/db/migrate/20230420155825_add_plaid_products_to_connections.rb @@ -0,0 +1,5 @@ +class AddPlaidProductsToConnections < ActiveRecord::Migration[7.1] + def change + add_column :connections, :plaid_products, :string, array: true, default: [] + end +end diff --git a/db/migrate/20230420180312_add_property_details_to_accounts.rb b/db/migrate/20230420180312_add_property_details_to_accounts.rb new file mode 100644 index 00000000..2d47f0b2 --- /dev/null +++ b/db/migrate/20230420180312_add_property_details_to_accounts.rb @@ -0,0 +1,5 @@ +class AddPropertyDetailsToAccounts < ActiveRecord::Migration[7.1] + def change + add_column :accounts, :property_details, :jsonb, default: {} + end +end diff --git a/db/migrate/20230420194311_add_auto_value_to_accounts.rb b/db/migrate/20230420194311_add_auto_value_to_accounts.rb new file mode 100644 index 00000000..497d2811 --- /dev/null +++ b/db/migrate/20230420194311_add_auto_value_to_accounts.rb @@ -0,0 +1,5 @@ +class AddAutoValueToAccounts < ActiveRecord::Migration[7.1] + def change + add_column :accounts, :auto_valuation, :boolean, default: false + end +end diff --git a/db/migrate/20230420202618_add_uniq_index_on_metrics.rb b/db/migrate/20230420202618_add_uniq_index_on_metrics.rb new file mode 100644 index 00000000..7d71d3f1 --- /dev/null +++ b/db/migrate/20230420202618_add_uniq_index_on_metrics.rb @@ -0,0 +1,14 @@ +class AddUniqIndexOnMetrics < ActiveRecord::Migration[7.1] + def change + # Find duplicate metrics + duplicate_metrics = Metric.group(:kind, :user_id, :date).having("count(*) > 1").count + + # Remove duplicate metrics + duplicate_metrics.each do |(kind, user_id, date), count| + Metric.where(kind: kind, user_id: user_id, date: date).order(created_at: :desc).offset(1).destroy_all + end + + # This index is needed to avoid duplicate metrics + add_index :metrics, [:kind, :user_id, :date], unique: true + end +end diff --git a/db/migrate/20230421165705_add_kind_to_balances.rb b/db/migrate/20230421165705_add_kind_to_balances.rb new file mode 100644 index 00000000..9f11da5d --- /dev/null +++ b/db/migrate/20230421165705_add_kind_to_balances.rb @@ -0,0 +1,9 @@ +class AddKindToBalances < ActiveRecord::Migration[7.1] + def change + add_column :balances, :kind, :string + + # Set kind based on presence of security_id + Balance.where(security_id: nil).update_all(kind: "account") + Balance.where.not(security_id: nil).update_all(kind: "security") + end +end diff --git a/db/migrate/20230424135903_add_status_to_messages.rb b/db/migrate/20230424135903_add_status_to_messages.rb new file mode 100644 index 00000000..b877b695 --- /dev/null +++ b/db/migrate/20230424135903_add_status_to_messages.rb @@ -0,0 +1,5 @@ +class AddStatusToMessages < ActiveRecord::Migration[7.1] + def change + add_column :messages, :status, :string, default: 'pending' + end +end diff --git a/db/migrate/20230424163137_add_family_to_metrics.rb b/db/migrate/20230424163137_add_family_to_metrics.rb new file mode 100644 index 00000000..0b0d2117 --- /dev/null +++ b/db/migrate/20230424163137_add_family_to_metrics.rb @@ -0,0 +1,16 @@ +class AddFamilyToMetrics < ActiveRecord::Migration[7.1] + def change + # Add reference to family, uuid + add_reference :metrics, :family, foreign_key: true, type: :uuid + + # Make user_id nullable and not required + change_column_null :metrics, :user_id, true + + # Migrate existing metrics to family + User.all.each do |user| + family = user.family + + user.metrics.update_all(family_id: family.id) + end + end +end diff --git a/db/migrate/20230424163504_add_subkind_to_metrics.rb b/db/migrate/20230424163504_add_subkind_to_metrics.rb new file mode 100644 index 00000000..db9b5215 --- /dev/null +++ b/db/migrate/20230424163504_add_subkind_to_metrics.rb @@ -0,0 +1,6 @@ +class AddSubkindToMetrics < ActiveRecord::Migration[7.1] + def change + # Add subkind to metrics + add_column :metrics, :subkind, :string + end +end diff --git a/db/migrate/20230424172744_add_family_to_accounts.rb b/db/migrate/20230424172744_add_family_to_accounts.rb new file mode 100644 index 00000000..a1344f35 --- /dev/null +++ b/db/migrate/20230424172744_add_family_to_accounts.rb @@ -0,0 +1,13 @@ +class AddFamilyToAccounts < ActiveRecord::Migration[7.1] + def change + # Add reference to family, uuid + add_reference :accounts, :family, foreign_key: true, type: :uuid + + # Migrate existing accounts to family + User.all.each do |user| + family = user.family + + user.accounts.update_all(family_id: family.id) + end + end +end diff --git a/db/migrate/20230424173230_add_family_to_connections.rb b/db/migrate/20230424173230_add_family_to_connections.rb new file mode 100644 index 00000000..bea76e61 --- /dev/null +++ b/db/migrate/20230424173230_add_family_to_connections.rb @@ -0,0 +1,13 @@ +class AddFamilyToConnections < ActiveRecord::Migration[7.1] + def change + # Add reference to family, uuid + add_reference :connections, :family, foreign_key: true, type: :uuid + + # Migrate existing connections to family + User.all.each do |user| + family = user.family + + user.connections.update_all(family_id: family.id) + end + end +end diff --git a/db/migrate/20230424174313_add_family_to_holdings.rb b/db/migrate/20230424174313_add_family_to_holdings.rb new file mode 100644 index 00000000..4ec3168b --- /dev/null +++ b/db/migrate/20230424174313_add_family_to_holdings.rb @@ -0,0 +1,13 @@ +class AddFamilyToHoldings < ActiveRecord::Migration[7.1] + def change + # Add reference to family, uuid + add_reference :holdings, :family, foreign_key: true, type: :uuid + + # Migrate existing holdings to family + Account.all.each do |account| + family = account.family + + account.holdings.update_all(family_id: family.id) + end + end +end diff --git a/db/migrate/20230424174608_add_family_to_transactions.rb b/db/migrate/20230424174608_add_family_to_transactions.rb new file mode 100644 index 00000000..74d53f41 --- /dev/null +++ b/db/migrate/20230424174608_add_family_to_transactions.rb @@ -0,0 +1,13 @@ +class AddFamilyToTransactions < ActiveRecord::Migration[7.1] + def change + # Add reference to family, uuid + add_reference :transactions, :family, foreign_key: true, type: :uuid + + # Migrate existing holdings to family + Account.all.each do |account| + family = account.family + + account.transactions.update_all(family_id: family.id) + end + end +end diff --git a/db/migrate/20230425162414_add_unique_keys_for_family.rb b/db/migrate/20230425162414_add_unique_keys_for_family.rb new file mode 100644 index 00000000..47a82558 --- /dev/null +++ b/db/migrate/20230425162414_add_unique_keys_for_family.rb @@ -0,0 +1,13 @@ +class AddUniqueKeysForFamily < ActiveRecord::Migration[7.1] + def change + # Remove duplicate metrics for kind, family_id, date + Metric.select(:kind, :subkind, :family_id, :date).group(:kind, :subkind, :family_id, :date).having('count(*) > 1').each do |metric| + metric_ids = Metric.where(kind: metric.kind, subkind: metric.subkind, family_id: metric.family_id, date: metric.date).pluck(:id) + metric_ids.shift + Metric.where(id: metric_ids).delete_all + end + + # Add unique key for metrics on kind, family_id, date + add_index :metrics, [:kind, :subkind, :family_id, :date], unique: true + end +end diff --git a/db/migrate/20230426141050_add_name_to_users.rb b/db/migrate/20230426141050_add_name_to_users.rb new file mode 100644 index 00000000..cf7e96e8 --- /dev/null +++ b/db/migrate/20230426141050_add_name_to_users.rb @@ -0,0 +1,6 @@ +class AddNameToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :first_name, :string + add_column :users, :last_name, :string + end +end diff --git a/db/migrate/20230426152640_add_birthday_to_users.rb b/db/migrate/20230426152640_add_birthday_to_users.rb new file mode 100644 index 00000000..1a25fdc2 --- /dev/null +++ b/db/migrate/20230426152640_add_birthday_to_users.rb @@ -0,0 +1,5 @@ +class AddBirthdayToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :birthday, :date + end +end diff --git a/db/migrate/20230426153241_add_locations_to_family.rb b/db/migrate/20230426153241_add_locations_to_family.rb new file mode 100644 index 00000000..59c479d4 --- /dev/null +++ b/db/migrate/20230426153241_add_locations_to_family.rb @@ -0,0 +1,6 @@ +class AddLocationsToFamily < ActiveRecord::Migration[7.1] + def change + add_column :families, :country, :string + add_column :families, :region, :string + end +end diff --git a/db/migrate/20230426164251_add_currency_to_families.rb b/db/migrate/20230426164251_add_currency_to_families.rb new file mode 100644 index 00000000..1280e653 --- /dev/null +++ b/db/migrate/20230426164251_add_currency_to_families.rb @@ -0,0 +1,5 @@ +class AddCurrencyToFamilies < ActiveRecord::Migration[7.1] + def change + add_column :families, :currency, :string, default: 'USD' + end +end diff --git a/db/migrate/20230426165640_add_family_fields.rb b/db/migrate/20230426165640_add_family_fields.rb new file mode 100644 index 00000000..8b2a25f9 --- /dev/null +++ b/db/migrate/20230426165640_add_family_fields.rb @@ -0,0 +1,10 @@ +class AddFamilyFields < ActiveRecord::Migration[7.1] + def change + #:household, :risk, :goals, :recap, :notifications, :agreements + + add_column :families, :household, :string + add_column :families, :risk, :string + add_column :families, :goals, :text + + end +end diff --git a/db/migrate/20230426182351_add_agreed_to_families.rb b/db/migrate/20230426182351_add_agreed_to_families.rb new file mode 100644 index 00000000..bc7b41cb --- /dev/null +++ b/db/migrate/20230426182351_add_agreed_to_families.rb @@ -0,0 +1,7 @@ +class AddAgreedToFamilies < ActiveRecord::Migration[7.1] + def change + add_column :families, :agreed, :boolean, default: false + add_column :families, :agreed_at, :datetime + add_column :families, :agreements, :jsonb, default: {} + end +end diff --git a/db/migrate/20230501160728_add_unique_index_for_metrics_again.rb b/db/migrate/20230501160728_add_unique_index_for_metrics_again.rb new file mode 100644 index 00000000..73aae5f1 --- /dev/null +++ b/db/migrate/20230501160728_add_unique_index_for_metrics_again.rb @@ -0,0 +1,15 @@ +class AddUniqueIndexForMetricsAgain < ActiveRecord::Migration[7.1] + def change + # Remove duplicate metrics for kind, family_id, date + Metric.select(:kind, :subkind, :family_id, :date).group(:kind, :subkind, :family_id, :date).having('count(*) > 1').each do |metric| + metric_ids = Metric.where(kind: metric.kind, subkind: metric.subkind, family_id: metric.family_id, date: metric.date).pluck(:id) + metric_ids.shift + Metric.where(id: metric_ids).delete_all + end + + remove_index :metrics, [:kind, :subkind, :family_id, :date] + + add_index :metrics, [:kind, :subkind, :family_id, :date], unique: true, where: 'subkind IS NOT NULL' + add_index :metrics, [:kind, :family_id, :date], unique: true, where: 'subkind IS NULL' + end +end diff --git a/db/migrate/20230501163248_add_family_id_to_balances.rb b/db/migrate/20230501163248_add_family_id_to_balances.rb new file mode 100644 index 00000000..961447e4 --- /dev/null +++ b/db/migrate/20230501163248_add_family_id_to_balances.rb @@ -0,0 +1,11 @@ +class AddFamilyIdToBalances < ActiveRecord::Migration[7.1] + def change + add_reference :balances, :family, foreign_key: true, type: :uuid + + #add_index :balances, :family_id, name: 'index_balances_on_family_id' + + Account.all.each do |account| + account.balances.update_all(family_id: account.family_id) + end + end +end diff --git a/db/migrate/20230501164317_add_unique_index_for_balances.rb b/db/migrate/20230501164317_add_unique_index_for_balances.rb new file mode 100644 index 00000000..9b5b483d --- /dev/null +++ b/db/migrate/20230501164317_add_unique_index_for_balances.rb @@ -0,0 +1,5 @@ +class AddUniqueIndexForBalances < ActiveRecord::Migration[7.1] + def change + add_index :balances, [:account_id, :security_id, :date, :kind, :family_id], unique: true, name: 'index_balances_on_account_id_security_id_date_kind_family_id' + end +end \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 00000000..09116230 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,436 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.1].define(version: 2023_05_01_164317) do + # These are extensions that must be enabled in order to support this database + enable_extension "hstore" + enable_extension "pgcrypto" + enable_extension "plpgsql" + + create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name" + t.string "source_id" + t.boolean "is_active", default: true + t.string "kind" + t.string "subkind" + t.uuid "connection_id", null: false + t.decimal "available_balance", precision: 19, scale: 4 + t.decimal "current_balance", precision: 19, scale: 4 + t.string "currency_code" + t.integer "sync_status", default: 0 + t.string "mask" + t.integer "source" + t.date "current_balance_date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "official_name" + t.decimal "credit_limit", precision: 10, scale: 2 + t.jsonb "property_details", default: {} + t.boolean "auto_valuation", default: false + t.uuid "family_id" + t.index ["connection_id"], name: "index_accounts_on_connection_id" + t.index ["family_id"], name: "index_accounts_on_family_id" + end + + create_table "balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "security_id" + t.decimal "balance", precision: 23, scale: 8 + t.decimal "quantity", precision: 36, scale: 18 + t.decimal "cost_basis", precision: 23, scale: 8 + t.date "date", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.decimal "change", precision: 23, scale: 8, default: "0.0" + t.string "kind" + t.uuid "family_id" + t.index ["account_id", "security_id", "date", "kind", "family_id"], name: "index_balances_on_account_id_security_id_date_kind_family_id", unique: true + t.index ["account_id", "security_id", "date"], name: "index_balances_on_account_id_and_security_id_and_date", unique: true + t.index ["account_id"], name: "index_balances_on_account_id" + t.index ["family_id"], name: "index_balances_on_family_id" + t.index ["security_id"], name: "index_balances_on_security_id" + end + + create_table "change_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "record_type" + t.uuid "record_id" + t.string "attribute_name" + t.decimal "old_value", precision: 36, scale: 18 + t.decimal "new_value", precision: 36, scale: 18 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "connections", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name" + t.integer "source" + t.uuid "user_id", null: false + t.integer "status" + t.integer "sync_status" + t.jsonb "error" + t.boolean "new_accounts_available" + t.datetime "consent_expiration" + t.string "aggregator_id" + t.string "item_id" + t.string "access_token" + t.string "cursor" + t.datetime "investments_last_synced_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "plaid_products", default: [], array: true + t.uuid "family_id" + t.index ["family_id"], name: "index_connections_on_family_id" + t.index ["user_id"], name: "index_connections_on_user_id" + end + + create_table "conversations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "title" + t.text "summary" + t.uuid "user_id", null: false + t.string "status" + t.string "role" + t.string "kind" + t.string "subkind" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_conversations_on_user_id" + end + + create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "demographics", default: {} + t.string "country" + t.string "region" + t.string "currency", default: "USD" + t.string "household" + t.string "risk" + t.text "goals" + t.boolean "agreed", default: false + t.datetime "agreed_at" + t.jsonb "agreements", default: {} + end + + create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "security_id", null: false + t.decimal "value", precision: 19, scale: 4 + t.decimal "quantity", precision: 36, scale: 18 + t.decimal "cost_basis_source", precision: 23, scale: 8 + t.string "currency_code" + t.string "source_id" + t.boolean "excluded", default: false + t.string "source", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "family_id" + t.index ["account_id", "security_id"], name: "index_holdings_on_account_id_and_security_id", unique: true + t.index ["account_id"], name: "index_holdings_on_account_id" + t.index ["family_id"], name: "index_holdings_on_family_id" + t.index ["security_id"], name: "index_holdings_on_security_id" + end + + create_table "institutions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name" + t.text "logo" + t.string "color" + t.string "url" + t.string "provider" + t.string "provider_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["provider_id"], name: "index_institutions_on_provider_id", unique: true + end + + create_table "investment_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "security_id", null: false + t.date "date" + t.string "name" + t.decimal "amount", precision: 19, scale: 4 + t.decimal "quantity", precision: 36, scale: 18 + t.decimal "price", precision: 23, scale: 8 + t.string "currency_code" + t.string "source_transaction_id" + t.string "source_type" + t.string "source_subtype" + t.decimal "fees", precision: 19, scale: 4 + t.string "category" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_investment_transactions_on_account_id" + t.index ["security_id"], name: "index_investment_transactions_on_security_id" + t.index ["source_transaction_id"], name: "index_investment_transactions_on_source_transaction_id", unique: true + end + + create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "conversation_id", null: false + t.uuid "user_id" + t.string "role" + t.text "content" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "hidden", default: false + t.text "log" + t.string "status", default: "pending" + t.index ["conversation_id"], name: "index_messages_on_conversation_id" + t.index ["user_id"], name: "index_messages_on_user_id" + end + + create_table "metrics", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "kind", null: false + t.decimal "amount", precision: 19, scale: 2 + t.uuid "user_id" + t.date "date", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "family_id" + t.string "subkind" + t.index ["family_id"], name: "index_metrics_on_family_id" + t.index ["kind", "family_id", "date"], name: "index_metrics_on_kind_and_family_id_and_date", unique: true, where: "(subkind IS NULL)" + t.index ["kind", "subkind", "family_id", "date"], name: "index_metrics_on_kind_and_subkind_and_family_id_and_date", unique: true, where: "(subkind IS NOT NULL)" + t.index ["kind", "user_id", "date"], name: "index_metrics_on_kind_and_user_id_and_date", unique: true + t.index ["user_id"], name: "index_metrics_on_user_id" + end + + create_table "pay_charges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "customer_id", null: false + t.uuid "subscription_id" + t.string "processor_id", null: false + t.integer "amount", null: false + t.string "currency" + t.integer "application_fee_amount" + t.integer "amount_refunded" + t.jsonb "metadata" + t.jsonb "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["customer_id", "processor_id"], name: "index_pay_charges_on_customer_id_and_processor_id", unique: true + t.index ["subscription_id"], name: "index_pay_charges_on_subscription_id" + end + + create_table "pay_customers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "owner_type" + t.uuid "owner_id" + t.string "processor", null: false + t.string "processor_id" + t.boolean "default" + t.jsonb "data" + t.datetime "deleted_at", precision: nil + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id", "deleted_at", "default"], name: "pay_customer_owner_index" + t.index ["processor", "processor_id"], name: "index_pay_customers_on_processor_and_processor_id", unique: true + end + + create_table "pay_merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "owner_type" + t.uuid "owner_id" + t.string "processor", null: false + t.string "processor_id" + t.boolean "default" + t.jsonb "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id", "processor"], name: "index_pay_merchants_on_owner_type_and_owner_id_and_processor" + end + + create_table "pay_payment_methods", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "customer_id", null: false + t.string "processor_id", null: false + t.boolean "default" + t.string "type" + t.jsonb "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["customer_id", "processor_id"], name: "index_pay_payment_methods_on_customer_id_and_processor_id", unique: true + end + + create_table "pay_subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "customer_id", null: false + t.string "name", null: false + t.string "processor_id", null: false + t.string "processor_plan", null: false + t.integer "quantity", default: 1, null: false + t.string "status", null: false + t.datetime "current_period_start", precision: nil + t.datetime "current_period_end", precision: nil + t.datetime "trial_ends_at", precision: nil + t.datetime "ends_at", precision: nil + t.boolean "metered" + t.string "pause_behavior" + t.datetime "pause_starts_at", precision: nil + t.datetime "pause_resumes_at", precision: nil + t.decimal "application_fee_percent", precision: 8, scale: 2 + t.jsonb "metadata" + t.jsonb "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["customer_id", "processor_id"], name: "index_pay_subscriptions_on_customer_id_and_processor_id", unique: true + t.index ["metered"], name: "index_pay_subscriptions_on_metered" + t.index ["pause_starts_at"], name: "index_pay_subscriptions_on_pause_starts_at" + end + + create_table "pay_webhooks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "processor" + t.string "event_type" + t.jsonb "event" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "prompts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "content" + t.string "categories", default: [], array: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name" + t.string "symbol" + t.string "cusip" + t.string "isin" + t.string "currency_code" + t.string "source", null: false + t.string "source_id" + t.string "source_type" + t.decimal "shares_per_contract", precision: 36, scale: 19 + t.boolean "is_cash_equivalent", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "last_synced_at" + t.decimal "real_time_price", precision: 10, scale: 2 + t.datetime "real_time_price_updated_at" + t.string "logo" + t.string "logo_source" + t.string "sector" + t.string "industry" + t.string "website" + t.text "logo_svg" + t.jsonb "logo_colors", default: [] + t.index ["source", "source_id"], name: "index_securities_on_source_and_source_id", unique: true + t.index ["source_id"], name: "index_securities_on_source_id", unique: true + end + + create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "security_id", null: false + t.date "date", null: false + t.decimal "open", precision: 20, scale: 11 + t.decimal "high", precision: 20, scale: 11 + t.decimal "low", precision: 20, scale: 11 + t.decimal "close", precision: 20, scale: 11 + t.string "currency", default: "USD" + t.string "exchange" + t.string "kind" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["security_id", "date"], name: "index_security_prices_on_security_id_and_date", unique: true + t.index ["security_id"], name: "index_security_prices_on_security_id" + end + + create_table "transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name" + t.decimal "amount", precision: 19, scale: 2 + t.boolean "is_pending", default: false + t.date "date" + t.uuid "account_id", null: false + t.string "currency_code" + t.string "source_transaction_id" + t.string "source_category_id" + t.string "source_type" + t.jsonb "categories" + t.string "merchant_name" + t.integer "flow", default: 0 + t.boolean "excluded", default: false + t.string "payment_channel" + t.jsonb "enrichment", default: {} + t.datetime "enriched_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "enrichment_country" + t.jsonb "enrichment_intermediaries", default: {} + t.string "enrichment_label_group" + t.string "enrichment_label" + t.string "enrichment_location" + t.string "enrichment_logo" + t.integer "enrichment_mcc" + t.string "enrichment_merchant_name" + t.string "enrichment_merchant_id" + t.string "enrichment_merchant_website" + t.string "enrichment_person" + t.string "enrichment_recurrence" + t.jsonb "enrichment_recurrence_group", default: {} + t.uuid "family_id" + t.index ["account_id"], name: "index_transactions_on_account_id" + t.index ["family_id"], name: "index_transactions_on_family_id" + t.index ["source_transaction_id"], name: "index_transactions_on_source_transaction_id", unique: true + end + + create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.datetime "remember_created_at" + t.integer "sign_in_count", default: 0, null: false + t.datetime "current_sign_in_at" + t.datetime "last_sign_in_at" + t.string "current_sign_in_ip" + t.string "last_sign_in_ip" + t.string "confirmation_token" + t.datetime "confirmed_at" + t.datetime "confirmation_sent_at" + t.string "unconfirmed_email" + t.integer "failed_attempts", default: 0, null: false + t.string "unlock_token" + t.datetime "locked_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "family_id", null: false + t.string "plaid_link_token" + t.datetime "plaid_link_token_expires_at" + t.string "first_name" + t.string "last_name" + t.date "birthday" + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["family_id"], name: "index_users_on_family_id" + t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true + end + + add_foreign_key "accounts", "connections" + add_foreign_key "accounts", "families" + add_foreign_key "balances", "accounts" + add_foreign_key "balances", "families" + add_foreign_key "balances", "securities" + add_foreign_key "connections", "families" + add_foreign_key "connections", "users" + add_foreign_key "conversations", "users" + add_foreign_key "holdings", "accounts" + add_foreign_key "holdings", "families" + add_foreign_key "holdings", "securities" + add_foreign_key "investment_transactions", "accounts" + add_foreign_key "investment_transactions", "securities" + add_foreign_key "messages", "conversations" + add_foreign_key "messages", "users" + add_foreign_key "metrics", "families" + add_foreign_key "metrics", "users" + add_foreign_key "pay_charges", "pay_customers", column: "customer_id" + add_foreign_key "pay_charges", "pay_subscriptions", column: "subscription_id" + add_foreign_key "pay_payment_methods", "pay_customers", column: "customer_id" + add_foreign_key "pay_subscriptions", "pay_customers", column: "customer_id" + add_foreign_key "security_prices", "securities" + add_foreign_key "transactions", "accounts" + add_foreign_key "transactions", "families" + add_foreign_key "users", "families" +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 00000000..4fbd6ed9 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,9 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/lib/assets/.keep b/lib/assets/.keep new file mode 100644 index 00000000..e69de29b diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 00000000..e69de29b diff --git a/lib/tasks/maintenance.rake b/lib/tasks/maintenance.rake new file mode 100644 index 00000000..066a691c --- /dev/null +++ b/lib/tasks/maintenance.rake @@ -0,0 +1,68 @@ +namespace :maintenance do + desc "Quick Update" + task :quick_update => :environment do + Connection.all.each do |connection| + SyncPlaidItemAccountsJob.perform_async(connection.item_id) + SyncPlaidHoldingsJob.perform_async(connection.item_id) + SyncPlaidInvestmentTransactionsJob.perform_async(connection.item_id) + + GenerateMetricsJob.perform_in(1.minute, connection.family_id) + end + + EnrichTransactionsJob.perform_async + + # Sync security prices that haven't been synced in the last 24 hours or are nil + Security.where("last_synced_at IS NULL OR last_synced_at < ?", 24.hours.ago).each do |security| + SyncSecurityHistoryJob.perform_async(security.id) + end + + # Sync security real time prices that haven't been synced in the last 30 minutes or are nil + Security.where("real_time_price_updated_at IS NULL OR real_time_price_updated_at < ?", 30.minutes.ago).each do |security| + RealTimeSyncJob.perform_async(security.id) + end + + Account.all.each do |account| + GenerateBalanceJob.perform_async(account.id) + end + + Account.property.each do |account| + SyncPropertyValuesJob.perform_async(account.id) + end + + Family.all.each do |family| + GenerateCategoricalMetricsJob.perform_async(family.id) + end + end + + desc "Institution Sync" + task :institution_sync => :environment do + SyncPlaidInstitutionsJob.perform_async + end + + desc "Security Details Sync" + task :security_details_sync => :environment do + # Get Security where logo is nil + Security.where(logo: nil).each do |security| + SyncSecurityDetailsJob.perform_async(security.id) + end + end + + desc "Reset all connections" + task :reset_connections => :environment do + Transaction.delete_all + Account.delete_all + #Connection.delete_all + end + + desc "Backfill balance changes" + task :backfill_balance_changes => :environment do + # Get each balance for each day and calculate the difference betweent "today" and the previous date available for that balance + Balance.all.each do |balance| + last_balance = Balance.where(account_id: balance.account_id, security_id: balance.security_id).where("date < ?", balance.date).order(date: :desc).limit(1).last&.balance + + if last_balance.present? + balance.update(change: balance.balance - last_balance) + end + end + end +end \ No newline at end of file diff --git a/log/.keep b/log/.keep new file mode 100644 index 00000000..e69de29b diff --git a/public/404.html b/public/404.html new file mode 100644 index 00000000..2be3af26 --- /dev/null +++ b/public/404.html @@ -0,0 +1,67 @@ + + + + The page you were looking for doesn't exist (404) + + + + + + +
    +
    +

    The page you were looking for doesn't exist.

    +

    You may have mistyped the address or the page may have moved.

    +
    +

    If you are the application owner check the logs for more information.

    +
    + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 00000000..c08eac0d --- /dev/null +++ b/public/422.html @@ -0,0 +1,67 @@ + + + + The change you wanted was rejected (422) + + + + + + +
    +
    +

    The change you wanted was rejected.

    +

    Maybe you tried to change something you didn't have access to.

    +
    +

    If you are the application owner check the logs for more information.

    +
    + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 00000000..78a030af --- /dev/null +++ b/public/500.html @@ -0,0 +1,66 @@ + + + + We're sorry, but something went wrong (500) + + + + + + +
    +
    +

    We're sorry, but something went wrong.

    +
    +

    If you are the application owner check the logs for more information.

    +
    + + diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png new file mode 100644 index 00000000..e69de29b diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 00000000..e69de29b diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..e69de29b diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..ad49c252 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Maybe", + "short_name": "maybe", + "description": "Your personal financial assistant.", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#000000", + "orientation": "portrait", + "prefer_related_applications": false +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..c19f78ab --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 00000000..e69de29b diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 00000000..d19212ab --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,5 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :chrome, screen_size: [1400, 1400] +end diff --git a/test/channels/application_cable/connection_test.rb b/test/channels/application_cable/connection_test.rb new file mode 100644 index 00000000..6340bf9c --- /dev/null +++ b/test/channels/application_cable/connection_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +module ApplicationCable + class ConnectionTest < ActionCable::Connection::TestCase + # test "connects with cookies" do + # cookies.signed[:user_id] = 42 + # + # connect + # + # assert_equal connection.user_id, "42" + # end + end +end diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 00000000..e69de29b diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb new file mode 100644 index 00000000..14aa82c9 --- /dev/null +++ b/test/controllers/accounts_controller_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class AccountsControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get accounts_index_url + assert_response :success + end + + test "should get assets" do + get accounts_assets_url + assert_response :success + end + + test "should get cash" do + get accounts_cash_url + assert_response :success + end + + test "should get investments" do + get accounts_investments_url + assert_response :success + end + + test "should get show" do + get accounts_show_url + assert_response :success + end + + test "should get credit" do + get accounts_credit_url + assert_response :success + end + + test "should get debts" do + get accounts_debts_url + assert_response :success + end +end diff --git a/test/controllers/api/plaid_controller_test.rb b/test/controllers/api/plaid_controller_test.rb new file mode 100644 index 00000000..37d18d4f --- /dev/null +++ b/test/controllers/api/plaid_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Api::PlaidControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/connections_controller_test.rb b/test/controllers/connections_controller_test.rb new file mode 100644 index 00000000..b5198c95 --- /dev/null +++ b/test/controllers/connections_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ConnectionsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/conversations_controller_test.rb b/test/controllers/conversations_controller_test.rb new file mode 100644 index 00000000..1e3d0993 --- /dev/null +++ b/test/controllers/conversations_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ConversationsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/families_controller_test.rb b/test/controllers/families_controller_test.rb new file mode 100644 index 00000000..05f79ec1 --- /dev/null +++ b/test/controllers/families_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class FamiliesControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/holdings_controller_test.rb b/test/controllers/holdings_controller_test.rb new file mode 100644 index 00000000..473f0315 --- /dev/null +++ b/test/controllers/holdings_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class HoldingsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/onboarding_controller_test.rb b/test/controllers/onboarding_controller_test.rb new file mode 100644 index 00000000..aa4f1029 --- /dev/null +++ b/test/controllers/onboarding_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class OnboardingControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb new file mode 100644 index 00000000..d975bb19 --- /dev/null +++ b/test/controllers/pages_controller_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class PagesControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get pages_index_url + assert_response :success + end +end diff --git a/test/controllers/prompts_controller_test.rb b/test/controllers/prompts_controller_test.rb new file mode 100644 index 00000000..99c8ab4d --- /dev/null +++ b/test/controllers/prompts_controller_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class PromptsControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get prompts_index_url + assert_response :success + end +end diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml new file mode 100644 index 00000000..d387b88f --- /dev/null +++ b/test/fixtures/accounts.yml @@ -0,0 +1,29 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + source_id: MyString + is_active: false + type: + subtype: MyString + connection: one + balance: 9.99 + currency_code: MyString + sync_status: 1 + mask: MyString + source: 1 + current_balance_date: 2023-04-03 + +two: + name: MyString + source_id: MyString + is_active: false + type: + subtype: MyString + connection: two + balance: 9.99 + currency_code: MyString + sync_status: 1 + mask: MyString + source: 1 + current_balance_date: 2023-04-03 diff --git a/test/fixtures/balances.yml b/test/fixtures/balances.yml new file mode 100644 index 00000000..1bc36afd --- /dev/null +++ b/test/fixtures/balances.yml @@ -0,0 +1,17 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + account: one + security: one + balance: 9.99 + quantity: 9.99 + cost_basis: 9.99 + date: 2023-04-06 + +two: + account: two + security: two + balance: 9.99 + quantity: 9.99 + cost_basis: 9.99 + date: 2023-04-06 diff --git a/test/fixtures/change_logs.yml b/test/fixtures/change_logs.yml new file mode 100644 index 00000000..7981f614 --- /dev/null +++ b/test/fixtures/change_logs.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + record_type: MyString + record_id: 1 + attribute_name: MyString + old_value: MyString + new_value: MyString + +two: + record_type: MyString + record_id: 1 + attribute_name: MyString + old_value: MyString + new_value: MyString diff --git a/test/fixtures/connections.yml b/test/fixtures/connections.yml new file mode 100644 index 00000000..56f2c5da --- /dev/null +++ b/test/fixtures/connections.yml @@ -0,0 +1,31 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + source: 1 + user: one + status: 1 + sync_status: 1 + error: + new_accounts_available: false + consent_expiration: 2023-04-03 13:35:41 + aggregator_id: MyString + item_id: MyString + access_token: MyString + cursor: MyString + investments_last_synced_at: 2023-04-03 13:35:41 + +two: + name: MyString + source: 1 + user: two + status: 1 + sync_status: 1 + error: + new_accounts_available: false + consent_expiration: 2023-04-03 13:35:41 + aggregator_id: MyString + item_id: MyString + access_token: MyString + cursor: MyString + investments_last_synced_at: 2023-04-03 13:35:41 diff --git a/test/fixtures/conversations.yml b/test/fixtures/conversations.yml new file mode 100644 index 00000000..f4b62dc1 --- /dev/null +++ b/test/fixtures/conversations.yml @@ -0,0 +1,19 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + title: MyString + summary: MyText + user: one + status: MyString + role: MyString + kind: MyString + subkind: MyString + +two: + title: MyString + summary: MyText + user: two + status: MyString + role: MyString + kind: MyString + subkind: MyString diff --git a/test/fixtures/embeddings.yml b/test/fixtures/embeddings.yml new file mode 100644 index 00000000..1138c623 --- /dev/null +++ b/test/fixtures/embeddings.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + reference_id: MyString + metadata: + vector: + +two: + reference_id: MyString + metadata: + vector: diff --git a/test/fixtures/families.yml b/test/fixtures/families.yml new file mode 100644 index 00000000..7d412240 --- /dev/null +++ b/test/fixtures/families.yml @@ -0,0 +1,7 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + +two: + name: MyString diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/holdings.yml b/test/fixtures/holdings.yml new file mode 100644 index 00000000..c62304b7 --- /dev/null +++ b/test/fixtures/holdings.yml @@ -0,0 +1,23 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + account: one + security: one + value: 9.99 + quantity: 9.99 + cost_basis_source: 9.99 + currency_code: MyString + source_id: MyString + excluded: false + source: MyString + +two: + account: two + security: two + value: 9.99 + quantity: 9.99 + cost_basis_source: 9.99 + currency_code: MyString + source_id: MyString + excluded: false + source: MyString diff --git a/test/fixtures/institutions.yml b/test/fixtures/institutions.yml new file mode 100644 index 00000000..a6ce4384 --- /dev/null +++ b/test/fixtures/institutions.yml @@ -0,0 +1,13 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + logo: MyString + color: MyString + url: MyString + +two: + name: MyString + logo: MyString + color: MyString + url: MyString diff --git a/test/fixtures/investment_transactions.yml b/test/fixtures/investment_transactions.yml new file mode 100644 index 00000000..ead3c61c --- /dev/null +++ b/test/fixtures/investment_transactions.yml @@ -0,0 +1,31 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + account: one + security: one + date: 2023-04-03 + name: MyString + amount: 9.99 + quantity: 9.99 + price: 9.99 + currency_code: MyString + source_transaction_id: MyString + source_type: MyString + source_subtype: MyString + fees: 9.99 + category: MyString + +two: + account: two + security: two + date: 2023-04-03 + name: MyString + amount: 9.99 + quantity: 9.99 + price: 9.99 + currency_code: MyString + source_transaction_id: MyString + source_type: MyString + source_subtype: MyString + fees: 9.99 + category: MyString diff --git a/test/fixtures/messages.yml b/test/fixtures/messages.yml new file mode 100644 index 00000000..a61ad122 --- /dev/null +++ b/test/fixtures/messages.yml @@ -0,0 +1,13 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + converstation: one + user: one + role: MyString + content: MyText + +two: + converstation: two + user: two + role: MyString + content: MyText diff --git a/test/fixtures/metrics.yml b/test/fixtures/metrics.yml new file mode 100644 index 00000000..6fe66951 --- /dev/null +++ b/test/fixtures/metrics.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + kind: MyString + amount: 9.99 + user: one + +two: + kind: MyString + amount: 9.99 + user: two diff --git a/test/fixtures/prompts.yml b/test/fixtures/prompts.yml new file mode 100644 index 00000000..9d608fc3 --- /dev/null +++ b/test/fixtures/prompts.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + content: MyString + categories: MyString + +two: + content: MyString + categories: MyString diff --git a/test/fixtures/securities.yml b/test/fixtures/securities.yml new file mode 100644 index 00000000..bdb765b8 --- /dev/null +++ b/test/fixtures/securities.yml @@ -0,0 +1,25 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + symbol: MyString + cusip: MyString + isin: MyString + currency_code: MyString + source: MyString + source_id: MyString + source_type: MyString + shares_per_contract: 9.99 + is_cash_equivalent: false + +two: + name: MyString + symbol: MyString + cusip: MyString + isin: MyString + currency_code: MyString + source: MyString + source_id: MyString + source_type: MyString + shares_per_contract: 9.99 + is_cash_equivalent: false diff --git a/test/fixtures/security_prices.yml b/test/fixtures/security_prices.yml new file mode 100644 index 00000000..4517bb3a --- /dev/null +++ b/test/fixtures/security_prices.yml @@ -0,0 +1,23 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + security: one + date: 2023-04-04 + open: 9.99 + high: 9.99 + low: 9.99 + close: 9.99 + currency: MyString + exchange: MyString + kind: MyString + +two: + security: two + date: 2023-04-04 + open: 9.99 + high: 9.99 + low: 9.99 + close: 9.99 + currency: MyString + exchange: MyString + kind: MyString diff --git a/test/fixtures/transactions.yml b/test/fixtures/transactions.yml new file mode 100644 index 00000000..94c6a1cd --- /dev/null +++ b/test/fixtures/transactions.yml @@ -0,0 +1,37 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + amount: 9.99 + is_pending: false + date: 2023-04-03 + account: one + currency_code: MyString + source_transaction_id: MyString + source_category_id: MyString + source_type: MyString + categories: + merchant_name: MyString + flow: 1 + excluded: false + payment_channel: MyString + enrichment: + enriched_at: 2023-04-03 14:02:42 + +two: + name: MyString + amount: 9.99 + is_pending: false + date: 2023-04-03 + account: two + currency_code: MyString + source_transaction_id: MyString + source_category_id: MyString + source_type: MyString + categories: + merchant_name: MyString + flow: 1 + excluded: false + payment_channel: MyString + enrichment: + enriched_at: 2023-04-03 14:02:42 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 00000000..d7a33292 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +# This model initially had no columns defined. If you add columns to the +# model remove the "{}" from the fixture names and add the columns immediately +# below each fixture, per the syntax in the comments below +# +one: {} +# column: value +# +two: {} +# column: value diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 00000000..e69de29b diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 00000000..e69de29b diff --git a/test/mailers/.keep b/test/mailers/.keep new file mode 100644 index 00000000..e69de29b diff --git a/test/models/.keep b/test/models/.keep new file mode 100644 index 00000000..e69de29b diff --git a/test/models/account_test.rb b/test/models/account_test.rb new file mode 100644 index 00000000..b6de6a15 --- /dev/null +++ b/test/models/account_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class AccountTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/balance_test.rb b/test/models/balance_test.rb new file mode 100644 index 00000000..611c56ff --- /dev/null +++ b/test/models/balance_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class BalanceTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/change_log_test.rb b/test/models/change_log_test.rb new file mode 100644 index 00000000..d5105cb7 --- /dev/null +++ b/test/models/change_log_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ChangeLogTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/connection_test.rb b/test/models/connection_test.rb new file mode 100644 index 00000000..cbeece35 --- /dev/null +++ b/test/models/connection_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ConnectionTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/conversation_test.rb b/test/models/conversation_test.rb new file mode 100644 index 00000000..0c0d00ac --- /dev/null +++ b/test/models/conversation_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ConversationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/embedding_test.rb b/test/models/embedding_test.rb new file mode 100644 index 00000000..d4c3d39a --- /dev/null +++ b/test/models/embedding_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class EmbeddingTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/family_test.rb b/test/models/family_test.rb new file mode 100644 index 00000000..5cde4991 --- /dev/null +++ b/test/models/family_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class FamilyTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/holding_test.rb b/test/models/holding_test.rb new file mode 100644 index 00000000..f4cdde13 --- /dev/null +++ b/test/models/holding_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class HoldingTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/institution_test.rb b/test/models/institution_test.rb new file mode 100644 index 00000000..c100be93 --- /dev/null +++ b/test/models/institution_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class InstitutionTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/investment_transaction_test.rb b/test/models/investment_transaction_test.rb new file mode 100644 index 00000000..5da778bf --- /dev/null +++ b/test/models/investment_transaction_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class InvestmentTransactionTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/message_test.rb b/test/models/message_test.rb new file mode 100644 index 00000000..0e0d35bb --- /dev/null +++ b/test/models/message_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MessageTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/metric_test.rb b/test/models/metric_test.rb new file mode 100644 index 00000000..9e1ddd12 --- /dev/null +++ b/test/models/metric_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MetricTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/prompt_test.rb b/test/models/prompt_test.rb new file mode 100644 index 00000000..b426f5ee --- /dev/null +++ b/test/models/prompt_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class PromptTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/security_price_test.rb b/test/models/security_price_test.rb new file mode 100644 index 00000000..255084c1 --- /dev/null +++ b/test/models/security_price_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class SecurityPriceTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/security_test.rb b/test/models/security_test.rb new file mode 100644 index 00000000..8e82099f --- /dev/null +++ b/test/models/security_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class SecurityTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/transaction_test.rb b/test/models/transaction_test.rb new file mode 100644 index 00000000..dc48590c --- /dev/null +++ b/test/models/transaction_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class TransactionTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 00000000..5c07f490 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/sidekiq/ask_question_job_test.rb b/test/sidekiq/ask_question_job_test.rb new file mode 100644 index 00000000..35f816ab --- /dev/null +++ b/test/sidekiq/ask_question_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class AskQuestionJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/enrich_transactions_job_test.rb b/test/sidekiq/enrich_transactions_job_test.rb new file mode 100644 index 00000000..76f8b0e0 --- /dev/null +++ b/test/sidekiq/enrich_transactions_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class EnrichTransactionsJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/generate_balance_job_test.rb b/test/sidekiq/generate_balance_job_test.rb new file mode 100644 index 00000000..6c50c4f1 --- /dev/null +++ b/test/sidekiq/generate_balance_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class GenerateBalanceJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/generate_categorical_metrics_job_test.rb b/test/sidekiq/generate_categorical_metrics_job_test.rb new file mode 100644 index 00000000..569b6534 --- /dev/null +++ b/test/sidekiq/generate_categorical_metrics_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class GenerateCategoricalMetricsJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/generate_metrics_job_test.rb b/test/sidekiq/generate_metrics_job_test.rb new file mode 100644 index 00000000..1bb8f62c --- /dev/null +++ b/test/sidekiq/generate_metrics_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class GenerateMetricsJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/real_time_sync_job_test.rb b/test/sidekiq/real_time_sync_job_test.rb new file mode 100644 index 00000000..62e758a2 --- /dev/null +++ b/test/sidekiq/real_time_sync_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class RealTimeSyncJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/sync_plaid_holdings_job_test.rb b/test/sidekiq/sync_plaid_holdings_job_test.rb new file mode 100644 index 00000000..a7b9844d --- /dev/null +++ b/test/sidekiq/sync_plaid_holdings_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class SyncPlaidHoldingsJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/sync_plaid_institutions_job_test.rb b/test/sidekiq/sync_plaid_institutions_job_test.rb new file mode 100644 index 00000000..4d1bd0d1 --- /dev/null +++ b/test/sidekiq/sync_plaid_institutions_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class SyncPlaidInstitutionsJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/sync_plaid_investment_transactions_job_test.rb b/test/sidekiq/sync_plaid_investment_transactions_job_test.rb new file mode 100644 index 00000000..693030de --- /dev/null +++ b/test/sidekiq/sync_plaid_investment_transactions_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class SyncPlaidInvestmentTransactionsJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/sync_plaid_item_accounts_job_test.rb b/test/sidekiq/sync_plaid_item_accounts_job_test.rb new file mode 100644 index 00000000..30b4329b --- /dev/null +++ b/test/sidekiq/sync_plaid_item_accounts_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class SyncPlaidItemAccountsJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/sync_plaid_transactions_job_test.rb b/test/sidekiq/sync_plaid_transactions_job_test.rb new file mode 100644 index 00000000..566f2d46 --- /dev/null +++ b/test/sidekiq/sync_plaid_transactions_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class SyncPlaidTransactionsJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/sync_property_values_job_test.rb b/test/sidekiq/sync_property_values_job_test.rb new file mode 100644 index 00000000..0fcfc117 --- /dev/null +++ b/test/sidekiq/sync_property_values_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class SyncPropertyValuesJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/sync_security_details_job_test.rb b/test/sidekiq/sync_security_details_job_test.rb new file mode 100644 index 00000000..6f12882a --- /dev/null +++ b/test/sidekiq/sync_security_details_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class SyncSecurityDetailsJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/sidekiq/sync_security_history_job_test.rb b/test/sidekiq/sync_security_history_job_test.rb new file mode 100644 index 00000000..daea48c9 --- /dev/null +++ b/test/sidekiq/sync_security_history_job_test.rb @@ -0,0 +1,6 @@ +require 'test_helper' +class SyncSecurityHistoryJobTest < Minitest::Test + def test_example + skip "add some examples to (or delete) #{__FILE__}" + end +end diff --git a/test/system/.keep b/test/system/.keep new file mode 100644 index 00000000..e69de29b diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 00000000..0c22470e --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,15 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rails/test_help" + +module ActiveSupport + class TestCase + # Run tests in parallel with specified workers + parallelize(workers: :number_of_processors) + + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all + + # Add more helper methods to be used by all tests here... + end +end diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 00000000..e69de29b diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 00000000..e69de29b diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 00000000..e69de29b diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 00000000..e69de29b diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep new file mode 100644 index 00000000..e69de29b