Compare commits

...

22 Commits

Author SHA1 Message Date
Zach Gollwitzer
c95bb082a9 Bump to v0.4.3
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-02-28 15:11:41 -05:00
Zach Gollwitzer
4d0df9b950 Escape quotations in CSV imports properly (#1929)
* Parse quotes in imports

* Update invalid CSV for test
2025-02-28 12:21:07 -05:00
Zach Gollwitzer
7c66f16750 Invert liability graphs to have correct signage (#1928) 2025-02-28 12:17:25 -05:00
Zach Gollwitzer
fa0248056d Show UI warning to user when they need provider data but have not setup Synth yet (#1926)
* Simplify provider concerns

* Update tests

* Add UI warning for missing Synth key if family requires external data
2025-02-28 11:35:10 -05:00
Tony Vincent
624faa10d0 fix: Don't show Billings on settings navbar when self-hosted (#1912)
* Do not show billing settings navbar item when self hosted

* Do not show billing settings navbar item when self hosted

* Add condition to settings helper

* Let Stripe::AuthenticationError bubble up
2025-02-28 09:35:00 -05:00
Zach Gollwitzer
9138bd2b76 Allow offline trade tickers (#1925) 2025-02-28 09:34:14 -05:00
Zach Gollwitzer
882857fcf0 Add transitions to buttons and other common design system elements (#1924) 2025-02-28 09:29:07 -05:00
Zach Gollwitzer
d6793dec05 Fix import configuration form so number format is applied (#1923)
* Fix number format form error when loading import

* Add test to verify import configuration was properly applied
2025-02-28 08:36:57 -05:00
Zach Gollwitzer
e771c8c1df Fix value wrapping on account balance in sidebar (#1922) 2025-02-28 08:35:14 -05:00
Zach Gollwitzer
58cc09f5ae Fix bad link in bug template
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-02-28 08:25:20 -05:00
Zach Gollwitzer
98c842d3b8 Add note about self hosted versions prior to opening bugs 2025-02-28 08:23:46 -05:00
Tony Vincent
fae781e1be Make tags scrollable again (#1921) 2025-02-28 07:53:05 -05:00
Tony Vincent
8208722247 Feat: Data "reset" button (#1913)
* feat: Allow admins to delete family data

* feat: Allow self-hosting users to delete cached data

* Remove system tests
2025-02-28 07:49:12 -05:00
Harshit Chaudhary
f7064fd4dd fixed example account balance (#1910) 2025-02-26 15:13:51 -05:00
Zach Gollwitzer
c610b0ba4b Dashboard design fixes (#1898)
* Dashboard design fixes

* Update dashboard greeting

* Remove sidebar toggle from settings breadcrumbs

* Autofocus and outlines for category dropdowns

* Lint fixes
2025-02-25 17:28:40 -05:00
Josh Pigford
a4874815a6 Add breadcrumbs support across application (#1897)
* Add breadcrumbs support across application

Fixes #1896

* Potential fix for tests

* Simplify breadcrumbs implementation

Remove complex breadcrumbs logic from controllers and concern, replacing with a simpler default approach that sets a basic breadcrumb based on the current controller name

* Refactor page header and breadcrumbs rendering

Remove complex breadcrumbs helper method and update layout to use more flexible content_for approach for page headers and breadcrumbs

* Add fallback breadcrumbs rendering to settings layout
2025-02-25 10:14:07 -06:00
Josh Pigford
763e222cdd Add Sentry user context to authentication concern 2025-02-25 08:48:26 -06:00
Josh Pigford
e8390a68d8 Reduce Sentry sampling rates for performance monitoring 2025-02-25 08:44:13 -06:00
Josh Pigford
0e76d753bd Replace StackProf with Vernier for performance profiling 2025-02-25 08:37:51 -06:00
Zach Gollwitzer
f5ff5332d5 Fix parent category sums in budget (#1894) 2025-02-24 12:51:13 -05:00
Zach Gollwitzer
0dea36ec7d Fix bulk edit drawer height 2025-02-24 12:48:03 -05:00
Syed Bariman Jan
95989a6c9b Add new category flow (#1857)
* resolve git issue

* Add new category flow

* Improve contrast checker

* make error message small

* update ui to match figma design

* realign color picker

* changes

* rename color picker controller to new category controller

* cleanup code

* cleanup code

* resize and realign icon avatar

* Fix js lint errors

Signed-off-by: Syed Bariman Jan <syedbarimanjan@gmail.com>

---------

Signed-off-by: Syed Bariman Jan <syedbarimanjan@gmail.com>
2025-02-24 11:08:05 -05:00
105 changed files with 1111 additions and 336 deletions

View File

@@ -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_

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -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 {

View File

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

View File

@@ -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)

View File

@@ -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?

View File

@@ -25,6 +25,7 @@ class BudgetsController < ApplicationController
end
private
def budget_create_params
params.require(:budget).permit(:start_date)
end

View File

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

View 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

View File

@@ -35,6 +35,7 @@ class Import::ConfigurationsController < ApplicationController
:notes_col_label,
:currency_col_label,
:date_format,
:number_format,
:signage_convention
)
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ class TransactionsController < ApplicationController
end
private
def search_params
cleaned_params = params.fetch(:q, {})
.permit(

View File

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

View File

@@ -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" })

View File

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

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

View File

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

View File

@@ -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");

View File

@@ -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");

View File

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

View 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

View 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

View File

@@ -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")

View File

@@ -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 = []

View File

@@ -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?

View File

@@ -1,5 +1,5 @@
class Account::Entry < ApplicationRecord
include Monetizable
include Monetizable, Provided
monetize :amount

View 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

View File

@@ -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)

View File

@@ -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?

View File

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

View File

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

View 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

View File

@@ -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)

View File

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

View File

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

View File

@@ -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?
@@ -178,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)

View File

@@ -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,

View File

@@ -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,17 +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],
exchange_operating_mic: query[:exchange_operating_mic]
).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?

View File

@@ -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,

View 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

View File

@@ -75,10 +75,7 @@ class TradeImport < Import
return internal_security if internal_security.present?
# If security prices provider isn't properly configured or available, create with nil exchange_operating_mic
provider = Security.security_prices_provider
unless provider.present? && provider.respond_to?(:search_securities)
return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil)
end
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
@@ -86,18 +83,10 @@ class TradeImport < Import
@provider_securities_cache ||= {}
provider_security = @provider_securities_cache[cache_key] ||= begin
response = provider.search_securities(
Security.search_provider(
query: ticker,
exchange_operating_mic: exchange_operating_mic
)
if !response || !response.success? || !response.securities || response.securities.empty?
nil
else
response.securities.first
end
rescue => e
nil
).first
end
return Security.find_or_create_by!(ticker: ticker, exchange_operating_mic: nil) if provider_security.nil?

View File

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

View File

@@ -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 %>

View File

@@ -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 %>

View File

@@ -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 %>

View File

@@ -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") %>

View File

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

View File

@@ -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 %>

View File

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

View File

@@ -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" }),

View File

@@ -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 %>

View File

@@ -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 %>

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

View File

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

View File

@@ -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 %>

View File

@@ -23,11 +23,10 @@
<%= 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)" } %>
<% if Security.security_prices_provider.nil? %>
<% unless Security.provider %>
<div class="alert alert-warning">
<p>
<strong>Note:</strong> The Synth provider is not configured. Exchange validation is disabled.
Securities will be created without exchange validation, and price history will not be available.
<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 %>

View File

@@ -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 %>

View File

@@ -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 %>

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

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

View File

@@ -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 %>

View File

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

View File

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

View File

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

View File

@@ -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? %>

View File

@@ -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" %>

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

View File

@@ -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 %>

View File

@@ -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 %>

View File

@@ -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 %>

View File

@@ -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") %>

View File

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

View File

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

View File

@@ -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 %>

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ module Maybe
private
def semver
"0.4.1"
"0.4.3"
end
end
end

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
en:
subscriptions:
self_hosted_alert: "Maybe+ is not available in self-hosted mode."

View File

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

View File

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

View File

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

4
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_02_20_153958) 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_20_153958) 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,41 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal "Your profile has been updated.", flash[:notice]
end
test "admin can reset family data" do
account = accounts(:investment)
category = categories(:income)
tag = tags(:one)
merchant = merchants(:netflix)
import = imports(:transaction)
budget = budgets(:one)
plaid_item = plaid_items(:one)
perform_enqueued_jobs(only: FamilyResetJob) do
delete reset_user_url(@user)
end
assert_redirected_to settings_profile_url
assert_equal I18n.t("users.reset.success"), flash[:notice]
assert_not Account.exists?(account.id)
assert_not Category.exists?(category.id)
assert_not Tag.exists?(tag.id)
assert_not Merchant.exists?(merchant.id)
assert_not Import.exists?(import.id)
assert_not Budget.exists?(budget.id)
assert_not PlaidItem.exists?(plaid_item.id)
end
test "non-admin cannot reset family data" do
sign_in @member = users(:family_member)
delete reset_user_url(@member)
assert_redirected_to settings_profile_url
assert_equal I18n.t("users.reset.unauthorized"), flash[:alert]
assert_no_enqueued_jobs only: FamilyResetJob
end
test "member can deactivate their account" do
sign_in @member = users(:family_member)
delete user_url(@member)

View File

@@ -1,3 +1 @@
name,age
"John Doe,23
"Jane Doe",25
name,description,amount,currency
Can't render this file because it contains an unexpected character in line 3 and column 1.

View File

@@ -5,14 +5,16 @@ class ExchangeRateTest < ActiveSupport::TestCase
setup do
@provider = mock
ExchangeRate.stubs(:exchange_rates_provider).returns(@provider)
ExchangeRate.stubs(:provider).returns(@provider)
end
test "exchange rate provider nil if no api key configured" do
ExchangeRate.unstub(:exchange_rates_provider)
ExchangeRate.unstub(:provider)
Setting.stubs(:synth_api_key).returns(nil)
with_env_overrides SYNTH_API_KEY: nil do
assert_not ExchangeRate.exchange_rates_provider
assert_not ExchangeRate.provider
end
end
@@ -42,7 +44,9 @@ class ExchangeRateTest < ActiveSupport::TestCase
end
test "nil if rate is not found in DB and provider is disabled" do
ExchangeRate.unstub(:exchange_rates_provider)
ExchangeRate.unstub(:provider)
Setting.stubs(:synth_api_key).returns(nil)
with_env_overrides SYNTH_API_KEY: nil do
assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current)
@@ -102,7 +106,9 @@ class ExchangeRateTest < ActiveSupport::TestCase
end
test "returns empty array if no rates found in DB or provider" do
ExchangeRate.unstub(:exchange_rates_provider)
ExchangeRate.unstub(:provider)
Setting.stubs(:synth_api_key).returns(nil)
with_env_overrides SYNTH_API_KEY: nil do
assert_equal [], ExchangeRate.find_rates(from: "USD", to: "JPY", start_date: 10.days.ago.to_date)

View File

@@ -8,15 +8,15 @@ class IncomeStatementTest < ActiveSupport::TestCase
@income_category = @family.categories.create! name: "Income", classification: "income"
@food_category = @family.categories.create! name: "Food", classification: "expense"
@shopping_category = @family.categories.create! name: "Shopping", classification: "expense", parent: @food_category
@groceries_category = @family.categories.create! name: "Groceries", classification: "expense", parent: @food_category
@checking_account = @family.accounts.create! name: "Checking", currency: @family.currency, balance: 5000, accountable: Depository.new
@credit_card_account = @family.accounts.create! name: "Credit Card", currency: @family.currency, balance: 1000, accountable: CreditCard.new
create_transaction(account: @checking_account, amount: -1000, category: @food_category)
create_transaction(account: @checking_account, amount: 200, category: @shopping_category)
create_transaction(account: @credit_card_account, amount: 300, category: @food_category)
create_transaction(account: @credit_card_account, amount: 400, category: @shopping_category)
create_transaction(account: @checking_account, amount: -1000, category: @income_category)
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
create_transaction(account: @credit_card_account, amount: 300, category: @groceries_category)
create_transaction(account: @credit_card_account, amount: 400, category: @groceries_category)
end
test "calculates totals for transactions" do
@@ -28,12 +28,23 @@ class IncomeStatementTest < ActiveSupport::TestCase
test "calculates expenses for a period" do
income_statement = IncomeStatement.new(@family)
assert_equal 200 + 300 + 400, income_statement.expense_totals(period: Period.last_30_days).total
expense_totals = income_statement.expense_totals(period: Period.last_30_days)
expected_total_expense = 200 + 300 + 400
assert_equal expected_total_expense, expense_totals.total
assert_equal expected_total_expense, expense_totals.category_totals.find { |ct| ct.category.id == @groceries_category.id }.total
assert_equal expected_total_expense, expense_totals.category_totals.find { |ct| ct.category.id == @food_category.id }.total
end
test "calculates income for a period" do
income_statement = IncomeStatement.new(@family)
assert_equal 1000, income_statement.income_totals(period: Period.last_30_days).total
income_totals = income_statement.income_totals(period: Period.last_30_days)
expected_total_income = 1000
assert_equal expected_total_income, income_totals.total
assert_equal expected_total_income, income_totals.category_totals.find { |ct| ct.category.id == @income_category.id }.total
end
test "calculates median expense" do

View File

@@ -5,14 +5,16 @@ class Security::PriceTest < ActiveSupport::TestCase
setup do
@provider = mock
Security::Price.stubs(:security_prices_provider).returns(@provider)
Security::Price.stubs(:provider).returns(@provider)
end
test "security price provider nil if no api key provided" do
Security::Price.unstub(:security_prices_provider)
Security::Price.unstub(:provider)
Setting.stubs(:synth_api_key).returns(nil)
with_env_overrides SYNTH_API_KEY: nil do
assert_not Security::Price.security_prices_provider
assert_not Security::Price.provider
end
end
@@ -60,7 +62,10 @@ class Security::PriceTest < ActiveSupport::TestCase
end
test "returns nil if price not found in DB and provider disabled" do
Security::Price.unstub(:security_prices_provider)
Security::Price.unstub(:provider)
Setting.stubs(:synth_api_key).returns(nil)
security = Security.new(ticker: "NVDA")
with_env_overrides SYNTH_API_KEY: nil do
@@ -105,7 +110,9 @@ class Security::PriceTest < ActiveSupport::TestCase
end
test "returns empty array if no prices found in DB or from provider" do
Security::Price.unstub(:security_prices_provider)
Security::Price.unstub(:provider)
Setting.stubs(:synth_api_key).returns(nil)
with_env_overrides SYNTH_API_KEY: nil do
assert_equal [], Security::Price.find_prices(security: Security.new(ticker: "NVDA"), start_date: 10.days.ago.to_date, end_date: Date.current)

View File

@@ -12,30 +12,20 @@ class TradeImportTest < ActiveSupport::TestCase
# Create an existing AAPL security with no exchange_operating_mic
aapl = Security.create!(ticker: "AAPL", exchange_operating_mic: nil)
provider = mock
# We should only hit the provider for GOOGL since AAPL already exists
provider.expects(:search_securities).with(
Security.expects(:search_provider).with(
query: "GOOGL",
exchange_operating_mic: "XNAS"
).returns(
OpenStruct.new(
securities: [
{
ticker: "GOOGL",
name: "Google Inc.",
country_code: "US",
exchange_mic: "XNGS",
exchange_operating_mic: "XNAS",
exchange_acronym: "NGS"
}
],
success?: true,
raw_response: nil
).returns([
Security.new(
ticker: "GOOGL",
name: "Google Inc.",
country_code: "US",
exchange_mic: "XNGS",
exchange_operating_mic: "XNAS",
exchange_acronym: "NGS"
)
).once
Security.stubs(:security_prices_provider).returns(provider)
]).once
import = <<~CSV
date,ticker,qty,price,currency,account,name,exchange_operating_mic

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