Compare commits

...

17 Commits

Author SHA1 Message Date
Zach Gollwitzer
e6528bafec Bump to v0.1.0-alpha.15
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-16 16:09:37 -04:00
Zach Gollwitzer
1b6ce6af45 Improved UI warning states for holdings with missing data (#1098)
* Fix security price issue flow

* Fix tooltip positioning and add tooltip for missing holding data

* Fix tooltip controller error with stale arrow target

* Lint fixes
2024-08-16 16:08:27 -04:00
Alexander Schrot
4527482aa2 Add support for different column separator in csv import logic (#1096)
* add col_sep to import model

* add validation for col_sep column

* add col_sep option to csv import model

* make use of col_sep option in import model

* add column separator field to new/edit action of an import

* add col_sep parameter to create/update action

* fix spacing between fields

Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Signed-off-by: Alexander Schrot <alexander@axs-labs.com>

---------

Signed-off-by: Alexander Schrot <alexander@axs-labs.com>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-08-16 14:00:16 -04:00
Zach Gollwitzer
707c5ca0ca Account Issue Model and Resolution Flow + Troubleshooting guides (#1090)
* Rough draft of issue system

* Simplify design

* Remove stale files from merge conflicts

* STI for issues

* Cleanup

* Improve Synth api key flow

* Stub api key for test
2024-08-16 12:13:48 -04:00
Alexander Schrot
c70a08aca2 add pagination to account transactions list (#1095)
* add pagination to account transactions list

* use global pagination partial
2024-08-16 09:00:05 -04:00
Zach Gollwitzer
9dda2606d5 Bump Dockerfile to 3.3.4
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-15 13:23:40 -04:00
Zach Gollwitzer
acf3564a86 Fix for invalid accountable data (#1086) 2024-08-15 12:49:49 -04:00
Josh Pigford
1f6f55c4a8 Switch to general release of Rails 7.2 2024-08-15 11:17:28 -05:00
Zach Gollwitzer
0691041d37 Update required Ruby version for development in README
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-13 12:50:26 -04:00
Chris Covington
b437bb20c4 Bump ruby from 3.3.1 to 3.3.4 (#1084) 2024-08-13 12:49:51 -04:00
Pedro Carmona
3c64f3ff3b Fix: i18n symbol typo (#1085) 2024-08-13 12:31:51 -04:00
dependabot[bot]
82d3b8bcaf Bump rails from 43530b4 to f6d62b5 (#1083)
Bumps [rails](https://github.com/rails/rails) from `43530b4` to `f6d62b5`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](43530b4ac9...f6d62b5f21)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-12 20:41:33 -04:00
Pedro Carmona
14c4b9e93c Refactor: Use native error i18n lookup (#1076) 2024-08-12 20:38:58 -04:00
dependabot[bot]
150fce41a8 Bump ruby-lsp-rails from 0.3.11 to 0.3.12 (#1081)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.11 to 0.3.12.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.11...v0.3.12)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-12 20:33:49 -04:00
dependabot[bot]
67f65d399e Bump bootsnap from 1.18.3 to 1.18.4 (#1079)
Bumps [bootsnap](https://github.com/Shopify/bootsnap) from 1.18.3 to 1.18.4.
- [Changelog](https://github.com/Shopify/bootsnap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Shopify/bootsnap/compare/v1.18.3...v1.18.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-12 20:33:30 -04:00
dependabot[bot]
72fe6d87f0 Bump tailwindcss-rails from 2.6.5 to 2.7.2 (#1078)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.5 to 2.7.2.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.6.5...v2.7.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-12 20:26:38 -04:00
Zach Gollwitzer
94be117a02 Deposit, Withdrawal, and Interest Transactions for Investment View (#1075)
* Trade and Transaction builders

* Consolidate logic

* Remove redundant fields from trade form

* Add deposit, withdrawal, and interest form controls
2024-08-09 20:11:27 -04:00
111 changed files with 1278 additions and 447 deletions

View File

@@ -1,4 +1,4 @@
ARG RUBY_VERSION=3.3.1
ARG RUBY_VERSION=3.3.4
FROM ruby:${RUBY_VERSION}-slim-bullseye
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \

View File

@@ -1 +1 @@
3.3.1
3.3.4

View File

@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.3.1
ARG RUBY_VERSION=3.3.4
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
# Rails app lives here

View File

@@ -3,7 +3,7 @@ source "https://rubygems.org"
ruby file: ".ruby-version"
# Rails
gem "rails", github: "rails/rails", branch: "7-2-stable"
gem "rails", "~> 7.2.0"
# Drivers
gem "pg", "~> 1.5"

View File

@@ -5,34 +5,32 @@ GIT
lucide-rails (0.2.0)
railties (>= 4.1.0)
GIT
remote: https://github.com/rails/rails.git
revision: 43530b4ac911b8722b8a7ac8025eb9298e1292b4
branch: 7-2-stable
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actioncable (7.2.0)
actionpack (= 7.2.0)
activesupport (= 7.2.0)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activestorage (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actionmailbox (7.2.0)
actionpack (= 7.2.0)
activejob (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
mail (>= 2.8.0)
actionmailer (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
actionview (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actionmailer (7.2.0)
actionpack (= 7.2.0)
actionview (= 7.2.0)
activejob (= 7.2.0)
activesupport (= 7.2.0)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.0.beta3)
actionview (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actionpack (7.2.0)
actionview (= 7.2.0)
activesupport (= 7.2.0)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
@@ -41,35 +39,35 @@ GIT
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activestorage (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actiontext (7.2.0)
actionpack (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.0.beta3)
activesupport (= 7.2.0.beta3)
actionview (7.2.0)
activesupport (= 7.2.0)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.0.beta3)
activesupport (= 7.2.0.beta3)
activejob (7.2.0)
activesupport (= 7.2.0)
globalid (>= 0.3.6)
activemodel (7.2.0.beta3)
activesupport (= 7.2.0.beta3)
activerecord (7.2.0.beta3)
activemodel (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
activemodel (7.2.0)
activesupport (= 7.2.0)
activerecord (7.2.0)
activemodel (= 7.2.0)
activesupport (= 7.2.0)
timeout (>= 0.4.0)
activestorage (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
activestorage (7.2.0)
actionpack (= 7.2.0)
activejob (= 7.2.0)
activerecord (= 7.2.0)
activesupport (= 7.2.0)
marcel (~> 1.0)
activesupport (7.2.0.beta3)
activesupport (7.2.0)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -80,32 +78,6 @@ GIT
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
rails (7.2.0.beta3)
actioncable (= 7.2.0.beta3)
actionmailbox (= 7.2.0.beta3)
actionmailer (= 7.2.0.beta3)
actionpack (= 7.2.0.beta3)
actiontext (= 7.2.0.beta3)
actionview (= 7.2.0.beta3)
activejob (= 7.2.0.beta3)
activemodel (= 7.2.0.beta3)
activerecord (= 7.2.0.beta3)
activestorage (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
bundler (>= 1.15.0)
railties (= 7.2.0.beta3)
railties (7.2.0.beta3)
actionpack (= 7.2.0.beta3)
activesupport (= 7.2.0.beta3)
irb (~> 1.13)
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)
@@ -136,7 +108,7 @@ GEM
smart_properties
bigdecimal (3.1.8)
bindex (0.8.1)
bootsnap (1.18.3)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (6.1.2)
racc
@@ -152,7 +124,7 @@ GEM
xpath (~> 3.2)
childprocess (5.0.0)
climate_control (1.2.0)
concurrent-ruby (1.3.3)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
@@ -320,6 +292,20 @@ GEM
rackup (2.1.0)
rack (>= 3)
webrick (~> 1.8)
rails (7.2.0)
actioncable (= 7.2.0)
actionmailbox (= 7.2.0)
actionmailer (= 7.2.0)
actionpack (= 7.2.0)
actiontext (= 7.2.0)
actionview (= 7.2.0)
activejob (= 7.2.0)
activemodel (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
bundler (>= 1.15.0)
railties (= 7.2.0)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -333,6 +319,14 @@ GEM
rails-settings-cached (2.9.4)
activerecord (>= 5.0.0)
railties (>= 5.0.0)
railties (7.2.0)
actionpack (= 7.2.0)
activesupport (= 7.2.0)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rb-fsevent (0.11.2)
@@ -377,13 +371,13 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.17.8)
ruby-lsp (0.17.12)
language_server-protocol (~> 3.17.0)
prism (>= 0.29.0, < 0.31)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.11)
ruby-lsp (>= 0.17.2, < 0.18.0)
ruby-lsp-rails (0.3.12)
ruby-lsp (>= 0.17.12, < 0.18.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ffi (~> 1.12)
@@ -413,23 +407,23 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11491)
sorbet-runtime (0.5.11518)
stackprof (0.2.26)
stimulus-rails (1.3.3)
railties (>= 6.0.0)
stringio (3.1.1)
strscan (3.1.0)
tailwindcss-rails (2.6.5)
tailwindcss-rails (2.7.2)
railties (>= 7.0.0)
tailwindcss-rails (2.6.5-aarch64-linux)
tailwindcss-rails (2.7.2-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.5-arm-linux)
tailwindcss-rails (2.7.2-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.5-arm64-darwin)
tailwindcss-rails (2.7.2-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.5-x86_64-darwin)
tailwindcss-rails (2.7.2-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.5-x86_64-linux)
tailwindcss-rails (2.7.2-x86_64-linux)
railties (>= 7.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@@ -499,7 +493,7 @@ DEPENDENCIES
pg (~> 1.5)
propshaft
puma (>= 5.0)
rails!
rails (~> 7.2.0)
rails-settings-cached
redcarpet
rubocop-rails-omakase
@@ -518,7 +512,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.3.1p55
ruby 3.3.4p94
BUNDLED WITH
2.5.9

View File

@@ -42,7 +42,7 @@ The instructions below are for developers to get started with contributing to th
### Requirements
- Ruby 3.3.1
- Ruby 3.3.4
- PostgreSQL >9.3 (ideally, latest stable version)
After cloning the repo, the basic setup commands are:

View File

@@ -12,13 +12,14 @@ class Account::TradesController < ApplicationController
end
def create
@builder = Account::TradeBuilder.new(entry_params)
@builder = Account::EntryBuilder.new(entry_params)
if entry = @builder.save
entry.sync_account_later
redirect_to account_path(@account), notice: t(".success")
else
render :new, status: :unprocessable_entity
flash[:alert] = t(".failure")
redirect_back_or_to account_path(@account)
end
end
@@ -29,6 +30,8 @@ class Account::TradesController < ApplicationController
end
def entry_params
params.require(:account_entry).permit(:type, :date, :qty, :ticker, :price).merge(account: @account)
params.require(:account_entry)
.permit(:type, :date, :qty, :ticker, :price, :amount, :currency, :transfer_account_id)
.merge(account: @account)
end
end

View File

@@ -5,7 +5,10 @@ class Account::TransactionsController < ApplicationController
before_action :set_entry, only: :update
def index
@entries = @account.entries.account_transactions.reverse_chronological
@pagy, @entries = pagy(
@account.entries.account_transactions.reverse_chronological,
limit: params[:per_page] || "10"
)
end
def update

View File

@@ -18,14 +18,14 @@ class ImportsController < ApplicationController
def update
account = Current.family.accounts.find(params[:import][:account_id])
@import.update! account: account, col_sep: params[:import][:col_sep]
@import.update! account: account
redirect_to load_import_path(@import), notice: t(".import_updated")
end
def create
account = Current.family.accounts.find(params[:import][:account_id])
@import = Import.create!(account: account)
@import = Import.create! account: account, col_sep: params[:import][:col_sep]
redirect_to load_import_path(@import), notice: t(".import_created")
end

View File

@@ -0,0 +1,19 @@
class Issue::ExchangeRateProviderMissingsController < ApplicationController
before_action :set_issue, only: :update
def update
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
@issue.issuable.sync_later
redirect_back_or_to account_path(@issue.issuable)
end
private
def set_issue
@issue = Current.family.issues.find(params[:id])
end
def exchange_rate_params
params.require(:issue_exchange_rate_provider_missing).permit(:synth_api_key)
end
end

View File

@@ -0,0 +1,13 @@
class IssuesController < ApplicationController
before_action :set_issue, only: :show
def show
render template: "#{@issue.class.name.underscore.pluralize}/show", layout: "issues"
end
private
def set_issue
@issue = Current.family.issues.find(params[:id])
end
end

View File

@@ -17,7 +17,7 @@ class Settings::ProfilesController < SettingsController
if Current.user.update(user_params_with_family)
redirect_to settings_profile_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: t(".file_size_error")
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
end
end

View File

@@ -38,7 +38,7 @@ module Account::EntriesHelper
name = entry.name || generated
name
else
entry.name
entry.name || "Transaction"
end
end

View File

@@ -4,11 +4,11 @@ import {
flip,
shift,
offset,
arrow
autoUpdate
} from '@floating-ui/dom';
export default class extends Controller {
static targets = ["arrow", "tooltip"];
static targets = ["tooltip"];
static values = {
placement: { type: String, default: "top" },
offset: { type: Number, default: 10 },
@@ -17,58 +17,67 @@ export default class extends Controller {
};
connect() {
this.element.addEventListener("mouseenter", this.showTooltip);
this.element.addEventListener("mouseleave", this.hideTooltip);
this.element.addEventListener("focus", this.showTooltip);
this.element.addEventListener("blur", this.hideTooltip);
};
showTooltip = () => {
this.tooltipTarget.style.display = 'block';
this.#update();
};
hideTooltip = () => {
this.tooltipTarget.style.display = '';
};
this._cleanup = null;
this.boundUpdate = this.update.bind(this);
this.startAutoUpdate();
this.addEventListeners();
}
disconnect() {
this.element.removeEventListener("mouseenter", this.showTooltip);
this.element.removeEventListener("mouseleave", this.hideTooltip);
this.element.removeEventListener("focus", this.showTooltip);
this.element.removeEventListener("blur", this.hideTooltip);
};
this.removeEventListeners();
this.stopAutoUpdate();
}
#update() {
addEventListeners() {
this.element.addEventListener("mouseenter", this.show);
this.element.addEventListener("mouseleave", this.hide);
}
removeEventListeners() {
this.element.removeEventListener("mouseenter", this.show);
this.element.removeEventListener("mouseleave", this.hide);
}
show = () => {
this.tooltipTarget.style.display = 'block';
this.update(); // Ensure immediate update when shown
}
hide = () => {
this.tooltipTarget.style.display = 'none';
}
startAutoUpdate() {
if (!this._cleanup) {
this._cleanup = autoUpdate(
this.element,
this.tooltipTarget,
this.boundUpdate
);
}
}
stopAutoUpdate() {
if (this._cleanup) {
this._cleanup();
this._cleanup = null;
}
}
update() {
// Update position even if not visible, to ensure correct positioning when shown
computePosition(this.element, this.tooltipTarget, {
placement: this.placementValue,
middleware: [
offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }),
flip(),
shift({ padding: 5 }),
arrow({ element: this.arrowTarget }),
shift({ padding: 5 })
],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(this.tooltipTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
const { x: arrowX, y: arrowY } = middlewareData.arrow;
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.split('-')[0]];
Object.assign(this.arrowTarget.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: '-4px',
});
});
};
}
}
}

View File

@@ -0,0 +1,64 @@
import {Controller} from "@hotwired/stimulus"
const TRADE_TYPES = {
BUY: "buy",
SELL: "sell",
TRANSFER_IN: "transfer_in",
TRANSFER_OUT: "transfer_out",
INTEREST: "interest"
}
const FIELD_VISIBILITY = {
[TRADE_TYPES.BUY]: {ticker: true, qty: true, price: true},
[TRADE_TYPES.SELL]: {ticker: true, qty: true, price: true},
[TRADE_TYPES.TRANSFER_IN]: {amount: true, transferAccount: true},
[TRADE_TYPES.TRANSFER_OUT]: {amount: true, transferAccount: true},
[TRADE_TYPES.INTEREST]: {amount: true}
}
// Connects to data-controller="trade-form"
export default class extends Controller {
static targets = ["typeInput", "tickerInput", "amountInput", "transferAccountInput", "qtyInput", "priceInput"]
connect() {
this.handleTypeChange = this.handleTypeChange.bind(this)
this.typeInputTarget.addEventListener("change", this.handleTypeChange)
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY)
}
disconnect() {
this.typeInputTarget.removeEventListener("change", this.handleTypeChange)
}
handleTypeChange(event) {
this.updateFields(event.target.value)
}
updateFields(type) {
const visibleFields = FIELD_VISIBILITY[type] || {}
Object.entries(this.fieldTargets).forEach(([field, target]) => {
const isVisible = visibleFields[field] || false
// Update visibility
target.hidden = !isVisible
// Update required status based on visibility
if (isVisible) {
target.setAttribute('required', '')
} else {
target.removeAttribute('required')
}
})
}
get fieldTargets() {
return {
ticker: this.tickerInputTarget,
amount: this.amountInputTarget,
transferAccount: this.transferAccountInputTarget,
qty: this.qtyInputTarget,
price: this.priceInputTarget
}
}
}

View File

@@ -1,6 +1,5 @@
class Account < ApplicationRecord
include Syncable
include Monetizable
include Syncable, Monetizable, Issuable
validates :name, :balance, :currency, presence: true
@@ -15,6 +14,7 @@ class Account < ApplicationRecord
has_many :balances, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :syncs, dependent: :destroy
has_many :issues, as: :issuable, dependent: :destroy
monetize :balance
@@ -28,6 +28,8 @@ class Account < ApplicationRecord
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
delegate :value, :series, to: :accountable
class << self
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
@@ -73,34 +75,11 @@ class Account < ApplicationRecord
end
end
# Start of temporary fix for #1068
# ==========================================================================
# TODO: Both `series` and `value` methods are a temporary fix for #1068, which appears to be a data corruption issue.
# Every account should have an accountable no matter what, but some self hosted instances seem to have missing accountables.
# When this is fixed, we can add this back to `delegate :value, :series, to: :accountable`
def series(period: Period.all, currency: self.currency)
if accountable.present?
accountable.series(period: period, currency: currency)
else
TimeSeries.new([])
end
end
def value
if accountable.present?
accountable.value
else
balance_money
end
end
# ==========================================================================
# End of temporary fix for #1068
def alert
latest_sync = syncs.latest
[ latest_sync&.error, *latest_sync&.warnings ].compact.first
def owns_ticker?(ticker)
security_id = Security.find_by(ticker: ticker)&.id
entries.account_trades
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
.where(account_trades: { security_id: security_id }).any?
end
def favorable_direction

View File

@@ -1,9 +1,6 @@
class Account::Balance::Syncer
attr_reader :warnings
def initialize(account, start_date: nil)
@account = account
@warnings = []
@sync_start_date = calculate_sync_start_date(start_date)
end
@@ -20,6 +17,8 @@ class Account::Balance::Syncer
account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance
end
end
rescue Money::ConversionError => e
account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ])
end
private
@@ -67,20 +66,26 @@ class Account::Balance::Syncer
from_currency = account.currency
to_currency = account.family.currency
if ExchangeRate.exchange_rates_provider.nil?
account.observe_missing_exchange_rate_provider
return []
end
exchange_rates = ExchangeRate.find_rates from: from_currency,
to: to_currency,
start_date: sync_start_date
missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date)
if missing_exchange_rates.any?
account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates)
return []
end
balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
raise Money::ConversionError.new("missing exchange rate from #{from_currency} to #{to_currency} on date #{balance.date}") unless exchange_rate
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
end
rescue Money::ConversionError
@warnings << "missing exchange rates from #{from_currency} to #{to_currency}"
[]
end
def build_balance(date, balance, currency = nil)

View File

@@ -204,7 +204,14 @@ class Account::Entry < ApplicationRecord
current_qty = account.holding_qty(account_trade.security)
if current_qty < account_trade.qty.abs
errors.add(:base, "cannot sell #{account_trade.qty.abs} shares of #{account_trade.security.ticker} because you only own #{current_qty} shares")
# i18n-tasks-use t('activerecord.errors.models.account/entry.attributes.base.invalid_sell_quantity')
errors.add(
:base,
:invalid_sell_quantity,
sell_qty: account_trade.qty.abs,
ticker: account_trade.security.ticker,
current_qty: current_qty
)
end
end
end

View File

@@ -0,0 +1,45 @@
class Account::EntryBuilder
include ActiveModel::Model
TYPES = %w[ income expense buy sell interest transfer_in transfer_out ].freeze
attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id
validates :type, inclusion: { in: TYPES }
def save
if valid?
create_builder.save
end
end
private
def create_builder
case type
when "buy", "sell"
create_trade_builder
else
create_transaction_builder
end
end
def create_trade_builder
Account::TradeBuilder.new \
type: type,
date: date,
qty: qty,
ticker: ticker,
price: price,
account: account
end
def create_transaction_builder
Account::TransactionBuilder.new \
type: type,
date: date,
amount: amount,
account: account,
transfer_account_id: transfer_account_id
end
end

View File

@@ -1,9 +1,6 @@
class Account::Holding::Syncer
attr_reader :warnings
def initialize(account, start_date: nil)
@account = account
@warnings = []
@sync_date_range = calculate_sync_start_date(start_date)..Date.current
@portfolio = {}
@@ -69,6 +66,8 @@ class Account::Holding::Syncer
price = get_cached_price(ticker, date) || trade_price
account.observe_missing_price(ticker:, date:) unless price
account.holdings.build \
date: date,
security_id: holding[:security_id],

View File

@@ -16,34 +16,27 @@ class Account::Sync < ApplicationRecord
def run
start!
account.resolve_stale_issues
sync_balances
sync_holdings
complete!
rescue StandardError => error
account.observe_unknown_issue(error)
fail! error
raise error if Rails.env.development?
end
private
def sync_balances
syncer = Account::Balance::Syncer.new(account, start_date: start_date)
syncer.run
append_warnings(syncer.warnings)
Account::Balance::Syncer.new(account, start_date: start_date).run
end
def sync_holdings
syncer = Account::Holding::Syncer.new(account, start_date: start_date)
syncer.run
append_warnings(syncer.warnings)
end
def append_warnings(new_warnings)
update! warnings: warnings + new_warnings
Account::Holding::Syncer.new(account, start_date: start_date).run
end
def start!
@@ -53,12 +46,17 @@ class Account::Sync < ApplicationRecord
def complete!
update! status: "completed"
broadcast_result type: "notice", message: "Sync complete"
if account.has_issues?
broadcast_result type: "alert", message: account.highest_priority_issue.title
else
broadcast_result type: "notice", message: "Sync complete"
end
end
def fail!(error)
update! status: "failed", error: error.message
broadcast_result type: "alert", message: error.message
broadcast_result type: "alert", message: I18n.t("account.sync.failed")
end
def broadcast_start
@@ -78,6 +76,7 @@ class Account::Sync < ApplicationRecord
partial: "shared/notification",
locals: { type: type, message: message }
)
account.family.broadcast_refresh
end
end

View File

@@ -1,8 +1,8 @@
class Account::TradeBuilder
TYPES = %w[ buy sell ].freeze
class Account::TradeBuilder < Account::EntryBuilder
include ActiveModel::Model
TYPES = %w[ buy sell ].freeze
attr_accessor :type, :qty, :price, :ticker, :date, :account
validates :type, :qty, :price, :ticker, :date, presence: true

View File

@@ -0,0 +1,63 @@
class Account::TransactionBuilder
include ActiveModel::Model
TYPES = %w[ income expense interest transfer_in transfer_out ].freeze
attr_accessor :type, :amount, :date, :account, :transfer_account_id
validates :type, :amount, :date, presence: true
validates :type, inclusion: { in: TYPES }
def save
if valid?
transfer? ? create_transfer : create_transaction
end
end
private
def transfer?
%w[transfer_in transfer_out].include?(type)
end
def create_transfer
return create_unlinked_transfer(account.id, signed_amount) unless transfer_account_id
from_account_id = type == "transfer_in" ? transfer_account_id : account.id
to_account_id = type == "transfer_in" ? account.id : transfer_account_id
outflow = create_unlinked_transfer(from_account_id, signed_amount.abs)
inflow = create_unlinked_transfer(to_account_id, signed_amount.abs * -1)
Account::Transfer.create! entries: [ outflow, inflow ]
inflow
end
def create_unlinked_transfer(account_id, amount)
build_entry(account_id, amount, marked_as_transfer: true).tap(&:save!)
end
def create_transaction
build_entry(account.id, signed_amount).tap(&:save!)
end
def build_entry(account_id, amount, marked_as_transfer: false)
Account::Entry.new \
account_id: account_id,
amount: amount,
currency: account.currency,
date: date,
marked_as_transfer: marked_as_transfer,
entryable: Account::Transaction.new
end
def signed_amount
case type
when "expense", "transfer_out"
amount.to_d
else
amount.to_d * -1
end
end
end

View File

@@ -13,15 +13,15 @@ class Account::Transfer < ApplicationRecord
end
def from_name
outflow_transaction&.account&.name || I18n.t("account.transfer.from_fallback_name")
outflow_transaction&.account&.name || I18n.t("account/transfer.from_fallback_name")
end
def to_name
inflow_transaction&.account&.name || I18n.t("account.transfer.to_fallback_name")
inflow_transaction&.account&.name || I18n.t("account/transfer.to_fallback_name")
end
def name
I18n.t("account.transfer.name", from_account: from_name, to_account: to_name)
I18n.t("account/transfer.name", from_account: from_name, to_account: to_name)
end
def inflow_transaction
@@ -72,24 +72,28 @@ class Account::Transfer < ApplicationRecord
def transaction_count
unless entries.size == 2
errors.add :entries, "must have exactly 2 entries"
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_have_exactly_2_entries')
errors.add :entries, :must_have_exactly_2_entries
end
end
def from_different_accounts
accounts = entries.map { |e| e.account_id }.uniq
errors.add :entries, "must be from different accounts" if accounts.size < entries.size
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_be_from_different_accounts')
errors.add :entries, :must_be_from_different_accounts if accounts.size < entries.size
end
def net_zero_flows
unless entries.sum(&:amount).zero?
errors.add :transactions, "must have an inflow and outflow that net to zero"
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_have_an_inflow_and_outflow_that_net_to_zero')
errors.add :entries, :must_have_an_inflow_and_outflow_that_net_to_zero
end
end
def all_transactions_marked
unless entries.all?(&:marked_as_transfer)
errors.add :entries, "must be marked as transfer"
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_be_marked_as_transfer')
errors.add :entries, :must_be_marked_as_transfer
end
end
end

View File

@@ -0,0 +1,58 @@
module Issuable
extend ActiveSupport::Concern
included do
has_many :issues, dependent: :destroy, as: :issuable
end
def has_issues?
issues.active.any?
end
def resolve_stale_issues
issues.active.each do |issue|
issue.resolve! if issue.stale?
end
end
def observe_unknown_issue(error)
observe_issue(
Issue::Unknown.new(data: { error: error.message })
)
end
def observe_missing_exchange_rates(from:, to:, dates:)
observe_issue(
Issue::ExchangeRatesMissing.new(data: { from_currency: from, to_currency: to, dates: dates })
)
end
def observe_missing_exchange_rate_provider
observe_issue(
Issue::ExchangeRateProviderMissing.new
)
end
def observe_missing_price(ticker:, date:)
issue = issues.find_or_create_by(type: Issue::PricesMissing.name, resolved_at: nil)
issue.append_missing_price(ticker, date)
issue.save!
end
def highest_priority_issue
issues.active.ordered.first
end
private
def observe_issue(new_issue)
existing_issue = issues.find_by(type: new_issue.type, resolved_at: nil)
if existing_issue
existing_issue.update!(last_observed_at: Time.current, data: new_issue.data)
else
new_issue.issuable = self
new_issue.save!
end
end
end

View File

@@ -21,10 +21,12 @@ module Providable
private
def synth_provider
@synth_provider ||= begin
api_key = ENV["SYNTH_API_KEY"]
api_key.present? ? Provider::Synth.new(api_key) : nil
end
api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"]
api_key.present? ? Provider::Synth.new(api_key) : nil
end
def self_hosted?
Rails.application.config.app_mode.self_hosted?
end
end
end

View File

@@ -4,6 +4,10 @@ module ExchangeRate::Provided
include Providable
class_methods do
def provider_healthy?
exchange_rates_provider.present? && exchange_rates_provider.healthy?
end
private
def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false)

View File

@@ -8,6 +8,7 @@ class Family < ApplicationRecord
has_many :imports, through: :accounts
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy
has_many :issues, through: :accounts
def snapshot(period = Period.all)
query = accounts.active.joins(:balances)

View File

@@ -1,54 +0,0 @@
class Help::Article
attr_reader :frontmatter, :content
def initialize(frontmatter:, content:)
@frontmatter = frontmatter
@content = content
end
def title
frontmatter["title"]
end
def html
render_markdown(content)
end
class << self
def root_path
Rails.root.join("docs", "help")
end
def find(slug)
Dir.glob(File.join(root_path, "*.md")).each do |file_path|
file_content = File.read(file_path)
frontmatter, markdown_content = parse_frontmatter(file_content)
return new(frontmatter:, content: markdown_content) if frontmatter["slug"] == slug
end
nil
end
private
def parse_frontmatter(content)
if content =~ /\A---(.+?)---/m
frontmatter = YAML.safe_load($1)
markdown_content = content[($~.end(0))..-1].strip
else
frontmatter = {}
markdown_content = content
end
[ frontmatter, markdown_content ]
end
end
private
def render_markdown(content)
markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML)
markdown.render(content)
end
end

View File

@@ -2,6 +2,7 @@ class Import < ApplicationRecord
belongs_to :account
validate :raw_csv_must_be_parsable
validates :col_sep, inclusion: { in: Csv::COL_SEP_LIST }
before_save :initialize_csv, if: :should_initialize_csv?
@@ -88,7 +89,7 @@ class Import < ApplicationRecord
def get_raw_csv
return nil if raw_csv_str.nil?
Import::Csv.new(raw_csv_str)
Import::Csv.new(raw_csv_str, col_sep:)
end
def should_initialize_csv?
@@ -102,7 +103,7 @@ class Import < ApplicationRecord
# Uses the user-provided raw CSV + mappings to generate a normalized CSV for the import
def generate_normalized_csv(csv_str)
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings)
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings, col_sep)
end
def update_csv(row_idx, col_idx, value)
@@ -176,9 +177,10 @@ class Import < ApplicationRecord
def raw_csv_must_be_parsable
begin
CSV.parse(raw_csv_str || "")
CSV.parse(raw_csv_str || "", col_sep:)
rescue CSV::MalformedCSVError
errors.add(:raw_csv_str, "is not a valid CSV format")
# i18n-tasks-use t('activerecord.errors.models.import.attributes.raw_csv_str.invalid_csv_format')
errors.add(:raw_csv_str, :invalid_csv_format)
end
end
end

View File

@@ -1,12 +1,20 @@
class Import::Csv
def self.parse_csv(csv_str)
CSV.parse((csv_str || "").strip, headers: true, converters: [ ->(str) { str&.strip } ])
DEFAULT_COL_SEP = ",".freeze
COL_SEP_LIST = [ DEFAULT_COL_SEP, ";" ].freeze
def self.parse_csv(csv_str, col_sep: DEFAULT_COL_SEP)
CSV.parse(
csv_str&.strip || "",
headers: true,
col_sep:,
converters: [ ->(str) { str&.strip } ]
)
end
def self.create_with_field_mappings(raw_csv_str, fields, field_mappings)
raw_csv = self.parse_csv(raw_csv_str)
def self.create_with_field_mappings(raw_csv_str, fields, field_mappings, col_sep = DEFAULT_COL_SEP)
raw_csv = self.parse_csv(raw_csv_str, col_sep:)
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true do |csv|
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true, col_sep: do |csv|
raw_csv.each do |row|
row_values = []
@@ -22,18 +30,19 @@ class Import::Csv
end
end
new(generated_csv_str)
new(generated_csv_str, col_sep:)
end
attr_reader :csv_str
attr_reader :csv_str, :col_sep
def initialize(csv_str, column_validators: nil)
def initialize(csv_str, column_validators: nil, col_sep: DEFAULT_COL_SEP)
@csv_str = csv_str
@col_sep = col_sep
@column_validators = column_validators || {}
end
def table
@table ||= self.class.parse_csv(csv_str)
@table ||= self.class.parse_csv(csv_str, col_sep:)
end
def update_cell(row_idx, col_idx, value)

35
app/models/issue.rb Normal file
View File

@@ -0,0 +1,35 @@
class Issue < ApplicationRecord
belongs_to :issuable, polymorphic: true
after_initialize :set_default_severity
enum :severity, { critical: 1, error: 2, warning: 3, info: 4 }
validates :severity, presence: true
scope :active, -> { where(resolved_at: nil) }
scope :ordered, -> { order(:severity) }
def title
model_name.human
end
# The conditions that must be met for an issue to be fixed
def stale?
raise NotImplementedError, "#{self.class} must implement #{__method__}"
end
def resolve!
update!(resolved_at: Time.current)
end
def default_severity
:warning
end
private
def set_default_severity
self.severity ||= default_severity
end
end

View File

@@ -0,0 +1,9 @@
class Issue::ExchangeRateProviderMissing < Issue
def default_severity
:error
end
def stale?
ExchangeRate.provider_healthy?
end
end

View File

@@ -0,0 +1,15 @@
class Issue::ExchangeRatesMissing < Issue
store_accessor :data, :from_currency, :to_currency, :dates
validates :from_currency, :to_currency, :dates, presence: true
def stale?
if dates.length == 1
ExchangeRate.find_rate(from: from_currency, to: to_currency, date: dates.first).present?
else
sorted_dates = dates.sort
rates = ExchangeRate.find_rates(from: from_currency, to: to_currency, start_date: sorted_dates.first, end_date: sorted_dates.last)
rates.length == dates.length
end
end
end

View File

@@ -0,0 +1,33 @@
class Issue::PricesMissing < Issue
store_accessor :data, :missing_prices
after_initialize :initialize_missing_prices
validates :missing_prices, presence: true
def append_missing_price(ticker, date)
missing_prices[ticker] ||= []
missing_prices[ticker] << date
end
def stale?
stale = true
missing_prices.each do |ticker, dates|
next unless issuable.owns_ticker?(ticker)
oldest_date = dates.min
expected_price_count = (oldest_date..Date.current).count
prices = Security::Price.find_prices(ticker: ticker, start_date: oldest_date)
stale = false if prices.count < expected_price_count
end
stale
end
private
def initialize_missing_prices
self.missing_prices ||= {}
end
end

View File

@@ -0,0 +1,11 @@
class Issue::Unknown < Issue
def default_severity
:warning
end
# Unknown issues are always stale because we only want to show them
# to the user once. If the same error occurs again, we'll create a new instance.
def stale?
true
end
end

View File

@@ -5,6 +5,11 @@ class Provider::Synth
@api_key = api_key
end
def healthy?
response = client.get("#{base_url}/user")
JSON.parse(response.body).dig("id").present?
end
def fetch_security_prices(ticker:, start_date:, end_date:)
prices = paginate(
"#{base_url}/tickers/#{ticker}/open-close",

View File

@@ -20,6 +20,8 @@ class Setting < RailsSettings::Base
field :app_domain, type: :string, default: ENV["APP_DOMAIN"]
field :email_sender, type: :string, default: ENV["EMAIL_SENDER"]
field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"]
scope :smtp_settings do
field :smtp_host, type: :string, read_only: true, default: ENV["SMTP_ADDRESS"]
field :smtp_port, type: :string, read_only: true, default: ENV["SMTP_PORT"]

View File

@@ -83,18 +83,22 @@ class TimeSeries::Trend
def values_must_be_of_same_type
unless current.class == previous.class || [ previous, current ].any?(&:nil?)
errors.add :current, "must be of the same type as previous"
errors.add :previous, "must be of the same type as current"
# i18n-tasks-use t('activemodel.errors.models.time_series/trend.attributes.current.must_be_of_the_same_type_as_previous')
errors.add :current, :must_be_of_the_same_type_as_previous
# i18n-tasks-use t('activemodel.errors.models.time_series/trend.attributes.previous.must_be_of_the_same_type_as_current')
errors.add :previous, :must_be_of_the_same_type_as_current
end
end
def values_must_be_of_known_type
unless current.is_a?(Money) || current.is_a?(Numeric) || current.nil?
errors.add :current, "must be of type Money, Numeric, or nil"
# i18n-tasks-use t('activemodel.errors.models.time_series/trend.attributes.current.must_be_of_type_money_numeric_or_nil')
errors.add :current, :must_be_of_type_money_numeric_or_nil
end
unless previous.is_a?(Money) || previous.is_a?(Numeric) || previous.nil?
errors.add :previous, "must be of type Money, Numeric, or nil"
# i18n-tasks-use t('activemodel.errors.models.time_series/trend.attributes.previous.must_be_of_type_money_numeric_or_nil')
errors.add :previous, :must_be_of_type_money_numeric_or_nil
end
end

View File

@@ -40,7 +40,8 @@ class TimeSeries::Value
def value_must_be_of_known_type
unless value.is_a?(Money) || value.is_a?(Numeric)
errors.add :value, "must be a Money or Numeric"
# i18n-tasks-use t('activemodel.errors.models.time_series/value.attributes.value.must_be_a_money_or_numeric')
errors.add :value, :must_be_a_money_or_numeric
end
end
end

View File

@@ -55,7 +55,8 @@ class User < ApplicationRecord
def can_deactivate
if admin? && family.users.count > 1
errors.add(:base, I18n.t("activerecord.errors.user.cannot_deactivate_admin_with_other_users"))
# i18n-tasks-use t('activerecord.errors.models.user.attributes.base.cannot_deactivate_admin_with_other_users')
errors.add(:base, :cannot_deactivate_admin_with_other_users)
end
end
@@ -83,7 +84,8 @@ class User < ApplicationRecord
def profile_image_size
if profile_image.attached? && profile_image.byte_size > 5.megabytes
errors.add(:profile_image, "is too large. Maximum size is 5 MB.")
# i18n-tasks-use t('activerecord.errors.models.user.attributes.profile_image.invalid_file_size')
errors.add(:profile_image, :invalid_file_size, max_megabytes: 5)
end
end
end

View File

@@ -4,9 +4,13 @@
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-4 flex items-center gap-4">
<%= render "shared/circle_logo", name: holding.name || "H" %>
<div>
<div class="space-y-0.5">
<%= link_to holding.name || holding.ticker, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
<% if holding.amount %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
<% else %>
<%= render "missing_price_tooltip" %>
<% end %>
</div>
</div>
@@ -15,7 +19,7 @@
<%= render "shared/progress_circle", progress: holding.weight, text_class: "text-blue-500" %>
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<%= tag.p "--", class: "text-gray-500 mb-5" %>
<% end %>
</div>
@@ -28,7 +32,7 @@
<% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<%= tag.p "--", class: "text-gray-500" %>
<% end %>
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-gray-500" %>
</div>
@@ -38,7 +42,7 @@
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<%= tag.p "--", class: "text-gray-500 mb-4" %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,11 @@
<div data-controller="tooltip" data-tooltip-cross-axis-value="50">
<div class="flex items-center gap-1 text-warning">
<%= lucide_icon "info", class: "w-4 h-4 shrink-0" %>
<%= tag.span t(".missing_data"), class: "font-normal text-xs" %>
</div>
<div role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64">
<div class="text-white">
<%= t(".description") %>
</div>
</div>
</div>

View File

@@ -1,17 +1,32 @@
<%# locals: (entry:) %>
<%= styled_form_with data: { turbo_frame: "_top" },
<%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" },
scope: :account_entry,
url: entry.new_record? ? account_trades_path(entry.account) : account_entry_path(entry.account, entry) do |form| %>
<div class="space-y-4">
<div class="space-y-2">
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell]], "buy"), label: t(".type") %>
<%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %>
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %>
<div data-trade-form-target="tickerInput">
<%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %>
</div>
<%= form.date_field :date, label: true %>
<%= form.hidden_field :currency, value: entry.account.currency %>
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0 %>
<%= money_with_currency_field form, :price_money, label: t(".price"), disable_currency: true %>
<%= form.hidden_field :currency, value: entry.account.currency %>
<div data-trade-form-target="amountInput" hidden>
<%= money_with_currency_field form, :amount_money, label: t(".amount"), disable_currency: true %>
</div>
<div data-trade-form-target="transferAccountInput" hidden>
<%= form.collection_select :transfer_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %>
</div>
<div data-trade-form-target="qtyInput">
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0 %>
</div>
<div data-trade-form-target="priceInput">
<%= money_with_currency_field form, :price_money, label: t(".price"), disable_currency: true %>
</div>
</div>
<%= form.submit t(".submit") %>

View File

@@ -42,7 +42,7 @@
<div class="col-span-3 flex items-center justify-end">
<% if entry.account_transaction? %>
<%= tag.p format_money(entry.amount_money), class: { "text-green-500": entry.inflow? } %>
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": entry.inflow? } %>
<% else %>
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
<% end %>

View File

@@ -13,14 +13,14 @@
<div class="max-w-full">
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= entry.name[0].upcase %>
<%= entry_name(entry).first.upcase %>
</div>
<div class="truncate text-gray-900">
<% if entry.new_record? %>
<%= content_tag :p, entry.name %>
<% else %>
<%= link_to entry.name,
<%= link_to entry_name(entry),
account_entry_path(account, entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>

View File

@@ -23,6 +23,9 @@
<%= render entries %>
<% end %>
</div>
<div class="pt-4">
<%= render "pagination", pagy: @pagy %>
</div>
<% end %>
</div>
</div>

View File

@@ -1,19 +0,0 @@
<%# locals: (message:, help_path: nil) -%>
<%= tag.div class: "flex gap-6 items-center rounded-xl px-4 py-3 bg-error/5",
data: { controller: "element-removal" },
role: "alert" do %>
<div class="flex gap-3 items-center text-red-500 grow overflow-x-scroll">
<%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %>
<p class="text-sm whitespace-nowrap"><%= message %></p>
</div>
<div class="flex items-center gap-4 ml-auto">
<% if help_path %>
<%= link_to "Troubleshoot", help_path, class: "text-red-500 font-medium hover:underline", data: { turbo_frame: :drawer } %>
<% end %>
<%= tag.button data: { action: "click->element-removal#remove" } do %>
<%= lucide_icon("x", class: "w-5 h-5 shrink-0 text-red-500") %>
<% end %>
</div>
<% end %>

View File

@@ -1,7 +1,7 @@
<%# locals: (account:) -%>
<div data-controller="tooltip" data-tooltip-target="element" data-tooltip-placement-value="right" data-tooltip-offset-value=10 data-tooltip-cross-axis-value=50>
<div data-controller="tooltip" data-tooltip-placement-value="right" data-tooltip-offset-value=10 data-tooltip-cross-axis-value=50>
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %>
<div id="tooltip" role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64">
<div role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64">
<div class="text-white">
<%= t(".total_value_tooltip") %>
</div>
@@ -22,5 +22,4 @@
</div>
</div>
</div>
<div data-tooltip-target="arrow"></div>
</div>

View File

@@ -45,8 +45,8 @@
</div>
</div>
<% if @account.alert %>
<%= render "alert", message: @account.alert, help_path: help_article_path("troubleshooting") %>
<% if @account.highest_priority_issue %>
<%= render partial: "issues/issue", locals: { issue: @account.highest_priority_issue } %>
<% end %>
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">

View File

@@ -40,7 +40,7 @@
</div>
</div>
<div class="flex items-center gap-4">
<%= form_with url: transactions_path,
<%= form_with url: url_for,
method: :get,
class: "flex items-center gap-4",
data: { controller: "auto-submit-form" } do |f| %>

View File

@@ -1,7 +0,0 @@
<%= drawer do %>
<div class="prose">
<%= tag.h1 @article.title %>
<%= sanitize(@article.html).html_safe %>
</div>
<% end %>

View File

@@ -1,6 +1,7 @@
<%= styled_form_with model: @import do |form| %>
<div class="mb-4">
<div class="mb-4 space-y-3">
<%= form.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".account"), required: true } %>
<%= form.collection_select :col_sep, Import::Csv::COL_SEP_LIST, :to_s, -> { t(".col_sep_char.#{_1.ord}") }, { prompt: t(".select_col_sep"), label: t(".col_sep"), required: true } %>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium cursor-pointer hover:bg-gray-700" %>

View File

@@ -0,0 +1,12 @@
<p>The Synth data provider could not find the requested data.</p>
<p>We are actively developing Synth to be a low cost and easy to use data provider. You can help us improve Synth by
requesting the data you need.</p>
<p>Please post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %> with the
following information:</p>
<ul>
<li>What type of data is missing?</li>
<li>Any other information you think might be helpful</li>
</ul>

View File

@@ -0,0 +1,28 @@
<%= content_for :title, @issue.title %>
<%= content_for :description do %>
<p>You have set your family currency preference to <%= Current.family.currency %>. <%= @issue.issuable.name %> has
entries in another currency, which means we have to fetch exchange rates from a data provider to accurately show
historical results.</p>
<p>We have detected that your exchange rates provider is not configured yet.</p>
<% end %>
<%= content_for :action do %>
<% if self_hosted? %>
<p>To fix this issue, you need to provide an API key for your exchange rate provider.</p>
<p>Currently, we support <%= link_to "Synth Finance", "https://synthfinance.com", target: "_blank" %>, so you need
to
get a free API key from the link provided.</p>
<p>Once you have your API key, paste it below to configure it.</p>
<%= styled_form_with model: @issue, url: issue_exchange_rate_provider_missing_path(@issue), method: :patch, class: "space-y-3" do |form| %>
<%= form.text_field :synth_api_key, label: "Synth API Key", placeholder: "Synth API Key", type: "password", class: "w-full", value: Setting.synth_api_key %>
<%= form.submit "Save and Re-Sync Account", class: "btn-primary" %>
<% end %>
<% else %>
<p>Please contact the Maybe team.</p>
<% end %>
<% end %>

View File

@@ -0,0 +1,11 @@
<%= content_for :title, @issue.title %>
<%= content_for :description do %>
<p>Some exchange rates are missing for this account.</p>
<pre><code><%= JSON.pretty_generate(@issue.data) %></code></pre>
<% end %>
<%= content_for :action do %>
<%= render "issue/request_synth_data_action" %>
<% end %>

View File

@@ -0,0 +1,11 @@
<%= content_for :title, @issue.title %>
<%= content_for :description do %>
<p>Some stock prices are missing for this account.</p>
<pre><code><%= JSON.pretty_generate(@issue.data) %></code></pre>
<% end %>
<%= content_for :action do %>
<%= render "issue/request_synth_data_action" %>
<% end %>

View File

@@ -0,0 +1,23 @@
<%= content_for :title, @issue.title %>
<%= content_for :description do %>
<p>An unknown issue has occurred.</p>
<pre><code><%= JSON.pretty_generate(@issue.data || "No data provided for this issue") %></code></pre>
<% end %>
<%= content_for :action do %>
<p>There is no fix for this issue yet.</p>
<p>Maybe is in active development and we value your feedback. There are a couple ways you can report this issue to
help us make Maybe better:</p>
<ul>
<li>Post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %></li>
<li>Open an issue on
our <%= link_to "Github repository", "https://github.com/maybe-finance/maybe/issues", target: "_blank" %></li>
</ul>
<p>If there is data shown in the code block above that you think might be helpful, please include it in your
report.</p>
<% end %>

View File

@@ -0,0 +1,15 @@
<%# locals: (issue:) %>
<% priority_class = issue.critical? || issue.error? ? "bg-error/5" : "bg-warning/5" %>
<% text_class = issue.critical? || issue.error? ? "text-error" : "text-warning" %>
<%= tag.div class: "flex gap-6 items-center rounded-xl px-4 py-3 #{priority_class}" do %>
<div class="flex gap-3 items-center grow overflow-x-scroll <%= text_class %>">
<%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %>
<p class="text-sm whitespace-nowrap"><%= issue.title %></p>
</div>
<div class="flex items-center gap-4 ml-auto">
<%= link_to "Troubleshoot", issue_path(issue), class: "#{text_class} font-medium hover:underline", data: { turbo_frame: :drawer } %>
</div>
<% end %>

View File

@@ -0,0 +1,15 @@
<%= drawer do %>
<article class="prose">
<%= tag.h2 do %>
<%= yield :title %>
<% end %>
<%= tag.h3 t(".description") %>
<%= yield :description %>
<%= tag.h3 t(".action") %>
<%= yield :action %>
</article>
<% end %>
<%= render template: "layouts/application" %>

View File

@@ -1,12 +1,12 @@
<%= turbo_frame_tag "drawer" do %>
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none" data-controller="modal" data-action="click->modal#clickOutside">
<div class="flex flex-col h-full p-4">
<div class="flex justify-end items-center pb-4">
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none flex flex-col" data-controller="modal" data-action="click->modal#clickOutside">
<div class="flex flex-col h-full">
<div class="flex justify-end items-center p-4">
<div data-action="click->modal#close" class="cursor-pointer p-2">
<%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
</div>
</div>
<div class="flex flex-col overflow-scroll">
<div class="flex-1 overflow-y-auto px-4 pb-4">
<%= content %>
</div>
</div>

View File

@@ -1,6 +1,29 @@
{
"ignored_warnings": [
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "03a2010b605b8bdb7d4e1566720904d69ef2fbf8e7bc35735b84e161b475215e",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/controllers/issues_controller.rb",
"line": 5,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(template => \"#{Current.family.issues.find(params[:id]).class.name.underscore.pluralize}/show\", { :layout => \"issues\" })",
"render_path": null,
"location": {
"type": "method",
"class": "IssuesController",
"method": "show"
},
"user_input": "params[:id]",
"confidence": "Weak",
"cwe_id": [
22
],
"note": ""
}
],
"updated": "2024-08-09 10:16:00 -0400",
"updated": "2024-08-16 10:19:50 -0400",
"brakeman_version": "6.1.2"
}

View File

@@ -25,7 +25,7 @@ search:
- app/assets/builds
ignore_unused:
- 'activerecord.attributes.*' # i18n-tasks does not detect these on forms, forms validations (https://github.com/glebm/i18n-tasks/blob/0b4b483c82664f26c5696fb0f6aa1297356e4683/templates/config/i18n-tasks.yml#L146)
- 'activerecord.models.account*' # i18n-tasks does not detect use in dynamic model names (e.g. object.model_name.human)
- 'activerecord.models.*' # i18n-tasks does not detect use in dynamic model names (e.g. object.model_name.human)
- 'helpers.submit.*' # i18n-tasks does not detect used at forms
- 'helpers.label.*' # i18n-tasks does not detect used at forms
- 'accounts.show.sync_message_*' # messages generated in the sync ActiveJob

View File

@@ -10,7 +10,7 @@ module Maybe
private
def semver
"0.1.0-alpha.14"
"0.1.0-alpha.15"
end
end
end

View File

@@ -0,0 +1,10 @@
---
en:
activerecord:
errors:
models:
account/entry:
attributes:
base:
invalid_sell_quantity: cannot sell %{sell_qty} shares of %{ticker} because
you only own %{current_qty} shares

View File

@@ -0,0 +1,5 @@
---
en:
account:
sync:
failed: Sync failed

View File

@@ -1,7 +1,17 @@
---
en:
account:
transfer:
from_fallback_name: Originator
name: Transfer from %{from_account} to %{to_account}
to_fallback_name: Receiver
account/transfer:
from_fallback_name: Originator
name: Transfer from %{from_account} to %{to_account}
to_fallback_name: Receiver
activerecord:
errors:
models:
account/transfer:
attributes:
entries:
must_be_from_different_accounts: must be from different accounts
must_be_marked_as_transfer: must be marked as transfer
must_have_an_inflow_and_outflow_that_net_to_zero: must have an inflow
and outflow that net to zero
must_have_exactly_2_entries: must have exactly 2 entries

View File

@@ -0,0 +1,9 @@
---
en:
activerecord:
errors:
models:
import:
attributes:
raw_csv_str:
invalid_csv_format: is not a valid CSV format

View File

@@ -0,0 +1,8 @@
---
en:
activerecord:
models:
issue/exchange_rate_provider_missing: Exchange rate provider missing
issue/exchange_rates_missing: Exchange rates missing
issue/missing_prices: Missing prices
issue/unknown: Unknown issue occurred

View File

@@ -0,0 +1,15 @@
---
en:
activemodel:
errors:
models:
time_series/trend:
attributes:
current:
must_be_of_the_same_type_as_previous: must be of the same type as previous
must_be_of_type_money_numeric_or_nil: must be of type Money, Numeric,
or nil
previous:
must_be_of_the_same_type_as_current: must be of the same type as current
must_be_of_type_money_numeric_or_nil: must be of type Money, Numeric,
or nil

View File

@@ -0,0 +1,9 @@
---
en:
activemodel:
errors:
models:
time_series/value:
attributes:
value:
must_be_a_money_or_numeric: must be a Money or Numeric

View File

@@ -11,6 +11,11 @@ en:
password: Password
password_confirmation: Password Confirmation
errors:
user:
cannot_deactivate_admin_with_other_users: Admin cannot delete account while
other users are present. Please delete all members first.
models:
user:
attributes:
base:
cannot_deactivate_admin_with_other_users: Admin cannot delete account
while other users are present. Please delete all members first.
profile_image:
invalid_file_size: file size must be less than %{max_megabytes}MB

View File

@@ -15,6 +15,10 @@ en:
no_holdings: No holdings to show.
return: total return
weight: weight
missing_price_tooltip:
description: This investment has missing values and we could not calculate
its returns or value.
missing_data: Missing data
show:
history: History
overview: Overview

View File

@@ -3,8 +3,12 @@ en:
account:
trades:
create:
failure: Something went wrong
success: Transaction created successfully.
form:
account: Transfer account (optional)
account_prompt: Search account
amount: Amount
holding: Ticker symbol
price: Price per share
qty: Quantity

View File

@@ -0,0 +1,5 @@
---
en:
application:
pagination:
rows_per_page: Rows per page

View File

@@ -58,8 +58,13 @@ en:
new: New Import
form:
account: Account
col_sep: CSV column separator
col_sep_char:
'44': Comma (,)
'59': Semicolon (;)
next: Next
select_account: Select account
select_col_sep: Select CSV column separator
import:
complete: Complete
completed_on: Completed on %{datetime}

View File

@@ -8,6 +8,9 @@ en:
sign_up: create an account
terms_of_service: Terms of Service
your_account: Your account
issues:
action: How to fix this issue
description: Issue Description
sidebar:
accounts: Accounts
dashboard: Dashboard

View File

@@ -108,5 +108,4 @@ en:
profile_title: Profile
save: Save
update:
file_size_error: File size must be less than 5MB
success: Profile updated successfully.

View File

@@ -42,7 +42,5 @@ en:
success: Marked as transfer
new:
new_transaction: New transaction
pagination:
rows_per_page: Rows per page
unmark_transfers:
success: Transfer removed

View File

@@ -102,6 +102,12 @@ Rails.application.routes.draw do
resources :institutions, except: %i[ index show ]
resources :issues, only: :show
namespace :issue do
resources :exchange_rate_provider_missings, only: :update
end
# For managing self-hosted upgrades and release notifications
resources :upgrades, only: [] do
member do

View File

@@ -52,27 +52,17 @@ module.exports = {
to: { "stroke-dashoffset": 0 },
},
},
typography: {
typography: (theme) => ({
DEFAULT: {
css: {
maxWidth: "none",
a: {
color: "inherit",
textDecoration: "underline",
},
h2: {
fontSize: "1.125rem",
fontWeight: "inherit",
lineHeight: "1.75rem",
marginBottom: "0.625rem",
marginTop: "0.875rem",
fontSize: theme("fontSize.xl"),
fontWeight: theme("fontWeight.medium"),
},
p: {
marginBottom: "0.625rem",
marginTop: "0.875rem",
},
strong: {
color: "inherit",
h3: {
fontSize: theme("fontSize.lg"),
fontWeight: theme("fontWeight.medium"),
},
li: {
margin: 0,
@@ -94,7 +84,7 @@ module.exports = {
},
},
},
},
}),
},
},
plugins: [

View File

@@ -0,0 +1,15 @@
class FixInvalidAccountableData < ActiveRecord::Migration[7.2]
def up
Account.all.each do |account|
unless account.accountable
puts "Generating new accountable for id=#{account.id}, name=#{account.name}, type=#{account.accountable_type}"
new_accountable = Accountable.from_type(account.accountable_type).new
account.update!(accountable: new_accountable)
end
end
end
def down
# Not reversible
end
end

View File

@@ -0,0 +1,14 @@
class CreateIssues < ActiveRecord::Migration[7.2]
def change
create_table :issues, id: :uuid do |t|
t.references :issuable, type: :uuid, polymorphic: true
t.string :type
t.integer :severity
t.datetime :last_observed_at
t.datetime :resolved_at
t.jsonb :data
t.timestamps
end
end
end

View File

@@ -0,0 +1,5 @@
class RemoveWarningsFromSync < ActiveRecord::Migration[7.2]
def change
remove_column :account_syncs, :warnings, :text, array: true, default: []
end
end

View File

@@ -0,0 +1,5 @@
class AddColSepToImports < ActiveRecord::Migration[7.2]
def change
add_column :imports, :col_sep, :string, default: ','
end
end

17
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_08_07_153618) do
ActiveRecord::Schema[7.2].define(version: 2024_08_16_071555) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -69,7 +69,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_07_153618) do
t.date "start_date"
t.datetime "last_ran_at"
t.string "error"
t.text "warnings", default: [], array: true
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_account_syncs_on_account_id"
@@ -294,6 +293,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_07_153618) do
t.string "normalized_csv_str"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "col_sep", default: ","
t.index ["account_id"], name: "index_imports_on_account_id"
end
@@ -318,6 +318,19 @@ ActiveRecord::Schema[7.2].define(version: 2024_08_07_153618) do
t.index ["token"], name: "index_invite_codes_on_token", unique: true
end
create_table "issues", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "issuable_type"
t.uuid "issuable_id"
t.string "type"
t.integer "severity"
t.datetime "last_observed_at"
t.datetime "resolved_at"
t.jsonb "data"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["issuable_type", "issuable_id"], name: "index_issues_on_issuable"
end
create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false

View File

@@ -1,10 +0,0 @@
---
title: Troubleshooting
slug: troubleshooting
---
Coming soon...
We're working on new guides to help troubleshoot various issues within the app.
Help us out by reporting [issues on Github](https://github.com/maybe-finance/maybe/issues).

View File

@@ -3,6 +3,17 @@ class Money
include ActiveModel::Validations
class ConversionError < StandardError
attr_reader :from_currency, :to_currency, :date
def initialize(from_currency:, to_currency:, date:)
@from_currency = from_currency
@to_currency = to_currency
@date = date
error_message = message || "Couldn't find exchange rate from #{from_currency} to #{to_currency} on #{date}"
super(error_message)
end
end
attr_reader :amount, :currency, :store
@@ -37,7 +48,7 @@ class Money
else
exchange_rate = store.find_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate
raise ConversionError.new("Couldn't find exchange rate from #{iso_code} to #{other_iso_code} on #{date}") unless exchange_rate
raise ConversionError.new(from_currency: iso_code, to_currency: other_iso_code, date: date) unless exchange_rate
Money.new(amount * exchange_rate, other_iso_code)
end

View File

@@ -16,6 +16,81 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "creates deposit entry" do
from_account = accounts(:depository) # Account the deposit is coming from
assert_difference -> { Account::Entry.count } => 2,
-> { Account::Transaction.count } => 2,
-> { Account::Transfer.count } => 1 do
post account_trades_url(@entry.account), params: {
account_entry: {
type: "transfer_in",
date: Date.current,
amount: 10,
transfer_account_id: from_account.id
}
}
end
assert_redirected_to account_path(@entry.account)
end
test "creates withdrawal entry" do
to_account = accounts(:depository) # Account the withdrawal is going to
assert_difference -> { Account::Entry.count } => 2,
-> { Account::Transaction.count } => 2,
-> { Account::Transfer.count } => 1 do
post account_trades_url(@entry.account), params: {
account_entry: {
type: "transfer_out",
date: Date.current,
amount: 10,
transfer_account_id: to_account.id
}
}
end
assert_redirected_to account_path(@entry.account)
end
test "deposit and withdrawal has optional transfer account" do
assert_difference -> { Account::Entry.count } => 1,
-> { Account::Transaction.count } => 1,
-> { Account::Transfer.count } => 0 do
post account_trades_url(@entry.account), params: {
account_entry: {
type: "transfer_out",
date: Date.current,
amount: 10
}
}
end
created_entry = Account::Entry.order(created_at: :desc).first
assert created_entry.amount.positive?
assert created_entry.marked_as_transfer
assert_redirected_to account_path(@entry.account)
end
test "creates interest entry" do
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do
post account_trades_url(@entry.account), params: {
account_entry: {
type: "interest",
date: Date.current,
amount: 10
}
}
end
created_entry = Account::Entry.order(created_at: :desc).first
assert created_entry.amount.negative?
assert_redirected_to account_path(@entry.account)
end
test "creates trade buy entry" do
assert_difference [ "Account::Entry.count", "Account::Trade.count", "Security.count" ], 1 do
post account_trades_url(@entry.account), params: {

View File

@@ -1,18 +0,0 @@
require "test_helper"
class Help::ArticlesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@article = Help::Article.new(frontmatter: { title: "Test Article", slug: "test-article" }, content: "")
Help::Article.stubs(:find).returns(@article)
end
test "can view help article" do
get help_article_path(@article)
assert_response :success
assert_dom "h1", text: @article.title, count: 1
end
end

View File

@@ -29,7 +29,7 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
test "should create import" do
assert_difference("Import.count") do
post imports_url, params: { import: { account_id: @user.family.accounts.first.id } }
post imports_url, params: { import: { account_id: @user.family.accounts.first.id, col_sep: "," } }
end
assert_redirected_to load_import_path(Import.ordered.first)
@@ -41,7 +41,7 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
end
test "should update import" do
patch import_url(@empty_import), params: { import: { account_id: @empty_import.account_id } }
patch import_url(@empty_import), params: { import: { account_id: @empty_import.account_id, col_sep: "," } }
assert_redirected_to load_import_path(@empty_import)
end

View File

@@ -0,0 +1,19 @@
require "test_helper"
class Issue::ExchangeRateProviderMissingsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@issue = issues(:one)
end
test "should update issue" do
patch issue_exchange_rate_provider_missing_url(@issue), params: {
issue_exchange_rate_provider_missing: {
synth_api_key: "1234"
}
}
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_url(@issue.issuable)
end
end

View File

@@ -0,0 +1,17 @@
require "test_helper"
class IssuesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
end
test "should get show polymorphically" do
issues.each do |issue|
get issue_url(issue)
assert_response :success
assert_dom "h2", text: issue.title
assert_dom "h3", text: "Issue Description"
assert_dom "h3", text: "How to fix this issue"
end
end
end

View File

@@ -25,6 +25,7 @@ class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest
delete settings_profile_url
assert_redirected_to settings_profile_url
assert_equal "Admin cannot delete account while other users are present. Please delete all members first.", flash[:alert]
assert_no_enqueued_jobs only: UserPurgeJob
assert User.find(@admin.id).active?
end

View File

@@ -4,7 +4,6 @@ one:
start_date: 2024-07-07
last_ran_at: 2024-07-07 09:03:31
error: test sync error
warnings: [ "test warning 1", "test warning 2" ]
two:
account: investment

View File

@@ -1,6 +0,0 @@
---
title: Placeholder
slug: placeholder
---
Test help article

5
test/fixtures/issues.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
one:
issuable: depository
issuable_type: Account
type: Issue::Unknown
last_observed_at: 2024-08-15 08:54:04

View File

@@ -4,7 +4,9 @@ module ExchangeRateProviderInterfaceTest
extend ActiveSupport::Testing::Declarative
test "exchange rate provider interface" do
assert_respond_to @subject, :healthy?
assert_respond_to @subject, :fetch_exchange_rate
assert_respond_to @subject, :fetch_exchange_rates
end
test "exchange rate provider response contract" do

View File

@@ -4,6 +4,7 @@ module SecurityPriceProviderInterfaceTest
extend ActiveSupport::Testing::Declarative
test "security price provider interface" do
assert_respond_to @subject, :healthy?
assert_respond_to @subject, :fetch_security_prices
end

View File

@@ -78,7 +78,9 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
create_exchange_rate(1.day.ago.to_date, from: "EUR", to: "USD", rate: 2)
create_exchange_rate(Date.current, from: "EUR", to: "USD", rate: 2)
run_sync_for(@account)
with_env_overrides SYNTH_API_KEY: ENV["SYNTH_API_KEY"] || "fookey" do
run_sync_for(@account)
end
usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance)
eur_balances = @account.balances.where(currency: "EUR").chronological.map(&:balance)
@@ -88,30 +90,29 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
assert_equal [ 42000, 40000, 40000 ], usd_balances # converted balances at rate of 2:1
end
test "fails with error if exchange rate not available for any entry" do
create_transaction(account: @account, currency: "EUR")
test "raises issue if missing exchange rates" do
create_transaction(date: Date.current, account: @account, currency: "EUR")
ExchangeRate.expects(:find_rate).with(from: "EUR", to: "USD", date: Date.current).returns(nil)
@account.expects(:observe_missing_exchange_rates).with(from: "EUR", to: "USD", dates: [ Date.current ])
syncer = Account::Balance::Syncer.new(@account)
with_env_overrides SYNTH_API_KEY: nil do
assert_raises Money::ConversionError do
syncer.run
end
end
syncer.run
end
# Account is able to calculate balances in its own currency (i.e. can still show a historical graph), but
# doesn't have exchange rates available to convert those calculated balances to the family currency
test "completes with warning if exchange rates not available to convert to family currency" do
test "observes issue if exchange rate provider is not configured" do
@account.update! currency: "EUR"
syncer = Account::Balance::Syncer.new(@account)
@account.expects(:observe_missing_exchange_rate_provider)
with_env_overrides SYNTH_API_KEY: nil do
syncer.run
end
assert_equal 1, syncer.warnings.count
end
test "overwrites existing balances and purges stale balances" do

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