Compare commits
17 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6528bafec | ||
|
|
1b6ce6af45 | ||
|
|
4527482aa2 | ||
|
|
707c5ca0ca | ||
|
|
c70a08aca2 | ||
|
|
9dda2606d5 | ||
|
|
acf3564a86 | ||
|
|
1f6f55c4a8 | ||
|
|
0691041d37 | ||
|
|
b437bb20c4 | ||
|
|
3c64f3ff3b | ||
|
|
82d3b8bcaf | ||
|
|
14c4b9e93c | ||
|
|
150fce41a8 | ||
|
|
67f65d399e | ||
|
|
72fe6d87f0 | ||
|
|
94be117a02 |
@@ -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 \
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.3.1
|
||||
3.3.4
|
||||
|
||||
@@ -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
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -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"
|
||||
|
||||
156
Gemfile.lock
156
Gemfile.lock
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
13
app/controllers/issues_controller.rb
Normal file
13
app/controllers/issues_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ module Account::EntriesHelper
|
||||
name = entry.name || generated
|
||||
name
|
||||
else
|
||||
entry.name
|
||||
entry.name || "Transaction"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
64
app/javascript/controllers/trade_form_controller.js
Normal file
64
app/javascript/controllers/trade_form_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
45
app/models/account/entry_builder.rb
Normal file
45
app/models/account/entry_builder.rb
Normal 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
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
63
app/models/account/transaction_builder.rb
Normal file
63
app/models/account/transaction_builder.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
58
app/models/concerns/issuable.rb
Normal file
58
app/models/concerns/issuable.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
35
app/models/issue.rb
Normal 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
|
||||
9
app/models/issue/exchange_rate_provider_missing.rb
Normal file
9
app/models/issue/exchange_rate_provider_missing.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class Issue::ExchangeRateProviderMissing < Issue
|
||||
def default_severity
|
||||
:error
|
||||
end
|
||||
|
||||
def stale?
|
||||
ExchangeRate.provider_healthy?
|
||||
end
|
||||
end
|
||||
15
app/models/issue/exchange_rates_missing.rb
Normal file
15
app/models/issue/exchange_rates_missing.rb
Normal 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
|
||||
33
app/models/issue/prices_missing.rb
Normal file
33
app/models/issue/prices_missing.rb
Normal 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
|
||||
11
app/models/issue/unknown.rb
Normal file
11
app/models/issue/unknown.rb
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
11
app/views/account/holdings/_missing_price_tooltip.html.erb
Normal file
11
app/views/account/holdings/_missing_price_tooltip.html.erb
Normal 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>
|
||||
@@ -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") %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
<%= render entries %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<%= render "pagination", pagy: @pagy %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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| %>
|
||||
@@ -1,7 +0,0 @@
|
||||
<%= drawer do %>
|
||||
<div class="prose">
|
||||
<%= tag.h1 @article.title %>
|
||||
|
||||
<%= sanitize(@article.html).html_safe %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -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" %>
|
||||
|
||||
12
app/views/issue/_request_synth_data_action.html.erb
Normal file
12
app/views/issue/_request_synth_data_action.html.erb
Normal 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>
|
||||
@@ -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 %>
|
||||
11
app/views/issue/exchange_rates_missings/show.html.erb
Normal file
11
app/views/issue/exchange_rates_missings/show.html.erb
Normal 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 %>
|
||||
11
app/views/issue/prices_missings/show.html.erb
Normal file
11
app/views/issue/prices_missings/show.html.erb
Normal 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 %>
|
||||
23
app/views/issue/unknowns/show.html.erb
Normal file
23
app/views/issue/unknowns/show.html.erb
Normal 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 %>
|
||||
15
app/views/issues/_issue.html.erb
Normal file
15
app/views/issues/_issue.html.erb
Normal 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 %>
|
||||
15
app/views/layouts/issues.html.erb
Normal file
15
app/views/layouts/issues.html.erb
Normal 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" %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ module Maybe
|
||||
|
||||
private
|
||||
def semver
|
||||
"0.1.0-alpha.14"
|
||||
"0.1.0-alpha.15"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
10
config/locales/models/account/entry/en.yml
Normal file
10
config/locales/models/account/entry/en.yml
Normal 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
|
||||
5
config/locales/models/account/sync/en.yml
Normal file
5
config/locales/models/account/sync/en.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
en:
|
||||
account:
|
||||
sync:
|
||||
failed: Sync failed
|
||||
@@ -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
|
||||
|
||||
9
config/locales/models/import/en.yml
Normal file
9
config/locales/models/import/en.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
en:
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
import:
|
||||
attributes:
|
||||
raw_csv_str:
|
||||
invalid_csv_format: is not a valid CSV format
|
||||
8
config/locales/models/issue/en.yml
Normal file
8
config/locales/models/issue/en.yml
Normal 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
|
||||
15
config/locales/models/time_series/trend/en.yml
Normal file
15
config/locales/models/time_series/trend/en.yml
Normal 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
|
||||
9
config/locales/models/time_series/value/en.yml
Normal file
9
config/locales/models/time_series/value/en.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
5
config/locales/views/application/en.yml
Normal file
5
config/locales/views/application/en.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
en:
|
||||
application:
|
||||
pagination:
|
||||
rows_per_page: Rows per page
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
15
db/migrate/20240813170608_fix_invalid_accountable_data.rb
Normal file
15
db/migrate/20240813170608_fix_invalid_accountable_data.rb
Normal 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
|
||||
14
db/migrate/20240815125404_create_issues.rb
Normal file
14
db/migrate/20240815125404_create_issues.rb
Normal 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
|
||||
5
db/migrate/20240815190722_remove_warnings_from_sync.rb
Normal file
5
db/migrate/20240815190722_remove_warnings_from_sync.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class RemoveWarningsFromSync < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
remove_column :account_syncs, :warnings, :text, array: true, default: []
|
||||
end
|
||||
end
|
||||
5
db/migrate/20240816071555_add_col_sep_to_imports.rb
Normal file
5
db/migrate/20240816071555_add_col_sep_to_imports.rb
Normal 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
17
db/schema.rb
generated
@@ -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
|
||||
|
||||
@@ -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).
|
||||
13
lib/money.rb
13
lib/money.rb
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
17
test/controllers/issues_controller_test.rb
Normal file
17
test/controllers/issues_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
1
test/fixtures/account/syncs.yml
vendored
1
test/fixtures/account/syncs.yml
vendored
@@ -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
|
||||
|
||||
6
test/fixtures/files/help_article.md
vendored
6
test/fixtures/files/help_article.md
vendored
@@ -1,6 +0,0 @@
|
||||
---
|
||||
title: Placeholder
|
||||
slug: placeholder
|
||||
---
|
||||
|
||||
Test help article
|
||||
5
test/fixtures/issues.yml
vendored
Normal file
5
test/fixtures/issues.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
one:
|
||||
issuable: depository
|
||||
issuable_type: Account
|
||||
type: Issue::Unknown
|
||||
last_observed_at: 2024-08-15 08:54:04
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user