Merge branch 'main' into feature/llm-cache-reset

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Juan José Mata
2026-01-27 08:50:01 +01:00
committed by GitHub
139 changed files with 5125 additions and 605 deletions

View File

@@ -21,7 +21,7 @@ services:
- ..:/workspace:cached
- bundle_cache:/bundle
ports:
- "3000:3000"
- ${PORT:-3000}:3000
command: sleep infinity
environment:
<<: *rails_env

View File

@@ -103,7 +103,8 @@ POSTHOG_HOST=
# Active Record Encryption Keys (Optional)
# These keys are used to encrypt sensitive data like API keys in the database.
# If not provided, they will be automatically generated based on your SECRET_KEY_BASE.
# For managed mode: Set these environment variables to provide encryption keys.
# For self-hosted mode: If not provided, they will be automatically generated based on your SECRET_KEY_BASE.
# You can generate your own keys by running: rails db:encryption:init
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=

View File

@@ -1,6 +1,10 @@
# To enable / disable self-hosting features.
SELF_HOSTED = true
# Custom port config
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
PORT=3000
# SimpleFIN runtime flags (default-off)
# Accepted truthy values: 1, true, yes, on
# SIMPLEFIN_DEBUG_RAW: when truthy, logs the raw payload returned by SimpleFIN (debug-only; can be noisy)

View File

@@ -1,5 +1,9 @@
SELF_HOSTED=false
# Custom port config
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
PORT=3000
# SimpleFIN runtime flags (default-off)
# Accepted truthy values: 1, true, yes, on
# SIMPLEFIN_DEBUG_RAW: when truthy, logs the raw payload returned by SimpleFIN (debug-only; can be noisy)

View File

@@ -329,9 +329,9 @@ jobs:
> **Note**: These are debug builds intended for testing purposes. For production use, please build from source with proper signing credentials.
bump-alpha-version:
name: Bump Alpha Version
if: startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, 'alpha')
bump-pre_release-version:
name: Bump Pre-release Version
if: startsWith(github.ref, 'refs/tags/v') && (contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc'))
needs: [merge]
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -346,7 +346,7 @@ jobs:
ref: main
token: ${{ secrets.GH_PAT }}
- name: Bump alpha version
- name: Bump pre-release version
run: |
VERSION_FILE="config/initializers/version.rb"
@@ -357,20 +357,26 @@ jobs:
fi
# Extract current version
CURRENT_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+' "$VERSION_FILE")
CURRENT_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+' "$VERSION_FILE")
if [ -z "$CURRENT_VERSION" ]; then
echo "ERROR: Could not extract version from $VERSION_FILE"
exit 1
fi
echo "Current version: $CURRENT_VERSION"
# Extract the alpha number and increment it
ALPHA_NUM=$(echo "$CURRENT_VERSION" | grep -oP 'alpha\.\K[0-9]+')
if [ -z "$ALPHA_NUM" ]; then
echo "ERROR: Could not extract alpha number from $CURRENT_VERSION"
# Extract the pre-release tag and number, then increment it
PRE_RELEASE_TAG=$(echo "$CURRENT_VERSION" | grep -oP '(alpha|beta|rc)')
if [ -z "$PRE_RELEASE_TAG" ]; then
echo "ERROR: Could not extract pre-release tag from $CURRENT_VERSION"
exit 1
fi
NEW_ALPHA_NUM=$((ALPHA_NUM + 1))
PRE_RELEASE_NUM=$(echo "$CURRENT_VERSION" | grep -oP '(alpha|beta|rc)\.\K[0-9]+')
if [ -z "$PRE_RELEASE_NUM" ]; then
echo "ERROR: Could not extract pre-release number from $CURRENT_VERSION"
exit 1
fi
NEW_PRE_RELEASE_NUM=$((PRE_RELEASE_NUM + 1))
# Create new version string
BASE_VERSION=$(echo "$CURRENT_VERSION" | grep -oP '^[0-9]+\.[0-9]+\.[0-9]+')
@@ -378,7 +384,7 @@ jobs:
echo "ERROR: Could not extract base version from $CURRENT_VERSION"
exit 1
fi
NEW_VERSION="${BASE_VERSION}-alpha.${NEW_ALPHA_NUM}"
NEW_VERSION="${BASE_VERSION}-${PRE_RELEASE_TAG}.${NEW_PRE_RELEASE_NUM}"
echo "New version: $NEW_VERSION"
# Update the version file
@@ -401,7 +407,7 @@ jobs:
exit 0
fi
git commit -m "Bump version to next alpha after ${{ github.ref_name }} release"
git commit -m "Bump version to next iteration after ${{ github.ref_name }} release"
# Push with retry logic
attempts=0

View File

@@ -104,7 +104,8 @@ For further instructions, see guides below.
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=sure)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/sure?referralCode=CW_fPQ)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/T_draF?referralCode=CW_fPQ)
## License and Trademarks

View File

@@ -8,12 +8,104 @@ module Localize
private
def switch_locale(&action)
locale = locale_from_param || Current.family.try(:locale) || I18n.default_locale
locale = locale_from_param || locale_from_user || locale_from_accept_language || locale_from_family || I18n.default_locale
I18n.with_locale(locale, &action)
end
def locale_from_user
locale = Current.user&.locale
return if locale.blank?
locale_sym = locale.to_sym
locale_sym if I18n.available_locales.include?(locale_sym)
end
def locale_from_family
locale = Current.family&.locale
return if locale.blank?
locale_sym = locale.to_sym
locale_sym if I18n.available_locales.include?(locale_sym)
end
def locale_from_accept_language
locale = accept_language_top_locale
return if locale.blank?
locale_sym = locale.to_sym
return unless I18n.available_locales.include?(locale_sym)
# Auto-save detected locale to user profile (once per user, not per session)
if Current.user.present? && Current.user.locale.blank?
Current.user.update_column(:locale, locale_sym.to_s)
end
locale_sym
end
def accept_language_top_locale
header = request.get_header("HTTP_ACCEPT_LANGUAGE")
return if header.blank?
# Parse language;q pairs and sort by q-value (descending), preserving header order for ties
parsed_languages = parse_accept_language(header)
return if parsed_languages.empty?
# Find first supported locale by q-value priority
parsed_languages.each do |lang, _q|
normalized = normalize_locale(lang)
canonical = supported_locales[normalized.downcase]
return canonical if canonical.present?
primary_language = normalized.split("-").first
primary_match = supported_locales[primary_language.downcase]
return primary_match if primary_match.present?
end
nil
end
def parse_accept_language(header)
entries = []
header.split(",").each_with_index do |entry, index|
parts = entry.split(";")
language = parts.first.to_s.strip
next if language.blank?
# Extract q-value, default to 1.0
q_value = 1.0
parts[1..].each do |param|
param = param.strip
if param.start_with?("q=")
q_str = param[2..]
q_value = Float(q_str) rescue 1.0
q_value = q_value.clamp(0.0, 1.0)
break
end
end
entries << [ language, q_value, index ]
end
# Sort by q-value descending, then by original header order ascending
entries.sort_by { |_lang, q, idx| [ -q, idx ] }.map { |lang, q, _idx| [ lang, q ] }
end
def supported_locales
@supported_locales ||= LanguagesHelper::SUPPORTED_LOCALES.each_with_object({}) do |locale, locales|
normalized = normalize_locale(locale)
locales[normalized.downcase] = normalized
end
end
def normalize_locale(locale)
locale.to_s.strip.gsub("_", "-")
end
def locale_from_param
return unless params[:locale].is_a?(String) && params[:locale].present?
locale = params[:locale].to_sym
locale if I18n.available_locales.include?(locale)
end

View File

@@ -46,6 +46,7 @@ class Import::ConfigurationsController < ApplicationController
:number_format,
:signage_convention,
:amount_type_strategy,
:amount_type_identifier_value,
:amount_type_inflow_value,
:rows_to_skip
)

View File

@@ -10,6 +10,9 @@ class Settings::ProvidersController < ApplicationController
]
prepare_show_context
rescue ActiveRecord::Encryption::Errors::Configuration => e
Rails.logger.error("Active Record Encryption not configured: #{e.message}")
@encryption_error = true
end
def update

View File

@@ -1,6 +1,8 @@
class TradesController < ApplicationController
include EntryableResource
before_action :set_entry_for_unlock, only: :unlock
# Defaults to a buy trade
def new
@account = Current.family.accounts.find_by(id: params[:account_id])
@@ -17,6 +19,12 @@ class TradesController < ApplicationController
@model = Trade::CreateForm.new(create_params.merge(account: @account)).create
if @model.persisted?
# Mark manually created entries as user-modified to protect from sync
if @model.is_a?(Entry)
@model.lock_saved_attributes!
@model.mark_user_modified!
end
flash[:notice] = t("entries.create.success")
respond_to do |format|
@@ -30,19 +38,28 @@ class TradesController < ApplicationController
def update
if @entry.update(update_entry_params)
@entry.lock_saved_attributes!
@entry.mark_user_modified!
@entry.sync_account_later
# Reload to ensure fresh state for turbo stream rendering
@entry.reload
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: t("entries.update.success") }
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace(
"header_entry_#{@entry.id}",
dom_id(@entry, :header),
partial: "trades/header",
locals: { entry: @entry }
),
turbo_stream.replace("entry_#{@entry.id}", partial: "entries/entry", locals: { entry: @entry })
turbo_stream.replace(
dom_id(@entry, :protection),
partial: "entries/protection_indicator",
locals: { entry: @entry, unlock_path: unlock_trade_path(@entry.trade) }
),
turbo_stream.replace(@entry)
]
end
end
@@ -51,7 +68,19 @@ class TradesController < ApplicationController
end
end
def unlock
@entry.unlock_for_sync!
flash[:notice] = t("entries.unlock.success")
redirect_back_or_to account_path(@entry.account)
end
private
def set_entry_for_unlock
trade = Current.family.trades.find(params[:id])
@entry = trade.entry
end
def entry_params
params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature,

View File

@@ -1,6 +1,7 @@
class TransactionsController < ApplicationController
include EntryableResource
before_action :set_entry_for_unlock, only: :unlock
before_action :store_params!, only: :index
def new
@@ -68,6 +69,7 @@ class TransactionsController < ApplicationController
if @entry.save
@entry.sync_account_later
@entry.lock_saved_attributes!
@entry.mark_user_modified!
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
flash[:notice] = "Transaction created"
@@ -98,6 +100,9 @@ class TransactionsController < ApplicationController
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
@entry.sync_account_later
# Reload to ensure fresh state for turbo stream rendering
@entry.reload
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" }
format.turbo_stream do
@@ -107,6 +112,11 @@ class TransactionsController < ApplicationController
partial: "transactions/header",
locals: { entry: @entry }
),
turbo_stream.replace(
dom_id(@entry, :protection),
partial: "entries/protection_indicator",
locals: { entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) }
),
turbo_stream.replace(@entry),
*flash_notification_stream_items
]
@@ -206,7 +216,7 @@ class TransactionsController < ApplicationController
original_name: @entry.name,
original_date: I18n.l(@entry.date, format: :long))
@entry.account.entries.create!(
new_entry = @entry.account.entries.create!(
name: params[:trade_name] || Trade.build_name(is_sell ? "sell" : "buy", qty, security.ticker),
date: @entry.date,
amount: signed_amount,
@@ -221,6 +231,10 @@ class TransactionsController < ApplicationController
)
)
# Mark the new trade as user-modified to protect from sync
new_entry.lock_saved_attributes!
new_entry.mark_user_modified!
# Mark original transaction as excluded (soft delete)
@entry.update!(excluded: true)
end
@@ -235,6 +249,13 @@ class TransactionsController < ApplicationController
redirect_back_or_to transactions_path, status: :see_other
end
def unlock
@entry.unlock_for_sync!
flash[:notice] = t("entries.unlock.success")
redirect_back_or_to transactions_path
end
def mark_as_recurring
transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
@@ -286,6 +307,11 @@ class TransactionsController < ApplicationController
end
private
def set_entry_for_unlock
transaction = Current.family.transactions.find(params[:id])
@entry = transaction.entry
end
def needs_rule_notification?(transaction)
return false if Current.user.rule_prompts_disabled

View File

@@ -105,8 +105,8 @@ class UsersController < ApplicationController
def user_params
params.require(:user).permit(
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
:show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ],
:show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, :locale,
family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :id ],
goals: []
)
end

View File

@@ -44,9 +44,9 @@ module SettingsHelper
}
end
def settings_section(title:, subtitle: nil, collapsible: false, open: true, &block)
def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, &block)
content = capture(&block)
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open }
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param }
end
def settings_nav_footer

View File

@@ -0,0 +1,29 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="auto-open"
// Auto-opens a <details> element based on URL param
// Use data-auto-open-param-value="paramName" to open when ?paramName=1 is in URL
export default class extends Controller {
static values = { param: String };
connect() {
if (!this.hasParamValue || !this.paramValue) return;
const params = new URLSearchParams(window.location.search);
if (params.get(this.paramValue) === "1") {
this.element.open = true;
// Clean up the URL param after opening
params.delete(this.paramValue);
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}${window.location.hash}`
: `${window.location.pathname}${window.location.hash}`;
window.history.replaceState({}, "", newUrl);
// Scroll into view after opening
requestAnimationFrame(() => {
this.element.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
}
}

View File

@@ -11,6 +11,7 @@ export default class extends Controller {
"signedAmountFieldset",
"customColumnFieldset",
"amountTypeValue",
"amountTypeInflowValue",
"amountTypeStrategySelect",
];
@@ -20,6 +21,9 @@ export default class extends Controller {
this.amountTypeColumnKeyValue
) {
this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue);
if (this.amountTypeValueTarget.querySelector("select")?.value) {
this.#showAmountTypeInflowValueTargets();
}
}
}
@@ -31,6 +35,9 @@ export default class extends Controller {
if (this.amountTypeColumnKeyValue) {
this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue);
if (this.amountTypeValueTarget.querySelector("select")?.value) {
this.#showAmountTypeInflowValueTargets();
}
}
}
@@ -43,6 +50,11 @@ export default class extends Controller {
const amountTypeColumnKey = event.target.value;
this.#showAmountTypeValueTargets(amountTypeColumnKey);
this.#showAmountTypeInflowValueTargets();
}
handleAmountTypeIdentifierChange(event) {
this.#showAmountTypeInflowValueTargets();
}
refreshForm(event) {
@@ -91,6 +103,29 @@ export default class extends Controller {
select.appendChild(fragment);
}
#showAmountTypeInflowValueTargets() {
// Called when amount_type_identifier_value changes
// Updates the displayed identifier value in the UI text and shows/hides the inflow value dropdown
const identifierValueSelect = this.amountTypeValueTarget.querySelector("select");
const selectedValue = identifierValueSelect.value;
if (!selectedValue) {
this.amountTypeInflowValueTarget.classList.add("hidden");
this.amountTypeInflowValueTarget.classList.remove("flex");
return;
}
// Show the inflow value dropdown
this.amountTypeInflowValueTarget.classList.remove("hidden");
this.amountTypeInflowValueTarget.classList.add("flex");
// Update the displayed identifier value in the text
const identifierSpan = this.amountTypeInflowValueTarget.querySelector("span.font-medium");
if (identifierSpan) {
identifierSpan.textContent = selectedValue;
}
}
#uniqueValuesForColumn(column) {
const colIdx = this.csvValue[0].indexOf(column);
const values = this.csvValue.slice(1).map((row) => row[colIdx]);
@@ -120,6 +155,11 @@ export default class extends Controller {
this.customColumnFieldsetTarget.classList.add("hidden");
this.signedAmountFieldsetTarget.classList.remove("hidden");
// Hide the inflow value targets when using signed amount strategy
this.amountTypeValueTarget.classList.add("hidden");
this.amountTypeValueTarget.classList.remove("flex");
this.amountTypeInflowValueTarget.classList.add("hidden");
this.amountTypeInflowValueTarget.classList.remove("flex");
// Remove required from custom column fields
this.customColumnFieldsetTarget
.querySelectorAll("select, input")

View File

@@ -3,11 +3,26 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="lazy-load"
// Used with <details> elements to lazy-load content when expanded
// Use data-action="toggle->lazy-load#toggled" on the <details> element
// Optional: data-lazy-load-auto-open-param-value="paramName" to auto-open when ?paramName=1 is in URL
export default class extends Controller {
static targets = ["content", "loading", "frame"];
static values = { url: String, loaded: Boolean };
static values = { url: String, loaded: Boolean, autoOpenParam: String };
connect() {
// Check if we should auto-open based on URL param
if (this.hasAutoOpenParamValue && this.autoOpenParamValue) {
const params = new URLSearchParams(window.location.search);
if (params.get(this.autoOpenParamValue) === "1") {
this.element.open = true;
// Clean up the URL param after opening
params.delete(this.autoOpenParamValue);
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}${window.location.hash}`
: `${window.location.pathname}${window.location.hash}`;
window.history.replaceState({}, "", newUrl);
}
}
// If already open on connect (browser restored state), load immediately
if (this.element.open && !this.loadedValue) {
this.load();

View File

@@ -1,8 +1,12 @@
class ApiKey < ApplicationRecord
include Encryptable
belongs_to :user
# Use Rails built-in encryption for secure storage
encrypts :display_key, deterministic: true
# Encrypt display_key if ActiveRecord encryption is configured
if encryption_ready?
encrypts :display_key, deterministic: true
end
# Constants
SOURCES = [ "web", "mobile" ].freeze

View File

@@ -0,0 +1,16 @@
module Encryptable
extend ActiveSupport::Concern
class_methods do
# Helper to detect if ActiveRecord Encryption is configured for this app.
# This allows encryption to be optional - if not configured, sensitive fields
# are stored in plaintext (useful for development or legacy deployments).
def encryption_ready?
creds_ready = Rails.application.credentials.active_record_encryption.present?
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
creds_ready || env_ready
end
end
end

View File

@@ -1,5 +1,11 @@
class EnableBankingAccount < ApplicationRecord
include CurrencyNormalizable
include CurrencyNormalizable, Encryptable
# Encrypt raw payloads if ActiveRecord encryption is configured
if encryption_ready?
encrypts :raw_payload
encrypts :raw_transactions_payload
end
belongs_to :enable_banking_item

View File

@@ -1,21 +1,14 @@
class EnableBankingItem < ApplicationRecord
include Syncable, Provided, Unlinking
include Syncable, Provided, Unlinking, Encryptable
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
# Helper to detect if ActiveRecord Encryption is configured for this app
def self.encryption_ready?
creds_ready = Rails.application.credentials.active_record_encryption.present?
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
creds_ready || env_ready
end
# Encrypt sensitive credentials if ActiveRecord encryption is configured
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
if encryption_ready?
encrypts :client_certificate, deterministic: true
encrypts :session_id, deterministic: true
encrypts :raw_payload
encrypts :raw_institution_payload
end
validates :name, presence: true

View File

@@ -265,6 +265,50 @@ class Entry < ApplicationRecord
update!(user_modified: true)
end
# Returns the reason this entry is protected from sync, or nil if not protected.
# Priority: excluded > user_modified > import_locked
#
# @return [Symbol, nil] :excluded, :user_modified, :import_locked, or nil
def protection_reason
return :excluded if excluded?
return :user_modified if user_modified?
return :import_locked if import_locked?
nil
end
# Returns array of field names that are locked on entry and entryable.
#
# @return [Array<String>] locked field names
def locked_field_names
entry_keys = locked_attributes&.keys || []
entryable_keys = entryable&.locked_attributes&.keys || []
(entry_keys + entryable_keys).uniq
end
# Returns hash of locked field names to their lock timestamps.
# Combines locked_attributes from both entry and entryable.
# Parses ISO8601 timestamps stored in locked_attributes.
#
# @return [Hash{String => Time}] field name to lock timestamp
def locked_fields_with_timestamps
combined = (locked_attributes || {}).merge(entryable&.locked_attributes || {})
combined.transform_values do |timestamp|
Time.zone.parse(timestamp.to_s) rescue timestamp
end
end
# Clears protection flags so provider sync can update this entry again.
# Clears user_modified, import_locked flags, and all locked_attributes
# on both the entry and its entryable.
#
# @return [void]
def unlock_for_sync!
self.class.transaction do
update!(user_modified: false, import_locked: false, locked_attributes: {})
entryable&.update!(locked_attributes: {})
end
end
class << self
def search(params)
EntrySearch.new(params).build_query(all)

View File

@@ -53,6 +53,10 @@ module Family::Subscribeable
subscription&.current_period_ends_at
end
def subscription_pending_cancellation?
subscription&.pending_cancellation?
end
def start_subscription!(stripe_subscription_id)
if subscription.present?
subscription.update!(status: "active", stripe_id: stripe_subscription_id)

View File

@@ -40,6 +40,7 @@ class Import < ApplicationRecord
validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) }
validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }, allow_nil: true
validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys }
validate :custom_column_import_requires_identifier
validates :rows_to_skip, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validate :account_belongs_to_family
validate :rows_to_skip_within_file_bounds
@@ -305,6 +306,14 @@ class Import < ApplicationRecord
self.number_format ||= "1,234.56" # Default to US/UK format
end
def custom_column_import_requires_identifier
return unless amount_type_strategy == "custom_column"
if amount_type_inflow_value.blank?
errors.add(:base, I18n.t("imports.errors.custom_column_requires_inflow"))
end
end
# Common encodings to try when UTF-8 detection fails
# Windows-1250 is prioritized for Central/Eastern European languages
COMMON_ENCODINGS = [ "Windows-1250", "Windows-1252", "ISO-8859-1", "ISO-8859-2" ].freeze

View File

@@ -47,12 +47,27 @@ class Import::Row < ApplicationRecord
if import.amount_type_strategy == "signed_amount"
value * (import.signage_convention == "inflows_positive" ? -1 : 1)
elsif import.amount_type_strategy == "custom_column"
inflow_value = import.amount_type_inflow_value
legacy_identifier = import.amount_type_inflow_value
selected_identifier =
if import.amount_type_identifier_value.present?
import.amount_type_identifier_value
else
legacy_identifier
end
if entity_type == inflow_value
value * -1
inflow_treatment =
if import.amount_type_inflow_value.in?(%w[inflows_positive inflows_negative])
import.amount_type_inflow_value
elsif import.signage_convention.in?(%w[inflows_positive inflows_negative])
import.signage_convention
else
"inflows_positive"
end
if entity_type == selected_identifier
value * (inflow_treatment == "inflows_positive" ? -1 : 1)
else
value
value * (inflow_treatment == "inflows_positive" ? 1 : -1)
end
else
raise "Unknown amount type strategy for import: #{import.amount_type_strategy}"

View File

@@ -1,7 +1,15 @@
class Invitation < ApplicationRecord
include Encryptable
belongs_to :family
belongs_to :inviter, class_name: "User"
# Encrypt sensitive fields if ActiveRecord encryption is configured
if encryption_ready?
encrypts :token, deterministic: true
encrypts :email, deterministic: true, downcase: true
end
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, presence: true, inclusion: { in: %w[admin member] }
validates :token, presence: true, uniqueness: true

View File

@@ -1,4 +1,11 @@
class InviteCode < ApplicationRecord
include Encryptable
# Encrypt token if ActiveRecord encryption is configured
if encryption_ready?
encrypts :token, deterministic: true, downcase: true
end
before_validation :generate_token, on: :create
class << self

View File

@@ -1,5 +1,11 @@
class LunchflowAccount < ApplicationRecord
include CurrencyNormalizable
include CurrencyNormalizable, Encryptable
# Encrypt raw payloads if ActiveRecord encryption is configured
if encryption_ready?
encrypts :raw_payload
encrypts :raw_transactions_payload
end
belongs_to :lunchflow_item

View File

@@ -1,20 +1,13 @@
class LunchflowItem < ApplicationRecord
include Syncable, Provided, Unlinking
include Syncable, Provided, Unlinking, Encryptable
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
# Helper to detect if ActiveRecord Encryption is configured for this app
def self.encryption_ready?
creds_ready = Rails.application.credentials.active_record_encryption.present?
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
creds_ready || env_ready
end
# Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars)
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
if encryption_ready?
encrypts :api_key, deterministic: true
encrypts :raw_payload
encrypts :raw_institution_payload
end
validates :name, presence: true

View File

@@ -1,4 +1,11 @@
class MobileDevice < ApplicationRecord
include Encryptable
# Encrypt device_id if ActiveRecord encryption is configured
if encryption_ready?
encrypts :device_id, deterministic: true
end
belongs_to :user
belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true

View File

@@ -1,4 +1,15 @@
class PlaidAccount < ApplicationRecord
include Encryptable
# Encrypt raw payloads if ActiveRecord encryption is configured
if encryption_ready?
encrypts :raw_payload
encrypts :raw_transactions_payload
# Support reading data encrypted under the old column name after rename
encrypts :raw_holdings_payload, previous: { attribute: :raw_investments_payload }
encrypts :raw_liabilities_payload
end
belongs_to :plaid_item
# Legacy association via foreign key (will be removed after migration)
@@ -38,9 +49,9 @@ class PlaidAccount < ApplicationRecord
save!
end
def upsert_plaid_investments_snapshot!(investments_snapshot)
def upsert_plaid_holdings_snapshot!(holdings_snapshot)
assign_attributes(
raw_investments_payload: investments_snapshot
raw_holdings_payload: holdings_snapshot
)
save!

View File

@@ -23,7 +23,7 @@ class PlaidAccount::Importer
end
def import_investments
plaid_account.upsert_plaid_investments_snapshot!(account_snapshot.investments_data)
plaid_account.upsert_plaid_holdings_snapshot!(account_snapshot.investments_data)
end
def import_liabilities

View File

@@ -44,7 +44,7 @@ class PlaidAccount::Investments::BalanceCalculator
attr_reader :plaid_account, :security_resolver
def holdings
plaid_account.raw_investments_payload["holdings"] || []
plaid_account.raw_holdings_payload&.dig("holdings") || []
end
def calculate_investment_brokerage_cash

View File

@@ -51,7 +51,7 @@ class PlaidAccount::Investments::HoldingsProcessor
end
def holdings
plaid_account.raw_investments_payload&.[]("holdings") || []
plaid_account.raw_holdings_payload&.[]("holdings") || []
end
def parse_decimal(value)

View File

@@ -43,7 +43,7 @@ class PlaidAccount::Investments::SecurityResolver
Response = Struct.new(:security, :cash_equivalent?, :brokerage_cash?, keyword_init: true)
def securities
plaid_account.raw_investments_payload["securities"] || []
plaid_account.raw_holdings_payload&.dig("securities") || []
end
# Tries to find security, or returns the "proxy security" (common with options contracts that have underlying securities)

View File

@@ -98,7 +98,7 @@ class PlaidAccount::Investments::TransactionsProcessor
end
def transactions
plaid_account.raw_investments_payload["transactions"] || []
plaid_account.raw_holdings_payload&.dig("transactions") || []
end
# Plaid unfortunately returns incorrect signage on some `quantity` values. They claim all "sell" transactions

View File

@@ -1,21 +1,14 @@
class PlaidItem < ApplicationRecord
include Syncable, Provided
include Syncable, Provided, Encryptable
enum :plaid_region, { us: "us", eu: "eu" }
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
# Helper to detect if ActiveRecord Encryption is configured for this app
def self.encryption_ready?
creds_ready = Rails.application.credentials.active_record_encryption.present?
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
creds_ready || env_ready
end
# Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars)
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
if encryption_ready?
encrypts :access_token, deterministic: true
encrypts :raw_payload
encrypts :raw_institution_payload
end
validates :name, presence: true

View File

@@ -61,7 +61,7 @@ class PlaidItem::Syncer
def count_holdings(plaid_accounts)
plaid_accounts.sum do |pa|
Array(pa.raw_investments_payload).size
pa.raw_holdings_payload&.dig("holdings")&.size || 0
end
end
end

View File

@@ -10,7 +10,8 @@ class Provider::Stripe::SubscriptionEventProcessor < Provider::Stripe::EventProc
interval: subscription_details.plan.interval,
amount: subscription_details.plan.amount / 100.0, # Stripe returns cents, we report dollars
currency: subscription_details.plan.currency.upcase,
current_period_ends_at: Time.at(subscription_details.current_period_end)
current_period_ends_at: Time.at(subscription_details.current_period_end),
cancel_at_period_end: subscription.cancel_at_period_end
)
end

View File

@@ -0,0 +1,42 @@
class Rule::ConditionFilter::TransactionType < Rule::ConditionFilter
# Transfer kinds matching Transaction#transfer? method
TRANSFER_KINDS = %w[funds_movement cc_payment loan_payment].freeze
def type
"select"
end
def options
[
[ I18n.t("rules.condition_filters.transaction_type.income"), "income" ],
[ I18n.t("rules.condition_filters.transaction_type.expense"), "expense" ],
[ I18n.t("rules.condition_filters.transaction_type.transfer"), "transfer" ]
]
end
def operators
[ [ I18n.t("rules.condition_filters.transaction_type.equal_to"), "=" ] ]
end
def prepare(scope)
scope.with_entry
end
def apply(scope, operator, value)
# Logic matches Transaction::Search#apply_type_filter for consistency
case value
when "income"
# Negative amounts, excluding transfers and investment_contribution
scope.where("entries.amount < 0")
.where.not(kind: TRANSFER_KINDS + %w[investment_contribution])
when "expense"
# Positive amounts OR investment_contribution (regardless of sign), excluding transfers
scope.where("entries.amount >= 0 OR transactions.kind = 'investment_contribution'")
.where.not(kind: TRANSFER_KINDS)
when "transfer"
scope.where(kind: TRANSFER_KINDS)
else
scope
end
end
end

View File

@@ -7,6 +7,7 @@ class Rule::Registry::TransactionResource < Rule::Registry
[
Rule::ConditionFilter::TransactionName.new(rule),
Rule::ConditionFilter::TransactionAmount.new(rule),
Rule::ConditionFilter::TransactionType.new(rule),
Rule::ConditionFilter::TransactionMerchant.new(rule),
Rule::ConditionFilter::TransactionCategory.new(rule),
Rule::ConditionFilter::TransactionDetails.new(rule),

View File

@@ -1,14 +1,18 @@
class Session < ApplicationRecord
include Encryptable
# Encrypt user_agent if ActiveRecord encryption is configured
if encryption_ready?
encrypts :user_agent
end
belongs_to :user
belongs_to :active_impersonator_session,
-> { where(status: :in_progress) },
class_name: "ImpersonationSession",
optional: true
before_create do
self.user_agent = Current.user_agent
self.ip_address = Current.ip_address
end
before_create :capture_session_info
def get_preferred_tab(tab_key)
data.dig("tab_preferences", tab_key)
@@ -19,4 +23,13 @@ class Session < ApplicationRecord
data["tab_preferences"][tab_key] = tab_value
save!
end
private
def capture_session_info
self.user_agent = Current.user_agent
raw_ip = Current.ip_address
self.ip_address = raw_ip
self.ip_address_digest = Digest::SHA256.hexdigest(raw_ip.to_s) if raw_ip.present?
end
end

View File

@@ -1,4 +1,13 @@
class SimplefinAccount < ApplicationRecord
include Encryptable
# Encrypt raw payloads if ActiveRecord encryption is configured
if encryption_ready?
encrypts :raw_payload
encrypts :raw_transactions_payload
encrypts :raw_holdings_payload
end
belongs_to :simplefin_item
# Legacy association via foreign key (will be removed after migration)

View File

@@ -1,5 +1,5 @@
class SimplefinItem < ApplicationRecord
include Syncable, Provided
include Syncable, Provided, Encryptable
include SimplefinItem::Unlinking
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
@@ -7,18 +7,11 @@ class SimplefinItem < ApplicationRecord
# Virtual attribute for the setup token form field
attr_accessor :setup_token
# Helper to detect if ActiveRecord Encryption is configured for this app
def self.encryption_ready?
creds_ready = Rails.application.credentials.active_record_encryption.present?
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
creds_ready || env_ready
end
# Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars)
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
if encryption_ready?
encrypts :access_url, deterministic: true
encrypts :raw_payload
encrypts :raw_institution_payload
end
validates :name, presence: true

View File

@@ -1,8 +1,12 @@
# frozen_string_literal: true
class SsoProvider < ApplicationRecord
# Encrypt sensitive credentials using Rails 7.2 built-in encryption
encrypts :client_secret, deterministic: false
include Encryptable
# Encrypt sensitive credentials if ActiveRecord encryption is configured
if encryption_ready?
encrypts :client_secret, deterministic: false
end
# Default enabled to true for new providers
attribute :enabled, :boolean, default: true

View File

@@ -35,4 +35,8 @@ class Subscription < ApplicationRecord
"Open demo"
end
end
def pending_cancellation?
active? && cancel_at_period_end?
end
end

View File

@@ -1,8 +1,27 @@
class User < ApplicationRecord
include Encryptable
# Allow nil password for SSO-only users (JIT provisioning).
# Custom validation ensures password is present for non-SSO registration.
has_secure_password validations: false
# Encrypt sensitive fields if ActiveRecord encryption is configured
if encryption_ready?
# MFA secrets
encrypts :otp_secret, deterministic: true
# Note: otp_backup_codes is a PostgreSQL array column which doesn't support
# AR encryption. To encrypt it, a migration would be needed to change the
# column type from array to text/jsonb.
# PII - emails (deterministic for lookups, downcase for case-insensitive)
encrypts :email, deterministic: true, downcase: true
encrypts :unconfirmed_email, deterministic: true, downcase: true
# PII - names (non-deterministic for maximum security)
encrypts :first_name
encrypts :last_name
end
belongs_to :family
belongs_to :last_viewed_chat, class_name: "Chat", optional: true
has_many :sessions, dependent: :destroy
@@ -20,6 +39,7 @@ class User < ApplicationRecord
validate :ensure_valid_profile_image
validates :default_period, inclusion: { in: Period::PERIODS.keys }
validates :default_account_order, inclusion: { in: AccountOrder::ORDERS.keys }
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }, allow_nil: true
# Password is required on create unless the user is being created via SSO JIT.
# SSO JIT users have password_digest = nil and authenticate via OIDC only.
@@ -332,7 +352,7 @@ class User < ApplicationRecord
if (index = otp_backup_codes.index(code))
remaining_codes = otp_backup_codes.dup
remaining_codes.delete_at(index)
update_column(:otp_backup_codes, remaining_codes)
update!(otp_backup_codes: remaining_codes)
true
else
false

View File

@@ -61,14 +61,14 @@
<%= format_money(budget_category.actual_spending_money) %>
</span>
</div>
<div class="whitespace-nowrap w-full sm:w-auto">
<div class="whitespace-nowrap w-full sm:w-auto inline-flex items-center gap-1">
<span class="text-sm text-secondary"><%= t("reports.budget_performance.budgeted") %>:</span>
<span class="font-medium text-primary">
<%= format_money(budget_category.budgeted_spending_money) %>
<% if budget_category.inherits_parent_budget? %>
<span class="text-xs text-subdued">(<%= t("reports.budget_performance.shared") %>)</span>
<% end %>
</span>
<% if budget_category.inherits_parent_budget? %>
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-gray-500/5 text-secondary"><%= t("reports.budget_performance.shared") %></span>
<% end %>
</div>
<div class="whitespace-nowrap w-full sm:w-auto lg:ml-auto">
<% if budget_category.available_to_spend >= 0 %>

View File

@@ -1,6 +1,21 @@
<%# locals: (coinbase_item:) %>
<%= tag.div id: dom_id(coinbase_item) do %>
<%# Compute unlinked count early so it's available for both menu and bottom section %>
<% unlinked_count = if defined?(@coinbase_unlinked_count_map) && @coinbase_unlinked_count_map
@coinbase_unlinked_count_map[coinbase_item.id] || 0
else
begin
coinbase_item.coinbase_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
.count
rescue => e
Rails.logger.warn("Coinbase card: unlinked_count fallback failed: #{e.class} - #{e.message}")
0
end
end %>
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
<div class="flex items-center gap-2">
@@ -55,15 +70,25 @@
href: settings_providers_path,
frame: "_top"
) %>
<% elsif Rails.env.development? %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_coinbase_item_path(coinbase_item)
href: sync_coinbase_item_path(coinbase_item),
disabled: coinbase_item.syncing?
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% if unlinked_count.to_i > 0 %>
<% menu.with_item(
variant: "link",
text: t(".import_wallets_menu"),
icon: "plus",
href: setup_accounts_coinbase_item_path(coinbase_item),
frame: :modal
) %>
<% end %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
@@ -93,20 +118,6 @@
provider_item: coinbase_item
) %>
<%# Compute unlinked Coinbase accounts (no AccountProvider link) %>
<% unlinked_count = if defined?(@coinbase_unlinked_count_map) && @coinbase_unlinked_count_map
@coinbase_unlinked_count_map[coinbase_item.id] || 0
else
begin
coinbase_item.coinbase_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
.count
rescue => e
0
end
end %>
<% if unlinked_count.to_i > 0 && coinbase_item.accounts.empty? %>
<%# No accounts imported yet - show prominent setup prompt %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
@@ -120,16 +131,6 @@
frame: :modal
) %>
</div>
<% elsif unlinked_count.to_i > 0 %>
<%# Some accounts imported, more available - show subtle link %>
<div class="pt-2 border-t border-primary">
<%= link_to setup_accounts_coinbase_item_path(coinbase_item),
data: { turbo_frame: :modal },
class: "flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors" do %>
<%= icon "plus", size: "sm" %>
<span><%= t(".more_wallets_available", count: unlinked_count) %></span>
<% end %>
</div>
<% elsif coinbase_item.accounts.empty? && coinbase_item.coinbase_accounts.none? %>
<%# No coinbase_accounts at all - waiting for sync %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">

View File

@@ -0,0 +1,42 @@
<%# locals: (entry:, unlock_path:) %>
<%# Protection indicator - shows when entry is protected from sync overwrites %>
<%= turbo_frame_tag dom_id(entry, :protection) do %>
<% if entry.protected_from_sync? && !entry.excluded? %>
<details class="mx-4 my-3 border border-primary rounded-lg overflow-hidden">
<summary class="flex items-center gap-2 cursor-pointer p-3 bg-container hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden">
<%= icon "lock", size: "sm", class: "text-secondary" %>
<span class="text-sm font-medium text-primary flex-1"><%= t("entries.protection.title") %></span>
<%= icon "chevron-down", size: "sm", class: "text-secondary transition-transform [[open]>&]:rotate-180" %>
</summary>
<div class="p-4 border-t border-primary bg-surface-inset space-y-4">
<p class="text-sm text-secondary">
<%= t("entries.protection.description") %>
</p>
<% if entry.locked_field_names.any? %>
<div class="space-y-2">
<p class="text-xs font-medium text-secondary"><%= t("entries.protection.locked_fields_label") %></p>
<% entry.locked_fields_with_timestamps.each do |field, timestamp| %>
<div class="flex items-center justify-between text-sm">
<span class="text-primary"><%= field.humanize %></span>
<span class="text-secondary"><%= timestamp.respond_to?(:strftime) ? l(timestamp.to_date, format: :long) : timestamp %></span>
</div>
<% end %>
</div>
<% end %>
<%= link_to unlock_path,
class: "w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-secondary text-primary hover:bg-surface-hover transition-colors",
data: {
turbo_method: :post,
turbo_confirm: t("entries.protection.unlock_confirm"),
turbo_frame: "_top"
} do %>
<%= icon "unlock", size: "sm" %>
<span><%= t("entries.protection.unlock_button") %></span>
<% end %>
</div>
</details>
<% end %>
<% end %>

View File

@@ -82,11 +82,21 @@
<div class="items-center gap-2 text-sm <%= @import.entity_type_col_label.nil? ? "hidden" : "flex" %>" data-import-target="amountTypeValue">
<span class="shrink-0 text-secondary">↪</span>
<span class="text-secondary">Set</span>
<%= form.select :amount_type_inflow_value,
<%= form.select :amount_type_identifier_value,
@import.selectable_amount_type_values,
{ prompt: "Select column", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
{ prompt: "Select value", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
required: @import.amount_type_strategy == "custom_column",
data: { action: "import#handleAmountTypeIdentifierChange" } %>
<span class="text-secondary">as identifier value</span>
</div>
<div class="items-center gap-2 text-sm <%= @import.amount_type_identifier_value.nil? ? "hidden" : "flex" %>" data-import-target="amountTypeInflowValue">
<span class="shrink-0 text-secondary">↪</span>
<span class="text-secondary">Treat "<span class="font-medium"><%= @import.amount_type_identifier_value %></span>" as</span>
<%= form.select :amount_type_inflow_value,
[["Income (inflow)", "inflows_positive"], ["Expense (outflow)", "inflows_negative"]],
{ prompt: "Select type", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" },
required: @import.amount_type_strategy == "custom_column" %>
<span class="text-secondary">as "income" (inflow) value</span>
</div>
</div>
<% end %>

View File

@@ -1,6 +1,6 @@
<%= render "layouts/shared/htmldoc" do %>
<div class="flex flex-col h-full">
<div class="flex flex-col h-full px-6 py-12 bg-surface">
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col min-h-full px-6 py-12 bg-surface">
<div class="grow flex flex-col justify-center">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="flex justify-center mt-2 md:mb-6">

View File

@@ -76,7 +76,7 @@
<%= family_form.select :locale,
language_options,
{ label: t(".locale"), required: true, selected: params[:locale] || @user.family.locale },
{ label: t(".locale"), required: true, selected: params[:locale] || @user.locale || I18n.locale },
{ data: { action: "onboarding#setLocale" } } %>
<%= family_form.select :currency,

View File

@@ -13,7 +13,7 @@
<%= property_form.number_field :year_built,
label: t("properties.form.year_built"),
placeholder: t("properties.form.year_built_placeholder"),
min: 1800,
min: 1500,
max: Time.current.year %>
</div>

View File

@@ -17,7 +17,7 @@
<%= property_form.number_field :year_built,
label: "Year Built (optional)",
placeholder: "1990",
min: 1800,
min: 1500,
max: Time.current.year %>
</div>

View File

@@ -1,6 +1,8 @@
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true) %>
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil) %>
<% if collapsible %>
<details <%= "open" if open %> class="group bg-container shadow-border-xs rounded-xl p-4">
<details <%= "open" if open %>
class="group bg-container shadow-border-xs rounded-xl p-4"
<%= "data-controller=\"auto-open\" data-auto-open-param-value=\"#{h(auto_open_param)}\"".html_safe if auto_open_param.present? %>>
<summary class="flex items-center justify-between gap-2 cursor-pointer rounded-lg list-none [&::-webkit-details-marker]:hidden">
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>

View File

@@ -16,7 +16,11 @@
<span>Currently on the <span class="font-medium"><%= @family.subscription.name %></span>.</span> <br />
<% if @family.next_payment_date %>
<span><%= t("views.settings.payments.renewal", date: l(@family.next_payment_date, format: :long)) %></span>
<% if @family.subscription_pending_cancellation? %>
<span><%= t("views.settings.payments.cancellation", date: l(@family.next_payment_date, format: :long)) %></span>
<% else %>
<span><%= t("views.settings.payments.renewal", date: l(@family.next_payment_date, format: :long)) %></span>
<% end %>
<% end %>
</p>
<% elsif @family.trialing? %>

View File

@@ -5,16 +5,16 @@
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
<%= form.select :locale,
language_options,
{ label: t(".language"), include_blank: t(".language_auto") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= form.fields_for :family do |family_form| %>
<%= family_form.select :currency,
Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] },
{ label: t(".currency") }, disabled: true %>
<%= family_form.select :locale,
language_options,
{ label: t(".language") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :timezone,
timezone_options,
{ label: t(".timezone") },

View File

@@ -54,31 +54,26 @@
<div class="border-t border-primary pt-4 mt-4">
<% if items&.any? %>
<% item = items.first %>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<% if item.user_registered? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary">
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
<% if item.unlinked_accounts_count > 0 %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
<% end %>
</p>
<% else %>
<div class="w-2 h-2 bg-warning rounded-full"></div>
<p class="text-sm text-secondary"><%= t("providers.snaptrade.status_needs_registration") %></p>
<% end %>
</div>
</div>
<% if item.user_registered? %>
<details class="group mt-4"
<details class="group"
data-controller="lazy-load"
data-action="toggle->lazy-load#toggled"
data-lazy-load-url-value="<%= connections_snaptrade_item_path(item) %>">
<summary class="flex items-center justify-end gap-1 cursor-pointer text-sm text-secondary hover:text-primary list-none [&::-webkit-details-marker]:hidden">
<%= t("providers.snaptrade.manage_connections") %>
<%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %>
data-lazy-load-url-value="<%= connections_snaptrade_item_path(item) %>"
data-lazy-load-auto-open-param-value="manage">
<summary class="flex items-center justify-between cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary">
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
<% if item.unlinked_accounts_count > 0 %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
<% end %>
</p>
</div>
<span class="flex items-center gap-1 text-sm text-secondary hover:text-primary">
<%= t("providers.snaptrade.manage_connections") %>
<%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %>
</span>
</summary>
<div class="mt-3 space-y-3" data-lazy-load-target="content">
@@ -86,7 +81,6 @@
<%= t("providers.snaptrade.connection_limit_info") %>
</p>
<%# Loading state - replaced by fetched content %>
<div data-lazy-load-target="loading" class="flex items-center gap-2 text-sm text-secondary py-2">
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %>
<%= t("providers.snaptrade.loading_connections") %>
@@ -96,6 +90,11 @@
</div>
</div>
</details>
<% else %>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-warning rounded-full"></div>
<p class="text-sm text-secondary"><%= t("providers.snaptrade.status_needs_registration") %></p>
</div>
<% end %>
<% else %>
<div class="flex items-center gap-2">

View File

@@ -1,62 +1,76 @@
<%= content_for :page_title, "Sync Providers" %>
<div class="space-y-4">
<div>
<p class="text-secondary mb-4">
Configure credentials for third-party sync providers. Settings configured here will override environment variables.
</p>
</div>
<% if @encryption_error %>
<div class="p-4 rounded-lg bg-destructive/10 border border-destructive/20">
<div class="flex items-start gap-3">
<%= icon("triangle-alert", class: "w-5 h-5 text-destructive shrink-0 mt-0.5") %>
<div>
<h3 class="font-medium text-primary"><%= t("settings.providers.encryption_error.title") %></h3>
<p class="text-secondary text-sm mt-1"><%= t("settings.providers.encryption_error.message") %></p>
</div>
</div>
</div>
<% else %>
<div>
<p class="text-secondary mb-4">
Configure credentials for third-party sync providers. Settings configured here will override environment variables.
</p>
</div>
<% end %>
<% @provider_configurations.each do |config| %>
<%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %>
<%= render "settings/providers/provider_form", configuration: config %>
<% unless @encryption_error %>
<% @provider_configurations.each do |config| %>
<%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %>
<%= render "settings/providers/provider_form", configuration: config %>
<% end %>
<% end %>
<%# Providers below are hardcoded because they manage Family-scoped connections %>
<%# (via their own models like SimplefinItem, LunchflowItem, etc.) rather than global settings. %>
<%# They require custom UI for connection management, status display, and sync actions. %>
<%# The controller excludes them from @provider_configurations (see prepare_show_context). %>
<%= settings_section title: "Lunch Flow", collapsible: true, open: false do %>
<turbo-frame id="lunchflow-providers-panel">
<%= render "settings/providers/lunchflow_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "SimpleFIN", collapsible: true, open: false do %>
<turbo-frame id="simplefin-providers-panel">
<%= render "settings/providers/simplefin_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %>
<turbo-frame id="enable_banking-providers-panel">
<%= render "settings/providers/enable_banking_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %>
<turbo-frame id="coinstats-providers-panel">
<%= render "settings/providers/coinstats_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Mercury (beta)", collapsible: true, open: false do %>
<turbo-frame id="mercury-providers-panel">
<%= render "settings/providers/mercury_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %>
<turbo-frame id="coinbase-providers-panel">
<%= render "settings/providers/coinbase_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %>
<turbo-frame id="snaptrade-providers-panel">
<%= render "settings/providers/snaptrade_panel" %>
</turbo-frame>
<% end %>
<% end %>
<%# Providers below are hardcoded because they manage Family-scoped connections %>
<%# (via their own models like SimplefinItem, LunchflowItem, etc.) rather than global settings. %>
<%# They require custom UI for connection management, status display, and sync actions. %>
<%# The controller excludes them from @provider_configurations (see prepare_show_context). %>
<%= settings_section title: "Lunch Flow", collapsible: true, open: false do %>
<turbo-frame id="lunchflow-providers-panel">
<%= render "settings/providers/lunchflow_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "SimpleFIN", collapsible: true, open: false do %>
<turbo-frame id="simplefin-providers-panel">
<%= render "settings/providers/simplefin_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %>
<turbo-frame id="enable_banking-providers-panel">
<%= render "settings/providers/enable_banking_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %>
<turbo-frame id="coinstats-providers-panel">
<%= render "settings/providers/coinstats_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Mercury (beta)", collapsible: true, open: false do %>
<turbo-frame id="mercury-providers-panel">
<%= render "settings/providers/mercury_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %>
<turbo-frame id="coinbase-providers-panel">
<%= render "settings/providers/coinbase_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false do %>
<turbo-frame id="snaptrade-providers-panel">
<%= render "settings/providers/snaptrade_panel" %>
</turbo-frame>
<% end %>
</div>

View File

@@ -1,6 +1,21 @@
<%# locals: (simplefin_item:) %>
<%= tag.div id: dom_id(simplefin_item) do %>
<%# Compute unlinked count early so it's available for both menu and bottom section %>
<% unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map
@simplefin_unlinked_count_map[simplefin_item.id] || 0
else
begin
simplefin_item.simplefin_accounts
.left_joins(:account, :account_provider)
.where(accounts: { id: nil }, account_providers: { id: nil })
.count
rescue => e
Rails.logger.warn("SimpleFin card: unlinked_count fallback failed: #{e.class} - #{e.message}")
0
end
end %>
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
@@ -16,31 +31,9 @@
<% end %>
</div>
<%# Compute unlinked count early for badge display %>
<% header_unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map
@simplefin_unlinked_count_map[simplefin_item.id] || 0
else
begin
simplefin_item.simplefin_accounts
.left_joins(:account, :account_provider)
.where(accounts: { id: nil }, account_providers: { id: nil })
.count
rescue => e
0
end
end %>
<div class="pl-1 text-sm">
<div class="flex items-center gap-2">
<%= tag.p simplefin_item.name, class: "font-medium text-primary" %>
<% if header_unlinked_count.to_i > 0 %>
<%= link_to setup_accounts_simplefin_item_path(simplefin_item),
data: { turbo_frame: :modal },
class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning hover:bg-warning/20 transition-colors" do %>
<%= icon "alert-circle", size: "xs" %>
<span><%= header_unlinked_count %> <%= header_unlinked_count == 1 ? "account" : "accounts" %> need setup</span>
<% end %>
<% end %>
<% if simplefin_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
@@ -49,25 +42,25 @@
<p class="text-xs text-secondary">
<%= simplefin_item.institution_summary %>
</p>
<%# Extra inline badges from latest sync stats %>
<%# Extra inline badges from latest sync stats - only show warnings %>
<% stats = (@simplefin_sync_stats_map || {})[simplefin_item.id] || {} %>
<% if stats.present? %>
<% has_warnings = stats["accounts_skipped"].to_i > 0 ||
stats["rate_limited"].present? ||
stats["rate_limited_at"].present? ||
stats["total_errors"].to_i > 0 ||
(stats["errors"].is_a?(Array) && stats["errors"].any?) %>
<% if has_warnings %>
<div class="flex items-center gap-2 mt-1">
<% if stats["unlinked_accounts"].to_i > 0 %>
<%= render DS::Tooltip.new(text: "Accounts need setup", icon: "link-2", size: "sm") %>
<span class="text-xs text-secondary">Unlinked: <%= stats["unlinked_accounts"].to_i %></span>
<% end %>
<% if stats["accounts_skipped"].to_i > 0 %>
<%= render DS::Tooltip.new(text: "Some accounts were skipped due to errors during sync", icon: "alert-triangle", size: "sm", color: "warning") %>
<span class="text-xs text-warning">Skipped: <%= stats["accounts_skipped"].to_i %></span>
<%= render DS::Tooltip.new(text: t(".accounts_skipped_tooltip"), icon: "alert-triangle", size: "sm", color: "warning") %>
<span class="text-xs text-warning"><%= t(".accounts_skipped_label", count: stats["accounts_skipped"].to_i) %></span>
<% end %>
<% if stats["rate_limited"].present? || stats["rate_limited_at"].present? %>
<% ts = stats["rate_limited_at"] %>
<% ago = (ts.present? ? (begin; time_ago_in_words(Time.parse(ts)); rescue StandardError; nil; end) : nil) %>
<%= render DS::Tooltip.new(
text: (ago ? "Rate limited (" + ago + " ago)" : "Rate limited recently"),
text: (ago ? t(".rate_limited_ago", time: ago) : t(".rate_limited_recently")),
icon: "clock",
size: "sm",
color: "warning"
@@ -80,10 +73,6 @@
<%= render DS::Tooltip.new(text: tooltip_text, icon: "alert-octagon", size: "sm", color: "warning") %>
<% end %>
<% end %>
<% if stats["total_accounts"].to_i > 0 %>
<span class="text-xs text-secondary">Total: <%= stats["total_accounts"].to_i %></span>
<% end %>
</div>
<% end %>
<% end %>
@@ -163,15 +152,25 @@
href: edit_simplefin_item_path(simplefin_item),
frame: "modal"
) %>
<% elsif Rails.env.development? %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_simplefin_item_path(simplefin_item)
href: sync_simplefin_item_path(simplefin_item),
disabled: simplefin_item.syncing?
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% if unlinked_count.to_i > 0 %>
<% menu.with_item(
variant: "link",
text: t(".setup_accounts_menu"),
icon: "settings",
href: setup_accounts_simplefin_item_path(simplefin_item),
frame: :modal
) %>
<% end %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
@@ -205,23 +204,8 @@
institutions_count: simplefin_item.connected_institutions.size
) %>
<%# Compute unlinked SimpleFin accounts (no legacy account and no AccountProvider link)
# Prefer controller-provided map; fallback to a local query so the card stays accurate after Turbo broadcasts %>
<% unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map
@simplefin_unlinked_count_map[simplefin_item.id] || 0
else
begin
simplefin_item.simplefin_accounts
.left_joins(:account, :account_provider)
.where(accounts: { id: nil }, account_providers: { id: nil })
.count
rescue => e
Rails.logger.warn("SimpleFin card: unlinked_count fallback failed: #{e.class} - #{e.message}")
0
end
end %>
<% if unlinked_count.to_i > 0 %>
<% if unlinked_count.to_i > 0 && simplefin_item.accounts.empty? %>
<%# No accounts imported yet - show prominent setup prompt %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_description") %></p>

View File

@@ -6,12 +6,12 @@
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<div class="flex items-center justify-center h-8 w-8 bg-primary/10 rounded-full">
<div class="flex items-center justify-center h-8 w-8 bg-success/10 rounded-full">
<% if snaptrade_item.logo.attached? %>
<%= image_tag snaptrade_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %>
<% else %>
<div class="flex items-center justify-center">
<%= tag.p snaptrade_item.name.first.upcase, class: "text-primary text-xs font-medium" %>
<%= tag.p snaptrade_item.name.first.upcase, class: "text-success text-xs font-medium" %>
</div>
<% end %>
</div>
@@ -21,14 +21,6 @@
<div class="pl-1 text-sm">
<div class="flex items-center gap-2">
<%= tag.p snaptrade_item.name, class: "font-medium text-primary" %>
<% if unlinked_count > 0 %>
<%= link_to setup_accounts_snaptrade_item_path(snaptrade_item),
data: { turbo_frame: :modal },
class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning hover:bg-warning/20 transition-colors" do %>
<%= icon "alert-circle", size: "xs" %>
<span><%= t(".accounts_need_setup", count: unlinked_count) %></span>
<% end %>
<% end %>
<% if snaptrade_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
@@ -89,6 +81,21 @@
icon: "plus",
href: connect_snaptrade_item_path(snaptrade_item)
) %>
<% if unlinked_count > 0 %>
<% menu.with_item(
variant: "link",
text: t(".setup_accounts_menu"),
icon: "settings",
href: setup_accounts_snaptrade_item_path(snaptrade_item),
frame: :modal
) %>
<% end %>
<% menu.with_item(
variant: "link",
text: t(".manage_connections"),
icon: "cable",
href: settings_providers_path(manage: "1")
) %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
@@ -105,13 +112,6 @@
<div class="space-y-4 mt-4">
<% if snaptrade_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: snaptrade_item.accounts %>
<div class="flex justify-end pt-2">
<%= link_to connect_snaptrade_item_path(snaptrade_item),
class: "text-sm text-secondary hover:text-primary flex items-center gap-1 transition-colors" do %>
<%= icon "plus", size: "sm" %>
<span><%= t(".add_another_brokerage") %></span>
<% end %>
</div>
<% end %>
<%# Sync summary (collapsible) - using shared ProviderSyncSummary component %>
@@ -124,7 +124,8 @@
activities_pending: activities_pending
) %>
<% if unlinked_count > 0 %>
<% if unlinked_count > 0 && snaptrade_item.accounts.empty? %>
<%# No accounts imported yet - show prominent setup prompt %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_description") %></p>

View File

@@ -4,7 +4,7 @@
<%= turbo_frame_tag dom_id(entry) do %>
<%= turbo_frame_tag dom_id(trade) do %>
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-primary" %> text-sm font-medium p-4">
<div class="group grid grid-cols-12 items-center <%= entry.excluded ? "text-secondary bg-surface-inset" : "text-primary" %> text-sm font-medium p-4">
<div class="col-span-8 flex items-center gap-4">
<%= check_box_tag dom_id(entry, "selection"),
class: "checkbox checkbox--light hidden lg:block",
@@ -44,7 +44,16 @@
<%= render "investment_activity/quick_edit_badge", entry: entry, entryable: trade %>
</div>
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto text-right">
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto flex items-center justify-end gap-2">
<%# Protection indicator - shows on hover when entry is protected from sync %>
<% if entry.protected_from_sync? && !entry.excluded? %>
<%= link_to entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "invisible group-hover:visible transition-opacity",
title: t("entries.protection.tooltip") do %>
<%= icon "lock", size: "sm", class: "text-secondary" %>
<% end %>
<% end %>
<%= content_tag :p,
format_money(-entry.amount_money),
class: ["text-green-600": entry.amount.negative?] %>

View File

@@ -6,6 +6,8 @@
<% trade = @entry.trade %>
<% dialog.with_body do %>
<%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_trade_path(trade) %>
<% dialog.with_section(title: t(".details"), open: true) do %>
<div class="pb-4">
<%= styled_form_with model: @entry,

View File

@@ -4,7 +4,7 @@
<%= turbo_frame_tag dom_id(entry) do %>
<%= turbo_frame_tag dom_id(transaction) do %>
<div class="flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4 <%= entry.excluded ? "opacity-50 text-gray-400" : "" %>">
<div class="group flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4 <%= entry.excluded ? "opacity-50 text-secondary" : "" %>">
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 min-w-0">
<%= check_box_tag dom_id(entry, "selection"),
@@ -22,7 +22,7 @@
<div class="hidden lg:flex">
<% if transaction.merchant&.logo_url.present? %>
<%= image_tag Setting.transform_brand_fetch_url(transaction.merchant.logo_url),
class: "w-9 h-9 rounded-full",
class: "w-9 h-9 rounded-full border border-secondary",
loading: "lazy" %>
<% else %>
<div class="hidden lg:flex">
@@ -145,7 +145,16 @@
<% end %>
</div>
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto text-right">
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto flex items-center justify-end gap-2">
<%# Protection indicator - shows on hover when entry is protected from sync %>
<% if entry.protected_from_sync? && !entry.excluded? %>
<%= link_to entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "invisible group-hover:visible transition-opacity",
title: t("entries.protection.tooltip") do %>
<%= icon "lock", size: "sm", class: "text-secondary" %>
<% end %>
<% end %>
<%= content_tag :p,
transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
class: ["text-green-600": entry.amount.negative?] %>

View File

@@ -14,7 +14,7 @@
<div class="space-y-2">
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: "Select a category", label: "Category", class: "text-subdued" } %>
<%= form.collection_select :merchant_id, Current.family.available_merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %>
<%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags", container_class: "h-40" } %>
<%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags" } %>
<%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %>
</div>
<% end %>

View File

@@ -45,6 +45,8 @@
<% end %>
<% end %>
<%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) %>
<% dialog.with_section(title: t(".overview"), open: true) do %>
<div class="pb-4">
<%= styled_form_with model: @entry,

View File

@@ -533,9 +533,9 @@ ingress:
secretName: finance-tls
```
## Boot-required secrets (self-hosted)
## Boot-required secrets
In self-hosted mode the Rails initializer for Active Record Encryption loads on boot. To prevent boot crashes, ensure the following environment variables are present for ALL workloads (web, worker, migrate job/initContainer, CronJobs, and the SimpleFin backfill job):
The Rails initializer for Active Record Encryption loads on boot. To prevent boot crashes, ensure the following environment variables are present for ALL workloads (web, worker, migrate job/initContainer, CronJobs, and the SimpleFin backfill job):
- `SECRET_KEY_BASE`
- `ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY`
@@ -552,7 +552,7 @@ rails:
enabled: true # set to false to skip injecting the three AR encryption env vars
```
Note: Even if `simplefin.encryption.enabled=false`, the app initializer expects these env vars to exist in self-hosted mode.
Note: In self-hosted mode, if these env vars are not provided, they will be automatically generated from `SECRET_KEY_BASE`. In managed mode, these env vars must be explicitly provided via environment variables or Rails credentials.
## Advanced environment variable injection

View File

@@ -106,7 +106,7 @@ services:
volumes:
- app-storage:/rails/storage
ports:
- "3000:3000"
- ${PORT:-3000}:3000
restart: unless-stopped
environment:
<<: *rails_env

View File

@@ -50,7 +50,7 @@ services:
volumes:
- app-storage:/rails/storage
ports:
- 3000:3000
- ${PORT:-3000}:3000
restart: unless-stopped
environment:
<<: *rails_env

View File

@@ -1,25 +1,33 @@
# Auto-generate Active Record encryption keys for self-hosted instances
# This ensures encryption works out of the box without manual setup
if Rails.application.config.app_mode.self_hosted? && !Rails.application.credentials.active_record_encryption.present?
# Check if keys are provided via environment variables
primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"]
deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"]
key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"]
# Configure Active Record encryption keys
# Priority order:
# 1. Environment variables (works for both managed and self-hosted modes)
# 2. Auto-generation from SECRET_KEY_BASE (self-hosted only, if credentials not present)
# 3. Rails credentials (fallback, handled in application.rb)
# If any key is missing, generate all of them based on SECRET_KEY_BASE
if primary_key.blank? || deterministic_key.blank? || key_derivation_salt.blank?
# Use SECRET_KEY_BASE as the seed for deterministic key generation
# This ensures keys are consistent across container restarts
secret_base = Rails.application.secret_key_base
# Check if keys are provided via environment variables
primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"]
deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"]
key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"]
# Generate deterministic keys from the secret base
primary_key = Digest::SHA256.hexdigest("#{secret_base}:primary_key")[0..63]
deterministic_key = Digest::SHA256.hexdigest("#{secret_base}:deterministic_key")[0..63]
key_derivation_salt = Digest::SHA256.hexdigest("#{secret_base}:key_derivation_salt")[0..63]
end
# If all environment variables are present, use them (works for both managed and self-hosted)
if primary_key.present? && deterministic_key.present? && key_derivation_salt.present?
Rails.application.config.active_record.encryption.primary_key = primary_key
Rails.application.config.active_record.encryption.deterministic_key = deterministic_key
Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt
elsif Rails.application.config.app_mode.self_hosted? && !Rails.application.credentials.active_record_encryption.present?
# For self-hosted instances without credentials or env vars, auto-generate keys
# Use SECRET_KEY_BASE as the seed for deterministic key generation
# This ensures keys are consistent across container restarts
secret_base = Rails.application.secret_key_base
# Generate deterministic keys from the secret base
primary_key = Digest::SHA256.hexdigest("#{secret_base}:primary_key")[0..63]
deterministic_key = Digest::SHA256.hexdigest("#{secret_base}:deterministic_key")[0..63]
key_derivation_salt = Digest::SHA256.hexdigest("#{secret_base}:key_derivation_salt")[0..63]
# Configure Active Record encryption
Rails.application.config.active_record.encryption.primary_key = primary_key
Rails.application.config.active_record.encryption.deterministic_key = deterministic_key
Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt
end
# If none of the above conditions are met, credentials from application.rb will be used

View File

@@ -58,6 +58,14 @@ end
Sidekiq.configure_server do |config|
config.redis = redis_config
# Initialize auto-sync scheduler when Sidekiq server starts
config.on(:startup) do
AutoSyncScheduler.sync!
Rails.logger.info("[AutoSyncScheduler] Initialized sync_all_accounts cron job")
rescue => e
Rails.logger.error("[AutoSyncScheduler] Failed to initialize: #{e.message}")
end
end
Sidekiq.configure_client do |config|

View File

@@ -14,7 +14,7 @@ module Sure
private
def semver
"0.6.7-alpha.16"
"0.6.8-alpha.1"
end
end
end

View File

@@ -47,6 +47,7 @@ en:
setup_needed: Wallets ready to import
setup_description: Select which Coinbase wallets you want to track.
setup_action: Import Wallets
import_wallets_menu: Import Wallets
more_wallets_available:
one: "%{count} more wallet available to import"
other: "%{count} more wallets available to import"

View File

@@ -12,3 +12,12 @@ en:
loading: Loading entries...
update:
success: Entry updated
unlock:
success: Entry unlocked. It may be updated on next sync.
protection:
tooltip: Protected from sync
title: Protected from sync
description: Your edits to this entry won't be overwritten by provider sync.
locked_fields_label: "Locked fields:"
unlock_button: Allow sync to update
unlock_confirm: Allow sync to update this entry? Your changes may be overwritten on the next sync.

View File

@@ -109,3 +109,5 @@ en:
description: Here's a summary of the new items that will be added to your account
once you publish this import.
title: Confirm your import data
errors:
custom_column_requires_inflow: "Custom column imports require an inflow column to be selected"

View File

@@ -43,3 +43,9 @@ en:
confirm_body: Are you sure you want to reset the AI cache? This will allow AI rules to re-process all transactions. This may incur additional API costs.
confirm_button: Reset Cache
success: AI cache is being cleared. This may take a few moments.
condition_filters:
transaction_type:
income: Income
expense: Expense
transfer: Transfer
equal_to: Equal to

View File

@@ -41,6 +41,7 @@ ca:
general_subtitle: Configura les teves preferències
general_title: General
language: Idioma
language_auto: Idioma del navegador
page_title: Preferències
theme_dark: Fosc
theme_light: Clar

View File

@@ -34,6 +34,7 @@ de:
default_period: Standardzeitraum
default_account_order: Standardreihenfolge der Konten
language: Sprache
language_auto: Browsersprache
page_title: Einstellungen
theme_dark: Dunkel
theme_light: Hell

View File

@@ -4,6 +4,7 @@ en:
settings:
payments:
renewal: "Your contribution continues on %{date}."
cancellation: "Your contribution ends on %{date}."
settings:
ai_prompts:
show:
@@ -35,6 +36,7 @@ en:
default_period: Default Period
default_account_order: Default Account Order
language: Language
language_auto: Browser language
page_title: Preferences
theme_dark: Dark
theme_light: Light
@@ -147,6 +149,9 @@ en:
providers:
show:
coinbase_title: Coinbase
encryption_error:
title: Encryption Configuration Required
message: Active Record encryption keys are not configured. Please ensure the encryption credentials (active_record_encryption.primary_key, active_record_encryption.deterministic_key, and active_record_encryption.key_derivation_salt) are properly set up in your Rails credentials or environment variables before using sync providers.
coinbase_panel:
setup_instructions: "To connect Coinbase:"
step1_html: Go to <a href="https://portal.cdp.coinbase.com/projects/api-keys" target="_blank" class="text-primary underline">Coinbase API Settings</a>

View File

@@ -35,6 +35,7 @@ es:
default_period: Periodo Predeterminado
default_account_order: Orden Predeterminado de Cuentas
language: Idioma
language_auto: Idioma del navegador
page_title: Preferencias
theme_dark: Oscuro
theme_light: Claro

View File

@@ -35,6 +35,7 @@ fr:
default_period: Période par défaut
default_account_order: Ordre d'affichage des comptes par défaut
language: Langue
language_auto: Langue du navigateur
page_title: Préférences
theme_dark: Sombre
theme_light: Clair

View File

@@ -19,6 +19,7 @@ nb:
general_title: Generelt
default_period: Standardperiode
language: Språk
language_auto: Nettleserens språk
page_title: Preferanser
theme_dark: Mørk
theme_light: Lys

View File

@@ -35,6 +35,7 @@ nl:
default_period: Standaard Periode
default_account_order: Standaard Account Volgorde
language: Taal
language_auto: Browsertaal
page_title: Voorkeuren
theme_dark: Donker
theme_light: Licht

View File

@@ -34,6 +34,7 @@ pt-BR:
general_title: Geral
default_period: Período Padrão
language: Idioma
language_auto: Idioma do navegador
page_title: Preferências
theme_dark: Escuro
theme_light: Claro

View File

@@ -35,6 +35,7 @@ ro:
default_period: Perioadă implicită
default_account_order: Ordine implicită conturi
language: Limbă
language_auto: Limba browserului
page_title: Preferințe
theme_dark: Întunecat
theme_light: Luminos

View File

@@ -19,6 +19,7 @@ tr:
general_title: Genel
default_period: Varsayılan Dönem
language: Dil
language_auto: Tarayıcı dili
page_title: Tercihler
theme_dark: Koyu
theme_light: ık

View File

@@ -38,6 +38,7 @@ zh-CN:
general_subtitle: 配置个人偏好设置
general_title: 通用设置
language: 语言
language_auto: 浏览器语言
page_title: 偏好设置
theme_dark: 深色模式
theme_light: 浅色模式

View File

@@ -35,6 +35,7 @@ zh-TW:
default_period: 預設時段
default_account_order: 預設帳戶排序
language: 語言
language_auto: 瀏覽器語言
page_title: 偏好設定
theme_dark: 深色
theme_light: 淺色

View File

@@ -65,6 +65,14 @@ en:
setup_needed: New accounts ready to set up
setup_description: Choose account types for your newly imported SimpleFIN accounts.
setup_action: Set Up New Accounts
setup_accounts_menu: Set Up Accounts
more_accounts_available:
one: "%{count} more account available to set up"
other: "%{count} more accounts available to set up"
accounts_skipped_tooltip: "Some accounts were skipped due to errors during sync"
accounts_skipped_label: "Skipped: %{count}"
rate_limited_ago: "Rate limited (%{time} ago)"
rate_limited_recently: "Rate limited recently"
status: Last synced %{timestamp} ago
status_never: Never synced
status_with_summary: "Last synced %{timestamp} ago • %{summary}"

View File

@@ -94,6 +94,11 @@ en:
setup_needed: "Accounts need setup"
setup_description: "Some accounts from SnapTrade need to be linked to Sure accounts."
setup_action: "Setup Accounts"
setup_accounts_menu: "Set Up Accounts"
manage_connections: "Manage Connections"
more_accounts_available:
one: "%{count} more account available to set up"
other: "%{count} more accounts available to set up"
no_accounts_title: "No accounts discovered"
no_accounts_description: "Connect a brokerage to import your investment accounts."

View File

@@ -40,6 +40,16 @@ en:
convert_to_trade_title: Convert to Security Trade
convert_to_trade_description: Convert this transaction into a Buy or Sell trade with security details for portfolio tracking.
convert_to_trade_button: Convert to Trade
merchant_label: Merchant
name_label: Name
nature: Type
none: "(none)"
note_label: Notes
note_placeholder: Enter a note
overview: Overview
settings: Settings
tags_label: Tags
uncategorized: "(uncategorized)"
activity_labels:
buy: Buy
sell: Sell
@@ -57,16 +67,6 @@ en:
mark_recurring: Mark as Recurring
mark_recurring_subtitle: Track this as a recurring transaction. Amount variance is automatically calculated from past 6 months of similar transactions.
mark_recurring_title: Recurring Transaction
merchant_label: Merchant
name_label: Name
nature: Type
none: "(none)"
note_label: Notes
note_placeholder: Enter a note
overview: Overview
settings: Settings
tags_label: Tags
uncategorized: "(uncategorized)"
potential_duplicate_title: Possible duplicate detected
potential_duplicate_description: This pending transaction may be the same as the posted transaction below. If so, merge them to avoid double-counting.
merge_duplicate: Yes, merge them

View File

@@ -40,6 +40,16 @@ nl:
convert_to_trade_title: Converteer naar Effectentransactie
convert_to_trade_description: Converteer deze transactie naar een Koop- of Verkooptransactie met effectendetails voor portfoliobeheer.
convert_to_trade_button: Converteer naar Trade
merchant_label: Handelaar
name_label: Naam
nature: Type
none: "(geen)"
note_label: Notities
note_placeholder: Voer een notitie in
overview: Overzicht
settings: Instellingen
tags_label: Tags
uncategorized: "(ongecategoriseerd)"
activity_labels:
buy: Kopen
sell: Verkopen
@@ -57,16 +67,6 @@ nl:
mark_recurring: Markeer als Terugkerend
mark_recurring_subtitle: Volg dit als een terugkerende transactie. Bedragvariatie wordt automatisch berekend uit de afgelopen 6 maanden van vergelijkbare transacties.
mark_recurring_title: Terugkerende Transactie
merchant_label: Handelaar
name_label: Naam
nature: Type
none: "(geen)"
note_label: Notities
note_placeholder: Voer een notitie in
overview: Overzicht
settings: Instellingen
tags_label: Tags
uncategorized: "(ongecategoriseerd)"
potential_duplicate_title: Mogelijk duplicaat gedetecteerd
potential_duplicate_description: Deze wachtende transactie kan hetzelfde zijn als de geposte transactie hieronder. Indien ja, voeg ze samen om dubbeltelling te voorkomen.
merge_duplicate: Ja, voeg ze samen

View File

@@ -231,7 +231,11 @@ Rails.application.routes.draw do
post :reset_security
end
end
resources :trades, only: %i[show new create update destroy]
resources :trades, only: %i[show new create update destroy] do
member do
post :unlock
end
end
resources :valuations, only: %i[show new create update destroy] do
post :confirm_create, on: :collection
post :confirm_update, on: :member
@@ -257,6 +261,7 @@ Rails.application.routes.draw do
post :mark_as_recurring
post :merge_duplicate
post :dismiss_duplicate
post :unlock
end
end

View File

@@ -0,0 +1,15 @@
class AddHashColumnsForSecurity < ActiveRecord::Migration[7.2]
def change
# Invitations - for token hashing
add_column :invitations, :token_digest, :string
add_index :invitations, :token_digest, unique: true, where: "token_digest IS NOT NULL"
# InviteCodes - for token hashing
add_column :invite_codes, :token_digest, :string
add_index :invite_codes, :token_digest, unique: true, where: "token_digest IS NOT NULL"
# Sessions - for IP hashing
add_column :sessions, :ip_address_digest, :string
add_index :sessions, :ip_address_digest
end
end

View File

@@ -0,0 +1,7 @@
class AddAmountTypeIdentifierValueToImports < ActiveRecord::Migration[7.2]
def change
unless column_exists?(:imports, :amount_type_identifier_value)
add_column :imports, :amount_type_identifier_value, :string
end
end
end

View File

@@ -0,0 +1,5 @@
class AddCancelAtPeriodEndToSubscriptions < ActiveRecord::Migration[7.2]
def change
add_column :subscriptions, :cancel_at_period_end, :boolean, default: false, null: false
end
end

View File

@@ -0,0 +1,5 @@
class RenameRawInvestmentsPayloadToRawHoldingsPayload < ActiveRecord::Migration[7.2]
def change
rename_column :plaid_accounts, :raw_investments_payload, :raw_holdings_payload
end
end

View File

@@ -0,0 +1,6 @@
class AddLocaleToUsers < ActiveRecord::Migration[7.2]
def change
add_column :users, :locale, :string, null: true, default: nil
add_index :users, :locale
end
end

14
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: 2026_01_22_160000) do
ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -659,6 +659,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
t.string "amount_type_inflow_value"
t.integer "rows_to_skip", default: 0, null: false
t.integer "rows_count", default: 0, null: false
t.string "amount_type_identifier_value"
t.index ["family_id"], name: "index_imports_on_family_id"
end
@@ -679,18 +680,22 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
t.datetime "expires_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "token_digest"
t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id", unique: true
t.index ["email"], name: "index_invitations_on_email"
t.index ["family_id"], name: "index_invitations_on_family_id"
t.index ["inviter_id"], name: "index_invitations_on_inviter_id"
t.index ["token"], name: "index_invitations_on_token", unique: true
t.index ["token_digest"], name: "index_invitations_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)"
end
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "token", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "token_digest"
t.index ["token"], name: "index_invite_codes_on_token", unique: true
t.index ["token_digest"], name: "index_invite_codes_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)"
end
create_table "llm_usages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -938,7 +943,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
t.datetime "updated_at", null: false
t.jsonb "raw_payload", default: {}
t.jsonb "raw_transactions_payload", default: {}
t.jsonb "raw_investments_payload", default: {}
t.jsonb "raw_holdings_payload", default: {}
t.jsonb "raw_liabilities_payload", default: {}
t.index ["plaid_id"], name: "index_plaid_accounts_on_plaid_id", unique: true
t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id"
@@ -1103,7 +1108,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
t.datetime "subscribed_at"
t.jsonb "prev_transaction_page_params", default: {}
t.jsonb "data", default: {}
t.string "ip_address_digest"
t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id"
t.index ["ip_address_digest"], name: "index_sessions_on_ip_address_digest"
t.index ["user_id"], name: "index_sessions_on_user_id"
end
@@ -1259,6 +1266,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
t.datetime "trial_ends_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "cancel_at_period_end", default: false, null: false
t.index ["family_id"], name: "index_subscriptions_on_family_id", unique: true
end
@@ -1394,9 +1402,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
t.datetime "set_onboarding_goals_at"
t.string "default_account_order", default: "name_asc"
t.jsonb "preferences", default: {}, null: false
t.string "locale"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["family_id"], name: "index_users_on_family_id"
t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id"
t.index ["locale"], name: "index_users_on_locale"
t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)"
t.index ["preferences"], name: "index_users_on_preferences", using: :gin
end

View File

@@ -40,7 +40,7 @@ When you arrive at the main dashboard, showing **No accounts yet**, you're all s
> - [**Plaid**](/docs/hosting/plaid.md)
> - [**SimpleFIN**](https://beta-bridge.simplefin.org/)
> - [**Enable Banking**](https://enablebanking.com/) (beta)
> - [**CoinStats**](https://coinstats.app//) (beta)
> - [**CoinStats**](https://coinstats.app/) (beta)
>
> Even if you use an integration, we still recommend reading through this guide to understand **account types** and how they work in Sure.

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