Merge branch 'main' into add-config-import-csv-skip-first-x-rows

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Juan José Mata
2026-01-13 12:56:22 +01:00
committed by GitHub
269 changed files with 16974 additions and 831 deletions

View File

@@ -70,8 +70,16 @@ POSTGRES_PASSWORD=postgres
POSTGRES_USER=postgres
# Redis configuration
# Standard Redis URL (for direct connection)
REDIS_URL=redis://localhost:6379/1
# Redis Sentinel configuration (for high availability)
# When REDIS_SENTINEL_HOSTS is set, it takes precedence over REDIS_URL
# REDIS_SENTINEL_HOSTS=sentinel1:26379,sentinel2:26379,sentinel3:26379
# REDIS_SENTINEL_MASTER=mymaster
# REDIS_SENTINEL_USERNAME=default
# REDIS_PASSWORD=your-redis-password
# App Domain
# This is the domain that your Sure instance will be hosted at. It is used to generate links in emails and other places.
APP_DOMAIN=

View File

@@ -344,7 +344,7 @@ jobs:
uses: actions/checkout@v4.2.0
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.GH_PAT }}
- name: Bump alpha version
run: |
@@ -386,6 +386,7 @@ jobs:
# Verify the change
echo "Updated version.rb:"
grep "semver" "$VERSION_FILE"
- name: Commit and push version bump
run: |
@@ -398,6 +399,7 @@ jobs:
if git diff --cached --quiet; then
echo "No changes to commit - version may have already been bumped"
exit 0
fi
git commit -m "Bump version to next alpha after ${{ github.ref_name }} release"

View File

@@ -100,10 +100,14 @@ Two primary data ingestion methods:
- SimpleFIN: pending via `pending: true` or `posted` blank/0 + `transacted_at`.
- Plaid: pending via Plaid `pending: true` (stored at `extra["plaid"]["pending"]` for bank/credit transactions imported via `PlaidEntry::Processor`).
- Storage: provider data on `Transaction#extra` (e.g., `extra["simplefin"]["pending"]`; FX uses `fx_from`, `fx_date`).
- UI: Pending badge when `transaction.pending?` is true; no badge if provider omits pendings.
- Configuration (default-off)
- Centralized in `config/initializers/simplefin.rb` via `Rails.configuration.x.simplefin.*`.
- ENV-backed keys: `SIMPLEFIN_INCLUDE_PENDING=1`, `SIMPLEFIN_DEBUG_RAW=1`.
- UI: "Pending" badge when `transaction.pending?` is true; no badge if provider omits pendings.
- Configuration (default-on for pending)
- SimpleFIN: `config/initializers/simplefin.rb` via `Rails.configuration.x.simplefin.*`.
- Plaid: `config/initializers/plaid_config.rb` via `Rails.configuration.x.plaid.*`.
- Pending transactions are fetched by default and handled via reconciliation/filtering.
- Set `SIMPLEFIN_INCLUDE_PENDING=0` to disable pending fetching for SimpleFIN.
- Set `PLAID_INCLUDE_PENDING=0` to disable pending fetching for Plaid.
- Set `SIMPLEFIN_DEBUG_RAW=1` to enable raw payload debug logging.
Provider support notes:
- SimpleFIN: supports pending + FX metadata (stored under `extra["simplefin"]`).

View File

@@ -59,6 +59,7 @@ gem "countries"
# OAuth & API Security
gem "doorkeeper"
gem "rack-attack", "~> 6.6"
gem "pundit"
gem "faraday"
gem "faraday-retry"
gem "faraday-multipart"
@@ -68,6 +69,7 @@ gem "pagy"
gem "rails-settings-cached"
gem "tzinfo-data", platforms: %i[windows jruby]
gem "csv"
gem "rchardet" # Character encoding detection
gem "redcarpet"
gem "stripe"
gem "plaid"
@@ -77,17 +79,22 @@ gem "rqrcode", "~> 3.0"
gem "activerecord-import"
gem "rubyzip", "~> 2.3"
# OpenID Connect & OAuth authentication
# OpenID Connect, OAuth & SAML authentication
gem "omniauth", "~> 2.1"
gem "omniauth-rails_csrf_protection"
gem "omniauth_openid_connect"
gem "omniauth-google-oauth2"
gem "omniauth-github"
gem "omniauth-saml", "~> 2.1"
# State machines
gem "aasm"
gem "after_commit_everywhere", "~> 1.0"
# Feature flags
gem "flipper"
gem "flipper-active_record"
# AI
gem "ruby-openai"
gem "langfuse-ruby", "~> 0.1.4", require: "langfuse"

View File

@@ -216,6 +216,11 @@ GEM
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
flipper (1.3.6)
concurrent-ruby (< 2)
flipper-active_record (1.3.6)
activerecord (>= 4.2, < 9)
flipper (~> 1.3.6)
foreman (0.88.1)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
@@ -415,6 +420,9 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth-saml (2.2.4)
omniauth (~> 2.1)
ruby-saml (~> 1.18)
omniauth_openid_connect (0.8.0)
omniauth (>= 1.9, < 3)
openid_connect (~> 2.2)
@@ -461,6 +469,8 @@ GEM
public_suffix (6.0.2)
puma (6.6.0)
nio4r (~> 2.0)
pundit (2.5.2)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.18)
@@ -528,6 +538,7 @@ GEM
ffi (~> 1.0)
rbs (3.9.4)
logger
rchardet (1.10.0)
rdoc (6.14.2)
erb
psych (>= 4.0.0)
@@ -614,6 +625,9 @@ GEM
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
ruby-saml (1.18.1)
nokogiri (>= 1.13.10)
rexml
ruby-statistics (4.1.0)
ruby-vips (2.2.4)
ffi (~> 1.12)
@@ -769,6 +783,8 @@ DEPENDENCIES
faraday
faraday-multipart
faraday-retry
flipper
flipper-active_record
foreman
hotwire-livereload
hotwire_combobox
@@ -790,6 +806,7 @@ DEPENDENCIES
omniauth-github
omniauth-google-oauth2
omniauth-rails_csrf_protection
omniauth-saml (~> 2.1)
omniauth_openid_connect
ostruct
pagy
@@ -798,10 +815,12 @@ DEPENDENCIES
posthog-ruby
propshaft
puma (>= 5.0)
pundit
rack-attack (~> 6.6)
rack-mini-profiler
rails (~> 7.2.2)
rails-settings-cached
rchardet
redcarpet
redis (~> 5.4)
rotp (~> 6.3)

View File

@@ -1,3 +1,5 @@
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/we-promise/sure)
[![Dosu](https://raw.githubusercontent.com/dosu-ai/assets/main/dosu-badge.svg)](https://app.dosu.dev/a72bdcfd-15f5-4edc-bd85-ea0daa6c3adc/ask)
<img width="1270" height="1140" alt="sure_shot" src="https://github.com/user-attachments/assets/9c6e03cc-3490-40ab-9a68-52e042c51293" />

View File

@@ -12,6 +12,7 @@
@import "./google-sign-in.css";
@import "./date-picker-dark-mode.css";
@import "./print-report.css";
@layer components {
.pcr-app{

View File

@@ -0,0 +1,296 @@
/*
Print Report Styles
Tufte-inspired styling for the printable financial report.
Uses design system tokens where applicable.
*/
/* Print Body & Container */
.print-body {
background: var(--color-white);
color: var(--color-gray-900);
font-family: var(--font-sans);
line-height: 1.5;
}
.print-container {
max-width: 680px;
margin: 0 auto;
padding: 32px 24px;
}
.tufte-report {
font-size: 11px;
color: var(--color-gray-900);
}
/* Header */
.tufte-header {
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 2px solid var(--color-gray-900);
}
.tufte-title {
font-size: 20px;
font-weight: 700;
margin: 0 0 4px 0;
color: var(--color-gray-900);
letter-spacing: -0.3px;
}
.tufte-period {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--color-gray-600);
margin-top: 2px;
}
.tufte-meta {
font-size: 10px;
color: var(--color-gray-500);
margin: 8px 0 0 0;
}
/* Sections */
.tufte-section {
margin-bottom: 24px;
}
.tufte-section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--color-gray-900);
margin: 0 0 12px 0;
border-bottom: 1px solid var(--color-gray-200);
padding-bottom: 6px;
}
.tufte-subsection {
font-size: 11px;
font-weight: 600;
margin: 16px 0 8px 0;
padding-bottom: 4px;
border-bottom: 1px solid var(--color-gray-100);
}
/* Metric Cards */
.tufte-metric-card {
display: inline-block;
min-width: 100px;
}
.tufte-metric-card-main {
display: block;
}
.tufte-metric-card-label {
display: block;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-gray-500);
margin-bottom: 4px;
}
.tufte-metric-card-value {
display: block;
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.1;
letter-spacing: -0.5px;
}
.tufte-metric-card-change {
display: inline-block;
font-size: 10px;
font-weight: 500;
margin-top: 4px;
padding: 1px 4px;
border-radius: 2px;
}
.tufte-metric-card-sm .tufte-metric-card-value {
font-size: 16px;
}
.tufte-metric-card-sm .tufte-metric-card-label {
font-size: 9px;
}
/* Metric Row (horizontal layout) */
.tufte-metric-row {
display: flex;
gap: 32px;
flex-wrap: wrap;
margin-bottom: 8px;
}
/* Semantic Colors */
.tufte-income { color: var(--color-green-700); }
.tufte-expense { color: var(--color-red-700); }
.tufte-muted { color: var(--color-gray-500); font-size: 10px; }
.tufte-up { color: var(--color-green-700); }
.tufte-down { color: var(--color-red-700); }
/* Two Column Layout */
.tufte-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
margin-top: 12px;
}
/* Tables - Clean, readable style */
.tufte-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.tufte-table thead th {
text-align: left;
font-weight: 600;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-gray-600);
padding: 8px 12px 8px 0;
border-bottom: 2px solid var(--color-gray-900);
}
.tufte-table tbody td {
padding: 6px 12px 6px 0;
border-bottom: 1px solid var(--color-gray-200);
vertical-align: middle;
}
.tufte-table tbody tr:last-child td {
border-bottom: none;
}
.tufte-table tfoot td {
padding: 8px 12px 6px 0;
border-top: 2px solid var(--color-gray-900);
font-weight: 600;
}
.tufte-table.tufte-compact thead th {
padding: 6px 8px 6px 0;
}
.tufte-table.tufte-compact tbody td {
padding: 5px 8px 5px 0;
}
.tufte-right {
text-align: right;
padding-right: 0 !important;
}
.tufte-highlight {
background: var(--color-yellow-100);
}
.tufte-highlight td:first-child {
font-weight: 600;
}
/* Category Dots */
.tufte-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
/* Footnotes */
.tufte-footnote {
font-size: 10px;
color: var(--color-gray-500);
margin-top: 8px;
font-style: italic;
}
/* Footer */
.tufte-footer {
margin-top: 32px;
padding-top: 12px;
border-top: 1px solid var(--color-gray-200);
font-size: 10px;
color: var(--color-gray-500);
text-align: center;
}
/* Print-specific overrides */
@media print {
@page {
size: A4;
margin: 15mm 18mm;
}
/* Scoped to .print-body to avoid affecting other pages when printing */
.print-body {
font-size: 10px;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.print-container {
max-width: none;
padding: 0;
}
.tufte-section {
page-break-inside: auto;
}
.tufte-section-title {
page-break-after: avoid;
}
.tufte-table {
page-break-inside: auto;
}
.tufte-table thead {
display: table-header-group;
}
.tufte-table tr {
page-break-inside: avoid;
}
.tufte-two-col {
page-break-inside: avoid;
}
.tufte-keep-together {
page-break-inside: avoid;
}
.tufte-header {
page-break-after: avoid;
}
/* Force colors in print */
.tufte-income { color: var(--color-green-700) !important; }
.tufte-expense { color: var(--color-red-700) !important; }
.tufte-up { color: var(--color-green-700) !important; }
.tufte-down { color: var(--color-red-700) !important; }
.tufte-footer {
page-break-before: avoid;
}
.tufte-highlight {
background: var(--color-yellow-100) !important;
}
}

View File

@@ -46,6 +46,42 @@
"data-auto-submit-form-target": "auto" %>
</div>
</div>
<%= render DS::Menu.new(variant: "button", no_padding: true) do |menu| %>
<% menu.with_button(
id: "activity-status-filter-button",
type: "button",
text: t("accounts.show.activity.filter"),
variant: "outline",
icon: "list-filter"
) %>
<% menu.with_custom_content do %>
<div class="p-3 space-y-3 min-w-[160px]">
<p class="text-xs font-medium text-secondary uppercase"><%= t("accounts.show.activity.status") %></p>
<div class="flex items-center gap-3">
<%= check_box_tag "q[status][]",
"confirmed",
params.dig(:q, :status)&.include?("confirmed"),
id: "q_status_confirmed",
class: "checkbox checkbox--light",
form: "entries-search",
onchange: "document.getElementById('entries-search').requestSubmit()" %>
<%= label_tag "q_status_confirmed", t("accounts.show.activity.confirmed"), class: "text-sm text-primary" %>
</div>
<div class="flex items-center gap-3">
<%= check_box_tag "q[status][]",
"pending",
params.dig(:q, :status)&.include?("pending"),
id: "q_status_pending",
class: "checkbox checkbox--light",
form: "entries-search",
onchange: "document.getElementById('entries-search').requestSubmit()" %>
<%= label_tag "q_status_pending", t("accounts.show.activity.pending"), class: "text-sm text-primary" %>
</div>
</div>
<% end %>
<% end %>
<% end %>
<%= button_tag type: "button",
id: "toggle-checkboxes-button",

View File

@@ -69,6 +69,102 @@
<% end %>
</div>
<%# Pending→posted reconciliation %>
<% if has_pending_reconciled? %>
<div class="mt-1">
<div class="flex items-center gap-1">
<%= helpers.icon "check-circle", size: "sm", color: "success" %>
<span class="text-success"><%= t("provider_sync_summary.health.pending_reconciled", count: pending_reconciled) %></span>
</div>
<% if pending_reconciled_details.any? %>
<details class="mt-1">
<summary class="text-xs cursor-pointer text-secondary hover:text-primary">
<%= t("provider_sync_summary.health.view_reconciled") %>
</summary>
<div class="mt-1 pl-2 border-l-2 border-surface-inset space-y-1">
<% pending_reconciled_details.each do |detail| %>
<p class="text-xs text-success">
<%= detail["account_name"] %>: <%= detail["pending_name"] %>
</p>
<% end %>
</div>
</details>
<% end %>
</div>
<% end %>
<%# Duplicate suggestions needing review %>
<% if has_duplicate_suggestions_created? %>
<div class="mt-1">
<div class="flex items-center gap-1">
<%= helpers.icon "alert-triangle", size: "sm", color: "warning" %>
<span class="text-warning"><%= t("provider_sync_summary.health.duplicate_suggestions", count: duplicate_suggestions_created) %></span>
</div>
<% if duplicate_suggestions_details.any? %>
<details class="mt-1">
<summary class="text-xs cursor-pointer text-secondary hover:text-primary">
<%= t("provider_sync_summary.health.view_duplicate_suggestions") %>
</summary>
<div class="mt-1 pl-2 border-l-2 border-surface-inset space-y-1">
<% duplicate_suggestions_details.each do |detail| %>
<p class="text-xs text-warning">
<%= detail["account_name"] %>: <%= detail["pending_name"] %> → <%= detail["posted_name"] %>
</p>
<% end %>
</div>
</details>
<% end %>
</div>
<% end %>
<%# Stale pending transactions (auto-excluded) %>
<% if has_stale_pending? %>
<div class="mt-1">
<div class="flex items-center gap-1">
<%= helpers.icon "clock", size: "sm", color: "warning" %>
<span class="text-warning"><%= t("provider_sync_summary.health.stale_pending", count: stale_pending_excluded) %></span>
</div>
<% if stale_pending_details.any? %>
<details class="mt-1">
<summary class="text-xs cursor-pointer text-secondary hover:text-primary">
<%= t("provider_sync_summary.health.view_stale_pending") %>
</summary>
<div class="mt-1 pl-2 border-l-2 border-surface-inset space-y-1">
<% stale_pending_details.each do |detail| %>
<p class="text-xs text-warning">
<%= detail["account_name"] %>: <%= t("provider_sync_summary.health.stale_pending_count", count: detail["count"]) %>
</p>
<% end %>
</div>
</details>
<% end %>
</div>
<% end %>
<%# Stale unmatched pending (need manual review) %>
<% if has_stale_unmatched_pending? %>
<div class="mt-1">
<div class="flex items-center gap-1">
<%= helpers.icon "help-circle", size: "sm" %>
<span class="text-secondary"><%= t("provider_sync_summary.health.stale_unmatched", count: stale_unmatched_pending) %></span>
</div>
<% if stale_unmatched_details.any? %>
<details class="mt-1">
<summary class="text-xs cursor-pointer text-secondary hover:text-primary">
<%= t("provider_sync_summary.health.view_stale_unmatched") %>
</summary>
<div class="mt-1 pl-2 border-l-2 border-surface-inset space-y-1">
<% stale_unmatched_details.each do |detail| %>
<p class="text-xs text-secondary">
<%= detail["account_name"] %>: <%= t("provider_sync_summary.health.stale_unmatched_count", count: detail["count"]) %>
</p>
<% end %>
</div>
</details>
<% end %>
</div>
<% end %>
<%# Data quality warnings %>
<% if has_data_quality_issues? %>
<div class="flex items-center gap-3 mt-1">

View File

@@ -127,6 +127,58 @@ class ProviderSyncSummary < ViewComponent::Base
total_errors > 0
end
# Stale pending transactions (auto-excluded)
def stale_pending_excluded
stats["stale_pending_excluded"].to_i
end
def has_stale_pending?
stale_pending_excluded > 0
end
def stale_pending_details
stats["stale_pending_details"] || []
end
# Stale unmatched pending (need manual review - couldn't be automatically matched)
def stale_unmatched_pending
stats["stale_unmatched_pending"].to_i
end
def has_stale_unmatched_pending?
stale_unmatched_pending > 0
end
def stale_unmatched_details
stats["stale_unmatched_details"] || []
end
# Pending→posted reconciliation stats
def pending_reconciled
stats["pending_reconciled"].to_i
end
def has_pending_reconciled?
pending_reconciled > 0
end
def pending_reconciled_details
stats["pending_reconciled_details"] || []
end
# Duplicate suggestions needing user review
def duplicate_suggestions_created
stats["duplicate_suggestions_created"].to_i
end
def has_duplicate_suggestions_created?
duplicate_suggestions_created > 0
end
def duplicate_suggestions_details
stats["duplicate_suggestions_details"] || []
end
# Data quality / warnings
def data_warnings
stats["data_warnings"].to_i

View File

@@ -35,7 +35,7 @@ class AccountsController < ApplicationController
def show
@chart_view = params[:chart_view] || "balance"
@tab = params[:tab]
@q = params.fetch(:q, {}).permit(:search)
@q = params.fetch(:q, {}).permit(:search, status: [])
entries = @account.entries.search(@q).reverse_chronological
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
module Admin
class BaseController < ApplicationController
before_action :require_super_admin!
layout "settings"
private
def require_super_admin!
unless Current.user&.super_admin?
redirect_to root_path, alert: t("admin.unauthorized")
end
end
end
end

View File

@@ -0,0 +1,163 @@
# frozen_string_literal: true
module Admin
class SsoProvidersController < Admin::BaseController
before_action :set_sso_provider, only: %i[show edit update destroy toggle test_connection]
def index
authorize SsoProvider
@sso_providers = policy_scope(SsoProvider).order(:name)
# Load runtime providers (from YAML/env) that might not be in the database
# This helps show users that legacy providers are active but not manageable via UI
@runtime_providers = Rails.configuration.x.auth.sso_providers || []
db_provider_names = @sso_providers.pluck(:name)
@legacy_providers = @runtime_providers.reject { |p| db_provider_names.include?(p[:name].to_s) }
end
def show
authorize @sso_provider
end
def new
@sso_provider = SsoProvider.new
authorize @sso_provider
end
def create
@sso_provider = SsoProvider.new(processed_params)
authorize @sso_provider
# Auto-generate redirect_uri if not provided
if @sso_provider.redirect_uri.blank? && @sso_provider.name.present?
@sso_provider.redirect_uri = "#{request.base_url}/auth/#{@sso_provider.name}/callback"
end
if @sso_provider.save
log_provider_change(:create, @sso_provider)
clear_provider_cache
redirect_to admin_sso_providers_path, notice: t(".success")
else
render :new, status: :unprocessable_entity
end
end
def edit
authorize @sso_provider
end
def update
authorize @sso_provider
# Auto-update redirect_uri if name changed
params_hash = processed_params.to_h
if params_hash[:name].present? && params_hash[:name] != @sso_provider.name
params_hash[:redirect_uri] = "#{request.base_url}/auth/#{params_hash[:name]}/callback"
end
if @sso_provider.update(params_hash)
log_provider_change(:update, @sso_provider)
clear_provider_cache
redirect_to admin_sso_providers_path, notice: t(".success")
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @sso_provider
@sso_provider.destroy!
log_provider_change(:destroy, @sso_provider)
clear_provider_cache
redirect_to admin_sso_providers_path, notice: t(".success")
end
def toggle
authorize @sso_provider
@sso_provider.update!(enabled: !@sso_provider.enabled)
log_provider_change(:toggle, @sso_provider)
clear_provider_cache
notice = @sso_provider.enabled? ? t(".success_enabled") : t(".success_disabled")
redirect_to admin_sso_providers_path, notice: notice
end
def test_connection
authorize @sso_provider
tester = SsoProviderTester.new(@sso_provider)
result = tester.test!
render json: {
success: result.success?,
message: result.message,
details: result.details
}
end
private
def set_sso_provider
@sso_provider = SsoProvider.find(params[:id])
end
def sso_provider_params
params.require(:sso_provider).permit(
:strategy,
:name,
:label,
:icon,
:enabled,
:issuer,
:client_id,
:client_secret,
:redirect_uri,
:scopes,
:prompt,
settings: [
:default_role, :scopes, :prompt,
# SAML settings
:idp_metadata_url, :idp_sso_url, :idp_slo_url,
:idp_certificate, :idp_cert_fingerprint, :name_id_format,
role_mapping: {}
]
)
end
# Process params to convert role_mapping comma-separated strings to arrays
def processed_params
result = sso_provider_params.to_h
if result[:settings].present? && result[:settings][:role_mapping].present?
result[:settings][:role_mapping] = result[:settings][:role_mapping].transform_values do |v|
# Convert comma-separated string to array, removing empty values
v.to_s.split(",").map(&:strip).reject(&:blank?)
end
# Remove empty role mappings
result[:settings][:role_mapping] = result[:settings][:role_mapping].reject { |_, v| v.empty? }
result[:settings].delete(:role_mapping) if result[:settings][:role_mapping].empty?
end
result
end
def log_provider_change(action, provider)
Rails.logger.info(
"[Admin::SsoProviders] #{action.to_s.upcase} - " \
"user_id=#{Current.user.id} " \
"provider_id=#{provider.id} " \
"provider_name=#{provider.name} " \
"strategy=#{provider.strategy} " \
"enabled=#{provider.enabled}"
)
end
def clear_provider_cache
ProviderLoader.clear_cache
Rails.logger.info("[Admin::SsoProviders] Provider cache cleared by user_id=#{Current.user.id}")
end
end
end

View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true
module Admin
class UsersController < Admin::BaseController
before_action :set_user, only: %i[update]
def index
authorize User
@users = policy_scope(User).order(:email)
end
def update
authorize @user
if @user.update(user_params)
Rails.logger.info(
"[Admin::Users] Role changed - " \
"by_user_id=#{Current.user.id} " \
"target_user_id=#{@user.id} " \
"new_role=#{@user.role}"
)
redirect_to admin_users_path, notice: t(".success")
else
redirect_to admin_users_path, alert: t(".failure")
end
end
private
def set_user
@user = User.find(params[:id])
end
def user_params
params.require(:user).permit(:role)
end
end
end

View File

@@ -28,7 +28,12 @@ class Api::V1::ChatsController < Api::V1::BaseController
)
if @message.save
AssistantResponseJob.perform_later(@message)
# NOTE: Commenting out duplicate job enqueue to fix mobile app receiving duplicate AI responses
# UserMessage model already triggers AssistantResponseJob via after_create_commit callback
# in app/models/user_message.rb:10-12, so this manual enqueue causes the job to run twice,
# resulting in duplicate AI responses with different content and wasted tokens.
# See: https://github.com/dwvwdv/sure (mobile app integration issue)
# AssistantResponseJob.perform_later(@message)
render :show, status: :created
else
@chat.destroy

View File

@@ -0,0 +1,85 @@
# frozen_string_literal: true
module Api
module V1
# API v1 endpoint for merchants
# Provides read-only access to family and provider merchants
#
# @example List all merchants
# GET /api/v1/merchants
#
# @example Get a specific merchant
# GET /api/v1/merchants/:id
#
class MerchantsController < BaseController
before_action :ensure_read_scope
# List all merchants available to the family
#
# Returns both family-owned merchants and provider merchants
# that are assigned to the family's transactions.
#
# @return [Array<Hash>] JSON array of merchant objects
def index
family = current_resource_owner.family
# Single query with OR conditions - more efficient than Ruby deduplication
family_merchant_ids = family.merchants.select(:id)
provider_merchant_ids = family.transactions.select(:merchant_id)
@merchants = Merchant
.where(id: family_merchant_ids)
.or(Merchant.where(id: provider_merchant_ids, type: "ProviderMerchant"))
.distinct
.alphabetically
render json: @merchants.map { |m| merchant_json(m) }
rescue StandardError => e
Rails.logger.error("API Merchants Error: #{e.message}")
render json: { error: "Failed to fetch merchants" }, status: :internal_server_error
end
# Get a specific merchant by ID
#
# Returns a merchant if it belongs to the family or is assigned
# to any of the family's transactions.
#
# @param id [String] The merchant ID
# @return [Hash] JSON merchant object or error
def show
family = current_resource_owner.family
@merchant = family.merchants.find_by(id: params[:id]) ||
Merchant.joins(:transactions)
.where(transactions: { account_id: family.accounts.select(:id) })
.distinct
.find_by(id: params[:id])
if @merchant
render json: merchant_json(@merchant)
else
render json: { error: "Merchant not found" }, status: :not_found
end
rescue StandardError => e
Rails.logger.error("API Merchant Show Error: #{e.message}")
render json: { error: "Failed to fetch merchant" }, status: :internal_server_error
end
private
# Serialize a merchant to JSON format
#
# @param merchant [Merchant] The merchant to serialize
# @return [Hash] JSON-serializable hash
def merchant_json(merchant)
{
id: merchant.id,
name: merchant.name,
type: merchant.type,
created_at: merchant.created_at,
updated_at: merchant.updated_at
}
end
end
end
end

View File

@@ -13,7 +13,12 @@ class Api::V1::MessagesController < Api::V1::BaseController
)
if @message.save
AssistantResponseJob.perform_later(@message)
# NOTE: Commenting out duplicate job enqueue to fix mobile app receiving duplicate AI responses
# UserMessage model already triggers AssistantResponseJob via after_create_commit callback
# in app/models/user_message.rb:10-12, so this manual enqueue causes the job to run twice,
# resulting in duplicate AI responses with different content and wasted tokens.
# See: https://github.com/dwvwdv/sure (mobile app integration issue)
# AssistantResponseJob.perform_later(@message)
render :show, status: :created
else
render json: { error: "Failed to create message", details: @message.errors.full_messages }, status: :unprocessable_entity

View File

@@ -0,0 +1,130 @@
# frozen_string_literal: true
module Api
module V1
# API v1 endpoint for tags
# Provides full CRUD operations for family tags
#
# @example List all tags
# GET /api/v1/tags
#
# @example Create a new tag
# POST /api/v1/tags
# { "tag": { "name": "WhiteHouse", "color": "#3b82f6" } }
#
class TagsController < BaseController
before_action :ensure_read_scope, only: %i[index show]
before_action :ensure_write_scope, only: %i[create update destroy]
before_action :set_tag, only: %i[show update destroy]
# List all tags belonging to the family
#
# @return [Array<Hash>] JSON array of tag objects sorted alphabetically
def index
family = current_resource_owner.family
@tags = family.tags.alphabetically
render json: @tags.map { |t| tag_json(t) }
rescue StandardError => e
Rails.logger.error("API Tags Error: #{e.message}")
render json: { error: "Failed to fetch tags" }, status: :internal_server_error
end
# Get a specific tag by ID
#
# @param id [String] The tag ID
# @return [Hash] JSON tag object
def show
render json: tag_json(@tag)
rescue StandardError => e
Rails.logger.error("API Tag Show Error: #{e.message}")
render json: { error: "Failed to fetch tag" }, status: :internal_server_error
end
# Create a new tag for the family
#
# @param name [String] Tag name (required)
# @param color [String] Hex color code (optional, auto-assigned if not provided)
# @return [Hash] JSON tag object with status 201
def create
family = current_resource_owner.family
@tag = family.tags.new(tag_params)
# Assign random color if not provided
@tag.color ||= Tag::COLORS.sample
if @tag.save
render json: tag_json(@tag), status: :created
else
render json: { error: @tag.errors.full_messages.join(", ") }, status: :unprocessable_entity
end
rescue StandardError => e
Rails.logger.error("API Tag Create Error: #{e.message}")
render json: { error: "Failed to create tag" }, status: :internal_server_error
end
# Update an existing tag
#
# @param id [String] The tag ID
# @param name [String] New tag name (optional)
# @param color [String] New hex color code (optional)
# @return [Hash] JSON tag object
def update
if @tag.update(tag_params)
render json: tag_json(@tag)
else
render json: { error: @tag.errors.full_messages.join(", ") }, status: :unprocessable_entity
end
rescue StandardError => e
Rails.logger.error("API Tag Update Error: #{e.message}")
render json: { error: "Failed to update tag" }, status: :internal_server_error
end
# Delete a tag
#
# @param id [String] The tag ID
# @return [nil] Empty response with status 204
def destroy
@tag.destroy!
head :no_content
rescue StandardError => e
Rails.logger.error("API Tag Destroy Error: #{e.message}")
render json: { error: "Failed to delete tag" }, status: :internal_server_error
end
private
# Find and set the tag from params
#
# @raise [ActiveRecord::RecordNotFound] if tag not found
# @return [Tag] The found tag
def set_tag
family = current_resource_owner.family
@tag = family.tags.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Tag not found" }, status: :not_found
end
# Strong parameters for tag creation/update
#
# @return [ActionController::Parameters] Permitted parameters
def tag_params
params.require(:tag).permit(:name, :color)
end
# Serialize a tag to JSON format
#
# @param tag [Tag] The tag to serialize
# @return [Hash] JSON-serializable hash
def tag_json(tag)
{
id: tag.id,
name: tag.name,
color: tag.color,
created_at: tag.created_at,
updated_at: tag.updated_at
}
end
end
end
end

View File

@@ -2,9 +2,15 @@ class ApplicationController < ActionController::Base
include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,
SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,
FeatureGuardable, Notifiable
include Pundit::Authorization
include Pagy::Backend
# Pundit uses current_user by default, but this app uses Current.user
def pundit_user
Current.user
end
before_action :detect_os
before_action :set_default_chat
before_action :set_active_storage_url_options

View File

@@ -1,5 +1,5 @@
class HoldingsController < ApplicationController
before_action :set_holding, only: %i[show destroy]
before_action :set_holding, only: %i[show update destroy unlock_cost_basis]
def index
@account = Current.family.accounts.find(params[:account_id])
@@ -8,6 +8,31 @@ class HoldingsController < ApplicationController
def show
end
def update
total_cost_basis = holding_params[:cost_basis].to_d
if total_cost_basis >= 0 && @holding.qty.positive?
# Convert total cost basis to per-share cost (the cost_basis field stores per-share)
# Zero is valid for gifted/inherited shares
per_share_cost = total_cost_basis / @holding.qty
@holding.set_manual_cost_basis!(per_share_cost)
flash[:notice] = t(".success")
else
flash[:alert] = t(".error")
end
# Redirect to account page holdings tab to refresh list and close drawer
redirect_to account_path(@holding.account, tab: "holdings")
end
def unlock_cost_basis
@holding.unlock_cost_basis!
flash[:notice] = t(".success")
# Redirect to account page holdings tab to refresh list and close drawer
redirect_to account_path(@holding.account, tab: "holdings")
end
def destroy
if @holding.account.can_delete_holdings?
@holding.destroy_holding_and_entries!
@@ -26,4 +51,8 @@ class HoldingsController < ApplicationController
def set_holding
@holding = Current.family.holdings.find(params[:id])
end
def holding_params
params.require(:holding).permit(:cost_basis)
end
end

View File

@@ -37,6 +37,13 @@ class OidcAccountsController < ApplicationController
user
)
# Log account linking
SsoAuditLog.log_link!(
user: user,
provider: @pending_auth["provider"],
request: request
)
# Clear pending auth from session
session.delete(:pending_oidc_auth)
@@ -104,15 +111,28 @@ class OidcAccountsController < ApplicationController
# Create new family for this user
@user.family = Family.new
@user.role = :admin
# Use provider-configured default role, or fall back to member (not admin)
provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] }
default_role = provider_config&.dig(:settings, :default_role) || "member"
@user.role = default_role
if @user.save
# Create the OIDC (or other SSO) identity
OidcIdentity.create_from_omniauth(
identity = OidcIdentity.create_from_omniauth(
build_auth_hash(@pending_auth),
@user
)
# Only log JIT account creation if identity was successfully created
if identity.persisted?
SsoAuditLog.log_jit_account_created!(
user: @user,
provider: @pending_auth["provider"],
request: request
)
end
# Clear pending auth from session
session.delete(:pending_oidc_auth)

View File

@@ -7,38 +7,7 @@ class ReportsController < ApplicationController
before_action :authenticate_for_export, only: :export_transactions
def index
@period_type = params[:period_type]&.to_sym || :monthly
@start_date = parse_date_param(:start_date) || default_start_date
@end_date = parse_date_param(:end_date) || default_end_date
# Validate and fix date range if end_date is before start_date
validate_and_fix_date_range(show_flash: true)
# Build the period
@period = Period.custom(start_date: @start_date, end_date: @end_date)
@previous_period = build_previous_period
# Get aggregated data
@current_income_totals = Current.family.income_statement.income_totals(period: @period)
@current_expense_totals = Current.family.income_statement.expense_totals(period: @period)
@previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period)
@previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period)
# Calculate summary metrics
@summary_metrics = build_summary_metrics
# Build trend data (last 6 months)
@trends_data = build_trends_data
# Net worth metrics
@net_worth_metrics = build_net_worth_metrics
# Transactions breakdown
@transactions = build_transactions_breakdown
# Investment metrics (must be before build_reports_sections)
@investment_metrics = build_investment_metrics
setup_report_data(show_flash: true)
# Build reports sections for collapsible/reorderable UI
@reports_sections = build_reports_sections
@@ -46,6 +15,12 @@ class ReportsController < ApplicationController
@breadcrumbs = [ [ "Home", root_path ], [ "Reports", nil ] ]
end
def print
setup_report_data(show_flash: false)
render layout: "print"
end
def update_preferences
if Current.user.update_reports_preferences(preferences_params)
head :ok
@@ -114,6 +89,44 @@ class ReportsController < ApplicationController
end
private
def setup_report_data(show_flash: false)
@period_type = params[:period_type]&.to_sym || :monthly
@start_date = parse_date_param(:start_date) || default_start_date
@end_date = parse_date_param(:end_date) || default_end_date
# Validate and fix date range if end_date is before start_date
validate_and_fix_date_range(show_flash: show_flash)
# Build the period
@period = Period.custom(start_date: @start_date, end_date: @end_date)
@previous_period = build_previous_period
# Get aggregated data
@current_income_totals = Current.family.income_statement.income_totals(period: @period)
@current_expense_totals = Current.family.income_statement.expense_totals(period: @period)
@previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period)
@previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period)
# Calculate summary metrics
@summary_metrics = build_summary_metrics
# Build trend data (last 6 months)
@trends_data = build_trends_data
# Net worth metrics
@net_worth_metrics = build_net_worth_metrics
# Transactions breakdown
@transactions = build_transactions_breakdown
# Investment metrics
@investment_metrics = build_investment_metrics
# Flags for view rendering
@has_accounts = Current.family.accounts.any?
end
def preferences_params
prefs = params.require(:preferences)
{}.tap do |permitted|

View File

@@ -1,9 +1,14 @@
class SessionsController < ApplicationController
before_action :set_session, only: :destroy
skip_authentication only: %i[new create openid_connect failure]
skip_authentication only: %i[index new create openid_connect failure post_logout]
layout "auth"
# Handle GET /sessions (usually from browser back button)
def index
redirect_to new_session_path
end
def new
begin
demo = Rails.application.config_for(:demo)
@@ -62,7 +67,40 @@ class SessionsController < ApplicationController
end
def destroy
user = Current.user
id_token = session[:id_token_hint]
login_provider = session[:sso_login_provider]
# Find the identity for the provider used during login, with fallback to first if session data lost
oidc_identity = if login_provider.present?
user.oidc_identities.find_by(provider: login_provider)
else
user.oidc_identities.first
end
# Destroy local session
@session.destroy
session.delete(:id_token_hint)
session.delete(:sso_login_provider)
# Check if we should redirect to IdP for federated logout
if oidc_identity && id_token.present?
idp_logout_url = build_idp_logout_url(oidc_identity, id_token)
if idp_logout_url
SsoAuditLog.log_logout_idp!(user: user, provider: oidc_identity.provider, request: request)
redirect_to idp_logout_url, allow_other_host: true
return
end
end
# Standard local logout
SsoAuditLog.log_logout!(user: user, request: request)
redirect_to new_session_path, notice: t(".logout_successful")
end
# Handle redirect back from IdP after federated logout
def post_logout
redirect_to new_session_path, notice: t(".logout_successful")
end
@@ -82,6 +120,14 @@ class SessionsController < ApplicationController
# Existing OIDC identity found - authenticate the user
user = oidc_identity.user
oidc_identity.record_authentication!
oidc_identity.sync_user_attributes!(auth)
# Store id_token and provider for RP-initiated logout
session[:id_token_hint] = auth.credentials&.id_token if auth.credentials&.id_token
session[:sso_login_provider] = auth.provider
# Log successful SSO login
SsoAuditLog.log_login!(user: user, provider: auth.provider, request: request)
# MFA check: If user has MFA enabled, require verification
if user.otp_required?
@@ -107,7 +153,27 @@ class SessionsController < ApplicationController
end
def failure
redirect_to new_session_path, alert: t("sessions.failure.failed")
# Sanitize reason to known values only
known_reasons = %w[sso_provider_unavailable sso_invalid_response sso_failed]
sanitized_reason = known_reasons.include?(params[:message]) ? params[:message] : "sso_failed"
# Log failed SSO attempt
SsoAuditLog.log_login_failed!(
provider: params[:strategy],
request: request,
reason: sanitized_reason
)
message = case sanitized_reason
when "sso_provider_unavailable"
t("sessions.failure.sso_provider_unavailable")
when "sso_invalid_response"
t("sessions.failure.sso_invalid_response")
else
t("sessions.failure.sso_failed")
end
redirect_to new_session_path, alert: message
end
private
@@ -130,4 +196,53 @@ class SessionsController < ApplicationController
demo["hosts"].include?(request.host)
end
def build_idp_logout_url(oidc_identity, id_token)
# Find the provider configuration using unified loader (supports both YAML and DB providers)
provider_config = ProviderLoader.load_providers.find do |p|
p[:name] == oidc_identity.provider
end
return nil unless provider_config
# For OIDC providers, fetch end_session_endpoint from discovery
if provider_config[:strategy] == "openid_connect" && provider_config[:issuer].present?
begin
discovery_url = discovery_url_for(provider_config[:issuer])
response = Faraday.get(discovery_url) do |req|
req.options.timeout = 5
req.options.open_timeout = 3
end
return nil unless response.success?
discovery = JSON.parse(response.body)
end_session_endpoint = discovery["end_session_endpoint"]
return nil unless end_session_endpoint.present?
# Build the logout URL with post_logout_redirect_uri
post_logout_redirect = "#{request.base_url}/auth/logout/callback"
params = {
id_token_hint: id_token,
post_logout_redirect_uri: post_logout_redirect
}
"#{end_session_endpoint}?#{params.to_query}"
rescue Faraday::Error, JSON::ParserError, StandardError => e
Rails.logger.warn("[SSO] Failed to fetch OIDC discovery for logout: #{e.message}")
nil
end
else
nil
end
end
def discovery_url_for(issuer)
if issuer.end_with?("/")
"#{issuer}.well-known/openid-configuration"
else
"#{issuer}/.well-known/openid-configuration"
end
end
end

View File

@@ -58,6 +58,33 @@ class Settings::HostingsController < ApplicationController
Setting.securities_provider = hosting_params[:securities_provider]
end
if hosting_params.key?(:syncs_include_pending)
Setting.syncs_include_pending = hosting_params[:syncs_include_pending] == "1"
end
sync_settings_changed = false
if hosting_params.key?(:auto_sync_enabled)
Setting.auto_sync_enabled = hosting_params[:auto_sync_enabled] == "1"
sync_settings_changed = true
end
if hosting_params.key?(:auto_sync_time)
time_value = hosting_params[:auto_sync_time]
unless Setting.valid_auto_sync_time?(time_value)
flash[:alert] = t(".invalid_sync_time")
return redirect_to settings_hosting_path
end
Setting.auto_sync_time = time_value
Setting.auto_sync_timezone = current_user_timezone
sync_settings_changed = true
end
if sync_settings_changed
sync_auto_sync_scheduler!
end
if hosting_params.key?(:openai_access_token)
token_param = hosting_params[:openai_access_token].to_s.strip
# Ignore blanks and redaction placeholders to prevent accidental overwrite
@@ -99,10 +126,22 @@ class Settings::HostingsController < ApplicationController
private
def hosting_params
params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider)
params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time)
end
def ensure_admin
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin?
end
def sync_auto_sync_scheduler!
AutoSyncScheduler.sync!
rescue StandardError => error
Rails.logger.error("[AutoSyncScheduler] Failed to sync scheduler: #{error.message}")
Rails.logger.error(error.backtrace.join("\n"))
flash[:alert] = t(".scheduler_sync_failed")
end
def current_user_timezone
Current.family&.timezone.presence || "UTC"
end
end

View File

@@ -6,5 +6,6 @@ class Settings::SecuritiesController < ApplicationController
[ "Home", root_path ],
[ "Security", nil ]
]
@oidc_identities = Current.user.oidc_identities.order(:provider)
end
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
class Settings::SsoIdentitiesController < ApplicationController
layout "settings"
def destroy
@identity = Current.user.oidc_identities.find(params[:id])
# Prevent unlinking last identity if user has no password
if Current.user.oidc_identities.count == 1 && Current.user.password_digest.blank?
redirect_to settings_security_path, alert: t(".cannot_unlink_last")
return
end
provider_name = @identity.provider
@identity.destroy!
# Log account unlinking
SsoAuditLog.log_unlink!(
user: Current.user,
provider: provider_name,
request: request
)
redirect_to settings_security_path, notice: t(".success", provider: provider_name)
end
end

View File

@@ -24,9 +24,14 @@ class TransactionCategoriesController < ApplicationController
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace(
dom_id(transaction, :category_menu),
partial: "categories/menu",
locals: { transaction: transaction }
dom_id(transaction, "category_menu_mobile"),
partial: "transactions/transaction_category",
locals: { transaction: transaction, variant: "mobile" }
),
turbo_stream.replace(
dom_id(transaction, "category_menu_desktop"),
partial: "transactions/transaction_category",
locals: { transaction: transaction, variant: "desktop" }
),
turbo_stream.replace(
"category_name_mobile_#{transaction.id}",

View File

@@ -116,6 +116,38 @@ class TransactionsController < ApplicationController
end
end
def merge_duplicate
transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
if transaction.merge_with_duplicate!
flash[:notice] = t("transactions.merge_duplicate.success")
else
flash[:alert] = t("transactions.merge_duplicate.failure")
end
redirect_to transactions_path
rescue ActiveRecord::RecordNotDestroyed, ActiveRecord::RecordInvalid => e
Rails.logger.error("Failed to merge duplicate transaction #{params[:id]}: #{e.message}")
flash[:alert] = t("transactions.merge_duplicate.failure")
redirect_to transactions_path
end
def dismiss_duplicate
transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
if transaction.dismiss_duplicate_suggestion!
flash[:notice] = t("transactions.dismiss_duplicate.success")
else
flash[:alert] = t("transactions.dismiss_duplicate.failure")
end
redirect_back_or_to transactions_path
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error("Failed to dismiss duplicate suggestion for transaction #{params[:id]}: #{e.message}")
flash[:alert] = t("transactions.dismiss_duplicate.failure")
redirect_back_or_to transactions_path
end
def mark_as_recurring
transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
@@ -186,7 +218,7 @@ class TransactionsController < ApplicationController
def entry_params
entry_params = params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, { tag_ids: [] } ]
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, :investment_activity_label, { tag_ids: [] } ]
)
nature = entry_params.delete(:nature)
@@ -205,7 +237,7 @@ class TransactionsController < ApplicationController
:start_date, :end_date, :search, :amount,
:amount_operator, :active_accounts_only,
accounts: [], account_ids: [],
categories: [], merchants: [], types: [], tags: []
categories: [], merchants: [], types: [], tags: [], status: []
)
.to_h
.compact_blank

View File

@@ -58,7 +58,7 @@ module LanguagesHelper
uk: "Ukrainian",
vi: "Vietnamese",
'zh-CN': "简体中文",
'zh-TW': "Chinese (Traditional)",
'zh-TW': "繁體中文",
af: "Afrikaans",
az: "Azerbaijani",
be: "Belarusian",
@@ -163,7 +163,8 @@ module LanguagesHelper
"ca", # Catalan - 57 translation files
"ro", # Romanian - 62 translation files
"pt-BR", # Brazilian Portuguese - 60 translation files
"zh-CN" # Chinese (Simplified) - 59 translation files
"zh-CN", # Chinese (Simplified) - 59 translation files
"zh-TW" # Chinese (Traditional) - 63 translation files
].freeze
COUNTRY_MAPPING = {

View File

@@ -0,0 +1,34 @@
module ReportsHelper
# Generate SVG polyline points for a sparkline chart
# Returns empty string if fewer than 2 data points (can't draw a line with 1 point)
def sparkline_points(values, width: 60, height: 16)
return "" if values.nil? || values.length < 2 || values.all? { |v| v.nil? || v.zero? }
nums = values.map(&:to_f)
max_val = nums.max
min_val = nums.min
range = max_val - min_val
range = 1.0 if range.zero?
points = nums.each_with_index.map do |val, i|
x = (i.to_f / [ nums.length - 1, 1 ].max) * width
y = height - ((val - min_val) / range * (height - 2)) - 1
"#{x.round(1)},#{y.round(1)}"
end
points.join(" ")
end
# Calculate cumulative net values from trends data
def cumulative_net_values(trends)
return [] if trends.nil?
running = 0
trends.map { |t| running += t[:net].to_i; running }
end
# Check if trends data has enough points for sparklines (need at least 2)
def has_sparkline_data?(trends_data)
trends_data&.length.to_i >= 2
end
end

View File

@@ -1,13 +1,14 @@
module TransactionsHelper
def transaction_search_filters
[
{ key: "account_filter", label: "Account", icon: "layers" },
{ key: "date_filter", label: "Date", icon: "calendar" },
{ key: "type_filter", label: "Type", icon: "tag" },
{ key: "amount_filter", label: "Amount", icon: "hash" },
{ key: "category_filter", label: "Category", icon: "shapes" },
{ key: "tag_filter", label: "Tag", icon: "tags" },
{ key: "merchant_filter", label: "Merchant", icon: "store" }
{ key: "account_filter", label: t("transactions.search.filters.account"), icon: "layers" },
{ key: "date_filter", label: t("transactions.search.filters.date"), icon: "calendar" },
{ key: "type_filter", label: t("transactions.search.filters.type"), icon: "tag" },
{ key: "status_filter", label: t("transactions.search.filters.status"), icon: "clock" },
{ key: "amount_filter", label: t("transactions.search.filters.amount"), icon: "hash" },
{ key: "category_filter", label: t("transactions.search.filters.category"), icon: "shapes" },
{ key: "tag_filter", label: t("transactions.search.filters.tag"), icon: "tags" },
{ key: "merchant_filter", label: t("transactions.search.filters.merchant"), icon: "store" }
]
end

View File

@@ -0,0 +1,226 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="admin-sso-form"
export default class extends Controller {
static targets = ["callbackUrl", "testResult", "samlCallbackUrl"]
connect() {
// Initialize field visibility on page load
this.toggleFields()
// Initialize callback URL
this.updateCallbackUrl()
}
updateCallbackUrl() {
const nameInput = this.element.querySelector('input[name*="[name]"]')
const callbackDisplay = this.callbackUrlTarget
if (!nameInput || !callbackDisplay) return
const providerName = nameInput.value.trim() || 'PROVIDER_NAME'
const baseUrl = window.location.origin
callbackDisplay.textContent = `${baseUrl}/auth/${providerName}/callback`
}
toggleFields() {
const strategySelect = this.element.querySelector('select[name*="[strategy]"]')
if (!strategySelect) return
const strategy = strategySelect.value
const isOidc = strategy === "openid_connect"
const isSaml = strategy === "saml"
// Toggle OIDC fields
const oidcFields = this.element.querySelectorAll('[data-oidc-field]')
oidcFields.forEach(field => {
if (isOidc) {
field.classList.remove('hidden')
} else {
field.classList.add('hidden')
}
})
// Toggle SAML fields
const samlFields = this.element.querySelectorAll('[data-saml-field]')
samlFields.forEach(field => {
if (isSaml) {
field.classList.remove('hidden')
} else {
field.classList.add('hidden')
}
})
// Update SAML callback URL if present
if (this.hasSamlCallbackUrlTarget) {
this.updateSamlCallbackUrl()
}
}
updateSamlCallbackUrl() {
const nameInput = this.element.querySelector('input[name*="[name]"]')
if (!nameInput || !this.hasSamlCallbackUrlTarget) return
const providerName = nameInput.value.trim() || 'PROVIDER_NAME'
const baseUrl = window.location.origin
this.samlCallbackUrlTarget.textContent = `${baseUrl}/auth/${providerName}/callback`
}
copySamlCallback(event) {
event.preventDefault()
if (!this.hasSamlCallbackUrlTarget) return
const callbackUrl = this.samlCallbackUrlTarget.textContent
navigator.clipboard.writeText(callbackUrl).then(() => {
const button = event.currentTarget
const originalText = button.innerHTML
button.innerHTML = '<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Copied!'
button.classList.add('text-green-600')
setTimeout(() => {
button.innerHTML = originalText
button.classList.remove('text-green-600')
}, 2000)
}).catch(err => {
console.error('Failed to copy:', err)
alert('Failed to copy to clipboard')
})
}
async validateIssuer(event) {
const issuerInput = event.target
const issuer = issuerInput.value.trim()
if (!issuer) return
try {
// Construct discovery URL
const discoveryUrl = issuer.endsWith('/')
? `${issuer}.well-known/openid-configuration`
: `${issuer}/.well-known/openid-configuration`
// Show loading state
issuerInput.classList.add('border-yellow-300')
const response = await fetch(discoveryUrl, {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
if (data.issuer) {
// Valid OIDC discovery endpoint
issuerInput.classList.remove('border-yellow-300', 'border-red-300')
issuerInput.classList.add('border-green-300')
this.showValidationMessage(issuerInput, 'Valid OIDC issuer', 'success')
} else {
throw new Error('Invalid discovery response')
}
} else {
throw new Error(`Discovery endpoint returned ${response.status}`)
}
} catch (error) {
// CORS errors are expected when validating from browser - show as warning not error
issuerInput.classList.remove('border-yellow-300', 'border-green-300')
issuerInput.classList.add('border-amber-300')
this.showValidationMessage(issuerInput, "Could not validate from browser (CORS). Provider can still be saved.", 'warning')
}
}
copyCallback(event) {
event.preventDefault()
const callbackDisplay = this.callbackUrlTarget
if (!callbackDisplay) return
const callbackUrl = callbackDisplay.textContent
// Copy to clipboard
navigator.clipboard.writeText(callbackUrl).then(() => {
// Show success feedback
const button = event.currentTarget
const originalText = button.innerHTML
button.innerHTML = '<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Copied!'
button.classList.add('text-green-600')
setTimeout(() => {
button.innerHTML = originalText
button.classList.remove('text-green-600')
}, 2000)
}).catch(err => {
console.error('Failed to copy:', err)
alert('Failed to copy to clipboard')
})
}
showValidationMessage(input, message, type) {
// Remove any existing validation message
const existingMessage = input.parentElement.querySelector('.validation-message')
if (existingMessage) {
existingMessage.remove()
}
// Create new validation message
const messageEl = document.createElement('p')
const colorClass = type === 'success' ? 'text-green-600' : type === 'warning' ? 'text-amber-600' : 'text-red-600'
messageEl.className = `validation-message mt-1 text-sm ${colorClass}`
messageEl.textContent = message
input.parentElement.appendChild(messageEl)
// Auto-remove after 5 seconds (except warnings which stay)
if (type !== 'warning') {
setTimeout(() => {
messageEl.remove()
input.classList.remove('border-green-300', 'border-red-300', 'border-amber-300')
}, 5000)
}
}
async testConnection(event) {
const button = event.currentTarget
const testUrl = button.dataset.adminSsoFormTestUrlValue
const resultEl = this.testResultTarget
if (!testUrl) return
// Show loading state
button.disabled = true
button.textContent = 'Testing...'
resultEl.textContent = ''
resultEl.className = 'ml-2 text-sm'
try {
const response = await fetch(testUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
}
})
const data = await response.json()
if (data.success) {
resultEl.textContent = `${data.message}`
resultEl.classList.add('text-green-600')
} else {
resultEl.textContent = `${data.message}`
resultEl.classList.add('text-red-600')
}
// Show details in console for debugging
if (data.details && Object.keys(data.details).length > 0) {
console.log('SSO Test Connection Details:', data.details)
}
} catch (error) {
resultEl.textContent = `✗ Request failed: ${error.message}`
resultEl.classList.add('text-red-600')
} finally {
button.disabled = false
button.textContent = 'Test Connection'
}
}
}

View File

@@ -0,0 +1,30 @@
import { Controller } from "@hotwired/stimulus"
// Handles bidirectional conversion between total cost basis and per-share cost
// in the manual cost basis entry form.
export default class extends Controller {
static targets = ["total", "perShare", "perShareValue"]
static values = { qty: Number }
// Called when user types in the total cost basis field
// Updates the per-share display and input to show the calculated value
updatePerShare() {
const total = Number.parseFloat(this.totalTarget.value) || 0
const qty = this.qtyValue || 1
const perShare = qty > 0 ? (total / qty).toFixed(2) : "0.00"
this.perShareValueTarget.textContent = perShare
if (this.hasPerShareTarget) {
this.perShareTarget.value = perShare
}
}
// Called when user types in the per-share field
// Updates the total cost basis field with the calculated value
updateTotal() {
const perShare = Number.parseFloat(this.perShareTarget.value) || 0
const qty = this.qtyValue || 1
const total = (perShare * qty).toFixed(2)
this.totalTarget.value = total
this.perShareValueTarget.textContent = perShare.toFixed(2)
}
}

View File

@@ -0,0 +1,33 @@
import { Controller } from "@hotwired/stimulus"
// Handles the inline cost basis editor in the holding drawer.
// Shows/hides the form and handles bidirectional total <-> per-share conversion.
export default class extends Controller {
static targets = ["form", "total", "perShare", "perShareValue"]
static values = { qty: Number }
toggle(event) {
event.preventDefault()
this.formTarget.classList.toggle("hidden")
}
// Called when user types in total cost basis field
updatePerShare() {
const total = Number.parseFloat(this.totalTarget.value) || 0
const qty = this.qtyValue || 1
const perShare = qty > 0 ? (total / qty).toFixed(2) : "0.00"
this.perShareValueTarget.textContent = perShare
if (this.hasPerShareTarget) {
this.perShareTarget.value = perShare
}
}
// Called when user types in per-share field
updateTotal() {
const perShare = Number.parseFloat(this.perShareTarget.value) || 0
const qty = this.qtyValue || 1
const total = (perShare * qty).toFixed(2)
this.totalTarget.value = total
this.perShareValueTarget.textContent = perShare.toFixed(2)
}
}

View File

@@ -2,166 +2,30 @@ class SimplefinConnectionUpdateJob < ApplicationJob
queue_as :high_priority
# Disable automatic retries for this job since the setup token is single-use.
# If the token claim succeeds but import fails, retrying would fail at claim.
# If the token claim succeeds but sync fails, retrying would fail at claim.
discard_on Provider::Simplefin::SimplefinError do |job, error|
Rails.logger.error(
"SimplefinConnectionUpdateJob discarded: #{error.class} - #{error.message} " \
"(family_id=#{job.arguments.first[:family_id]}, old_item_id=#{job.arguments.first[:old_simplefin_item_id]})"
"(family_id=#{job.arguments.first[:family_id]}, item_id=#{job.arguments.first[:old_simplefin_item_id]})"
)
end
def perform(family_id:, old_simplefin_item_id:, setup_token:)
family = Family.find(family_id)
old_item = family.simplefin_items.find(old_simplefin_item_id)
simplefin_item = family.simplefin_items.find(old_simplefin_item_id)
# Step 1: Claim the token and create the new item.
# This is the critical step - if it fails, we can safely retry.
# If it succeeds, the token is consumed and we must not retry the claim.
updated_item = family.create_simplefin_item!(
setup_token: setup_token,
item_name: old_item.name
# Step 1: Claim the new token and update the existing item's access_url.
# This preserves all existing account linkages - no need to transfer anything.
simplefin_item.update_access_url!(setup_token: setup_token)
# Step 2: Sync the item to import fresh data.
# The existing repair_stale_linkages logic handles cases where SimpleFIN
# account IDs changed (e.g., user re-added institution in SimpleFIN Bridge).
simplefin_item.sync_later
Rails.logger.info(
"SimplefinConnectionUpdateJob: Successfully updated SimplefinItem #{simplefin_item.id} " \
"with new access_url for family #{family_id}"
)
# Step 2: Import accounts from SimpleFin.
# If this fails, we have an orphaned item but the token is already consumed.
# We handle this gracefully by marking the item and continuing.
begin
updated_item.import_latest_simplefin_data
rescue => e
Rails.logger.error(
"SimplefinConnectionUpdateJob: import failed for new item #{updated_item.id}: " \
"#{e.class} - #{e.message}. Item created but may need manual sync."
)
# Mark the item as needing attention but don't fail the job entirely.
# The item exists and can be synced manually later.
updated_item.update!(status: :requires_update)
# Still proceed to transfer accounts and schedule old item deletion
end
# Step 3: Transfer account links from old to new item.
# This is idempotent and safe to retry.
# Check for linked accounts via BOTH legacy FK and AccountProvider.
ActiveRecord::Base.transaction do
old_item.simplefin_accounts.includes(:account, account_provider: :account).each do |old_account|
# Get the linked account via either system
linked_account = old_account.current_account
next unless linked_account.present?
new_simplefin_account = find_matching_simplefin_account(old_account, updated_item.simplefin_accounts)
next unless new_simplefin_account
# Update legacy FK
linked_account.update!(simplefin_account_id: new_simplefin_account.id)
# Also migrate AccountProvider if it exists
if old_account.account_provider.present?
old_account.account_provider.update!(
provider_type: "SimplefinAccount",
provider_id: new_simplefin_account.id
)
else
# Create AccountProvider for consistency
new_simplefin_account.ensure_account_provider!
end
end
end
# Schedule deletion outside transaction to avoid race condition where
# the job is enqueued even if the transaction rolls back
old_item.destroy_later
# Only mark as good if import succeeded (status wasn't set to requires_update above)
updated_item.update!(status: :good) unless updated_item.requires_update?
end
private
# Find a matching SimpleFin account in the new item's accounts.
# Uses a multi-tier matching strategy:
# 1. Exact account_id match (preferred)
# 2. Fingerprint match (name + institution + account_type)
# 3. Fuzzy name match with same institution (fallback)
def find_matching_simplefin_account(old_account, new_accounts)
exact_match = new_accounts.find_by(account_id: old_account.account_id)
return exact_match if exact_match
old_fingerprint = account_fingerprint(old_account)
fingerprint_match = new_accounts.find { |new_account| account_fingerprint(new_account) == old_fingerprint }
return fingerprint_match if fingerprint_match
old_institution = extract_institution_id(old_account)
old_name_normalized = normalize_account_name(old_account.name)
new_accounts.find do |new_account|
new_institution = extract_institution_id(new_account)
new_name_normalized = normalize_account_name(new_account.name)
next false unless old_institution.present? && old_institution == new_institution
names_similar?(old_name_normalized, new_name_normalized)
end
end
def account_fingerprint(simplefin_account)
institution_id = extract_institution_id(simplefin_account)
name_normalized = normalize_account_name(simplefin_account.name)
account_type = simplefin_account.account_type.to_s.downcase
"#{institution_id}:#{name_normalized}:#{account_type}"
end
def extract_institution_id(simplefin_account)
org_data = simplefin_account.org_data
return nil unless org_data.is_a?(Hash)
org_data["id"] || org_data["domain"] || org_data["name"]&.downcase&.gsub(/\s+/, "_")
end
def normalize_account_name(name)
return "" if name.blank?
name.to_s
.downcase
.gsub(/[^a-z0-9]/, "")
end
def names_similar?(name1, name2)
return false if name1.blank? || name2.blank?
return true if name1 == name2
return true if name1.include?(name2) || name2.include?(name1)
longer = [ name1.length, name2.length ].max
return false if longer == 0
# Use Levenshtein distance for more accurate similarity
distance = levenshtein_distance(name1, name2)
similarity = 1.0 - (distance.to_f / longer)
similarity >= 0.8
end
# Compute Levenshtein edit distance between two strings
def levenshtein_distance(s1, s2)
m, n = s1.length, s2.length
return n if m.zero?
return m if n.zero?
# Use a single array and update in place for memory efficiency
prev_row = (0..n).to_a
curr_row = []
(1..m).each do |i|
curr_row[0] = i
(1..n).each do |j|
cost = s1[i - 1] == s2[j - 1] ? 0 : 1
curr_row[j] = [
prev_row[j] + 1, # deletion
curr_row[j - 1] + 1, # insertion
prev_row[j - 1] + cost # substitution
].min
end
prev_row, curr_row = curr_row, prev_row
end
prev_row[n]
end
end

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
# Middleware to catch OmniAuth/OIDC errors and redirect gracefully
# instead of showing ugly error pages
class OmniauthErrorHandler
def initialize(app)
@app = app
end
def call(env)
@app.call(env)
rescue OpenIDConnect::Discovery::DiscoveryFailed => e
Rails.logger.error("[OmniAuth] OIDC Discovery failed: #{e.message}")
redirect_to_failure(env, "sso_provider_unavailable")
rescue OmniAuth::Error => e
Rails.logger.error("[OmniAuth] Authentication error: #{e.message}")
redirect_to_failure(env, "sso_failed")
end
private
def redirect_to_failure(env, message)
[
302,
{ "Location" => "/auth/failure?message=#{message}", "Content-Type" => "text/html" },
[ "Redirecting..." ]
]
end
end

View File

@@ -16,9 +16,11 @@ class Account::ProviderImportAdapter
# @param category_id [Integer, nil] Optional category ID
# @param merchant [Merchant, nil] Optional merchant object
# @param notes [String, nil] Optional transaction notes/memo
# @param pending_transaction_id [String, nil] Plaid's linking ID for pending→posted reconciliation
# @param extra [Hash, nil] Optional provider-specific metadata to merge into transaction.extra
# @param investment_activity_label [String, nil] Optional activity type label (e.g., "Buy", "Dividend")
# @return [Entry] The created or updated entry
def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, extra: nil)
def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, pending_transaction_id: nil, extra: nil, investment_activity_label: nil)
raise ArgumentError, "external_id is required" if external_id.blank?
raise ArgumentError, "source is required" if source.blank?
@@ -43,6 +45,42 @@ class Account::ProviderImportAdapter
end
end
# If still a new entry and this is a POSTED transaction, check for matching pending transactions
incoming_pending = extra.is_a?(Hash) && (
ActiveModel::Type::Boolean.new.cast(extra.dig("simplefin", "pending")) ||
ActiveModel::Type::Boolean.new.cast(extra.dig("plaid", "pending"))
)
if entry.new_record? && !incoming_pending
pending_match = nil
# PRIORITY 1: Use Plaid's pending_transaction_id if provided (most reliable)
# Plaid explicitly links pending→posted with this ID - no guessing required
if pending_transaction_id.present?
pending_match = account.entries.find_by(external_id: pending_transaction_id, source: source)
if pending_match
Rails.logger.info("Reconciling pending→posted via Plaid pending_transaction_id: claiming entry #{pending_match.id} (#{pending_match.name}) with new external_id #{external_id}")
end
end
# PRIORITY 2: Fallback to EXACT amount match (for SimpleFIN and providers without linking IDs)
# Only searches backward in time - pending date must be <= posted date
if pending_match.nil?
pending_match = find_pending_transaction(date: date, amount: amount, currency: currency, source: source)
if pending_match
Rails.logger.info("Reconciling pending→posted via exact amount match: claiming entry #{pending_match.id} (#{pending_match.name}) with new external_id #{external_id}")
end
end
if pending_match
entry = pending_match
entry.assign_attributes(external_id: external_id)
end
end
# Track if this is a new posted transaction (for fuzzy suggestion after save)
is_new_posted = entry.new_record? && !incoming_pending
# Validate entryable type matches to prevent external_id collisions
if entry.persisted? && !entry.entryable.is_a?(Transaction)
raise ArgumentError, "Entry with external_id '#{external_id}' already exists with different entryable type: #{entry.entryable_type}"
@@ -77,7 +115,70 @@ class Account::ProviderImportAdapter
entry.transaction.extra = existing.deep_merge(incoming)
entry.transaction.save!
end
# Set investment activity label if provided and not already set
if investment_activity_label.present? && entry.entryable.is_a?(Transaction)
if entry.transaction.investment_activity_label.blank?
entry.transaction.assign_attributes(investment_activity_label: investment_activity_label)
end
end
entry.save!
entry.transaction.save! if entry.transaction.changed?
# AFTER save: For NEW posted transactions, check for fuzzy matches to SUGGEST (not auto-claim)
# This handles tip adjustments where auto-matching is too risky
if is_new_posted
# PRIORITY 1: Try medium-confidence fuzzy match (≤30% amount difference)
fuzzy_suggestion = find_pending_transaction_fuzzy(
date: date,
amount: amount,
currency: currency,
source: source,
merchant_id: merchant&.id,
name: name
)
if fuzzy_suggestion
# Store suggestion on the PENDING entry for user to review
begin
store_duplicate_suggestion(
pending_entry: fuzzy_suggestion,
posted_entry: entry,
reason: "fuzzy_amount_match",
posted_amount: amount,
confidence: "medium"
)
Rails.logger.info("Suggested potential duplicate (medium confidence): pending entry #{fuzzy_suggestion.id} (#{fuzzy_suggestion.name}, #{fuzzy_suggestion.amount}) may match posted #{entry.name} (#{amount})")
rescue ActiveRecord::RecordInvalid => e
Rails.logger.warn("Failed to store duplicate suggestion for entry #{fuzzy_suggestion.id}: #{e.message}")
end
else
# PRIORITY 2: Try low-confidence match (>30% to 100% difference - big tips)
low_confidence_suggestion = find_pending_transaction_low_confidence(
date: date,
amount: amount,
currency: currency,
source: source,
merchant_id: merchant&.id,
name: name
)
if low_confidence_suggestion
begin
store_duplicate_suggestion(
pending_entry: low_confidence_suggestion,
posted_entry: entry,
reason: "low_confidence_match",
posted_amount: amount,
confidence: "low"
)
Rails.logger.info("Suggested potential duplicate (low confidence): pending entry #{low_confidence_suggestion.id} (#{low_confidence_suggestion.name}, #{low_confidence_suggestion.amount}) may match posted #{entry.name} (#{amount})")
rescue ActiveRecord::RecordInvalid => e
Rails.logger.warn("Failed to store duplicate suggestion for entry #{low_confidence_suggestion.id}: #{e.message}")
end
end
end
end
entry
end
end
@@ -222,17 +323,32 @@ class Account::ProviderImportAdapter
end
end
holding.assign_attributes(
# Reconcile cost_basis to respect priority hierarchy
reconciled = Holding::CostBasisReconciler.reconcile(
existing_holding: holding.persisted? ? holding : nil,
incoming_cost_basis: cost_basis,
incoming_source: "provider"
)
# Build base attributes
attributes = {
security: security,
date: date,
currency: currency,
qty: quantity,
price: price,
amount: amount,
cost_basis: cost_basis,
account_provider_id: account_provider_id,
external_id: external_id
)
}
# Only update cost_basis if reconciliation says to
if reconciled[:should_update]
attributes[:cost_basis] = reconciled[:cost_basis]
attributes[:cost_basis_source] = reconciled[:cost_basis_source]
end
holding.assign_attributes(attributes)
begin
Holding.transaction(requires_new: true) do
@@ -262,11 +378,22 @@ class Account::ProviderImportAdapter
updates = {
qty: quantity,
price: price,
amount: amount,
cost_basis: cost_basis
amount: amount
}
# Adopt the row to this provider if its currently unowned
# Reconcile cost_basis to respect priority hierarchy
collision_reconciled = Holding::CostBasisReconciler.reconcile(
existing_holding: existing,
incoming_cost_basis: cost_basis,
incoming_source: "provider"
)
if collision_reconciled[:should_update]
updates[:cost_basis] = collision_reconciled[:cost_basis]
updates[:cost_basis_source] = collision_reconciled[:cost_basis_source]
end
# Adopt the row to this provider if it's currently unowned
if account_provider_id.present? && existing.account_provider_id.nil?
updates[:account_provider_id] = account_provider_id
end
@@ -444,4 +571,196 @@ class Account::ProviderImportAdapter
query.order(created_at: :asc).first
end
# Finds a pending transaction that likely matches a newly posted transaction
# Used to reconcile pending→posted when SimpleFIN gives different IDs for the same transaction
#
# @param date [Date, String] Posted transaction date
# @param amount [BigDecimal, Numeric] Transaction amount (must match exactly)
# @param currency [String] Currency code
# @param source [String] Provider name (e.g., "simplefin")
# @param date_window [Integer] Days to search around the posted date (default: 8)
# @return [Entry, nil] The pending entry or nil if not found
def find_pending_transaction(date:, amount:, currency:, source:, date_window: 8)
date = Date.parse(date.to_s) unless date.is_a?(Date)
# Look for entries that:
# 1. Same account (implicit via account.entries)
# 2. Same source (simplefin)
# 3. Same amount (exact match - this is the strongest signal)
# 4. Same currency
# 5. Date within window (pending can post days later)
# 6. Is a Transaction (not Trade or Valuation)
# 7. Has pending=true in transaction.extra["simplefin"]["pending"] or extra["plaid"]["pending"]
candidates = account.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(source: source)
.where(amount: amount)
.where(currency: currency)
.where(date: (date - date_window.days)..date) # Pending must be ON or BEFORE posted date
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
SQL
.order(date: :desc) # Prefer most recent pending transaction
candidates.first
end
# Finds a pending transaction using fuzzy amount matching for tip adjustments
# Used when exact amount matching fails - handles restaurant tips, adjusted authorizations, etc.
#
# IMPORTANT: Only returns a match if there's exactly ONE candidate to avoid false positives
# with recurring merchant transactions (e.g., gas stations, coffee shops).
#
# @param date [Date, String] Posted transaction date
# @param amount [BigDecimal, Numeric] Posted transaction amount (typically higher due to tip)
# @param currency [String] Currency code
# @param source [String] Provider name (e.g., "simplefin")
# @param merchant_id [Integer, nil] Merchant ID for more accurate matching
# @param name [String, nil] Transaction name for fuzzy name matching
# @param date_window [Integer] Days to search backward from posted date (default: 3 for fuzzy)
# @param amount_tolerance [Float] Maximum percentage difference allowed (default: 0.30 = 30%)
# @return [Entry, nil] The pending entry or nil if not found/ambiguous
def find_pending_transaction_fuzzy(date:, amount:, currency:, source:, merchant_id: nil, name: nil, date_window: 3, amount_tolerance: 0.30)
date = Date.parse(date.to_s) unless date.is_a?(Date)
amount = BigDecimal(amount.to_s)
# Calculate amount bounds using ABS to handle both positive and negative amounts
# Posted amount should be >= pending (tips add, not subtract)
# Allow posted to be up to 30% higher than pending (covers typical tips)
abs_amount = amount.abs
min_pending_abs = abs_amount / (1 + amount_tolerance) # If posted is 100, pending could be as low as ~77
max_pending_abs = abs_amount # Pending should not be higher than posted
# Build base query for pending transactions
# CRITICAL: Pending must be ON or BEFORE the posted date (authorization happens first)
# Use tighter date window (3 days) - tips post quickly, not a week later
# Use ABS() for amount comparison to handle negative amounts correctly
candidates = account.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(source: source)
.where(currency: currency)
.where(date: (date - date_window.days)..date) # Pending ON or BEFORE posted
.where("ABS(entries.amount) BETWEEN ? AND ?", min_pending_abs, max_pending_abs)
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
SQL
# If merchant_id is provided, prioritize matching by merchant
if merchant_id.present?
merchant_matches = candidates.where("transactions.merchant_id = ?", merchant_id).to_a
# Only match if exactly ONE candidate to avoid false positives
return merchant_matches.first if merchant_matches.size == 1
if merchant_matches.size > 1
Rails.logger.info("Skipping fuzzy pending match: #{merchant_matches.size} ambiguous merchant candidates for amount=#{amount} date=#{date}")
end
end
# If name is provided, try fuzzy name matching as fallback
if name.present?
# Extract first few significant words for comparison
name_words = name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
if name_words.present?
name_matches = candidates.select do |c|
c_name_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
name_words == c_name_words
end
# Only match if exactly ONE candidate to avoid false positives
return name_matches.first if name_matches.size == 1
if name_matches.size > 1
Rails.logger.info("Skipping fuzzy pending match: #{name_matches.size} ambiguous name candidates for '#{name_words}' amount=#{amount} date=#{date}")
end
end
end
# No merchant or name match, return nil (too risky to match on amount alone)
# This prevents false positives when multiple pending transactions exist
nil
end
# Finds a pending transaction with low confidence (>30% to 100% amount difference)
# Used for large tip scenarios where normal fuzzy matching would miss
# Creates a "review recommended" suggestion rather than "possible duplicate"
#
# @param date [Date, String] Posted transaction date
# @param amount [BigDecimal, Numeric] Posted transaction amount
# @param currency [String] Currency code
# @param source [String] Provider name
# @param merchant_id [Integer, nil] Merchant ID for matching
# @param name [String, nil] Transaction name for matching
# @param date_window [Integer] Days to search backward (default: 3)
# @return [Entry, nil] The pending entry or nil if not found/ambiguous
def find_pending_transaction_low_confidence(date:, amount:, currency:, source:, merchant_id: nil, name: nil, date_window: 3)
date = Date.parse(date.to_s) unless date.is_a?(Date)
amount = BigDecimal(amount.to_s)
# Allow up to 100% difference (e.g., $50 pending → $100 posted with huge tip)
# This is low confidence - requires strong name/merchant match
# Use ABS to handle both positive and negative amounts correctly
abs_amount = amount.abs
min_pending_abs = abs_amount / 2.0 # Posted could be up to 2x pending
max_pending_abs = abs_amount * 0.77 # Pending must be at least 30% less (to not overlap with fuzzy)
# Build base query for pending transactions
# Use ABS() for amount comparison to handle negative amounts correctly
candidates = account.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(source: source)
.where(currency: currency)
.where(date: (date - date_window.days)..date)
.where("ABS(entries.amount) BETWEEN ? AND ?", min_pending_abs, max_pending_abs)
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
SQL
# For low confidence, require BOTH merchant AND name match (stronger signal needed)
if merchant_id.present? && name.present?
name_words = name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
return nil if name_words.blank?
merchant_matches = candidates.where("transactions.merchant_id = ?", merchant_id).to_a
name_matches = merchant_matches.select do |c|
c_name_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
name_words == c_name_words
end
# Only match if exactly ONE candidate
return name_matches.first if name_matches.size == 1
end
nil
end
# Stores a duplicate suggestion on a pending entry for user review
# The suggestion is stored in the pending transaction's extra field
#
# @param pending_entry [Entry] The pending entry that may be a duplicate
# @param posted_entry [Entry] The posted entry it may match
# @param reason [String] Why this was flagged (e.g., "fuzzy_amount_match", "low_confidence_match")
# @param posted_amount [BigDecimal] The posted transaction amount
# @param confidence [String] Confidence level: "medium" (≤30% diff) or "low" (>30% diff)
def store_duplicate_suggestion(pending_entry:, posted_entry:, reason:, posted_amount:, confidence: "medium")
return unless pending_entry&.entryable.is_a?(Transaction)
pending_transaction = pending_entry.entryable
existing_extra = pending_transaction.extra || {}
# Don't overwrite if already has a suggestion (keep first one found)
return if existing_extra["potential_posted_match"].present?
pending_transaction.update!(
extra: existing_extra.merge(
"potential_posted_match" => {
"entry_id" => posted_entry.id,
"reason" => reason,
"posted_amount" => posted_amount.to_s,
"confidence" => confidence,
"detected_at" => Date.current.to_s
}
)
)
end
end

View File

@@ -105,8 +105,8 @@ class Assistant::Function::GetHoldings < Assistant::Function
amount: holding.amount.to_f,
formatted_amount: holding.amount_money.format,
weight: holding.weight&.round(2),
average_cost: holding.avg_cost.to_f,
formatted_average_cost: holding.avg_cost.format,
average_cost: holding.avg_cost&.to_f,
formatted_average_cost: holding.avg_cost&.format,
account: holding.account.name,
date: holding.date
}

View File

@@ -12,11 +12,11 @@ class CoinstatsItem::Syncer
# @param sync [Sync] Sync record for status tracking
def perform_sync(sync)
# Phase 1: Import data from CoinStats API
sync.update!(status_text: "Importing wallets from CoinStats...") if sync.respond_to?(:status_text)
sync.update!(status_text: I18n.t("models.coinstats_item.syncer.importing_wallets")) if sync.respond_to?(:status_text)
coinstats_item.import_latest_coinstats_data
# Phase 2: Check account setup status and collect sync statistics
sync.update!(status_text: "Checking wallet configuration...") if sync.respond_to?(:status_text)
sync.update!(status_text: I18n.t("models.coinstats_item.syncer.checking_configuration")) if sync.respond_to?(:status_text)
total_accounts = coinstats_item.coinstats_accounts.count
linked_accounts = coinstats_item.coinstats_accounts.joins(:account_provider).joins(:account).merge(Account.visible)
@@ -30,18 +30,18 @@ class CoinstatsItem::Syncer
if unlinked_accounts.any?
coinstats_item.update!(pending_account_setup: true)
sync.update!(status_text: "#{unlinked_accounts.count} wallets need setup...") if sync.respond_to?(:status_text)
sync.update!(status_text: I18n.t("models.coinstats_item.syncer.wallets_need_setup", count: unlinked_accounts.count)) if sync.respond_to?(:status_text)
else
coinstats_item.update!(pending_account_setup: false)
end
# Phase 3: Process holdings for linked accounts only
if linked_accounts.any?
sync.update!(status_text: "Processing holdings...") if sync.respond_to?(:status_text)
sync.update!(status_text: I18n.t("models.coinstats_item.syncer.processing_holdings")) if sync.respond_to?(:status_text)
coinstats_item.process_accounts
# Phase 4: Schedule balance calculations for linked accounts
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
sync.update!(status_text: I18n.t("models.coinstats_item.syncer.calculating_balances")) if sync.respond_to?(:status_text)
coinstats_item.schedule_account_syncs(
parent_sync: sync,
window_start_date: sync.window_start_date,

View File

@@ -35,6 +35,188 @@ class Entry < ApplicationRecord
)
}
# Pending transaction scopes - check Transaction.extra for provider pending flags
# Works with any provider that stores pending status in extra["provider_name"]["pending"]
scope :pending, -> {
joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
SQL
}
scope :excluding_pending, -> {
# For non-Transaction entries (Trade, Valuation), always include
# For Transaction entries, exclude if pending flag is true
where(<<~SQL.squish)
entries.entryable_type != 'Transaction'
OR NOT EXISTS (
SELECT 1 FROM transactions t
WHERE t.id = entries.entryable_id
AND (
(t.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (t.extra -> 'plaid' ->> 'pending')::boolean = true
)
)
SQL
}
# Find stale pending transactions (pending for more than X days with no matching posted version)
scope :stale_pending, ->(days: 8) {
pending.where("entries.date < ?", days.days.ago.to_date)
}
# Auto-exclude stale pending transactions for an account
# Called during sync to clean up pending transactions that never posted
# @param account [Account] The account to clean up
# @param days [Integer] Number of days after which pending is considered stale (default: 8)
# @return [Integer] Number of entries excluded
def self.auto_exclude_stale_pending(account:, days: 8)
stale_entries = account.entries.stale_pending(days: days).where(excluded: false)
count = stale_entries.count
if count > 0
stale_entries.update_all(excluded: true, updated_at: Time.current)
Rails.logger.info("Auto-excluded #{count} stale pending transaction(s) for account #{account.id} (#{account.name})")
end
count
end
# Retroactively reconcile pending transactions that have a matching posted version
# This handles duplicates created before reconciliation code was deployed
#
# @param account [Account, nil] Specific account to clean up, or nil for all accounts
# @param dry_run [Boolean] If true, only report what would be done without making changes
# @param date_window [Integer] Days to search forward for posted matches (default: 8)
# @param amount_tolerance [Float] Percentage difference allowed for fuzzy matching (default: 0.25)
# @return [Hash] Stats about what was reconciled
def self.reconcile_pending_duplicates(account: nil, dry_run: false, date_window: 8, amount_tolerance: 0.25)
stats = { checked: 0, reconciled: 0, details: [] }
# Get pending entries to check
scope = Entry.pending.where(excluded: false)
scope = scope.where(account: account) if account
scope.includes(:account, :entryable).find_each do |pending_entry|
stats[:checked] += 1
acct = pending_entry.account
# PRIORITY 1: Look for posted transaction with EXACT amount match
# CRITICAL: Only search forward in time - posted date must be >= pending date
exact_candidates = acct.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where.not(id: pending_entry.id)
.where(currency: pending_entry.currency)
.where(amount: pending_entry.amount)
.where(date: pending_entry.date..(pending_entry.date + date_window.days)) # Posted must be ON or AFTER pending date
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE
SQL
.limit(2) # Only need to know if 0, 1, or 2+ candidates
.to_a # Load limited records to avoid COUNT(*) on .size
# Handle exact match - auto-exclude only if exactly ONE candidate (high confidence)
# Multiple candidates = ambiguous = skip to avoid excluding wrong entry
if exact_candidates.size == 1
posted_match = exact_candidates.first
detail = {
pending_id: pending_entry.id,
pending_name: pending_entry.name,
pending_amount: pending_entry.amount.to_f,
pending_date: pending_entry.date,
posted_id: posted_match.id,
posted_name: posted_match.name,
posted_amount: posted_match.amount.to_f,
posted_date: posted_match.date,
account: acct.name,
match_type: "exact"
}
stats[:details] << detail
stats[:reconciled] += 1
unless dry_run
pending_entry.update!(excluded: true)
Rails.logger.info("Reconciled pending→posted duplicate: excluded entry #{pending_entry.id} (#{pending_entry.name}) matched to #{posted_match.id}")
end
next
end
# PRIORITY 2: If no exact match, try fuzzy amount match for tip adjustments
# Store as SUGGESTION instead of auto-excluding (medium confidence)
pending_amount = pending_entry.amount.abs
min_amount = pending_amount
max_amount = pending_amount * (1 + amount_tolerance)
fuzzy_date_window = 3
candidates = acct.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where.not(id: pending_entry.id)
.where(currency: pending_entry.currency)
.where(date: pending_entry.date..(pending_entry.date + fuzzy_date_window.days)) # Posted ON or AFTER pending
.where("ABS(entries.amount) BETWEEN ? AND ?", min_amount, max_amount)
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE
SQL
# Match by name similarity (first 3 words)
name_words = pending_entry.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
if name_words.present?
matching_candidates = candidates.select do |c|
c_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
name_words == c_words
end
# Only suggest if there's exactly ONE matching candidate
# Multiple matches = ambiguous (e.g., recurring gas station visits) = skip
if matching_candidates.size == 1
fuzzy_match = matching_candidates.first
detail = {
pending_id: pending_entry.id,
pending_name: pending_entry.name,
pending_amount: pending_entry.amount.to_f,
pending_date: pending_entry.date,
posted_id: fuzzy_match.id,
posted_name: fuzzy_match.name,
posted_amount: fuzzy_match.amount.to_f,
posted_date: fuzzy_match.date,
account: acct.name,
match_type: "fuzzy_suggestion"
}
stats[:details] << detail
unless dry_run
# Store suggestion on the pending entry instead of auto-excluding
pending_transaction = pending_entry.entryable
if pending_transaction.is_a?(Transaction)
existing_extra = pending_transaction.extra || {}
unless existing_extra["potential_posted_match"].present?
pending_transaction.update!(
extra: existing_extra.merge(
"potential_posted_match" => {
"entry_id" => fuzzy_match.id,
"reason" => "fuzzy_amount_match",
"posted_amount" => fuzzy_match.amount.to_s,
"detected_at" => Date.current.to_s
}
)
)
Rails.logger.info("Stored duplicate suggestion for entry #{pending_entry.id} (#{pending_entry.name}) → #{fuzzy_match.id}")
end
end
end
elsif matching_candidates.size > 1
Rails.logger.info("Skipping fuzzy reconciliation for #{pending_entry.id} (#{pending_entry.name}): #{matching_candidates.size} ambiguous candidates")
end
end
end
stats
end
def classification
amount.negative? ? "income" : "expense"
end

View File

@@ -6,6 +6,7 @@ class EntrySearch
attribute :amount, :string
attribute :amount_operator, :string
attribute :types, :string
attribute :status, array: true
attribute :accounts, array: true
attribute :account_ids, array: true
attribute :start_date, :string
@@ -16,7 +17,7 @@ class EntrySearch
return scope if search.blank?
query = scope
query = query.where("entries.name ILIKE :search",
query = query.where("entries.name ILIKE :search OR entries.notes ILIKE :search",
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
)
query
@@ -56,6 +57,44 @@ class EntrySearch
query = query.where(accounts: { id: account_ids }) if account_ids.present?
query
end
def apply_status_filter(scope, statuses)
return scope unless statuses.present?
return scope if statuses.uniq.sort == %w[confirmed pending] # Both selected = no filter
pending_condition = <<~SQL.squish
entries.entryable_type = 'Transaction'
AND EXISTS (
SELECT 1 FROM transactions t
WHERE t.id = entries.entryable_id
AND (
(t.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (t.extra -> 'plaid' ->> 'pending')::boolean = true
)
)
SQL
confirmed_condition = <<~SQL.squish
entries.entryable_type != 'Transaction'
OR NOT EXISTS (
SELECT 1 FROM transactions t
WHERE t.id = entries.entryable_id
AND (
(t.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (t.extra -> 'plaid' ->> 'pending')::boolean = true
)
)
SQL
case statuses.sort
when [ "pending" ]
scope.where(pending_condition)
when [ "confirmed" ]
scope.where(confirmed_condition)
else
scope
end
end
end
def build_query(scope)
@@ -64,6 +103,7 @@ class EntrySearch
query = self.class.apply_date_filters(query, start_date, end_date)
query = self.class.apply_amount_filter(query, amount, amount_operator)
query = self.class.apply_accounts_filter(query, accounts, account_ids)
query = self.class.apply_status_filter(query, status)
query
end
end

View File

@@ -3,6 +3,16 @@ class Holding < ApplicationRecord
monetize :amount
# Cost basis source priority (higher = takes precedence)
COST_BASIS_SOURCE_PRIORITY = {
nil => 0,
"provider" => 1,
"calculated" => 2,
"manual" => 3
}.freeze
COST_BASIS_SOURCES = %w[manual calculated provider].freeze
belongs_to :account
belongs_to :security
belongs_to :account_provider, optional: true
@@ -10,9 +20,12 @@ class Holding < ApplicationRecord
validates :qty, :currency, :date, :price, :amount, presence: true
validates :qty, :price, :amount, numericality: { greater_than_or_equal_to: 0 }
validates :external_id, uniqueness: { scope: :account_id }, allow_blank: true
validates :cost_basis_source, inclusion: { in: COST_BASIS_SOURCES }, allow_nil: true
scope :chronological, -> { order(:date) }
scope :for, ->(security) { where(security_id: security).order(:date) }
scope :with_locked_cost_basis, -> { where(cost_basis_locked: true) }
scope :with_unlocked_cost_basis, -> { where(cost_basis_locked: false) }
delegate :ticker, to: :security
@@ -27,12 +40,16 @@ class Holding < ApplicationRecord
account.balance.zero? ? 1 : amount / account.balance * 100
end
# Basic approximation of cost-basis
# Returns average cost per share, or nil if unknown.
#
# Uses pre-computed cost_basis if available (set during materialization),
# otherwise falls back to calculating from trades
# otherwise falls back to calculating from trades. Returns nil when cost
# basis cannot be determined (no trades and no provider cost_basis).
def avg_cost
# Use stored cost_basis if available (eliminates N+1 queries)
return Money.new(cost_basis, currency) if cost_basis.present?
# Use stored cost_basis if available and positive (eliminates N+1 queries)
# Note: cost_basis of 0 is treated as "unknown" since providers sometimes
# return 0 when they don't have the data
return Money.new(cost_basis, currency) if cost_basis.present? && cost_basis.positive?
# Fallback to calculation for holdings without pre-computed cost_basis
calculate_avg_cost
@@ -72,9 +89,57 @@ class Holding < ApplicationRecord
account.sync_later
end
# Returns the priority level for the current source (higher = better)
def cost_basis_source_priority
COST_BASIS_SOURCE_PRIORITY[cost_basis_source] || 0
end
# Check if this holding's cost_basis can be overwritten by the given source
def cost_basis_replaceable_by?(new_source)
return false if cost_basis_locked?
new_priority = COST_BASIS_SOURCE_PRIORITY[new_source] || 0
# Special case: when user unlocks a manual cost_basis, they're opting into
# recalculation. Allow only "calculated" source to replace it (from trades).
# This is the whole point of the unlock action.
if cost_basis_source == "manual"
return new_source == "calculated"
end
new_priority > cost_basis_source_priority
end
# Set cost_basis from user input (locks the value)
def set_manual_cost_basis!(value)
update!(
cost_basis: value,
cost_basis_source: "manual",
cost_basis_locked: true
)
end
# Unlock cost_basis to allow provider/calculated updates
def unlock_cost_basis!
update!(cost_basis_locked: false)
end
# Check if cost_basis is known (has a source and positive value)
def cost_basis_known?
cost_basis.present? && cost_basis.positive? && cost_basis_source.present?
end
# Human-readable source label for UI display
def cost_basis_source_label
return nil unless cost_basis_source.present?
I18n.t("holdings.cost_basis_sources.#{cost_basis_source}")
end
private
def calculate_trend
return nil unless amount_money
return nil unless avg_cost # Can't calculate trend without cost basis
start_amount = qty * avg_cost
@@ -83,6 +148,8 @@ class Holding < ApplicationRecord
previous: start_amount
end
# Calculates weighted average cost from buy trades.
# Returns nil if no trades exist (cost basis is unknown).
def calculate_avg_cost
trades = account.trades
.with_entry
@@ -101,13 +168,10 @@ class Holding < ApplicationRecord
Arel.sql("SUM(trades.qty)")
)
weighted_avg =
if total_qty && total_qty > 0
total_cost / total_qty
else
price
end
# Return nil when no trades exist - cost basis is genuinely unknown
# Previously this fell back to current market price, which was misleading
return nil unless total_qty && total_qty > 0
Money.new(weighted_avg || price, currency)
Money.new(total_cost / total_qty, currency)
end
end

View File

@@ -0,0 +1,58 @@
# Determines the appropriate cost_basis value and source when updating a holding.
#
# Used by both Materializer (for trade-derived calculations) and
# ProviderImportAdapter (for provider-supplied values) to ensure consistent
# reconciliation logic across all data sources.
#
# Priority hierarchy: manual > calculated > provider > unknown
#
class Holding::CostBasisReconciler
# Determines the appropriate cost_basis value and source for a holding update
#
# @param existing_holding [Holding, nil] The existing holding record (nil for new)
# @param incoming_cost_basis [BigDecimal, nil] The incoming cost_basis value
# @param incoming_source [String] The source of incoming data ('calculated', 'provider')
# @return [Hash] { cost_basis: value, cost_basis_source: source, should_update: boolean }
def self.reconcile(existing_holding:, incoming_cost_basis:, incoming_source:)
# Treat zero cost_basis from provider as unknown
if incoming_source == "provider" && (incoming_cost_basis.nil? || incoming_cost_basis.zero?)
incoming_cost_basis = nil
end
# New holding - use whatever we have
if existing_holding.nil?
return {
cost_basis: incoming_cost_basis,
cost_basis_source: incoming_cost_basis.present? ? incoming_source : nil,
should_update: true
}
end
# Locked - never overwrite
if existing_holding.cost_basis_locked?
return {
cost_basis: existing_holding.cost_basis,
cost_basis_source: existing_holding.cost_basis_source,
should_update: false
}
end
# Check priority - can the incoming source replace the existing?
if existing_holding.cost_basis_replaceable_by?(incoming_source)
if incoming_cost_basis.present?
return {
cost_basis: incoming_cost_basis,
cost_basis_source: incoming_source,
should_update: true
}
end
end
# Keep existing (equal or lower priority, or incoming is nil)
{
cost_basis: existing_holding.cost_basis,
cost_basis_source: existing_holding.cost_basis_source,
should_update: false
}
end
end

View File

@@ -27,14 +27,75 @@ class Holding::Materializer
end
def persist_holdings
return if @holdings.empty?
current_time = Time.now
account.holdings.upsert_all(
@holdings.map { |h| h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id", "cost_basis")
.merge("account_id" => account.id, "updated_at" => current_time) },
unique_by: %i[account_id security_id date currency]
)
# Load existing holdings to check locked status and source priority
existing_holdings_map = load_existing_holdings_map
# Separate holdings into categories based on cost_basis reconciliation
holdings_to_upsert_with_cost = []
holdings_to_upsert_without_cost = []
@holdings.each do |holding|
key = holding_key(holding)
existing = existing_holdings_map[key]
reconciled = Holding::CostBasisReconciler.reconcile(
existing_holding: existing,
incoming_cost_basis: holding.cost_basis,
incoming_source: "calculated"
)
base_attrs = holding.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.merge("account_id" => account.id, "updated_at" => current_time)
if existing&.cost_basis_locked?
# For locked holdings, preserve ALL cost_basis fields
holdings_to_upsert_without_cost << base_attrs
elsif reconciled[:should_update] && reconciled[:cost_basis].present?
# Update with new cost_basis and source
holdings_to_upsert_with_cost << base_attrs.merge(
"cost_basis" => reconciled[:cost_basis],
"cost_basis_source" => reconciled[:cost_basis_source]
)
else
# No cost_basis to set, or existing is better - don't touch cost_basis fields
holdings_to_upsert_without_cost << base_attrs
end
end
# Upsert with cost_basis updates
if holdings_to_upsert_with_cost.any?
account.holdings.upsert_all(
holdings_to_upsert_with_cost,
unique_by: %i[account_id security_id date currency]
)
end
# Upsert without cost_basis (preserves existing)
if holdings_to_upsert_without_cost.any?
account.holdings.upsert_all(
holdings_to_upsert_without_cost,
unique_by: %i[account_id security_id date currency]
)
end
end
def load_existing_holdings_map
# Load holdings that might affect reconciliation:
# - Locked holdings (must preserve their cost_basis)
# - Holdings with a source (need to check priority)
account.holdings
.where(cost_basis_locked: true)
.or(account.holdings.where.not(cost_basis_source: nil))
.index_by { |h| holding_key(h) }
end
def holding_key(holding)
[ holding.account_id || account.id, holding.security_id, holding.date, holding.currency ]
end
def purge_stale_holdings

View File

@@ -22,6 +22,7 @@ class Import < ApplicationRecord
belongs_to :account, optional: true
before_validation :set_default_number_format
before_validation :ensure_utf8_encoding
scope :ordered, -> { order(created_at: :desc) }
@@ -302,6 +303,68 @@ class Import < ApplicationRecord
self.number_format ||= "1,234.56" # Default to US/UK format
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
def ensure_utf8_encoding
# Handle nil or empty string first (before checking if changed)
return if raw_file_str.nil? || raw_file_str.bytesize == 0
# Only process if the attribute was changed
# Use will_save_change_to_attribute? which is safer for binary data
return unless will_save_change_to_raw_file_str?
# If already valid UTF-8, nothing to do
begin
if raw_file_str.encoding == Encoding::UTF_8 && raw_file_str.valid_encoding?
return
end
rescue ArgumentError
# raw_file_str might have invalid encoding, continue to detection
end
# Detect encoding using rchardet
begin
require "rchardet"
detection = CharDet.detect(raw_file_str)
detected_encoding = detection["encoding"]
confidence = detection["confidence"]
# Only convert if we have reasonable confidence in the detection
if detected_encoding && confidence > 0.75
# Force encoding and convert to UTF-8
self.raw_file_str = raw_file_str.force_encoding(detected_encoding).encode("UTF-8", invalid: :replace, undef: :replace)
else
# Fallback: try common encodings
try_common_encodings
end
rescue LoadError
# rchardet not available, fallback to trying common encodings
try_common_encodings
rescue ArgumentError, Encoding::CompatibilityError => e
# Handle encoding errors by falling back to common encodings
try_common_encodings
end
end
def try_common_encodings
COMMON_ENCODINGS.each do |encoding|
begin
test = raw_file_str.dup.force_encoding(encoding)
if test.valid_encoding?
self.raw_file_str = test.encode("UTF-8", invalid: :replace, undef: :replace)
return
end
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
next
end
end
# If nothing worked, force UTF-8 and replace invalid bytes
self.raw_file_str = raw_file_str.force_encoding("UTF-8").scrub("?")
end
def account_belongs_to_family
return if account.nil?
return if account.family_id == family_id

View File

@@ -12,7 +12,9 @@ class IncomeStatement
end
def totals(transactions_scope: nil, date_range:)
transactions_scope ||= family.transactions.visible
# Default to excluding pending transactions from budget/analytics calculations
# Pending transactions shouldn't affect budget totals until they post
transactions_scope ||= family.transactions.visible.excluding_pending
result = totals_query(transactions_scope: transactions_scope, date_range: date_range)
@@ -64,7 +66,8 @@ class IncomeStatement
end
def build_period_total(classification:, period:)
totals = totals_query(transactions_scope: family.transactions.visible.in_period(period), date_range: period.date_range).select { |t| t.classification == classification }
# Exclude pending transactions from budget calculations
totals = totals_query(transactions_scope: family.transactions.visible.excluding_pending.in_period(period), date_range: period.date_range).select { |t| t.classification == classification }
classification_total = totals.sum(&:total)
uncategorized_category = family.categories.uncategorized
@@ -127,7 +130,7 @@ class IncomeStatement
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
Rails.cache.fetch([
"income_statement", "totals_query", family.id, sql_hash, family.entries_cache_version
"income_statement", "totals_query", "v2", family.id, sql_hash, family.entries_cache_version
]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range).call }
end

View File

@@ -47,8 +47,10 @@ class IncomeStatement::CategoryStats
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
)
SELECT

View File

@@ -44,8 +44,10 @@ class IncomeStatement::FamilyStats
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
)
SELECT

View File

@@ -69,9 +69,9 @@ class IncomeStatement::Totals
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND a.family_id = :family_id
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
SQL
@@ -95,9 +95,9 @@ class IncomeStatement::Totals
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND a.family_id = :family_id
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
SQL
@@ -126,7 +126,7 @@ class IncomeStatement::Totals
WHERE a.family_id = :family_id
AND a.status IN ('draft', 'active')
AND ae.excluded = false
AND ae.date BETWEEN :start_date AND :end_date
AND ae.date BETWEEN :start_date AND :end_date
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END, CASE WHEN t.category_id IS NULL THEN true ELSE false END
SQL
end

View File

@@ -133,8 +133,12 @@ class InvestmentStatement
holdings = current_holdings.to_a
return nil if holdings.empty?
current = holdings.sum(&:amount)
previous = holdings.sum { |h| h.qty * h.avg_cost.amount }
# Only include holdings with known cost basis in the calculation
holdings_with_cost_basis = holdings.select(&:avg_cost)
return nil if holdings_with_cost_basis.empty?
current = holdings_with_cost_basis.sum(&:amount)
previous = holdings_with_cost_basis.sum { |h| h.qty * h.avg_cost.amount }
Trend.new(current: current, previous: previous)
end

View File

@@ -10,12 +10,79 @@ class OidcIdentity < ApplicationRecord
update!(last_authenticated_at: Time.current)
end
# Sync user attributes from IdP on each login
# Updates stored identity info and syncs name to user (not email - that's identity)
def sync_user_attributes!(auth)
# Extract groups from claims (various common claim names)
groups = extract_groups(auth)
# Update stored identity info with latest from IdP
update!(info: {
email: auth.info&.email,
name: auth.info&.name,
first_name: auth.info&.first_name,
last_name: auth.info&.last_name,
groups: groups
})
# Sync name to user if provided (keep existing if IdP doesn't provide)
user.update!(
first_name: auth.info&.first_name.presence || user.first_name,
last_name: auth.info&.last_name.presence || user.last_name
)
# Apply role mapping based on group membership
apply_role_mapping!(groups)
end
# Extract groups from various common IdP claim formats
def extract_groups(auth)
# Try various common group claim locations
groups = auth.extra&.raw_info&.groups ||
auth.extra&.raw_info&.[]("groups") ||
auth.extra&.raw_info&.[]("Group") ||
auth.info&.groups ||
auth.extra&.raw_info&.[]("http://schemas.microsoft.com/ws/2008/06/identity/claims/groups") ||
auth.extra&.raw_info&.[]("cognito:groups") ||
[]
# Normalize to array of strings
Array(groups).map(&:to_s)
end
# Apply role mapping based on IdP group membership
def apply_role_mapping!(groups)
config = provider_config
return unless config.present?
role_mapping = config.dig(:settings, :role_mapping) || config.dig(:settings, "role_mapping")
return unless role_mapping.present?
# Check roles in order of precedence (highest to lowest)
%w[super_admin admin member].each do |role|
mapped_groups = role_mapping[role] || role_mapping[role.to_sym] || []
mapped_groups = Array(mapped_groups)
# Check if user is in any of the mapped groups
if mapped_groups.include?("*") || (mapped_groups & groups).any?
# Only update if different to avoid unnecessary writes
user.update!(role: role) unless user.role == role
Rails.logger.info("[SSO] Applied role mapping: user_id=#{user.id} role=#{role} groups=#{groups}")
return
end
end
end
# Extract and store relevant info from OmniAuth auth hash
def self.create_from_omniauth(auth, user)
# Extract issuer from OIDC auth response if available
issuer = auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss")
create!(
user: user,
provider: auth.provider,
uid: auth.uid,
issuer: issuer,
info: {
email: auth.info&.email,
name: auth.info&.name,
@@ -25,4 +92,20 @@ class OidcIdentity < ApplicationRecord
last_authenticated_at: Time.current
)
end
# Find the configured provider for this identity
def provider_config
Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == provider || p[:id] == provider }
end
# Validate that the stored issuer matches the configured provider's issuer
# Returns true if valid, false if mismatch (security concern)
def issuer_matches_config?
return true if issuer.blank? # Backward compatibility for old records
config = provider_config
return true if config.blank? || config[:issuer].blank? # No config to validate against
issuer == config[:issuer]
end
end

View File

@@ -1,6 +1,24 @@
class PlaidAccount::Investments::TransactionsProcessor
SecurityNotFoundError = Class.new(StandardError)
# Map Plaid investment transaction types to activity labels
# All values must be valid Transaction::ACTIVITY_LABELS
PLAID_TYPE_TO_LABEL = {
"buy" => "Buy",
"sell" => "Sell",
"cancel" => "Other",
"cash" => "Other",
"fee" => "Fee",
"transfer" => "Transfer",
"dividend" => "Dividend",
"interest" => "Interest",
"contribution" => "Contribution",
"withdrawal" => "Withdrawal",
"dividend reinvestment" => "Reinvestment",
"spin off" => "Other",
"split" => "Other"
}.freeze
def initialize(plaid_account, security_resolver:)
@plaid_account = plaid_account
@security_resolver = security_resolver
@@ -68,10 +86,16 @@ class PlaidAccount::Investments::TransactionsProcessor
currency: transaction["iso_currency_code"],
date: transaction["date"],
name: transaction["name"],
source: "plaid"
source: "plaid",
investment_activity_label: label_from_plaid_type(transaction)
)
end
def label_from_plaid_type(transaction)
plaid_type = transaction["type"]&.downcase
PLAID_TYPE_TO_LABEL[plaid_type] || "Other"
end
def transactions
plaid_account.raw_investments_payload["transactions"] || []
end

View File

@@ -51,7 +51,20 @@ class PlaidAccount::Transactions::Processor
modified = plaid_account.raw_transactions_payload["modified"] || []
added = plaid_account.raw_transactions_payload["added"] || []
modified + added
transactions = modified + added
# Filter out pending transactions based on env var or Setting
# Priority: env var > Setting (allows runtime changes via UI)
include_pending = if ENV["PLAID_INCLUDE_PENDING"].present?
Rails.configuration.x.plaid.include_pending
else
Setting.syncs_include_pending
end
unless include_pending
transactions = transactions.reject { |t| t["pending"] == true }
end
transactions
end
def removed_transactions

View File

@@ -16,9 +16,11 @@ class PlaidEntry::Processor
source: "plaid",
category_id: matched_category&.id,
merchant: merchant,
pending_transaction_id: pending_transaction_id, # Plaid's linking ID for pending→posted
extra: {
plaid: {
pending: plaid_transaction["pending"]
pending: plaid_transaction["pending"],
pending_transaction_id: pending_transaction_id # Also store for reference
}
}
)
@@ -55,6 +57,12 @@ class PlaidEntry::Processor
plaid_transaction["date"]
end
# Plaid provides this linking ID when a posted transaction matches a pending one
# This is the most reliable way to reconcile pending→posted
def pending_transaction_id
plaid_transaction["pending_transaction_id"]
end
def detailed_category
plaid_transaction.dig("personal_finance_category", "detailed")
end

View File

@@ -25,6 +25,9 @@ class Provider::Coinstats < Provider
res = self.class.get("#{BASE_URL}/wallet/blockchains", headers: auth_headers)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: GET /wallet/blockchains failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
# Returns blockchain options formatted for select dropdowns
@@ -75,6 +78,9 @@ class Provider::Coinstats < Provider
)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: GET /wallet/balances failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
# Extract balance data for a specific wallet from bulk response
@@ -114,6 +120,9 @@ class Provider::Coinstats < Provider
)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: GET /wallet/transactions failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
# Extract transaction data for a specific wallet from bulk response
@@ -162,23 +171,36 @@ class Provider::Coinstats < Provider
when 200
JSON.parse(response.body, symbolize_names: true)
when 400
raise Error, "CoinStats: #{response.code} Bad Request - Invalid parameters or request format #{response.body}"
log_api_error(response, "Bad Request")
raise Error, "CoinStats: Invalid request parameters"
when 401
raise Error, "CoinStats: #{response.code} Unauthorized - Invalid or missing API key #{response.body}"
log_api_error(response, "Unauthorized")
raise Error, "CoinStats: Invalid or missing API key"
when 403
raise Error, "CoinStats: #{response.code} Forbidden - #{response.body}"
log_api_error(response, "Forbidden")
raise Error, "CoinStats: Access denied"
when 404
raise Error, "CoinStats: #{response.code} Not Found - Resource not found #{response.body}"
log_api_error(response, "Not Found")
raise Error, "CoinStats: Resource not found"
when 409
raise Error, "CoinStats: #{response.code} Conflict - Resource conflict #{response.body}"
log_api_error(response, "Conflict")
raise Error, "CoinStats: Resource conflict"
when 429
raise Error, "CoinStats: #{response.code} Too Many Requests - Rate limit exceeded #{response.body}"
log_api_error(response, "Too Many Requests")
raise Error, "CoinStats: Rate limit exceeded, try again later"
when 500
raise Error, "CoinStats: #{response.code} Internal Server Error - Server error #{response.body}"
log_api_error(response, "Internal Server Error")
raise Error, "CoinStats: Server error, try again later"
when 503
raise Error, "CoinStats: #{response.code} Service Unavailable - #{response.body}"
log_api_error(response, "Service Unavailable")
raise Error, "CoinStats: Service temporarily unavailable"
else
raise Error, "CoinStats: #{response.code} Unexpected Error - #{response.body}"
log_api_error(response, "Unexpected Error")
raise Error, "CoinStats: An unexpected error occurred"
end
end
def log_api_error(response, error_type)
Rails.logger.error "CoinStats API: #{response.code} #{error_type} - #{response.body}"
end
end

View File

@@ -534,7 +534,8 @@ class Provider::Openai::AutoCategorizer
# Format transactions in a simpler, more readable way for smaller LLMs
def format_transactions_simply
transactions.map do |t|
"- ID: #{t[:id]}, Amount: #{t[:amount]}, Type: #{t[:classification]}, Description: \"#{t[:description]}\""
description = t[:description].presence || t[:merchant].presence || ""
"- ID: #{t[:id]}, Amount: #{t[:amount]}, Type: #{t[:classification]}, Description: \"#{description}\""
end.join("\n")
end
end

View File

@@ -491,7 +491,9 @@ class Provider::Openai::AutoMerchantDetector
# Format transactions in a simpler, more readable way for smaller LLMs
def format_transactions_simply
transactions.map do |t|
"- ID: #{t[:id]}, Description: \"#{t[:name] || t[:description]}\""
parts = [ t[:merchant], t[:description] ].compact.reject(&:blank?)
combined = parts.join(" - ")
"- ID: #{t[:id]}, Description: \"#{combined}\""
end.join("\n")
end
end

View File

@@ -0,0 +1,31 @@
class Rule::ActionExecutor::SetInvestmentActivityLabel < Rule::ActionExecutor
def label
"Set investment activity label"
end
def type
"select"
end
def options
Transaction::ACTIVITY_LABELS.map { |l| [ l, l ] }
end
def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil)
return 0 unless Transaction::ACTIVITY_LABELS.include?(value)
scope = transaction_scope
unless ignore_attribute_locks
scope = scope.enrichable(:investment_activity_label)
end
count_modified_resources(scope) do |txn|
txn.enrich_attribute(
:investment_activity_label,
value,
source: "rule"
)
end
end
end

View File

@@ -20,6 +20,7 @@ class Rule::Registry::TransactionResource < Rule::Registry
Rule::ActionExecutor::SetTransactionTags.new(rule),
Rule::ActionExecutor::SetTransactionMerchant.new(rule),
Rule::ActionExecutor::SetTransactionName.new(rule),
Rule::ActionExecutor::SetInvestmentActivityLabel.new(rule),
Rule::ActionExecutor::ExcludeTransaction.new(rule)
]

View File

@@ -16,6 +16,30 @@ class Setting < RailsSettings::Base
field :exchange_rate_provider, type: :string, default: ENV.fetch("EXCHANGE_RATE_PROVIDER", "twelve_data")
field :securities_provider, type: :string, default: ENV.fetch("SECURITIES_PROVIDER", "twelve_data")
# Sync settings - check both provider env vars for default
# Only defaults to true if neither provider explicitly disables pending
SYNCS_INCLUDE_PENDING_DEFAULT = begin
simplefin = ENV.fetch("SIMPLEFIN_INCLUDE_PENDING", "1") == "1"
plaid = ENV.fetch("PLAID_INCLUDE_PENDING", "1") == "1"
simplefin && plaid
end
field :syncs_include_pending, type: :boolean, default: SYNCS_INCLUDE_PENDING_DEFAULT
field :auto_sync_enabled, type: :boolean, default: ENV.fetch("AUTO_SYNC_ENABLED", "1") == "1"
field :auto_sync_time, type: :string, default: ENV.fetch("AUTO_SYNC_TIME", "02:22")
field :auto_sync_timezone, type: :string, default: ENV.fetch("AUTO_SYNC_TIMEZONE", "UTC")
AUTO_SYNC_TIME_FORMAT = /\A([01]?\d|2[0-3]):([0-5]\d)\z/
def self.valid_auto_sync_time?(time_str)
return false if time_str.blank?
AUTO_SYNC_TIME_FORMAT.match?(time_str.to_s.strip)
end
def self.valid_auto_sync_timezone?(timezone_str)
return false if timezone_str.blank?
ActiveSupport::TimeZone[timezone_str].present?
end
# Dynamic fields are now stored as individual entries with "dynamic:" prefix
# This prevents race conditions and ensures each field is independently managed

View File

@@ -46,7 +46,9 @@ class SimplefinAccount::Processor
avail = to_decimal(simplefin_account.available_balance)
# Choose an observed value prioritizing posted balance first
observed = bal.nonzero? ? bal : avail
# Use available_balance only when current_balance is truly missing (nil),
# not when it's explicitly zero (e.g., dormant credit card with no debt)
observed = simplefin_account.current_balance.nil? ? avail : bal
# Determine if this should be treated as a liability for normalization
is_linked_liability = [ "CreditCard", "Loan" ].include?(account.accountable_type)

View File

@@ -34,9 +34,27 @@ class SimplefinEntry::Processor
# Include provider-supplied extra hash if present
sf["extra"] = data[:extra] if data[:extra].is_a?(Hash)
# Pending detection: only use explicit provider flag
# Pending detection: explicit flag OR inferred from posted=0 + transacted_at
# SimpleFIN indicates pending via:
# 1. pending: true (explicit flag)
# 2. posted=0 (epoch zero) + transacted_at present (implicit - some banks use this pattern)
#
# Note: We only infer from posted=0, NOT from posted=nil/blank, because some providers
# don't supply posted dates even for settled transactions (would cause false positives).
# We always set the key (true or false) to ensure deep_merge overwrites any stale value
if ActiveModel::Type::Boolean.new.cast(data[:pending])
is_pending = if ActiveModel::Type::Boolean.new.cast(data[:pending])
true
else
# Infer pending ONLY when posted is explicitly 0 (epoch) AND transacted_at is present
# posted=nil/blank is NOT treated as pending (some providers omit posted for settled txns)
posted_val = data[:posted]
transacted_val = data[:transacted_at]
posted_is_epoch_zero = posted_val.present? && posted_val.to_i.zero?
transacted_present = transacted_val.present? && transacted_val.to_i > 0
posted_is_epoch_zero && transacted_present
end
if is_pending
sf["pending"] = true
Rails.logger.debug("SimpleFIN: flagged pending transaction #{external_id}")
else

View File

@@ -55,6 +55,20 @@ class SimplefinItem < ApplicationRecord
SimplefinItem::Importer.new(self, simplefin_provider: simplefin_provider, sync: sync).import
end
# Update the access_url by claiming a new setup token.
# This is used when reconnecting an existing SimpleFIN connection.
# Unlike create_simplefin_item!, this updates in-place, preserving all account linkages.
def update_access_url!(setup_token:)
new_access_url = simplefin_provider.claim_access_url(setup_token)
update!(
access_url: new_access_url,
status: :good
)
self
end
def process_accounts
# Process accounts linked via BOTH legacy FK and AccountProvider
# Use direct query to ensure fresh data from DB, bypassing any association cache
@@ -271,8 +285,8 @@ class SimplefinItem < ApplicationRecord
return nil unless latest
# If sync has statistics, use them
if latest.sync_stats.present?
stats = latest.sync_stats
stats = parse_sync_stats(latest.sync_stats)
if stats.present?
total = stats["total_accounts"] || 0
linked = stats["linked_accounts"] || 0
unlinked = stats["unlinked_accounts"] || 0
@@ -399,7 +413,68 @@ class SimplefinItem < ApplicationRecord
issues
end
# Get reconciled duplicates count from the last sync
# Returns { count: N, message: "..." } or { count: 0 } if none
def last_sync_reconciled_status
latest_sync = syncs.ordered.first
return { count: 0 } unless latest_sync
stats = parse_sync_stats(latest_sync.sync_stats)
count = stats&.dig("pending_reconciled").to_i
if count > 0
{
count: count,
message: I18n.t("simplefin_items.reconciled_status.message", count: count)
}
else
{ count: 0 }
end
end
# Count stale pending transactions (>8 days old) across all linked accounts
# Returns { count: N, accounts: [names] } or { count: 0 } if none
def stale_pending_status(days: 8)
# Get all accounts linked to this SimpleFIN item
# Eager-load both association paths to avoid N+1 on current_account method
linked_accounts = simplefin_accounts.includes(:account, :linked_account).filter_map(&:current_account)
return { count: 0 } if linked_accounts.empty?
# Batch query to avoid N+1
account_ids = linked_accounts.map(&:id)
counts_by_account = Entry.stale_pending(days: days)
.where(excluded: false)
.where(account_id: account_ids)
.group(:account_id)
.count
account_counts = linked_accounts
.map { |account| { account: account, count: counts_by_account[account.id].to_i } }
.select { |ac| ac[:count] > 0 }
total = account_counts.sum { |ac| ac[:count] }
if total > 0
{
count: total,
accounts: account_counts.map { |ac| ac[:account].name },
message: I18n.t("simplefin_items.stale_pending_status.message", count: total, days: days)
}
else
{ count: 0 }
end
end
private
# Parse sync_stats, handling cases where it might be a raw JSON string
# (e.g., from console testing or bypassed serialization)
def parse_sync_stats(sync_stats)
return nil if sync_stats.blank?
return sync_stats if sync_stats.is_a?(Hash)
if sync_stats.is_a?(String)
JSON.parse(sync_stats) rescue nil
end
end
def remove_simplefin_item
# SimpleFin doesn't require server-side cleanup like Plaid
# The access URL just becomes inactive

View File

@@ -9,6 +9,7 @@ class SimplefinItem::Importer
@simplefin_provider = simplefin_provider
@sync = sync
@enqueued_holdings_job_ids = Set.new
@reconciled_account_ids = Set.new # Debounce pending reconciliation per run
end
def import
@@ -44,6 +45,11 @@ class SimplefinItem::Importer
Rails.logger.info "SimplefinItem::Importer - Using REGULAR SYNC (last_synced_at=#{simplefin_item.last_synced_at&.strftime('%Y-%m-%d %H:%M')})"
import_regular_sync
end
# Reset status to good if no auth errors occurred in this sync.
# This allows the item to recover automatically when a bank's auth issue is resolved
# in SimpleFIN Bridge, without requiring the user to manually reconnect.
maybe_clear_requires_update_status
rescue RateLimitedError => e
stats["rate_limited"] = true
stats["rate_limited_at"] = Time.current.iso8601
@@ -201,8 +207,8 @@ class SimplefinItem::Importer
end
adapter.update_balance(
balance: normalized,
cash_balance: cash,
balance: account_data[:balance],
cash_balance: account_data[:"available-balance"],
source: "simplefin"
)
end
@@ -320,6 +326,21 @@ class SimplefinItem::Importer
sync.update_columns(sync_stats: merged) # avoid callbacks/validations during tight loops
end
# Reset status to good if no auth errors occurred in this sync.
# This allows automatic recovery when a bank's auth issue is resolved in SimpleFIN Bridge.
def maybe_clear_requires_update_status
return unless simplefin_item.requires_update?
auth_errors = stats.dig("error_buckets", "auth").to_i
if auth_errors.zero?
simplefin_item.update!(status: :good)
Rails.logger.info(
"SimpleFIN: cleared requires_update status for item ##{simplefin_item.id} " \
"(no auth errors in this sync)"
)
end
end
def import_with_chunked_history
# SimpleFin's actual limit is 60 days (not 365 as documented)
# Use 60-day chunks to stay within limits
@@ -428,9 +449,12 @@ class SimplefinItem::Importer
perform_account_discovery
# Step 2: Fetch transactions/holdings using the regular window.
# Note: Don't pass explicit `pending:` here - let fetch_accounts_data use the
# SIMPLEFIN_INCLUDE_PENDING config. This allows users to disable pending transactions
# if their bank's SimpleFIN integration produces duplicates when pending→posted.
start_date = determine_sync_start_date
Rails.logger.info "SimplefinItem::Importer - import_regular_sync: last_synced_at=#{simplefin_item.last_synced_at&.strftime('%Y-%m-%d %H:%M')} => start_date=#{start_date&.strftime('%Y-%m-%d')}"
accounts_data = fetch_accounts_data(start_date: start_date, pending: true)
accounts_data = fetch_accounts_data(start_date: start_date)
return if accounts_data.nil? # Error already handled
# Store raw payload
@@ -554,9 +578,15 @@ class SimplefinItem::Importer
# Returns a Hash payload with keys like :accounts, or nil when an error is
# handled internally via `handle_errors`.
def fetch_accounts_data(start_date:, end_date: nil, pending: nil)
# Determine whether to include pending based on explicit arg or global config.
# `Rails.configuration.x.simplefin.include_pending` is ENV-backed.
effective_pending = pending.nil? ? Rails.configuration.x.simplefin.include_pending : pending
# Determine whether to include pending based on explicit arg, env var, or Setting.
# Priority: explicit arg > env var > Setting (allows runtime changes via UI)
effective_pending = if !pending.nil?
pending
elsif ENV["SIMPLEFIN_INCLUDE_PENDING"].present?
Rails.configuration.x.simplefin.include_pending
else
Setting.syncs_include_pending
end
# Debug logging to track exactly what's being sent to SimpleFin API
start_str = start_date.respond_to?(:strftime) ? start_date.strftime("%Y-%m-%d") : "none"
@@ -806,6 +836,15 @@ class SimplefinItem::Importer
# Post-save side effects
acct = simplefin_account.current_account
if acct
# Handle pending transaction reconciliation (debounced per run to avoid
# repeated scans during chunked history imports)
unless @reconciled_account_ids.include?(acct.id)
@reconciled_account_ids << acct.id
reconcile_and_track_pending_duplicates(acct)
exclude_and_track_stale_pending(acct)
track_stale_unmatched_pending(acct)
end
# Refresh credit attributes when available-balance present
if acct.accountable_type == "CreditCard" && account_data[:"available-balance"].present?
begin
@@ -1146,19 +1185,103 @@ class SimplefinItem::Importer
ids.group_by(&:itself).select { |_, v| v.size > 1 }.keys
end
# --- Simple helpers for numeric handling in normalization ---
def to_decimal(value)
return BigDecimal("0") if value.nil?
case value
when BigDecimal then value
when String then BigDecimal(value) rescue BigDecimal("0")
when Numeric then BigDecimal(value.to_s)
else
BigDecimal("0")
# Reconcile pending transactions that have a matching posted version
# Handles duplicates where pending and posted both exist (tip adjustments, etc.)
def reconcile_and_track_pending_duplicates(account)
reconcile_stats = Entry.reconcile_pending_duplicates(account: account, dry_run: false)
exact_matches = reconcile_stats[:details].select { |d| d[:match_type] == "exact" }
fuzzy_suggestions = reconcile_stats[:details].select { |d| d[:match_type] == "fuzzy_suggestion" }
if exact_matches.any?
stats["pending_reconciled"] = stats.fetch("pending_reconciled", 0) + exact_matches.size
stats["pending_reconciled_details"] ||= []
exact_matches.each do |detail|
stats["pending_reconciled_details"] << {
"account_name" => detail[:account],
"pending_name" => detail[:pending_name],
"posted_name" => detail[:posted_name]
}
end
stats["pending_reconciled_details"] = stats["pending_reconciled_details"].last(50)
end
if fuzzy_suggestions.any?
stats["duplicate_suggestions_created"] = stats.fetch("duplicate_suggestions_created", 0) + fuzzy_suggestions.size
stats["duplicate_suggestions_details"] ||= []
fuzzy_suggestions.each do |detail|
stats["duplicate_suggestions_details"] << {
"account_name" => detail[:account],
"pending_name" => detail[:pending_name],
"posted_name" => detail[:posted_name]
}
end
stats["duplicate_suggestions_details"] = stats["duplicate_suggestions_details"].last(50)
end
rescue => e
Rails.logger.warn("SimpleFin: pending reconciliation failed for account #{account.id}: #{e.class} - #{e.message}")
record_reconciliation_error("pending_reconciliation", account, e)
end
def same_sign?(a, b)
(a.positive? && b.positive?) || (a.negative? && b.negative?)
# Auto-exclude stale pending transactions (>8 days old with no matching posted version)
# Prevents orphaned pending transactions from affecting budgets indefinitely
def exclude_and_track_stale_pending(account)
excluded_count = Entry.auto_exclude_stale_pending(account: account)
return unless excluded_count > 0
stats["stale_pending_excluded"] = stats.fetch("stale_pending_excluded", 0) + excluded_count
stats["stale_pending_details"] ||= []
stats["stale_pending_details"] << {
"account_name" => account.name,
"account_id" => account.id,
"count" => excluded_count
}
stats["stale_pending_details"] = stats["stale_pending_details"].last(50)
rescue => e
Rails.logger.warn("SimpleFin: stale pending cleanup failed for account #{account.id}: #{e.class} - #{e.message}")
record_reconciliation_error("stale_pending_cleanup", account, e)
end
# Track stale pending transactions that couldn't be matched (for user awareness)
# These are >8 days old, still pending, and have no duplicate suggestion
def track_stale_unmatched_pending(account)
stale_unmatched = account.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(excluded: false)
.where("entries.date < ?", 8.days.ago.to_date)
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
SQL
.where(<<~SQL.squish)
transactions.extra -> 'potential_posted_match' IS NULL
SQL
.count
return unless stale_unmatched > 0
stats["stale_unmatched_pending"] = stats.fetch("stale_unmatched_pending", 0) + stale_unmatched
stats["stale_unmatched_details"] ||= []
stats["stale_unmatched_details"] << {
"account_name" => account.name,
"account_id" => account.id,
"count" => stale_unmatched
}
stats["stale_unmatched_details"] = stats["stale_unmatched_details"].last(50)
rescue => e
Rails.logger.warn("SimpleFin: stale unmatched tracking failed for account #{account.id}: #{e.class} - #{e.message}")
record_reconciliation_error("stale_unmatched_tracking", account, e)
end
# Record reconciliation errors to sync_stats for UI visibility
def record_reconciliation_error(context, account, error)
stats["reconciliation_errors"] ||= []
stats["reconciliation_errors"] << {
"context" => context,
"account_id" => account.id,
"account_name" => account.name,
"error" => "#{error.class}: #{error.message}"
}
stats["reconciliation_errors"] = stats["reconciliation_errors"].last(20)
end
end

108
app/models/sso_audit_log.rb Normal file
View File

@@ -0,0 +1,108 @@
# frozen_string_literal: true
class SsoAuditLog < ApplicationRecord
belongs_to :user, optional: true
# Event types for SSO audit logging
EVENT_TYPES = %w[
login
login_failed
logout
logout_idp
link
unlink
jit_account_created
].freeze
validates :event_type, presence: true, inclusion: { in: EVENT_TYPES }
scope :recent, -> { order(created_at: :desc) }
scope :for_user, ->(user) { where(user: user) }
scope :by_event, ->(event) { where(event_type: event) }
class << self
# Log a successful SSO login
def log_login!(user:, provider:, request:, metadata: {})
create!(
user: user,
event_type: "login",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
# Log a failed SSO login attempt
def log_login_failed!(provider:, request:, reason:, metadata: {})
create!(
user: nil,
event_type: "login_failed",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata.merge(reason: reason)
)
end
# Log a logout (local only)
def log_logout!(user:, request:, metadata: {})
create!(
user: user,
event_type: "logout",
provider: nil,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
# Log a federated logout (to IdP)
def log_logout_idp!(user:, provider:, request:, metadata: {})
create!(
user: user,
event_type: "logout_idp",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
# Log an account link (existing user links SSO identity)
def log_link!(user:, provider:, request:, metadata: {})
create!(
user: user,
event_type: "link",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
# Log an account unlink (user disconnects SSO identity)
def log_unlink!(user:, provider:, request:, metadata: {})
create!(
user: user,
event_type: "unlink",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
# Log JIT account creation via SSO
def log_jit_account_created!(user:, provider:, request:, metadata: {})
create!(
user: user,
event_type: "jit_account_created",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
end
end

144
app/models/sso_provider.rb Normal file
View File

@@ -0,0 +1,144 @@
# frozen_string_literal: true
class SsoProvider < ApplicationRecord
# Encrypt sensitive credentials using Rails 7.2 built-in encryption
encrypts :client_secret, deterministic: false
# Default enabled to true for new providers
attribute :enabled, :boolean, default: true
# Validations
validates :strategy, presence: true, inclusion: {
in: %w[openid_connect google_oauth2 github saml],
message: "%{value} is not a supported strategy"
}
validates :name, presence: true, uniqueness: true, format: {
with: /\A[a-z0-9_]+\z/,
message: "must contain only lowercase letters, numbers, and underscores"
}
validates :label, presence: true
validates :enabled, inclusion: { in: [ true, false ] }
# Strategy-specific validations
validate :validate_oidc_fields, if: -> { strategy == "openid_connect" }
validate :validate_oauth_fields, if: -> { strategy.in?(%w[google_oauth2 github]) }
validate :validate_saml_fields, if: -> { strategy == "saml" }
validate :validate_default_role_setting
# Note: OIDC discovery validation is done client-side via Stimulus
# Server-side validation can fail due to network issues, so we skip it
# validate :validate_oidc_discovery, if: -> { strategy == "openid_connect" && issuer.present? && will_save_change_to_issuer? }
# Scopes
scope :enabled, -> { where(enabled: true) }
scope :by_strategy, ->(strategy) { where(strategy: strategy) }
# Convert to hash format compatible with OmniAuth initializer
def to_omniauth_config
{
id: name,
strategy: strategy,
name: name,
label: label,
icon: icon,
issuer: issuer,
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirect_uri,
settings: settings || {}
}.compact
end
private
def validate_oidc_fields
if issuer.blank?
errors.add(:issuer, "is required for OpenID Connect providers")
elsif issuer.present? && !valid_url?(issuer)
errors.add(:issuer, "must be a valid URL")
end
errors.add(:client_id, "is required for OpenID Connect providers") if client_id.blank?
errors.add(:client_secret, "is required for OpenID Connect providers") if client_secret.blank?
if redirect_uri.present? && !valid_url?(redirect_uri)
errors.add(:redirect_uri, "must be a valid URL")
end
end
def validate_oauth_fields
errors.add(:client_id, "is required for OAuth providers") if client_id.blank?
errors.add(:client_secret, "is required for OAuth providers") if client_secret.blank?
end
def validate_saml_fields
# SAML requires either a metadata URL or manual configuration
idp_metadata_url = settings&.dig("idp_metadata_url")
idp_sso_url = settings&.dig("idp_sso_url")
if idp_metadata_url.blank? && idp_sso_url.blank?
errors.add(:settings, "Either IdP Metadata URL or IdP SSO URL is required for SAML providers")
end
# If using manual config, require certificate
if idp_metadata_url.blank? && idp_sso_url.present?
idp_cert = settings&.dig("idp_certificate")
idp_fingerprint = settings&.dig("idp_cert_fingerprint")
if idp_cert.blank? && idp_fingerprint.blank?
errors.add(:settings, "Either IdP Certificate or Certificate Fingerprint is required when not using metadata URL")
end
end
# Validate URL formats if provided
if idp_metadata_url.present? && !valid_url?(idp_metadata_url)
errors.add(:settings, "IdP Metadata URL must be a valid URL")
end
if idp_sso_url.present? && !valid_url?(idp_sso_url)
errors.add(:settings, "IdP SSO URL must be a valid URL")
end
end
def validate_default_role_setting
default_role = settings&.dig("default_role")
return if default_role.blank?
unless User.roles.key?(default_role)
errors.add(:settings, "default_role must be member, admin, or super_admin")
end
end
def validate_oidc_discovery
return unless issuer.present?
begin
discovery_url = issuer.end_with?("/") ? "#{issuer}.well-known/openid-configuration" : "#{issuer}/.well-known/openid-configuration"
response = Faraday.get(discovery_url) do |req|
req.options.timeout = 5
req.options.open_timeout = 3
end
unless response.success?
errors.add(:issuer, "discovery endpoint returned #{response.status}")
return
end
discovery_data = JSON.parse(response.body)
unless discovery_data["issuer"].present?
errors.add(:issuer, "discovery endpoint did not return valid issuer")
end
rescue Faraday::Error => e
errors.add(:issuer, "could not connect to discovery endpoint: #{e.message}")
rescue JSON::ParserError
errors.add(:issuer, "discovery endpoint returned invalid JSON")
rescue StandardError => e
errors.add(:issuer, "discovery validation failed: #{e.message}")
end
end
def valid_url?(url)
uri = URI.parse(url)
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
rescue URI::InvalidURIError
false
end
end

View File

@@ -0,0 +1,201 @@
# frozen_string_literal: true
# Tests SSO provider configuration by validating discovery endpoints
class SsoProviderTester
attr_reader :provider, :result
Result = Struct.new(:success?, :message, :details, keyword_init: true)
def initialize(provider)
@provider = provider
@result = nil
end
def test!
@result = case provider.strategy
when "openid_connect"
test_oidc_discovery
when "google_oauth2"
test_google_oauth
when "github"
test_github_oauth
when "saml"
test_saml_metadata
else
Result.new(success?: false, message: "Unknown strategy: #{provider.strategy}", details: {})
end
end
private
def test_oidc_discovery
return Result.new(success?: false, message: "Issuer URL is required", details: {}) if provider.issuer.blank?
discovery_url = build_discovery_url(provider.issuer)
begin
response = Faraday.get(discovery_url) do |req|
req.options.timeout = 10
req.options.open_timeout = 5
end
unless response.success?
return Result.new(
success?: false,
message: "Discovery endpoint returned HTTP #{response.status}",
details: { url: discovery_url, status: response.status }
)
end
discovery = JSON.parse(response.body)
# Validate required OIDC fields
required_fields = %w[issuer authorization_endpoint token_endpoint]
missing = required_fields.select { |f| discovery[f].blank? }
if missing.any?
return Result.new(
success?: false,
message: "Discovery document missing required fields: #{missing.join(", ")}",
details: { url: discovery_url, missing_fields: missing }
)
end
# Check if issuer matches
if discovery["issuer"] != provider.issuer && discovery["issuer"] != provider.issuer.chomp("/")
return Result.new(
success?: false,
message: "Issuer mismatch: expected #{provider.issuer}, got #{discovery["issuer"]}",
details: { expected: provider.issuer, actual: discovery["issuer"] }
)
end
Result.new(
success?: true,
message: "OIDC discovery validated successfully",
details: {
issuer: discovery["issuer"],
authorization_endpoint: discovery["authorization_endpoint"],
token_endpoint: discovery["token_endpoint"],
end_session_endpoint: discovery["end_session_endpoint"],
scopes_supported: discovery["scopes_supported"]
}
)
rescue Faraday::TimeoutError
Result.new(success?: false, message: "Connection timed out", details: { url: discovery_url })
rescue Faraday::ConnectionFailed => e
Result.new(success?: false, message: "Connection failed: #{e.message}", details: { url: discovery_url })
rescue JSON::ParserError
Result.new(success?: false, message: "Invalid JSON response from discovery endpoint", details: { url: discovery_url })
rescue StandardError => e
Result.new(success?: false, message: "Error: #{e.message}", details: { url: discovery_url })
end
end
def test_google_oauth
# Google OAuth doesn't require discovery validation - just check credentials present
if provider.client_id.blank?
return Result.new(success?: false, message: "Client ID is required", details: {})
end
if provider.client_secret.blank?
return Result.new(success?: false, message: "Client Secret is required", details: {})
end
Result.new(
success?: true,
message: "Google OAuth2 configuration looks valid",
details: {
note: "Full validation occurs during actual authentication"
}
)
end
def test_github_oauth
# GitHub OAuth doesn't require discovery validation - just check credentials present
if provider.client_id.blank?
return Result.new(success?: false, message: "Client ID is required", details: {})
end
if provider.client_secret.blank?
return Result.new(success?: false, message: "Client Secret is required", details: {})
end
Result.new(
success?: true,
message: "GitHub OAuth configuration looks valid",
details: {
note: "Full validation occurs during actual authentication"
}
)
end
def test_saml_metadata
# SAML testing - check for IdP metadata or SSO URL
if provider.settings&.dig("idp_metadata_url").blank? &&
provider.settings&.dig("idp_sso_url").blank?
return Result.new(
success?: false,
message: "Either IdP Metadata URL or IdP SSO URL is required",
details: {}
)
end
# If metadata URL is provided, try to fetch it
metadata_url = provider.settings&.dig("idp_metadata_url")
if metadata_url.present?
begin
response = Faraday.get(metadata_url) do |req|
req.options.timeout = 10
req.options.open_timeout = 5
end
unless response.success?
return Result.new(
success?: false,
message: "Metadata endpoint returned HTTP #{response.status}",
details: { url: metadata_url, status: response.status }
)
end
# Basic XML validation
unless response.body.include?("<") && response.body.include?("EntityDescriptor")
return Result.new(
success?: false,
message: "Response does not appear to be valid SAML metadata",
details: { url: metadata_url }
)
end
return Result.new(
success?: true,
message: "SAML metadata fetched successfully",
details: { url: metadata_url }
)
rescue Faraday::TimeoutError
return Result.new(success?: false, message: "Connection timed out", details: { url: metadata_url })
rescue Faraday::ConnectionFailed => e
return Result.new(success?: false, message: "Connection failed: #{e.message}", details: { url: metadata_url })
rescue StandardError => e
return Result.new(success?: false, message: "Error: #{e.message}", details: { url: metadata_url })
end
end
Result.new(
success?: true,
message: "SAML configuration looks valid",
details: {
note: "Full validation occurs during actual authentication"
}
)
end
def build_discovery_url(issuer)
if issuer.end_with?("/")
"#{issuer}.well-known/openid-configuration"
else
"#{issuer}/.well-known/openid-configuration"
end
end
end

View File

@@ -16,7 +16,30 @@ class Transaction < ApplicationRecord
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
one_time: "one_time" # A one-time expense/income, excluded from budget analytics
one_time: "one_time", # A one-time expense/income, excluded from budget analytics
investment_contribution: "investment_contribution" # Transfer to investment/crypto account, excluded from budget analytics
}
# All valid investment activity labels (for UI dropdown)
ACTIVITY_LABELS = [
"Buy", "Sell", "Sweep In", "Sweep Out", "Dividend", "Reinvestment",
"Interest", "Fee", "Transfer", "Contribution", "Withdrawal", "Exchange", "Other"
].freeze
# Pending transaction scopes - filter based on provider pending flags in extra JSONB
# Works with any provider that stores pending status in extra["provider_name"]["pending"]
scope :pending, -> {
where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
SQL
}
scope :excluding_pending, -> {
where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
SQL
}
# Overarching grouping method for all transfer-type transactions
@@ -42,7 +65,85 @@ class Transaction < ApplicationRecord
false
end
# Potential duplicate matching methods
# These help users review and resolve fuzzy-matched pending/posted pairs
def has_potential_duplicate?
potential_posted_match_data.present? && !potential_duplicate_dismissed?
end
def potential_duplicate_entry
return nil unless has_potential_duplicate?
Entry.find_by(id: potential_posted_match_data["entry_id"])
end
def potential_duplicate_reason
potential_posted_match_data&.dig("reason")
end
def potential_duplicate_confidence
potential_posted_match_data&.dig("confidence") || "medium"
end
def low_confidence_duplicate?
potential_duplicate_confidence == "low"
end
def potential_duplicate_posted_amount
potential_posted_match_data&.dig("posted_amount")&.to_d
end
def potential_duplicate_dismissed?
potential_posted_match_data&.dig("dismissed") == true
end
# Merge this pending transaction with its suggested posted match
# This DELETES the pending entry since the posted version is canonical
def merge_with_duplicate!
return false unless has_potential_duplicate?
posted_entry = potential_duplicate_entry
return false unless posted_entry
pending_entry_id = entry.id
pending_entry_name = entry.name
# Delete this pending entry completely (no need to keep it around)
entry.destroy!
Rails.logger.info("User merged pending entry #{pending_entry_id} (#{pending_entry_name}) with posted entry #{posted_entry.id}")
true
end
# Dismiss the duplicate suggestion - user says these are NOT the same transaction
def dismiss_duplicate_suggestion!
return false unless potential_posted_match_data.present?
updated_extra = (extra || {}).deep_dup
updated_extra["potential_posted_match"]["dismissed"] = true
update!(extra: updated_extra)
Rails.logger.info("User dismissed duplicate suggestion for entry #{entry.id}")
true
end
# Clear the duplicate suggestion entirely
def clear_duplicate_suggestion!
return false unless potential_posted_match_data.present?
updated_extra = (extra || {}).deep_dup
updated_extra.delete("potential_posted_match")
update!(extra: updated_extra)
true
end
private
def potential_posted_match_data
return nil unless extra.is_a?(Hash)
extra["potential_posted_match"]
end
def clear_merchant_unlinked_association
return unless merchant_id.present? && merchant.is_a?(ProviderMerchant)

View File

@@ -6,6 +6,7 @@ class Transaction::Search
attribute :amount, :string
attribute :amount_operator, :string
attribute :types, array: true
attribute :status, array: true
attribute :accounts, array: true
attribute :account_ids, array: true
attribute :start_date, :string
@@ -30,6 +31,7 @@ class Transaction::Search
query = apply_active_accounts_filter(query, active_accounts_only)
query = apply_category_filter(query, categories)
query = apply_type_filter(query, types)
query = apply_status_filter(query, status)
query = apply_merchant_filter(query, merchants)
query = apply_tag_filter(query, tags)
query = EntrySearch.apply_search_filter(query, search)
@@ -47,8 +49,8 @@ class Transaction::Search
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
result = transactions_scope
.select(
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
"COUNT(entries.id) as transactions_count"
)
.joins(
@@ -98,14 +100,14 @@ class Transaction::Search
if parent_category_ids.empty?
query = query.left_joins(:category).where(
"categories.name IN (?) OR (
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution'))
)",
categories
)
else
query = query.left_joins(:category).where(
"categories.name IN (?) OR categories.parent_id IN (?) OR (
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution'))
)",
categories, parent_category_ids
)
@@ -153,4 +155,28 @@ class Transaction::Search
return query unless tags.present?
query.joins(:tags).where(tags: { name: tags })
end
def apply_status_filter(query, statuses)
return query unless statuses.present?
return query if statuses.uniq.sort == [ "confirmed", "pending" ] # Both selected = no filter
pending_condition = <<~SQL.squish
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
SQL
confirmed_condition = <<~SQL.squish
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
SQL
case statuses.sort
when [ "pending" ]
query.where(pending_condition)
when [ "confirmed" ]
query.where(confirmed_condition)
else
query
end
end
end

View File

@@ -16,6 +16,10 @@ class Transfer < ApplicationRecord
def kind_for_account(account)
if account.loan?
"loan_payment"
elsif account.credit_card?
"cc_payment"
elsif account.investment? || account.crypto?
"investment_contribution"
elsif account.liability?
"cc_payment"
else

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index?
false
end
def show?
false
end
def create?
false
end
def new?
create?
end
def update?
false
end
def edit?
update?
end
def destroy?
false
end
class Scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
raise NoMethodError, "You must define #resolve in #{self.class}"
end
private
attr_reader :user, :scope
end
end

View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true
class SsoProviderPolicy < ApplicationPolicy
# Only super admins can manage SSO providers (instance-wide auth config)
def index?
user&.super_admin?
end
def show?
user&.super_admin?
end
def create?
user&.super_admin?
end
def new?
create?
end
def update?
user&.super_admin?
end
def edit?
update?
end
def destroy?
user&.super_admin?
end
def toggle?
update?
end
def test_connection?
user&.super_admin?
end
class Scope < ApplicationPolicy::Scope
def resolve
if user&.super_admin?
scope.all
else
scope.none
end
end
end
end

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
class UserPolicy < ApplicationPolicy
# Only super_admins can manage user roles
def index?
user&.super_admin?
end
def update?
return false unless user&.super_admin?
# Prevent users from changing their own role (must be done by another super_admin)
user.id != record.id
end
class Scope < ApplicationPolicy::Scope
def resolve
if user&.super_admin?
scope.all
else
scope.none
end
end
end
end

View File

@@ -0,0 +1,52 @@
class AutoSyncScheduler
JOB_NAME = "sync_all_accounts"
def self.sync!
Rails.logger.info("[AutoSyncScheduler] auto_sync_enabled=#{Setting.auto_sync_enabled}, time=#{Setting.auto_sync_time}")
if Setting.auto_sync_enabled?
upsert_job
else
remove_job
end
end
def self.upsert_job
time_str = Setting.auto_sync_time || "02:22"
timezone_str = Setting.auto_sync_timezone || "UTC"
unless Setting.valid_auto_sync_time?(time_str)
Rails.logger.error("[AutoSyncScheduler] Invalid time format: #{time_str}, using default 02:22")
time_str = "02:22"
end
hour, minute = time_str.split(":").map(&:to_i)
timezone = ActiveSupport::TimeZone[timezone_str] || ActiveSupport::TimeZone["UTC"]
local_time = timezone.now.change(hour: hour, min: minute, sec: 0)
utc_time = local_time.utc
cron = "#{utc_time.min} #{utc_time.hour} * * *"
job = Sidekiq::Cron::Job.create(
name: JOB_NAME,
cron: cron,
class: "SyncAllJob",
queue: "scheduled",
description: "Syncs all accounts for all families"
)
if job.nil? || (job.respond_to?(:valid?) && !job.valid?)
error_msg = job.respond_to?(:errors) ? job.errors.to_a.join(", ") : "unknown error"
Rails.logger.error("[AutoSyncScheduler] Failed to create cron job: #{error_msg}")
raise StandardError, "Failed to create sync schedule: #{error_msg}"
end
Rails.logger.info("[AutoSyncScheduler] Created cron job with schedule: #{cron} (#{time_str} #{timezone_str})")
job
end
def self.remove_job
if (job = Sidekiq::Cron::Job.find(JOB_NAME))
job.destroy
end
end
end

View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
# Service class to load SSO provider configurations from either YAML or database
# based on the :db_sso_providers feature flag.
#
# Usage:
# providers = ProviderLoader.load_providers
#
class ProviderLoader
CACHE_KEY = "sso_providers_config"
CACHE_EXPIRES_IN = 5.minutes
class << self
# Load providers from either DB or YAML based on feature flag
# Returns an array of provider configuration hashes
def load_providers
# Check cache first for performance
cached = Rails.cache.read(CACHE_KEY)
return cached if cached.present?
providers = if use_database_providers?
load_from_database
else
load_from_yaml
end
# Cache the result
Rails.cache.write(CACHE_KEY, providers, expires_in: CACHE_EXPIRES_IN)
providers
end
# Clear the provider cache (call after updating providers in admin)
def clear_cache
Rails.cache.delete(CACHE_KEY)
end
private
def use_database_providers?
return false if Rails.env.test?
begin
# Check if feature exists, create if not (defaults to disabled)
unless Flipper.exist?(:db_sso_providers)
Flipper.add(:db_sso_providers)
end
Flipper.enabled?(:db_sso_providers)
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, StandardError => e
# Database not ready or other error, fall back to YAML
Rails.logger.warn("[ProviderLoader] Could not check feature flag (#{e.class}), falling back to YAML providers")
false
end
end
def load_from_database
begin
providers = SsoProvider.enabled.order(:name).map(&:to_omniauth_config)
if providers.empty?
Rails.logger.info("[ProviderLoader] No enabled providers in database, falling back to YAML")
return load_from_yaml
end
Rails.logger.info("[ProviderLoader] Loaded #{providers.count} provider(s) from database")
providers
rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError => e
Rails.logger.error("[ProviderLoader] Database error loading providers: #{e.message}, falling back to YAML")
load_from_yaml
rescue StandardError => e
Rails.logger.error("[ProviderLoader] Unexpected error loading providers from database: #{e.message}, falling back to YAML")
load_from_yaml
end
end
def load_from_yaml
begin
auth_config = Rails.application.config_for(:auth)
providers = auth_config.dig("providers") || []
Rails.logger.info("[ProviderLoader] Loaded #{providers.count} provider(s) from YAML")
providers
rescue RuntimeError, Errno::ENOENT => e
Rails.logger.error("[ProviderLoader] Error loading auth.yml: #{e.message}")
[]
end
end
end
end

View File

@@ -1,17 +0,0 @@
# frozen_string_literal: true
# DEPRECATED: This thin wrapper remains only for backward compatibility.
# Business logic has moved into `SimplefinItem::Unlinking` (model concern).
# Prefer calling `item.unlink_all!(dry_run: ...)` directly.
class SimplefinItem::Unlinker
attr_reader :item, :dry_run
def initialize(item, dry_run: false)
@item = item
@dry_run = dry_run
end
def unlink_all!
item.unlink_all!(dry_run: dry_run)
end
end

View File

@@ -0,0 +1,280 @@
<%# locals: (sso_provider:) %>
<% if sso_provider.errors.any? %>
<div class="bg-destructive/10 border border-destructive rounded-lg p-4 mb-4">
<div class="flex">
<%= icon "alert-circle", class: "w-5 h-5 text-destructive mr-2 shrink-0" %>
<div>
<p class="text-sm font-medium text-destructive">
<%= pluralize(sso_provider.errors.count, "error") %> prohibited this provider from being saved:
</p>
<ul class="mt-2 text-sm text-destructive list-disc list-inside">
<% sso_provider.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
<% end %>
<%= styled_form_with model: [:admin, sso_provider], class: "space-y-6", data: { controller: "admin-sso-form" } do |form| %>
<div class="space-y-4">
<h3 class="font-medium text-primary">Basic Information</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= form.select :strategy,
options_for_select([
["OpenID Connect", "openid_connect"],
["SAML 2.0", "saml"],
["Google OAuth2", "google_oauth2"],
["GitHub", "github"]
], sso_provider.strategy),
{ label: "Strategy" },
{ data: { action: "change->admin-sso-form#toggleFields" } } %>
<%= form.text_field :name,
label: "Name",
placeholder: "e.g., keycloak, authentik",
required: true,
data: { action: "input->admin-sso-form#updateCallbackUrl" } %>
</div>
<p class="text-xs text-secondary -mt-2">Unique identifier (lowercase, numbers, underscores only)</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= form.text_field :label,
label: "Button Label",
placeholder: "e.g., Sign in with Keycloak",
required: true %>
<div>
<%= form.text_field :icon,
label: "Icon (optional)",
placeholder: "e.g., key, shield" %>
<p class="text-xs text-secondary mt-1">Lucide icon name for the login button</p>
</div>
</div>
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="text-sm font-medium text-primary"><%= t("admin.sso_providers.form.enabled_label") %></p>
<p class="text-xs text-secondary"><%= t("admin.sso_providers.form.enabled_help") %></p>
</div>
<%= form.toggle :enabled %>
</div>
</div>
<div class="border-t border-primary pt-4 space-y-4">
<h3 class="font-medium text-primary">OAuth/OIDC Configuration</h3>
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
<%= form.text_field :issuer,
label: "Issuer URL",
placeholder: "https://your-idp.example.com/realms/your-realm",
data: { action: "blur->admin-sso-form#validateIssuer" } %>
<p class="text-xs text-secondary mt-1">OIDC issuer URL (validates .well-known/openid-configuration)</p>
</div>
<%= form.text_field :client_id,
label: "Client ID",
placeholder: "your-client-id",
required: true %>
<%= form.password_field :client_secret,
label: "Client Secret",
placeholder: sso_provider.persisted? ? "••••••••" : "your-client-secret",
required: !sso_provider.persisted? %>
<% if sso_provider.persisted? %>
<p class="text-xs text-secondary -mt-2">Leave blank to keep existing secret</p>
<% end %>
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
<label class="block text-sm font-medium text-primary mb-1">Callback URL</label>
<div class="flex items-center gap-2">
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
data-admin-sso-form-target="callbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
<button type="button"
data-action="click->admin-sso-form#copyCallback"
class="p-2 text-secondary hover:text-primary shrink-0"
title="Copy to clipboard">
<%= icon "copy", class: "w-4 h-4" %>
</button>
</div>
<p class="text-xs text-secondary mt-1">Configure this URL in your identity provider</p>
</div>
</div>
<div data-saml-field class="border-t border-primary pt-4 space-y-4 <%= "hidden" unless sso_provider.strategy == "saml" %>">
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.saml_configuration") %></h3>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_metadata_url") %></label>
<input type="text" name="sso_provider[settings][idp_metadata_url]"
value="<%= sso_provider.settings&.dig("idp_metadata_url") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="https://idp.example.com/metadata"
autocomplete="off">
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.idp_metadata_url_help") %></p>
</div>
<details class="mt-4">
<summary class="cursor-pointer text-sm font-medium text-secondary hover:text-primary"><%= t("admin.sso_providers.form.manual_saml_config") %></summary>
<div class="mt-3 space-y-3 pl-4 border-l-2 border-secondary/30">
<p class="text-xs text-secondary"><%= t("admin.sso_providers.form.manual_saml_help") %></p>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_sso_url") %></label>
<input type="text" name="sso_provider[settings][idp_sso_url]"
value="<%= sso_provider.settings&.dig("idp_sso_url") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="https://idp.example.com/sso"
autocomplete="off">
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_slo_url") %></label>
<input type="text" name="sso_provider[settings][idp_slo_url]"
value="<%= sso_provider.settings&.dig("idp_slo_url") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="https://idp.example.com/slo (optional)"
autocomplete="off">
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_certificate") %></label>
<textarea name="sso_provider[settings][idp_certificate]"
rows="4"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm font-mono"
placeholder="-----BEGIN CERTIFICATE-----"><%= sso_provider.settings&.dig("idp_certificate") %></textarea>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.idp_certificate_help") %></p>
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_cert_fingerprint") %></label>
<input type="text" name="sso_provider[settings][idp_cert_fingerprint]"
value="<%= sso_provider.settings&.dig("idp_cert_fingerprint") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm font-mono"
placeholder="AB:CD:EF:..."
autocomplete="off">
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.name_id_format") %></label>
<select name="sso_provider[settings][name_id_format]"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm">
<option value="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" <%= "selected" if sso_provider.settings&.dig("name_id_format").blank? || sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" %>><%= t("admin.sso_providers.form.name_id_email") %></option>
<option value="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" %>><%= t("admin.sso_providers.form.name_id_persistent") %></option>
<option value="urn:oasis:names:tc:SAML:2.0:nameid-format:transient" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" %>><%= t("admin.sso_providers.form.name_id_transient") %></option>
<option value="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" %>><%= t("admin.sso_providers.form.name_id_unspecified") %></option>
</select>
</div>
</div>
</details>
<div>
<label class="block text-sm font-medium text-primary mb-1">SP Callback URL (ACS URL)</label>
<div class="flex items-center gap-2">
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
data-admin-sso-form-target="samlCallbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
<button type="button"
data-action="click->admin-sso-form#copySamlCallback"
class="p-2 text-secondary hover:text-primary shrink-0"
title="Copy to clipboard">
<%= icon "copy", class: "w-4 h-4" %>
</button>
</div>
<p class="text-xs text-secondary mt-1">Configure this URL as the Assertion Consumer Service URL in your IdP</p>
</div>
</div>
<div class="border-t border-primary pt-4 space-y-4">
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.provisioning_title") %></h3>
<%= form.select "settings[default_role]",
options_for_select([
[t("admin.sso_providers.form.role_member"), "member"],
[t("admin.sso_providers.form.role_admin"), "admin"],
[t("admin.sso_providers.form.role_super_admin"), "super_admin"]
], sso_provider.settings&.dig("default_role") || "member"),
{ label: t("admin.sso_providers.form.default_role_label"), include_blank: false } %>
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.default_role_help") %></p>
<details class="mt-4">
<summary class="cursor-pointer text-sm font-medium text-secondary hover:text-primary"><%= t("admin.sso_providers.form.role_mapping_title") %></summary>
<div class="mt-3 space-y-3 pl-4 border-l-2 border-secondary/30">
<p class="text-xs text-secondary"><%= t("admin.sso_providers.form.role_mapping_help") %></p>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.super_admin_groups") %></label>
<input type="text" name="sso_provider[settings][role_mapping][super_admin]"
value="<%= Array(sso_provider.settings&.dig("role_mapping", "super_admin")).join(", ") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="Platform-Admins, IdP-Superusers"
autocomplete="off">
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.groups_help") %></p>
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.admin_groups") %></label>
<input type="text" name="sso_provider[settings][role_mapping][admin]"
value="<%= Array(sso_provider.settings&.dig("role_mapping", "admin")).join(", ") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="Team-Leads, Managers"
autocomplete="off">
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.member_groups") %></label>
<input type="text" name="sso_provider[settings][role_mapping][member]"
value="<%= Array(sso_provider.settings&.dig("role_mapping", "member")).join(", ") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="* (all groups)"
autocomplete="off">
</div>
</div>
</details>
</div>
<div data-oidc-field class="border-t border-primary pt-4 space-y-4 <%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.advanced_title") %></h3>
<div>
<%= form.text_field "settings[scopes]",
label: t("admin.sso_providers.form.scopes_label"),
value: sso_provider.settings&.dig("scopes"),
placeholder: "openid email profile groups" %>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.scopes_help") %></p>
</div>
<%= form.select "settings[prompt]",
options_for_select([
[t("admin.sso_providers.form.prompt_default"), ""],
[t("admin.sso_providers.form.prompt_login"), "login"],
[t("admin.sso_providers.form.prompt_consent"), "consent"],
[t("admin.sso_providers.form.prompt_select_account"), "select_account"],
[t("admin.sso_providers.form.prompt_none"), "none"]
], sso_provider.settings&.dig("prompt")),
{ label: t("admin.sso_providers.form.prompt_label"), include_blank: false } %>
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.prompt_help") %></p>
</div>
<div class="flex justify-between items-center gap-3 pt-4 border-t border-primary">
<div>
<% if sso_provider.persisted? %>
<button type="button"
data-action="click->admin-sso-form#testConnection"
data-admin-sso-form-test-url-value="<%= test_connection_admin_sso_provider_path(sso_provider) %>"
class="px-4 py-2 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg">
<%= t("admin.sso_providers.form.test_connection") %>
</button>
<span data-admin-sso-form-target="testResult" class="ml-2 text-sm"></span>
<% end %>
</div>
<div class="flex gap-3">
<%= link_to "Cancel", admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %>
<%= form.submit sso_provider.persisted? ? "Update Provider" : "Create Provider",
class: "px-4 py-2 bg-primary text-inverse rounded-lg text-sm font-medium hover:bg-primary/90" %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,9 @@
<%= content_for :page_title, "Edit #{@sso_provider.label}" %>
<div class="space-y-4">
<p class="text-secondary">Update configuration for <%= @sso_provider.label %>.</p>
<%= settings_section title: "Provider Configuration" do %>
<%= render "form", sso_provider: @sso_provider %>
<% end %>
</div>

View File

@@ -0,0 +1,126 @@
<%= content_for :page_title, "SSO Providers" %>
<div class="space-y-4">
<p class="text-secondary mb-4">
Manage single sign-on authentication providers for your instance.
<% unless Flipper.enabled?(:db_sso_providers) %>
<span class="text-warning">Changes require a server restart to take effect.</span>
<% end %>
</p>
<%= settings_section title: "Configured Providers" do %>
<% if @sso_providers.any? %>
<div class="divide-y divide-primary">
<% @sso_providers.each do |provider| %>
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div class="flex items-center gap-3">
<% if provider.icon.present? %>
<%= icon provider.icon, class: "w-5 h-5 text-secondary" %>
<% else %>
<%= icon "key", class: "w-5 h-5 text-secondary" %>
<% end %>
<div>
<p class="font-medium text-primary"><%= provider.label %></p>
<p class="text-sm text-secondary"><%= provider.strategy.titleize %> · <%= provider.name %></p>
</div>
</div>
<div class="flex items-center gap-2">
<% if provider.enabled? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Enabled
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
Disabled
</span>
<% end %>
<%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: "Edit" do %>
<%= icon "pencil", class: "w-4 h-4" %>
<% end %>
<%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? "Disable" : "Enable", form: { data: { turbo_confirm: "Are you sure you want to #{provider.enabled? ? 'disable' : 'enable'} this provider?" } } do %>
<%= icon provider.enabled? ? "toggle-right" : "toggle-left", class: "w-4 h-4" %>
<% end %>
<%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: "Delete", form: { data: { turbo_confirm: "Are you sure you want to delete this provider? This action cannot be undone." } } do %>
<%= icon "trash-2", class: "w-4 h-4" %>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-6">
<%= icon "key", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
<p class="text-secondary">No SSO providers configured yet.</p>
</div>
<% end %>
<div class="pt-4 border-t border-primary">
<%= link_to new_admin_sso_provider_path, class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-secondary" do %>
<%= icon "plus", class: "w-4 h-4" %>
Add Provider
<% end %>
</div>
<% end %>
<% if @legacy_providers.any? %>
<%= settings_section title: t("admin.sso_providers.index.legacy_providers_title"), collapsible: true, open: true do %>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
<div class="flex gap-2">
<%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0" %>
<p class="text-sm text-amber-800">
<%= t("admin.sso_providers.index.legacy_providers_notice") %>
</p>
</div>
</div>
<div class="divide-y divide-primary">
<% @legacy_providers.each do |provider| %>
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div class="flex items-center gap-3">
<% provider_icon = provider[:icon].presence || "key" %>
<%= icon provider_icon, class: "w-5 h-5 text-secondary" %>
<div>
<p class="font-medium text-primary"><%= provider[:label].presence || provider[:name] %></p>
<p class="text-sm text-secondary">
<%= provider[:strategy].to_s.titleize %> · <%= provider[:name] %>
<% if provider[:issuer].present? %>
· <span class="text-xs"><%= provider[:issuer] %></span>
<% end %>
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
<%= t("admin.sso_providers.index.env_configured") %>
</span>
</div>
</div>
<% end %>
</div>
<% end %>
<% end %>
<%= settings_section title: "Configuration Mode", collapsible: true, open: false do %>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-primary">Database-backed providers</p>
<p class="text-sm text-secondary">Load providers from database instead of YAML config</p>
</div>
<% if Flipper.enabled?(:db_sso_providers) %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Enabled
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
Disabled
</span>
<% end %>
</div>
<p class="text-sm text-secondary">
Set <code class="bg-surface px-1 py-0.5 rounded text-xs">AUTH_PROVIDERS_SOURCE=db</code> to enable database-backed providers.
This allows changes without server restarts.
</p>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,9 @@
<%= content_for :page_title, "Add SSO Provider" %>
<div class="space-y-4">
<p class="text-secondary">Configure a new single sign-on authentication provider.</p>
<%= settings_section title: "Provider Configuration" do %>
<%= render "form", sso_provider: @sso_provider %>
<% end %>
</div>

View File

@@ -0,0 +1,73 @@
<%= content_for :page_title, t(".title") %>
<div class="space-y-4">
<p class="text-secondary"><%= t(".description") %></p>
<%= settings_section title: t(".section_title") do %>
<div class="divide-y divide-primary">
<% @users.each do |user| %>
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-surface flex items-center justify-center">
<span class="text-sm font-medium text-primary"><%= user.initials %></span>
</div>
<div>
<p class="font-medium text-primary"><%= user.display_name %></p>
<p class="text-sm text-secondary"><%= user.email %></p>
</div>
</div>
<div class="flex items-center gap-3">
<% if user.id == Current.user.id %>
<span class="text-sm text-secondary"><%= t(".you") %></span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary">
<%= t(".roles.#{user.role}") %>
</span>
<% else %>
<%= form_with model: [:admin, user], method: :patch, class: "flex items-center gap-2" do |form| %>
<%= form.select :role,
options_for_select([
[t(".roles.member"), "member"],
[t(".roles.admin"), "admin"],
[t(".roles.super_admin"), "super_admin"]
], user.role),
{},
class: "text-sm rounded-lg border-primary bg-container text-primary px-2 py-1",
onchange: "this.form.requestSubmit()" %>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
<% if @users.empty? %>
<div class="text-center py-6">
<%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
<p class="text-secondary"><%= t(".no_users") %></p>
</div>
<% end %>
<% end %>
<%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: false do %>
<div class="space-y-3 text-sm">
<div class="flex items-start gap-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
<%= t(".roles.member") %>
</span>
<p class="text-secondary"><%= t(".role_descriptions.member") %></p>
</div>
<div class="flex items-start gap-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
<%= t(".roles.admin") %>
</span>
<p class="text-secondary"><%= t(".role_descriptions.admin") %></p>
</div>
<div class="flex items-start gap-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 shrink-0">
<%= t(".roles.super_admin") %>
</span>
<p class="text-secondary"><%= t(".role_descriptions.super_admin") %></p>
</div>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,109 @@
<%# locals: (holding:, editable: true) %>
<%
# Pre-calculate values for the form
# Note: cost_basis field stores per-share cost, so calculate total for display
current_per_share = holding.cost_basis.present? && holding.cost_basis.positive? ? holding.cost_basis : nil
current_total = current_per_share && holding.qty.positive? ? (current_per_share * holding.qty).round(2) : nil
currency = Money::Currency.new(holding.currency)
%>
<%= turbo_frame_tag dom_id(holding, :cost_basis) do %>
<% if holding.cost_basis_locked? && !editable %>
<%# Locked and not editable (from holdings list) - just show value, right-aligned %>
<div class="flex items-center justify-end gap-1">
<%= tag.span format_money(holding.avg_cost) %>
<%= icon "lock", size: "xs", class: "text-secondary" %>
</div>
<% else %>
<%# Unlocked OR editable context (drawer) - show clickable menu %>
<%= render DS::Menu.new(variant: :button, placement: "bottom-end") do |menu| %>
<% menu.with_button(class: "hover:text-primary cursor-pointer group") do %>
<% if holding.avg_cost %>
<div class="flex items-center gap-1">
<%= tag.span format_money(holding.avg_cost) %>
<% if holding.cost_basis_locked? %>
<%= icon "lock", size: "xs", class: "text-secondary" %>
<% end %>
<%= icon "pencil", size: "xs", class: "text-secondary opacity-0 group-hover:opacity-100 transition-opacity" %>
</div>
<% else %>
<div class="flex items-center gap-1 px-2 py-0.5 rounded text-secondary hover:text-primary hover:bg-gray-100 theme-dark:hover:bg-gray-700 transition-colors">
<%= icon "pencil", size: "xs" %>
<span class="text-xs">Set</span>
</div>
<% end %>
<% end %>
<% menu.with_custom_content do %>
<div class="p-4 min-w-[280px]"
data-controller="cost-basis-form"
data-cost-basis-form-qty-value="<%= holding.qty %>">
<h4 class="font-medium text-sm mb-3">
<%= t(".set_cost_basis_header", ticker: holding.ticker, qty: number_with_precision(holding.qty, precision: 2)) %>
</h4>
<%
form_data = { turbo: false }
if holding.avg_cost
form_data[:turbo_confirm] = {
title: t(".overwrite_confirm_title"),
body: t(".overwrite_confirm_body", current: format_money(holding.avg_cost))
}
end
%>
<%= styled_form_with model: holding,
url: holding_path(holding),
method: :patch,
class: "space-y-3",
data: form_data do |f| %>
<!-- Primary: Total cost basis (custom input, no spinners) -->
<div class="form-field">
<div class="form-field__body">
<label class="form-field__label"><%= t(".total_cost_basis_label") %></label>
<div class="flex items-center gap-1">
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
<input type="text" inputmode="decimal"
name="holding[cost_basis]"
class="form-field__input grow"
placeholder="0.00"
autocomplete="off"
value="<%= number_with_precision(current_total, precision: 2) if current_total %>"
data-action="input->cost-basis-form#updatePerShare"
data-cost-basis-form-target="total">
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
</div>
</div>
</div>
<p class="text-xs text-secondary -mt-2" data-cost-basis-form-target="perShareDisplay">
= <%= currency.symbol %><span data-cost-basis-form-target="perShareValue"><%= number_with_precision(current_per_share, precision: 2) || "0.00" %></span> <%= t(".per_share") %>
</p>
<!-- Alternative: Per-share input -->
<div class="pt-2 border-t border-tertiary">
<label class="text-xs text-secondary block mb-1"><%= t(".or_per_share_label") %></label>
<div class="flex items-center gap-1">
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
<input type="text" inputmode="decimal"
class="form-field__input grow"
placeholder="0.00"
autocomplete="off"
value="<%= number_with_precision(current_per_share, precision: 2) if current_per_share %>"
data-action="input->cost-basis-form#updateTotal"
data-cost-basis-form-target="perShare">
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button"
class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600"
data-action="click->DS--menu#close">
<%= t(".cancel") %>
</button>
<%= f.submit t(".save"), class: "inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %>
</div>
<% end %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>

View File

@@ -31,7 +31,7 @@
</div>
<div class="col-span-2 text-right">
<%= tag.p format_money holding.avg_cost %>
<%= render "holdings/cost_basis_cell", holding: holding, editable: false %>
<%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
</div>
@@ -45,13 +45,13 @@
</div>
<div class="col-span-2 text-right">
<%# Show Total Return (unrealized G/L) when cost basis exists %>
<% if holding.trades.any? && holding.trend %>
<%# Show Total Return (unrealized G/L) when cost basis exists (from trades or manual) %>
<% if holding.trend %>
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
<% else %>
<%= tag.p "--", class: "text-secondary" %>
<%= tag.p "No cost basis", class: "text-xs text-secondary" %>
<%= tag.p t(".no_cost_basis"), class: "text-xs text-secondary" %>
<% end %>
</div>
</div>

View File

@@ -35,16 +35,107 @@
<dd class="text-primary"><%= @holding.weight ? number_to_percentage(@holding.weight, precision: 2) : t(".unknown") %></dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".avg_cost_label") %></dt>
<dd class="text-primary"><%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %></dd>
<%# Average Cost with inline editor %>
<%
currency = Money::Currency.new(@holding.currency)
current_per_share = @holding.cost_basis.present? && @holding.cost_basis.positive? ? @holding.cost_basis : nil
current_total = current_per_share && @holding.qty.positive? ? (current_per_share * @holding.qty).round(2) : nil
%>
<div data-controller="drawer-cost-basis" data-drawer-cost-basis-qty-value="<%= @holding.qty %>">
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".avg_cost_label") %></dt>
<dd class="text-primary flex items-center gap-1">
<%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %>
<% if @holding.cost_basis_locked? %>
<%= icon "lock", size: "xs", class: "text-secondary" %>
<% end %>
<% if @holding.cost_basis_source.present? %>
<span class="text-xs text-secondary">(<%= @holding.cost_basis_source_label %>)</span>
<% end %>
<button type="button" class="ml-1" data-action="click->drawer-cost-basis#toggle">
<%= icon "pencil", size: "xs", class: "text-secondary hover:text-primary" %>
</button>
</dd>
</div>
<%# Inline cost basis editor (hidden by default) %>
<div class="hidden mt-3 space-y-3" data-drawer-cost-basis-target="form">
<%
drawer_form_data = { turbo: false }
if @holding.avg_cost
drawer_form_data[:turbo_confirm] = {
title: t("holdings.cost_basis_cell.overwrite_confirm_title"),
body: t("holdings.cost_basis_cell.overwrite_confirm_body", current: format_money(@holding.avg_cost))
}
end
%>
<%= styled_form_with model: @holding,
url: holding_path(@holding),
method: :patch,
class: "space-y-3",
data: drawer_form_data do |f| %>
<p class="text-xs text-secondary mb-2">
<%= t("holdings.cost_basis_cell.set_cost_basis_header", ticker: @holding.ticker, qty: number_with_precision(@holding.qty, precision: 4)) %>
</p>
<!-- Total cost basis input -->
<div class="form-field">
<div class="form-field__body">
<label class="form-field__label"><%= t("holdings.cost_basis_cell.total_cost_basis_label") %></label>
<div class="flex items-center gap-1">
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
<input type="text" inputmode="decimal"
name="holding[cost_basis]"
class="form-field__input grow"
placeholder="0.00"
autocomplete="off"
value="<%= number_with_precision(current_total, precision: 2) if current_total %>"
data-action="input->drawer-cost-basis#updatePerShare"
data-drawer-cost-basis-target="total">
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
</div>
</div>
</div>
<p class="text-xs text-secondary -mt-2">
= <%= currency.symbol %><span data-drawer-cost-basis-target="perShareValue"><%= number_with_precision(current_per_share, precision: 2) || "0.00" %></span> <%= t("holdings.cost_basis_cell.per_share") %>
</p>
<!-- Per-share input -->
<div class="pt-2 border-t border-tertiary">
<label class="text-xs text-secondary block mb-1"><%= t("holdings.cost_basis_cell.or_per_share_label") %></label>
<div class="flex items-center gap-1">
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
<input type="text" inputmode="decimal"
class="form-field__input grow"
placeholder="0.00"
autocomplete="off"
value="<%= number_with_precision(current_per_share, precision: 2) if current_per_share %>"
data-action="input->drawer-cost-basis#updateTotal"
data-drawer-cost-basis-target="perShare">
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600"
data-action="click->drawer-cost-basis#toggle">
<%= t("holdings.cost_basis_cell.cancel") %>
</button>
<%= f.submit t("holdings.cost_basis_cell.save"), class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %>
</div>
<% end %>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".total_return_label") %></dt>
<dd style="color: <%= @holding.trend&.color %>;">
<%= @holding.trend ? render("shared/trend_change", trend: @holding.trend) : t(".unknown") %>
</dd>
<% if @holding.trend %>
<dd style="color: <%= @holding.trend.color %>;">
<%= render("shared/trend_change", trend: @holding.trend) %>
</dd>
<% else %>
<dd class="text-secondary"><%= t(".unknown") %></dd>
<% end %>
</div>
</dl>
</div>
@@ -85,21 +176,39 @@
</div>
<% end %>
<% if @holding.account.can_delete_holdings? %>
<% if @holding.cost_basis_locked? || @holding.account.can_delete_holdings? %>
<% dialog.with_section(title: t(".settings"), open: true) do %>
<div class="pb-4">
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".delete_title") %></h4>
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
</div>
<% if @holding.cost_basis_locked? %>
<div class="flex items-center justify-between gap-2 p-3 border-b border-tertiary">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".cost_basis_locked_label") %></h4>
<p class="text-secondary"><%= t(".cost_basis_locked_description") %></p>
</div>
<%= button_to t(".delete"),
holding_path(@holding),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
data: { turbo_confirm: true } %>
</div>
<%= button_to t(".unlock_cost_basis"),
unlock_cost_basis_holding_path(@holding),
method: :post,
class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600",
form: { data: { turbo: false } },
data: { turbo_confirm: { title: t(".unlock_confirm_title"), body: t(".unlock_confirm_body") } } %>
</div>
<% end %>
<% if @holding.account.can_delete_holdings? %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".delete_title") %></h4>
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
</div>
<%= button_to t(".delete"),
holding_path(@holding),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
data: { turbo_confirm: true } %>
</div>
<% end %>
</div>
<% end %>
<% end %>

View File

@@ -1,7 +1,11 @@
<div data-drag-and-drop-import-target="overlay" class="fixed inset-0 bg-primary/20 backdrop-blur-sm z-50 hidden flex items-center justify-center pointer-events-none">
<div class="text-center p-8 bg-container rounded-xl shadow-2xl border-2 border-dashed border-primary animate-in fade-in zoom-in duration-200">
<%= icon("upload", size: "xl", class: "text-primary mb-4 mx-auto w-16 h-16") %>
<h3 class="text-2xl font-semibold text-primary mb-2"><%= title %></h3>
<p class="text-secondary text-base"><%= subtitle %></p>
<div data-drag-and-drop-import-target="overlay" class="fixed inset-0 bg-overlay backdrop-blur-sm z-50 hidden flex items-center justify-center pointer-events-none">
<div class="bg-container rounded-xl shadow-border-xs p-6 max-w-sm w-full mx-4 animate-in fade-in zoom-in duration-200">
<div class="flex flex-col items-center text-center">
<div class="w-12 h-12 rounded-full bg-indigo-500/10 flex items-center justify-center mb-4">
<%= icon("upload", class: "text-indigo-500 w-6 h-6") %>
</div>
<h3 class="text-lg font-medium text-primary mb-1"><%= title %></h3>
<p class="text-sm text-secondary"><%= subtitle %></p>
</div>
</div>
</div>
</div>

View File

@@ -19,8 +19,7 @@
data-app-layout-user-id-value="<%= Current.user.id %>">
<div
class="hidden fixed inset-0 bg-surface z-20 h-full w-full pt-[calc(env(safe-area-inset-top)+0.75rem)] pr-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] pl-3 overflow-y-auto transition-all duration-300"
data-app-layout-target="mobileSidebar"
data-print="hide">
data-app-layout-target="mobileSidebar">
<div class="mb-2">
<%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
</div>
@@ -34,7 +33,7 @@
</div>
<%# MOBILE - Top nav %>
<nav class="lg:hidden flex justify-between items-center p-3" data-print="hide">
<nav class="lg:hidden flex justify-between items-center p-3">
<%= icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
<%= link_to root_path, class: "block" do %>
@@ -45,7 +44,7 @@
</nav>
<%# DESKTOP - Left navbar %>
<div class="hidden lg:block" data-print="hide">
<div class="hidden lg:block">
<nav class="h-full flex flex-col shrink-0 w-[84px] py-4 mr-3">
<div class="pl-2 mb-3">
<%= link_to root_path, class: "block" do %>
@@ -79,7 +78,7 @@
"hidden lg:block py-4 overflow-y-auto shrink-0 max-w-[320px] transition-all duration-300",
Current.user.show_sidebar? ? expanded_sidebar_class : collapsed_sidebar_class,
),
data: { app_layout_target: "leftSidebar", print: "hide" } do %>
data: { app_layout_target: "leftSidebar" } do %>
<% if content_for?(:sidebar) %>
<%= yield :sidebar %>
<% else %>
@@ -139,7 +138,7 @@
"hidden lg:block h-full overflow-y-auto shrink-0 max-w-[400px] transition-all duration-300",
Current.user.show_ai_sidebar? ? expanded_sidebar_class : collapsed_sidebar_class,
),
data: { app_layout_target: "rightSidebar", print: "hide" } do %>
data: { app_layout_target: "rightSidebar" } do %>
<%= tag.div id: "chat-container", class: "relative h-full", data: { controller: "chat hotkey", turbo_permanent: true } do %>
<div class="flex flex-col h-full justify-between shrink-0">
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
@@ -158,7 +157,7 @@
<% end %>
<%# MOBILE - Bottom Nav %>
<%= tag.nav class: "lg:hidden fixed bottom-0 left-0 right-0 bg-surface z-10 pb-[env(safe-area-inset-bottom)] border-t border-tertiary flex justify-around", data: { print: "hide" } do %>
<%= tag.nav class: "lg:hidden fixed bottom-0 left-0 right-0 bg-surface z-10 pb-[env(safe-area-inset-bottom)] border-t border-tertiary flex justify-around" do %>
<% mobile_nav_items.each do |nav_item| %>
<%= render "layouts/shared/nav_item", **nav_item %>
<% end %>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en" class="font-sans">
<head>
<title><%= content_for(:title) || t("reports.print.document_title") %></title>
<%= csrf_meta_tags %>
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
</head>
<body class="bg-white text-gray-900 antialiased print-body">
<div class="print-container">
<%= yield %>
</div>
<script>
// Auto-trigger print dialog when page loads
window.onload = function() {
// Small delay to ensure styles are loaded
setTimeout(function() {
window.print();
}, 500);
};
</script>
</body>
</html>

View File

@@ -119,7 +119,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<% investment_metrics[:accounts].each do |account| %>
<div class="bg-container-inset rounded-lg p-4 flex items-center justify-between">
<%= link_to account_path(account), class: "bg-container-inset rounded-lg p-4 flex items-center justify-between hover:bg-container-hover transition-colors" do %>
<div class="flex items-center gap-3">
<%= render "accounts/logo", account: account, size: "sm" %>
<div>
@@ -128,7 +128,7 @@
</div>
</div>
<p class="font-medium text-primary"><%= format_money(account.balance_money) %></p>
</div>
<% end %>
<% end %>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<div>
<%# Export Controls %>
<div class="reports-print-hide flex items-center justify-end mb-4 flex-wrap gap-3">
<div class="flex items-center justify-end mb-4 flex-wrap gap-3">
<%
# Build params hash for links
base_params = {

View File

@@ -79,4 +79,3 @@
<% end %>
</div>
</div>
</div>

View File

@@ -1,103 +1,5 @@
<% content_for :head do %>
<style media="print">
/* Print-specific custom property overrides for values using --alpha() */
:root {
--print-shadow-color: rgba(11, 11, 11, 0.06);
}
@page {
margin: 0.75in;
}
body {
background: var(--color-gray-50);
color: var(--color-gray-900);
font-family: var(--font-sans);
}
#notification-tray,
#cta,
#chat-container,
turbo-frame#modal,
turbo-frame#drawer,
[data-print="hide"] {
display: none !important;
}
[data-controller="app-layout"] {
height: auto !important;
overflow: visible !important;
display: block !important;
}
[data-app-layout-target="content"] {
max-width: none !important;
max-height: none !important;
height: auto !important;
padding: 0 !important;
margin: 0 !important;
overflow: visible !important;
}
body,
html {
height: auto !important;
overflow: visible !important;
}
main {
overflow: visible !important;
height: auto !important;
max-height: none !important;
}
[data-app-layout-target="content"] > .hidden.lg\:flex {
display: none !important;
}
.reports-page {
background: transparent;
padding: 0 !important;
}
.reports-print-sheet {
background: var(--color-white);
border-radius: 16px;
padding: 0.75in 0.65in;
box-shadow: 0 10px 25px var(--print-shadow-color);
}
.reports-page section {
break-inside: avoid;
page-break-inside: avoid;
}
.reports-print-hide,
[data-reports-section-target="button"],
[data-reports-sortable-target="handle"] {
display: none !important;
}
[data-reports-section-target="content"] {
display: block !important;
}
section[data-section-key="transactions_breakdown"] {
break-before: page;
page-break-before: always;
margin-top: 0 !important;
padding-top: 0 !important;
}
.reports-page table th,
.reports-page table td {
padding-inline: 0.5rem !important;
}
</style>
<% end %>
<% content_for :page_header do %>
<div class="space-y-4 mb-6 reports-print-hide">
<div class="space-y-4 mb-6">
<div class="space-y-1">
<h1 class="text-xl lg:text-3xl font-medium text-primary">
<%= t("reports.index.title") %>
@@ -115,42 +17,54 @@
<% end %>
<%# Period Navigation Tabs %>
<div class="reports-print-hide flex items-center gap-2 overflow-x-auto pb-2">
<%= render DS::Link.new(
text: t("reports.index.periods.monthly"),
variant: @period_type == :monthly ? "secondary" : "ghost",
href: reports_path(period_type: :monthly),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.quarterly"),
variant: @period_type == :quarterly ? "secondary" : "ghost",
href: reports_path(period_type: :quarterly),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.ytd"),
variant: @period_type == :ytd ? "secondary" : "ghost",
href: reports_path(period_type: :ytd),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.last_6_months"),
variant: @period_type == :last_6_months ? "secondary" : "ghost",
href: reports_path(period_type: :last_6_months),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.custom"),
variant: @period_type == :custom ? "secondary" : "ghost",
href: reports_path(period_type: :custom),
size: :sm
) %>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2 overflow-x-auto pb-2">
<%= render DS::Link.new(
text: t("reports.index.periods.monthly"),
variant: @period_type == :monthly ? "secondary" : "ghost",
href: reports_path(period_type: :monthly),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.quarterly"),
variant: @period_type == :quarterly ? "secondary" : "ghost",
href: reports_path(period_type: :quarterly),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.ytd"),
variant: @period_type == :ytd ? "secondary" : "ghost",
href: reports_path(period_type: :ytd),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.last_6_months"),
variant: @period_type == :last_6_months ? "secondary" : "ghost",
href: reports_path(period_type: :last_6_months),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.custom"),
variant: @period_type == :custom ? "secondary" : "ghost",
href: reports_path(period_type: :custom),
size: :sm
) %>
</div>
<%# Print Report Button %>
<%= link_to print_reports_path(period_type: @period_type, start_date: @start_date, end_date: @end_date),
target: "_blank",
rel: "noopener",
aria: { label: t("reports.index.print_report") },
class: "inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-secondary bg-surface-inset hover:bg-surface-hover rounded-lg transition-colors flex-shrink-0" do %>
<%= icon("printer", size: "sm") %>
<span class="hidden sm:inline"><%= t("reports.index.print_report") %></span>
<% end %>
</div>
<%# Custom Date Range Picker (only shown when custom is selected) %>
<% if @period_type == :custom %>
<%= form_with url: reports_path, method: :get, data: { controller: "auto-submit-form" }, class: "reports-print-hide flex items-center gap-3 bg-surface-inset p-3 rounded-lg" do |f| %>
<%= form_with url: reports_path, method: :get, data: { controller: "auto-submit-form" }, class: "flex items-center gap-3 bg-surface-inset p-3 rounded-lg" do |f| %>
<%= f.hidden_field :period_type, value: :custom %>
<div class="flex items-center gap-2">
@@ -182,7 +96,7 @@
</div>
<% end %>
<div class="reports-page w-full space-y-6 pb-24 reports-print-sheet">
<div class="w-full space-y-6 pb-24">
<% if Current.family.transactions.any? %>
<%# Summary Dashboard - Always visible, not collapsible %>
<section>

View File

@@ -0,0 +1,345 @@
<% content_for :title do %>
<%= t("reports.print.document_title") %> - <%= @start_date.strftime("%B %d, %Y") %> to <%= @end_date.strftime("%B %d, %Y") %>
<% end %>
<div class="tufte-report">
<%# Header %>
<header class="tufte-header">
<h1 class="tufte-title"><%= t("reports.print.title") %></h1>
<span class="tufte-period"><%= @start_date.strftime("%B %d, %Y") %> <%= @end_date.strftime("%B %d, %Y") %></span>
<p class="tufte-meta"><%= Current.family.name %> · <%= t("reports.print.generated_on", date: Time.current.strftime("%B %d, %Y")) %></p>
</header>
<%# Summary %>
<section class="tufte-section">
<h2 class="tufte-section-title"><%= t("reports.print.summary.title") %></h2>
<div class="tufte-metric-row">
<div class="tufte-metric-card tufte-metric-card-sm">
<span class="tufte-metric-card-label"><%= t("reports.print.summary.income") %></span>
<span class="tufte-metric-card-value tufte-income"><%= @summary_metrics[:current_income].format %></span>
<% if @summary_metrics[:income_change] && @summary_metrics[:income_change] != 0 %>
<span class="tufte-metric-card-change <%= @summary_metrics[:income_change] >= 0 ? "tufte-up" : "tufte-down" %>">
<%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:income_change] >= 0 ? "+#{@summary_metrics[:income_change]}" : @summary_metrics[:income_change].to_s)) %>
</span>
<% end %>
<% if has_sparkline_data?(@trends_data) %>
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:income] }, width: 60, height: 20) %>" fill="none" stroke="#047857" stroke-width="1.5" />
</svg>
<% end %>
</div>
<div class="tufte-metric-card tufte-metric-card-sm">
<span class="tufte-metric-card-label"><%= t("reports.print.summary.expenses") %></span>
<span class="tufte-metric-card-value tufte-expense"><%= @summary_metrics[:current_expenses].format %></span>
<% if @summary_metrics[:expense_change] && @summary_metrics[:expense_change] != 0 %>
<span class="tufte-metric-card-change <%= @summary_metrics[:expense_change] >= 0 ? "tufte-down" : "tufte-up" %>">
<%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:expense_change] >= 0 ? "+#{@summary_metrics[:expense_change]}" : @summary_metrics[:expense_change].to_s)) %>
</span>
<% end %>
<% if has_sparkline_data?(@trends_data) %>
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:expenses] }, width: 60, height: 20) %>" fill="none" stroke="#b91c1c" stroke-width="1.5" />
</svg>
<% end %>
</div>
<div class="tufte-metric-card tufte-metric-card-sm">
<span class="tufte-metric-card-label"><%= t("reports.print.summary.net_savings") %></span>
<span class="tufte-metric-card-value <%= @summary_metrics[:net_savings] >= 0 ? "tufte-income" : "tufte-expense" %>"><%= @summary_metrics[:net_savings].format %></span>
<%
# Calculate savings rate
savings_rate = @summary_metrics[:current_income].amount > 0 ? ((@summary_metrics[:net_savings].amount / @summary_metrics[:current_income].amount) * 100).round(0) : 0
%>
<% if savings_rate != 0 %>
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.summary.of_income", percent: savings_rate) %></span>
<% end %>
<% if has_sparkline_data?(@trends_data) %>
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:net] }, width: 60, height: 20) %>" fill="none" stroke="<%= @summary_metrics[:net_savings] >= 0 ? "#047857" : "#b91c1c" %>" stroke-width="1.5" />
</svg>
<% end %>
</div>
<% if @summary_metrics[:budget_percent] %>
<div class="tufte-metric-card tufte-metric-card-sm">
<span class="tufte-metric-card-label"><%= t("reports.print.summary.budget") %></span>
<span class="tufte-metric-card-value"><%= @summary_metrics[:budget_percent] %>%</span>
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.summary.used") %></span>
</div>
<% end %>
</div>
</section>
<%# Net Worth %>
<% if @has_accounts %>
<section class="tufte-section">
<h2 class="tufte-section-title"><%= t("reports.print.net_worth.title") %></h2>
<div class="tufte-metric-row">
<div class="tufte-metric-card">
<span class="tufte-metric-card-label"><%= t("reports.print.net_worth.current_balance") %></span>
<span class="tufte-metric-card-value <%= @net_worth_metrics[:current_net_worth] >= 0 ? "tufte-income" : "tufte-expense" %>">
<%= @net_worth_metrics[:current_net_worth].format %>
</span>
<% if @net_worth_metrics[:trend] %>
<span class="tufte-metric-card-change" style="color: <%= @net_worth_metrics[:trend].color %>">
<%= @net_worth_metrics[:trend].value >= 0 ? "+" : "" %><%= @net_worth_metrics[:trend].value.format %> (<%= @net_worth_metrics[:trend].percent_formatted %>) <%= t("reports.print.net_worth.this_period") %>
</span>
<% end %>
<% if has_sparkline_data?(@trends_data) %>
<svg width="80" height="24" viewBox="0 0 80 24" style="display:block;margin-top:8px;">
<polyline points="<%= sparkline_points(cumulative_net_values(@trends_data), width: 80, height: 24) %>" fill="none" stroke="<%= @net_worth_metrics[:current_net_worth] >= 0 ? "#047857" : "#b91c1c" %>" stroke-width="1.5" />
</svg>
<% end %>
</div>
</div>
<div class="tufte-two-col">
<div>
<h3 class="tufte-subsection"><%= t("reports.print.net_worth.assets") %> <span class="tufte-income"><%= @net_worth_metrics[:total_assets].format %></span></h3>
<% if @net_worth_metrics[:asset_groups].any? %>
<table class="tufte-table tufte-compact">
<tbody>
<% @net_worth_metrics[:asset_groups].each do |group| %>
<tr>
<td><%= group[:name] %></td>
<td class="tufte-right"><%= group[:total].format %></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
</div>
<div>
<h3 class="tufte-subsection"><%= t("reports.print.net_worth.liabilities") %> <span class="tufte-expense"><%= @net_worth_metrics[:total_liabilities].format %></span></h3>
<% if @net_worth_metrics[:liability_groups].any? %>
<table class="tufte-table tufte-compact">
<tbody>
<% @net_worth_metrics[:liability_groups].each do |group| %>
<tr>
<td><%= group[:name] %></td>
<td class="tufte-right"><%= group[:total].format %></td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="tufte-muted" style="margin: 0;"><%= t("reports.print.net_worth.no_liabilities") %></p>
<% end %>
</div>
</div>
</section>
<% end %>
<%# Monthly Trends %>
<% if has_sparkline_data?(@trends_data) %>
<section class="tufte-section">
<h2 class="tufte-section-title"><%= t("reports.print.trends.title") %></h2>
<table class="tufte-table">
<thead>
<tr>
<th><%= t("reports.print.trends.month") %></th>
<th class="tufte-right"><%= t("reports.print.trends.income") %></th>
<th class="tufte-right"><%= t("reports.print.trends.expenses") %></th>
<th class="tufte-right"><%= t("reports.print.trends.net") %></th>
<th class="tufte-right"><%= t("reports.print.trends.savings_rate") %></th>
</tr>
</thead>
<tbody>
<% @trends_data.each do |trend| %>
<tr class="<%= trend[:is_current_month] ? "tufte-highlight" : "" %>">
<td><%= trend[:month] %><%= trend[:is_current_month] ? " *" : "" %></td>
<td class="tufte-right tufte-income"><%= Money.new(trend[:income], Current.family.currency).format %></td>
<td class="tufte-right tufte-expense"><%= Money.new(trend[:expenses], Current.family.currency).format %></td>
<td class="tufte-right <%= trend[:net] >= 0 ? "tufte-income" : "tufte-expense" %>"><%= Money.new(trend[:net], Current.family.currency).format %></td>
<td class="tufte-right">
<% month_savings_rate = trend[:income] > 0 ? ((trend[:net].to_f / trend[:income].to_f) * 100).round(0) : 0 %>
<%= month_savings_rate %>%
</td>
</tr>
<% end %>
</tbody>
<tfoot>
<%
total_income = @trends_data.sum { |t| t[:income].to_d }
total_expenses = @trends_data.sum { |t| t[:expenses].to_d }
total_net = @trends_data.sum { |t| t[:net].to_d }
trends_count = @trends_data.length
avg_income = trends_count > 0 ? (total_income / trends_count) : 0
avg_expenses = trends_count > 0 ? (total_expenses / trends_count) : 0
avg_net = trends_count > 0 ? (total_net / trends_count) : 0
overall_savings_rate = total_income > 0 ? ((total_net / total_income) * 100).round(0) : 0
%>
<tr>
<td><%= t("reports.print.trends.average") %></td>
<td class="tufte-right tufte-income"><%= Money.new(avg_income, Current.family.currency).format %></td>
<td class="tufte-right tufte-expense"><%= Money.new(avg_expenses, Current.family.currency).format %></td>
<td class="tufte-right <%= avg_net >= 0 ? "tufte-income" : "tufte-expense" %>"><%= Money.new(avg_net, Current.family.currency).format %></td>
<td class="tufte-right"><%= overall_savings_rate %>%</td>
</tr>
</tfoot>
</table>
<% if @trends_data.any? { |t| t[:is_current_month] } %>
<p class="tufte-footnote"><%= t("reports.print.trends.current_month_note") %></p>
<% end %>
</section>
<% end %>
<%# Investments %>
<% if @investment_metrics[:has_investments] %>
<section class="tufte-section">
<h2 class="tufte-section-title"><%= t("reports.print.investments.title") %></h2>
<div class="tufte-metric-row">
<div class="tufte-metric-card tufte-metric-card-sm">
<span class="tufte-metric-card-label"><%= t("reports.print.investments.portfolio_value") %></span>
<span class="tufte-metric-card-value"><%= format_money(@investment_metrics[:portfolio_value]) %></span>
<% if has_sparkline_data?(@trends_data) %>
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
<polyline points="<%= sparkline_points(cumulative_net_values(@trends_data), width: 60, height: 20) %>" fill="none" stroke="#6366f1" stroke-width="1.5" />
</svg>
<% end %>
</div>
<% if @investment_metrics[:unrealized_trend] %>
<div class="tufte-metric-card tufte-metric-card-sm">
<span class="tufte-metric-card-label"><%= t("reports.print.investments.total_return") %></span>
<span class="tufte-metric-card-value" style="color: <%= @investment_metrics[:unrealized_trend].color %>">
<%= @investment_metrics[:unrealized_trend].value >= 0 ? "+" : "" %><%= format_money(Money.new(@investment_metrics[:unrealized_trend].value, Current.family.currency)) %>
</span>
<span class="tufte-metric-card-change" style="color: <%= @investment_metrics[:unrealized_trend].color %>">
<%= @investment_metrics[:unrealized_trend].percent_formatted %>
</span>
</div>
<% end %>
<div class="tufte-metric-card tufte-metric-card-sm">
<span class="tufte-metric-card-label"><%= t("reports.print.investments.contributions") %></span>
<span class="tufte-metric-card-value tufte-income"><%= format_money(@investment_metrics[:period_contributions]) %></span>
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.investments.this_period") %></span>
</div>
<div class="tufte-metric-card tufte-metric-card-sm">
<span class="tufte-metric-card-label"><%= t("reports.print.investments.withdrawals") %></span>
<span class="tufte-metric-card-value tufte-expense"><%= format_money(@investment_metrics[:period_withdrawals]) %></span>
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.investments.this_period") %></span>
</div>
</div>
<% if @investment_metrics[:top_holdings].any? %>
<h3 class="tufte-subsection"><%= t("reports.print.investments.top_holdings") %></h3>
<table class="tufte-table tufte-compact">
<thead>
<tr>
<th><%= t("reports.print.investments.holding") %></th>
<th class="tufte-right"><%= t("reports.print.investments.weight") %></th>
<th class="tufte-right"><%= t("reports.print.investments.value") %></th>
<th class="tufte-right"><%= t("reports.print.investments.return") %></th>
</tr>
</thead>
<tbody>
<% @investment_metrics[:top_holdings].each do |holding| %>
<tr>
<td><strong><%= holding.ticker %></strong> <span class="tufte-muted"><%= truncate(holding.name, length: 25) %></span></td>
<td class="tufte-right"><%= number_to_percentage(holding.weight || 0, precision: 1) %></td>
<td class="tufte-right"><%= format_money(holding.amount_money) %></td>
<td class="tufte-right">
<% if holding.trend %>
<span style="color: <%= holding.trend.color %>"><%= holding.trend.percent_formatted %></span>
<% else %>
<span class="tufte-muted">—</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
</section>
<% end %>
<%# Spending by Category %>
<% if @transactions.any? %>
<section class="tufte-section tufte-keep-together">
<h2 class="tufte-section-title"><%= t("reports.print.spending.title") %></h2>
<%
income_groups = @transactions.select { |g| g[:type] == "income" }
expense_groups = @transactions.select { |g| g[:type] == "expense" }
income_total = income_groups.sum { |g| g[:total] }
expense_total = expense_groups.sum { |g| g[:total] }
%>
<div class="tufte-two-col">
<% if income_groups.any? %>
<div>
<h3 class="tufte-subsection"><%= t("reports.print.spending.income") %> <span class="tufte-income"><%= Money.new(income_total, Current.family.currency).format %></span></h3>
<table class="tufte-table tufte-compact">
<thead>
<tr>
<th><%= t("reports.print.spending.category") %></th>
<th class="tufte-right"><%= t("reports.print.spending.amount") %></th>
<th class="tufte-right"><%= t("reports.print.spending.percent") %></th>
</tr>
</thead>
<tbody>
<% income_groups.first(8).each do |group| %>
<% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(0) %>
<tr>
<td>
<span class="tufte-dot" style="background: <%= group[:category_color] %>"></span>
<%= group[:category_name] %>
</td>
<td class="tufte-right"><%= Money.new(group[:total], Current.family.currency).format %></td>
<td class="tufte-right tufte-muted"><%= percentage %>%</td>
</tr>
<% end %>
<% if income_groups.length > 8 %>
<tr>
<td class="tufte-muted"><%= t("reports.print.spending.more_categories", count: income_groups.length - 8) %></td>
<td></td>
<td></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% if expense_groups.any? %>
<div>
<h3 class="tufte-subsection"><%= t("reports.print.spending.expenses") %> <span class="tufte-expense"><%= Money.new(expense_total, Current.family.currency).format %></span></h3>
<table class="tufte-table tufte-compact">
<thead>
<tr>
<th><%= t("reports.print.spending.category") %></th>
<th class="tufte-right"><%= t("reports.print.spending.amount") %></th>
<th class="tufte-right"><%= t("reports.print.spending.percent") %></th>
</tr>
</thead>
<tbody>
<% expense_groups.first(8).each do |group| %>
<% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(0) %>
<tr>
<td>
<span class="tufte-dot" style="background: <%= group[:category_color] %>"></span>
<%= group[:category_name] %>
</td>
<td class="tufte-right"><%= Money.new(group[:total], Current.family.currency).format %></td>
<td class="tufte-right tufte-muted"><%= percentage %>%</td>
</tr>
<% end %>
<% if expense_groups.length > 8 %>
<tr>
<td class="tufte-muted"><%= t("reports.print.spending.more_categories", count: expense_groups.length - 8) %></td>
<td></td>
<td></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</section>
<% end %>
<footer class="tufte-footer">
<%= product_name %> · <%= @start_date.strftime("%B %Y") %> <%= @end_date.strftime("%B %Y") %>
</footer>
</div>

View File

@@ -30,7 +30,9 @@ nav_sections = [
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: "Providers", path: settings_providers_path, icon: "plug" },
{ label: t(".imports_label"), path: imports_path, icon: "download" }
{ label: t(".imports_label"), path: imports_path, icon: "download" },
{ label: "SSO Providers", path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? },
{ label: "Users", path: admin_users_path, icon: "users", if: Current.user&.super_admin? }
]
} : nil
),

View File

@@ -0,0 +1,64 @@
<% env_configured = ENV["SIMPLEFIN_INCLUDE_PENDING"].present? || ENV["PLAID_INCLUDE_PENDING"].present? %>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="text-sm"><%= t(".include_pending_label") %></p>
<p class="text-secondary text-sm"><%= t(".include_pending_description") %></p>
</div>
<%= styled_form_with model: Setting.new,
url: settings_hosting_path,
method: :patch,
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
<%= form.toggle :syncs_include_pending,
checked: Setting.syncs_include_pending,
disabled: env_configured,
data: { auto_submit_form_target: "auto" } %>
<% end %>
</div>
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="text-sm"><%= t(".auto_sync_label") %></p>
<p class="text-secondary text-sm"><%= t(".auto_sync_description") %></p>
</div>
<%= styled_form_with model: Setting.new,
url: settings_hosting_path,
method: :patch,
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
<%= form.toggle :auto_sync_enabled,
checked: Setting.auto_sync_enabled,
data: { auto_submit_form_target: "auto" } %>
<% end %>
</div>
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="text-sm"><%= t(".auto_sync_time_label") %></p>
<p class="text-secondary text-sm"><%= t(".auto_sync_time_description") %></p>
</div>
<%= form_with model: Setting.new,
url: settings_hosting_path,
method: :patch,
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
<%= form.time_field :auto_sync_time,
value: Setting.auto_sync_time,
disabled: !Setting.auto_sync_enabled,
class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container text-primary w-full",
data: { auto_submit_form_target: "auto" } %>
<% end %>
</div>
<% if env_configured %>
<div class="bg-warning-50 border border-warning-200 rounded-lg p-3">
<div class="flex items-start gap-2">
<%= icon("alert-circle", class: "w-5 h-5 text-warning-600 mt-0.5 shrink-0") %>
<p class="text-sm text-warning-800">
<%= t(".env_configured_message") %>
</p>
</div>
</div>
<% end %>
</div>

View File

@@ -16,6 +16,9 @@
<% end %>
</div>
<% end %>
<%= settings_section title: t(".sync_settings") do %>
<%= render "settings/hostings/sync_settings" %>
<% end %>
<%= settings_section title: t(".invites") do %>
<%= render "settings/hostings/invite_code_settings" %>
<% end %>

View File

@@ -44,3 +44,58 @@
</div>
</div>
<% end %>
<% if @oidc_identities.any? || AuthConfig.sso_providers.any? %>
<%= settings_section title: t(".sso_title"), subtitle: t(".sso_subtitle") do %>
<% if @oidc_identities.any? %>
<div class="space-y-2">
<% @oidc_identities.each do |identity| %>
<div class="flex items-center justify-between bg-container p-4 shadow-border-xs rounded-lg">
<div class="flex items-center gap-3">
<div class="w-9 h-9 shrink-0 bg-surface rounded-full flex items-center justify-center">
<%= icon identity.provider_config&.dig(:icon) || "key", class: "w-5 h-5 text-secondary" %>
</div>
<div>
<p class="font-medium text-primary"><%= identity.provider_config&.dig(:label) || identity.provider.titleize %></p>
<p class="text-sm text-secondary"><%= identity.info&.dig("email") || t(".sso_no_email") %></p>
<p class="text-xs text-secondary">
<%= t(".sso_last_used") %>:
<%= identity.last_authenticated_at&.to_fs(:short) || t(".sso_never") %>
</p>
</div>
</div>
<% if @oidc_identities.count > 1 || Current.user.password_digest.present? %>
<%= render DS::Button.new(
text: t(".sso_disconnect"),
variant: "outline",
size: "sm",
href: settings_sso_identity_path(identity),
method: :delete,
confirm: CustomConfirm.new(
title: t(".sso_confirm_title"),
body: t(".sso_confirm_body", provider: identity.provider_config&.dig(:label) || identity.provider.titleize),
btn_text: t(".sso_confirm_button"),
destructive: true
)
) %>
<% end %>
</div>
<% end %>
</div>
<% if @oidc_identities.count == 1 && Current.user.password_digest.blank? %>
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div class="flex items-start gap-2">
<%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0 mt-0.5" %>
<p class="text-sm text-amber-800"><%= t(".sso_warning_message") %></p>
</div>
</div>
<% end %>
<% else %>
<div class="text-center py-6">
<%= icon "link", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
<p class="text-secondary"><%= t(".sso_no_identities") %></p>
<p class="text-sm text-secondary mt-2"><%= t(".sso_connect_hint") %></p>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -104,6 +104,25 @@
<%= icon "alert-circle", size: "sm", color: "warning" %>
<%= tag.span stale_status[:message], class: "text-sm" %>
</div>
<% elsif (pending_status = simplefin_item.stale_pending_status)[:count] > 0 %>
<div class="text-secondary">
<div class="flex items-center gap-1">
<%= icon "clock", size: "sm", color: "secondary" %>
<%= tag.span pending_status[:message], class: "text-sm" %>
<span class="text-xs text-tertiary"><%= t(".stale_pending_note") %></span>
</div>
<% if pending_status[:accounts]&.any? %>
<div class="text-xs text-tertiary ml-5">
<%= t(".stale_pending_accounts", accounts: pending_status[:accounts].join(", ")) %>
</div>
<% end %>
</div>
<% elsif (reconciled_status = simplefin_item.last_sync_reconciled_status)[:count] > 0 %>
<div class="text-success flex items-center gap-1">
<%= icon "check-circle", size: "sm", color: "success" %>
<%= tag.span reconciled_status[:message], class: "text-sm" %>
<span class="text-xs text-tertiary"><%= t(".reconciled_details_note") %></span>
</div>
<% elsif simplefin_item.rate_limited_message.present? %>
<div class="text-warning flex items-center gap-1">
<%= icon "clock", size: "sm", color: "warning" %>
@@ -117,7 +136,7 @@
<% elsif duplicate_only_errors %>
<div class="text-secondary flex items-center gap-1">
<%= icon "info", size: "sm" %>
<%= tag.span "Some accounts were skipped as duplicates — use Link existing accounts to merge.", class: "text-secondary" %>
<%= tag.span t(".duplicate_accounts_skipped"), class: "text-secondary" %>
</div>
<% else %>
<p class="text-secondary">

View File

@@ -36,7 +36,7 @@
<% end %>
</div>
<div class="flex md:hidden items-center gap-1 col-span-2 relative">
<%= render "transactions/transaction_category", transaction: transaction %>
<%= render "transactions/transaction_category", transaction: transaction, variant: "mobile" %>
<% if transaction.merchant&.logo_url.present? %>
<%= image_tag transaction.merchant.logo_url,
class: "w-5 h-5 rounded-full absolute -bottom-1 -right-1 border border-secondary pointer-events-none",
@@ -78,14 +78,36 @@
</span>
<% end %>
<%# Investment activity label badge %>
<% if transaction.investment_activity_label.present? %>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-alpha-black-50 text-secondary" title="<%= t("transactions.transaction.activity_type_tooltip") %>">
<%= t("transactions.activity_labels.#{transaction.investment_activity_label.parameterize(separator: '_')}") %>
</span>
<% end %>
<%# Pending indicator %>
<% if transaction.pending? %>
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary text-secondary" title="Pending — may change when posted">
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary text-secondary" title="<%= t("transactions.transaction.pending_tooltip") %>">
<%= icon "clock", size: "sm", color: "current" %>
Pending
<%= t("transactions.transaction.pending") %>
</span>
<% end %>
<%# Potential duplicate indicator - different styling for low vs medium confidence %>
<% if transaction.has_potential_duplicate? %>
<% if transaction.low_confidence_duplicate? %>
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary bg-surface-inset text-secondary" title="<%= t("transactions.transaction.review_recommended_tooltip") %>">
<%= icon "help-circle", size: "sm", color: "current" %>
<%= t("transactions.transaction.review_recommended") %>
</span>
<% else %>
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-warning bg-warning/10 text-warning" title="<%= t("transactions.transaction.potential_duplicate_tooltip") %>">
<%= icon "alert-triangle", size: "sm", color: "current" %>
<%= t("transactions.transaction.possible_duplicate") %>
</span>
<% end %>
<% end %>
<% if transaction.transfer.present? %>
<%= render "transactions/transfer_match", transaction: transaction %>
<% end %>
@@ -122,7 +144,7 @@
</div>
<div class="hidden md:flex items-center gap-1 col-span-2">
<%= render "transactions/transaction_category", transaction: transaction %>
<%= render "transactions/transaction_category", transaction: transaction, variant: "desktop" %>
</div>
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto text-right">

View File

@@ -1,6 +1,6 @@
<%# locals: (transaction:) %>
<%# locals: (transaction:, variant:) %>
<div id="<%= dom_id(transaction, "category_menu") %>">
<div id="<%= dom_id(transaction, "category_menu_#{variant}") %>">
<% if transaction.transfer&.categorizable? || transaction.transfer.nil? %>
<%= render "categories/menu", transaction: transaction %>
<% else %>

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