Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c95bb082a9 | ||
|
|
4d0df9b950 | ||
|
|
7c66f16750 | ||
|
|
fa0248056d | ||
|
|
624faa10d0 | ||
|
|
9138bd2b76 | ||
|
|
882857fcf0 | ||
|
|
d6793dec05 | ||
|
|
e771c8c1df | ||
|
|
58cc09f5ae | ||
|
|
98c842d3b8 | ||
|
|
fae781e1be | ||
|
|
8208722247 | ||
|
|
f7064fd4dd | ||
|
|
c610b0ba4b | ||
|
|
a4874815a6 | ||
|
|
763e222cdd | ||
|
|
e8390a68d8 | ||
|
|
0e76d753bd | ||
|
|
f5ff5332d5 | ||
|
|
0dea36ec7d | ||
|
|
95989a6c9b | ||
|
|
ac9703031f | ||
|
|
457e7062bf | ||
|
|
32ef6ca154 | ||
|
|
fd95f8d2bd | ||
|
|
da668f3dc0 | ||
|
|
cc11fec08a | ||
|
|
ce12e5b5c7 |
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -10,6 +10,7 @@ assignees: ''
|
||||
**Where did this bug occur? (required)**
|
||||
|
||||
- [ ] I am a self-hosted user reporting a bug from my self hosted app
|
||||
- [ ] I have verified that I am running the **latest** version of the Maybe app (your app should be running [this version](https://github.com/maybe-finance/maybe/pkgs/container/maybe) before opening a bug)
|
||||
- [ ] I am a user of Maybe's paid app
|
||||
|
||||
_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64,linux/arm/v7' || 'linux/amd64,linux/arm64' }}
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
@@ -9,7 +9,7 @@ WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y curl libvips postgresql-client
|
||||
apt-get install --no-install-recommends -y curl libvips postgresql-client git
|
||||
|
||||
# Set production environment
|
||||
ENV RAILS_ENV="production" \
|
||||
@@ -21,7 +21,7 @@ ENV RAILS_ENV="production" \
|
||||
FROM base AS build
|
||||
|
||||
# Install packages needed to build gems
|
||||
RUN apt-get install --no-install-recommends -y build-essential git libpq-dev pkg-config
|
||||
RUN apt-get install --no-install-recommends -y build-essential libpq-dev pkg-config
|
||||
|
||||
# Install application gems
|
||||
COPY .ruby-version Gemfile Gemfile.lock ./
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -29,7 +29,7 @@ gem "hotwire_combobox", github: "josefarias/hotwire_combobox", ref: "b827048a830
|
||||
gem "good_job"
|
||||
|
||||
# Error logging
|
||||
gem "stackprof"
|
||||
gem "vernier"
|
||||
gem "rack-mini-profiler"
|
||||
gem "sentry-ruby"
|
||||
gem "sentry-rails"
|
||||
|
||||
10
Gemfile.lock
10
Gemfile.lock
@@ -318,7 +318,7 @@ GEM
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
plaid (36.0.0)
|
||||
plaid (36.1.0)
|
||||
faraday (>= 1.0.1, < 3.0)
|
||||
faraday-multipart (>= 1.0.1, < 2.0)
|
||||
platform_agent (1.0.1)
|
||||
@@ -399,7 +399,7 @@ GEM
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.0)
|
||||
rexml (3.4.1)
|
||||
rotp (6.3.0)
|
||||
rqrcode (2.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
@@ -450,7 +450,7 @@ GEM
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.28.0)
|
||||
selenium-webdriver (4.29.1)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -470,7 +470,6 @@ GEM
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11813)
|
||||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.3)
|
||||
@@ -501,6 +500,7 @@ GEM
|
||||
useragent (0.16.11)
|
||||
vcr (6.3.1)
|
||||
base64
|
||||
vernier (1.5.0)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
@@ -579,13 +579,13 @@ DEPENDENCIES
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
simplecov
|
||||
stackprof
|
||||
stimulus-rails
|
||||
stripe
|
||||
tailwindcss-rails
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
vcr
|
||||
vernier
|
||||
web-console
|
||||
webmock
|
||||
|
||||
|
||||
2
app/assets/stylesheets/simonweb_pickr.css
Normal file
2
app/assets/stylesheets/simonweb_pickr.css
Normal file
File diff suppressed because one or more lines are too long
@@ -8,6 +8,35 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@import "../stylesheets/simonweb_pickr.css";
|
||||
|
||||
@layer components {
|
||||
.pcr-app{
|
||||
position: static !important;
|
||||
background: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.pcr-color-palette{
|
||||
height: 12em !important;
|
||||
width: 21.5rem !important;
|
||||
}
|
||||
.pcr-palette{
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.pcr-palette:before{
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.pcr-color-chooser{
|
||||
height: 1.5em !important;
|
||||
}
|
||||
.pcr-picker{
|
||||
height: 20px !important;
|
||||
width: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.combobox {
|
||||
.hw-combobox__main__wrapper,
|
||||
.hw-combobox__input {
|
||||
|
||||
@@ -331,29 +331,13 @@
|
||||
details>summary {
|
||||
@apply list-none;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] {
|
||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option {
|
||||
@apply py-2 rounded-md;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:checked {
|
||||
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:active,
|
||||
select[multiple="multiple"] option:focus {
|
||||
@apply bg-white;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
@@ -380,6 +364,25 @@
|
||||
.form-field {
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
@apply transition-all duration-300;
|
||||
|
||||
/* Add styles for multiple select within form fields */
|
||||
select[multiple] {
|
||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
||||
|
||||
option {
|
||||
@apply py-2 rounded-md;
|
||||
}
|
||||
|
||||
option:checked {
|
||||
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
|
||||
}
|
||||
|
||||
option:active,
|
||||
option:focus {
|
||||
@apply bg-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@@ -392,6 +395,7 @@
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:text-gray-400;
|
||||
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
||||
@apply transition-opacity duration-300;
|
||||
|
||||
&select {
|
||||
@apply pr-8;
|
||||
@@ -410,6 +414,7 @@
|
||||
.checkbox {
|
||||
&[type='checkbox'] {
|
||||
@apply rounded-sm;
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,8 +445,10 @@
|
||||
/* Switches */
|
||||
.switch {
|
||||
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
|
||||
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
|
||||
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full;
|
||||
@apply after:transition-transform after:duration-300 after:ease-in-out;
|
||||
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
|
||||
@@ -10,7 +10,7 @@ class Account::TradesController < ApplicationController
|
||||
|
||||
def create_entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
|
||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
).tap do |params|
|
||||
account_id = params.delete(:account_id)
|
||||
params[:account] = Current.family.accounts.find(account_id)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable
|
||||
include Pagy::Backend
|
||||
|
||||
helper_method :require_upgrade?, :subscription_pending?
|
||||
|
||||
@@ -25,6 +25,7 @@ class BudgetsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def budget_create_params
|
||||
params.require(:budget).permit(:start_date)
|
||||
end
|
||||
|
||||
@@ -4,11 +4,13 @@ module Authentication
|
||||
included do
|
||||
before_action :set_request_details
|
||||
before_action :authenticate_user!
|
||||
before_action :set_sentry_user
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def skip_authentication(**options)
|
||||
skip_before_action :authenticate_user!, **options
|
||||
skip_before_action :set_sentry_user, **options
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,4 +45,17 @@ module Authentication
|
||||
Current.user_agent = request.user_agent
|
||||
Current.ip_address = request.ip
|
||||
end
|
||||
|
||||
def set_sentry_user
|
||||
return unless defined?(Sentry) && ENV["SENTRY_DSN"].present?
|
||||
|
||||
if Current.user
|
||||
Sentry.set_user(
|
||||
id: Current.user.id,
|
||||
email: Current.user.email,
|
||||
username: Current.user.display_name,
|
||||
ip_address: Current.ip_address
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
13
app/controllers/concerns/breadcrumbable.rb
Normal file
13
app/controllers/concerns/breadcrumbable.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Breadcrumbable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_breadcrumbs
|
||||
end
|
||||
|
||||
private
|
||||
# The default, unless specific controller or action explicitly overrides
|
||||
def set_breadcrumbs
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ controller_name.titleize, nil ] ]
|
||||
end
|
||||
end
|
||||
@@ -29,11 +29,13 @@ class Import::ConfigurationsController < ApplicationController
|
||||
:account_col_label,
|
||||
:qty_col_label,
|
||||
:ticker_col_label,
|
||||
:exchange_operating_mic_col_label,
|
||||
:price_col_label,
|
||||
:entity_type_col_label,
|
||||
:notes_col_label,
|
||||
:currency_col_label,
|
||||
:date_format,
|
||||
:number_format,
|
||||
:signage_convention
|
||||
)
|
||||
end
|
||||
|
||||
@@ -29,10 +29,8 @@ class Import::UploadsController < ApplicationController
|
||||
end
|
||||
|
||||
def csv_valid?(str)
|
||||
require "csv"
|
||||
|
||||
begin
|
||||
csv = CSV.parse(str || "", headers: true, col_sep: upload_params[:col_sep])
|
||||
csv = Import.parse_csv_str(str, col_sep: upload_params[:col_sep])
|
||||
return false if csv.headers.empty?
|
||||
return false if csv.count == 0
|
||||
true
|
||||
|
||||
@@ -5,6 +5,8 @@ class PagesController < ApplicationController
|
||||
@period = Period.from_key(params[:period], fallback: true)
|
||||
@balance_sheet = Current.family.balance_sheet
|
||||
@accounts = Current.family.accounts.active.with_attached_logo
|
||||
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
|
||||
end
|
||||
|
||||
def changelog
|
||||
|
||||
@@ -3,7 +3,7 @@ class SecuritiesController < ApplicationController
|
||||
query = params[:q]
|
||||
return render json: [] if query.blank? || query.length < 2 || query.length > 100
|
||||
|
||||
@securities = Security.search({
|
||||
@securities = Security.search_provider({
|
||||
search: query,
|
||||
country: params[:country_code] == "US" ? "US" : nil
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ class Settings::HostingsController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
before_action :raise_if_not_self_hosted
|
||||
before_action :ensure_admin, only: :clear_cache
|
||||
|
||||
def show
|
||||
@synth_usage = Current.family.synth_usage
|
||||
@@ -38,6 +39,11 @@ class Settings::HostingsController < ApplicationController
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def clear_cache
|
||||
DataCacheClearJob.perform_later(Current.family)
|
||||
redirect_to settings_hosting_path, notice: t(".cache_cleared")
|
||||
end
|
||||
|
||||
private
|
||||
def hosting_params
|
||||
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
|
||||
@@ -46,4 +52,8 @@ class Settings::HostingsController < ApplicationController
|
||||
def raise_if_not_self_hosted
|
||||
raise "Settings not available on non-self-hosted instance" unless self_hosted?
|
||||
end
|
||||
|
||||
def ensure_admin
|
||||
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class SubscriptionsController < ApplicationController
|
||||
before_action :redirect_to_root_if_self_hosted
|
||||
|
||||
def new
|
||||
if Current.family.stripe_customer_id.blank?
|
||||
customer = stripe_client.v1.customers.create(
|
||||
@@ -44,4 +46,8 @@ class SubscriptionsController < ApplicationController
|
||||
def stripe_client
|
||||
@stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
|
||||
end
|
||||
|
||||
def redirect_to_root_if_self_hosted
|
||||
redirect_to root_path, alert: I18n.t("subscriptions.self_hosted_alert") if self_hosted?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,6 +49,7 @@ class TransactionsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_params
|
||||
cleaned_params = params.fetch(:q, {})
|
||||
.permit(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class UsersController < ApplicationController
|
||||
before_action :set_user
|
||||
before_action :ensure_admin, only: :reset
|
||||
|
||||
def update
|
||||
@user = Current.user
|
||||
@@ -26,6 +27,11 @@ class UsersController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def reset
|
||||
FamilyResetJob.perform_later(Current.family)
|
||||
redirect_to settings_profile_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @user.deactivate
|
||||
Current.session.destroy
|
||||
@@ -68,4 +74,8 @@ class UsersController < ApplicationController
|
||||
def set_user
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
def ensure_admin
|
||||
redirect_to settings_profile_path, alert: I18n.t("users.reset.unauthorized") unless Current.user.admin?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,7 +17,7 @@ module FormsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def period_select(form:, selected:, classes: "border border-tertiary shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
|
||||
def period_select(form:, selected:, classes: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
|
||||
periods_for_select = Period.all.map { |period| [ period.label_short, period.key ] }
|
||||
|
||||
form.select(:period, periods_for_select, { selected: selected.key }, class: classes, data: { "auto-submit-form-target": "auto" })
|
||||
|
||||
@@ -20,6 +20,7 @@ module ImportsHelper
|
||||
notes: "Notes",
|
||||
qty: "Quantity",
|
||||
ticker: "Ticker",
|
||||
exchange: "Exchange",
|
||||
price: "Price",
|
||||
entity_type: "Type"
|
||||
}[key]
|
||||
|
||||
@@ -4,7 +4,7 @@ module SettingsHelper
|
||||
{ name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path },
|
||||
{ name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path },
|
||||
{ name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
|
||||
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path },
|
||||
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path, condition: :not_self_hosted? },
|
||||
{ name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path },
|
||||
{ name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path },
|
||||
{ name: I18n.t("settings.settings_nav.tags_label"), path: :tags_path },
|
||||
@@ -45,4 +45,9 @@ module SettingsHelper
|
||||
concat(next_setting)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def not_self_hosted?
|
||||
!self_hosted?
|
||||
end
|
||||
end
|
||||
|
||||
206
app/javascript/controllers/category_controller.js
Normal file
206
app/javascript/controllers/category_controller.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import Pickr from '@simonwep/pickr'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["pickerBtn", "colorInput", "colorsSection", "paletteSection", "pickerSection", "colorPreview", "avatar", "details", "icon","validationMessage","selection","colorPickerRadioBtn"];
|
||||
static values = {
|
||||
presetColors: Array,
|
||||
};
|
||||
|
||||
initialize() {
|
||||
this.pickerBtnTarget.addEventListener('click', () => {
|
||||
this.showPaletteSection();
|
||||
});
|
||||
|
||||
this.colorInputTarget.addEventListener('input', (e) => {
|
||||
this.picker.setColor(e.target.value);
|
||||
});
|
||||
|
||||
this.detailsTarget.addEventListener('toggle', (e) => {
|
||||
if (!this.colorInputTarget.checkValidity()) {
|
||||
e.preventDefault();
|
||||
this.colorInputTarget.reportValidity();
|
||||
e.target.open = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.selectedIcon = null;
|
||||
|
||||
if (!this.presetColorsValue.includes(this.colorInputTarget.value)) {
|
||||
this.colorPickerRadioBtnTarget.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
initPicker() {
|
||||
const pickerContainer = document.createElement("div");
|
||||
pickerContainer.classList.add("pickerContainer");
|
||||
this.pickerSectionTarget.append(pickerContainer);
|
||||
|
||||
this.picker = Pickr.create({
|
||||
el: this.pickerBtnTarget,
|
||||
theme: 'monolith',
|
||||
container: ".pickerContainer",
|
||||
useAsButton: true,
|
||||
showAlways: true,
|
||||
default: this.colorInputTarget.value,
|
||||
components: {
|
||||
hue: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.picker.on('change', (color) => {
|
||||
const hexColor = color.toHEXA().toString();
|
||||
const rgbacolor = color.toRGBA();
|
||||
|
||||
this.updateAvatarColors(hexColor);
|
||||
this.updateSelectedIconColor(hexColor);
|
||||
|
||||
const backgroundColor = this.backgroundColor(rgbacolor, 10);
|
||||
const contrastRatio = this.contrast(rgbacolor, backgroundColor);
|
||||
|
||||
this.colorInputTarget.value = hexColor;
|
||||
this.colorInputTarget.dataset.colorPickerColorValue = hexColor;
|
||||
this.colorPreviewTarget.style.backgroundColor = hexColor;
|
||||
|
||||
this.handleContrastValidation(contrastRatio);
|
||||
});
|
||||
}
|
||||
|
||||
updateAvatarColors(color) {
|
||||
this.avatarTarget.style.backgroundColor = `${this.#backgroundColor(color)}`;
|
||||
this.avatarTarget.style.color = color;
|
||||
}
|
||||
|
||||
handleIconColorChange(e) {
|
||||
const selectedIcon = e.target;
|
||||
this.selectedIcon = selectedIcon;
|
||||
|
||||
const currentColor = this.colorInputTarget.value;
|
||||
|
||||
this.iconTargets.forEach(icon => {
|
||||
const iconWrapper = icon.nextElementSibling;
|
||||
iconWrapper.style.removeProperty("background-color")
|
||||
iconWrapper.style.color = "black";
|
||||
});
|
||||
|
||||
this.updateSelectedIconColor(currentColor);
|
||||
}
|
||||
|
||||
handleIconChange(e) {
|
||||
const iconSVG = e.currentTarget.closest('label').querySelector('svg').cloneNode(true);
|
||||
this.avatarTarget.innerHTML = '';
|
||||
iconSVG.style.padding = "0px"
|
||||
iconSVG.classList.add("w-8","h-8")
|
||||
this.avatarTarget.appendChild(iconSVG);
|
||||
}
|
||||
|
||||
updateSelectedIconColor(color) {
|
||||
if (this.selectedIcon) {
|
||||
const iconWrapper = this.selectedIcon.nextElementSibling;
|
||||
iconWrapper.style.backgroundColor = `${this.#backgroundColor(color)}`;
|
||||
iconWrapper.style.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
handleColorChange(e) {
|
||||
const color = e.currentTarget.value;
|
||||
this.colorInputTarget.value = color;
|
||||
this.colorPreviewTarget.style.backgroundColor = color;
|
||||
this.updateAvatarColors(color);
|
||||
this.updateSelectedIconColor(color);
|
||||
}
|
||||
|
||||
handleContrastValidation(contrastRatio) {
|
||||
if (contrastRatio < 4.5) {
|
||||
this.colorInputTarget.setCustomValidity("Poor contrast, choose darker color or auto-adjust.");
|
||||
|
||||
this.validationMessageTarget.classList.remove("hidden");
|
||||
} else {
|
||||
this.colorInputTarget.setCustomValidity("");
|
||||
this.validationMessageTarget.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
autoAdjust(e){
|
||||
const currentRGBA = this.picker.getColor();
|
||||
const adjustedRGBA = this.darkenColor(currentRGBA).toString();
|
||||
this.picker.setColor(adjustedRGBA);
|
||||
}
|
||||
|
||||
handleParentChange(e) {
|
||||
const parent = e.currentTarget.value;
|
||||
const display = typeof parent === "string" && parent !== "" ? "none" : "flex";
|
||||
this.selectionTarget.style.display = display;
|
||||
}
|
||||
|
||||
backgroundColor([r,g,b,a], percentage) {
|
||||
const mixedR = Math.round((r * (percentage / 100)) + (255 * (1 - percentage / 100)));
|
||||
const mixedG = Math.round((g * (percentage / 100)) + (255 * (1 - percentage / 100)));
|
||||
const mixedB = Math.round((b * (percentage / 100)) + (255 * (1 - percentage / 100)));
|
||||
return [mixedR, mixedG, mixedB];
|
||||
}
|
||||
|
||||
luminance([r,g,b]) {
|
||||
const toLinear = c => {
|
||||
const scaled = c / 255;
|
||||
return scaled <= 0.04045
|
||||
? scaled / 12.92
|
||||
: ((scaled + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
||||
}
|
||||
|
||||
contrast(foregroundColor, backgroundColor) {
|
||||
const fgLum = this.luminance(foregroundColor);
|
||||
const bgLum = this.luminance(backgroundColor);
|
||||
const [l1, l2] = [Math.max(fgLum, bgLum), Math.min(fgLum, bgLum)];
|
||||
return (l1 + 0.05) / (l2 + 0.05);
|
||||
}
|
||||
|
||||
darkenColor(color) {
|
||||
let darkened = color.toRGBA();
|
||||
const backgroundColor = this.backgroundColor(darkened, 10);
|
||||
let contrastRatio = this.contrast(darkened, backgroundColor);
|
||||
|
||||
while (contrastRatio < 4.5 && (darkened[0] > 0 || darkened[1] > 0 || darkened[2] > 0)) {
|
||||
darkened = [
|
||||
Math.max(0, darkened[0] - 10),
|
||||
Math.max(0, darkened[1] - 10),
|
||||
Math.max(0, darkened[2] - 10),
|
||||
darkened[3]
|
||||
];
|
||||
contrastRatio = this.contrast(darkened, backgroundColor);
|
||||
}
|
||||
|
||||
return `rgba(${darkened.join(", ")})`;
|
||||
}
|
||||
|
||||
showPaletteSection() {
|
||||
this.initPicker();
|
||||
this.colorsSectionTarget.classList.add('hidden');
|
||||
this.paletteSectionTarget.classList.remove('hidden');
|
||||
this.pickerSectionTarget.classList.remove('hidden');
|
||||
this.picker.show();
|
||||
}
|
||||
|
||||
showColorsSection() {
|
||||
this.colorsSectionTarget.classList.remove('hidden');
|
||||
this.paletteSectionTarget.classList.add('hidden');
|
||||
this.pickerSectionTarget.classList.add('hidden');
|
||||
if (this.picker) {
|
||||
this.picker.destroyAndRemove();
|
||||
}
|
||||
}
|
||||
|
||||
toggleSections() {
|
||||
if (this.colorsSectionTarget.classList.contains('hidden')) {
|
||||
this.showColorsSection();
|
||||
} else {
|
||||
this.showPaletteSection();
|
||||
}
|
||||
}
|
||||
|
||||
#backgroundColor(color) {
|
||||
return `color-mix(in oklab, ${color} 10%, transparent)`;
|
||||
}
|
||||
}
|
||||
@@ -21,14 +21,8 @@ export default class extends Controller {
|
||||
|
||||
handleColorChange(e) {
|
||||
const color = e.currentTarget.value;
|
||||
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 5%, white)`;
|
||||
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, white)`;
|
||||
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
|
||||
this.avatarTarget.style.color = color;
|
||||
}
|
||||
|
||||
handleParentChange(e) {
|
||||
const parent = e.currentTarget.value;
|
||||
const visibility = typeof parent === "string" && parent !== "" ? "hidden" : "visible"
|
||||
this.selectionTarget.style.visibility = visibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { Controller } from "@hotwired/stimulus";
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "list", "emptyMessage"];
|
||||
|
||||
connect() {
|
||||
this.inputTarget.focus();
|
||||
}
|
||||
|
||||
filter() {
|
||||
const filterValue = this.inputTarget.value.toLowerCase();
|
||||
const items = this.listTarget.querySelectorAll(".filterable-item");
|
||||
|
||||
@@ -8,7 +8,7 @@ export default class extends Controller {
|
||||
toggle() {
|
||||
this.panelTarget.classList.toggle("w-0");
|
||||
this.panelTarget.classList.toggle("opacity-0");
|
||||
this.panelTarget.classList.toggle("w-[260px]");
|
||||
this.panelTarget.classList.toggle("w-80");
|
||||
this.panelTarget.classList.toggle("opacity-100");
|
||||
this.contentTarget.classList.toggle("max-w-4xl");
|
||||
this.contentTarget.classList.toggle("max-w-5xl");
|
||||
|
||||
@@ -446,7 +446,7 @@ export default class extends Controller {
|
||||
|
||||
get _margin() {
|
||||
if (this.useLabelsValue) {
|
||||
return { top: 20, right: 0, bottom: 30, left: 0 };
|
||||
return { top: 20, right: 0, bottom: 10, left: 0 };
|
||||
}
|
||||
return { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
}
|
||||
|
||||
16
app/jobs/data_cache_clear_job.rb
Normal file
16
app/jobs/data_cache_clear_job.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
class DataCacheClearJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(family)
|
||||
ActiveRecord::Base.transaction do
|
||||
ExchangeRate.delete_all
|
||||
Security::Price.delete_all
|
||||
family.accounts.each do |account|
|
||||
account.balances.delete_all
|
||||
account.holdings.delete_all
|
||||
end
|
||||
|
||||
family.sync_later
|
||||
end
|
||||
end
|
||||
end
|
||||
19
app/jobs/family_reset_job.rb
Normal file
19
app/jobs/family_reset_job.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
class FamilyResetJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(family)
|
||||
# Delete all family data except users
|
||||
ActiveRecord::Base.transaction do
|
||||
# Delete accounts and related data
|
||||
family.accounts.destroy_all
|
||||
family.categories.destroy_all
|
||||
family.tags.destroy_all
|
||||
family.merchants.destroy_all
|
||||
family.plaid_items.destroy_all
|
||||
family.imports.destroy_all
|
||||
family.budgets.destroy_all
|
||||
|
||||
family.sync_later
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ class FetchSecurityInfoJob < ApplicationJob
|
||||
queue_as :latency_low
|
||||
|
||||
def perform(security_id)
|
||||
return unless Security.security_info_provider.present?
|
||||
return unless Security.provider.present?
|
||||
|
||||
security = Security.find(security_id)
|
||||
|
||||
@@ -12,7 +12,7 @@ class FetchSecurityInfoJob < ApplicationJob
|
||||
params[:mic_code] = security.exchange_mic if security.exchange_mic.present?
|
||||
params[:operating_mic] = security.exchange_operating_mic if security.exchange_operating_mic.present?
|
||||
|
||||
security_info_response = Security.security_info_provider.fetch_security_info(**params)
|
||||
security_info_response = Security.provider.fetch_security_info(**params)
|
||||
|
||||
security.update(
|
||||
name: security_info_response.info.dig("name")
|
||||
|
||||
@@ -14,6 +14,7 @@ module Account::Chartable
|
||||
])
|
||||
|
||||
balances = gapfill_balances(balances)
|
||||
balances = invert_balances(balances) if favorable_direction == "down"
|
||||
|
||||
values = [ nil, *balances ].each_cons(2).map do |prev, curr|
|
||||
Series::Value.new(
|
||||
@@ -69,6 +70,13 @@ module Account::Chartable
|
||||
SQL
|
||||
end
|
||||
|
||||
def invert_balances(balances)
|
||||
balances.map do |balance|
|
||||
balance.balance = -balance.balance
|
||||
balance
|
||||
end
|
||||
end
|
||||
|
||||
def gapfill_balances(balances)
|
||||
gapfilled = []
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Account::DataEnricher
|
||||
include Providable
|
||||
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@@ -37,7 +35,7 @@ class Account::DataEnricher
|
||||
|
||||
candidates.each do |entry|
|
||||
begin
|
||||
info = self.class.synth_provider.enrich_transaction(entry.name).info
|
||||
info = entry.fetch_enrichment_info
|
||||
|
||||
next unless info.present?
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account::Entry < ApplicationRecord
|
||||
include Monetizable
|
||||
include Monetizable, Provided
|
||||
|
||||
monetize :amount
|
||||
|
||||
|
||||
11
app/models/account/entry/provided.rb
Normal file
11
app/models/account/entry/provided.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
module Account::Entry::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Synthable
|
||||
|
||||
def fetch_enrichment_info
|
||||
return nil unless synth_client.present?
|
||||
|
||||
synth_client.enrich_transaction(name).info
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ class Account::TradeBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :type, :transfer_account_id
|
||||
:price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
|
||||
attr_reader :buildable
|
||||
|
||||
@@ -110,8 +110,9 @@ class Account::TradeBuilder
|
||||
account.family
|
||||
end
|
||||
|
||||
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
|
||||
def security
|
||||
ticker_symbol, exchange_operating_mic = ticker.split("|")
|
||||
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
||||
|
||||
Security.find_or_create_by(ticker: ticker_symbol, exchange_operating_mic: exchange_operating_mic) do |s|
|
||||
FetchSecurityInfoJob.perform_later(s.id)
|
||||
|
||||
@@ -100,11 +100,11 @@ class Budget < ApplicationRecord
|
||||
end
|
||||
|
||||
def income_category_totals
|
||||
income_totals.category_totals.reject { |ct| ct.category.subcategory? }.sort_by(&:weight).reverse
|
||||
income_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse
|
||||
end
|
||||
|
||||
def expense_category_totals
|
||||
expense_totals.category_totals.reject { |ct| ct.category.subcategory? }.sort_by(&:weight).reverse
|
||||
expense_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse
|
||||
end
|
||||
|
||||
def current?
|
||||
|
||||
@@ -8,13 +8,13 @@ class Category < ApplicationRecord
|
||||
has_many :subcategories, class_name: "Category", foreign_key: :parent_id, dependent: :nullify
|
||||
belongs_to :parent, class_name: "Category", optional: true
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
validates :name, :color, :lucide_icon, :family, presence: true
|
||||
validates :name, uniqueness: { scope: :family_id }
|
||||
|
||||
validate :category_level_limit
|
||||
validate :nested_category_matches_parent_classification
|
||||
|
||||
before_create :inherit_color_from_parent
|
||||
before_save :inherit_color_from_parent
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
scope :roots, -> { where(parent_id: nil) }
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# `Providable` serves as an extension point for integrating multiple providers.
|
||||
# For an example of a multi-provider, multi-concept implementation,
|
||||
# see: https://github.com/maybe-finance/maybe/pull/561
|
||||
|
||||
module Providable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def security_prices_provider
|
||||
synth_provider
|
||||
end
|
||||
|
||||
def security_info_provider
|
||||
synth_provider
|
||||
end
|
||||
|
||||
def exchange_rates_provider
|
||||
synth_provider
|
||||
end
|
||||
|
||||
def git_repository_provider
|
||||
Provider::Github.new
|
||||
end
|
||||
|
||||
def synth_provider
|
||||
api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"]
|
||||
api_key.present? ? Provider::Synth.new(api_key) : nil
|
||||
end
|
||||
|
||||
private
|
||||
def self_hosted?
|
||||
Rails.application.config.app_mode.self_hosted?
|
||||
end
|
||||
end
|
||||
end
|
||||
37
app/models/concerns/synthable.rb
Normal file
37
app/models/concerns/synthable.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
module Synthable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def synth_usage
|
||||
synth_client&.usage
|
||||
end
|
||||
|
||||
def synth_overage?
|
||||
synth_usage&.usage&.utilization.to_i >= 100
|
||||
end
|
||||
|
||||
def synth_healthy?
|
||||
synth_client&.healthy?
|
||||
end
|
||||
|
||||
def synth_client
|
||||
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
|
||||
|
||||
return nil unless api_key.present?
|
||||
|
||||
Provider::Synth.new(api_key)
|
||||
end
|
||||
end
|
||||
|
||||
def synth_client
|
||||
self.class.synth_client
|
||||
end
|
||||
|
||||
def synth_usage
|
||||
self.class.synth_usage
|
||||
end
|
||||
|
||||
def synth_overage?
|
||||
self.class.synth_overage?
|
||||
end
|
||||
end
|
||||
@@ -61,30 +61,83 @@ class Demo::Generator
|
||||
puts "Demo data loaded successfully!"
|
||||
end
|
||||
|
||||
def generate_multi_currency_data!
|
||||
def generate_basic_budget_data!(family_names)
|
||||
puts "Clearing existing data..."
|
||||
|
||||
destroy_everything!
|
||||
|
||||
puts "Data cleared"
|
||||
|
||||
create_family_and_user!("Demo Family 1", "user@maybe.local", currency: "EUR")
|
||||
|
||||
family = Family.find_by(name: "Demo Family 1")
|
||||
family_names.each_with_index do |family_name, index|
|
||||
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local")
|
||||
end
|
||||
|
||||
puts "Users reset"
|
||||
|
||||
usd_checking = family.accounts.create!(name: "USD Checking", currency: "USD", balance: 10000, accountable: Depository.new)
|
||||
eur_checking = family.accounts.create!(name: "EUR Checking", currency: "EUR", balance: 4900, accountable: Depository.new)
|
||||
family_names.each do |family_name|
|
||||
family = Family.find_by(name: family_name)
|
||||
|
||||
puts "Accounts created"
|
||||
ActiveRecord::Base.transaction do
|
||||
# Create parent categories
|
||||
food = family.categories.create!(name: "Food & Drink", color: COLORS.sample, classification: "expense")
|
||||
transport = family.categories.create!(name: "Transportation", color: COLORS.sample, classification: "expense")
|
||||
|
||||
create_transaction!(account: usd_checking, amount: -11000, currency: "USD", name: "USD income Transaction")
|
||||
create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction")
|
||||
create_transaction!(account: eur_checking, amount: -5000, currency: "EUR", name: "EUR income Transaction")
|
||||
create_transaction!(account: eur_checking, amount: 100, currency: "EUR", name: "EUR expense Transaction")
|
||||
# Create subcategory
|
||||
restaurants = family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense")
|
||||
|
||||
puts "Transactions created"
|
||||
# Create checking account
|
||||
checking = family.accounts.create!(
|
||||
accountable: Depository.new,
|
||||
name: "Demo Checking",
|
||||
balance: 3000,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
# Create one transaction for each category
|
||||
create_transaction!(account: checking, amount: 100, name: "Grocery Store", category: food, date: 2.days.ago)
|
||||
create_transaction!(account: checking, amount: 50, name: "Restaurant Meal", category: restaurants, date: 1.day.ago)
|
||||
create_transaction!(account: checking, amount: 20, name: "Gas Station", category: transport, date: Date.current)
|
||||
end
|
||||
|
||||
puts "Basic budget data created for #{family_name}"
|
||||
end
|
||||
|
||||
puts "Demo data loaded successfully!"
|
||||
end
|
||||
|
||||
def generate_multi_currency_data!(family_names)
|
||||
puts "Clearing existing data..."
|
||||
|
||||
destroy_everything!
|
||||
|
||||
puts "Data cleared"
|
||||
|
||||
family_names.each_with_index do |family_name, index|
|
||||
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", currency: "EUR")
|
||||
end
|
||||
|
||||
puts "Users reset"
|
||||
|
||||
family_names.each do |family_name|
|
||||
puts "Generating demo data for #{family_name}"
|
||||
family = Family.find_by(name: family_name)
|
||||
|
||||
usd_checking = family.accounts.create!(name: "USD Checking", currency: "USD", balance: 10000, accountable: Depository.new)
|
||||
eur_checking = family.accounts.create!(name: "EUR Checking", currency: "EUR", balance: 4900, accountable: Depository.new)
|
||||
eur_credit_card = family.accounts.create!(name: "EUR Credit Card", currency: "EUR", balance: 2300, accountable: CreditCard.new)
|
||||
|
||||
create_transaction!(account: eur_credit_card, amount: 1000, currency: "EUR", name: "EUR cc expense 1")
|
||||
create_transaction!(account: eur_credit_card, amount: 1000, currency: "EUR", name: "EUR cc expense 2")
|
||||
create_transaction!(account: eur_credit_card, amount: 300, currency: "EUR", name: "EUR cc expense 3")
|
||||
|
||||
create_transaction!(account: usd_checking, amount: -11000, currency: "USD", name: "USD income Transaction")
|
||||
create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction")
|
||||
create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction")
|
||||
create_transaction!(account: eur_checking, amount: -5000, currency: "EUR", name: "EUR income Transaction")
|
||||
create_transaction!(account: eur_checking, amount: 100, currency: "EUR", name: "EUR expense Transaction")
|
||||
|
||||
puts "Transactions created for #{family_name}"
|
||||
end
|
||||
|
||||
puts "Demo data loaded successfully!"
|
||||
end
|
||||
@@ -142,9 +195,9 @@ class Demo::Generator
|
||||
family.categories.bootstrap_defaults
|
||||
|
||||
food = family.categories.find_by(name: "Food & Drink")
|
||||
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, lucide_icon: "utensils", classification: "expense")
|
||||
family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, lucide_icon: "shopping-cart", classification: "expense")
|
||||
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, lucide_icon: "beer", classification: "expense")
|
||||
end
|
||||
|
||||
def create_merchants!(family)
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
module ExchangeRate::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Providable
|
||||
include Synthable
|
||||
|
||||
class_methods do
|
||||
def provider_healthy?
|
||||
exchange_rates_provider.present? && exchange_rates_provider.healthy?
|
||||
def provider
|
||||
synth_client
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false)
|
||||
return [] unless exchange_rates_provider.present?
|
||||
return [] unless provider.present?
|
||||
|
||||
response = exchange_rates_provider.fetch_exchange_rates \
|
||||
response = provider.fetch_exchange_rates \
|
||||
from: from,
|
||||
to: to,
|
||||
start_date: start_date,
|
||||
@@ -38,9 +37,9 @@ module ExchangeRate::Provided
|
||||
end
|
||||
|
||||
def fetch_rate_from_provider(from:, to:, date:, cache: false)
|
||||
return nil unless exchange_rates_provider.present?
|
||||
return nil unless provider.present?
|
||||
|
||||
response = exchange_rates_provider.fetch_exchange_rate \
|
||||
response = provider.fetch_exchange_rate \
|
||||
from: from,
|
||||
to: to,
|
||||
date: date
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Family < ApplicationRecord
|
||||
include Providable, Plaidable, Syncable, AutoTransferMatchable
|
||||
include Synthable, Plaidable, Syncable, AutoTransferMatchable
|
||||
|
||||
DATE_FORMATS = [
|
||||
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
||||
@@ -92,22 +92,25 @@ class Family < ApplicationRecord
|
||||
).link_token
|
||||
end
|
||||
|
||||
def synth_usage
|
||||
self.class.synth_provider&.usage
|
||||
end
|
||||
|
||||
def synth_overage?
|
||||
self.class.synth_provider&.usage&.utilization.to_i >= 100
|
||||
end
|
||||
|
||||
def synth_valid?
|
||||
self.class.synth_provider&.healthy?
|
||||
end
|
||||
|
||||
def subscribed?
|
||||
stripe_subscription_status == "active"
|
||||
end
|
||||
|
||||
def requires_data_provider?
|
||||
# If family has any trades, they need a provider for historical prices
|
||||
return true if trades.any?
|
||||
|
||||
# If family has any accounts not denominated in the family's currency, they need a provider for historical exchange rates
|
||||
return true if accounts.where.not(currency: self.currency).any?
|
||||
|
||||
# If family has any entries in different currencies, they need a provider for historical exchange rates
|
||||
uniq_currencies = entries.pluck(:currency).uniq
|
||||
return true if uniq_currencies.count > 1
|
||||
return true if uniq_currencies.first != self.currency
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def primary_user
|
||||
users.order(:created_at).first
|
||||
end
|
||||
|
||||
@@ -34,6 +34,18 @@ class Import < ApplicationRecord
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
|
||||
class << self
|
||||
def parse_csv_str(csv_str, col_sep: ",")
|
||||
CSV.parse(
|
||||
(csv_str || "").strip,
|
||||
headers: true,
|
||||
col_sep: col_sep,
|
||||
converters: [ ->(str) { str&.strip } ],
|
||||
liberal_parsing: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def publish_later
|
||||
raise "Import is not publishable" unless publishable?
|
||||
|
||||
@@ -111,6 +123,7 @@ class Import < ApplicationRecord
|
||||
date: row[date_col_label].to_s,
|
||||
qty: sanitize_number(row[qty_col_label]).to_s,
|
||||
ticker: row[ticker_col_label].to_s,
|
||||
exchange_operating_mic: row[exchange_operating_mic_col_label].to_s,
|
||||
price: sanitize_number(row[price_col_label]).to_s,
|
||||
amount: sanitize_number(row[amount_col_label]).to_s,
|
||||
currency: (row[currency_col_label] || default_currency).to_s,
|
||||
@@ -177,12 +190,7 @@ class Import < ApplicationRecord
|
||||
end
|
||||
|
||||
def parsed_csv
|
||||
@parsed_csv ||= CSV.parse(
|
||||
(raw_file_str || "").strip,
|
||||
headers: true,
|
||||
col_sep: col_sep,
|
||||
converters: [ ->(str) { str&.strip } ]
|
||||
)
|
||||
@parsed_csv ||= self.class.parse_csv_str(raw_file_str, col_sep: col_sep)
|
||||
end
|
||||
|
||||
def sanitize_number(value)
|
||||
|
||||
@@ -66,21 +66,25 @@ class IncomeStatement
|
||||
totals = totals_query(transactions_scope: family.transactions.active.in_period(period)).select { |t| t.classification == classification }
|
||||
classification_total = totals.sum(&:total)
|
||||
|
||||
category_totals = totals.map do |ct|
|
||||
# If parent category is nil, it's a top-level category. This means we need to
|
||||
# sum itself + SUM(children) to get the overall category total
|
||||
children_totals = if ct.parent_category_id.nil? && ct.category_id.present?
|
||||
totals.select { |t| t.parent_category_id == ct.category_id }.sum(&:total)
|
||||
else
|
||||
uncategorized_category = family.categories.uncategorized
|
||||
|
||||
category_totals = [ *categories, uncategorized_category ].map do |category|
|
||||
subcategory = categories.find { |c| c.id == category.parent_id }
|
||||
|
||||
parent_category_total = totals.select { |t| t.category_id == category.id }&.sum(&:total) || 0
|
||||
|
||||
children_totals = if category == uncategorized_category
|
||||
0
|
||||
else
|
||||
totals.select { |t| t.parent_category_id == category.id }&.sum(&:total) || 0
|
||||
end
|
||||
|
||||
category_total = ct.total + children_totals
|
||||
category_total = parent_category_total + children_totals
|
||||
|
||||
weight = (category_total.zero? ? 0 : category_total.to_f / classification_total) * 100
|
||||
|
||||
CategoryTotal.new(
|
||||
category: categories.find { |c| c.id == ct.category_id } || family.categories.uncategorized,
|
||||
category: category,
|
||||
total: category_total,
|
||||
currency: family.currency,
|
||||
weight: weight,
|
||||
|
||||
@@ -128,11 +128,12 @@ class Provider::Synth
|
||||
raw_response: error
|
||||
end
|
||||
|
||||
def search_securities(query:, dataset: "limited", country_code:)
|
||||
def search_securities(query:, dataset: "limited", country_code: nil, exchange_operating_mic: nil)
|
||||
response = client.get("#{base_url}/tickers/search") do |req|
|
||||
req.params["name"] = query
|
||||
req.params["dataset"] = dataset
|
||||
req.params["country_code"] = country_code
|
||||
req.params["country_code"] = country_code if country_code.present?
|
||||
req.params["exchange_operating_mic"] = exchange_operating_mic if exchange_operating_mic.present?
|
||||
req.params["limit"] = 25
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Security < ApplicationRecord
|
||||
include Providable
|
||||
include Provided
|
||||
|
||||
before_save :upcase_ticker
|
||||
|
||||
has_many :trades, dependent: :nullify, class_name: "Account::Trade"
|
||||
@@ -8,16 +9,6 @@ class Security < ApplicationRecord
|
||||
validates :ticker, presence: true
|
||||
validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false }
|
||||
|
||||
class << self
|
||||
def search(query)
|
||||
security_prices_provider.search_securities(
|
||||
query: query[:search],
|
||||
dataset: "limited",
|
||||
country_code: query[:country]
|
||||
).securities.map { |attrs| new(**attrs) }
|
||||
end
|
||||
end
|
||||
|
||||
def current_price
|
||||
@current_price ||= Security::Price.find_price(security: self, date: Date.current)
|
||||
return nil if @current_price.nil?
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
module Security::Price::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Providable
|
||||
include Synthable
|
||||
|
||||
class_methods do
|
||||
private
|
||||
def provider
|
||||
synth_client
|
||||
end
|
||||
|
||||
private
|
||||
def fetch_price_from_provider(security:, date:, cache: false)
|
||||
return nil unless security_prices_provider.present?
|
||||
return nil unless provider.present?
|
||||
return nil unless security.has_prices?
|
||||
|
||||
response = security_prices_provider.fetch_security_prices \
|
||||
response = provider.fetch_security_prices \
|
||||
ticker: security.ticker,
|
||||
mic_code: security.exchange_mic,
|
||||
start_date: date,
|
||||
@@ -31,11 +34,11 @@ module Security::Price::Provided
|
||||
end
|
||||
|
||||
def fetch_prices_from_provider(security:, start_date:, end_date:, cache: false)
|
||||
return [] unless security_prices_provider.present?
|
||||
return [] unless provider.present?
|
||||
return [] unless security
|
||||
return [] unless security.has_prices?
|
||||
|
||||
response = security_prices_provider.fetch_security_prices \
|
||||
response = provider.fetch_security_prices \
|
||||
ticker: security.ticker,
|
||||
mic_code: security.exchange_mic,
|
||||
start_date: start_date,
|
||||
|
||||
26
app/models/security/provided.rb
Normal file
26
app/models/security/provided.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
module Security::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Synthable
|
||||
|
||||
class_methods do
|
||||
def provider
|
||||
synth_client
|
||||
end
|
||||
|
||||
def search_provider(query)
|
||||
response = provider.search_securities(
|
||||
query: query[:search],
|
||||
dataset: "limited",
|
||||
country_code: query[:country],
|
||||
exchange_operating_mic: query[:exchange_operating_mic]
|
||||
)
|
||||
|
||||
if response.success?
|
||||
response.securities.map { |attrs| new(**attrs) }
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,14 +5,24 @@ class TradeImport < Import
|
||||
|
||||
rows.each do |row|
|
||||
account = mappings.accounts.mappable_for(row.account)
|
||||
security = Security.find_or_create_by(ticker: row.ticker)
|
||||
|
||||
# Try to find or create security with ticker only
|
||||
security = find_or_create_security(
|
||||
ticker: row.ticker,
|
||||
exchange_operating_mic: row.exchange_operating_mic
|
||||
)
|
||||
|
||||
entry = account.entries.build \
|
||||
date: row.date_iso,
|
||||
amount: row.signed_amount,
|
||||
name: row.name,
|
||||
currency: row.currency,
|
||||
entryable: Account::Trade.new(security: security, qty: row.qty, currency: row.currency, price: row.price),
|
||||
currency: row.currency.presence || account.currency,
|
||||
entryable: Account::Trade.new(
|
||||
security: security,
|
||||
qty: row.qty,
|
||||
currency: row.currency.presence || account.currency,
|
||||
price: row.price
|
||||
),
|
||||
import: self
|
||||
|
||||
entry.save!
|
||||
@@ -29,7 +39,7 @@ class TradeImport < Import
|
||||
end
|
||||
|
||||
def column_keys
|
||||
%i[date ticker qty price currency account name]
|
||||
%i[date ticker exchange_operating_mic currency qty price account name]
|
||||
end
|
||||
|
||||
def dry_run
|
||||
@@ -41,12 +51,52 @@ class TradeImport < Import
|
||||
|
||||
def csv_template
|
||||
template = <<-CSV
|
||||
date*,ticker*,qty*,price*,currency,account,name
|
||||
05/15/2024,AAPL,10,150.00,USD,Trading Account,Apple Inc. Purchase
|
||||
05/16/2024,GOOGL,-5,2500.00,USD,Investment Account,Alphabet Inc. Sale
|
||||
05/17/2024,TSLA,2,700.50,USD,Retirement Account,Tesla Inc. Purchase
|
||||
date*,ticker*,exchange_operating_mic,currency,qty*,price*,account,name
|
||||
05/15/2024,AAPL,XNAS,USD,10,150.00,Trading Account,Apple Inc. Purchase
|
||||
05/16/2024,GOOGL,XNAS,USD,-5,2500.00,Investment Account,Alphabet Inc. Sale
|
||||
05/17/2024,TSLA,XNAS,USD,2,700.50,Retirement Account,Tesla Inc. Purchase
|
||||
CSV
|
||||
|
||||
CSV.parse(template, headers: true)
|
||||
end
|
||||
|
||||
private
|
||||
def find_or_create_security(ticker:, exchange_operating_mic:)
|
||||
# Normalize empty string to nil for consistency
|
||||
exchange_operating_mic = nil if exchange_operating_mic.blank?
|
||||
|
||||
# First try to find an exact match in our DB, or if no exchange_operating_mic is provided, find by ticker only
|
||||
internal_security = if exchange_operating_mic.present?
|
||||
Security.find_by(ticker:, exchange_operating_mic:)
|
||||
else
|
||||
Security.find_by(ticker:)
|
||||
end
|
||||
|
||||
return internal_security if internal_security.present?
|
||||
|
||||
# If security prices provider isn't properly configured or available, create with nil exchange_operating_mic
|
||||
return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) unless Security.provider.present?
|
||||
|
||||
# Cache provider responses so that when we're looping through rows and importing,
|
||||
# we only hit our provider for the unique combinations of ticker / exchange_operating_mic
|
||||
cache_key = [ ticker, exchange_operating_mic ]
|
||||
@provider_securities_cache ||= {}
|
||||
|
||||
provider_security = @provider_securities_cache[cache_key] ||= begin
|
||||
Security.search_provider(
|
||||
query: ticker,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
).first
|
||||
end
|
||||
|
||||
return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) if provider_security.nil?
|
||||
|
||||
Security.find_or_create_by!(ticker: provider_security[:ticker], exchange_operating_mic: provider_security[:exchange_operating_mic]) do |security|
|
||||
security.name = provider_security[:name]
|
||||
security.country_code = provider_security[:country_code]
|
||||
security.logo_url = provider_security[:logo_url]
|
||||
security.exchange_acronym = provider_security[:exchange_acronym]
|
||||
security.exchange_mic = provider_security[:exchange_mic]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
module Upgrader::Provided
|
||||
extend ActiveSupport::Concern
|
||||
include Providable
|
||||
|
||||
class_methods do
|
||||
private
|
||||
def fetch_latest_upgrade_candidates_from_provider
|
||||
git_repository_provider.fetch_latest_upgrade_candidates
|
||||
end
|
||||
|
||||
def git_repository_provider
|
||||
Provider::Github.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,9 +27,18 @@
|
||||
}} %>
|
||||
|
||||
<% if %w[buy sell].include?(type) %>
|
||||
<div class="form-field combobox">
|
||||
<%= form.combobox :ticker, securities_path(country_code: Current.family.country), label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %>
|
||||
</div>
|
||||
<% if Security.provider.present? %>
|
||||
<div class="form-field combobox">
|
||||
<%= form.combobox :ticker,
|
||||
securities_path(country_code: Current.family.country),
|
||||
name_when_new: "account_entry[manual_ticker]",
|
||||
label: t(".holding"),
|
||||
placeholder: t(".ticker_placeholder"),
|
||||
required: true %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= form.text_field :manual_ticker, label: "Ticker", placeholder: "AAPL", required: true %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= form.date_field :date, label: true, value: Date.current, required: true %>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<%= turbo_frame_tag "bulk_transaction_edit_drawer" do %>
|
||||
<dialog data-controller="modal"
|
||||
data-action="click->modal#clickOutside"
|
||||
class="bg-white shadow-border-xs rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full mt-4 mr-4 ml-auto">
|
||||
class="bg-white shadow-border-xs rounded-2xl max-h-[calc(100vh-32px)] h-full max-w-[480px] w-full mt-4 mr-4 ml-auto">
|
||||
<%= styled_form_with url: bulk_update_account_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
|
||||
<div class="flex h-full flex-col justify-between p-4">
|
||||
<div class="flex h-full flex-col justify-between p-4 gap-4">
|
||||
<div>
|
||||
<div class="flex h-9 items-center justify-end">
|
||||
<div data-action="click->modal#close" class="cursor-pointer">
|
||||
@@ -49,12 +49,12 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end items-center gap-2">
|
||||
<%= link_to t(".cancel"), transactions_path, class: "text-sm font-medium text-primary px-3 py-2" %>
|
||||
<%= link_to t(".cancel"), transactions_path, class: "btn btn--ghost" %>
|
||||
|
||||
<%= tag.button t(".save"),
|
||||
type: "button",
|
||||
data: { "bulk-select-scope-param": "bulk_update", action: "bulk-select#submitBulkRequest" },
|
||||
class: "px-3 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg" %>
|
||||
class: "btn btn--primary" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
|
||||
<%= tag.p @series.trend.percent_formatted,
|
||||
style: "color: #{@series.trend.color}",
|
||||
class: "text-right text-xs font-medium text-primary" %>
|
||||
class: "font-mono text-right text-xs font-medium text-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
<%# locals: (family:) %>
|
||||
|
||||
<% if family.requires_data_provider? && family.synth_client.nil? %>
|
||||
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
|
||||
<summary class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "triangle-alert", size: "sm" %>
|
||||
<p class="font-medium">Missing historical data</p>
|
||||
</div>
|
||||
|
||||
<%= lucide_icon "chevron-down", class: "text-yellow-600 group-open:transform group-open:rotate-180 w-5" %>
|
||||
</summary>
|
||||
<div class="text-xs py-2 space-y-2">
|
||||
<p>Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.</p>
|
||||
|
||||
<p>
|
||||
<%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
|
||||
<div
|
||||
class="space-y-3"
|
||||
data-controller="tabs"
|
||||
@@ -8,21 +28,21 @@
|
||||
data-tabs-inactive-class="text-secondary"
|
||||
data-tabs-default-tab-value="assets-tab">
|
||||
<div class="bg-surface-inset rounded-lg p-1 flex">
|
||||
<button type="button" data-id="assets-tab" class="w-1/3 px-2 py-1 rounded-md text-sm text-secondary" data-tabs-target="btn" data-action="click->tabs#select">
|
||||
<button type="button" data-id="assets-tab" class="w-1/3 px-2 py-1 rounded-md text-sm text-secondary font-medium" data-tabs-target="btn" data-action="click->tabs#select">
|
||||
Assets
|
||||
</button>
|
||||
|
||||
<button type="button" data-id="debts-tab" class="w-1/3 px-2 py-1 rounded-md text-secondary text-sm" data-tabs-target="btn" data-action="click->tabs#select">
|
||||
<button type="button" data-id="debts-tab" class="w-1/3 px-2 py-1 rounded-md text-secondary text-sm font-medium" data-tabs-target="btn" data-action="click->tabs#select">
|
||||
Debts
|
||||
</button>
|
||||
|
||||
<button type="button" data-id="all-tab" class="w-1/3 px-2 py-1 rounded-md text-secondary text-sm" data-tabs-target="btn" data-action="click->tabs#select">
|
||||
<button type="button" data-id="all-tab" class="w-1/3 px-2 py-1 rounded-md text-secondary text-sm font-medium" data-tabs-target="btn" data-action="click->tabs#select">
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div data-tabs-target="tab" id="assets-tab">
|
||||
<%= link_to new_account_path(step: "method_select"),
|
||||
<%= link_to new_account_path(step: "method_select", classification: "asset"),
|
||||
class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= icon("plus") %>
|
||||
@@ -37,7 +57,7 @@
|
||||
</div>
|
||||
|
||||
<div data-tabs-target="tab" id="debts-tab" class="hidden">
|
||||
<%= link_to new_account_path(step: "method_select"),
|
||||
<%= link_to new_account_path(step: "method_select", classification: "liability"),
|
||||
class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= icon("plus") %>
|
||||
|
||||
@@ -19,16 +19,16 @@
|
||||
|
||||
<div class="space-y-1">
|
||||
<% account_group.accounts.each do |account| %>
|
||||
<%= link_to account_path(account), class: "block flex items-center gap-2 btn btn--ghost" do %>
|
||||
<%= link_to account_path(account), class: "block flex items-center gap-2 btn btn--ghost", title: account.name do %>
|
||||
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
||||
|
||||
<div>
|
||||
<%= tag.p account.name, class: "text-sm font-medium mb-0.5" %>
|
||||
<%= tag.p account.subtype&.humanize.presence || account_group.name, class: "text-sm text-secondary" %>
|
||||
<div class="min-w-0 grow">
|
||||
<%= tag.p account.name, class: "text-sm font-medium mb-0.5 truncate" %>
|
||||
<%= tag.p account.subtype&.humanize.presence || account_group.name, class: "text-sm text-secondary truncate" %>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto text-right grow h-10">
|
||||
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary" %>
|
||||
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
|
||||
<div class="flex items-center w-8 h-5 ml-auto">
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
} %>
|
||||
|
||||
<% if account.plaid_account_id? && account.institution_domain.present? %>
|
||||
<%= image_tag "https://logo.synthfinance.com/#{account.institution_domain}", class: "rounded-full #{size_classes[size]}" %>
|
||||
<%= image_tag "https://logo.synthfinance.com/#{account.institution_domain}", class: "shrink-0 rounded-full #{size_classes[size]}" %>
|
||||
<% elsif account.logo.attached? %>
|
||||
<%= image_tag account.logo, class: "rounded-full #{size_classes[size]}" %>
|
||||
<%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %>
|
||||
<% else %>
|
||||
<%= circle_logo(account.name, hex: color || account.accountable.color, size: size) %>
|
||||
<% end %>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<%= tag.span period.comparison_label, class: "text-secondary" %>
|
||||
</div>
|
||||
|
||||
<div class="h-64">
|
||||
<div class="h-64 pb-4">
|
||||
<% if series.any? %>
|
||||
<div
|
||||
id="lineChart"
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
<%= render layout: "accounts/new/container", locals: { title: t(".title") } do %>
|
||||
<div class="text-sm">
|
||||
<%= render "account_type", accountable: Depository.new %>
|
||||
<%= render "account_type", accountable: Investment.new %>
|
||||
<%= render "account_type", accountable: Crypto.new %>
|
||||
<%= render "account_type", accountable: Property.new %>
|
||||
<%= render "account_type", accountable: Vehicle.new %>
|
||||
<%= render "account_type", accountable: CreditCard.new %>
|
||||
<%= render "account_type", accountable: Loan.new %>
|
||||
<%= render "account_type", accountable: OtherAsset.new %>
|
||||
<%= render "account_type", accountable: OtherLiability.new %>
|
||||
<% unless params[:classification] == "liability" %>
|
||||
<%= render "account_type", accountable: Depository.new %>
|
||||
<%= render "account_type", accountable: Investment.new %>
|
||||
<%= render "account_type", accountable: Crypto.new %>
|
||||
<%= render "account_type", accountable: Property.new %>
|
||||
<%= render "account_type", accountable: Vehicle.new %>
|
||||
<% end %>
|
||||
|
||||
<% unless params[:classification] == "asset" %>
|
||||
<%= render "account_type", accountable: CreditCard.new %>
|
||||
<%= render "account_type", accountable: Loan.new %>
|
||||
<% end %>
|
||||
|
||||
<% unless params[:classification] == "liability" %>
|
||||
<%= render "account_type", accountable: OtherAsset.new %>
|
||||
<% end %>
|
||||
|
||||
<% unless params[:classification] == "asset" %>
|
||||
<%= render "account_type", accountable: OtherLiability.new %>
|
||||
<% end %>
|
||||
|
||||
<% unless params[:return_to].present? %>
|
||||
<%= button_to imports_path(import: { type: "AccountImport" }),
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
|
||||
<%= tag.p @account.sparkline_series.trend.percent_formatted,
|
||||
style: "color: #{@account.sparkline_series.trend.color}",
|
||||
class: "text-right text-xs font-medium text-primary" %>
|
||||
class: "font-mono text-right text-xs font-medium text-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<% param_key = Budget.date_to_param(date) %>
|
||||
|
||||
<% if Budget.budget_date_valid?(date, family: family) %>
|
||||
<%= link_to month_name, budget_path(param_key), data: { turbo_frame: "_top" }, class: "block px-3 py-2 text-sm text-primary hover:bg-gray-100 rounded-md" %>
|
||||
<%= link_to month_name, budget_path(param_key), data: { turbo_frame: "_top" }, class: "btn btn--ghost" %>
|
||||
<% else %>
|
||||
<span class="px-3 py-2 text-subdued rounded-md"><%= month_name %></span>
|
||||
<% end %>
|
||||
|
||||
8
app/views/categories/_color_avatar.html.erb
Normal file
8
app/views/categories/_color_avatar.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<%# locals: (category:) %>
|
||||
|
||||
<span
|
||||
data-category-target="avatar"
|
||||
class="w-14 h-14 flex items-center justify-center rounded-full"
|
||||
style="background-color: color-mix(in oklab, <%= category.color %> 10%, transparent); color: <%= category.color %>">
|
||||
<%= lucide_icon(category.lucide_icon, class: "w-8 h-8") %>
|
||||
</span>
|
||||
@@ -1,42 +1,70 @@
|
||||
<%# locals: (category:, categories:) %>
|
||||
|
||||
<div data-controller="color-avatar">
|
||||
<div data-controller="category" data-category-preset-colors-value="<%= Category::COLORS %>">
|
||||
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
|
||||
<section class="space-y-4">
|
||||
<div class="w-fit m-auto">
|
||||
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
|
||||
<%= render partial: "color_avatar", locals: { category: category } %>
|
||||
</div>
|
||||
<details data-category-target="details">
|
||||
<summary class="cursor-pointer absolute top-23 left-58.5 flex justify-center items-center bg-gray-50 border-2 w-7 h-7 border-white rounded-full text-gray-500">
|
||||
<%= icon("pen", size: "sm") %>
|
||||
</summary>
|
||||
|
||||
<div class="flex gap-2 items-center justify-center" data-color-avatar-target="selection">
|
||||
<% Category::COLORS.each do |color| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-avatar#handleColorChange" } %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class=" absolute z-50 bg-white p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit left-66 top-24">
|
||||
<div class="flex gap-2 flex-col mb-4" data-category-target="selection" style="<%= "display:none;" if @category.subcategory? %>">
|
||||
<div data-category-target="pickerSection"></div>
|
||||
<h4 class="text-gray-500 text-sm">Color</h4>
|
||||
<div class="flex gap-2 items-center" data-category-target="colorsSection">
|
||||
<% Category::COLORS.each do |color| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" style="background-color: <%= color %>"></div>
|
||||
</label>
|
||||
<% end %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, "custom-color", class: "sr-only peer", data: { category_target: "colorPickerRadioBtn"} %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" data-category-target="pickerBtn" style="background: conic-gradient(red,orange,yellow,lime,green,teal,cyan,blue,indigo,purple,magenta,pink,red)"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center hidden flex-col" data-category-target="paletteSection">
|
||||
<div class="flex gap-2 items-center w-full">
|
||||
<div class="w-6 h-6 p-4 rounded-full cursor-pointer" style="background-color: <%= category.color %>" data-category-target="colorPreview"></div>
|
||||
<%= f.text_field :color , data: { category_target: "colorInput"}, class: "form-field__input blah", inline: true %>
|
||||
<%= lucide_icon "palette", class: "w-8 h-8 cursor-pointer hover:bg-gray-100 p-1", data: { action: "click->category#toggleSections" } %>
|
||||
</div>
|
||||
<div data-category-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive ">
|
||||
<span>Poor contrast, choose darker color or</span>
|
||||
<button type="button" class="underline cursor-pointer" data-action="category#autoAdjust">auto-adjust.</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-center flex-col w-87">
|
||||
<h4 class="text-gray-500 text-sm">Icon</h4>
|
||||
<div class="flex flex-wrap gap-0.5">
|
||||
<% Category.icon_codes.each do |icon| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->category#handleIconChange change->category#handleIconColorChange", category_target:"icon" } %>
|
||||
<div class="w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-gray-100 peer-checked:bg-gray-100 border-1 border-transparent">
|
||||
<%= lucide_icon icon, class: "w-6 h-6 p-1" %>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<% if category.errors.any? %>
|
||||
<%= render "shared/form_errors", model: category %>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-center mb-4">
|
||||
|
||||
<% Category.icon_codes.each do |icon| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer" %>
|
||||
<div class="p-1 rounded cursor-pointer hover:bg-gray-100 peer-checked:bg-gray-100 border-1 border-transparent peer-checked:border-gray-500">
|
||||
<%= lucide_icon icon, class: "w-5 h-5" %>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %>
|
||||
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
|
||||
<% unless category.parent? %>
|
||||
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->color-avatar#handleParentChange" } %>
|
||||
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->category#handleParentChange" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<%# locals: (category:) %>
|
||||
<% is_selected = category.id === @selected_category&.id %>
|
||||
|
||||
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
|
||||
<%= content_tag :div,
|
||||
class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full hover:bg-gray-25 focus-within:bg-gray-25",
|
||||
{ "bg-gray-25": is_selected }],
|
||||
data: { filter_name: category.name } do %>
|
||||
<%= button_to account_transaction_category_path(
|
||||
@transaction.entry,
|
||||
account_entry: {
|
||||
@@ -10,7 +13,7 @@
|
||||
}
|
||||
),
|
||||
method: :patch,
|
||||
class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
|
||||
class: "flex w-full items-center gap-1.5 cursor-pointer focus:outline-none" do %>
|
||||
|
||||
<span class="w-5 h-5">
|
||||
<%= lucide_icon("check", class: "w-5 h-5 text-secondary") if is_selected %>
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
<%# locals: (import:) %>
|
||||
|
||||
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %>
|
||||
<%= form.select :date_format, Family::DATE_FORMATS, { label: t(".date_format_label")}, label: true, required: true %>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %>
|
||||
<%= form.select :date_format, Family::DATE_FORMATS, { label: t(".date_format_label")}, label: true, required: true %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :qty_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Quantity" } %>
|
||||
<%= form.select :signage_convention, [["Buys are positive qty", "inflows_positive"], ["Buys are negative qty", "inflows_negative"]], label: true %>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :qty_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Quantity" } %>
|
||||
<%= form.select :signage_convention, [["Buys are positive qty", "inflows_positive"], ["Buys are negative qty", "inflows_negative"]], label: true %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %>
|
||||
<%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %>
|
||||
<%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: "Format", prompt: "Select format" }, required: true %>
|
||||
</div>
|
||||
|
||||
<%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Ticker" } %>
|
||||
<%= form.select :price_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Price" } %>
|
||||
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||
<%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Ticker" } %>
|
||||
<%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Exchange Operating MIC" } %>
|
||||
<%= form.select :price_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Price" } %>
|
||||
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||
|
||||
<% unless Security.provider %>
|
||||
<div class="alert alert-warning">
|
||||
<p>
|
||||
<strong>Note:</strong> The security prices provider is not configured. Your trade imports will work, but Maybe will not backfill price history. Please go to your settings to configure this.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||
<% end %>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= render "layouts/sidebar/nav_item", name: "Budgets", path: budgets_path, icon_key: "layout-grid" %>
|
||||
<%= render "layouts/sidebar/nav_item", name: "Budgets", path: budgets_path, icon_key: "map" %>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300", Current.user.show_sidebar? ? "w-[260px]" : "w-0"), data: { sidebar_target: "panel" } do %>
|
||||
<%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300", Current.user.show_sidebar? ? "w-80" : "w-0"), data: { sidebar_target: "panel" } do %>
|
||||
<% if content_for?(:sidebar) %>
|
||||
<%= yield :sidebar %>
|
||||
<% else %>
|
||||
@@ -44,6 +44,16 @@
|
||||
<% end %>
|
||||
|
||||
<%= tag.div class: class_names("mx-auto w-full h-full", Current.user.show_sidebar? ? "max-w-4xl" : "max-w-5xl"), data: { sidebar_target: "content" } do %>
|
||||
<% if content_for?(:breadcrumbs) %>
|
||||
<%= yield :breadcrumbs %>
|
||||
<% else %>
|
||||
<%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %>
|
||||
<% end %>
|
||||
|
||||
<% if content_for?(:page_header) %>
|
||||
<%= yield :page_header %>
|
||||
<% end %>
|
||||
|
||||
<%= yield %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<%= render "layouts/shared/htmldoc" do %>
|
||||
<div class="flex h-full bg-gray-25">
|
||||
<div class="p-4 w-[260px] shrink-0 h-full overflow-y-auto">
|
||||
<div class="p-4 w-96 shrink-0 h-full overflow-y-auto">
|
||||
<%= render "settings/settings_nav" %>
|
||||
</div>
|
||||
|
||||
<main class="py-4 px-10 grow flex h-full overflow-y-auto">
|
||||
<div class="relative max-w-4xl mx-auto flex flex-col w-full h-full">
|
||||
<div class="grow space-y-4 overflow-y-auto -mx-1 px-1 pb-12">
|
||||
<% if content_for?(:breadcrumbs) %>
|
||||
<%= yield :breadcrumbs %>
|
||||
<% else %>
|
||||
<%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs, sidebar_toggle_enabled: false %>
|
||||
<% end %>
|
||||
|
||||
<% if content_for?(:page_title) %>
|
||||
<h1 class="text-primary text-xl font-medium">
|
||||
<%= content_for :page_title %>
|
||||
|
||||
25
app/views/layouts/shared/_breadcrumbs.html.erb
Normal file
25
app/views/layouts/shared/_breadcrumbs.html.erb
Normal file
@@ -0,0 +1,25 @@
|
||||
<%# locals: (breadcrumbs:, sidebar_toggle_enabled: true) %>
|
||||
|
||||
<nav class="flex items-center gap-2 mb-6">
|
||||
<% if sidebar_toggle_enabled %>
|
||||
<button data-action="sidebar#toggle" class="p-2 inline-flex rounded-lg items-center justify-center hover:bg-gray-100 cursor-pointer">
|
||||
<%= icon("panel-left", color: "gray") %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<div class="py-2 flex items-center gap-2">
|
||||
<% breadcrumbs.each_with_index do |(name, path), index| %>
|
||||
<% if index > 0 %>
|
||||
<%= icon("chevron-right", color: "gray", size: "sm") %>
|
||||
<% end %>
|
||||
|
||||
<% if path.present? && index < breadcrumbs.size - 1 %>
|
||||
<%= link_to name, path, class: "text-sm text-gray-500 font-medium" %>
|
||||
<% elsif index == breadcrumbs.size - 1 %>
|
||||
<span class="text-gray-900 font-medium text-sm"><%= name %></span>
|
||||
<% else %>
|
||||
<span class="text-sm text-gray-500 font-medium"><%= name %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</nav>
|
||||
11
app/views/layouts/shared/_page_header.html.erb
Normal file
11
app/views/layouts/shared/_page_header.html.erb
Normal file
@@ -0,0 +1,11 @@
|
||||
<%# This partial renders the page header with title and optional subtitle %>
|
||||
<header class="space-y-6">
|
||||
<% if local_assigns[:title].present? %>
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-3xl font-medium text-gray-900"><%= title %></h1>
|
||||
<% if local_assigns[:subtitle].present? %>
|
||||
<p class="text-gray-500"><%= subtitle %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</header>
|
||||
@@ -57,7 +57,7 @@
|
||||
placeholder: t(".code_placeholder") %>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<%= f.submit t(".verify_button"), class: "bg-gray-900 hover:bg-gray-700 cursor-pointer text-white rounded-lg px-3 py-2" %>
|
||||
<%= f.submit t(".verify_button"), class: "btn btn--primary" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="bg-white p-4 rounded-lg flex gap-8 shadow-border-xs">
|
||||
<div class="space-y-2">
|
||||
<%= tag.p t(".example"), class: "text-secondary text-sm" %>
|
||||
<%= tag.p "$2,323.25", class: "text-primary font-medium text-2xl" %>
|
||||
<%= tag.p format_money(Money.new(2325.25, params[:currency] || @user.family.currency)), class: "text-primary font-medium text-2xl" %>
|
||||
<p class="text-sm">
|
||||
<span class="text-green-500 font-medium">+<%= format_money(Money.new(78.90, params[:currency] || @user.family.currency)) %></span>
|
||||
<span class="text-green-500 font-medium">(+<%= format_money(Money.new(6.39, params[:currency] || @user.family.currency)) %>)</span>
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
<% content_for :page_header do %>
|
||||
<div class="space-y-1 mb-6">
|
||||
<h1 class="text-3xl font-medium text-gray-900">Welcome back, <%= Current.user.first_name %></h1>
|
||||
<p class="text-gray-500">Here's what's happening with your finances</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="w-full space-y-6 pb-24">
|
||||
<header class="space-y-6">
|
||||
<nav class="flex items-center gap-2">
|
||||
<button data-action="sidebar#toggle" class="w-9 h-9 inline-flex rounded-lg items-center justify-center hover:bg-gray-100 cursor-pointer">
|
||||
<%= icon("panel-left", color: "gray") %>
|
||||
</button>
|
||||
|
||||
<span class="text-sm text-gray-500 font-medium">Home</span>
|
||||
|
||||
<%= icon("chevron-right", color: "gray", size: "sm") %>
|
||||
|
||||
<span class="text-gray-900 font-medium text-sm">Dashboard</span>
|
||||
</nav>
|
||||
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-3xl font-medium text-gray-900">Welcome back, <%= Current.user.first_name %></h1>
|
||||
<p class="text-gray-500">Here's what's happening with your money this week</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="bg-white py-4 rounded-xl shadow-border-xs">
|
||||
<%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @balance_sheet.net_worth_series(period: @period), period: @period } %>
|
||||
</section>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<div class="h-2.5 w-2.5 rounded-full" style="background-color: <%= account_group.color %>;"></div>
|
||||
<p class="text-secondary"><%= account_group.name %></p>
|
||||
<p class="text-black"><%= number_to_percentage(account_group.weight, precision: 0) %></p>
|
||||
<p class="text-black font-mono"><%= number_to_percentage(account_group.weight, precision: 0) %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<%# locals: (series:, period:) %>
|
||||
|
||||
<div class="flex justify-between p-4">
|
||||
<div class="flex justify-between gap-4 px-4">
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-secondary font-medium">Net Worth</p>
|
||||
<p class="text-primary -space-x-0.5 text-xl font-medium">
|
||||
<p class="text-primary -space-x-0.5 text-3xl font-medium">
|
||||
<%= series.current.format %>
|
||||
</p>
|
||||
<% if series.trend.nil? %>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
||||
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
|
||||
<%= javascript_importmap_tags %>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 p-2">
|
||||
<div class="flex items-center gap-2 p-1.5">
|
||||
<%= link_to previous_path, class: "flex items-center gap-1 text-primary font-medium text-sm" do %>
|
||||
<%= lucide_icon "chevron-left", class: "w-5 h-5 text-secondary" %>
|
||||
<span>Back</span>
|
||||
@@ -32,10 +32,11 @@
|
||||
<%= render "settings/settings_nav_item", name: t(".self_hosting_label"), path: settings_hosting_path, icon: "database" %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign" %>
|
||||
</li>
|
||||
<% unless self_hosted? %>
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign" %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
<li>
|
||||
<%= render "settings/settings_nav_item", name: t(".accounts_label"), path: accounts_path, icon: "layers" %>
|
||||
|
||||
20
app/views/settings/hostings/_danger_zone_settings.html.erb
Normal file
20
app/views/settings/hostings/_danger_zone_settings.html.erb
Normal file
@@ -0,0 +1,20 @@
|
||||
<% if Current.user.admin? %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-2/3">
|
||||
<h3 class="font-medium text-primary"><%= t("settings.hostings.show.clear_cache") %></h3>
|
||||
<p class="text-secondary text-sm"><%= t("settings.hostings.show.clear_cache_warning") %></p>
|
||||
</div>
|
||||
<%=
|
||||
button_to t("settings.hostings.show.clear_cache"), clear_cache_settings_hosting_path, method: :delete,
|
||||
class: "bg-orange-500 text-white text-sm font-medium rounded-lg px-4 py-2",
|
||||
data: { turbo_confirm: {
|
||||
title: t("settings.hostings.show.confirm_clear_cache.title"),
|
||||
body: t("settings.hostings.show.confirm_clear_cache.body"),
|
||||
accept: t("settings.hostings.show.clear_cache"),
|
||||
acceptClass: "w-full bg-orange-500 text-white rounded-xl text-center p-[10px] border mb-2"
|
||||
}}
|
||||
%>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,7 +1,11 @@
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h2 class="font-medium mb-1"><%= t(".title") %></h2>
|
||||
<p class="text-secondary text-sm mb-4"><%= t(".description") %></p>
|
||||
<% if ENV["SYNTH_API_KEY"].present? %>
|
||||
<p class="text-sm text-secondary">You have successfully configured your Synth API key through the SYNTH_API_KEY environment variable.</p>
|
||||
<% else %>
|
||||
<p class="text-secondary text-sm mb-4"><%= t(".description") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= styled_form_with model: Setting.new,
|
||||
@@ -15,7 +19,8 @@
|
||||
label: t(".label"),
|
||||
type: "password",
|
||||
placeholder: t(".placeholder"),
|
||||
value: Setting.synth_api_key,
|
||||
value: ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key),
|
||||
disabled: ENV["SYNTH_API_KEY"].present?,
|
||||
container_class: @synth_usage.present? && !@synth_usage.success? ? "border-red-500" : "",
|
||||
data: { "auto-submit-form-target": "auto" } %>
|
||||
<% end %>
|
||||
|
||||
@@ -11,3 +11,7 @@
|
||||
<%= settings_section title: t(".invites") do %>
|
||||
<%= render "settings/hostings/invite_code_settings" %>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".danger_zone") do %>
|
||||
<%= render "settings/hostings/danger_zone_settings" %>
|
||||
<% end %>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name") %>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<%= form.submit t(".save"), class: "bg-gray-900 hover:bg-gray-700 cursor-pointer text-white rounded-lg px-3 py-2" %>
|
||||
<%= form.submit t(".save"), class: "btn btn--primary" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -127,20 +127,40 @@
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".danger_zone_title") do %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-primary"><%= t(".delete_account") %></h3>
|
||||
<p class="text-secondary text-sm"><%= t(".delete_account_warning") %></p>
|
||||
<div class="space-y-4">
|
||||
<% if Current.user.admin? %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-2/3">
|
||||
<h3 class="font-medium text-primary"><%= t(".reset_account") %></h3>
|
||||
<p class="text-secondary text-sm"><%= t(".reset_account_warning") %></p>
|
||||
</div>
|
||||
<%=
|
||||
button_to t(".reset_account"), reset_user_path(@user), method: :delete,
|
||||
class: "btn btn--destructive",
|
||||
data: { turbo_confirm: {
|
||||
title: t(".confirm_reset.title"),
|
||||
body: t(".confirm_reset.body"),
|
||||
accept: t(".reset_account"),
|
||||
acceptClass: "w-full bg-orange-500 text-white rounded-xl text-center p-[10px] border mb-2"
|
||||
}}
|
||||
%>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-primary"><%= t(".delete_account") %></h3>
|
||||
<p class="text-secondary text-sm"><%= t(".delete_account_warning") %></p>
|
||||
</div>
|
||||
<%=
|
||||
button_to t(".delete_account"), user_path(@user), method: :delete,
|
||||
class: "btn btn--destructive",
|
||||
data: { turbo_confirm: {
|
||||
title: t(".confirm_delete.title"),
|
||||
body: t(".confirm_delete.body"),
|
||||
accept: t(".delete_account"),
|
||||
acceptClass: "w-full bg-red-500 text-white rounded-xl text-center p-[10px] border mb-2"
|
||||
}}
|
||||
%>
|
||||
</div>
|
||||
<%=
|
||||
button_to t(".delete_account"), user_path(@user), method: :delete,
|
||||
class: "bg-red-500 text-white text-sm font-medium rounded-lg px-3 py-2",
|
||||
data: { turbo_confirm: {
|
||||
title: t(".confirm_delete.title"),
|
||||
body: t(".confirm_delete.body"),
|
||||
accept: t(".delete_account"),
|
||||
acceptClass: "w-full bg-red-500 text-white rounded-xl text-center p-[10px] border mb-2"
|
||||
}}
|
||||
%>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
data-controller="modal"
|
||||
data-action="click->modal#clickOutside"
|
||||
data-modal-reload-on-close-value="<%= reload_on_close %>">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex flex-col h-full gap-4">
|
||||
<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") %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%# locals: (content:, classes:) -%>
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<dialog class="m-auto bg-white shadow-border-xs rounded-2xl max-w-[580px] w-min-content h-fit <%= classes %>" data-controller="modal" data-action="click->modal#clickOutside">
|
||||
<dialog class="m-auto bg-white shadow-border-xs rounded-2xl max-w-[580px] w-min-content h-fit overflow-visible <%= classes %>" data-controller="modal" data-action="click->modal#clickOutside">
|
||||
<div class="flex flex-col">
|
||||
<%= content %>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<% if trend.direction.flat? %>
|
||||
<span>No change</span>
|
||||
<% else %>
|
||||
<span>
|
||||
<span class="font-mono">
|
||||
<%= trend.value.is_a?(Money) ? format_money(trend.value) : trend.value.round(2) %>
|
||||
</span>
|
||||
<% unless trend.percent.infinite? %>
|
||||
<span>(<%= lucide_icon(trend.icon, class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent_formatted %>)</span>
|
||||
<span class="font-mono">(<%= lucide_icon(trend.icon, class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent_formatted %>)</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<p class="text-sm font-medium text-primary"><%= t(".import") %></p>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_account_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
|
||||
<%= link_to new_account_transaction_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<p class="text-sm font-medium">New transaction</p>
|
||||
<% end %>
|
||||
|
||||
@@ -7,6 +7,7 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
||||
pin_all_from "app/javascript/controllers", under: "controllers"
|
||||
pin_all_from "app/javascript/services", under: "services", to: "services"
|
||||
pin "@github/hotkey", to: "@github--hotkey.js" # @3.1.0
|
||||
pin "@simonwep/pickr", to: "@simonwep--pickr.js" # @1.9.1
|
||||
|
||||
# D3 packages
|
||||
pin "d3" # @7.8.5
|
||||
|
||||
@@ -8,11 +8,13 @@ if ENV["SENTRY_DSN"].present?
|
||||
# Set traces_sample_rate to 1.0 to capture 100%
|
||||
# of transactions for performance monitoring.
|
||||
# We recommend adjusting this value in production.
|
||||
config.traces_sample_rate = 0.5
|
||||
config.traces_sample_rate = 0.25
|
||||
|
||||
# Set profiles_sample_rate to profile 100%
|
||||
# of sampled transactions.
|
||||
# We recommend adjusting this value in production.
|
||||
config.profiles_sample_rate = 0.5
|
||||
config.profiles_sample_rate = 0.25
|
||||
|
||||
config.profiler_class = Sentry::Vernier::Profiler
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,7 +10,7 @@ module Maybe
|
||||
|
||||
private
|
||||
def semver
|
||||
"0.4.0"
|
||||
"0.4.3"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,6 +39,9 @@ en:
|
||||
body: Are you sure you want to permanently delete your account? This action
|
||||
is irreversible.
|
||||
title: Delete account?
|
||||
confirm_reset:
|
||||
body: Are you sure you want to reset your account? This will delete all your accounts, categories, merchants, tags, and other data. This action cannot be undone.
|
||||
title: Reset account?
|
||||
confirm_remove_invitation:
|
||||
body: Are you sure you want to remove the invitation for %{email}?
|
||||
title: Remove Invitation
|
||||
@@ -49,6 +52,8 @@ en:
|
||||
delete_account: Delete account
|
||||
delete_account_warning: Deleting your account will permanently remove all
|
||||
your data and cannot be undone.
|
||||
reset_account: Reset account
|
||||
reset_account_warning: Resetting your account will delete all your accounts, categories, merchants, tags, and other data, but keep your user account intact.
|
||||
email: Email
|
||||
first_name: First Name
|
||||
household_form_input_placeholder: Enter household name
|
||||
|
||||
@@ -20,6 +20,12 @@ en:
|
||||
general: General Settings
|
||||
invites: Invite Codes
|
||||
title: Self-Hosting
|
||||
danger_zone: Danger Zone
|
||||
clear_cache: Clear data cache
|
||||
clear_cache_warning: Clearing the data cache will remove all exchange rates, security prices, account balances, and other data. This will not delete accounts, transactions, categories, or other user-owned data.
|
||||
confirm_clear_cache:
|
||||
title: Clear data cache?
|
||||
body: Are you sure you want to clear the data cache? This will remove all exchange rates, security prices, account balances, and other data. This action cannot be undone.
|
||||
synth_settings:
|
||||
api_calls_used: "%{used} / %{limit} API calls used (%{percentage})"
|
||||
description: Input the API key provided by Synth
|
||||
@@ -30,6 +36,8 @@ en:
|
||||
update:
|
||||
failure: Invalid setting value
|
||||
success: Settings updated
|
||||
clear_cache:
|
||||
cache_cleared: Data cache has been cleared. This may take a few moments to complete.
|
||||
upgrade_settings:
|
||||
description: Configure how your application receives updates
|
||||
latest_commit_description: Automatically update to the latest commit (unstable)
|
||||
@@ -40,3 +48,4 @@ en:
|
||||
manual_description: You control when to download and install updates
|
||||
manual_title: Manual
|
||||
title: Auto Upgrade
|
||||
not_authorized: You are not authorized to perform this action
|
||||
|
||||
3
config/locales/views/subscriptions/en.yml
Normal file
3
config/locales/views/subscriptions/en.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
en:
|
||||
subscriptions:
|
||||
self_hosted_alert: "Maybe+ is not available in self-hosted mode."
|
||||
@@ -8,3 +8,6 @@ en:
|
||||
email_change_initiated: Please check your new email address for confirmation
|
||||
instructions.
|
||||
success: Your profile has been updated.
|
||||
reset:
|
||||
success: Your account has been reset. Data will be deleted in the background in some time.
|
||||
unauthorized: You are not authorized to perform this action
|
||||
|
||||
@@ -18,7 +18,9 @@ Rails.application.routes.draw do
|
||||
resource :password, only: %i[edit update]
|
||||
resource :email_confirmation, only: :new
|
||||
|
||||
resources :users, only: %i[update destroy]
|
||||
resources :users, only: %i[update destroy] do
|
||||
delete :reset, on: :member
|
||||
end
|
||||
|
||||
resource :onboarding, only: :show do
|
||||
collection do
|
||||
@@ -30,7 +32,9 @@ Rails.application.routes.draw do
|
||||
namespace :settings do
|
||||
resource :profile, only: [ :show, :destroy ]
|
||||
resource :preferences, only: :show
|
||||
resource :hosting, only: %i[show update]
|
||||
resource :hosting, only: %i[show update] do
|
||||
delete :clear_cache, on: :collection
|
||||
end
|
||||
resource :billing, only: :show
|
||||
resource :security, only: :show
|
||||
end
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class UpdateImportsForOperatingMicV2 < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :import_rows, :exchange_operating_mic, :string
|
||||
add_column :imports, :exchange_operating_mic_col_label, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class AddDefaultLucideIconToCategories < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
execute <<-SQL
|
||||
UPDATE categories
|
||||
SET lucide_icon = 'shapes'
|
||||
WHERE lucide_icon IS NULL
|
||||
SQL
|
||||
|
||||
change_column_null :categories, :lucide_icon, false
|
||||
change_column_default :categories, :lucide_icon, 'shapes'
|
||||
end
|
||||
|
||||
def down
|
||||
change_column_default :categories, :lucide_icon, nil
|
||||
change_column_null :categories, :lucide_icon, true
|
||||
|
||||
execute <<-SQL
|
||||
UPDATE categories
|
||||
SET lucide_icon = NULL
|
||||
WHERE lucide_icon = 'shapes'
|
||||
SQL
|
||||
end
|
||||
end
|
||||
6
db/schema.rb
generated
6
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: 2025_02_12_213301) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_02_20_200735) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -192,7 +192,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_12_213301) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.uuid "parent_id"
|
||||
t.string "classification", default: "expense", null: false
|
||||
t.string "lucide_icon"
|
||||
t.string "lucide_icon", default: "shapes", null: false
|
||||
t.index ["family_id"], name: "index_categories_on_family_id"
|
||||
end
|
||||
|
||||
@@ -385,6 +385,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_12_213301) do
|
||||
t.text "notes"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "exchange_operating_mic"
|
||||
t.index ["import_id"], name: "index_import_rows_on_import_id"
|
||||
end
|
||||
|
||||
@@ -415,6 +416,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_12_213301) do
|
||||
t.string "signage_convention", default: "inflows_positive"
|
||||
t.string "error"
|
||||
t.string "number_format"
|
||||
t.string "exchange_operating_mic_col_label"
|
||||
t.index ["family_id"], name: "index_imports_on_family_id"
|
||||
end
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ namespace :demo_data do
|
||||
end
|
||||
|
||||
task multi_currency: :environment do
|
||||
Demo::Generator.new.generate_multi_currency_data!
|
||||
families = [ "Demo Family 1", "Demo Family 2" ]
|
||||
Demo::Generator.new.generate_multi_currency_data!(families)
|
||||
end
|
||||
|
||||
task basic_budget: :environment do
|
||||
families = [ "Demo Family 1" ]
|
||||
Demo::Generator.new.generate_basic_budget_data!(families)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,11 +23,24 @@ class Import::ConfigurationsControllerTest < ActionDispatch::IntegrationTest
|
||||
tags_col_label: "Tags",
|
||||
amount_col_label: "Amount",
|
||||
signage_convention: "inflows_positive",
|
||||
account_col_label: "Account"
|
||||
account_col_label: "Account",
|
||||
number_format: "1.234,56"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to import_clean_url(@import)
|
||||
assert_equal "Import configured successfully.", flash[:notice]
|
||||
|
||||
# Verify configurations were saved
|
||||
@import.reload
|
||||
assert_equal "Date", @import.date_col_label
|
||||
assert_equal "%Y-%m-%d", @import.date_format
|
||||
assert_equal "Name", @import.name_col_label
|
||||
assert_equal "Category", @import.category_col_label
|
||||
assert_equal "Tags", @import.tags_col_label
|
||||
assert_equal "Amount", @import.amount_col_label
|
||||
assert_equal "inflows_positive", @import.signage_convention
|
||||
assert_equal "Account", @import.account_col_label
|
||||
assert_equal "1.234,56", @import.number_format
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,4 +45,39 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal NEW_RENDER_DEPLOY_HOOK, Setting.render_deploy_hook
|
||||
end
|
||||
end
|
||||
|
||||
test "can clear data cache when self hosting is enabled" do
|
||||
account = accounts(:investment)
|
||||
holding = account.holdings.first
|
||||
exchange_rate = exchange_rates(:one)
|
||||
security_price = holding.security.prices.first
|
||||
account_balance = account.balances.create!(date: Date.current, balance: 1000, currency: "USD")
|
||||
|
||||
with_self_hosting do
|
||||
perform_enqueued_jobs(only: DataCacheClearJob) do
|
||||
delete clear_cache_settings_hosting_url
|
||||
end
|
||||
end
|
||||
|
||||
assert_redirected_to settings_hosting_url
|
||||
assert_equal I18n.t("settings.hostings.clear_cache.cache_cleared"), flash[:notice]
|
||||
|
||||
assert_not ExchangeRate.exists?(exchange_rate.id)
|
||||
assert_not Security::Price.exists?(security_price.id)
|
||||
assert_not Account::Holding.exists?(holding.id)
|
||||
assert_not Account::Balance.exists?(account_balance.id)
|
||||
end
|
||||
|
||||
test "can clear data only when admin" do
|
||||
with_self_hosting do
|
||||
sign_in users(:family_member)
|
||||
|
||||
assert_no_enqueued_jobs do
|
||||
delete clear_cache_settings_hosting_url
|
||||
end
|
||||
|
||||
assert_redirected_to settings_hosting_url
|
||||
assert_equal I18n.t("settings.hostings.not_authorized"), flash[:alert]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
require "test_helper"
|
||||
|
||||
class SubscriptionsControllerTest < ActionDispatch::IntegrationTest
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
end
|
||||
|
||||
test "redirects to settings if self hosting" do
|
||||
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
||||
get subscription_path
|
||||
assert_redirected_to root_path
|
||||
assert_equal I18n.t("subscriptions.self_hosted_alert"), flash[:alert]
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user