diff --git a/.env.example b/.env.example
index 1912a9260..56568fc88 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 66ceff881..955d15652 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -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"
diff --git a/CLAUDE.md b/CLAUDE.md
index f543d5bd9..eb1688d90 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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"]`).
diff --git a/Gemfile b/Gemfile
index ffd651e67..b571c3c8f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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"
diff --git a/Gemfile.lock b/Gemfile.lock
index 86435af42..c50c69d92 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/README.md b/README.md
index 48654668d..876a0f452 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+[](https://deepwiki.com/we-promise/sure)
+[](https://app.dosu.dev/a72bdcfd-15f5-4edc-bd85-ea0daa6c3adc/ask)
diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css
index 938f57d5c..c7439e481 100644
--- a/app/assets/tailwind/application.css
+++ b/app/assets/tailwind/application.css
@@ -12,6 +12,7 @@
@import "./google-sign-in.css";
@import "./date-picker-dark-mode.css";
+@import "./print-report.css";
@layer components {
.pcr-app{
diff --git a/app/assets/tailwind/print-report.css b/app/assets/tailwind/print-report.css
new file mode 100644
index 000000000..1917349a9
--- /dev/null
+++ b/app/assets/tailwind/print-report.css
@@ -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;
+ }
+}
diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb
index f46053401..4c586a09d 100644
--- a/app/components/UI/account/activity_feed.html.erb
+++ b/app/components/UI/account/activity_feed.html.erb
@@ -46,6 +46,42 @@
"data-auto-submit-form-target": "auto" %>
+
+ <%= 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 %>
+
+
<%= t("accounts.show.activity.status") %>
+
+ <%= 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" %>
+
+
+ <%= 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" %>
+
+
+ <% end %>
+ <% end %>
<% end %>
<%= button_tag type: "button",
id: "toggle-checkboxes-button",
diff --git a/app/components/provider_sync_summary.html.erb b/app/components/provider_sync_summary.html.erb
index 488a86004..184671c8e 100644
--- a/app/components/provider_sync_summary.html.erb
+++ b/app/components/provider_sync_summary.html.erb
@@ -69,6 +69,102 @@
<% end %>
+ <%# Pending→posted reconciliation %>
+ <% if has_pending_reconciled? %>
+
+
+ <%= helpers.icon "check-circle", size: "sm", color: "success" %>
+ <%= t("provider_sync_summary.health.pending_reconciled", count: pending_reconciled) %>
+
+ <% if pending_reconciled_details.any? %>
+
+
+ <%= t("provider_sync_summary.health.view_reconciled") %>
+
+
+ <% pending_reconciled_details.each do |detail| %>
+
+ <%= detail["account_name"] %>: <%= detail["pending_name"] %>
+
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
+ <%# Duplicate suggestions needing review %>
+ <% if has_duplicate_suggestions_created? %>
+
+
+ <%= helpers.icon "alert-triangle", size: "sm", color: "warning" %>
+ <%= t("provider_sync_summary.health.duplicate_suggestions", count: duplicate_suggestions_created) %>
+
+ <% if duplicate_suggestions_details.any? %>
+
+
+ <%= t("provider_sync_summary.health.view_duplicate_suggestions") %>
+
+
+ <% duplicate_suggestions_details.each do |detail| %>
+
+ <%= detail["account_name"] %>: <%= detail["pending_name"] %> → <%= detail["posted_name"] %>
+
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
+ <%# Stale pending transactions (auto-excluded) %>
+ <% if has_stale_pending? %>
+
+
+ <%= helpers.icon "clock", size: "sm", color: "warning" %>
+ <%= t("provider_sync_summary.health.stale_pending", count: stale_pending_excluded) %>
+
+ <% if stale_pending_details.any? %>
+
+
+ <%= t("provider_sync_summary.health.view_stale_pending") %>
+
+
+ <% stale_pending_details.each do |detail| %>
+
+ <%= detail["account_name"] %>: <%= t("provider_sync_summary.health.stale_pending_count", count: detail["count"]) %>
+
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
+ <%# Stale unmatched pending (need manual review) %>
+ <% if has_stale_unmatched_pending? %>
+
+
+ <%= helpers.icon "help-circle", size: "sm" %>
+ <%= t("provider_sync_summary.health.stale_unmatched", count: stale_unmatched_pending) %>
+
+ <% if stale_unmatched_details.any? %>
+
+
+ <%= t("provider_sync_summary.health.view_stale_unmatched") %>
+
+
+ <% stale_unmatched_details.each do |detail| %>
+
+ <%= detail["account_name"] %>: <%= t("provider_sync_summary.health.stale_unmatched_count", count: detail["count"]) %>
+
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
<%# Data quality warnings %>
<% if has_data_quality_issues? %>
diff --git a/app/components/provider_sync_summary.rb b/app/components/provider_sync_summary.rb
index 4d00f2343..19d9aceb7 100644
--- a/app/components/provider_sync_summary.rb
+++ b/app/components/provider_sync_summary.rb
@@ -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
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 48e484f40..1524b25d0 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -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")
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
new file mode 100644
index 000000000..7e0252491
--- /dev/null
+++ b/app/controllers/admin/base_controller.rb
@@ -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
diff --git a/app/controllers/admin/sso_providers_controller.rb b/app/controllers/admin/sso_providers_controller.rb
new file mode 100644
index 000000000..97bc86fa3
--- /dev/null
+++ b/app/controllers/admin/sso_providers_controller.rb
@@ -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
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
new file mode 100644
index 000000000..fdbc6e281
--- /dev/null
+++ b/app/controllers/admin/users_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/chats_controller.rb b/app/controllers/api/v1/chats_controller.rb
index 87094c26d..ad1a2b228 100644
--- a/app/controllers/api/v1/chats_controller.rb
+++ b/app/controllers/api/v1/chats_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/merchants_controller.rb b/app/controllers/api/v1/merchants_controller.rb
new file mode 100644
index 000000000..53df0ac35
--- /dev/null
+++ b/app/controllers/api/v1/merchants_controller.rb
@@ -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
] 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
diff --git a/app/controllers/api/v1/messages_controller.rb b/app/controllers/api/v1/messages_controller.rb
index 305ee09df..f9f3b8388 100644
--- a/app/controllers/api/v1/messages_controller.rb
+++ b/app/controllers/api/v1/messages_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb
new file mode 100644
index 000000000..287642930
--- /dev/null
+++ b/app/controllers/api/v1/tags_controller.rb
@@ -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] 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
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index d63adfa8a..b171a080d 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -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
diff --git a/app/controllers/holdings_controller.rb b/app/controllers/holdings_controller.rb
index 96ce2cd49..cd6d6260c 100644
--- a/app/controllers/holdings_controller.rb
+++ b/app/controllers/holdings_controller.rb
@@ -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
diff --git a/app/controllers/oidc_accounts_controller.rb b/app/controllers/oidc_accounts_controller.rb
index c85c4dbec..6e22a1c93 100644
--- a/app/controllers/oidc_accounts_controller.rb
+++ b/app/controllers/oidc_accounts_controller.rb
@@ -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)
diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb
index bfb833acb..91593f801 100644
--- a/app/controllers/reports_controller.rb
+++ b/app/controllers/reports_controller.rb
@@ -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|
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index f42fac451..2d9007668 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -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
diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb
index 9300a0b2c..dd4024d7b 100644
--- a/app/controllers/settings/hostings_controller.rb
+++ b/app/controllers/settings/hostings_controller.rb
@@ -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
diff --git a/app/controllers/settings/securities_controller.rb b/app/controllers/settings/securities_controller.rb
index 756accf79..fd6791994 100644
--- a/app/controllers/settings/securities_controller.rb
+++ b/app/controllers/settings/securities_controller.rb
@@ -6,5 +6,6 @@ class Settings::SecuritiesController < ApplicationController
[ "Home", root_path ],
[ "Security", nil ]
]
+ @oidc_identities = Current.user.oidc_identities.order(:provider)
end
end
diff --git a/app/controllers/settings/sso_identities_controller.rb b/app/controllers/settings/sso_identities_controller.rb
new file mode 100644
index 000000000..97f946a90
--- /dev/null
+++ b/app/controllers/settings/sso_identities_controller.rb
@@ -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
diff --git a/app/controllers/transaction_categories_controller.rb b/app/controllers/transaction_categories_controller.rb
index 3fe8207dc..d8fc64e96 100644
--- a/app/controllers/transaction_categories_controller.rb
+++ b/app/controllers/transaction_categories_controller.rb
@@ -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}",
diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb
index 07e829ebd..753b77300 100644
--- a/app/controllers/transactions_controller.rb
+++ b/app/controllers/transactions_controller.rb
@@ -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
diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb
index 98c35f4c1..65fb550c3 100644
--- a/app/helpers/languages_helper.rb
+++ b/app/helpers/languages_helper.rb
@@ -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 = {
diff --git a/app/helpers/reports_helper.rb b/app/helpers/reports_helper.rb
new file mode 100644
index 000000000..52b3016b5
--- /dev/null
+++ b/app/helpers/reports_helper.rb
@@ -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
diff --git a/app/helpers/transactions_helper.rb b/app/helpers/transactions_helper.rb
index 024d742be..92ce26c81 100644
--- a/app/helpers/transactions_helper.rb
+++ b/app/helpers/transactions_helper.rb
@@ -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
diff --git a/app/javascript/controllers/admin_sso_form_controller.js b/app/javascript/controllers/admin_sso_form_controller.js
new file mode 100644
index 000000000..2344b8b63
--- /dev/null
+++ b/app/javascript/controllers/admin_sso_form_controller.js
@@ -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 = ' 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 = ' 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'
+ }
+ }
+}
diff --git a/app/javascript/controllers/cost_basis_form_controller.js b/app/javascript/controllers/cost_basis_form_controller.js
new file mode 100644
index 000000000..467cd5a9a
--- /dev/null
+++ b/app/javascript/controllers/cost_basis_form_controller.js
@@ -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)
+ }
+}
diff --git a/app/javascript/controllers/drawer_cost_basis_controller.js b/app/javascript/controllers/drawer_cost_basis_controller.js
new file mode 100644
index 000000000..6091bc9cd
--- /dev/null
+++ b/app/javascript/controllers/drawer_cost_basis_controller.js
@@ -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)
+ }
+}
diff --git a/app/jobs/simplefin_connection_update_job.rb b/app/jobs/simplefin_connection_update_job.rb
index 850eaac8b..db4593762 100644
--- a/app/jobs/simplefin_connection_update_job.rb
+++ b/app/jobs/simplefin_connection_update_job.rb
@@ -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
diff --git a/app/middleware/omniauth_error_handler.rb b/app/middleware/omniauth_error_handler.rb
new file mode 100644
index 000000000..8373cb760
--- /dev/null
+++ b/app/middleware/omniauth_error_handler.rb
@@ -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
diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb
index 52964b165..dac0da8d4 100644
--- a/app/models/account/provider_import_adapter.rb
+++ b/app/models/account/provider_import_adapter.rb
@@ -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 it’s 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
diff --git a/app/models/assistant/function/get_holdings.rb b/app/models/assistant/function/get_holdings.rb
index 515888e8a..4cc277072 100644
--- a/app/models/assistant/function/get_holdings.rb
+++ b/app/models/assistant/function/get_holdings.rb
@@ -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
}
diff --git a/app/models/coinstats_item/syncer.rb b/app/models/coinstats_item/syncer.rb
index 459fa0926..76f7343bb 100644
--- a/app/models/coinstats_item/syncer.rb
+++ b/app/models/coinstats_item/syncer.rb
@@ -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,
diff --git a/app/models/entry.rb b/app/models/entry.rb
index 3a6672b7d..533e389e2 100644
--- a/app/models/entry.rb
+++ b/app/models/entry.rb
@@ -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
diff --git a/app/models/entry_search.rb b/app/models/entry_search.rb
index 07793df47..b082cac34 100644
--- a/app/models/entry_search.rb
+++ b/app/models/entry_search.rb
@@ -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
diff --git a/app/models/holding.rb b/app/models/holding.rb
index 0b7f602af..fd7fe4284 100644
--- a/app/models/holding.rb
+++ b/app/models/holding.rb
@@ -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
diff --git a/app/models/holding/cost_basis_reconciler.rb b/app/models/holding/cost_basis_reconciler.rb
new file mode 100644
index 000000000..88bd9b635
--- /dev/null
+++ b/app/models/holding/cost_basis_reconciler.rb
@@ -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
diff --git a/app/models/holding/materializer.rb b/app/models/holding/materializer.rb
index fee335fd6..3fb704ca7 100644
--- a/app/models/holding/materializer.rb
+++ b/app/models/holding/materializer.rb
@@ -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
diff --git a/app/models/import.rb b/app/models/import.rb
index fb8c72686..866e4b05c 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -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
diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb
index 6c4e16b90..83aa2c9fd 100644
--- a/app/models/income_statement.rb
+++ b/app/models/income_statement.rb
@@ -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
diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb
index 3daf69c5a..f4eb2815f 100644
--- a/app/models/income_statement/category_stats.rb
+++ b/app/models/income_statement/category_stats.rb
@@ -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
diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb
index a2509b114..48b0d9507 100644
--- a/app/models/income_statement/family_stats.rb
+++ b/app/models/income_statement/family_stats.rb
@@ -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
diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb
index 355212486..758ae6be3 100644
--- a/app/models/income_statement/totals.rb
+++ b/app/models/income_statement/totals.rb
@@ -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
diff --git a/app/models/investment_statement.rb b/app/models/investment_statement.rb
index ee8daacfa..11cfdffa4 100644
--- a/app/models/investment_statement.rb
+++ b/app/models/investment_statement.rb
@@ -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
diff --git a/app/models/oidc_identity.rb b/app/models/oidc_identity.rb
index 78276546d..e8993142f 100644
--- a/app/models/oidc_identity.rb
+++ b/app/models/oidc_identity.rb
@@ -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
diff --git a/app/models/plaid_account/investments/transactions_processor.rb b/app/models/plaid_account/investments/transactions_processor.rb
index 922a3f2a6..d90657ce6 100644
--- a/app/models/plaid_account/investments/transactions_processor.rb
+++ b/app/models/plaid_account/investments/transactions_processor.rb
@@ -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
diff --git a/app/models/plaid_account/transactions/processor.rb b/app/models/plaid_account/transactions/processor.rb
index 90e55d2d9..a59045a37 100644
--- a/app/models/plaid_account/transactions/processor.rb
+++ b/app/models/plaid_account/transactions/processor.rb
@@ -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
diff --git a/app/models/plaid_entry/processor.rb b/app/models/plaid_entry/processor.rb
index e3bc8c74c..c0a890038 100644
--- a/app/models/plaid_entry/processor.rb
+++ b/app/models/plaid_entry/processor.rb
@@ -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
diff --git a/app/models/provider/coinstats.rb b/app/models/provider/coinstats.rb
index e9d406ab9..0fc0d8695 100644
--- a/app/models/provider/coinstats.rb
+++ b/app/models/provider/coinstats.rb
@@ -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
diff --git a/app/models/provider/openai/auto_categorizer.rb b/app/models/provider/openai/auto_categorizer.rb
index 1d369e4f3..36cdf80bf 100644
--- a/app/models/provider/openai/auto_categorizer.rb
+++ b/app/models/provider/openai/auto_categorizer.rb
@@ -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
diff --git a/app/models/provider/openai/auto_merchant_detector.rb b/app/models/provider/openai/auto_merchant_detector.rb
index 3de15709d..a64dc17fa 100644
--- a/app/models/provider/openai/auto_merchant_detector.rb
+++ b/app/models/provider/openai/auto_merchant_detector.rb
@@ -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
diff --git a/app/models/rule/action_executor/set_investment_activity_label.rb b/app/models/rule/action_executor/set_investment_activity_label.rb
new file mode 100644
index 000000000..21e421292
--- /dev/null
+++ b/app/models/rule/action_executor/set_investment_activity_label.rb
@@ -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
diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb
index d051b5837..fac1d6667 100644
--- a/app/models/rule/registry/transaction_resource.rb
+++ b/app/models/rule/registry/transaction_resource.rb
@@ -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)
]
diff --git a/app/models/setting.rb b/app/models/setting.rb
index 1b5706171..4407d7293 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -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
diff --git a/app/models/simplefin_account/processor.rb b/app/models/simplefin_account/processor.rb
index 569f515cd..37d211673 100644
--- a/app/models/simplefin_account/processor.rb
+++ b/app/models/simplefin_account/processor.rb
@@ -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)
diff --git a/app/models/simplefin_entry/processor.rb b/app/models/simplefin_entry/processor.rb
index 4ef68449e..db4b5689b 100644
--- a/app/models/simplefin_entry/processor.rb
+++ b/app/models/simplefin_entry/processor.rb
@@ -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
diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb
index 45acd07b7..daabf99b3 100644
--- a/app/models/simplefin_item.rb
+++ b/app/models/simplefin_item.rb
@@ -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
diff --git a/app/models/simplefin_item/importer.rb b/app/models/simplefin_item/importer.rb
index f4d3d9e3a..c465bf951 100644
--- a/app/models/simplefin_item/importer.rb
+++ b/app/models/simplefin_item/importer.rb
@@ -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
diff --git a/app/models/sso_audit_log.rb b/app/models/sso_audit_log.rb
new file mode 100644
index 000000000..21aa4e05e
--- /dev/null
+++ b/app/models/sso_audit_log.rb
@@ -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
diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb
new file mode 100644
index 000000000..21e3dbf33
--- /dev/null
+++ b/app/models/sso_provider.rb
@@ -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
diff --git a/app/models/sso_provider_tester.rb b/app/models/sso_provider_tester.rb
new file mode 100644
index 000000000..0464088c4
--- /dev/null
+++ b/app/models/sso_provider_tester.rb
@@ -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
diff --git a/app/models/transaction.rb b/app/models/transaction.rb
index dd8eb3064..e1a86afb5 100644
--- a/app/models/transaction.rb
+++ b/app/models/transaction.rb
@@ -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)
diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb
index 8dc3efcb5..e46a66472 100644
--- a/app/models/transaction/search.rb
+++ b/app/models/transaction/search.rb
@@ -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
diff --git a/app/models/transfer.rb b/app/models/transfer.rb
index 93de0a068..d2dcbf667 100644
--- a/app/models/transfer.rb
+++ b/app/models/transfer.rb
@@ -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
diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb
new file mode 100644
index 000000000..e232556f6
--- /dev/null
+++ b/app/policies/application_policy.rb
@@ -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
diff --git a/app/policies/sso_provider_policy.rb b/app/policies/sso_provider_policy.rb
new file mode 100644
index 000000000..d68010415
--- /dev/null
+++ b/app/policies/sso_provider_policy.rb
@@ -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
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
new file mode 100644
index 000000000..c40bf6007
--- /dev/null
+++ b/app/policies/user_policy.rb
@@ -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
diff --git a/app/services/auto_sync_scheduler.rb b/app/services/auto_sync_scheduler.rb
new file mode 100644
index 000000000..44adea3b0
--- /dev/null
+++ b/app/services/auto_sync_scheduler.rb
@@ -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
diff --git a/app/services/provider_loader.rb b/app/services/provider_loader.rb
new file mode 100644
index 000000000..e2bf35365
--- /dev/null
+++ b/app/services/provider_loader.rb
@@ -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
diff --git a/app/services/simplefin_item/unlinker.rb b/app/services/simplefin_item/unlinker.rb
deleted file mode 100644
index d676af999..000000000
--- a/app/services/simplefin_item/unlinker.rb
+++ /dev/null
@@ -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
diff --git a/app/views/admin/sso_providers/_form.html.erb b/app/views/admin/sso_providers/_form.html.erb
new file mode 100644
index 000000000..590ba740e
--- /dev/null
+++ b/app/views/admin/sso_providers/_form.html.erb
@@ -0,0 +1,280 @@
+<%# locals: (sso_provider:) %>
+
+<% if sso_provider.errors.any? %>
+
+
+ <%= icon "alert-circle", class: "w-5 h-5 text-destructive mr-2 shrink-0" %>
+
+
+ <%= pluralize(sso_provider.errors.count, "error") %> prohibited this provider from being saved:
+
+
+ <% sso_provider.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+
+
+<% end %>
+
+<%= styled_form_with model: [:admin, sso_provider], class: "space-y-6", data: { controller: "admin-sso-form" } do |form| %>
+
+
Basic Information
+
+
+ <%= 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" } %>
+
+
Unique identifier (lowercase, numbers, underscores only)
+
+
+ <%= form.text_field :label,
+ label: "Button Label",
+ placeholder: "e.g., Sign in with Keycloak",
+ required: true %>
+
+
+ <%= form.text_field :icon,
+ label: "Icon (optional)",
+ placeholder: "e.g., key, shield" %>
+
Lucide icon name for the login button
+
+
+
+
+
+
<%= t("admin.sso_providers.form.enabled_label") %>
+
<%= t("admin.sso_providers.form.enabled_help") %>
+
+ <%= form.toggle :enabled %>
+
+
+
+
+
OAuth/OIDC Configuration
+
+
">
+ <%= form.text_field :issuer,
+ label: "Issuer URL",
+ placeholder: "https://your-idp.example.com/realms/your-realm",
+ data: { action: "blur->admin-sso-form#validateIssuer" } %>
+
OIDC issuer URL (validates .well-known/openid-configuration)
+
+
+ <%= 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? %>
+
Leave blank to keep existing secret
+ <% end %>
+
+
">
+
Callback URL
+
+ <%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %>
+
+ <%= icon "copy", class: "w-4 h-4" %>
+
+
+
Configure this URL in your identity provider
+
+
+
+ ">
+
<%= t("admin.sso_providers.form.saml_configuration") %>
+
+
+
<%= t("admin.sso_providers.form.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">
+
<%= t("admin.sso_providers.form.idp_metadata_url_help") %>
+
+
+
+ <%= t("admin.sso_providers.form.manual_saml_config") %>
+
+
<%= t("admin.sso_providers.form.manual_saml_help") %>
+
+
+ <%= t("admin.sso_providers.form.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">
+
+
+
+ <%= t("admin.sso_providers.form.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">
+
+
+
+
<%= t("admin.sso_providers.form.idp_certificate") %>
+
+
<%= t("admin.sso_providers.form.idp_certificate_help") %>
+
+
+
+ <%= t("admin.sso_providers.form.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">
+
+
+
+ <%= t("admin.sso_providers.form.name_id_format") %>
+
+ ><%= t("admin.sso_providers.form.name_id_email") %>
+ ><%= t("admin.sso_providers.form.name_id_persistent") %>
+ ><%= t("admin.sso_providers.form.name_id_transient") %>
+ ><%= t("admin.sso_providers.form.name_id_unspecified") %>
+
+
+
+
+
+
+
SP Callback URL (ACS URL)
+
+ <%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %>
+
+ <%= icon "copy", class: "w-4 h-4" %>
+
+
+
Configure this URL as the Assertion Consumer Service URL in your IdP
+
+
+
+
+
<%= t("admin.sso_providers.form.provisioning_title") %>
+
+ <%= 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 } %>
+
<%= t("admin.sso_providers.form.default_role_help") %>
+
+
+ <%= t("admin.sso_providers.form.role_mapping_title") %>
+
+
<%= t("admin.sso_providers.form.role_mapping_help") %>
+
+
+
<%= t("admin.sso_providers.form.super_admin_groups") %>
+
"
+ class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
+ placeholder="Platform-Admins, IdP-Superusers"
+ autocomplete="off">
+
<%= t("admin.sso_providers.form.groups_help") %>
+
+
+
+ <%= t("admin.sso_providers.form.admin_groups") %>
+ "
+ class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
+ placeholder="Team-Leads, Managers"
+ autocomplete="off">
+
+
+
+ <%= t("admin.sso_providers.form.member_groups") %>
+ "
+ class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
+ placeholder="* (all groups)"
+ autocomplete="off">
+
+
+
+
+
+ ">
+
<%= t("admin.sso_providers.form.advanced_title") %>
+
+
+ <%= form.text_field "settings[scopes]",
+ label: t("admin.sso_providers.form.scopes_label"),
+ value: sso_provider.settings&.dig("scopes"),
+ placeholder: "openid email profile groups" %>
+
<%= t("admin.sso_providers.form.scopes_help") %>
+
+
+ <%= 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 } %>
+
<%= t("admin.sso_providers.form.prompt_help") %>
+
+
+
+
+ <% if sso_provider.persisted? %>
+
+ <%= t("admin.sso_providers.form.test_connection") %>
+
+
+ <% end %>
+
+
+
+ <%= 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" %>
+
+
+<% end %>
diff --git a/app/views/admin/sso_providers/edit.html.erb b/app/views/admin/sso_providers/edit.html.erb
new file mode 100644
index 000000000..7fc531668
--- /dev/null
+++ b/app/views/admin/sso_providers/edit.html.erb
@@ -0,0 +1,9 @@
+<%= content_for :page_title, "Edit #{@sso_provider.label}" %>
+
+
+
Update configuration for <%= @sso_provider.label %>.
+
+ <%= settings_section title: "Provider Configuration" do %>
+ <%= render "form", sso_provider: @sso_provider %>
+ <% end %>
+
diff --git a/app/views/admin/sso_providers/index.html.erb b/app/views/admin/sso_providers/index.html.erb
new file mode 100644
index 000000000..006dc2647
--- /dev/null
+++ b/app/views/admin/sso_providers/index.html.erb
@@ -0,0 +1,126 @@
+<%= content_for :page_title, "SSO Providers" %>
+
+
+
+ Manage single sign-on authentication providers for your instance.
+ <% unless Flipper.enabled?(:db_sso_providers) %>
+ Changes require a server restart to take effect.
+ <% end %>
+
+
+ <%= settings_section title: "Configured Providers" do %>
+ <% if @sso_providers.any? %>
+
+ <% @sso_providers.each do |provider| %>
+
+
+ <% 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 %>
+
+
<%= provider.label %>
+
<%= provider.strategy.titleize %> · <%= provider.name %>
+
+
+
+ <% if provider.enabled? %>
+
+ Enabled
+
+ <% else %>
+
+ Disabled
+
+ <% 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 %>
+
+
+ <% end %>
+
+ <% else %>
+
+ <%= icon "key", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
+
No SSO providers configured yet.
+
+ <% end %>
+
+
+ <%= 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 %>
+
+ <% end %>
+
+ <% if @legacy_providers.any? %>
+ <%= settings_section title: t("admin.sso_providers.index.legacy_providers_title"), collapsible: true, open: true do %>
+
+
+ <%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0" %>
+
+ <%= t("admin.sso_providers.index.legacy_providers_notice") %>
+
+
+
+
+
+ <% @legacy_providers.each do |provider| %>
+
+
+ <% provider_icon = provider[:icon].presence || "key" %>
+ <%= icon provider_icon, class: "w-5 h-5 text-secondary" %>
+
+
<%= provider[:label].presence || provider[:name] %>
+
+ <%= provider[:strategy].to_s.titleize %> · <%= provider[:name] %>
+ <% if provider[:issuer].present? %>
+ · <%= provider[:issuer] %>
+ <% end %>
+
+
+
+
+
+ <%= t("admin.sso_providers.index.env_configured") %>
+
+
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+ <%= settings_section title: "Configuration Mode", collapsible: true, open: false do %>
+
+
+
+
Database-backed providers
+
Load providers from database instead of YAML config
+
+ <% if Flipper.enabled?(:db_sso_providers) %>
+
+ Enabled
+
+ <% else %>
+
+ Disabled
+
+ <% end %>
+
+
+ Set AUTH_PROVIDERS_SOURCE=db to enable database-backed providers.
+ This allows changes without server restarts.
+
+
+ <% end %>
+
diff --git a/app/views/admin/sso_providers/new.html.erb b/app/views/admin/sso_providers/new.html.erb
new file mode 100644
index 000000000..20be829c8
--- /dev/null
+++ b/app/views/admin/sso_providers/new.html.erb
@@ -0,0 +1,9 @@
+<%= content_for :page_title, "Add SSO Provider" %>
+
+
+
Configure a new single sign-on authentication provider.
+
+ <%= settings_section title: "Provider Configuration" do %>
+ <%= render "form", sso_provider: @sso_provider %>
+ <% end %>
+
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb
new file mode 100644
index 000000000..551cd4d10
--- /dev/null
+++ b/app/views/admin/users/index.html.erb
@@ -0,0 +1,73 @@
+<%= content_for :page_title, t(".title") %>
+
+
+
<%= t(".description") %>
+
+ <%= settings_section title: t(".section_title") do %>
+
+ <% @users.each do |user| %>
+
+
+
+ <%= user.initials %>
+
+
+
<%= user.display_name %>
+
<%= user.email %>
+
+
+
+ <% if user.id == Current.user.id %>
+ <%= t(".you") %>
+
+ <%= t(".roles.#{user.role}") %>
+
+ <% 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 %>
+
+
+ <% end %>
+
+
+ <% if @users.empty? %>
+
+ <%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
+
<%= t(".no_users") %>
+
+ <% end %>
+ <% end %>
+
+ <%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: false do %>
+
+
+
+ <%= t(".roles.member") %>
+
+
<%= t(".role_descriptions.member") %>
+
+
+
+ <%= t(".roles.admin") %>
+
+
<%= t(".role_descriptions.admin") %>
+
+
+
+ <%= t(".roles.super_admin") %>
+
+
<%= t(".role_descriptions.super_admin") %>
+
+
+ <% end %>
+
diff --git a/app/views/holdings/_cost_basis_cell.html.erb b/app/views/holdings/_cost_basis_cell.html.erb
new file mode 100644
index 000000000..24401b8bb
--- /dev/null
+++ b/app/views/holdings/_cost_basis_cell.html.erb
@@ -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 %>
+
+ <%= tag.span format_money(holding.avg_cost) %>
+ <%= icon "lock", size: "xs", class: "text-secondary" %>
+
+ <% 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 %>
+
+ <%= 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" %>
+
+ <% else %>
+
+ <%= icon "pencil", size: "xs" %>
+ Set
+
+ <% end %>
+ <% end %>
+ <% menu.with_custom_content do %>
+
+
+ <%= t(".set_cost_basis_header", ticker: holding.ticker, qty: number_with_precision(holding.qty, precision: 2)) %>
+
+ <%
+ 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| %>
+
+
+
+ = <%= currency.symbol %><%= number_with_precision(current_per_share, precision: 2) || "0.00" %> <%= t(".per_share") %>
+
+
+
+
+
<%= t(".or_per_share_label") %>
+
+ <%= currency.symbol %>
+
+ <%= currency.iso_code %>
+
+
+
+
+
+ <%= t(".cancel") %>
+
+ <%= 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" %>
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb
index 4865af8ee..85d9474ac 100644
--- a/app/views/holdings/_holding.html.erb
+++ b/app/views/holdings/_holding.html.erb
@@ -31,7 +31,7 @@
- <%= 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" %>
@@ -45,13 +45,13 @@
- <%# 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 %>
diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb
index 32918b49e..868b77f1d 100644
--- a/app/views/holdings/show.html.erb
+++ b/app/views/holdings/show.html.erb
@@ -35,16 +35,107 @@
<%= @holding.weight ? number_to_percentage(@holding.weight, precision: 2) : t(".unknown") %>
-
-
<%= t(".avg_cost_label") %>
-
<%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %>
+ <%# 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
+ %>
+
+
+
<%= t(".avg_cost_label") %>
+
+ <%= @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? %>
+ (<%= @holding.cost_basis_source_label %>)
+ <% end %>
+
+ <%= icon "pencil", size: "xs", class: "text-secondary hover:text-primary" %>
+
+
+
+
+ <%# Inline cost basis editor (hidden by default) %>
+
+ <%
+ 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| %>
+
+ <%= t("holdings.cost_basis_cell.set_cost_basis_header", ticker: @holding.ticker, qty: number_with_precision(@holding.qty, precision: 4)) %>
+
+
+
+
+ = <%= currency.symbol %><%= number_with_precision(current_per_share, precision: 2) || "0.00" %> <%= t("holdings.cost_basis_cell.per_share") %>
+
+
+
+
+
<%= t("holdings.cost_basis_cell.or_per_share_label") %>
+
+ <%= currency.symbol %>
+
+ <%= currency.iso_code %>
+
+
+
+
+
+ <%= t("holdings.cost_basis_cell.cancel") %>
+
+ <%= 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" %>
+
+ <% end %>
+
<%= t(".total_return_label") %>
-
- <%= @holding.trend ? render("shared/trend_change", trend: @holding.trend) : t(".unknown") %>
-
+ <% if @holding.trend %>
+
+ <%= render("shared/trend_change", trend: @holding.trend) %>
+
+ <% else %>
+ <%= t(".unknown") %>
+ <% end %>
@@ -85,21 +176,39 @@
<% 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 %>
-
-
-
<%= t(".delete_title") %>
-
<%= t(".delete_subtitle") %>
-
+ <% if @holding.cost_basis_locked? %>
+
+
+
<%= t(".cost_basis_locked_label") %>
+
<%= t(".cost_basis_locked_description") %>
+
- <%= 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 } %>
-
+ <%= 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") } } %>
+
+ <% end %>
+
+ <% if @holding.account.can_delete_holdings? %>
+
+
+
<%= t(".delete_title") %>
+
<%= t(".delete_subtitle") %>
+
+
+ <%= 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 } %>
+
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/imports/_drag_drop_overlay.html.erb b/app/views/imports/_drag_drop_overlay.html.erb
index 504920a86..e737eb25b 100644
--- a/app/views/imports/_drag_drop_overlay.html.erb
+++ b/app/views/imports/_drag_drop_overlay.html.erb
@@ -1,7 +1,11 @@
-
-
- <%= icon("upload", size: "xl", class: "text-primary mb-4 mx-auto w-16 h-16") %>
-
<%= title %>
-
<%= subtitle %>
+
+
+
+
+ <%= icon("upload", class: "text-indigo-500 w-6 h-6") %>
+
+
<%= title %>
+
<%= subtitle %>
+
-
\ No newline at end of file
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 3f19d50ed..5741ff241 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -19,8 +19,7 @@
data-app-layout-user-id-value="<%= Current.user.id %>">
+ data-app-layout-target="mobileSidebar">
<%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
@@ -34,7 +33,7 @@
<%# MOBILE - Top nav %>
-
+
<%= icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
<%= link_to root_path, class: "block" do %>
@@ -45,7 +44,7 @@
<%# DESKTOP - Left navbar %>
-
+
<%= 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 %>
<%= 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 %>
diff --git a/app/views/layouts/print.html.erb b/app/views/layouts/print.html.erb
new file mode 100644
index 000000000..582f139bc
--- /dev/null
+++ b/app/views/layouts/print.html.erb
@@ -0,0 +1,28 @@
+
+
+
+
<%= content_for(:title) || t("reports.print.document_title") %>
+
+ <%= csrf_meta_tags %>
+ <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
+
+
+
+
+
+
+
+ <%= yield %>
+
+
+
+
+
diff --git a/app/views/reports/_investment_performance.html.erb b/app/views/reports/_investment_performance.html.erb
index f88fa9fd2..c727c819a 100644
--- a/app/views/reports/_investment_performance.html.erb
+++ b/app/views/reports/_investment_performance.html.erb
@@ -119,7 +119,7 @@
<% investment_metrics[:accounts].each do |account| %>
-
+ <%= 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 %>
<%= render "accounts/logo", account: account, size: "sm" %>
@@ -128,7 +128,7 @@
<%= format_money(account.balance_money) %>
-
+ <% end %>
<% end %>
diff --git a/app/views/reports/_transactions_breakdown.html.erb b/app/views/reports/_transactions_breakdown.html.erb
index c7c14fffe..200ff60df 100644
--- a/app/views/reports/_transactions_breakdown.html.erb
+++ b/app/views/reports/_transactions_breakdown.html.erb
@@ -1,6 +1,6 @@
<%# Export Controls %>
-
+
<%
# Build params hash for links
base_params = {
diff --git a/app/views/reports/_trends_insights.html.erb b/app/views/reports/_trends_insights.html.erb
index 61364c537..ad95b15c4 100644
--- a/app/views/reports/_trends_insights.html.erb
+++ b/app/views/reports/_trends_insights.html.erb
@@ -79,4 +79,3 @@
<% end %>
-
diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb
index 032547cf5..8940b7551 100644
--- a/app/views/reports/index.html.erb
+++ b/app/views/reports/index.html.erb
@@ -1,103 +1,5 @@
-<% content_for :head do %>
-
-<% end %>
-
<% content_for :page_header do %>
-
+
<%= t("reports.index.title") %>
@@ -115,42 +17,54 @@
<% end %>
<%# Period Navigation Tabs %>
-
- <%= 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
- ) %>
+
+
+ <%= 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
+ ) %>
+
+
+ <%# 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") %>
+
<%= t("reports.index.print_report") %>
+ <% end %>
<%# 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 %>
@@ -182,7 +96,7 @@
<% end %>
-
+
<% if Current.family.transactions.any? %>
<%# Summary Dashboard - Always visible, not collapsible %>
diff --git a/app/views/reports/print.html.erb b/app/views/reports/print.html.erb
new file mode 100644
index 000000000..3add245f0
--- /dev/null
+++ b/app/views/reports/print.html.erb
@@ -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 %>
+
+
+ <%# Header %>
+
+
+ <%# Summary %>
+
+ <%= t("reports.print.summary.title") %>
+
+
+
<%= t("reports.print.summary.income") %>
+
<%= @summary_metrics[:current_income].format %>
+ <% if @summary_metrics[:income_change] && @summary_metrics[:income_change] != 0 %>
+
">
+ <%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:income_change] >= 0 ? "+#{@summary_metrics[:income_change]}" : @summary_metrics[:income_change].to_s)) %>
+
+ <% end %>
+ <% if has_sparkline_data?(@trends_data) %>
+
+
+
+ <% end %>
+
+
+
+
<%= t("reports.print.summary.expenses") %>
+
<%= @summary_metrics[:current_expenses].format %>
+ <% if @summary_metrics[:expense_change] && @summary_metrics[:expense_change] != 0 %>
+
">
+ <%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:expense_change] >= 0 ? "+#{@summary_metrics[:expense_change]}" : @summary_metrics[:expense_change].to_s)) %>
+
+ <% end %>
+ <% if has_sparkline_data?(@trends_data) %>
+
+
+
+ <% end %>
+
+
+
+
<%= t("reports.print.summary.net_savings") %>
+
"><%= @summary_metrics[:net_savings].format %>
+ <%
+ # 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 %>
+
<%= t("reports.print.summary.of_income", percent: savings_rate) %>
+ <% end %>
+ <% if has_sparkline_data?(@trends_data) %>
+
+ " stroke-width="1.5" />
+
+ <% end %>
+
+
+ <% if @summary_metrics[:budget_percent] %>
+
+ <%= t("reports.print.summary.budget") %>
+ <%= @summary_metrics[:budget_percent] %>%
+ <%= t("reports.print.summary.used") %>
+
+ <% end %>
+
+
+
+ <%# Net Worth %>
+ <% if @has_accounts %>
+
+ <%= t("reports.print.net_worth.title") %>
+
+
+
<%= t("reports.print.net_worth.current_balance") %>
+
">
+ <%= @net_worth_metrics[:current_net_worth].format %>
+
+ <% if @net_worth_metrics[:trend] %>
+
+ <%= @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") %>
+
+ <% end %>
+ <% if has_sparkline_data?(@trends_data) %>
+
+ " stroke-width="1.5" />
+
+ <% end %>
+
+
+
+
+
+
<%= t("reports.print.net_worth.assets") %> <%= @net_worth_metrics[:total_assets].format %>
+ <% if @net_worth_metrics[:asset_groups].any? %>
+
+
+ <% @net_worth_metrics[:asset_groups].each do |group| %>
+
+ <%= group[:name] %>
+ <%= group[:total].format %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+
<%= t("reports.print.net_worth.liabilities") %> <%= @net_worth_metrics[:total_liabilities].format %>
+ <% if @net_worth_metrics[:liability_groups].any? %>
+
+
+ <% @net_worth_metrics[:liability_groups].each do |group| %>
+
+ <%= group[:name] %>
+ <%= group[:total].format %>
+
+ <% end %>
+
+
+ <% else %>
+
<%= t("reports.print.net_worth.no_liabilities") %>
+ <% end %>
+
+
+
+ <% end %>
+
+ <%# Monthly Trends %>
+ <% if has_sparkline_data?(@trends_data) %>
+
+ <%= t("reports.print.trends.title") %>
+
+
+
+ <%= t("reports.print.trends.month") %>
+ <%= t("reports.print.trends.income") %>
+ <%= t("reports.print.trends.expenses") %>
+ <%= t("reports.print.trends.net") %>
+ <%= t("reports.print.trends.savings_rate") %>
+
+
+
+ <% @trends_data.each do |trend| %>
+ ">
+ <%= trend[:month] %><%= trend[:is_current_month] ? " *" : "" %>
+ <%= Money.new(trend[:income], Current.family.currency).format %>
+ <%= Money.new(trend[:expenses], Current.family.currency).format %>
+ "><%= Money.new(trend[:net], Current.family.currency).format %>
+
+ <% month_savings_rate = trend[:income] > 0 ? ((trend[:net].to_f / trend[:income].to_f) * 100).round(0) : 0 %>
+ <%= month_savings_rate %>%
+
+
+ <% end %>
+
+
+ <%
+ 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
+ %>
+
+ <%= t("reports.print.trends.average") %>
+ <%= Money.new(avg_income, Current.family.currency).format %>
+ <%= Money.new(avg_expenses, Current.family.currency).format %>
+ "><%= Money.new(avg_net, Current.family.currency).format %>
+ <%= overall_savings_rate %>%
+
+
+
+ <% if @trends_data.any? { |t| t[:is_current_month] } %>
+
+ <% end %>
+
+ <% end %>
+
+ <%# Investments %>
+ <% if @investment_metrics[:has_investments] %>
+
+ <%= t("reports.print.investments.title") %>
+
+
+
<%= t("reports.print.investments.portfolio_value") %>
+
<%= format_money(@investment_metrics[:portfolio_value]) %>
+ <% if has_sparkline_data?(@trends_data) %>
+
+
+
+ <% end %>
+
+ <% if @investment_metrics[:unrealized_trend] %>
+
+ <%= t("reports.print.investments.total_return") %>
+
+ <%= @investment_metrics[:unrealized_trend].value >= 0 ? "+" : "" %><%= format_money(Money.new(@investment_metrics[:unrealized_trend].value, Current.family.currency)) %>
+
+
+ <%= @investment_metrics[:unrealized_trend].percent_formatted %>
+
+
+ <% end %>
+
+ <%= t("reports.print.investments.contributions") %>
+ <%= format_money(@investment_metrics[:period_contributions]) %>
+ <%= t("reports.print.investments.this_period") %>
+
+
+ <%= t("reports.print.investments.withdrawals") %>
+ <%= format_money(@investment_metrics[:period_withdrawals]) %>
+ <%= t("reports.print.investments.this_period") %>
+
+
+
+ <% if @investment_metrics[:top_holdings].any? %>
+ <%= t("reports.print.investments.top_holdings") %>
+
+
+
+ <%= t("reports.print.investments.holding") %>
+ <%= t("reports.print.investments.weight") %>
+ <%= t("reports.print.investments.value") %>
+ <%= t("reports.print.investments.return") %>
+
+
+
+ <% @investment_metrics[:top_holdings].each do |holding| %>
+
+ <%= holding.ticker %> <%= truncate(holding.name, length: 25) %>
+ <%= number_to_percentage(holding.weight || 0, precision: 1) %>
+ <%= format_money(holding.amount_money) %>
+
+ <% if holding.trend %>
+ <%= holding.trend.percent_formatted %>
+ <% else %>
+ —
+ <% end %>
+
+
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
+ <%# Spending by Category %>
+ <% if @transactions.any? %>
+
+ <%= t("reports.print.spending.title") %>
+ <%
+ 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] }
+ %>
+
+
+ <% if income_groups.any? %>
+
+
<%= t("reports.print.spending.income") %> <%= Money.new(income_total, Current.family.currency).format %>
+
+
+
+ <%= t("reports.print.spending.category") %>
+ <%= t("reports.print.spending.amount") %>
+ <%= t("reports.print.spending.percent") %>
+
+
+
+ <% income_groups.first(8).each do |group| %>
+ <% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(0) %>
+
+
+
+ <%= group[:category_name] %>
+
+ <%= Money.new(group[:total], Current.family.currency).format %>
+ <%= percentage %>%
+
+ <% end %>
+ <% if income_groups.length > 8 %>
+
+ <%= t("reports.print.spending.more_categories", count: income_groups.length - 8) %>
+
+
+
+ <% end %>
+
+
+
+ <% end %>
+
+ <% if expense_groups.any? %>
+
+
<%= t("reports.print.spending.expenses") %> <%= Money.new(expense_total, Current.family.currency).format %>
+
+
+
+ <%= t("reports.print.spending.category") %>
+ <%= t("reports.print.spending.amount") %>
+ <%= t("reports.print.spending.percent") %>
+
+
+
+ <% expense_groups.first(8).each do |group| %>
+ <% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(0) %>
+
+
+
+ <%= group[:category_name] %>
+
+ <%= Money.new(group[:total], Current.family.currency).format %>
+ <%= percentage %>%
+
+ <% end %>
+ <% if expense_groups.length > 8 %>
+
+ <%= t("reports.print.spending.more_categories", count: expense_groups.length - 8) %>
+
+
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <% end %>
+
+
+
diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb
index e34f64f2d..89a1a73c2 100644
--- a/app/views/settings/_settings_nav.html.erb
+++ b/app/views/settings/_settings_nav.html.erb
@@ -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
),
diff --git a/app/views/settings/hostings/_sync_settings.html.erb b/app/views/settings/hostings/_sync_settings.html.erb
new file mode 100644
index 000000000..82cfb86ef
--- /dev/null
+++ b/app/views/settings/hostings/_sync_settings.html.erb
@@ -0,0 +1,64 @@
+<% env_configured = ENV["SIMPLEFIN_INCLUDE_PENDING"].present? || ENV["PLAID_INCLUDE_PENDING"].present? %>
+
+
+
+
<%= t(".include_pending_label") %>
+
<%= t(".include_pending_description") %>
+
+
+ <%= 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 %>
+
+
+
+
+
<%= t(".auto_sync_label") %>
+
<%= t(".auto_sync_description") %>
+
+
+ <%= 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 %>
+
+
+
+
+
<%= t(".auto_sync_time_label") %>
+
<%= t(".auto_sync_time_description") %>
+
+
+ <%= 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 %>
+
+
+ <% if env_configured %>
+
+
+ <%= icon("alert-circle", class: "w-5 h-5 text-warning-600 mt-0.5 shrink-0") %>
+
+ <%= t(".env_configured_message") %>
+
+
+
+ <% end %>
+
diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb
index 0d67f4436..00b60c823 100644
--- a/app/views/settings/hostings/show.html.erb
+++ b/app/views/settings/hostings/show.html.erb
@@ -16,6 +16,9 @@
<% end %>
<% 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 %>
diff --git a/app/views/settings/securities/show.html.erb b/app/views/settings/securities/show.html.erb
index 1d3a7350f..f0cfb56bb 100644
--- a/app/views/settings/securities/show.html.erb
+++ b/app/views/settings/securities/show.html.erb
@@ -44,3 +44,58 @@
<% end %>
+
+<% if @oidc_identities.any? || AuthConfig.sso_providers.any? %>
+ <%= settings_section title: t(".sso_title"), subtitle: t(".sso_subtitle") do %>
+ <% if @oidc_identities.any? %>
+
+ <% @oidc_identities.each do |identity| %>
+
+
+
+ <%= icon identity.provider_config&.dig(:icon) || "key", class: "w-5 h-5 text-secondary" %>
+
+
+
<%= identity.provider_config&.dig(:label) || identity.provider.titleize %>
+
<%= identity.info&.dig("email") || t(".sso_no_email") %>
+
+ <%= t(".sso_last_used") %>:
+ <%= identity.last_authenticated_at&.to_fs(:short) || t(".sso_never") %>
+
+
+
+ <% 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 %>
+
+ <% end %>
+
+ <% if @oidc_identities.count == 1 && Current.user.password_digest.blank? %>
+
+
+ <%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0 mt-0.5" %>
+
<%= t(".sso_warning_message") %>
+
+
+ <% end %>
+ <% else %>
+
+ <%= icon "link", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
+
<%= t(".sso_no_identities") %>
+
<%= t(".sso_connect_hint") %>
+
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/simplefin_items/_simplefin_item.html.erb b/app/views/simplefin_items/_simplefin_item.html.erb
index db3a0b731..530841a2b 100644
--- a/app/views/simplefin_items/_simplefin_item.html.erb
+++ b/app/views/simplefin_items/_simplefin_item.html.erb
@@ -104,6 +104,25 @@
<%= icon "alert-circle", size: "sm", color: "warning" %>
<%= tag.span stale_status[:message], class: "text-sm" %>
+ <% elsif (pending_status = simplefin_item.stale_pending_status)[:count] > 0 %>
+
+
+ <%= icon "clock", size: "sm", color: "secondary" %>
+ <%= tag.span pending_status[:message], class: "text-sm" %>
+ <%= t(".stale_pending_note") %>
+
+ <% if pending_status[:accounts]&.any? %>
+
+ <%= t(".stale_pending_accounts", accounts: pending_status[:accounts].join(", ")) %>
+
+ <% end %>
+
+ <% elsif (reconciled_status = simplefin_item.last_sync_reconciled_status)[:count] > 0 %>
+
+ <%= icon "check-circle", size: "sm", color: "success" %>
+ <%= tag.span reconciled_status[:message], class: "text-sm" %>
+ <%= t(".reconciled_details_note") %>
+
<% elsif simplefin_item.rate_limited_message.present? %>
<%= icon "clock", size: "sm", color: "warning" %>
@@ -117,7 +136,7 @@
<% elsif duplicate_only_errors %>
<%= 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" %>
<% else %>
diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb
index 6664e0750..5eb0b822b 100644
--- a/app/views/transactions/_transaction.html.erb
+++ b/app/views/transactions/_transaction.html.erb
@@ -36,7 +36,7 @@
<% end %>
- <%= 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 @@
<% end %>
+ <%# Investment activity label badge %>
+ <% if transaction.investment_activity_label.present? %>
+ ">
+ <%= t("transactions.activity_labels.#{transaction.investment_activity_label.parameterize(separator: '_')}") %>
+
+ <% end %>
+
<%# Pending indicator %>
<% if transaction.pending? %>
-
+ ">
<%= icon "clock", size: "sm", color: "current" %>
- Pending
+ <%= t("transactions.transaction.pending") %>
<% end %>
+ <%# Potential duplicate indicator - different styling for low vs medium confidence %>
+ <% if transaction.has_potential_duplicate? %>
+ <% if transaction.low_confidence_duplicate? %>
+ ">
+ <%= icon "help-circle", size: "sm", color: "current" %>
+ <%= t("transactions.transaction.review_recommended") %>
+
+ <% else %>
+ ">
+ <%= icon "alert-triangle", size: "sm", color: "current" %>
+ <%= t("transactions.transaction.possible_duplicate") %>
+
+ <% end %>
+ <% end %>
+
<% if transaction.transfer.present? %>
<%= render "transactions/transfer_match", transaction: transaction %>
<% end %>
@@ -122,7 +144,7 @@
- <%= render "transactions/transaction_category", transaction: transaction %>
+ <%= render "transactions/transaction_category", transaction: transaction, variant: "desktop" %>
diff --git a/app/views/transactions/_transaction_category.html.erb b/app/views/transactions/_transaction_category.html.erb
index e124c0213..3b5c8d1b9 100644
--- a/app/views/transactions/_transaction_category.html.erb
+++ b/app/views/transactions/_transaction_category.html.erb
@@ -1,6 +1,6 @@
-<%# locals: (transaction:) %>
+<%# locals: (transaction:, variant:) %>
-
">
+
">
<% if transaction.transfer&.categorizable? || transaction.transfer.nil? %>
<%= render "categories/menu", transaction: transaction %>
<% else %>
diff --git a/app/views/transactions/searches/filters/_badge.html.erb b/app/views/transactions/searches/filters/_badge.html.erb
index e71bf9ee5..a9276ce8b 100644
--- a/app/views/transactions/searches/filters/_badge.html.erb
+++ b/app/views/transactions/searches/filters/_badge.html.erb
@@ -35,6 +35,11 @@
end %>">
<%= t(".#{param_value.downcase}") %>
+ <% elsif param_key == "status" %>
+
+ <%= icon(param_value.downcase == "pending" ? "clock" : "check", size: "sm") %>
+
<%= t(".#{param_value.downcase}") %>
+
<% else %>
<%= param_value %>
diff --git a/app/views/transactions/searches/filters/_status_filter.html.erb b/app/views/transactions/searches/filters/_status_filter.html.erb
new file mode 100644
index 000000000..0e3242fd9
--- /dev/null
+++ b/app/views/transactions/searches/filters/_status_filter.html.erb
@@ -0,0 +1,26 @@
+<%# locals: (form:) %>
+
+
+
+ <%= form.check_box :status,
+ {
+ multiple: true,
+ checked: @q[:status]&.include?("confirmed"),
+ class: "checkbox checkbox--light"
+ },
+ "confirmed",
+ nil %>
+ <%= form.label :status, t(".confirmed"), value: "confirmed", class: "text-sm text-primary" %>
+
+
+ <%= form.check_box :status,
+ {
+ multiple: true,
+ checked: @q[:status]&.include?("pending"),
+ class: "checkbox checkbox--light"
+ },
+ "pending",
+ nil %>
+ <%= form.label :status, t(".pending"), value: "pending", class: "text-sm text-primary" %>
+
+
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index 1d040c953..3d8910dcb 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -4,6 +4,47 @@
<% end %>
<% dialog.with_body do %>
+ <%# Potential duplicate alert %>
+ <% if @entry.transaction.has_potential_duplicate? %>
+ <% potential_match = @entry.transaction.potential_duplicate_entry %>
+ <% if potential_match %>
+
+
+ <%= icon "alert-triangle", size: "md", color: "warning" %>
+
+
<%= t("transactions.show.potential_duplicate_title") %>
+
<%= t("transactions.show.potential_duplicate_description") %>
+
+
+
+
+
<%= potential_match.name %>
+
<%= potential_match.date.strftime("%b %d, %Y") %> • <%= potential_match.account.name %>
+
+
">
+ <%= format_money(-potential_match.amount_money) %>
+
+
+
+
+
+ <%= button_to t("transactions.show.merge_duplicate"),
+ merge_duplicate_transaction_path(@entry.transaction),
+ method: :post,
+ class: "btn btn--primary btn--sm",
+ data: { turbo_frame: "_top" } %>
+ <%= button_to t("transactions.show.keep_both"),
+ dismiss_duplicate_transaction_path(@entry.transaction),
+ method: :post,
+ class: "btn btn--outline btn--sm",
+ data: { turbo_frame: "_top" } %>
+
+
+
+
+ <% end %>
+ <% end %>
+
<% dialog.with_section(title: t(".overview"), open: true) do %>
<%= styled_form_with model: @entry,
@@ -153,8 +194,8 @@
data: { controller: "auto-submit-form" } do |f| %>
-
Exclude
-
Excluded transactions will be removed from budgeting calculations and reports.
+
<%= t(".exclude") %>
+
<%= t(".exclude_description") %>
<%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
@@ -162,6 +203,33 @@
<% end %>
+ <% if @entry.account.investment? || @entry.account.crypto? %>
+
+ <%= styled_form_with model: @entry,
+ url: transaction_path(@entry),
+ class: "p-3",
+ data: { controller: "auto-submit-form" } do |f| %>
+ <%= f.fields_for :entryable do |ef| %>
+
+
+
<%= t(".activity_type") %>
+
<%= t(".activity_type_description") %>
+
+
+ <%= ef.select :investment_activity_label,
+ options_for_select(
+ [["—", nil]] + Transaction::ACTIVITY_LABELS.map { |l| [t("transactions.activity_labels.#{l.parameterize(separator: '_')}"), l] },
+ @entry.entryable.investment_activity_label
+ ),
+ { label: false },
+ { class: "form-field__input border border-secondary rounded-lg px-3 py-1.5 max-w-40 text-sm",
+ data: { auto_submit_form_target: "auto" } } %>
+
+ <% end %>
+ <% end %>
+
+ <% end %>
+
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
@@ -170,8 +238,8 @@
<%= f.fields_for :entryable do |ef| %>
-
One-time <%= @entry.amount.negative? ? "Income" : "Expense" %>
-
One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.
+
<%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %>
+
<%= t(".one_time_description") %>
<%= ef.toggle :kind, {
diff --git a/app/views/transfers/update.turbo_stream.erb b/app/views/transfers/update.turbo_stream.erb
index 82a503d0f..929a63ab5 100644
--- a/app/views/transfers/update.turbo_stream.erb
+++ b/app/views/transfers/update.turbo_stream.erb
@@ -2,13 +2,21 @@
<%= turbo_stream.replace @transfer.inflow_transaction.entry %>
<%= turbo_stream.replace @transfer.outflow_transaction.entry %>
- <%= turbo_stream.replace dom_id(@transfer.inflow_transaction, "category_menu"),
+ <%= turbo_stream.replace dom_id(@transfer.inflow_transaction, "category_menu_mobile"),
partial: "transactions/transaction_category",
- locals: { transaction: @transfer.inflow_transaction } %>
+ locals: { transaction: @transfer.inflow_transaction, variant: "mobile" } %>
- <%= turbo_stream.replace dom_id(@transfer.outflow_transaction, "category_menu"),
+ <%= turbo_stream.replace dom_id(@transfer.inflow_transaction, "category_menu_desktop"),
partial: "transactions/transaction_category",
- locals: { transaction: @transfer.outflow_transaction } %>
+ locals: { transaction: @transfer.inflow_transaction, variant: "desktop" } %>
+
+ <%= turbo_stream.replace dom_id(@transfer.outflow_transaction, "category_menu_mobile"),
+ partial: "transactions/transaction_category",
+ locals: { transaction: @transfer.outflow_transaction, variant: "mobile" } %>
+
+ <%= turbo_stream.replace dom_id(@transfer.outflow_transaction, "category_menu_desktop"),
+ partial: "transactions/transaction_category",
+ locals: { transaction: @transfer.outflow_transaction, variant: "desktop" } %>
<%= turbo_stream.replace dom_id(@transfer.inflow_transaction, "transfer_match"),
partial: "transactions/transfer_match",
diff --git a/charts/sure/CHANGELOG.md b/charts/sure/CHANGELOG.md
index 3e5d6f603..f0d636ba5 100644
--- a/charts/sure/CHANGELOG.md
+++ b/charts/sure/CHANGELOG.md
@@ -1,8 +1,19 @@
-### 0.0.0
+# Changelog
+
+All notable changes to the Sure Helm chart will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+### [0.0.0], [0.6.5]
+
+### Added
- First (nightly/test) releases via
-### 0.6.5
+### [0.6.6] - 2025-12-31
+
+### Added
- First version/release that aligns versions with monorepo
- CNPG: render `Cluster.spec.backup` from `cnpg.cluster.backup`.
@@ -10,3 +21,19 @@
- For snapshot backups, `backup.volumeSnapshot.className` is required (template fails early if missing).
- Example-only keys like `backup.ttl` and `backup.volumeSnapshot.enabled` are stripped to avoid CRD warnings.
- CNPG: render `Cluster.spec.plugins` from `cnpg.cluster.plugins` (enables barman-cloud plugin / WAL archiver configuration).
+
+## [0.6.7-alpha] - 2026-01-10
+
+### Added
+- **Redis Sentinel support for Sidekiq high availability**: Application now automatically detects and configures Sidekiq to use Redis Sentinel when `redisOperator.mode=sentinel` and `redisOperator.sentinel.enabled=true`
+ - New Helm template helpers (`sure.redisSentinelEnabled`, `sure.redisSentinelHosts`, `sure.redisSentinelMaster`) for Sentinel configuration detection
+ - Automatic injection of `REDIS_SENTINEL_HOSTS` and `REDIS_SENTINEL_MASTER` environment variables when Sentinel mode is enabled
+ - Sidekiq configuration supports Sentinel authentication with `sentinel_username` (defaults to "default") and `sentinel_password`
+ - Robust validation of Sentinel endpoints with port range checking (1-65535) and graceful fallback to direct Redis URL on invalid configuration
+ - Production-ready HA timeouts: 200ms connect, 1s read/write, 3 reconnection attempts
+ - Backward compatible with existing `REDIS_URL` deployments
+
+## Notes
+- Chart version and application version are kept in sync
+- Requires Kubernetes >= 1.25.0
+- When upgrading from pre-Sentinel configurations, existing deployments using `REDIS_URL` continue to work unchanged
diff --git a/charts/sure/README.md b/charts/sure/README.md
index 3182e39bd..a1dfb8903 100644
--- a/charts/sure/README.md
+++ b/charts/sure/README.md
@@ -246,7 +246,11 @@ redisOperator:
cpu: 100m
memory: 256Mi
managed:
- enabled: true # render a RedisSentinel CR
+ enabled: true # render Redis CRs for in-cluster Redis
+ mode: sentinel # enables RedisSentinel CR in addition to RedisReplication
+ sentinel:
+ enabled: true # must be true when mode=sentinel
+ masterGroupName: mymaster
name: "" # defaults to -redis
replicas: 3
auth:
@@ -258,9 +262,14 @@ redisOperator:
```
Notes:
+- When `redisOperator.mode=sentinel` and `redisOperator.sentinel.enabled=true`, the chart automatically configures Sidekiq to use Redis Sentinel for high availability.
+- The application receives `REDIS_SENTINEL_HOSTS` (comma-separated list of Sentinel endpoints) and `REDIS_SENTINEL_MASTER` (master group name) environment variables instead of `REDIS_URL`.
+- Sidekiq will connect to Sentinel nodes for automatic master discovery and failover support.
+- Both the Redis master and Sentinel nodes use the same password from `REDIS_PASSWORD` (via `redisOperator.auth.existingSecret`).
+- Sentinel authentication uses username "default" by default (configurable via `REDIS_SENTINEL_USERNAME`).
- The operator master service is `-redis-master..svc.cluster.local:6379`.
- The CR references your existing password secret via `kubernetesConfig.redisSecret { name, key }`.
-- Provider precedence for auto-wiring is: explicit `rails.extraEnv.REDIS_URL` → `redisOperator.managed` → `redisSimple`.
+- Provider precedence for auto-wiring is: explicit `rails.extraEnv.REDIS_URL` → `redisOperator.managed` (with Sentinel if configured) → `redisSimple`.
- Only one in-cluster Redis provider should be enabled at a time to avoid ambiguity.
### HA scheduling and topology spreading
@@ -301,6 +310,74 @@ Security note on label selectors:
- CNPG: `cnpg.io/cluster: ` (CNPG labels its pods)
- RedisReplication: `app.kubernetes.io/instance: ` or `app.kubernetes.io/name: `
+#### Rolling update strategy
+
+When using topology spread constraints with `whenUnsatisfiable: DoNotSchedule`, you must configure the Kubernetes rolling update strategy to prevent deployment deadlocks.
+
+The chart now makes the rolling update strategy configurable for web and worker deployments. The defaults have been changed from Kubernetes defaults (`maxUnavailable=0`, `maxSurge=25%`) to:
+
+```yaml
+web:
+ strategy:
+ rollingUpdate:
+ maxUnavailable: 1
+ maxSurge: 0
+
+worker:
+ strategy:
+ rollingUpdate:
+ maxUnavailable: 1
+ maxSurge: 0
+```
+
+**Why these defaults?**
+
+With `maxSurge=0`, Kubernetes will terminate an old pod before creating a new one. This ensures that when all nodes are occupied (due to strict topology spreading), there is always space for the new pod to be scheduled.
+
+If you use `maxSurge > 0` with `DoNotSchedule` topology constraints and all nodes are occupied, Kubernetes cannot create the new pod (no space available) and cannot terminate the old pod (new pod must be ready first), resulting in a deployment deadlock.
+
+**Configuration examples:**
+
+For faster rollouts when not using strict topology constraints:
+
+```yaml
+web:
+ strategy:
+ rollingUpdate:
+ maxUnavailable: 0
+ maxSurge: 1
+
+worker:
+ strategy:
+ rollingUpdate:
+ maxUnavailable: 0
+ maxSurge: 1
+```
+
+For HA setups with topology spreading:
+
+```yaml
+web:
+ replicas: 3
+ strategy:
+ rollingUpdate:
+ maxUnavailable: 1
+ maxSurge: 0
+ topologySpreadConstraints:
+ - maxSkew: 1
+ topologyKey: kubernetes.io/hostname
+ whenUnsatisfiable: DoNotSchedule
+ labelSelector:
+ matchLabels:
+ app.kubernetes.io/name: sure
+ app.kubernetes.io/component: web
+```
+
+**Warning:** Using `maxSurge > 0` with `whenUnsatisfiable: DoNotSchedule` can cause deployment deadlocks when all nodes are occupied. If you need faster rollouts, either:
+- Use `whenUnsatisfiable: ScheduleAnyway` instead of `DoNotSchedule`
+- Ensure you have spare capacity on your nodes
+- Keep `maxSurge: 0` and accept slower rollouts
+
Compatibility:
- CloudNativePG v1.27.1 supports `minSyncReplicas`/`maxSyncReplicas` and standard k8s scheduling fields under `spec`.
- OT redis-operator v0.21.0 supports scheduling under `spec.kubernetesConfig`.
@@ -361,13 +438,13 @@ stringData:
# password: "__SET_SECRET__"
```
-Note: These are non-sensitive placeholder values. Do not commit real secrets to version control. Prefer External Secrets, Sealed Secrets, or your platform’s secret manager to source these at runtime.
+Note: These are non-sensitive placeholder values. Do not commit real secrets to version control. Prefer External Secrets, Sealed Secrets, or your platform's secret manager to source these at runtime.
### Linting Helm templates and YAML
Helm template files under `charts/**/templates/**` contain template delimiters like `{{- ... }}` that raw YAML linters will flag as invalid. To avoid false positives in CI:
-- Use Helm’s linter for charts:
+- Use Helm's linter for charts:
- `helm lint charts/sure`
- Configure your YAML linter (e.g., yamllint) to ignore Helm template directories (exclude `charts/**/templates/**`), or use a Helm-aware plugin that preprocesses templates before linting.
@@ -579,7 +656,7 @@ See `values.yaml` for the complete configuration surface, including:
- `redis-ha.*`: enable dandydev/redis-ha subchart and configure replicas/auth (Sentinel/HA); supports `existingSecret` and `existingSecretPasswordKey`
- `redisOperator.*`: optionally install OT redis-operator (`redisOperator.enabled`) and/or render a `RedisSentinel` CR (`redisOperator.managed.enabled`); configure `name`, `replicas`, `auth.existingSecret/passwordKey`, `persistence.className/size`, scheduling knobs, and `operator.resources` (controller) / `workloadResources` (Redis pods)
- `redisSimple.*`: optional single‑pod Redis (non‑HA) when `redis-ha.enabled=false`
-- `web.*`, `worker.*`: replicas, probes, resources, scheduling
+- `web.*`, `worker.*`: replicas, probes, resources, scheduling, **strategy** (rolling update configuration)
- `migrations.*`: strategy job or initContainer
- `simplefin.encryption.*`: enable + backfill options
- `cronjobs.*`: custom CronJobs
@@ -626,7 +703,7 @@ helm uninstall sure -n sure
## Cleanup & reset (k3s)
-For local k3s experimentation it’s sometimes useful to completely reset the `sure` namespace, especially if CR finalizers or PVCs get stuck.
+For local k3s experimentation it's sometimes useful to completely reset the `sure` namespace, especially if CR finalizers or PVCs get stuck.
The script below is a **last-resort tool** for cleaning the namespace. It:
diff --git a/charts/sure/templates/_env.tpl b/charts/sure/templates/_env.tpl
index bd253ed72..ccf0c1b69 100644
--- a/charts/sure/templates/_env.tpl
+++ b/charts/sure/templates/_env.tpl
@@ -68,6 +68,13 @@ The helper always injects:
key: {{ include "sure.redisPasswordKey" $ctx }}
- name: REDIS_URL
value: {{ $redis | quote }}
+{{- $sentinelHosts := include "sure.redisSentinelHosts" $ctx -}}
+{{- if $sentinelHosts }}
+- name: REDIS_SENTINEL_HOSTS
+ value: {{ $sentinelHosts | quote }}
+- name: REDIS_SENTINEL_MASTER
+ value: {{ include "sure.redisSentinelMaster" $ctx | quote }}
+{{- end }}
{{- end }}
{{- end }}
{{- range $k, $v := $ctx.Values.rails.settings }}
diff --git a/charts/sure/templates/_helpers.tpl b/charts/sure/templates/_helpers.tpl
index 2b202bc90..436127959 100644
--- a/charts/sure/templates/_helpers.tpl
+++ b/charts/sure/templates/_helpers.tpl
@@ -76,6 +76,38 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- end -}}
+{{/* Check if Redis Sentinel is enabled and configured */}}
+{{- define "sure.redisSentinelEnabled" -}}
+{{- if and .Values.redisOperator.managed.enabled .Values.redisOperator.sentinel.enabled (eq (.Values.redisOperator.mode | default "replication") "sentinel") -}}
+true
+{{- else -}}
+{{- end -}}
+{{- end -}}
+
+{{/* Compute Redis Sentinel hosts (comma-separated list of host:port) */}}
+{{- define "sure.redisSentinelHosts" -}}
+{{- if eq (include "sure.redisSentinelEnabled" .) "true" -}}
+ {{- $name := .Values.redisOperator.name | default (printf "%s-redis" (include "sure.fullname" .)) -}}
+ {{- $replicas := .Values.redisOperator.replicas | default 3 -}}
+ {{- $port := .Values.redisOperator.probes.sentinel.port | default 26379 -}}
+ {{- $hosts := list -}}
+ {{- range $i := until (int $replicas) -}}
+ {{- $host := printf "%s-sentinel-%d.%s-sentinel-headless.%s.svc.cluster.local:%d" $name $i $name $.Release.Namespace (int $port) -}}
+ {{- $hosts = append $hosts $host -}}
+ {{- end -}}
+ {{- join "," $hosts -}}
+{{- else -}}
+{{- end -}}
+{{- end -}}
+
+{{/* Get Redis Sentinel master group name */}}
+{{- define "sure.redisSentinelMaster" -}}
+{{- if eq (include "sure.redisSentinelEnabled" .) "true" -}}
+ {{- .Values.redisOperator.sentinel.masterGroupName | default "mymaster" -}}
+{{- else -}}
+{{- end -}}
+{{- end -}}
+
{{/* Common secret name helpers to avoid complex inline conditionals in env blocks */}}
{{- define "sure.appSecretName" -}}
diff --git a/charts/sure/values.yaml b/charts/sure/values.yaml
index 23b5ad8d7..d2fb97eba 100644
--- a/charts/sure/values.yaml
+++ b/charts/sure/values.yaml
@@ -266,6 +266,20 @@ web:
# Optional command/args override
command: []
args: []
+ # Kubernetes rolling update strategy for the web Deployment.
+ # Controls how pods are replaced during updates.
+ # Default: maxUnavailable=1, maxSurge=0
+ # This prevents deployment deadlocks when using topology spread constraints with DoNotSchedule.
+ # If you are not using strict topology constraints, you can increase maxSurge for faster rollouts.
+ # Example for faster rollouts (when not using DoNotSchedule):
+ # strategy:
+ # rollingUpdate:
+ # maxUnavailable: 0
+ # maxSurge: 1
+ strategy:
+ rollingUpdate:
+ maxUnavailable: 1
+ maxSurge: 0
resources:
requests:
cpu: 100m
@@ -312,6 +326,20 @@ worker:
# Optional command/args override for Sidekiq
command: []
args: []
+ # Kubernetes rolling update strategy for the worker Deployment.
+ # Controls how pods are replaced during updates.
+ # Default: maxUnavailable=1, maxSurge=0
+ # This prevents deployment deadlocks when using topology spread constraints with DoNotSchedule.
+ # If you are not using strict topology constraints, you can increase maxSurge for faster rollouts.
+ # Example for faster rollouts (when not using DoNotSchedule):
+ # strategy:
+ # rollingUpdate:
+ # maxUnavailable: 0
+ # maxSurge: 1
+ strategy:
+ rollingUpdate:
+ maxUnavailable: 1
+ maxSurge: 0
resources:
requests:
cpu: 100m
diff --git a/config/application.rb b/config/application.rb
index 8a7c2f7be..d0ef1361f 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -41,5 +41,9 @@ module Sure
# Enable Rack::Attack middleware for API rate limiting
config.middleware.use Rack::Attack
+
+ # Handle OmniAuth/OIDC errors gracefully (must be before OmniAuth middleware)
+ require_relative "../app/middleware/omniauth_error_handler"
+ config.middleware.use OmniauthErrorHandler
end
end
diff --git a/config/auth.yml b/config/auth.yml
index 1e237cca2..ebcbc6ea0 100644
--- a/config/auth.yml
+++ b/config/auth.yml
@@ -23,11 +23,25 @@ default: &default
# Generic OpenID Connect provider (e.g., Keycloak, Authentik, other OIDC issuers).
# This maps to the existing :openid_connect OmniAuth strategy and keeps
# backwards-compatible behavior for self-hosted setups using OIDC_* env vars.
+ #
+ # For the default OIDC provider, use these ENV vars:
+ # OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI
+ #
+ # To add additional OIDC providers, add more entries with unique names and use
+ # provider-specific ENV vars with the pattern: OIDC__*
+ # Example for a provider named "keycloak":
+ # OIDC_KEYCLOAK_ISSUER, OIDC_KEYCLOAK_CLIENT_ID,
+ # OIDC_KEYCLOAK_CLIENT_SECRET, OIDC_KEYCLOAK_REDIRECT_URI
- id: "oidc"
strategy: "openid_connect"
name: "openid_connect"
label: <%= ENV.fetch("OIDC_BUTTON_LABEL", "Sign in with OpenID Connect") %>
icon: <%= ENV.fetch("OIDC_BUTTON_ICON", "key") %>
+ # Per-provider credentials (optional, falls back to global OIDC_* vars)
+ issuer: <%= ENV["OIDC_ISSUER"] %>
+ client_id: <%= ENV["OIDC_CLIENT_ID"] %>
+ client_secret: <%= ENV["OIDC_CLIENT_SECRET"] %>
+ redirect_uri: <%= ENV["OIDC_REDIRECT_URI"] %>
# Optional Google OAuth provider. Requires the omniauth-google-oauth2 gem
# and GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET env vars.
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index 05172fd20..0cb225287 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -69,6 +69,29 @@
],
"note": ""
},
+ {
+ "warning_type": "Mass Assignment",
+ "warning_code": 105,
+ "fingerprint": "01a88a0a17848e70999c17f6438a636b00e01da39a2c0aa0c46f20f0685c7202",
+ "check_name": "PermitAttributes",
+ "message": "Potentially dangerous key allowed for mass assignment",
+ "file": "app/controllers/admin/users_controller.rb",
+ "line": 35,
+ "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
+ "code": "params.require(:user).permit(:role)",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "Admin::UsersController",
+ "method": "user_params"
+ },
+ "user_input": ":role",
+ "confidence": "Medium",
+ "cwe_id": [
+ 915
+ ],
+ "note": "Protected by Pundit authorization - UserPolicy requires super_admin and prevents users from changing their own role"
+ },
{
"warning_type": "Dangerous Eval",
"warning_code": 13,
diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb
new file mode 100644
index 000000000..6ed3abe4a
--- /dev/null
+++ b/config/initializers/flipper.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require "flipper"
+require "flipper/adapters/active_record"
+require "flipper/adapters/memory"
+
+# Configure Flipper with ActiveRecord adapter for database-backed feature flags
+# Falls back to memory adapter if tables don't exist yet (during migrations)
+Flipper.configure do |config|
+ config.adapter do
+ begin
+ Flipper::Adapters::ActiveRecord.new
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, NameError
+ # Tables don't exist yet, use memory adapter as fallback
+ Flipper::Adapters::Memory.new
+ end
+ end
+end
+
+# Initialize feature flags IMMEDIATELY (not in after_initialize)
+# This must happen before OmniAuth initializer runs
+unless Rails.env.test?
+ begin
+ # Feature flag to control SSO provider source (YAML vs DB)
+ # ENV: AUTH_PROVIDERS_SOURCE=db|yaml
+ # Default: "db" for self-hosted, "yaml" for managed
+ auth_source = ENV.fetch("AUTH_PROVIDERS_SOURCE") do
+ Rails.configuration.app_mode.self_hosted? ? "db" : "yaml"
+ end.downcase
+
+ # Ensure feature exists before enabling/disabling
+ Flipper.add(:db_sso_providers) unless Flipper.exist?(:db_sso_providers)
+
+ if auth_source == "db"
+ Flipper.enable(:db_sso_providers)
+ else
+ Flipper.disable(:db_sso_providers)
+ end
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
+ # Database not ready yet (e.g., during initial setup or migrations)
+ # This is expected during db:create or initial setup
+ rescue StandardError => e
+ Rails.logger.warn("[Flipper] Error initializing feature flags: #{e.message}")
+ end
+end
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
index 9b836e436..1b4d301b3 100644
--- a/config/initializers/omniauth.rb
+++ b/config/initializers/omniauth.rb
@@ -5,42 +5,101 @@ require "omniauth/rails_csrf_protection"
Rails.configuration.x.auth.oidc_enabled = false
Rails.configuration.x.auth.sso_providers ||= []
+# Configure OmniAuth to handle failures gracefully
+OmniAuth.config.on_failure = proc do |env|
+ error = env["omniauth.error"]
+ error_type = env["omniauth.error.type"]
+ strategy = env["omniauth.error.strategy"]
+
+ # Log the error for debugging
+ Rails.logger.error("[OmniAuth] Authentication failed: #{error_type} - #{error&.message}")
+
+ # Redirect to failure handler with error info
+ message = case error_type
+ when :discovery_failed, :invalid_credentials
+ "sso_provider_unavailable"
+ when :invalid_response
+ "sso_invalid_response"
+ else
+ "sso_failed"
+ end
+
+ Rack::Response.new([ "302 Moved" ], 302, "Location" => "/auth/failure?message=#{message}&strategy=#{strategy&.name}").finish
+end
+
Rails.application.config.middleware.use OmniAuth::Builder do
- (Rails.configuration.x.auth.providers || []).each do |raw_cfg|
+ # Load providers from either YAML or DB via ProviderLoader
+ providers = ProviderLoader.load_providers
+
+ providers.each do |raw_cfg|
cfg = raw_cfg.deep_symbolize_keys
strategy = cfg[:strategy].to_s
name = (cfg[:name] || cfg[:id]).to_s
case strategy
when "openid_connect"
- required_env = %w[OIDC_ISSUER OIDC_CLIENT_ID OIDC_CLIENT_SECRET OIDC_REDIRECT_URI]
- enabled = Rails.env.test? || required_env.all? { |k| ENV[k].present? }
- next unless enabled
+ # Support per-provider credentials from config or fall back to global ENV vars
+ issuer = cfg[:issuer].presence || ENV["OIDC_ISSUER"].presence
+ client_id = cfg[:client_id].presence || ENV["OIDC_CLIENT_ID"].presence
+ client_secret = cfg[:client_secret].presence || ENV["OIDC_CLIENT_SECRET"].presence
+ redirect_uri = cfg[:redirect_uri].presence || ENV["OIDC_REDIRECT_URI"].presence
- issuer = (ENV["OIDC_ISSUER"].presence || "https://test.example.com").to_s.strip
- client_id = ENV["OIDC_CLIENT_ID"].presence || "test_client_id"
- client_secret = ENV["OIDC_CLIENT_SECRET"].presence || "test_client_secret"
- redirect_uri = ENV["OIDC_REDIRECT_URI"].presence || "http://test.example.com/callback"
+ # In test environment, use test values if nothing is configured
+ if Rails.env.test?
+ issuer ||= "https://test.example.com"
+ client_id ||= "test_client_id"
+ client_secret ||= "test_client_secret"
+ redirect_uri ||= "http://test.example.com/callback"
+ end
- provider :openid_connect,
- name: name.to_sym,
- scope: %i[openid email profile],
- response_type: :code,
- issuer: issuer,
- discovery: true,
- pkce: true,
- client_options: {
- identifier: client_id,
- secret: client_secret,
- redirect_uri: redirect_uri
- }
+ # Skip if required fields are missing (except in test)
+ unless issuer.present? && client_id.present? && client_secret.present? && redirect_uri.present?
+ Rails.logger.warn("[OmniAuth] Skipping OIDC provider '#{name}' - missing required configuration")
+ next
+ end
+
+ # Custom scopes: parse from settings if provided, otherwise use defaults
+ custom_scopes = cfg.dig(:settings, :scopes).presence
+ scopes = if custom_scopes.present?
+ custom_scopes.to_s.split(/\s+/).map(&:to_sym)
+ else
+ %i[openid email profile]
+ end
+
+ # Build provider options
+ oidc_options = {
+ name: name.to_sym,
+ scope: scopes,
+ response_type: :code,
+ issuer: issuer.to_s.strip,
+ discovery: true,
+ pkce: true,
+ client_options: {
+ identifier: client_id,
+ secret: client_secret,
+ redirect_uri: redirect_uri
+ }
+ }
+
+ # Add prompt parameter if configured
+ prompt = cfg.dig(:settings, :prompt).presence
+ oidc_options[:prompt] = prompt if prompt.present?
+
+ provider :openid_connect, oidc_options
Rails.configuration.x.auth.oidc_enabled = true
- Rails.configuration.x.auth.sso_providers << cfg.merge(name: name)
+ Rails.configuration.x.auth.sso_providers << cfg.merge(name: name, issuer: issuer)
when "google_oauth2"
- client_id = ENV["GOOGLE_OAUTH_CLIENT_ID"].presence || (Rails.env.test? ? "test_client_id" : nil)
- client_secret = ENV["GOOGLE_OAUTH_CLIENT_SECRET"].presence || (Rails.env.test? ? "test_client_secret" : nil)
+ client_id = cfg[:client_id].presence || ENV["GOOGLE_OAUTH_CLIENT_ID"].presence
+ client_secret = cfg[:client_secret].presence || ENV["GOOGLE_OAUTH_CLIENT_SECRET"].presence
+
+ # Test environment fallback
+ if Rails.env.test?
+ client_id ||= "test_client_id"
+ client_secret ||= "test_client_secret"
+ end
+
next unless client_id.present? && client_secret.present?
provider :google_oauth2,
@@ -54,8 +113,15 @@ Rails.application.config.middleware.use OmniAuth::Builder do
Rails.configuration.x.auth.sso_providers << cfg.merge(name: name)
when "github"
- client_id = ENV["GITHUB_CLIENT_ID"].presence || (Rails.env.test? ? "test_client_id" : nil)
- client_secret = ENV["GITHUB_CLIENT_SECRET"].presence || (Rails.env.test? ? "test_client_secret" : nil)
+ client_id = cfg[:client_id].presence || ENV["GITHUB_CLIENT_ID"].presence
+ client_secret = cfg[:client_secret].presence || ENV["GITHUB_CLIENT_SECRET"].presence
+
+ # Test environment fallback
+ if Rails.env.test?
+ client_id ||= "test_client_id"
+ client_secret ||= "test_client_secret"
+ end
+
next unless client_id.present? && client_secret.present?
provider :github,
@@ -67,10 +133,54 @@ Rails.application.config.middleware.use OmniAuth::Builder do
}
Rails.configuration.x.auth.sso_providers << cfg.merge(name: name)
+
+ when "saml"
+ settings = cfg[:settings] || {}
+
+ # Require either metadata URL or manual SSO URL
+ idp_metadata_url = settings[:idp_metadata_url].presence || settings["idp_metadata_url"].presence
+ idp_sso_url = settings[:idp_sso_url].presence || settings["idp_sso_url"].presence
+
+ unless idp_metadata_url.present? || idp_sso_url.present?
+ Rails.logger.warn("[OmniAuth] Skipping SAML provider '#{name}' - missing IdP configuration")
+ next
+ end
+
+ # Build SAML options
+ saml_options = {
+ name: name.to_sym,
+ assertion_consumer_service_url: cfg[:redirect_uri].presence || "#{ENV['APP_URL']}/auth/#{name}/callback",
+ issuer: cfg[:issuer].presence || ENV["APP_URL"],
+ name_identifier_format: settings[:name_id_format].presence || settings["name_id_format"].presence ||
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ attribute_statements: {
+ email: [ "email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" ],
+ first_name: [ "first_name", "givenName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" ],
+ last_name: [ "last_name", "surname", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname" ],
+ groups: [ "groups", "http://schemas.microsoft.com/ws/2008/06/identity/claims/groups" ]
+ }
+ }
+
+ # Use metadata URL or manual configuration
+ if idp_metadata_url.present?
+ saml_options[:idp_metadata_url] = idp_metadata_url
+ else
+ saml_options[:idp_sso_service_url] = idp_sso_url
+ saml_options[:idp_cert] = settings[:idp_certificate].presence || settings["idp_certificate"].presence
+ saml_options[:idp_cert_fingerprint] = settings[:idp_cert_fingerprint].presence || settings["idp_cert_fingerprint"].presence
+ end
+
+ # Optional: IdP SLO (Single Logout) URL
+ idp_slo_url = settings[:idp_slo_url].presence || settings["idp_slo_url"].presence
+ saml_options[:idp_slo_service_url] = idp_slo_url if idp_slo_url.present?
+
+ provider :saml, saml_options
+
+ Rails.configuration.x.auth.sso_providers << cfg.merge(name: name, strategy: "saml")
end
end
end
if Rails.configuration.x.auth.sso_providers.empty?
- Rails.logger.warn("No SSO providers enabled; check auth.yml / ENV configuration")
+ Rails.logger.warn("No SSO providers enabled; check auth.yml / ENV configuration or database providers")
end
diff --git a/config/initializers/plaid_config.rb b/config/initializers/plaid_config.rb
index e69fcbc12..a555a3dbc 100644
--- a/config/initializers/plaid_config.rb
+++ b/config/initializers/plaid_config.rb
@@ -4,4 +4,13 @@
Rails.application.configure do
config.plaid = nil
config.plaid_eu = nil
+
+ # Plaid pending transaction settings (mirrors SimpleFIN config pattern)
+ falsy = %w[0 false no off]
+ config.x.plaid ||= ActiveSupport::OrderedOptions.new
+ # Default to true - fetch pending transactions for display with "Pending" badge
+ # and reconciliation when posted versions arrive (Plaid provides pending_transaction_id for reliable linking)
+ # Set PLAID_INCLUDE_PENDING=0 to disable if user prefers not to see pending transactions
+ pending_env = ENV["PLAID_INCLUDE_PENDING"].to_s.strip.downcase
+ config.x.plaid.include_pending = pending_env.blank? ? true : !falsy.include?(pending_env)
end
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index 3d225e58b..c7b3ac0c2 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -9,6 +9,12 @@ class Rack::Attack
request.ip if request.path == "/oauth/token"
end
+ # Throttle admin endpoints to prevent brute-force attacks
+ # More restrictive than general API limits since admin access is sensitive
+ throttle("admin/ip", limit: 10, period: 1.minute) do |request|
+ request.ip if request.path.start_with?("/admin/")
+ end
+
# Determine limits based on self-hosted mode
self_hosted = Rails.application.config.app_mode.self_hosted?
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index b80a8fddf..08c549cd5 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -10,6 +10,60 @@ if Rails.env.production?
end
end
+# Configure Redis connection for Sidekiq
+# Supports both Redis Sentinel (for HA) and direct Redis URL
+redis_config = if ENV["REDIS_SENTINEL_HOSTS"].present?
+ # Redis Sentinel configuration for high availability
+ # REDIS_SENTINEL_HOSTS should be comma-separated list: "host1:port1,host2:port2,host3:port3"
+ sentinels = ENV["REDIS_SENTINEL_HOSTS"].split(",").filter_map do |host_port|
+ parts = host_port.strip.split(":", 2)
+ host = parts[0]&.strip
+ port_str = parts[1]&.strip
+
+ next if host.blank?
+
+ # Parse port with validation, default to 26379 if invalid or missing
+ port = if port_str.present?
+ port_int = port_str.to_i
+ (port_int > 0 && port_int <= 65535) ? port_int : 26379
+ else
+ 26379
+ end
+
+ { host: host, port: port }
+ end
+
+ if sentinels.empty?
+ Rails.logger.warn("REDIS_SENTINEL_HOSTS is set but no valid sentinel hosts found, falling back to REDIS_URL")
+ { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
+ else
+ {
+ url: "redis://#{ENV.fetch('REDIS_SENTINEL_MASTER', 'mymaster')}/0",
+ sentinels: sentinels,
+ password: ENV["REDIS_PASSWORD"],
+ sentinel_username: ENV.fetch("REDIS_SENTINEL_USERNAME", "default"),
+ sentinel_password: ENV["REDIS_PASSWORD"],
+ role: :master,
+ # Recommended timeouts for Sentinel
+ connect_timeout: 0.2,
+ read_timeout: 1,
+ write_timeout: 1,
+ reconnect_attempts: 3
+ }
+ end
+else
+ # Standard Redis URL configuration (no Sentinel)
+ { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
+end
+
+Sidekiq.configure_server do |config|
+ config.redis = redis_config
+end
+
+Sidekiq.configure_client do |config|
+ config.redis = redis_config
+end
+
Sidekiq::Cron.configure do |config|
# 10 min "catch-up" window in case worker process is re-deploying when cron tick occurs
config.reschedule_grace_period = 600
diff --git a/config/initializers/simplefin.rb b/config/initializers/simplefin.rb
index 07de9aa77..450bcdd77 100644
--- a/config/initializers/simplefin.rb
+++ b/config/initializers/simplefin.rb
@@ -1,7 +1,15 @@
Rails.application.configure do
truthy = %w[1 true yes on]
+ falsy = %w[0 false no off]
config.x.simplefin ||= ActiveSupport::OrderedOptions.new
- config.x.simplefin.include_pending = truthy.include?(ENV["SIMPLEFIN_INCLUDE_PENDING"].to_s.strip.downcase)
+ # Default to true - always fetch pending transactions so they can be:
+ # - Displayed with a "Pending" badge
+ # - Excluded from budgets (but included in net worth)
+ # - Reconciled when posted versions arrive (avoiding duplicates)
+ # - Auto-excluded after 8 days if they remain stale
+ # Set SIMPLEFIN_INCLUDE_PENDING=0 to disable if a bank's integration causes issues
+ pending_env = ENV["SIMPLEFIN_INCLUDE_PENDING"].to_s.strip.downcase
+ config.x.simplefin.include_pending = pending_env.blank? ? true : !falsy.include?(pending_env)
config.x.simplefin.debug_raw = truthy.include?(ENV["SIMPLEFIN_DEBUG_RAW"].to_s.strip.downcase)
end
diff --git a/config/initializers/version.rb b/config/initializers/version.rb
index 53e1b6bcb..7e427c761 100644
--- a/config/initializers/version.rb
+++ b/config/initializers/version.rb
@@ -14,7 +14,7 @@ module Sure
private
def semver
- "0.6.7-alpha.6"
+ "0.6.7-alpha.9"
end
end
end
diff --git a/config/locales/defaults/zh-TW.yml b/config/locales/defaults/zh-TW.yml
index f4f78182b..cac18eb11 100644
--- a/config/locales/defaults/zh-TW.yml
+++ b/config/locales/defaults/zh-TW.yml
@@ -1,12 +1,15 @@
---
zh-TW:
+ defaults:
+ brand_name: "%{brand_name}"
+ product_name: "%{product_name}"
activerecord:
errors:
messages:
- record_invalid: 校驗失敗:%{errors}
+ record_invalid: '驗證失敗:%{errors}'
restrict_dependent_destroy:
- has_many: 由於%{record}需要此記錄,所以無法移除記錄
- has_one: 由於%{record}需要此記錄,所以無法移除記錄
+ has_many: 因為存在關聯的 %{record},所以無法刪除紀錄
+ has_one: 因為存在關聯的 %{record},所以無法刪除紀錄
date:
abbr_day_names:
- 週日
@@ -62,19 +65,43 @@ zh-TW:
- :day
datetime:
distance_in_words:
- about_x_hours: 大約%{count}小時
- about_x_months: 大約%{count}個月
- about_x_years: 大約%{count}年
- almost_x_years: 接近%{count}年
+ about_x_hours:
+ one: 大約 %{count} 小時
+ other: 大約 %{count} 小時
+ about_x_months:
+ one: 大約 %{count} 個月
+ other: 大約 %{count} 個月
+ about_x_years:
+ one: 大約 %{count} 年
+ other: 大約 %{count} 年
+ almost_x_years:
+ one: 接近 %{count} 年
+ other: 接近 %{count} 年
half_a_minute: 半分鐘
- less_than_x_minutes: 不到%{count}分鐘
- less_than_x_seconds: 不到%{count}秒
- over_x_years: "%{count}年多"
- x_days: "%{count}天"
- x_minutes: "%{count}分鐘"
- x_months: "%{count}個月"
- x_seconds: "%{count}秒"
- x_years: "%{count}年"
+ less_than_x_minutes:
+ one: 不到 1 分鐘
+ other: 不到 %{count} 分鐘
+ less_than_x_seconds:
+ one: 不到 %{count} 秒
+ other: 不到 %{count} 秒
+ over_x_years:
+ one: 超過 %{count} 年
+ other: 超過 %{count} 年
+ x_days:
+ one: "%{count} 天"
+ other: "%{count} 天"
+ x_minutes:
+ one: "%{count} 分鐘"
+ other: "%{count} 分鐘"
+ x_months:
+ one: "%{count} 個月"
+ other: "%{count} 個月"
+ x_seconds:
+ one: "%{count} 秒"
+ other: "%{count} 秒"
+ x_years:
+ one: "%{count} 年"
+ other: "%{count} 年"
prompts:
day: 日
hour: 時
@@ -83,55 +110,60 @@ zh-TW:
second: 秒
year: 年
errors:
- format: "%{attribute}%{message}"
+ format: "%{attribute} %{message}"
messages:
- accepted: 必須是可被接受的
+ accepted: 必須接受
blank: 不能為空白
- confirmation: 與%{attribute}須一致
- empty: 不能留空
- equal_to: 必須等於%{count}
- even: 必須是偶數
- exclusion: 是被保留的關鍵字
- greater_than: 必須大於%{count}
- greater_than_or_equal_to: 必須大於或等於%{count}
- inclusion: 沒有包含在列表中
- invalid: 是無效的
- less_than: 必須小於%{count}
- less_than_or_equal_to: 必須小於或等於%{count}
- model_invalid: 校驗失敗:%{errors}
+ confirmation: 與 %{attribute} 不符
+ empty: 不能為空
+ equal_to: 必須等於 %{count}
+ even: 必須為偶數
+ exclusion: 已被保留
+ greater_than: 必須大於 %{count}
+ greater_than_or_equal_to: 必須大於或等於 %{count}
+ in: 必須在 %{count} 範圍內
+ inclusion: 不包含在清單中
+ invalid: 無效
+ less_than: 必須小於 %{count}
+ less_than_or_equal_to: 必須小於或等於 %{count}
+ model_invalid: '驗證失敗:%{errors}'
not_a_number: 不是數字
not_an_integer: 必須是整數
- odd: 必須是奇數
- other_than: 不可以是%{count}個字
- present: 必須是空白
+ odd: 必須為奇數
+ other_than: 必須不等於 %{count}
+ present: 必須為空白
required: 必須存在
taken: 已經被使用
- too_long: 過長(最長是%{count}個字)
- too_short: 過短(最短是%{count}個字)
- wrong_length: 字數錯誤(必須是%{count}個字)
+ too_long:
+ one: 太長(最多 %{count} 個字元)
+ other: 太長(最多 %{count} 個字元)
+ too_short:
+ one: 太短(最少 %{count} 個字元)
+ other: 太短(最少 %{count} 個字元)
+ wrong_length:
+ one: 長度錯誤(應為 %{count} 個字元)
+ other: 長度錯誤(應為 %{count} 個字元)
template:
- body: 以下欄位發生問題:
- header: 有%{count}個錯誤發生使得「%{model}」無法被儲存。
+ body: '以下欄位發生問題:'
+ header:
+ one: "有 %{count} 個錯誤導致此 %{model} 無法儲存"
+ other: "有 %{count} 個錯誤導致此 %{model} 無法儲存"
helpers:
select:
prompt: 請選擇
submit:
- create: 新增%{model}
- submit: 儲存%{model}
- update: 更新%{model}
+ create: 建立 %{model}
+ submit: 儲存 %{model}
+ update: 更新 %{model}
number:
currency:
format:
- delimiter: ","
- format: "%u%n"
- precision: 2
- separator: "."
significant: false
strip_insignificant_zeros: false
- unit: NT$
format:
delimiter: ","
precision: 3
+ round_mode: default
separator: "."
significant: false
strip_insignificant_zeros: false
@@ -140,25 +172,31 @@ zh-TW:
format: "%n %u"
units:
billion: 十億
- million: 百萬
+ million:
+ one: 百萬
+ other: 百萬
quadrillion: 千兆
thousand: 千
- trillion: 兆
+ trillion:
+ one: 兆
+ other: 兆
unit: ''
format:
delimiter: ''
- precision: 1
- significant: false
- strip_insignificant_zeros: false
+ precision: 3
+ significant: true
+ strip_insignificant_zeros: true
storage_units:
format: "%n %u"
units:
byte:
- one: 位元組
- other: 位元組
+ one: 位元組 (Byte)
+ other: 位元組 (Bytes)
+ eb: EB
gb: GB
kb: KB
mb: MB
+ pb: PB
tb: TB
percentage:
format:
@@ -169,13 +207,13 @@ zh-TW:
delimiter: ''
support:
array:
- last_word_connector: "、"
- two_words_connector: 和
+ last_word_connector: " 與 "
+ two_words_connector: " 與 "
words_connector: "、"
time:
am: 上午
formats:
- default: "%Y年%m月%d日 %A %H:%M:%S %Z"
+ default: "%Y年%m月%d日 (%a) %H:%M:%S %z"
long: "%Y年%m月%d日 %H:%M"
short: "%m月%d日 %H:%M"
pm: 下午
diff --git a/config/locales/doorkeeper.zh-TW.yml b/config/locales/doorkeeper.zh-TW.yml
new file mode 100644
index 000000000..c1232212b
--- /dev/null
+++ b/config/locales/doorkeeper.zh-TW.yml
@@ -0,0 +1,155 @@
+zh-TW:
+ activerecord:
+ attributes:
+ doorkeeper/application:
+ name: '應用程式名稱'
+ redirect_uri: '重新導向 URI'
+ errors:
+ models:
+ doorkeeper/application:
+ attributes:
+ redirect_uri:
+ fragment_present: '不能包含片段(Fragment, #)。'
+ invalid_uri: '必須是有效的 URI。'
+ unspecified_scheme: '必須指定通訊協定(Scheme)。'
+ relative_uri: '必須是絕對 URI。'
+ secured_uri: '必須是 HTTPS/SSL URI。'
+ forbidden_uri: '此 URI 被伺服器禁止。'
+ scopes:
+ not_match_configured: "與伺服器上配置的權限範圍(Scopes)不符。"
+
+ doorkeeper:
+ applications:
+ confirmations:
+ destroy: '您確定要刪除嗎?'
+ buttons:
+ edit: '編輯'
+ destroy: '刪除'
+ submit: '送出'
+ cancel: '取消'
+ authorize: '授權'
+ form:
+ error: '哎呀!請檢查您的表單是否有誤'
+ help:
+ confidential: '應用程式將用於可以保密客戶端密鑰(Client Secret)的環境。原生行動應用程式(Native mobile apps)和單頁應用程式(SPA)被視為非機密性應用。'
+ redirect_uri: '每行輸入一個 URI'
+ blank_redirect_uri: "如果您設定的授權類型為客戶端憑證(Client Credentials)、資源擁有者密碼憑證(Resource Owner Password Credentials)或其他不需要重新導向 URI 的類型,請留白。"
+ scopes: '以空格分隔權限範圍(Scopes)。留白則使用預設範圍。'
+ edit:
+ title: '編輯應用程式'
+ index:
+ title: '您的應用程式'
+ new: '新增應用程式'
+ name: '名稱'
+ callback_url: '回呼 URL (Callback URL)'
+ confidential: '機密?'
+ actions: '操作'
+ confidentiality:
+ 'yes': '是'
+ 'no': '否'
+ new:
+ title: '新增應用程式'
+ show:
+ title: '應用程式:%{name}'
+ application_id: '客戶端 ID (UID)'
+ secret: '客戶端密鑰 (Secret)'
+ secret_hashed: '密鑰雜湊值 (Secret hashed)'
+ scopes: '權限範圍 (Scopes)'
+ confidential: '機密性'
+ callback_urls: '回呼 URL'
+ actions: '操作'
+ not_defined: '未定義'
+
+ authorizations:
+ buttons:
+ authorize: '授權'
+ deny: '拒絕'
+ error:
+ title: '發生錯誤'
+ new:
+ title: '需要授權'
+ prompt: '是否授權 %{client_name} 使用您的帳號?'
+ able_to: '此應用程式將能夠'
+ show:
+ title: '授權碼'
+ form_post:
+ title: '送出表單'
+
+ authorized_applications:
+ confirmations:
+ revoke: '您確定要撤銷嗎?'
+ buttons:
+ revoke: '撤銷授權'
+ index:
+ title: '您已授權的應用程式'
+ application: '應用程式'
+ created_at: '授權時間'
+ date_format: '%Y-%m-%d %H:%M:%S'
+
+ pre_authorization:
+ status: '預先授權'
+
+ errors:
+ messages:
+ # 通用錯誤訊息
+ invalid_request:
+ unknown: '請求缺少必要參數、包含不支援的參數值,或格式有誤。'
+ missing_param: '缺少必要參數:%{value}。'
+ request_not_authorized: '請求需要授權。授權請求所需的參數缺失或無效。'
+ invalid_code_challenge: '需要 Code challenge。'
+ invalid_redirect_uri: "要求的重新導向 URI 格式錯誤,或與客戶端設定的 URI 不符。"
+ unauthorized_client: '客戶端未經授權,無法使用此方法執行此請求。'
+ access_denied: '資源擁有者或授權伺服器拒絕了請求。'
+ invalid_scope: '要求的權限範圍(Scope)無效、不明或格式錯誤。'
+ invalid_code_challenge_method:
+ zero: '授權伺服器不支援 PKCE,因為沒有可接受的 code_challenge_method 值。'
+ one: 'code_challenge_method 必須為 %{challenge_methods}。'
+ other: 'code_challenge_method 必須是 %{challenge_methods} 其中之一。'
+ server_error: '授權伺服器遇到預期之外的狀況,導致無法完成請求。'
+ temporarily_unavailable: '由於伺服器暫時超載或維護,授權伺服器目前無法處理請求。'
+
+ # 配置錯誤訊息
+ credential_flow_not_configured: '由於未設定 Doorkeeper.configure.resource_owner_from_credentials,資源擁有者密碼憑證流程失敗。'
+ resource_owner_authenticator_not_configured: '由於未設定 Doorkeeper.configure.resource_owner_authenticator,尋找資源擁有者失敗。'
+ admin_authenticator_not_configured: '由於未設定 Doorkeeper.configure.admin_authenticator,禁止訪問管理介面。'
+
+ # 授權許可錯誤 (Access grant errors)
+ unsupported_response_type: '授權伺服器不支援此回應類型(Response type)。'
+ unsupported_response_mode: '授權伺服器不支援此回應模式(Response mode)。'
+
+ # 存取權杖錯誤 (Access token errors)
+ invalid_client: '客戶端驗證失敗(原因可能是:未知客戶端、未包含客戶端驗證,或驗證方式不支援)。'
+ invalid_grant: '提供的授權許可(Grant)無效、已過期、已撤銷、與授權請求中使用的重新導向 URI 不符,或已核發給其他客戶端。'
+ unsupported_grant_type: '授權伺服器不支援此授權類型(Grant type)。'
+
+ invalid_token:
+ revoked: "存取權杖(Access token)已被撤銷"
+ expired: "存取權杖(Access token)已過期"
+ unknown: "存取權杖(Access token)無效"
+ revoke:
+ unauthorized: "您沒有權限撤銷此權杖"
+
+ forbidden_token:
+ missing_scope: '存取此資源需要權限範圍:"%{oauth_scopes}"。'
+
+ flash:
+ applications:
+ create:
+ notice: '應用程式已建立。'
+ destroy:
+ notice: '應用程式已刪除。'
+ update:
+ notice: '應用程式已更新。'
+ authorized_applications:
+ destroy:
+ notice: '應用程式授權已撤銷。'
+
+ layouts:
+ admin:
+ title: 'Doorkeeper'
+ nav:
+ oauth2_provider: 'OAuth2 提供者'
+ applications: '應用程式管理'
+ home: '首頁'
+ application:
+ title: '需要 OAuth 授權'
diff --git a/config/locales/mailers/invitation_mailer/zh-TW.yml b/config/locales/mailers/invitation_mailer/zh-TW.yml
new file mode 100644
index 000000000..90a812b88
--- /dev/null
+++ b/config/locales/mailers/invitation_mailer/zh-TW.yml
@@ -0,0 +1,5 @@
+---
+zh-TW:
+ invitation_mailer:
+ invite_email:
+ subject: "%{inviter} 邀請您加入他們在 %{product_name} 的家庭帳號!"
diff --git a/config/locales/models/account/zh-TW.yml b/config/locales/models/account/zh-TW.yml
new file mode 100644
index 000000000..3a18c0611
--- /dev/null
+++ b/config/locales/models/account/zh-TW.yml
@@ -0,0 +1,21 @@
+---
+zh-TW:
+ activerecord:
+ attributes:
+ account:
+ balance: 餘額
+ currency: 幣別
+ family: 家庭
+ family_id: 家庭
+ name: 名稱
+ subtype: 次類型
+ models:
+ account: 帳戶
+ account/credit: 信用卡
+ account/depository: 銀行帳戶
+ account/investment: 投資帳戶
+ account/loan: 貸款
+ account/other_asset: 其他資產
+ account/other_liability: 其他負債
+ account/property: 房產
+ account/vehicle: 車輛
diff --git a/config/locales/models/address/zh-TW.yml b/config/locales/models/address/zh-TW.yml
new file mode 100644
index 000000000..22f7189d1
--- /dev/null
+++ b/config/locales/models/address/zh-TW.yml
@@ -0,0 +1,11 @@
+---
+zh-TW:
+ address:
+ attributes:
+ country: 國家/地區
+ line1: 地址列 1
+ line2: 地址列 2
+ locality: 城市/地區
+ postal_code: 郵遞區號
+ region: 縣市/區域/鄉鎮
+ format: "%{country}%{region}%{locality}%{line1}%{line2} %{postal_code}"
diff --git a/config/locales/models/coinstats_item/en.yml b/config/locales/models/coinstats_item/en.yml
new file mode 100644
index 000000000..e02b561cf
--- /dev/null
+++ b/config/locales/models/coinstats_item/en.yml
@@ -0,0 +1,10 @@
+---
+en:
+ models:
+ coinstats_item:
+ syncer:
+ importing_wallets: Importing wallets from CoinStats...
+ checking_configuration: Checking wallet configuration...
+ wallets_need_setup: "%{count} wallets need setup..."
+ processing_holdings: Processing holdings...
+ calculating_balances: Calculating balances...
diff --git a/config/locales/models/entry/zh-TW.yml b/config/locales/models/entry/zh-TW.yml
new file mode 100644
index 000000000..6f6dd3216
--- /dev/null
+++ b/config/locales/models/entry/zh-TW.yml
@@ -0,0 +1,9 @@
+---
+zh-TW:
+ activerecord:
+ errors:
+ models:
+ entry:
+ attributes:
+ base:
+ invalid_sell_quantity: 無法賣出 %{sell_qty} 股 %{ticker},因為您目前僅持有 %{current_qty} 股
diff --git a/config/locales/models/import/zh-TW.yml b/config/locales/models/import/zh-TW.yml
new file mode 100644
index 000000000..21b7003a5
--- /dev/null
+++ b/config/locales/models/import/zh-TW.yml
@@ -0,0 +1,13 @@
+---
+zh-TW:
+ activerecord:
+ attributes:
+ import:
+ currency: 貨幣
+ number_format: 數字格式
+ errors:
+ models:
+ import:
+ attributes:
+ raw_file_str:
+ invalid_csv_format: 格式無效,不是合法的 CSV 格式
diff --git a/config/locales/models/time_series/value/zh-TW.yml b/config/locales/models/time_series/value/zh-TW.yml
new file mode 100644
index 000000000..ba7d74ec5
--- /dev/null
+++ b/config/locales/models/time_series/value/zh-TW.yml
@@ -0,0 +1,9 @@
+---
+zh-TW:
+ activemodel:
+ errors:
+ models:
+ time_series/value:
+ attributes:
+ value:
+ must_be_a_money_or_numeric: 必須是金錢類型或數字類型
diff --git a/config/locales/models/transfer/zh-TW.yml b/config/locales/models/transfer/zh-TW.yml
new file mode 100644
index 000000000..eb58235e6
--- /dev/null
+++ b/config/locales/models/transfer/zh-TW.yml
@@ -0,0 +1,17 @@
+zh-TW:
+ activerecord:
+ errors:
+ models:
+ transfer:
+ attributes:
+ base:
+ inflow_cannot_be_in_multiple_transfers: 流入交易不能同時屬於多筆轉帳
+ must_be_from_different_accounts: 轉帳的來源與目的帳戶必須不同
+ must_be_from_same_family: 轉帳必須屬於同一個家庭
+ must_be_within_date_range: 轉帳交易日期的差距不能超過 4 天
+ must_have_opposite_amounts: 轉帳交易的金額正負號必須相反
+ must_have_single_currency: 轉帳必須使用單一幣別
+ outflow_cannot_be_in_multiple_transfers: 流出交易不能同時屬於多筆轉帳
+ transfer:
+ name: 轉帳至 %{to_account}
+ payment_name: 付款至 %{to_account}
diff --git a/config/locales/models/trend/zh-TW.yml b/config/locales/models/trend/zh-TW.yml
new file mode 100644
index 000000000..f847a5c03
--- /dev/null
+++ b/config/locales/models/trend/zh-TW.yml
@@ -0,0 +1,13 @@
+---
+zh-TW:
+ activemodel:
+ errors:
+ models:
+ trend:
+ attributes:
+ current:
+ must_be_of_the_same_type_as_previous: 必須與 previous 的資料類型相同
+ must_be_of_type_money_numeric_or_nil: 必須是 Money、Numeric 或 nil 類型
+ previous:
+ must_be_of_the_same_type_as_current: 必須與 current 的資料類型相同
+ must_be_of_type_money_numeric_or_nil: 必須是 Money、Numeric 或 nil 類型
diff --git a/config/locales/models/user/zh-TW.yml b/config/locales/models/user/zh-TW.yml
new file mode 100644
index 000000000..7bce8e3d0
--- /dev/null
+++ b/config/locales/models/user/zh-TW.yml
@@ -0,0 +1,19 @@
+zh-TW:
+ activerecord:
+ attributes:
+ user:
+ email: 電子郵件
+ family: 家庭
+ family_id: 家庭
+ first_name: 名字
+ last_name: 姓氏
+ password: 密碼
+ password_confirmation: 確認密碼
+ errors:
+ models:
+ user:
+ attributes:
+ base:
+ cannot_deactivate_admin_with_other_users: 當系統內仍有其他使用者時,管理員無法刪除帳號。請先刪除所有成員。
+ profile_image:
+ invalid_file_size: 檔案大小必須小於 %{max_megabytes}MB
diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml
index 688ca8459..8ed337ef4 100644
--- a/config/locales/views/accounts/en.yml
+++ b/config/locales/views/accounts/en.yml
@@ -50,15 +50,19 @@ en:
activity:
amount: Amount
balance: Balance
+ confirmed: Confirmed
date: Date
entries: entries
entry: entry
+ filter: Filter
new: New
new_balance: New balance
new_transaction: New transaction
no_entries: No entries found
+ pending: Pending
search:
placeholder: Search entries by name
+ status: Status
title: Activity
chart:
balance: Balance
diff --git a/config/locales/views/accounts/zh-TW.yml b/config/locales/views/accounts/zh-TW.yml
new file mode 100644
index 000000000..9169f6b54
--- /dev/null
+++ b/config/locales/views/accounts/zh-TW.yml
@@ -0,0 +1,120 @@
+---
+zh-TW:
+ accounts:
+ account:
+ link_lunchflow: 連結 Lunch Flow
+ link_provider: 連結服務提供商
+ unlink_provider: 解除服務提供商連結
+ troubleshoot: 疑難排解
+ chart:
+ data_not_available: 所選時段內無資料
+ create:
+ success: "%{type} 帳戶已建立"
+ destroy:
+ success: "%{type} 帳戶已排定刪除"
+ cannot_delete_linked: "無法刪除已連結的帳戶。請先解除連結。"
+ empty:
+ empty_message: 請透過連線、匯入或手動輸入來新增帳戶。
+ new_account: 新增帳戶
+ no_accounts: 尚無帳戶
+ form:
+ balance: 目前餘額
+ name_label: 帳戶名稱
+ name_placeholder: 例如:薪資帳戶
+ additional_details: 額外詳細資訊
+ institution_name_label: 金融機構名稱
+ institution_name_placeholder: 例如:玉山銀行
+ institution_domain_label: 機構網域
+ institution_domain_placeholder: e.g., esunbank.com.tw
+ notes_label: 備註
+ notes_placeholder: 儲存額外資訊,例如帳號、分行代碼、IBAN、銀行轉帳號碼 (Routing numbers) 等。
+ index:
+ accounts: 帳戶管理
+ manual_accounts:
+ other_accounts: 其他帳戶
+ new_account: 新增帳戶
+ sync: 全部同步
+ sync_all:
+ syncing: "正在同步帳戶..."
+ new:
+ import_accounts: 匯入帳戶
+ method_selector:
+ connected_entry: 連結帳戶
+ connected_entry_eu: 連結歐盟 (EU) 帳戶
+ link_with_provider: "與 %{provider} 連結"
+ lunchflow_entry: 連結 Lunch Flow 帳戶
+ manual_entry: 輸入帳戶餘額
+ title: 您想如何新增?
+ title: 您想新增什麼內容?
+ show:
+ activity:
+ amount: 金額
+ balance: 餘額
+ date: 日期
+ entries: 筆紀錄
+ entry: 筆紀錄
+ new: 新增
+ new_balance: 新餘額
+ new_transaction: 新增交易
+ no_entries: 找不到任何紀錄
+ search:
+ placeholder: 以名稱搜尋紀錄
+ title: 活動紀錄
+ chart:
+ balance: 餘額
+ owed: 欠款金額
+ menu:
+ confirm_accept: 刪除「%{name}」
+ confirm_body_html: "刪除此帳戶將會抹除其價值歷史紀錄,並影響您帳戶的各個層面。這將直接影響您的淨值計算和帳戶圖表。
刪除後將無法還原帳戶資訊,若需找回則必須重新新增為新帳戶。
"
+ confirm_title: 確定要刪除帳戶嗎?
+ edit: 編輯
+ import: 匯入交易
+ manage: 管理帳戶
+ update:
+ success: "%{type} 帳戶已更新"
+ sidebar:
+ missing_data: 缺少歷史資料
+ missing_data_description: "%{product} 使用第三方提供商來獲取歷史匯率、證券價格等資訊。這些資料是用於計算準確歷史帳戶餘額所必需的。"
+ configure_providers: 在此設定您的提供商。
+ tabs:
+ all: 全部
+ assets: 資產
+ debts: 負債
+ new_asset: 新增資產
+ new_debt: 新增負債
+ new_account: 新增帳戶
+ new_account_group: "新增 %{account_group}"
+ types:
+ depository: 現金/存款
+ investment: 投資
+ crypto: 加密貨幣
+ property: 不動產/房產
+ vehicle: 車輛
+ other_asset: 其他資產
+ credit_card: 信用卡
+ loan: 貸款
+ other_liability: 其他負債
+ confirm_unlink:
+ title: 解除帳戶與提供商的連結?
+ description_html: "您即將解除 %{account_name} 與 %{provider_name} 的連結。這會將其轉換為手動管理的帳戶。"
+ warning_title: 這代表什麼
+ warning_no_sync: 帳戶將不再與您的銀行自動同步
+ warning_manual_updates: 您將需要手動新增交易並更新餘額
+ warning_transactions_kept: 所有現有的交易和餘額紀錄將會保留
+ warning_can_delete: 解除連結後,您將可以根據需要刪除該帳戶
+ confirm_button: 確認並解除連結
+ unlink:
+ success: "帳戶連結已成功解除。現在這是一個手動管理的帳戶。"
+ not_linked: "帳戶未與任何提供商連結"
+ error: "解除連結失敗:%{error}"
+ generic_error: "發生預期之外的錯誤。請再試一次。"
+ select_provider:
+ title: 選擇要連結的提供商
+ description: "選擇您想用來連結 %{account_name} 的提供商"
+ already_linked: "帳戶已經與提供商連結"
+ no_providers: "目前未設定任何提供商"
+
+ email_confirmations:
+ new:
+ invalid_token: 無效或已過期的驗證連結。
+ success_login: 您的電子郵件已通過驗證。請使用新的電子郵件地址登入。
diff --git a/config/locales/views/admin/sso_providers/en.yml b/config/locales/views/admin/sso_providers/en.yml
new file mode 100644
index 000000000..ff26989aa
--- /dev/null
+++ b/config/locales/views/admin/sso_providers/en.yml
@@ -0,0 +1,113 @@
+---
+en:
+ admin:
+ unauthorized: "You are not authorized to access this area."
+ sso_providers:
+ index:
+ title: "SSO Providers"
+ description: "Manage single sign-on authentication providers for your instance"
+ add_provider: "Add Provider"
+ no_providers_title: "No SSO Providers"
+ no_providers_message: "Get started by adding your first SSO provider."
+ note: "Changes to SSO providers require a server restart to take effect. Alternatively, enable the AUTH_PROVIDERS_SOURCE=db feature flag to load providers from the database dynamically."
+ table:
+ name: "Name"
+ strategy: "Strategy"
+ status: "Status"
+ issuer: "Issuer"
+ actions: "Actions"
+ enabled: "Enabled"
+ disabled: "Disabled"
+ legacy_providers_title: "Environment-Configured Providers"
+ legacy_providers_notice: "These providers are configured via environment variables or YAML and cannot be managed through this interface. To manage them here, migrate them to database-backed providers by enabling AUTH_PROVIDERS_SOURCE=db and recreating them in the UI."
+ env_configured: "Env/YAML"
+ new:
+ title: "Add SSO Provider"
+ description: "Configure a new single sign-on authentication provider"
+ edit:
+ title: "Edit SSO Provider"
+ description: "Update configuration for %{label}"
+ create:
+ success: "SSO provider was successfully created."
+ update:
+ success: "SSO provider was successfully updated."
+ destroy:
+ success: "SSO provider was successfully deleted."
+ confirm: "Are you sure you want to delete this provider? This action cannot be undone."
+ toggle:
+ success_enabled: "SSO provider was successfully enabled."
+ success_disabled: "SSO provider was successfully disabled."
+ confirm_enable: "Are you sure you want to enable this provider?"
+ confirm_disable: "Are you sure you want to disable this provider?"
+ form:
+ basic_information: "Basic Information"
+ oauth_configuration: "OAuth/OIDC Configuration"
+ strategy_label: "Strategy"
+ strategy_help: "The authentication strategy to use"
+ name_label: "Name"
+ name_placeholder: "e.g., openid_connect, keycloak, authentik"
+ name_help: "Unique identifier (lowercase, numbers, underscores only)"
+ label_label: "Label"
+ label_placeholder: "e.g., Sign in with Keycloak"
+ label_help: "Button text shown to users"
+ icon_label: "Icon"
+ icon_placeholder: "e.g., key, google, github"
+ icon_help: "Lucide icon name (optional)"
+ enabled_label: "Enable this provider"
+ enabled_help: "Users can sign in with this provider when enabled"
+ issuer_label: "Issuer"
+ issuer_placeholder: "https://accounts.google.com"
+ issuer_help: "OIDC issuer URL (will validate .well-known/openid-configuration endpoint)"
+ client_id_label: "Client ID"
+ client_id_placeholder: "your-client-id"
+ client_id_help: "OAuth client ID from your identity provider"
+ client_secret_label: "Client Secret"
+ client_secret_placeholder_new: "your-client-secret"
+ client_secret_placeholder_existing: "••••••••••••••••"
+ client_secret_help: "OAuth client secret (encrypted in database)"
+ client_secret_help_existing: " - leave blank to keep existing"
+ redirect_uri_label: "Redirect URI"
+ redirect_uri_placeholder: "https://yourdomain.com/auth/openid_connect/callback"
+ redirect_uri_help: "Callback URL to configure in your identity provider"
+ copy_button: "Copy"
+ cancel: "Cancel"
+ submit: "Save Provider"
+ errors_title: "%{count} error prohibited this provider from being saved:"
+ provisioning_title: "User Provisioning"
+ default_role_label: "Default Role for New Users"
+ default_role_help: "Role assigned to users created via just-in-time (JIT) SSO account provisioning. Defaults to Member."
+ role_member: "Member"
+ role_admin: "Admin"
+ role_super_admin: "Super Admin"
+ role_mapping_title: "Group to Role Mapping (Optional)"
+ role_mapping_help: "Map IdP groups/claims to application roles. Users are assigned the highest matching role. Leave blank to use the default role above."
+ super_admin_groups: "Super Admin Groups"
+ admin_groups: "Admin Groups"
+ member_groups: "Member Groups"
+ groups_help: "Comma-separated list of IdP group names. Use * to match all groups."
+ advanced_title: "Advanced OIDC Settings"
+ scopes_label: "Custom Scopes"
+ scopes_help: "Space-separated list of OIDC scopes. Leave blank for defaults (openid email profile). Add 'groups' to retrieve group claims."
+ prompt_label: "Authentication Prompt"
+ prompt_default: "Default (IdP decides)"
+ prompt_login: "Force Login (re-authenticate)"
+ prompt_consent: "Force Consent (re-authorize)"
+ prompt_select_account: "Account Selection (choose account)"
+ prompt_none: "No Prompt (silent auth)"
+ prompt_help: "Controls how the IdP prompts the user during authentication."
+ test_connection: "Test Connection"
+ saml_configuration: "SAML Configuration"
+ idp_metadata_url: "IdP Metadata URL"
+ idp_metadata_url_help: "URL to your IdP's SAML metadata. If provided, other SAML settings will be auto-configured."
+ manual_saml_config: "Manual Configuration (if not using metadata URL)"
+ manual_saml_help: "Only use these settings if your IdP doesn't provide a metadata URL."
+ idp_sso_url: "IdP SSO URL"
+ idp_slo_url: "IdP SLO URL (optional)"
+ idp_certificate: "IdP Certificate"
+ idp_certificate_help: "X.509 certificate in PEM format. Required if not using metadata URL."
+ idp_cert_fingerprint: "Certificate Fingerprint (alternative)"
+ name_id_format: "NameID Format"
+ name_id_email: "Email Address (default)"
+ name_id_persistent: "Persistent"
+ name_id_transient: "Transient"
+ name_id_unspecified: "Unspecified"
diff --git a/config/locales/views/admin/users/en.yml b/config/locales/views/admin/users/en.yml
new file mode 100644
index 000000000..6e77b7011
--- /dev/null
+++ b/config/locales/views/admin/users/en.yml
@@ -0,0 +1,22 @@
+---
+en:
+ admin:
+ users:
+ index:
+ title: "User Management"
+ description: "Manage user roles for your instance. Super admins can access SSO provider settings and user management."
+ section_title: "Users"
+ you: "(You)"
+ no_users: "No users found."
+ role_descriptions_title: "Role Descriptions"
+ roles:
+ member: "Member"
+ admin: "Admin"
+ super_admin: "Super Admin"
+ role_descriptions:
+ member: "Basic user access. Can manage their own accounts, transactions, and settings."
+ admin: "Family administrator. Can access advanced settings like API keys, imports, and AI prompts."
+ super_admin: "Instance administrator. Can manage SSO providers, user roles, and impersonate users for support."
+ update:
+ success: "User role updated successfully."
+ failure: "Failed to update user role."
diff --git a/config/locales/views/application/zh-TW.yml b/config/locales/views/application/zh-TW.yml
new file mode 100644
index 000000000..edbbde281
--- /dev/null
+++ b/config/locales/views/application/zh-TW.yml
@@ -0,0 +1,10 @@
+---
+zh-TW:
+ number:
+ currency:
+ format:
+ delimiter: ","
+ format: "%u%n"
+ precision: 2
+ separator: "."
+ unit: "NT$"
diff --git a/config/locales/views/budgets/zh-TW.yml b/config/locales/views/budgets/zh-TW.yml
new file mode 100644
index 000000000..e35d802ce
--- /dev/null
+++ b/config/locales/views/budgets/zh-TW.yml
@@ -0,0 +1,7 @@
+---
+zh-TW:
+ budgets:
+ show:
+ tabs:
+ actual: 實際
+ budgeted: 預算
diff --git a/config/locales/views/categories/zh-TW.yml b/config/locales/views/categories/zh-TW.yml
new file mode 100644
index 000000000..05fb75013
--- /dev/null
+++ b/config/locales/views/categories/zh-TW.yml
@@ -0,0 +1,34 @@
+---
+zh-TW:
+ categories:
+ bootstrap:
+ success: 預設分類已建立成功
+ category:
+ delete: 刪除分類
+ edit: 編輯分類
+ create:
+ success: 分類建立成功
+ destroy:
+ success: 分類刪除成功
+ edit:
+ edit: 編輯分類
+ form:
+ placeholder: 分類名稱
+ index:
+ bootstrap: 使用預設分類(推薦)
+ categories: 分類管理
+ categories_expenses: 支出分類
+ categories_incomes: 收入分類
+ empty: 暫無分類
+ new: 新建分類
+ menu:
+ loading: 載入中...
+ new:
+ new_category: 新建分類
+ update:
+ success: 分類更新成功
+ category:
+ dropdowns:
+ show:
+ bootstrap: 產生預設分類
+ empty: 暫無分類
diff --git a/config/locales/views/category/deletions/zh-TW.yml b/config/locales/views/category/deletions/zh-TW.yml
new file mode 100644
index 000000000..096789aa4
--- /dev/null
+++ b/config/locales/views/category/deletions/zh-TW.yml
@@ -0,0 +1,13 @@
+---
+zh-TW:
+ category:
+ deletions:
+ create:
+ success: 交易分類已刪除成功
+ new:
+ category: 分類
+ delete_and_leave_uncategorized: 刪除「%{category_name}」並保留為未分類
+ delete_and_recategorize: 刪除「%{category_name}」並重新分配分類
+ delete_category: 確認刪除分類?
+ explanation: 刪除此分類後,所有標記為「%{category_name}」的交易將變為未分類狀態。您也可以在下述選項中選擇一個新分類進行重新分配。
+ replacement_category_prompt: 選擇新分類
diff --git a/config/locales/views/category/dropdowns/zh-TW.yml b/config/locales/views/category/dropdowns/zh-TW.yml
new file mode 100644
index 000000000..9970c4bd4
--- /dev/null
+++ b/config/locales/views/category/dropdowns/zh-TW.yml
@@ -0,0 +1,11 @@
+---
+zh-TW:
+ category:
+ dropdowns:
+ row:
+ delete: 刪除分類
+ edit: 編輯分類
+ show:
+ clear: 清空分類
+ no_categories: 暫無分類
+ search_placeholder: 搜尋分類
diff --git a/config/locales/views/chats/en.yml b/config/locales/views/chats/en.yml
index 26c4c9a7d..89bd1d152 100644
--- a/config/locales/views/chats/en.yml
+++ b/config/locales/views/chats/en.yml
@@ -3,4 +3,3 @@ en:
chats:
demo_banner_title: "Demo Mode Active"
demo_banner_message: "You are using an open-weights Qwen3 LLM with credits provided by Cloudflare Workers AI. Result may vary since the codebase was mostly tested on `gpt-4.1` but your tokens don't go anywhere else to be trained with! 🤖"
-
diff --git a/config/locales/views/chats/zh-TW.yml b/config/locales/views/chats/zh-TW.yml
new file mode 100644
index 000000000..9bd208f0d
--- /dev/null
+++ b/config/locales/views/chats/zh-TW.yml
@@ -0,0 +1,5 @@
+---
+zh-TW:
+ chats:
+ demo_banner_message: "您正在使用由 Cloudflare Workers AI 提供額度的開源權重 Qwen3 大語言模型。由於程式碼庫主要在 `gpt-4.1` 上測試,結果可能有所不同,但您的令牌不會被用於其他地方進行訓練!🤖"
+ demo_banner_title: 演示模式已啟動
diff --git a/config/locales/views/components/en.yml b/config/locales/views/components/en.yml
index 296d151d6..6e5d198f9 100644
--- a/config/locales/views/components/en.yml
+++ b/config/locales/views/components/en.yml
@@ -24,6 +24,28 @@ en:
rate_limited: "Rate limited %{time_ago}"
recently: recently
errors: "Errors: %{count}"
+ pending_reconciled:
+ one: "%{count} duplicate pending transaction reconciled"
+ other: "%{count} duplicate pending transactions reconciled"
+ view_reconciled: View reconciled transactions
+ duplicate_suggestions:
+ one: "%{count} possible duplicate needs review"
+ other: "%{count} possible duplicates need review"
+ view_duplicate_suggestions: View suggested duplicates
+ stale_pending:
+ one: "%{count} stale pending transaction (excluded from budgets)"
+ other: "%{count} stale pending transactions (excluded from budgets)"
+ view_stale_pending: View affected accounts
+ stale_pending_count:
+ one: "%{count} transaction"
+ other: "%{count} transactions"
+ stale_unmatched:
+ one: "%{count} pending transaction needs manual review"
+ other: "%{count} pending transactions need manual review"
+ view_stale_unmatched: View transactions needing review
+ stale_unmatched_count:
+ one: "%{count} transaction"
+ other: "%{count} transactions"
data_warnings: "Data warnings: %{count}"
notices: "Notices: %{count}"
view_data_quality: View data quality details
diff --git a/config/locales/views/credit_cards/zh-TW.yml b/config/locales/views/credit_cards/zh-TW.yml
new file mode 100644
index 000000000..11bbdc11c
--- /dev/null
+++ b/config/locales/views/credit_cards/zh-TW.yml
@@ -0,0 +1,25 @@
+---
+zh-TW:
+ credit_cards:
+ edit:
+ edit: 編輯 %{account}
+ form:
+ annual_fee: 年費
+ annual_fee_placeholder: '99'
+ apr: 年利率
+ apr_placeholder: '15.99'
+ available_credit: 可用額度
+ available_credit_placeholder: '10000'
+ expiration_date: 到期日期
+ minimum_payment: 最低還款額
+ minimum_payment_placeholder: '100'
+ new:
+ title: 請輸入信用卡詳細資訊
+ overview:
+ amount_owed: 欠款總額
+ annual_fee: 年費
+ apr: 年利率
+ available_credit: 可用額度
+ expiration_date: 到期日期
+ minimum_payment: 最低還款額
+ unknown: 未知
diff --git a/config/locales/views/cryptos/zh-TW.yml b/config/locales/views/cryptos/zh-TW.yml
new file mode 100644
index 000000000..3c7535956
--- /dev/null
+++ b/config/locales/views/cryptos/zh-TW.yml
@@ -0,0 +1,7 @@
+---
+zh-TW:
+ cryptos:
+ edit:
+ edit: 編輯 %{account}
+ new:
+ title: 請輸入帳戶餘額
diff --git a/config/locales/views/depositories/zh-TW.yml b/config/locales/views/depositories/zh-TW.yml
new file mode 100644
index 000000000..3e69e4b0d
--- /dev/null
+++ b/config/locales/views/depositories/zh-TW.yml
@@ -0,0 +1,10 @@
+---
+zh-TW:
+ depositories:
+ edit:
+ edit: 編輯 %{account}
+ form:
+ none: 無
+ subtype_prompt: 選擇帳戶類型
+ new:
+ title: 請輸入帳戶餘額
diff --git a/config/locales/views/email_confirmation_mailer/zh-TW.yml b/config/locales/views/email_confirmation_mailer/zh-TW.yml
new file mode 100644
index 000000000..85256ae58
--- /dev/null
+++ b/config/locales/views/email_confirmation_mailer/zh-TW.yml
@@ -0,0 +1,9 @@
+---
+zh-TW:
+ email_confirmation_mailer:
+ confirmation_email:
+ body: 您最近要求變更電子信箱地址。請點擊下方按鈕確認此次變更。
+ cta: 確認電子信箱變更
+ expiry_notice: 此連結將在 %{hours} 小時後失效。
+ greeting: 您好!
+ subject: "%{product_name}:確認您的電子信箱變更"
diff --git a/config/locales/views/enable_banking_items/zh-TW.yml b/config/locales/views/enable_banking_items/zh-TW.yml
new file mode 100644
index 000000000..fc33ae06d
--- /dev/null
+++ b/config/locales/views/enable_banking_items/zh-TW.yml
@@ -0,0 +1,49 @@
+---
+zh-TW:
+ enable_banking_items:
+ authorize:
+ authorization_failed: 啟動授權失敗
+ bank_required: 請選擇一家銀行。
+ invalid_redirect: 收到的授權 URL 無效。請重試或聯絡支援人員。
+ redirect_uri_not_allowed: 不允許重新導向。請在 Enable Banking 應用設定中設定 `%{callback_url}`。
+ unexpected_error: 發生未預期的錯誤。請重試。
+ callback:
+ authorization_error: 授權失敗
+ invalid_callback: 回呼參數無效。
+ item_not_found: 未找到連線。
+ session_failed: 無法完成授權
+ success: 已成功連線到您的銀行。您的帳戶正在同步中。
+ unexpected_error: 發生未預期的錯誤。請重試。
+ complete_account_setup:
+ all_skipped: 已跳過所有帳戶。您可以稍後在帳戶頁面進行設定。
+ no_accounts: 沒有可設定的帳戶。
+ success: 成功建立了 %{count} 個帳戶!
+ create:
+ success: Enable Banking 設定成功。
+ destroy:
+ success: Enable Banking 連線已加入刪除隊列。
+ link_accounts:
+ already_linked: 所選帳戶已關聯。
+ link_failed: 關聯帳戶失敗
+ no_accounts_selected: 未選擇任何帳戶。
+ no_session: 無活躍的 Enable Banking 連線。請先連線一家銀行。
+ success: 成功關聯了 %{count} 個帳戶。
+ link_existing_account:
+ errors:
+ invalid_enable_banking_account: 所選 Enable Banking 帳戶無效
+ only_manual: 只能關聯手動帳戶
+ success: 帳戶已成功關聯到 Enable Banking
+ new:
+ link_enable_banking_title: 關聯 Enable Banking
+ reauthorize:
+ invalid_redirect: 收到的授權 URL 無效。請重試或聯絡支援人員。
+ reauthorization_failed: 重新授權失敗
+ select_bank:
+ cancel: 取消
+ check_country: 請檢查您的國家代碼設定。
+ credentials_required: 請先設定您的 Enable Banking 憑證。
+ description: 選擇您想要連線到帳戶的銀行。
+ no_banks: 該國家/地區暫無可用銀行。
+ title: 選擇您的銀行
+ update:
+ success: Enable Banking 設定已更新。
diff --git a/config/locales/views/entries/zh-TW.yml b/config/locales/views/entries/zh-TW.yml
new file mode 100644
index 000000000..2cac5aa6b
--- /dev/null
+++ b/config/locales/views/entries/zh-TW.yml
@@ -0,0 +1,14 @@
+---
+zh-TW:
+ entries:
+ create:
+ success: 記錄已建立
+ destroy:
+ success: 記錄已刪除
+ empty:
+ description: 嘗試新增記錄、編輯篩選條件或最佳化搜尋詞
+ title: 未找到相關記錄
+ loading:
+ loading: 正在載入記錄...
+ update:
+ success: 記錄已更新
diff --git a/config/locales/views/family_exports/zh-TW.yml b/config/locales/views/family_exports/zh-TW.yml
new file mode 100644
index 000000000..198743f08
--- /dev/null
+++ b/config/locales/views/family_exports/zh-TW.yml
@@ -0,0 +1,7 @@
+---
+zh-TW:
+ family_exports:
+ list:
+ complete: 已完成
+ failed: 已失敗
+ in_progress: 進行中
diff --git a/config/locales/views/holdings/en.yml b/config/locales/views/holdings/en.yml
index 933a8b839..0e6d588d4 100644
--- a/config/locales/views/holdings/en.yml
+++ b/config/locales/views/holdings/en.yml
@@ -5,9 +5,30 @@ en:
brokerage_cash: Brokerage cash
destroy:
success: Holding deleted
+ update:
+ success: Cost basis saved.
+ error: Invalid cost basis value.
+ unlock_cost_basis:
+ success: Cost basis unlocked. It may be updated on next sync.
+ cost_basis_sources:
+ manual: User set
+ calculated: From trades
+ provider: From provider
+ cost_basis_cell:
+ unknown: "--"
+ set_cost_basis_header: "Set cost basis for %{ticker} (%{qty} shares)"
+ total_cost_basis_label: Total cost basis
+ or_per_share_label: "Or enter per share:"
+ per_share: per share
+ cancel: Cancel
+ save: Save
+ overwrite_confirm_title: Overwrite cost basis?
+ overwrite_confirm_body: "This will replace the current cost basis of %{current}."
holding:
per_share: per share
shares: "%{qty} shares"
+ unknown: "--"
+ no_cost_basis: No cost basis
index:
average_cost: Average cost
holdings: Holdings
@@ -35,3 +56,8 @@ en:
trade_history_entry: "%{qty} shares of %{security} at %{price}"
total_return_label: Total Return
unknown: Unknown
+ cost_basis_locked_label: Cost basis is locked
+ cost_basis_locked_description: Your manually set cost basis won't be changed by syncs.
+ unlock_cost_basis: Unlock
+ unlock_confirm_title: Unlock cost basis?
+ unlock_confirm_body: This will allow the cost basis to be updated by provider syncs or trade calculations.
diff --git a/config/locales/views/holdings/zh-TW.yml b/config/locales/views/holdings/zh-TW.yml
new file mode 100644
index 000000000..e1f797af7
--- /dev/null
+++ b/config/locales/views/holdings/zh-TW.yml
@@ -0,0 +1,35 @@
+---
+zh-TW:
+ holdings:
+ cash:
+ brokerage_cash: 經紀帳戶現金
+ destroy:
+ success: 持股已刪除
+ holding:
+ per_share: 每股
+ shares: "%{qty} 股"
+ index:
+ average_cost: 平均成本
+ holdings: 持股
+ name: 名稱
+ new_holding: 新建交易
+ no_holdings: 暫無持股記錄
+ return: 總收益
+ weight: 權重
+ missing_price_tooltip:
+ description: 此投資缺少價格資料,無法計算其收益或價值。
+ missing_data: 資料缺失
+ show:
+ avg_cost_label: 平均成本
+ current_market_price_label: 目前市價
+ delete: 刪除
+ delete_subtitle: 此操作將刪除該持股及相關所有交易記錄,且不可復原。
+ delete_title: 刪除持股
+ history: 歷史記錄
+ overview: 概覽
+ portfolio_weight_label: 投資組合權重
+ settings: 設定
+ ticker_label: 股票代碼
+ total_return_label: 總收益
+ trade_history_entry: "%{qty} 股 %{security} @ %{price}"
+ unknown: 未知
diff --git a/config/locales/views/impersonation_sessions/zh-TW.yml b/config/locales/views/impersonation_sessions/zh-TW.yml
new file mode 100644
index 000000000..a680ad1f8
--- /dev/null
+++ b/config/locales/views/impersonation_sessions/zh-TW.yml
@@ -0,0 +1,15 @@
+---
+zh-TW:
+ impersonation_sessions:
+ approve:
+ success: 請求已批准
+ complete:
+ success: 工作階段已完成
+ create:
+ success: 請求已傳送給使用者,等待批准中
+ join:
+ success: 已加入工作階段
+ leave:
+ success: 已離開工作階段
+ reject:
+ success: 請求已拒絕
diff --git a/config/locales/views/imports/zh-TW.yml b/config/locales/views/imports/zh-TW.yml
new file mode 100644
index 000000000..cf7ff7569
--- /dev/null
+++ b/config/locales/views/imports/zh-TW.yml
@@ -0,0 +1,88 @@
+---
+zh-TW:
+ import:
+ cleans:
+ show:
+ description: 在下表中編輯您的資料。紅色的儲存格為無效內容。
+ errors_notice: 您的資料中有錯誤。將游標移至錯誤上方可查看詳細資訊。
+ errors_notice_mobile: 您的資料中有錯誤。點擊錯誤提示框可查看詳細資訊。
+ title: 校正您的資料
+ configurations:
+ category_import:
+ button_label: 繼續
+ description: 上傳一個簡單的 CSV 檔案(類似於匯出資料時產生的檔案)。我們將自動為您對應欄位。
+ instructions: 點擊繼續以解析您的 CSV 並進入校正步驟。
+ mint_import:
+ date_format_label: 日期格式
+ rule_import:
+ description: 設定規則匯入。系統將根據 CSV 資料建立或更新規則。
+ process_button: 處理規則
+ process_help: 點擊下方按鈕以處理您的 CSV 並產生規則資料列。
+ show:
+ description: 選擇 CSV 中對應各個欄位的項目。
+ title: 設定您的匯入項目
+ trade_import:
+ date_format_label: 日期格式
+ transaction_import:
+ date_format_label: 日期格式
+ confirms:
+ mappings:
+ create_account: 建立帳戶
+ csv_mapping_label: "CSV 中的 %{mapping}"
+ sure_mapping_label: "%{product_name} 中的 %{mapping}"
+ no_accounts: 您尚未建立任何帳戶。請建立一個帳戶以便分配 CSV 中(未指定)的資料列,或返回「校正」步驟並提供可使用的帳戶名稱。
+ rows_label: 資料列
+ unassigned_account: 需要為未分配的資料列建立新帳戶嗎?
+ show:
+ account_mapping_description: 將匯入檔案中的帳戶對應到 Maybe 現有的帳戶。您也可以新增帳戶,或暫時不進行分類。
+ account_mapping_title: 分配您的帳戶
+ account_type_mapping_description: 將匯入檔案中的帳戶類型對應到 Maybe 的帳戶類型
+ account_type_mapping_title: 分配您的帳戶類型
+ category_mapping_description: 將匯入檔案中的類別對應到 Maybe 現有的類別。您也可以新增類別,或暫時不進行分類。
+ category_mapping_title: 分配您的類別
+ tag_mapping_description: 將匯入檔案中的標籤對應到 Maybe 現有的標籤。您也可以新增標籤,或暫時不進行分類。
+ tag_mapping_title: 分配您的標籤
+ uploads:
+ show:
+ description: 在下方貼上或上傳您的 CSV 檔案。開始前請先閱讀下表中的說明。
+ instructions_1: 以下是可用於匯入的 CSV 範例欄位。
+ instructions_2: 您的 CSV 檔案必須包含標題列 (Header row)
+ instructions_3: 您可以隨意命名您的欄位標題,稍後步驟中再進行對應即可。
+ instructions_4: 標有星號 (*) 的欄位為必填資料。
+ instructions_5: 數字中請勿包含逗號、貨幣符號或括號。
+ title: 匯入您的資料
+ imports:
+ empty:
+ message: 尚無匯入紀錄。
+ new: 新增匯入
+ import:
+ complete: 已完成
+ delete: 刪除
+ failed: 失敗
+ in_progress: 處理中
+ label: "%{type}:%{datetime}"
+ revert_failed: 還原失敗
+ reverting: 還原中
+ uploading: 正在處理資料列
+ view: 查看
+ index:
+ imports: 匯入紀錄
+ new: 新增匯入
+ title: 匯入/匯出
+ exports: 匯出紀錄
+ new_export: 新增匯出
+ no_exports: 尚無匯出紀錄。
+ new:
+ description: 您可以透過 CSV 手動匯入各種類型的資料,或使用我們提供的匯入範本(如 Mint)。
+ import_accounts: 匯入帳戶
+ import_categories: 匯入類別
+ import_mint: 從 Mint 匯入
+ import_portfolio: 匯入投資組合
+ import_rules: 匯入規則
+ import_transactions: 匯入交易紀錄
+ resume: 繼續處理 %{type}
+ sources: 來源
+ title: 新增 CSV 匯入
+ ready:
+ description: 以下是發佈此匯入後,將新增至您帳戶的項目摘要。
+ title: 確認您的匯入資料
diff --git a/config/locales/views/investments/zh-TW.yml b/config/locales/views/investments/zh-TW.yml
new file mode 100644
index 000000000..650198055
--- /dev/null
+++ b/config/locales/views/investments/zh-TW.yml
@@ -0,0 +1,17 @@
+---
+zh-TW:
+ investments:
+ edit:
+ edit: 編輯 %{account}
+ form:
+ none: 無
+ subtype_prompt: 選擇投資類型
+ new:
+ title: 請輸入帳戶餘額
+ show:
+ chart_title: 總價值
+ value_tooltip:
+ cash: 現金
+ holdings: 持股
+ total: 投資組合餘額
+ total_value_tooltip: 總投資組合餘額等於經紀帳戶現金(可用於交易)與持股目前市值的總和。
diff --git a/config/locales/views/invitation_mailer/zh-TW.yml b/config/locales/views/invitation_mailer/zh-TW.yml
new file mode 100644
index 000000000..588662635
--- /dev/null
+++ b/config/locales/views/invitation_mailer/zh-TW.yml
@@ -0,0 +1,8 @@
+---
+zh-TW:
+ invitation_mailer:
+ invite_email:
+ accept_button: 接受邀請
+ body: "%{inviter} 邀請您加入 %{family} 家庭,共同使用 %{product_name}!"
+ expiry_notice: 此邀請將在 %{days} 天後過期
+ greeting: 歡迎使用 %{product_name}!
diff --git a/config/locales/views/invitations/zh-TW.yml b/config/locales/views/invitations/zh-TW.yml
new file mode 100644
index 000000000..8349023db
--- /dev/null
+++ b/config/locales/views/invitations/zh-TW.yml
@@ -0,0 +1,19 @@
+---
+zh-TW:
+ invitations:
+ create:
+ failure: 傳送邀請失敗
+ success: 邀請傳送成功
+ destroy:
+ failure: 刪除邀請時出現問題。
+ not_authorized: 您沒有管理邀請的權限。
+ success: 邀請已成功刪除。
+ new:
+ email_label: 電子信箱地址
+ email_placeholder: 請輸入電子信箱地址
+ role_admin: 管理員
+ role_label: 角色
+ role_member: 成員
+ submit: 傳送邀請
+ subtitle: 傳送邀請,讓對方加入您在 %{product_name} 的家庭帳戶
+ title: 邀請成員
diff --git a/config/locales/views/invite_codes/zh-TW.yml b/config/locales/views/invite_codes/zh-TW.yml
new file mode 100644
index 000000000..b42282860
--- /dev/null
+++ b/config/locales/views/invite_codes/zh-TW.yml
@@ -0,0 +1,6 @@
+---
+zh-TW:
+ invite_codes:
+ index:
+ invite_code_description: 產生新代碼後將會顯示在此處。已使用的產生代碼將不再顯示。
+ no_invite_codes: 暫無邀請碼
diff --git a/config/locales/views/layout/zh-TW.yml b/config/locales/views/layout/zh-TW.yml
new file mode 100644
index 000000000..0e2b1487e
--- /dev/null
+++ b/config/locales/views/layout/zh-TW.yml
@@ -0,0 +1,19 @@
+---
+zh-TW:
+ layouts:
+ application:
+ nav:
+ assistant: 助手
+ budgets: 預算
+ home: 首頁
+ reports: 報表
+ transactions: 交易紀錄
+ auth:
+ existing_account: 已經有帳號了?
+ no_account: 第一次使用 %{product_name} 嗎?
+ sign_in: 登入
+ sign_up: 建立帳號
+ shared:
+ footer:
+ privacy_policy: 隱私權政策
+ terms_of_service: 服務條款
diff --git a/config/locales/views/loans/zh-TW.yml b/config/locales/views/loans/zh-TW.yml
new file mode 100644
index 000000000..9ef31669c
--- /dev/null
+++ b/config/locales/views/loans/zh-TW.yml
@@ -0,0 +1,23 @@
+---
+zh-TW:
+ loans:
+ edit:
+ edit: 編輯 %{account}
+ form:
+ interest_rate: 利率
+ interest_rate_placeholder: '5.25'
+ initial_balance: 原始貸款餘額
+ rate_type: 利率類型
+ term_months: 貸款期限(月)
+ term_months_placeholder: '360'
+ new:
+ title: 輸入貸款詳情
+ overview:
+ interest_rate: 利率
+ monthly_payment: 每月還款額
+ not_applicable: 不適用
+ original_principal: 原始本金
+ remaining_principal: 剩餘本金
+ term: 貸款期限
+ type: 類型
+ unknown: 未知
diff --git a/config/locales/views/lunchflow_items/zh-TW.yml b/config/locales/views/lunchflow_items/zh-TW.yml
new file mode 100644
index 000000000..2b51c3775
--- /dev/null
+++ b/config/locales/views/lunchflow_items/zh-TW.yml
@@ -0,0 +1,143 @@
+---
+zh-TW:
+ lunchflow_items:
+ create:
+ success: Lunch Flow 連線建立成功
+ destroy:
+ success: Lunch Flow 連線已移除
+ index:
+ title: Lunch Flow 連線
+ loading:
+ loading_message: 正在載入 Lunch Flow 帳戶...
+ loading_title: 載入中
+ link_accounts:
+ all_already_linked:
+ one: "所選的帳戶 (%{names}) 已經連結"
+ other: "所有 %{count} 個選定的帳戶都已經連結:%{names}"
+ api_error: "API 錯誤:%{message}"
+ invalid_account_names:
+ one: "無法連結名稱空白的帳戶"
+ other: "無法連結 %{count} 個名稱空白的帳戶"
+ link_failed: 連結帳戶失敗
+ no_accounts_selected: 請至少選擇一個帳戶
+ partial_invalid: "已成功連結 %{created_count} 個帳戶,%{already_linked_count} 個原本就已連結,%{invalid_count} 個帳戶名稱無效"
+ partial_success: "已成功連結 %{created_count} 個帳戶。其中 %{already_linked_count} 個原本就已連結:%{already_linked_names}"
+ success:
+ one: "已成功連結 %{count} 個帳戶"
+ other: "已成功連結 %{count} 個帳戶"
+ lunchflow_item:
+ accounts_need_setup: 帳戶需要設定
+ delete: 刪除連線
+ deletion_in_progress: 正在刪除...
+ error: 錯誤
+ no_accounts_description: 此連線尚未連結任何帳戶。
+ no_accounts_title: 無帳戶
+ setup_action: 設定新帳戶
+ setup_description: "已連結 %{total} 個帳戶中的 %{linked} 個。請為您新匯入的 Lunch Flow 帳戶選擇帳戶類型。"
+ setup_needed: 新帳戶已準備好進行設定
+ status: "%{timestamp} 前已同步"
+ status_never: 從未同步
+ status_with_summary: "最後同步於 %{timestamp} 前 • %{summary}"
+ syncing: 同步中...
+ total: 總計
+ unlinked: 未連結
+ select_accounts:
+ accounts_selected: 個帳戶已選擇
+ api_error: "API 錯誤:%{message}"
+ cancel: 取消
+ configure_name_in_lunchflow: 無法匯入 - 請在 Lunch Flow 中設定帳戶名稱
+ description: 選擇您想要連結到 %{product_name} 的帳戶。
+ link_accounts: 連結所選帳戶
+ no_accounts_found: 找不到帳戶。請檢查您的 API 金鑰設定。
+ no_api_key: 尚未設定 Lunch Flow API 金鑰。請前往「設定」進行配置。
+ no_name_placeholder: "(無名稱)"
+ title: 選擇 Lunch Flow 帳戶
+ select_existing_account:
+ account_already_linked: 此帳戶已與提供商連結
+ all_accounts_already_linked: 所有 Lunch Flow 帳戶都已連結
+ api_error: "API 錯誤:%{message}"
+ cancel: 取消
+ configure_name_in_lunchflow: 無法匯入 - 請在 Lunch Flow 中設定帳戶名稱
+ description: 選擇一個 Lunch Flow 帳戶與此帳戶連結。交易紀錄將會自動同步並排除重複。
+ link_account: 連結帳戶
+ no_account_specified: 未指定帳戶
+ no_accounts_found: 找不到 Lunch Flow 帳戶。請檢查您的 API 金鑰設定。
+ no_api_key: 尚未設定 Lunch Flow API 金鑰。請前往「設定」進行配置。
+ no_name_placeholder: "(無名稱)"
+ title: "將 %{account_name} 與 Lunch Flow 連結"
+ link_existing_account:
+ account_already_linked: 此帳戶已與提供商連結
+ api_error: "API 錯誤:%{message}"
+ invalid_account_name: 無法連結名稱空白的帳戶
+ lunchflow_account_already_linked: 此 Lunch Flow 帳戶已連結至另一個帳戶
+ lunchflow_account_not_found: 找不到 Lunch Flow 帳戶
+ missing_parameters: 缺少必要參數
+ success: "已成功將 %{account_name} 與 Lunch Flow 連結"
+ setup_accounts:
+ account_type_label: "帳戶類型:"
+ all_accounts_linked: "您的所有 Lunch Flow 帳戶都已設定完畢。"
+ api_error: "API 錯誤:%{message}"
+ fetch_failed: "獲取帳戶失敗"
+ no_accounts_to_setup: "沒有需要設定的帳戶"
+ no_api_key: "尚未設定 Lunch Flow API 金鑰。請檢查您的連線設定。"
+ account_types:
+ skip: 跳過此帳戶
+ depository: 支票或儲蓄帳戶
+ credit_card: 信用卡
+ investment: 投資帳戶
+ loan: 貸款或房屋貸款
+ other_asset: 其他資產
+ subtype_labels:
+ depository: "帳戶子類型:"
+ credit_card: ""
+ investment: "投資類型:"
+ loan: "貸款類型:"
+ other_asset: ""
+ subtype_messages:
+ credit_card: "信用卡將自動設定為信用卡帳戶。"
+ other_asset: "其他資產不需要額外選項。"
+ subtypes:
+ depository:
+ checking: 支票帳戶 (Checking)
+ savings: 儲蓄帳戶 (Savings)
+ hsa: 健康儲蓄帳戶 (HSA)
+ cd: 定期存款 (CD)
+ money_market: 貨幣市場帳戶
+ investment:
+ brokerage: 證券經紀
+ pension: 退休金
+ retirement: 退休帳戶
+ "401k": "401(k)"
+ roth_401k: "Roth 401(k)"
+ "403b": "403(b)"
+ tsp: 節儉儲蓄計畫 (TSP)
+ "529_plan": "529 教育儲蓄計畫"
+ hsa: 健康儲蓄帳戶 (HSA)
+ mutual_fund: 共同基金
+ ira: 傳統 IRA
+ roth_ira: Roth IRA
+ angel: 天使投資
+ loan:
+ mortgage: 房屋貸款
+ student: 學生貸款
+ auto: 汽車貸款
+ other: 其他貸款
+ balance: 餘額
+ cancel: 取消
+ choose_account_type: "請為每個 Lunch Flow 帳戶選擇正確的帳戶類型:"
+ create_accounts: 建立帳戶
+ creating_accounts: 正在建立帳戶...
+ historical_data_range: "歷史資料範圍:"
+ subtitle: 為您匯入的帳戶選擇正確的帳戶類型
+ sync_start_date_help: 選擇您想要同步交易紀錄的追溯時間。最多可提供 3 年的歷史紀錄。
+ sync_start_date_label: "開始同步交易的日期:"
+ title: 設定您的 Lunch Flow 帳戶
+ complete_account_setup:
+ all_skipped: "已跳過所有帳戶。未建立任何帳戶。"
+ creation_failed: "建立帳戶失敗:%{error}"
+ no_accounts: "沒有需要設定的帳戶。"
+ success: "已成功建立 %{count} 個帳戶。"
+ sync:
+ success: 同步已開始
+ update:
+ success: Lunch Flow 連線已更新
diff --git a/config/locales/views/merchants/zh-TW.yml b/config/locales/views/merchants/zh-TW.yml
new file mode 100644
index 000000000..400483a9f
--- /dev/null
+++ b/config/locales/views/merchants/zh-TW.yml
@@ -0,0 +1,35 @@
+---
+zh-TW:
+ family_merchants:
+ create:
+ error: '建立商家時發生錯誤:%{error}'
+ success: 成功建立新商家
+ destroy:
+ success: 成功刪除商家
+ edit:
+ title: 編輯商家
+ form:
+ name_placeholder: 商家名稱
+ index:
+ empty: 尚無商家
+ new: 新增商家
+ title: 商家管理
+ family_title: 家庭商家
+ family_empty: 尚無家庭商家
+ provider_title: 提供商商家
+ provider_empty: 目前尚無連結至此家庭的提供商商家
+ provider_read_only: 提供商商家是從您連結的金融機構同步而來的,無法在此進行編輯。
+ table:
+ merchant: 商家
+ actions: 操作
+ source: 來源
+ merchant:
+ confirm_accept: 刪除商家
+ confirm_body: 您確定要刪除此商家嗎?刪除此商家將會解除所有相關交易的連結,並可能影響您的報表產出。
+ confirm_title: 確定要刪除商家嗎?
+ delete: 刪除商家
+ edit: 編輯商家
+ new:
+ title: 新增商家
+ update:
+ success: 商家資訊已成功更新
diff --git a/config/locales/views/mfa/zh-TW.yml b/config/locales/views/mfa/zh-TW.yml
new file mode 100644
index 000000000..1ba52d07f
--- /dev/null
+++ b/config/locales/views/mfa/zh-TW.yml
@@ -0,0 +1,34 @@
+---
+zh-TW:
+ mfa:
+ backup_codes:
+ backup_codes_description: 每個代碼只能使用一次。請妥善保存並確保這些代碼的安全。
+ backup_codes_title: 您的備援代碼
+ continue: 繼續前往安全性設定
+ description: 請將這些備援代碼儲存在安全的地方 —— 若您無法使用驗證應用程式,將會需要這些代碼。
+ page_title: 備援代碼
+ title: 儲存您的備援代碼
+ create:
+ invalid_code: 驗證碼無效。請再試一次。
+ disable:
+ success: 雙重驗證已停用
+ new:
+ code_label: 驗證碼
+ code_placeholder: 輸入 6 位數驗證碼
+ description: 透過設定雙重驗證來提升您的帳號安全性。
+ page_title: 設定雙重驗證
+ scan_description: 請使用 Google Authenticator 或 1Password 等驗證應用程式掃描此 QR Code
+ scan_title: 1. 掃描 QR Code
+ secret_description: 若您無法掃描 QR Code,請在驗證應用程式中手動輸入此金鑰
+ secret_title: 手動輸入代碼
+ title: 設定雙重驗證
+ verify_button: 驗證並啟用雙重驗證 (2FA)
+ verify_description: 請輸入驗證應用程式中的 6 位數驗證碼
+ verify_title: 2. 輸入驗證碼
+ verify:
+ description: 請輸入驗證應用程式中的驗證碼以繼續
+ page_title: 雙重驗證
+ title: 雙重驗證
+ verify_button: 驗證
+ verify_code:
+ invalid_code: 驗證碼無效。請再試一次。
diff --git a/config/locales/views/oidc_accounts/zh-TW.yml b/config/locales/views/oidc_accounts/zh-TW.yml
new file mode 100644
index 000000000..5a26ed67e
--- /dev/null
+++ b/config/locales/views/oidc_accounts/zh-TW.yml
@@ -0,0 +1,5 @@
+---
+zh-TW:
+ oidc_accounts:
+ link:
+ account_creation_disabled: 透過單一登入建立新帳戶的功能已被禁用。請聯絡管理員為您建立帳戶。
diff --git a/config/locales/views/onboardings/zh-TW.yml b/config/locales/views/onboardings/zh-TW.yml
new file mode 100644
index 000000000..9846cd484
--- /dev/null
+++ b/config/locales/views/onboardings/zh-TW.yml
@@ -0,0 +1,27 @@
+---
+zh-TW:
+ onboardings:
+ header:
+ sign_out: 登出
+ preferences:
+ currency: 幣別
+ date_format: 日期格式
+ example: 帳戶範例
+ locale: 語言
+ preview: 預覽根據偏好設定顯示的資料。
+ submit: 完成
+ subtitle: 讓我們來設定您的偏好設定。
+ title: 設定您的偏好設定
+ profile:
+ country: 國家
+ first_name: 名字
+ household_name: 家戶名稱
+ last_name: 姓氏
+ profile_image: 個人頭像
+ submit: 繼續
+ subtitle: 讓我們完成您的個人資料。
+ title: 進行基礎設定
+ show:
+ message: 我們很高興您的加入!接下來我們會詢問幾個問題來完善您的個人資料,並完成所有設定。
+ setup: 開始設定帳號
+ title: 認識 %{product_name}
diff --git a/config/locales/views/other_assets/zh-TW.yml b/config/locales/views/other_assets/zh-TW.yml
new file mode 100644
index 000000000..f3c210a1b
--- /dev/null
+++ b/config/locales/views/other_assets/zh-TW.yml
@@ -0,0 +1,9 @@
+---
+zh-TW:
+ other_assets:
+ edit:
+ edit: 編輯 %{account}
+ balance_tracking_info: "「其他資產」是透過「新餘額」進行手動估值來追蹤,而非透過交易紀錄。現金流不會影響帳戶餘額。"
+ new:
+ title: 輸入資產詳情
+ balance_tracking_info: "「其他資產」是透過「新餘額」進行手動估值來追蹤,而非透過交易紀錄。現金流不會影響帳戶餘額。"
diff --git a/config/locales/views/other_liabilities/zh-TW.yml b/config/locales/views/other_liabilities/zh-TW.yml
new file mode 100644
index 000000000..0be5b10d8
--- /dev/null
+++ b/config/locales/views/other_liabilities/zh-TW.yml
@@ -0,0 +1,7 @@
+---
+zh-TW:
+ other_liabilities:
+ edit:
+ edit: 編輯 %{account}
+ new:
+ title: 輸入負債詳情
diff --git a/config/locales/views/pages/zh-TW.yml b/config/locales/views/pages/zh-TW.yml
new file mode 100644
index 000000000..cbf52c65c
--- /dev/null
+++ b/config/locales/views/pages/zh-TW.yml
@@ -0,0 +1,37 @@
+---
+zh-TW:
+ pages:
+ changelog:
+ title: 更新日誌
+ dashboard:
+ welcome: "歡迎回來,%{name}"
+ subtitle: "這是您目前的財務概況"
+ new: "最新"
+ drag_to_reorder: "拖曳以重新排列區塊"
+ toggle_section: "切換區塊顯示狀態"
+ net_worth_chart:
+ data_not_available: 所選時段內無資料
+ title: 淨值
+ no_account_empty_state:
+ new_account: 新增帳戶
+ no_account_subtitle: 尚未新增任何帳戶,因此目前無資料可顯示。請新增您的第一個帳戶以開始查看儀表板內容。
+ no_account_title: 尚無帳戶
+ balance_sheet:
+ title: "資產負債表"
+ no_items: "尚無 %{name}"
+ add_accounts: "新增您的 %{name} 帳戶以查看詳細分析"
+ cashflow_sankey:
+ title: "現金流"
+ no_data_title: "此時段內無現金流資料"
+ no_data_description: "新增交易紀錄以顯示現金流資料,或擴大時間範圍"
+ add_transaction: "新增交易"
+ no_accounts:
+ title: "尚無帳戶"
+ description: "新增帳戶以顯示淨值資料"
+ add_account: "新增帳戶"
+ outflows_donut:
+ title: "現金流出"
+ total_outflows: "總流出量"
+ categories: "類別"
+ value: "金額"
+ weight: "占比"
diff --git a/config/locales/views/password_mailer/zh-TW.yml b/config/locales/views/password_mailer/zh-TW.yml
new file mode 100644
index 000000000..94837a0bd
--- /dev/null
+++ b/config/locales/views/password_mailer/zh-TW.yml
@@ -0,0 +1,8 @@
+---
+zh-TW:
+ password_mailer:
+ password_reset:
+ cta: 重設您的密碼
+ ignore_if_not_requested: 如果您並未提出此請求,可以放心地忽略這封郵件。
+ request_made: 我們收到了重設您 %{product_name} 帳號密碼的請求。請點擊下方連結以進行重設。
+ subject: '%{product_name}:重設您的密碼'
diff --git a/config/locales/views/password_resets/zh-TW.yml b/config/locales/views/password_resets/zh-TW.yml
new file mode 100644
index 000000000..4e4516175
--- /dev/null
+++ b/config/locales/views/password_resets/zh-TW.yml
@@ -0,0 +1,14 @@
+---
+zh-TW:
+ password_resets:
+ disabled: 透過 Sure 重設密碼的功能已停用。請透過您的身份驗證提供商(Identity Provider)來重設密碼。
+ edit:
+ title: 重設密碼
+ new:
+ requested: 請檢查您的電子郵件,以取得重設密碼的連結。
+ submit: 重設密碼
+ title: 重設密碼
+ back: 返回
+ update:
+ invalid_token: 權杖(Token)無效。
+ success: 您的密碼已成功重設。
diff --git a/config/locales/views/passwords/zh-TW.yml b/config/locales/views/passwords/zh-TW.yml
new file mode 100644
index 000000000..ab83d5093
--- /dev/null
+++ b/config/locales/views/passwords/zh-TW.yml
@@ -0,0 +1,10 @@
+---
+zh-TW:
+ passwords:
+ edit:
+ password: 新密碼
+ password_challenge: 目前密碼
+ submit: 重設密碼
+ title: 更新密碼
+ update:
+ success: 您的密碼已重設。
diff --git a/config/locales/views/plaid_items/zh-TW.yml b/config/locales/views/plaid_items/zh-TW.yml
new file mode 100644
index 000000000..fe52565c9
--- /dev/null
+++ b/config/locales/views/plaid_items/zh-TW.yml
@@ -0,0 +1,28 @@
+---
+zh-TW:
+ plaid_items:
+ create:
+ success: 帳戶連結成功。請稍候,系統正在進行同步。
+ destroy:
+ success: 帳戶已排定刪除。
+ plaid_item:
+ add_new: 新增連線
+ confirm_accept: 刪除金融機構
+ confirm_body: 這將會永久刪除此群組中的所有帳戶及相關資料。
+ confirm_title: 確定要刪除金融機構嗎?
+ connection_lost: 連線中斷
+ connection_lost_description: 此連線已失效。您需要刪除此連線並重新新增,才能繼續同步資料。
+ delete: 刪除
+ error: 同步資料時發生錯誤
+ no_accounts_description: 我們無法從此金融機構載入任何帳戶。
+ no_accounts_title: 找不到帳戶
+ requires_update: 重新連線
+ status: "%{timestamp} 前已同步"
+ status_never: 需要同步資料
+ syncing: 同步中...
+ update: 更新
+ select_existing_account:
+ title: "將 %{account_name} 連結至 Plaid"
+ description: 選擇一個 Plaid 帳戶來連結至您現有的帳戶
+ cancel: 取消
+ link_account: 連結帳戶
diff --git a/config/locales/views/properties/zh-TW.yml b/config/locales/views/properties/zh-TW.yml
new file mode 100644
index 000000000..f6cef94ba
--- /dev/null
+++ b/config/locales/views/properties/zh-TW.yml
@@ -0,0 +1,32 @@
+---
+zh-TW:
+ properties:
+ edit:
+ edit: 編輯 %{account}
+ form:
+ address_line1: 街道地址
+ address_line1_placeholder: 例如:忠孝東路一段 123 號
+ area: 居住面積
+ area_placeholder: '30'
+ area_unit: 面積單位
+ country: 國家
+ country_placeholder: TW
+ locality: 城市
+ locality_placeholder: 台北市
+ none: 無
+ postal_code: 郵遞區號
+ postal_code_placeholder: '100'
+ region: 縣市/省份
+ region_placeholder: 台北市
+ subtype_prompt: 選擇不動產類型
+ year_built: 建築年份
+ year_built_placeholder: '2000'
+ new:
+ title: 輸入不動產詳情
+ overview:
+ living_area: 居住面積
+ market_value: 市場價值
+ purchase_price: 買入價格
+ trend: 趨勢
+ unknown: 未知
+ year_built: 建築年份
diff --git a/config/locales/views/recurring_transactions/zh-TW.yml b/config/locales/views/recurring_transactions/zh-TW.yml
new file mode 100644
index 000000000..2e545d1a5
--- /dev/null
+++ b/config/locales/views/recurring_transactions/zh-TW.yml
@@ -0,0 +1,49 @@
+---
+zh-TW:
+ recurring_transactions:
+ title: 定期交易
+ upcoming: 即將到來的定期交易
+ projected: 預估
+ recurring: 定期
+ expected_on: 預計於 %{date}
+ day_of_month: 每月第 %{day} 天
+ identify_patterns: 辨識規律
+ cleanup_stale: 清理失效交易
+ settings:
+ enable_label: 啟用定期交易
+ enable_description: 自動偵測定期交易規律,並顯示即將發生的預估交易。
+ settings_updated: 定期交易設定已更新
+ info:
+ title: 自動規律辨識
+ manual_description: 您可以使用上方的按鈕手動辨識規律,或清理失效的定期交易。
+ automatic_description: "自動辨識也會在下列情況後執行:"
+ triggers:
+ - CSV 匯入完成(包含交易、投資交易、帳戶等)
+ - 任何提供商同步完成(如 Plaid, SimpleFIN 等)
+ identified: 已辨識出 %{count} 個定期交易規律
+ cleaned_up: 已清理 %{count} 個失效的定期交易
+ marked_inactive: 定期交易已標記為停用
+ marked_active: 定期交易已標記為啟用
+ deleted: 定期交易已刪除
+ confirm_delete: 您確定要刪除此定期交易嗎?
+ marked_as_recurring: 交易已標記為定期交易
+ already_exists: 此規律已存在手動建立的定期交易
+ creation_failed: 建立定期交易失敗。請檢查交易詳情並再試一次。
+ unexpected_error: 建立定期交易時發生預期之外的錯誤
+ amount_range: "範圍:%{min} 至 %{max}"
+ empty:
+ title: 找不到定期交易
+ description: 點擊「辨識規律」以從您的交易紀錄中自動偵測定期交易。
+ table:
+ merchant: 商家
+ amount: 金額
+ expected_day: 預計日期
+ next_date: 下次交易日
+ last_occurrence: 最近一次發生
+ status: 狀態
+ actions: 操作
+ status:
+ active: 啟用中
+ inactive: 已停用
+ badges:
+ manual: 手動
diff --git a/config/locales/views/registrations/zh-TW.yml b/config/locales/views/registrations/zh-TW.yml
new file mode 100644
index 000000000..9912f0d72
--- /dev/null
+++ b/config/locales/views/registrations/zh-TW.yml
@@ -0,0 +1,25 @@
+---
+zh-TW:
+ helpers:
+ label:
+ user:
+ invite_code: 邀請碼
+ submit:
+ user:
+ create: 繼續
+ registrations:
+ closed: 目前已關閉註冊。
+ create:
+ failure: 註冊時發生問題。
+ invalid_invite_code: 邀請碼無效,請再試一次。
+ success: 您已成功註冊。
+ new:
+ invitation_message: "%{inviter} 已邀請您以 %{role} 身分加入"
+ join_family_title: 加入 %{family}
+ role_admin: 管理員
+ role_member: 成員
+ submit: 建立帳號
+ title: 建立您的帳號
+ welcome_body: 在開始之前,您必須註冊一個新帳號。註冊完成後,您將能在應用程式內進行進階設定。
+ welcome_title: 歡迎使用自行代管的 %{product_name}!
+ password_placeholder: 輸入您的密碼
diff --git a/config/locales/views/reports/en.yml b/config/locales/views/reports/en.yml
index 2d34539f7..ceebbe74c 100644
--- a/config/locales/views/reports/en.yml
+++ b/config/locales/views/reports/en.yml
@@ -5,6 +5,7 @@ en:
title: Reports
subtitle: Comprehensive insights into your financial health
export: Export CSV
+ print_report: Print Report
drag_to_reorder: "Drag to reorder section"
toggle_section: "Toggle section visibility"
periods:
@@ -149,3 +150,57 @@ en:
open_sheets: Open Google Sheets
go_to_api_keys: Go to API Keys
close: Got it
+ print:
+ document_title: Financial Report
+ title: Financial Report
+ generated_on: "Generated %{date}"
+ # Summary section
+ summary:
+ title: Summary
+ income: Income
+ expenses: Expenses
+ net_savings: Net Savings
+ budget: Budget
+ vs_prior: "%{percent}% vs prior"
+ of_income: "%{percent}% of income"
+ used: used
+ # Net Worth section
+ net_worth:
+ title: Net Worth
+ current_balance: Current Balance
+ this_period: this period
+ assets: Assets
+ liabilities: Liabilities
+ no_liabilities: No liabilities
+ # Monthly Trends section
+ trends:
+ title: Monthly Trends
+ month: Month
+ income: Income
+ expenses: Expenses
+ net: Net
+ savings_rate: Savings Rate
+ average: Average
+ current_month_note: "* Current month (partial data)"
+ # Investments section
+ investments:
+ title: Investments
+ portfolio_value: Portfolio Value
+ total_return: Total Return
+ contributions: Contributions
+ withdrawals: Withdrawals
+ this_period: this period
+ top_holdings: Top Holdings
+ holding: Holding
+ weight: Weight
+ value: Value
+ return: Return
+ # Spending by Category section
+ spending:
+ title: Spending by Category
+ income: Income
+ expenses: Expenses
+ category: Category
+ amount: Amount
+ percent: "%"
+ more_categories: "+ %{count} more categories"
diff --git a/config/locales/views/reports/zh-TW.yml b/config/locales/views/reports/zh-TW.yml
new file mode 100644
index 000000000..d6c6baade
--- /dev/null
+++ b/config/locales/views/reports/zh-TW.yml
@@ -0,0 +1,126 @@
+---
+zh-TW:
+ reports:
+ index:
+ title: 報表
+ subtitle: 深入洞察您的財務健康狀況
+ export: 匯出 CSV
+ drag_to_reorder: "拖曳以重新排列區塊"
+ toggle_section: "切換區塊顯示狀態"
+ periods:
+ monthly: 按月
+ quarterly: 按季
+ ytd: 年初至今 (YTD)
+ last_6_months: 過去 6 個月
+ custom: 自訂範圍
+ date_range:
+ from: 開始
+ to: 結束
+ showing_period: "顯示 %{start} 至 %{end} 的資料"
+ invalid_date_range: "結束日期不能早於開始日期。日期已自動對調。"
+ summary:
+ total_income: 總收入
+ total_expenses: 總支出
+ net_savings: 淨儲蓄
+ budget_performance: 預算執行狀況
+ vs_previous: 與上一期相比
+ income_minus_expenses: 收入減去支出
+ of_budget_used: 已使用預算
+ no_budget_data: 此時段內無預算資料
+ budget_performance:
+ title: 預算執行狀況
+ spent: 已支出
+ budgeted: 預算額度
+ remaining: 剩餘
+ over_by: 超支
+ suggested_daily: "剩餘 %{days} 天,建議每日支出為 %{amount}"
+ no_budgets: 本月尚未設定預算類別
+ status:
+ good: 符合進度
+ warning: 接近上限
+ over: 預算超支
+ trends:
+ title: 趨勢與洞察
+ monthly_breakdown: 每月明細
+ month: 月份
+ income: 收入
+ expenses: 支出
+ net: 淨值
+ savings_rate: 儲蓄率
+ current: 目前
+ avg_monthly_income: 平均月收入
+ avg_monthly_expenses: 平均月支出
+ avg_monthly_savings: 平均月儲蓄
+ no_data: 暫無趨勢資料
+ spending_patterns: 消費模式
+ weekday_spending: 平日支出
+ weekend_spending: 週末支出
+ total: 總計
+ avg_per_transaction: 每筆平均金額
+ transactions: 交易筆數
+ insight_title: 洞察
+ insight_higher_weekend: "您週末每筆交易的平均支出比平日高出 %{percent}%"
+ insight_higher_weekday: "您平日每筆交易的平均支出比週末高出 %{percent}%"
+ insight_similar: "您平日與週末的每筆交易平均支出相近"
+ no_spending_data: 此時段內無消費資料
+ empty_state:
+ title: 尚無可用資料
+ description: 開始新增交易紀錄或連結您的帳戶,以查看詳細的財務報表。
+ add_transaction: 新增交易
+ add_account: 新增帳戶
+ transactions_breakdown:
+ title: 交易明細拆解
+ no_transactions: 在選定的時段與過濾條件下找不到交易紀錄
+ filters:
+ title: 過濾條件
+ category: 類別
+ account: 帳戶
+ tag: 標籤
+ amount_min: 最低金額
+ amount_max: 最高金額
+ date_range: 日期範圍
+ all_categories: 所有類別
+ all_accounts: 所有帳戶
+ all_tags: 所有標籤
+ apply: 應用過濾
+ clear: 清除過濾
+ sort:
+ label: 排序方式
+ date_desc: 日期(從新到舊)
+ amount_desc: 金額(從高到低)
+ amount_asc: 金額(從低到高)
+ export:
+ label: 匯出
+ csv: CSV
+ excel: Excel
+ pdf: PDF
+ google_sheets: 在 Google 試算表中開啟
+ table:
+ category: 類別
+ amount: 金額
+ type: 類型
+ expense: 支出
+ income: 收入
+ uncategorized: 未分類
+ transactions: 筆交易
+ percentage: "占總計 %"
+ pagination:
+ showing: 顯示 %{count} 筆交易
+ previous: 上一頁
+ next: 下一頁
+ google_sheets_instructions:
+ title_with_key: "✅ 已複製 Google 試算表連結"
+ title_no_key: "⚠️ 需要 API 金鑰"
+ ready: 您的 CSV URL(含 API 金鑰)已就緒。
+ steps: "若要匯入 Google 試算表:\n1. 建立一個新的 Google 試算表\n2. 在 A1 儲存格中輸入下方公式\n3. 按下 Enter 鍵"
+ security_warning: "此 URL 包含您的 API 金鑰。請妥善保管!"
+ need_key: 若要將資料匯入 Google 試算表,您需要一個 API 金鑰。
+ step1: "前往「設定」→「API 金鑰」"
+ step2: "建立一個具備「讀取」權限的新 API 金鑰"
+ step3: 複製該 API 金鑰
+ step4: "將其加入此 URL 結尾:?api_key=您的金鑰"
+ example: 範例
+ then_use: 接著在 Google 試算表中使用帶有 =IMPORTDATA() 的完整連結。
+ open_sheets: 開啟 Google 試算表
+ go_to_api_keys: 前往 API 金鑰設定
+ close: 我知道了
diff --git a/config/locales/views/rules/zh-TW.yml b/config/locales/views/rules/zh-TW.yml
new file mode 100644
index 000000000..5a8bdf3bd
--- /dev/null
+++ b/config/locales/views/rules/zh-TW.yml
@@ -0,0 +1,24 @@
+---
+zh-TW:
+ rules:
+ no_action: 無動作
+ recent_runs:
+ title: 最近執行紀錄
+ description: 查看規則的執行歷史,包括成功/失敗狀態以及交易處理筆數。
+ unnamed_rule: 未命名規則
+ columns:
+ date_time: 日期/時間
+ execution_type: 類型
+ status: 狀態
+ rule_name: 規則名稱
+ transactions_counts:
+ queued: 已排入佇列
+ processed: 已處理
+ modified: 已修改
+ execution_types:
+ manual: 手動
+ scheduled: 排程
+ statuses:
+ pending: 待處理
+ success: 成功
+ failed: 失敗
diff --git a/config/locales/views/sessions/en.yml b/config/locales/views/sessions/en.yml
index 8891e194d..32bbeae7e 100644
--- a/config/locales/views/sessions/en.yml
+++ b/config/locales/views/sessions/en.yml
@@ -6,10 +6,15 @@ en:
local_login_disabled: Local password login is disabled. Please use single sign-on.
destroy:
logout_successful: You have signed out successfully.
+ post_logout:
+ logout_successful: You have signed out successfully.
openid_connect:
failed: Could not authenticate via OpenID Connect.
failure:
failed: Could not authenticate.
+ sso_provider_unavailable: "The SSO provider is currently unavailable. Please try again later or contact an administrator."
+ sso_invalid_response: "Received an invalid response from the SSO provider. Please try again."
+ sso_failed: "Single sign-on authentication failed. Please try again."
new:
email: Email address
email_placeholder: you@example.com
diff --git a/config/locales/views/sessions/zh-TW.yml b/config/locales/views/sessions/zh-TW.yml
new file mode 100644
index 000000000..31bdf64cc
--- /dev/null
+++ b/config/locales/views/sessions/zh-TW.yml
@@ -0,0 +1,26 @@
+---
+zh-TW:
+ sessions:
+ create:
+ invalid_credentials: 電子郵件或密碼無效。
+ local_login_disabled: 本地密碼登入功能已停用。請使用單一登入 (SSO)。
+ destroy:
+ logout_successful: 您已成功登出。
+ openid_connect:
+ failed: 無法透過 OpenID Connect 進行驗證。
+ failure:
+ failed: 無法進行驗證。
+ new:
+ email: 電子郵件地址
+ email_placeholder: you@example.com
+ forgot_password: 忘記密碼?
+ password: 密碼
+ submit: 登入
+ title: 登入
+ password_placeholder: 輸入您的密碼
+ openid_connect: 透過 OpenID Connect 登入
+ google_auth_connect: 透過 Google 帳號登入
+ local_login_admin_only: 本地登入僅限管理員使用。
+ no_auth_methods_enabled: 目前未啟用任何驗證方式。請聯絡管理員。
+ demo_banner_title: "示範模式已啟用"
+ demo_banner_message: "這是示範環境。為了方便使用,登入資訊已預先填寫。請勿在此輸入真實或敏感資料。"
diff --git a/config/locales/views/settings/api_keys/zh-TW.yml b/config/locales/views/settings/api_keys/zh-TW.yml
new file mode 100644
index 000000000..8ec8327ed
--- /dev/null
+++ b/config/locales/views/settings/api_keys/zh-TW.yml
@@ -0,0 +1,76 @@
+---
+zh-TW:
+ settings:
+ api_keys_controller:
+ success: "您的 API 金鑰已成功建立"
+ revoked_successfully: "API 金鑰已成功撤銷"
+ revoke_failed: "撤銷 API 金鑰失敗"
+ scope_descriptions:
+ read_accounts: "檢視帳戶"
+ read_transactions: "檢視交易紀錄"
+ read_balances: "檢視餘額"
+ write_transactions: "建立交易紀錄"
+ api_keys:
+ show:
+ title: "API 金鑰管理"
+ no_api_key:
+ title: "API 金鑰"
+ heading: "透過程式化方式存取您的帳戶資料"
+ description: "透過安全的 API 金鑰,以程式化方式存取您的 Sure 資料。"
+ what_you_can_do: "您可以使用 API 執行以下操作:"
+ feature_1: "以程式化方式存取您的帳戶資料"
+ feature_2: "建立自定義整合功能與應用程式"
+ feature_3: "自動化資料擷取與分析"
+ security_note_title: "安全性優先"
+ security_note: "您的 API 金鑰將根據您選擇的範圍(Scopes)擁有受限的權限。一次只能擁有一把啟用中的 API 金鑰。"
+ create_api_key: "建立 API 金鑰"
+ current_api_key:
+ title: "您的 API 金鑰"
+ description: "您的啟用中 API 金鑰已就緒。請妥善保管,切勿公開分享。"
+ active: "使用中"
+ key_name: "名稱"
+ created_at: "建立時間"
+ last_used: "最後使用時間"
+ expires: "過期時間"
+ ago: "前"
+ never_used: "未使用過"
+ never_expires: "永不過期"
+ permissions: "權限範圍"
+ usage_instructions_title: "如何使用您的 API 金鑰"
+ usage_instructions: "在向 Maybe API 發送請求時,請在 X-Api-Key 標頭 (Header) 中包含您的 API 金鑰:"
+ regenerate_key: "建立新金鑰"
+ revoke_key: "撤銷金鑰"
+ revoke_confirmation: "您確定要撤銷此 API 金鑰嗎?此操作無法還原,且會立即停用所有使用此金鑰的應用程式。"
+ new:
+ title: "建立 API 金鑰"
+ create_new_key: "建立新 API 金鑰"
+ description: "為您的新 API 金鑰設定一個易於辨識的名稱與適當的權限。"
+ name_label: "API 金鑰名稱"
+ name_placeholder: "例如:正式版應用程式、數據分析儀表板"
+ name_help: "選擇一個描述性名稱,以協助您辨識此金鑰的用途。"
+ permissions_label: "權限範圍"
+ permissions_help: "選擇 API 金鑰所需的權限。您可以隨時建立具有不同權限的新金鑰。"
+ scope_details:
+ read_accounts: "檢視帳戶資訊、餘額及帳戶層級資料"
+ read_transactions: "檢視交易資料、類別及交易詳情"
+ read_balances: "檢視歷史餘額資料與帳戶價值趨勢"
+ write_transactions: "建立與更新交易紀錄(即將推出)"
+ security_warning_title: "重要安全性告示"
+ security_warning: "API 金鑰在建立後僅會顯示一次。請將其安全地儲存,切勿公開分享。若遺失金鑰,您將需要重新建立一個。"
+ create_key: "建立 API 金鑰"
+ cancel: "取消"
+ created:
+ title: "API 金鑰已建立"
+ success_title: "API 金鑰建立成功"
+ success_description: "您的新 API 金鑰已就緒。請務必立即複製,稍後您將無法再次查看。"
+ your_api_key: "您的 API 金鑰"
+ key_name: "名稱"
+ permissions: "權限範圍"
+ critical_warning_title: "⚠️ 重要:請立即儲存您的 API 金鑰"
+ critical_warning_1: "這是您唯一一次能以純文字 (Plain text) 看到 API 金鑰的機會。"
+ critical_warning_2: "請複製金鑰並將其安全地儲存在您的密碼管理員或應用程式中。"
+ critical_warning_3: "若遺失此金鑰,您將需要重新建立一個。"
+ usage_instructions_title: "快速入門"
+ usage_instructions: "使用 API 金鑰時,請將其包含在 X-Api-Key 標頭中:"
+ copy_key: "複製 API 金鑰"
+ continue: "繼續前往 API 金鑰設定"
diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml
index 567310cc8..e84e9e056 100644
--- a/config/locales/views/settings/en.yml
+++ b/config/locales/views/settings/en.yml
@@ -89,6 +89,23 @@ en:
securities:
show:
page_title: Security
+ mfa_title: Two-Factor Authentication
+ mfa_description: Add an extra layer of security to your account by requiring a code from your authenticator app when signing in
+ enable_mfa: Enable 2FA
+ disable_mfa: Disable 2FA
+ disable_mfa_confirm: Are you sure you want to disable two-factor authentication?
+ sso_title: Connected Accounts
+ sso_subtitle: Manage your single sign-on account connections
+ sso_disconnect: Disconnect
+ sso_last_used: Last used
+ sso_never: Never
+ sso_no_email: No email
+ sso_no_identities: No SSO accounts connected
+ sso_connect_hint: Log out and sign in with an SSO provider to connect an account.
+ sso_confirm_title: Disconnect Account?
+ sso_confirm_body: Are you sure you want to disconnect your %{provider} account? You can reconnect it later by signing in with that provider again.
+ sso_confirm_button: Disconnect
+ sso_warning_message: This is your only login method. You should set a password in your security settings before disconnecting, otherwise you may be locked out of your account.
settings_nav:
accounts_label: Accounts
advanced_section_title: Advanced
diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml
index 81d73148c..4742bc809 100644
--- a/config/locales/views/settings/hostings/en.yml
+++ b/config/locales/views/settings/hostings/en.yml
@@ -17,6 +17,7 @@ en:
show:
general: General Settings
financial_data_providers: Financial Data Providers
+ sync_settings: Sync Settings
invites: Invite Codes
title: Self-Hosting
danger_zone: Danger Zone
@@ -74,6 +75,16 @@ en:
failure: Invalid setting value
success: Settings updated
invalid_onboarding_state: Invalid onboarding state
+ invalid_sync_time: Invalid sync time format. Please use HH:MM format (e.g., 02:30).
+ scheduler_sync_failed: Settings saved, but failed to update the sync schedule. Please try again or check the server logs.
clear_cache:
cache_cleared: Data cache has been cleared. This may take a few moments to complete.
not_authorized: You are not authorized to perform this action
+ sync_settings:
+ auto_sync_label: Enable automatic sync
+ auto_sync_description: When enabled, all accounts will be automatically synced daily at the specified time.
+ auto_sync_time_label: Sync time (HH:MM)
+ auto_sync_time_description: Specify the time of day when automatic sync should occur.
+ include_pending_label: Include pending transactions
+ include_pending_description: When enabled, pending (uncleared) transactions will be imported and automatically reconciled when they post. Disable if your bank provides unreliable pending data.
+ env_configured_message: This setting is disabled because a provider environment variable (SIMPLEFIN_INCLUDE_PENDING or PLAID_INCLUDE_PENDING) is set. Remove it to enable this setting.
diff --git a/config/locales/views/settings/hostings/zh-TW.yml b/config/locales/views/settings/hostings/zh-TW.yml
new file mode 100644
index 000000000..4907d2fd8
--- /dev/null
+++ b/config/locales/views/settings/hostings/zh-TW.yml
@@ -0,0 +1,78 @@
+---
+zh-TW:
+ settings:
+ hostings:
+ invite_code_settings:
+ description: 控制新使用者註冊您的 %{product} 實例 (Instance) 的方式。
+ email_confirmation_description: 啟用後,使用者在更改電子郵件地址時必須進行驗證。
+ email_confirmation_title: 需要電子郵件驗證
+ generate_tokens: 產生新代碼
+ generated_tokens: 已產生的代碼
+ title: 加入流程
+ states:
+ open: 開放註冊
+ closed: 關閉註冊
+ invite_only: 僅限邀請
+ show:
+ general: 一般設定
+ financial_data_providers: 金融資料提供商
+ invites: 邀請碼
+ title: 自行代管 (Self-Hosting)
+ danger_zone: 危險區域
+ clear_cache: 清除資料快取
+ clear_cache_warning: 清除資料快取將會移除所有匯率、證券價格、帳戶餘額等資料。這不會刪除帳戶、交易紀錄、類別或其他使用者擁有的資料。
+ confirm_clear_cache:
+ title: 清除資料快取?
+ body: 您確定要清除資料快取嗎?這將移除所有匯率、證券價格、帳戶餘額及其他暫存資料。此操作無法復原。
+ provider_selection:
+ title: 提供商選擇
+ description: 選擇用於獲取匯率和證券價格的服務。Yahoo Finance 是免費的且不需要 API 金鑰。Twelve Data 需要免費的 API 金鑰,但可能提供更廣的資料涵蓋範圍。
+ exchange_rate_provider_label: 匯率資料提供商
+ securities_provider_label: 證券(股價)資料提供商
+ env_configured_message: 提供商選擇已被停用,因為已設定環境變數 (EXCHANGE_RATE_PROVIDER 或 SECURITIES_PROVIDER)。若要在此處啟用選擇,請從系統配置中移除這些環境變數。
+ providers:
+ twelve_data: Twelve Data
+ yahoo_finance: Yahoo Finance
+ brand_fetch_settings:
+ description: 輸入 Brand Fetch 提供的 Client ID
+ label: Client ID
+ placeholder: 在此輸入您的 Client ID
+ title: Brand Fetch 設定
+ openai_settings:
+ description: 輸入存取權杖 (Access Token),並可選擇配置自定義的 OpenAI 相容提供商。
+ env_configured_message: 已透過環境變數成功配置。
+ access_token_label: 存取權杖 (Access Token)
+ access_token_placeholder: 在此輸入您的存取權杖
+ uri_base_label: API 基礎 URL(選填)
+ uri_base_placeholder: "預設為 https://api.openai.com/v1"
+ model_label: 模型名稱(選填)
+ model_placeholder: "預設為 gpt-4.1"
+ json_mode_label: JSON 模式
+ json_mode_auto: 自動(建議)
+ json_mode_strict: 嚴格 (Strict) - 最適合思考模型
+ json_mode_none: 無 (None) - 最適合標準模型
+ json_mode_json_object: JSON 物件 (JSON Object)
+ json_mode_help: "「嚴格模式」最適合思考模型(如 qwen-thinking, deepseek-reasoner)。「無模式」最適合標準模型(如 llama, mistral, gpt-oss)。"
+ title: OpenAI
+ yahoo_finance_settings:
+ title: Yahoo Finance
+ description: Yahoo Finance 提供免費的股票價格、匯率和金融資料存取,且不需要 API 金鑰。
+ status_active: Yahoo Finance 已啟用且運作正常
+ status_inactive: Yahoo Finance 連線失敗
+ connection_failed: 無法連線至 Yahoo Finance
+ troubleshooting: 請檢查您的網路連線與防火牆設定。Yahoo Finance 可能暫時無法使用。
+ twelve_data_settings:
+ api_calls_used: "每日 API 呼叫已使用 %{used} / %{limit} (%{percentage})"
+ description: 輸入 Twelve Data 提供的 API 金鑰
+ env_configured_message: 已透過 TWELVE_DATA_API_KEY 環境變數成功配置。
+ label: API 金鑰
+ placeholder: 在此輸入您的 API 金鑰
+ plan: "%{plan} 方案"
+ title: Twelve Data
+ update:
+ failure: 設定值無效
+ success: 設定已更新
+ invalid_onboarding_state: 註冊狀態無效
+ clear_cache:
+ cache_cleared: 資料快取已清除。這可能需要幾分鐘的時間來完成。
+ not_authorized: 您沒有權限執行此操作
diff --git a/config/locales/views/settings/securities/zh-TW.yml b/config/locales/views/settings/securities/zh-TW.yml
new file mode 100644
index 000000000..d7699f551
--- /dev/null
+++ b/config/locales/views/settings/securities/zh-TW.yml
@@ -0,0 +1,10 @@
+---
+zh-TW:
+ settings:
+ securities:
+ show:
+ disable_mfa: 停用雙重驗證 (2FA)
+ disable_mfa_confirm: 您確定要停用雙重驗證嗎?這將會降低您帳號的安全性。
+ enable_mfa: 啟用雙重驗證 (2FA)
+ mfa_description: 透過在登入時要求輸入驗證應用程式產生的代碼,為您的帳號增加額外的安全防護。
+ mfa_title: 雙重驗證
diff --git a/config/locales/views/settings/zh-TW.yml b/config/locales/views/settings/zh-TW.yml
new file mode 100644
index 000000000..7006e16fc
--- /dev/null
+++ b/config/locales/views/settings/zh-TW.yml
@@ -0,0 +1,121 @@
+---
+zh-TW:
+ settings:
+ ai_prompts:
+ show:
+ page_title: AI 提示詞
+ openai_label: OpenAI
+ disable_ai: 停用 AI 助手
+ prompt_instructions: 提示詞指令
+ main_system_prompt:
+ title: 主系統提示詞 (System Prompt)
+ subtitle: 定義 AI 助手在所有對話中行為模式的核心指令
+ transaction_categorizer:
+ title: 交易分類器
+ subtitle: AI 會根據您定義的類別自動為交易進行分類
+ merchant_detector:
+ title: 商家偵測器
+ subtitle: AI 會辨識交易資料並自動補全商家資訊
+ billings:
+ show:
+ page_title: 帳單
+ subscription_subtitle: 更新您的訂閱方案與帳單詳情
+ subscription_title: 管理訂閱
+ preferences:
+ show:
+ country: 國家
+ currency: 幣別
+ date_format: 日期格式
+ general_subtitle: 配置您的偏好設定
+ general_title: 一般
+ default_period: 預設時段
+ default_account_order: 預設帳戶排序
+ language: 語言
+ page_title: 偏好設定
+ theme_dark: 深色
+ theme_light: 淺色
+ theme_subtitle: 選擇您偏好的應用程式主題
+ theme_system: 系統設定
+ theme_title: 主題
+ timezone: 時區
+ profiles:
+ destroy:
+ cannot_remove_self: 您無法將自己從帳號中移除。
+ member_removal_failed: 移除成員時發生問題。
+ member_removed: 已成功移除成員。
+ not_authorized: 您沒有移除成員的權限。
+ show:
+ confirm_delete:
+ body: 您確定要永久刪除您的帳號嗎?此操作無法復原。
+ title: 刪除帳號?
+ confirm_reset:
+ body: 您確定要重設帳號嗎?這將刪除您所有的帳戶、類別、商家、標籤及其他資料。此操作無法復原。
+ title: 重設帳號?
+ confirm_reset_with_sample_data:
+ body: 您確定要重設帳號並載入範例資料嗎?這將刪除您現有的資料,並替換為示範資料,讓您可以安全地探索 Sure 的功能。
+ title: 重設帳號並載入範例資料?
+ confirm_remove_invitation:
+ body: 您確定要移除對 %{email} 的邀請嗎?
+ title: 移除邀請
+ confirm_remove_member:
+ body: 您確定要將 %{name} 從您的帳號中移除嗎?
+ title: 移除成員
+ danger_zone_title: 危險區域
+ delete_account: 刪除帳號
+ delete_account_warning: 刪除帳號將永久移除您所有的資料,且無法復原。
+ reset_account: 重設帳號
+ reset_account_warning: 重設帳號將刪除您所有的帳戶、類別、商家、標籤及其他資料,但會保留您的使用者帳號。
+ reset_account_with_sample_data: 重設並預載資料
+ reset_account_with_sample_data_warning: 刪除所有現有資料,並載入全新的範例資料,讓您在預填好的環境中進行探索。
+ email: 電子郵件
+ first_name: 名字
+ household_form_input_placeholder: 輸入家戶名稱
+ household_form_label: 家戶名稱
+ household_subtitle: 邀請家庭成員、伴侶或其他成員加入。受邀者可以登入您的家戶並存取共享帳戶。
+ household_title: 家戶
+ invitation_link: 邀請連結
+ invite_member: 新增成員
+ last_name: 姓氏
+ page_title: 個人資料
+ pending: 待處理
+ profile_subtitle: 自訂您在 %{product_name} 上的顯示方式
+ profile_title: 個人資訊
+ remove_invitation: 移除邀請
+ remove_member: 移除成員
+ save: 儲存
+ securities:
+ show:
+ page_title: 安全性
+ settings_nav:
+ accounts_label: 帳戶管理
+ advanced_section_title: 進階設定
+ ai_prompts_label: AI 提示詞
+ api_key_label: API 金鑰
+ billing_label: 帳單
+ categories_label: 類別
+ feedback_label: 意見回饋
+ general_section_title: 一般
+ imports_label: 匯入/匯出
+ logout: 登出
+ merchants_label: 商家
+ guides_label: 使用指南
+ other_section_title: 更多
+ preferences_label: 偏好設定
+ profile_label: 個人資料
+ recurring_transactions_label: 定期交易
+ rules_label: 自動化規則
+ security_label: 安全性
+ self_hosting_label: 自行代管
+ tags_label: 標籤
+ transactions_section_title: 交易
+ whats_new_label: 最新動態
+ api_keys_label: API 金鑰
+ bank_sync_label: 銀行同步
+ settings_nav_link_large:
+ next: 下一步
+ previous: 返回
+ user_avatar_field:
+ accepted_formats: 僅限 JPG 或 PNG。檔案大小上限為 5MB。
+ choose: 上傳相片
+ choose_label: (選填)
+ change: 變更相片
diff --git a/config/locales/views/shared/zh-TW.yml b/config/locales/views/shared/zh-TW.yml
new file mode 100644
index 000000000..a29edfe67
--- /dev/null
+++ b/config/locales/views/shared/zh-TW.yml
@@ -0,0 +1,14 @@
+---
+zh-TW:
+ shared:
+ confirm_modal:
+ accept: 確認
+ body_html: "此操作將無法復原
"
+ cancel: 取消
+ title: 您確定嗎?
+ money_field:
+ label: 金額
+ syncing_notice:
+ syncing: 正在同步帳戶資料...
+ trend_change:
+ no_change: "無變動"
diff --git a/config/locales/views/simplefin_items/en.yml b/config/locales/views/simplefin_items/en.yml
index a84c335e3..10f83be0f 100644
--- a/config/locales/views/simplefin_items/en.yml
+++ b/config/locales/views/simplefin_items/en.yml
@@ -70,6 +70,10 @@ en:
status_with_summary: "Last synced %{timestamp} ago • %{summary}"
syncing: Syncing...
update: Update
+ stale_pending_note: "(excluded from budgets)"
+ stale_pending_accounts: "in: %{accounts}"
+ reconciled_details_note: "(see sync summary for details)"
+ duplicate_accounts_skipped: "Some accounts were skipped as duplicates — use 'Link existing accounts' to merge."
select_existing_account:
title: "Link %{account_name} to SimpleFIN"
description: Select a SimpleFIN account to link to your existing account
@@ -86,3 +90,11 @@ en:
errors:
only_manual: Only manual accounts can be linked
invalid_simplefin_account: Invalid SimpleFIN account selected
+ reconciled_status:
+ message:
+ one: "%{count} duplicate pending transaction reconciled"
+ other: "%{count} duplicate pending transactions reconciled"
+ stale_pending_status:
+ message:
+ one: "%{count} pending transaction older than %{days} days"
+ other: "%{count} pending transactions older than %{days} days"
diff --git a/config/locales/views/simplefin_items/zh-TW.yml b/config/locales/views/simplefin_items/zh-TW.yml
new file mode 100644
index 000000000..93ca0ee5a
--- /dev/null
+++ b/config/locales/views/simplefin_items/zh-TW.yml
@@ -0,0 +1,71 @@
+---
+zh-TW:
+ simplefin_items:
+ new:
+ title: 連結 SimpleFIN
+ setup_token: 設定權杖 (Setup token)
+ setup_token_placeholder: 貼上您的 SimpleFIN 設定權杖
+ connect: 連結
+ cancel: 取消
+ create:
+ success: SimpleFIN 連線已成功新增!您的帳戶將在背景同步完成後顯示。
+ errors:
+ blank_token: 請輸入 SimpleFIN 設定權杖。
+ invalid_token: 權杖無效。請確認您從 SimpleFIN Bridge 複製的是完整的權杖。
+ token_compromised: 此權杖可能已外洩、過期或已被使用。請建立一個新權杖。
+ create_failed: "連結失敗:%{message}"
+ unexpected: 發生預期之外的錯誤。請再試一次或聯絡支援團隊。
+ destroy:
+ success: SimpleFIN 連線將被移除
+ update:
+ success: SimpleFIN 連線已成功更新!您的帳戶正在重新連線。
+ errors:
+ blank_token: 請輸入 SimpleFIN 設定權杖。
+ invalid_token: 權杖無效。請確認您從 SimpleFIN Bridge 複製的是完整的權杖。
+ token_compromised: 此權杖可能已外洩、過期或已被使用。請建立一個新權杖。
+ update_failed: "更新連線失敗:%{message}"
+ unexpected: 發生預期之外的錯誤。請再試一次或聯絡支援團隊。
+ edit:
+ setup_token:
+ label: "SimpleFIN 設定權杖:"
+ placeholder: "在此貼上您的 SimpleFIN 設定權杖..."
+ help_text: "權杖應為一串由字母和數字組成的長字串"
+ complete_account_setup:
+ all_skipped: "已跳過所有帳戶。未建立任何帳戶。"
+ no_accounts: "沒有需要設定的帳戶。"
+ success: "已成功建立 %{count} 個 SimpleFIN 帳戶!您的交易紀錄與持倉資料正在背景匯入中。"
+ simplefin_item:
+ add_new: 新增連線
+ confirm_accept: 刪除連線
+ confirm_body: 這將永久刪除此群組中的所有帳戶及相關資料。
+ confirm_title: 確定要刪除 SimpleFIN 連線嗎?
+ delete: 刪除
+ deletion_in_progress: "(正在刪除...)"
+ error: 同步資料時發生錯誤
+ no_accounts_description: 此連線目前尚無任何已同步的帳戶。
+ no_accounts_title: 找不到帳戶
+ requires_update: 重新連線
+ setup_needed: 新帳戶已準備好進行設定
+ setup_description: 請為您新匯入的 SimpleFIN 帳戶選擇帳戶類型。
+ setup_action: 設定新帳戶
+ status: "%{timestamp} 前已同步"
+ status_never: 從未同步
+ status_with_summary: "最後同步於 %{timestamp} 前 • %{summary}"
+ syncing: 同步中...
+ update: 更新
+ select_existing_account:
+ title: "將 %{account_name} 連結至 SimpleFIN"
+ description: 選擇一個 SimpleFIN 帳戶來連結至您現有的帳戶
+ cancel: 取消
+ link_account: 連結帳戶
+ no_accounts_found: 此家庭中找不到任何 SimpleFIN 帳戶。
+ wait_for_sync: 如果您剛完成連線或同步,請在同步完成後再試一次。
+ unlink_to_move: 若要更改連結,請先從帳戶的操作選單中解除連結。
+ all_accounts_already_linked: 所有的 SimpleFIN 帳戶似乎都已連結。
+ currently_linked_to: "目前連結至:%{account_name}"
+
+ link_existing_account:
+ success: 帳戶已成功連結至 SimpleFIN
+ errors:
+ only_manual: 僅能連結手動管理的帳戶
+ invalid_simplefin_account: 所選的 SimpleFIN 帳戶無效
diff --git a/config/locales/views/subscriptions/zh-TW.yml b/config/locales/views/subscriptions/zh-TW.yml
new file mode 100644
index 000000000..5aa881e31
--- /dev/null
+++ b/config/locales/views/subscriptions/zh-TW.yml
@@ -0,0 +1,4 @@
+---
+zh-TW:
+ subscriptions:
+ self_hosted_alert: "在自行代管模式下不支援 %{product_name}。"
diff --git a/config/locales/views/tag/deletions/zh-TW.yml b/config/locales/views/tag/deletions/zh-TW.yml
new file mode 100644
index 000000000..a1aa56193
--- /dev/null
+++ b/config/locales/views/tag/deletions/zh-TW.yml
@@ -0,0 +1,13 @@
+---
+zh-TW:
+ tag:
+ deletions:
+ create:
+ deleted: 標籤已刪除
+ new:
+ delete_and_leave_uncategorized: 刪除「%{tag_name}」
+ delete_and_recategorize: 刪除「%{tag_name}」並指定新標籤
+ delete_tag: 確定要刪除標籤嗎?
+ explanation: "「%{tag_name}」將會從交易紀錄及其他可標記項目中移除。除了讓這些項目保持無標籤狀態,您也可以在下方選擇一個新標籤進行替換。"
+ replacement_tag_prompt: 選擇標籤
+ tag: 標籤
diff --git a/config/locales/views/tags/zh-TW.yml b/config/locales/views/tags/zh-TW.yml
new file mode 100644
index 000000000..cd8307529
--- /dev/null
+++ b/config/locales/views/tags/zh-TW.yml
@@ -0,0 +1,23 @@
+---
+zh-TW:
+ tags:
+ create:
+ created: 標籤已建立
+ error: '建立標籤時發生錯誤:%{error}'
+ destroy:
+ deleted: 標籤已刪除
+ edit:
+ edit: 編輯標籤
+ form:
+ placeholder: 標籤名稱
+ index:
+ empty: 尚無標籤
+ new: 新增標籤
+ tags: 標籤
+ new:
+ new: 新增標籤
+ tag:
+ delete: 刪除
+ edit: 編輯
+ update:
+ updated: 標籤已更新
diff --git a/config/locales/views/trades/zh-TW.yml b/config/locales/views/trades/zh-TW.yml
new file mode 100644
index 000000000..05c6ca25b
--- /dev/null
+++ b/config/locales/views/trades/zh-TW.yml
@@ -0,0 +1,38 @@
+---
+zh-TW:
+ trades:
+ form:
+ account: 轉帳帳戶(選填)
+ account_prompt: 搜尋帳戶
+ amount: 金額
+ holding: 股票代碼
+ price: 每股價格
+ qty: 數量
+ submit: 新增交易
+ ticker_placeholder: AAPL
+ type: 類型
+ header:
+ buy: 買入
+ current_market_price_label: 目前市價
+ overview: 概覽
+ purchase_price_label: 買入價格
+ purchase_qty_label: 買入數量
+ sell: 賣出
+ symbol_label: 代碼
+ total_return_label: 未實現損益
+ new:
+ title: 新增交易
+ show:
+ additional: 額外資訊
+ cost_per_share_label: 每股成本
+ date_label: 日期
+ delete: 刪除
+ delete_subtitle: 此操作將無法復原
+ delete_title: 刪除交易紀錄
+ details: 詳情
+ exclude_subtitle: 此筆交易將不計入報表與分析計算
+ exclude_title: 從分析中排除
+ note_label: 備註
+ note_placeholder: 在此新增額外備註...
+ quantity_label: 數量
+ settings: 設定
diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml
index 4572ed8c6..ae7bb1cb3 100644
--- a/config/locales/views/transactions/en.yml
+++ b/config/locales/views/transactions/en.yml
@@ -31,19 +31,57 @@ en:
balances, and cannot be undone.
delete_title: Delete transaction
details: Details
- mark_recurring: Mark as Recurring
- mark_recurring_subtitle: Track this as a recurring transaction. Amount variance is automatically calculated from past 6 months of similar transactions.
- mark_recurring_title: Recurring Transaction
- merchant_label: Merchant
- name_label: Name
- nature: Type
- none: "(none)"
- note_label: Notes
- note_placeholder: Enter a note
- overview: Overview
- settings: Settings
- tags_label: Tags
- uncategorized: "(uncategorized)"
+ exclude: Exclude
+ exclude_description: Excluded transactions will be removed from budgeting calculations and reports.
+ activity_type: Activity Type
+ activity_type_description: Type of investment activity (Buy, Sell, Dividend, etc.). Auto-detected or set manually.
+ one_time_title: One-time %{type}
+ one_time_description: One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.
+ activity_labels:
+ buy: Buy
+ sell: Sell
+ sweep_in: Sweep In
+ sweep_out: Sweep Out
+ dividend: Dividend
+ reinvestment: Reinvestment
+ interest: Interest
+ fee: Fee
+ transfer: Transfer
+ contribution: Contribution
+ withdrawal: Withdrawal
+ exchange: Exchange
+ other: Other
+ mark_recurring: Mark as Recurring
+ mark_recurring_subtitle: Track this as a recurring transaction. Amount variance is automatically calculated from past 6 months of similar transactions.
+ mark_recurring_title: Recurring Transaction
+ merchant_label: Merchant
+ name_label: Name
+ nature: Type
+ none: "(none)"
+ note_label: Notes
+ note_placeholder: Enter a note
+ overview: Overview
+ settings: Settings
+ tags_label: Tags
+ uncategorized: "(uncategorized)"
+ potential_duplicate_title: Possible duplicate detected
+ potential_duplicate_description: This pending transaction may be the same as the posted transaction below. If so, merge them to avoid double-counting.
+ merge_duplicate: Yes, merge them
+ keep_both: No, keep both
+ transaction:
+ pending: Pending
+ pending_tooltip: Pending transaction — may change when posted
+ activity_type_tooltip: Investment activity type
+ possible_duplicate: Duplicate?
+ potential_duplicate_tooltip: This may be a duplicate of another transaction
+ review_recommended: Review
+ review_recommended_tooltip: Large amount difference — review recommended to check if this is a duplicate
+ merge_duplicate:
+ success: Transactions merged successfully
+ failure: Could not merge transactions
+ dismiss_duplicate:
+ success: Kept as separate transactions
+ failure: Could not dismiss duplicate suggestion
header:
edit_categories: Edit categories
edit_imports: Edit imports
@@ -55,6 +93,16 @@ en:
transactions: transactions
import: Import
toggle_recurring_section: Toggle upcoming recurring transactions
+ search:
+ filters:
+ account: Account
+ date: Date
+ type: Type
+ status: Status
+ amount: Amount
+ category: Category
+ tag: Tag
+ merchant: Merchant
searches:
filters:
amount_filter:
@@ -68,10 +116,15 @@ en:
on_or_after: on or after %{date}
on_or_before: on or before %{date}
transfer: Transfer
+ confirmed: Confirmed
+ pending: Pending
type_filter:
expense: Expense
income: Income
transfer: Transfer
+ status_filter:
+ confirmed: Confirmed
+ pending: Pending
menu:
account_filter: Account
amount_filter: Amount
@@ -81,6 +134,7 @@ en:
clear_filters: Clear filters
date_filter: Date
merchant_filter: Merchant
+ status_filter: Status
tag_filter: Tag
type_filter: Type
search:
diff --git a/config/locales/views/transactions/zh-TW.yml b/config/locales/views/transactions/zh-TW.yml
new file mode 100644
index 000000000..d5bb0fb46
--- /dev/null
+++ b/config/locales/views/transactions/zh-TW.yml
@@ -0,0 +1,89 @@
+---
+zh-TW:
+ transactions:
+ form:
+ account: 帳戶
+ account_prompt: 選擇帳戶
+ amount: 金額
+ category: 類別
+ category_prompt: 選擇類別
+ date: 日期
+ description: 描述
+ description_placeholder: 描述此筆交易
+ expense: 支出
+ income: 收入
+ none: (無)
+ note_label: 備註
+ note_placeholder: 輸入備註
+ submit: 新增交易
+ tags_label: 標籤
+ transfer: 轉帳
+ new:
+ new_transaction: 新增交易
+ show:
+ account_label: 帳戶
+ amount: 金額
+ category_label: 類別
+ date_label: 日期
+ delete: 刪除
+ delete_subtitle: 這將永久刪除此筆交易,並影響您的歷史餘額,此操作無法復原。
+ delete_title: 刪除交易
+ details: 詳情
+ mark_recurring: 標記為定期交易
+ mark_recurring_subtitle: 將此筆交易追蹤為定期交易。系統會根據過去 6 個月的類似交易自動計算金額變動範圍。
+ mark_recurring_title: 定期交易
+ merchant_label: 商家
+ name_label: 名稱
+ nature: 類型
+ none: "(無)"
+ note_label: 備註
+ note_placeholder: 輸入備註
+ overview: 概覽
+ settings: 設定
+ tags_label: 標籤
+ uncategorized: "(未分類)"
+ header:
+ edit_categories: 編輯類別
+ edit_imports: 編輯匯入
+ edit_merchants: 編輯商家
+ edit_tags: 編輯標籤
+ import: 匯入
+ index:
+ transaction: 筆交易
+ transactions: 筆交易
+ import: 匯入
+ toggle_recurring_section: 切換即將到來的定期交易顯示狀態
+ searches:
+ filters:
+ amount_filter:
+ equal_to: 等於
+ greater_than: 大於
+ less_than: 小於
+ placeholder: '0'
+ badge:
+ expense: 支出
+ income: 收入
+ on_or_after: "%{date} 當日或之後"
+ on_or_before: "%{date} 當日或之前"
+ transfer: 轉帳
+ type_filter:
+ expense: 支出
+ income: 收入
+ transfer: 轉帳
+ menu:
+ account_filter: 帳戶
+ amount_filter: 金額
+ apply: 應用
+ cancel: 取消
+ category_filter: 類別
+ clear_filters: 清除過濾
+ date_filter: 日期
+ merchant_filter: 商家
+ tag_filter: 標籤
+ type_filter: 類型
+ search:
+ equal_to: 等於
+ greater_than: 大於
+ less_than: 小於
+ form:
+ toggle_selection_checkboxes: 切換全選狀態
diff --git a/config/locales/views/transfers/zh-TW.yml b/config/locales/views/transfers/zh-TW.yml
new file mode 100644
index 000000000..e747368cd
--- /dev/null
+++ b/config/locales/views/transfers/zh-TW.yml
@@ -0,0 +1,30 @@
+---
+zh-TW:
+ transfers:
+ create:
+ success: 轉帳已建立
+ destroy:
+ success: 轉帳已移除
+ form:
+ amount: 金額
+ date: 日期
+ expense: 支出
+ from: 從
+ income: 收入
+ select_account: 選擇帳戶
+ submit: 建立轉帳
+ to: 至
+ transfer: 轉帳
+ new:
+ title: 新增轉帳
+ show:
+ delete: 移除轉帳
+ delete_subtitle: 這將會移除轉帳連結,但不會刪除其對應的原始交易紀錄。
+ delete_title: 確定要移除轉帳嗎?
+ details: 詳情
+ note_label: 備註
+ note_placeholder: 為此筆轉帳新增備註
+ overview: 概覽
+ settings: 設定
+ update:
+ success: 轉帳已更新
diff --git a/config/locales/views/users/zh-TW.yml b/config/locales/views/users/zh-TW.yml
new file mode 100644
index 000000000..7ce2801ad
--- /dev/null
+++ b/config/locales/views/users/zh-TW.yml
@@ -0,0 +1,17 @@
+---
+zh-TW:
+ users:
+ destroy:
+ success: 您的帳號已刪除。
+ update:
+ email_change_failed: 變更電子郵件地址失敗。
+ email_change_initiated: 請檢查您的新電子郵件信箱以獲取驗證說明。
+ success: 您的個人資料已更新。
+ resend_confirmation_email:
+ success: 新的驗證郵件已排入傳送佇列。
+ no_pending_change: 目前沒有待處理的電子郵件變更請求!
+ reset:
+ success: 您的帳號已重設。資料將在一段時間後於背景進行刪除。
+ unauthorized: 您沒有權限執行此操作。
+ reset_with_sample_data:
+ success: 您的帳號已重設,範例資料正在準備中。您很快就會看到示範資料。
diff --git a/config/locales/views/valuations/zh-TW.yml b/config/locales/views/valuations/zh-TW.yml
new file mode 100644
index 000000000..b1a374afe
--- /dev/null
+++ b/config/locales/views/valuations/zh-TW.yml
@@ -0,0 +1,30 @@
+---
+zh-TW:
+ valuations:
+ form:
+ amount: 金額
+ submit: 新增餘額更新
+ header:
+ balance: 餘額
+ index:
+ change: 變動
+ date: 日期
+ new_entry: 新增紀錄
+ no_valuations: 此帳戶尚無價值紀錄
+ valuations: 價值
+ value: 價值
+ new:
+ title: 新餘額
+ show:
+ amount: 金額
+ date_label: 日期
+ delete: 刪除
+ delete_subtitle: 此操作將無法復原
+ delete_title: 刪除紀錄
+ details: 詳情
+ name_label: 名稱
+ name_placeholder: 為此紀錄輸入名稱
+ note_label: 備註
+ note_placeholder: 加入關於此紀錄的額外資訊
+ overview: 概覽
+ settings: 設定
diff --git a/config/locales/views/vehicles/zh-TW.yml b/config/locales/views/vehicles/zh-TW.yml
new file mode 100644
index 000000000..e16fc5ed6
--- /dev/null
+++ b/config/locales/views/vehicles/zh-TW.yml
@@ -0,0 +1,25 @@
+---
+zh-TW:
+ vehicles:
+ edit:
+ edit: 編輯 %{account}
+ form:
+ make: 廠牌
+ make_placeholder: 例如:Toyota
+ mileage: 里程數
+ mileage_placeholder: '15000'
+ mileage_unit: 單位
+ model: 車型
+ model_placeholder: 例如:Camry
+ year: 年份
+ year_placeholder: '2023'
+ new:
+ title: 輸入車輛詳情
+ overview:
+ current_price: 目前價值
+ make_model: 廠牌與車型
+ mileage: 里程數
+ purchase_price: 買入價格
+ trend: 趨勢
+ unknown: 未知
+ year: 年份
diff --git a/config/routes.rb b/config/routes.rb
index 615cd1314..07a70144e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -64,9 +64,10 @@ Rails.application.routes.draw do
resource :current_session, only: %i[update]
resource :registration, only: %i[new create]
- resources :sessions, only: %i[new create destroy]
+ resources :sessions, only: %i[index new create destroy]
match "/auth/:provider/callback", to: "sessions#openid_connect", via: %i[get post]
match "/auth/failure", to: "sessions#failure", via: %i[get post]
+ get "/auth/logout/callback", to: "sessions#post_logout"
resource :oidc_account, only: [] do
get :link, on: :collection
post :create_link, on: :collection
@@ -100,6 +101,7 @@ Rails.application.routes.draw do
end
resource :billing, only: :show
resource :security, only: :show
+ resources :sso_identities, only: :destroy
resource :api_key, only: [ :show, :new, :create, :destroy ]
resource :ai_prompts, only: :show
resource :llm_usage, only: :show
@@ -135,6 +137,7 @@ Rails.application.routes.draw do
patch :update_preferences, on: :collection
get :export_transactions, on: :collection
get :google_sheets_instructions, on: :collection
+ get :print, on: :collection
end
resources :budgets, only: %i[index show edit update], param: :month_year do
@@ -168,7 +171,11 @@ Rails.application.routes.draw do
resources :mappings, only: :update, module: :import
end
- resources :holdings, only: %i[index new show destroy]
+ resources :holdings, only: %i[index new show update destroy] do
+ member do
+ post :unlock_cost_basis
+ end
+ end
resources :trades, only: %i[show new create update destroy]
resources :valuations, only: %i[show new create update destroy] do
post :confirm_create, on: :collection
@@ -191,6 +198,8 @@ Rails.application.routes.draw do
member do
post :mark_as_recurring
+ post :merge_duplicate
+ post :dismiss_duplicate
end
end
@@ -287,6 +296,9 @@ Rails.application.routes.draw do
# Production API endpoints
resources :accounts, only: [ :index, :show ]
resources :categories, only: [ :index, :show ]
+ resources :merchants, only: %i[index show]
+ resources :tags, only: %i[index show create update destroy]
+
resources :transactions, only: [ :index, :show, :create, :update, :destroy ]
resources :imports, only: [ :index, :show, :create ]
resource :usage, only: [ :show ], controller: :usage
@@ -386,6 +398,17 @@ Rails.application.routes.draw do
get "privacy", to: redirect("about:blank")
get "terms", to: redirect("about:blank")
+ # Admin namespace for super admin functionality
+ namespace :admin do
+ resources :sso_providers do
+ member do
+ patch :toggle
+ post :test_connection
+ end
+ end
+ resources :users, only: [ :index, :update ]
+ end
+
# Defines the root path route ("/")
root "pages#dashboard"
end
diff --git a/config/schedule.yml b/config/schedule.yml
index b12d75ee7..c0d324408 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -19,12 +19,6 @@ run_security_health_checks:
queue: "scheduled"
description: "Runs security health checks to detect issues with security data"
-sync_all_accounts:
- cron: "22 2 * * *" # every 24 hours at 2:22am
- class: "SyncAllJob"
- queue: "scheduled"
- description: "Syncs all accounts for all families"
-
sync_hourly:
cron: "0 * * * *" # every hour at the top of the hour
class: "SyncHourlyJob"
diff --git a/db/migrate/20251228181150_create_flipper_tables.rb b/db/migrate/20251228181150_create_flipper_tables.rb
new file mode 100644
index 000000000..811f528cf
--- /dev/null
+++ b/db/migrate/20251228181150_create_flipper_tables.rb
@@ -0,0 +1,22 @@
+class CreateFlipperTables < ActiveRecord::Migration[7.2]
+ def up
+ create_table :flipper_features do |t|
+ t.string :key, null: false
+ t.timestamps null: false
+ end
+ add_index :flipper_features, :key, unique: true
+
+ create_table :flipper_gates do |t|
+ t.string :feature_key, null: false
+ t.string :key, null: false
+ t.text :value
+ t.timestamps null: false
+ end
+ add_index :flipper_gates, [ :feature_key, :key, :value ], unique: true, length: { value: 255 }
+ end
+
+ def down
+ drop_table :flipper_gates
+ drop_table :flipper_features
+ end
+end
diff --git a/db/migrate/20251228181429_create_sso_providers.rb b/db/migrate/20251228181429_create_sso_providers.rb
new file mode 100644
index 000000000..5278af4e4
--- /dev/null
+++ b/db/migrate/20251228181429_create_sso_providers.rb
@@ -0,0 +1,21 @@
+class CreateSsoProviders < ActiveRecord::Migration[7.2]
+ def change
+ create_table :sso_providers, id: :uuid do |t|
+ t.string :strategy, null: false
+ t.string :name, null: false
+ t.string :label, null: false
+ t.string :icon
+ t.boolean :enabled, null: false, default: true
+ t.string :issuer
+ t.string :client_id
+ t.string :client_secret
+ t.string :redirect_uri
+ t.jsonb :settings, null: false, default: {}
+
+ t.timestamps
+ end
+
+ add_index :sso_providers, :name, unique: true
+ add_index :sso_providers, :enabled
+ end
+end
diff --git a/db/migrate/20251228182113_add_issuer_to_oidc_identities.rb b/db/migrate/20251228182113_add_issuer_to_oidc_identities.rb
new file mode 100644
index 000000000..5922cd3fc
--- /dev/null
+++ b/db/migrate/20251228182113_add_issuer_to_oidc_identities.rb
@@ -0,0 +1,6 @@
+class AddIssuerToOidcIdentities < ActiveRecord::Migration[7.2]
+ def change
+ add_column :oidc_identities, :issuer, :string
+ add_index :oidc_identities, :issuer
+ end
+end
diff --git a/db/migrate/20260103170412_create_sso_audit_logs.rb b/db/migrate/20260103170412_create_sso_audit_logs.rb
new file mode 100644
index 000000000..f28ce14e9
--- /dev/null
+++ b/db/migrate/20260103170412_create_sso_audit_logs.rb
@@ -0,0 +1,18 @@
+class CreateSsoAuditLogs < ActiveRecord::Migration[7.2]
+ def change
+ create_table :sso_audit_logs, id: :uuid do |t|
+ t.references :user, type: :uuid, foreign_key: true, null: true
+ t.string :event_type, null: false
+ t.string :provider
+ t.string :ip_address
+ t.string :user_agent
+ t.jsonb :metadata, null: false, default: {}
+
+ t.timestamps
+ end
+
+ add_index :sso_audit_logs, :event_type
+ add_index :sso_audit_logs, :created_at
+ add_index :sso_audit_logs, [ :user_id, :created_at ]
+ end
+end
diff --git a/db/migrate/20260105225906_remove_synthfinance_logo_urls.rb b/db/migrate/20260105225906_remove_synthfinance_logo_urls.rb
new file mode 100644
index 000000000..772d1eed6
--- /dev/null
+++ b/db/migrate/20260105225906_remove_synthfinance_logo_urls.rb
@@ -0,0 +1,15 @@
+class RemoveSynthfinanceLogoUrls < ActiveRecord::Migration[7.2]
+ def up
+ # Remove logo URLs pointing to the old synthfinance.com domain
+ # These URLs are no longer valid and should be set to NULL
+ execute <<-SQL
+ UPDATE merchants
+ SET logo_url = NULL
+ WHERE logo_url LIKE '%logo.synthfinance.com%'
+ SQL
+ end
+
+ def down
+ # No-op: we can't restore the old logo URLs
+ end
+end
diff --git a/db/migrate/20260110120000_add_investment_cashflow_support.rb b/db/migrate/20260110120000_add_investment_cashflow_support.rb
new file mode 100644
index 000000000..39765f4f1
--- /dev/null
+++ b/db/migrate/20260110120000_add_investment_cashflow_support.rb
@@ -0,0 +1,5 @@
+class AddInvestmentCashflowSupport < ActiveRecord::Migration[7.2]
+ # No-op: exclude_from_cashflow was consolidated into the existing 'excluded' toggle
+ def change
+ end
+end
diff --git a/db/migrate/20260110180000_add_investment_activity_label_to_transactions.rb b/db/migrate/20260110180000_add_investment_activity_label_to_transactions.rb
new file mode 100644
index 000000000..658900bc2
--- /dev/null
+++ b/db/migrate/20260110180000_add_investment_activity_label_to_transactions.rb
@@ -0,0 +1,8 @@
+class AddInvestmentActivityLabelToTransactions < ActiveRecord::Migration[7.2]
+ def change
+ # Label for investment activity type (Buy, Sell, Sweep In, Dividend, etc.)
+ # Provides human-readable context for why a transaction is excluded from cashflow
+ add_column :transactions, :investment_activity_label, :string
+ add_index :transactions, :investment_activity_label
+ end
+end
diff --git a/db/migrate/20260112011546_add_cost_basis_source_tracking_to_holdings.rb b/db/migrate/20260112011546_add_cost_basis_source_tracking_to_holdings.rb
new file mode 100644
index 000000000..f805dfbe4
--- /dev/null
+++ b/db/migrate/20260112011546_add_cost_basis_source_tracking_to_holdings.rb
@@ -0,0 +1,6 @@
+class AddCostBasisSourceTrackingToHoldings < ActiveRecord::Migration[7.2]
+ def change
+ add_column :holdings, :cost_basis_source, :string
+ add_column :holdings, :cost_basis_locked, :boolean, default: false, null: false
+ end
+end
diff --git a/db/migrate/20260112065106_backfill_cost_basis_source_for_holdings.rb b/db/migrate/20260112065106_backfill_cost_basis_source_for_holdings.rb
new file mode 100644
index 000000000..b81f92156
--- /dev/null
+++ b/db/migrate/20260112065106_backfill_cost_basis_source_for_holdings.rb
@@ -0,0 +1,42 @@
+class BackfillCostBasisSourceForHoldings < ActiveRecord::Migration[7.2]
+ disable_ddl_transaction!
+
+ def up
+ # Backfill cost_basis_source for existing holdings that have cost_basis but no source
+ # This is safe - it only adds metadata, doesn't change actual cost_basis values
+ # Locks existing data by default to protect it - users can unlock if they want syncs to update
+
+ say_with_time "Backfilling cost_basis_source for holdings" do
+ updated = 0
+
+ # Process in batches to avoid locking issues
+ Holding.where.not(cost_basis: nil)
+ .where(cost_basis_source: nil)
+ .where("cost_basis > 0")
+ .find_each do |holding|
+ # Heuristic: If holding's account has buy trades for this security, likely calculated
+ # Otherwise, likely from provider (SimpleFIN/Plaid/Lunchflow)
+ has_trades = holding.account.trades
+ .where(security_id: holding.security_id)
+ .where("qty > 0")
+ .exists?
+
+ source = has_trades ? "calculated" : "provider"
+
+ # Lock existing data to protect it - users can unlock via UI if they want syncs to update
+ holding.update_columns(cost_basis_source: source, cost_basis_locked: true)
+ updated += 1
+ end
+
+ updated
+ end
+ end
+
+ def down
+ # Reversible: clear the source and unlock for holdings that were backfilled
+ # We can't know for sure which ones were backfilled vs manually set,
+ # but clearing all non-manual sources is safe since they'd be re-detected
+ Holding.where(cost_basis_source: %w[calculated provider])
+ .update_all(cost_basis_source: nil, cost_basis_locked: false)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index db2b27089..1b7bba765 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_01_10_142603) do
+ActiveRecord::Schema[7.2].define(version: 2026_01_12_065106) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -49,6 +49,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_10_142603) do
t.string "institution_name"
t.string "institution_domain"
t.text "notes"
+ t.jsonb "holdings_snapshot_data"
+ t.datetime "holdings_snapshot_at"
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["currency"], name: "index_accounts_on_currency"
@@ -472,6 +474,22 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_10_142603) do
t.index ["merchant_id"], name: "index_family_merchant_associations_on_merchant_id"
end
+ create_table "flipper_features", force: :cascade do |t|
+ t.string "key", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["key"], name: "index_flipper_features_on_key", unique: true
+ end
+
+ create_table "flipper_gates", force: :cascade do |t|
+ t.string "feature_key", null: false
+ t.string "key", null: false
+ t.text "value"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true
+ end
+
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "security_id", null: false
@@ -485,6 +503,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_10_142603) do
t.string "external_id"
t.decimal "cost_basis", precision: 19, scale: 4
t.uuid "account_provider_id"
+ t.string "cost_basis_source"
+ t.boolean "cost_basis_locked", default: false, null: false
t.index ["account_id", "external_id"], name: "idx_holdings_on_account_id_external_id_unique", unique: true, where: "(external_id IS NOT NULL)"
t.index ["account_id", "security_id", "date", "currency"], name: "idx_on_account_id_security_id_date_currency_5323e39f8b", unique: true
t.index ["account_id"], name: "index_holdings_on_account_id"
@@ -795,6 +815,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_10_142603) do
t.datetime "last_authenticated_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.string "issuer"
+ t.index ["issuer"], name: "index_oidc_identities_on_issuer"
t.index ["provider", "uid"], name: "index_oidc_identities_on_provider_and_uid", unique: true
t.index ["user_id"], name: "index_oidc_identities_on_user_id"
end
@@ -1050,6 +1072,38 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_10_142603) do
t.index ["status"], name: "index_simplefin_items_on_status"
end
+ create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "user_id"
+ t.string "event_type", null: false
+ t.string "provider"
+ t.string "ip_address"
+ t.string "user_agent"
+ t.jsonb "metadata", default: {}, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["created_at"], name: "index_sso_audit_logs_on_created_at"
+ t.index ["event_type"], name: "index_sso_audit_logs_on_event_type"
+ t.index ["user_id", "created_at"], name: "index_sso_audit_logs_on_user_id_and_created_at"
+ t.index ["user_id"], name: "index_sso_audit_logs_on_user_id"
+ end
+
+ create_table "sso_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.string "strategy", null: false
+ t.string "name", null: false
+ t.string "label", null: false
+ t.string "icon"
+ t.boolean "enabled", default: true, null: false
+ t.string "issuer"
+ t.string "client_id"
+ t.string "client_secret"
+ t.string "redirect_uri"
+ t.jsonb "settings", default: {}, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["enabled"], name: "index_sso_providers_on_enabled"
+ t.index ["name"], name: "index_sso_providers_on_name", unique: true
+ end
+
create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "status", null: false
@@ -1126,7 +1180,14 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_10_142603) do
t.string "currency"
t.jsonb "locked_attributes", default: {}
t.uuid "category_id"
+ t.decimal "realized_gain", precision: 19, scale: 4
+ t.decimal "cost_basis_amount", precision: 19, scale: 4
+ t.string "cost_basis_currency"
+ t.integer "holding_period_days"
+ t.string "realized_gain_confidence"
+ t.string "realized_gain_currency"
t.index ["category_id"], name: "index_trades_on_category_id"
+ t.index ["realized_gain"], name: "index_trades_on_realized_gain_not_null", where: "(realized_gain IS NOT NULL)"
t.index ["security_id"], name: "index_trades_on_security_id"
end
@@ -1139,9 +1200,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_10_142603) do
t.string "kind", default: "standard", null: false
t.string "external_id"
t.jsonb "extra", default: {}, null: false
+ t.string "investment_activity_label"
t.index ["category_id"], name: "index_transactions_on_category_id"
t.index ["external_id"], name: "index_transactions_on_external_id"
t.index ["extra"], name: "index_transactions_on_extra", using: :gin
+ t.index ["investment_activity_label"], name: "index_transactions_on_investment_activity_label"
t.index ["kind"], name: "index_transactions_on_kind"
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
end
@@ -1275,6 +1338,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_10_142603) do
add_foreign_key "sessions", "users"
add_foreign_key "simplefin_accounts", "simplefin_items"
add_foreign_key "simplefin_items", "families"
+ add_foreign_key "sso_audit_logs", "users"
add_foreign_key "subscriptions", "families"
add_foreign_key "syncs", "syncs", column: "parent_id"
add_foreign_key "taggings", "tags"
diff --git a/docs/api/merchants.md b/docs/api/merchants.md
new file mode 100644
index 000000000..5e00b3dda
--- /dev/null
+++ b/docs/api/merchants.md
@@ -0,0 +1,117 @@
+# Merchants API
+
+The Merchants API allows external applications to retrieve merchants within Sure. Merchants represent payees or vendors associated with transactions.
+
+## Generated OpenAPI specification
+
+- The source of truth for the documentation lives in [`spec/requests/api/v1/merchants_spec.rb`](../../spec/requests/api/v1/merchants_spec.rb). These specs authenticate against the Rails stack, exercise every merchant endpoint, and capture real response shapes.
+- Regenerate the OpenAPI document with:
+
+ ```sh
+ SWAGGER_DRY_RUN=0 bundle exec rspec spec/requests --format Rswag::Specs::SwaggerFormatter
+ ```
+
+ The task compiles the request specs and writes the result to [`docs/api/openapi.yaml`](openapi.yaml).
+
+- Run just the documentation specs with:
+
+ ```sh
+ bundle exec rspec spec/requests/api/v1/merchants_spec.rb
+ ```
+
+## Authentication requirements
+
+All merchant endpoints require an OAuth2 access token or API key that grants the `read` scope.
+
+## Available endpoints
+
+| Endpoint | Scope | Description |
+| --- | --- | --- |
+| `GET /api/v1/merchants` | `read` | List all merchants available to the family. |
+| `GET /api/v1/merchants/{id}` | `read` | Retrieve a single merchant by ID. |
+
+Refer to the generated [`openapi.yaml`](openapi.yaml) for request/response schemas, reusable components, and security definitions.
+
+## Merchant types
+
+Sure supports two types of merchants:
+
+| Type | Description |
+| --- | --- |
+| `FamilyMerchant` | Merchants created and owned by the family. |
+| `ProviderMerchant` | Merchants from external providers (e.g., Plaid) assigned to transactions. |
+
+The `GET /api/v1/merchants` endpoint returns both types: all family merchants plus any provider merchants that are assigned to the family's transactions.
+
+## Merchant object
+
+A merchant response includes:
+
+```json
+{
+ "id": "uuid",
+ "name": "Whole Foods",
+ "type": "FamilyMerchant",
+ "created_at": "2024-01-15T10:30:00Z",
+ "updated_at": "2024-01-15T10:30:00Z"
+}
+```
+
+## Listing merchants
+
+Example request:
+
+```http
+GET /api/v1/merchants
+Authorization: Bearer
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "name": "Amazon",
+ "type": "FamilyMerchant",
+ "created_at": "2024-01-10T08:00:00Z",
+ "updated_at": "2024-01-10T08:00:00Z"
+ },
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440002",
+ "name": "Starbucks",
+ "type": "ProviderMerchant",
+ "created_at": "2024-01-12T14:30:00Z",
+ "updated_at": "2024-01-12T14:30:00Z"
+ }
+]
+```
+
+## Using merchants with transactions
+
+When creating or updating transactions, you can assign a merchant using the `merchant_id` field:
+
+```json
+{
+ "transaction": {
+ "account_id": "uuid",
+ "date": "2024-01-15",
+ "amount": 75.50,
+ "name": "Coffee",
+ "nature": "expense",
+ "merchant_id": "550e8400-e29b-41d4-a716-446655440002"
+ }
+}
+```
+
+## Error responses
+
+Errors conform to the shared `ErrorResponse` schema in the OpenAPI document:
+
+```json
+{
+ "error": "Human readable error message"
+}
+```
+
+Common error codes include `unauthorized`, `not_found`, and `internal_server_error`.
diff --git a/docs/api/tags.md b/docs/api/tags.md
new file mode 100644
index 000000000..ab040c267
--- /dev/null
+++ b/docs/api/tags.md
@@ -0,0 +1,162 @@
+# Tags API
+
+The Tags API allows external applications to manage tags within Sure. Tags provide a flexible way to categorize and label transactions beyond the standard category system.
+
+## Generated OpenAPI specification
+
+- The source of truth for the documentation lives in [`spec/requests/api/v1/tags_spec.rb`](../../spec/requests/api/v1/tags_spec.rb). These specs authenticate against the Rails stack, exercise every tag endpoint, and capture real response shapes.
+- Regenerate the OpenAPI document with:
+
+ ```sh
+ SWAGGER_DRY_RUN=0 bundle exec rspec spec/requests --format Rswag::Specs::SwaggerFormatter
+ ```
+
+ The task compiles the request specs and writes the result to [`docs/api/openapi.yaml`](openapi.yaml).
+
+- Run just the documentation specs with:
+
+ ```sh
+ bundle exec rspec spec/requests/api/v1/tags_spec.rb
+ ```
+
+## Authentication requirements
+
+| Operation | Scope Required |
+| --- | --- |
+| List/View tags | `read` |
+| Create/Update/Delete tags | `write` |
+
+## Available endpoints
+
+| Endpoint | Scope | Description |
+| --- | --- | --- |
+| `GET /api/v1/tags` | `read` | List all tags belonging to the family. |
+| `GET /api/v1/tags/{id}` | `read` | Retrieve a single tag by ID. |
+| `POST /api/v1/tags` | `write` | Create a new tag. |
+| `PATCH /api/v1/tags/{id}` | `write` | Update an existing tag. |
+| `DELETE /api/v1/tags/{id}` | `write` | Permanently delete a tag. |
+
+Refer to the generated [`openapi.yaml`](openapi.yaml) for request/response schemas, reusable components, and security definitions.
+
+## Tag object
+
+A tag response includes:
+
+```json
+{
+ "id": "uuid",
+ "name": "Essential",
+ "color": "#3b82f6",
+ "created_at": "2024-01-15T10:30:00Z",
+ "updated_at": "2024-01-15T10:30:00Z"
+}
+```
+
+## Available colors
+
+Sure provides a predefined set of colors for tags. If no color is specified when creating a tag, one will be randomly assigned from this palette:
+
+```text
+#e99537, #4da568, #6471eb, #db5a54, #df4e92,
+#c44fe9, #eb5429, #61c9ea, #805dee, #6b7c93
+```
+
+## Creating tags
+
+Example request:
+
+```http
+POST /api/v1/tags
+Authorization: Bearer
+Content-Type: application/json
+
+{
+ "tag": {
+ "name": "Business",
+ "color": "#6471eb"
+ }
+}
+```
+
+The `color` field is optional. If omitted, a random color from the predefined palette will be assigned.
+
+Example response (201 Created):
+
+```json
+{
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "name": "Business",
+ "color": "#6471eb",
+ "created_at": "2024-01-15T10:30:00Z",
+ "updated_at": "2024-01-15T10:30:00Z"
+}
+```
+
+## Updating tags
+
+Example request:
+
+```http
+PATCH /api/v1/tags/550e8400-e29b-41d4-a716-446655440001
+Authorization: Bearer
+Content-Type: application/json
+
+{
+ "tag": {
+ "name": "Work Expenses",
+ "color": "#4da568"
+ }
+}
+```
+
+Both `name` and `color` are optional in update requests.
+
+## Deleting tags
+
+Example request:
+
+```http
+DELETE /api/v1/tags/550e8400-e29b-41d4-a716-446655440001
+Authorization: Bearer
+```
+
+Returns `204 No Content` on success.
+
+## Using tags with transactions
+
+Tags can be assigned to transactions using the `tag_ids` array field. A transaction can have multiple tags:
+
+```json
+{
+ "transaction": {
+ "account_id": "uuid",
+ "date": "2024-01-15",
+ "amount": 150.00,
+ "name": "Team lunch",
+ "nature": "expense",
+ "tag_ids": [
+ "550e8400-e29b-41d4-a716-446655440001",
+ "550e8400-e29b-41d4-a716-446655440002"
+ ]
+ }
+}
+```
+
+## Error responses
+
+Errors conform to the shared `ErrorResponse` schema in the OpenAPI document:
+
+```json
+{
+ "error": "Human readable error message"
+}
+```
+
+Common error codes include:
+
+| Status | Error | Description |
+| --- | --- | --- |
+| 401 | `unauthorized` | Invalid or missing access token. |
+| 404 | `not_found` | Tag not found or does not belong to the family. |
+| 422 | `validation_failed` | Invalid input (e.g., duplicate name, missing required field). |
+| 500 | `internal_server_error` | Unexpected server error. |
diff --git a/docs/hosting/oidc.md b/docs/hosting/oidc.md
index 1db92e8ed..3aa676b8a 100644
--- a/docs/hosting/oidc.md
+++ b/docs/hosting/oidc.md
@@ -1,6 +1,6 @@
-# Configuring OpenID Connect and SSO providers
+# Configuring OpenID Connect, SAML, and SSO Providers
-This guide shows how to enable OpenID Connect (OIDC) and other single sign-on (SSO) providers for Sure using Google, GitHub, or another OIDC‑compatible identity provider (e.g. Keycloak, Authentik).
+This guide shows how to enable OpenID Connect (OIDC), SAML 2.0, and other single sign-on (SSO) providers for Sure using Google, GitHub, or another identity provider (e.g. Keycloak, Authentik, Okta, Azure AD).
It also documents the new `config/auth.yml` and environment variables that control:
@@ -174,6 +174,26 @@ To enable Google:
- `http://localhost:3000/auth//callback`
+### 3.5 Bootstrapping the first super‑admin
+
+The first `super_admin` must be set via Rails console. Access the console in your container/pod or directly on the server:
+
+```bash
+bin/rails console
+```
+
+Then promote a user:
+
+```ruby
+# Set super_admin role
+User.find_by(email: "admin@example.com").update!(role: :super_admin)
+
+# Verify
+User.find_by(email: "admin@example.com").role # => "super_admin"
+```
+
+Once set, super‑admins can promote other users via the web UI at `/admin/users`.
+
---
## 4. Example configurations
@@ -250,3 +270,298 @@ With these settings, you can run Sure in:
- Domain‑restricted and link‑only enterprise SSO modes
Use the combination that best fits your self‑hosted environment and security posture.
+
+---
+
+## 5. Multiple OIDC Providers
+
+Sure supports configuring multiple OIDC providers simultaneously, allowing users to choose between different identity providers (e.g., Keycloak, Authentik, Okta) on the login page.
+
+### 5.1 YAML-based multi-provider configuration
+
+To add multiple OIDC providers in `config/auth.yml`, add additional provider entries with unique names:
+
+```yaml
+providers:
+ # First OIDC provider (e.g., Keycloak)
+ - id: "keycloak"
+ strategy: "openid_connect"
+ name: "keycloak"
+ label: "Sign in with Keycloak"
+ icon: "key"
+ issuer: <%= ENV["OIDC_KEYCLOAK_ISSUER"] %>
+ client_id: <%= ENV["OIDC_KEYCLOAK_CLIENT_ID"] %>
+ client_secret: <%= ENV["OIDC_KEYCLOAK_CLIENT_SECRET"] %>
+ redirect_uri: <%= ENV["OIDC_KEYCLOAK_REDIRECT_URI"] %>
+
+ # Second OIDC provider (e.g., Authentik)
+ - id: "authentik"
+ strategy: "openid_connect"
+ name: "authentik"
+ label: "Sign in with Authentik"
+ icon: "shield"
+ issuer: <%= ENV["OIDC_AUTHENTIK_ISSUER"] %>
+ client_id: <%= ENV["OIDC_AUTHENTIK_CLIENT_ID"] %>
+ client_secret: <%= ENV["OIDC_AUTHENTIK_CLIENT_SECRET"] %>
+ redirect_uri: <%= ENV["OIDC_AUTHENTIK_REDIRECT_URI"] %>
+```
+
+Set the corresponding environment variables:
+
+```bash
+# Keycloak provider
+OIDC_KEYCLOAK_ISSUER="https://keycloak.example.com/realms/myrealm"
+OIDC_KEYCLOAK_CLIENT_ID="sure-client"
+OIDC_KEYCLOAK_CLIENT_SECRET="your-keycloak-secret"
+OIDC_KEYCLOAK_REDIRECT_URI="https://yourdomain.com/auth/keycloak/callback"
+
+# Authentik provider
+OIDC_AUTHENTIK_ISSUER="https://authentik.example.com/application/o/sure/"
+OIDC_AUTHENTIK_CLIENT_ID="sure-authentik-client"
+OIDC_AUTHENTIK_CLIENT_SECRET="your-authentik-secret"
+OIDC_AUTHENTIK_REDIRECT_URI="https://yourdomain.com/auth/authentik/callback"
+```
+
+**Important:** Each provider must have a unique `name` field, which determines the callback URL path (`/auth//callback`).
+
+---
+
+## 6. Database-Backed Provider Management
+
+For more dynamic provider management, Sure supports storing SSO provider configurations in the database with a web-based admin interface.
+
+### 6.1 Enabling database providers
+
+Set the feature flag to load providers from the database instead of YAML:
+
+```bash
+AUTH_PROVIDERS_SOURCE=db
+```
+
+When enabled:
+- Providers are loaded from the `sso_providers` database table
+- Changes take effect immediately (no server restart required)
+- Providers can be managed through the admin UI at `/admin/sso_providers`
+
+When disabled (default):
+- Providers are loaded from `config/auth.yml`
+- Changes require a server restart
+
+### 6.2 Admin UI for SSO providers
+
+Super-admin users can manage SSO providers through the web interface:
+
+1. Navigate to `/admin/sso_providers`
+2. View all configured providers (enabled/disabled status)
+3. Add new providers with the "Add Provider" button
+4. Edit existing providers (credentials, labels, icons)
+5. Enable/disable providers with the toggle button
+6. Delete providers (with confirmation)
+
+**Security notes:**
+- Only users with `super_admin` role can access the admin interface
+- All provider changes are logged with user ID and timestamp
+- Client secrets are encrypted in the database using Rails 7.2 encryption
+- Admin endpoints are rate-limited (10 requests/minute per IP)
+
+### 6.3 Seeding providers from YAML to database
+
+To migrate your existing YAML configuration to the database:
+
+```bash
+# Dry run (preview changes without saving)
+DRY_RUN=true rails sso_providers:seed
+
+# Apply changes
+rails sso_providers:seed
+```
+
+The seeding task:
+- Reads providers from `config/auth.yml`
+- Creates or updates database records (idempotent)
+- Preserves existing client secrets if not provided in YAML
+- Provides detailed output (created/updated/skipped/errors)
+
+To list all providers in the database:
+
+```bash
+rails sso_providers:list
+```
+
+### 6.4 Migration workflow
+
+Recommended steps to migrate from YAML to database-backed providers:
+
+1. **Backup your configuration:**
+ ```bash
+ cp config/auth.yml config/auth.yml.backup
+ ```
+
+2. **Run migrations:**
+ ```bash
+ rails db:migrate
+ ```
+
+3. **Seed providers from YAML (dry run first):**
+ ```bash
+ DRY_RUN=true rails sso_providers:seed
+ ```
+
+4. **Review the output, then apply:**
+ ```bash
+ rails sso_providers:seed
+ ```
+
+5. **Enable database provider source:**
+ ```bash
+ # Add to .env or environment
+ AUTH_PROVIDERS_SOURCE=db
+ ```
+
+6. **Restart the application:**
+ ```bash
+ # Docker Compose
+ docker-compose restart app
+
+ # Or your process manager
+ systemctl restart sure
+ ```
+
+7. **Verify providers are loaded:**
+ - Check logs for `[ProviderLoader] Loaded N provider(s) from database`
+ - Visit `/admin/sso_providers` to manage providers
+
+### 6.5 Rollback to YAML
+
+To switch back to YAML-based configuration:
+
+1. Remove or set `AUTH_PROVIDERS_SOURCE=yaml`
+2. Restart the application
+3. Providers will be loaded from `config/auth.yml`
+
+### 6.6 JIT provisioning settings
+
+Each provider has a **Default Role** field (defaults to `member`) that sets the role for JIT-created users.
+
+**Role mapping from IdP groups:**
+
+Expand **"Role Mapping"** in the admin UI to map IdP group names to Sure roles. Enter comma-separated group names for each role:
+
+- **Super Admin Groups**: `Platform-Admins, IdP-Superusers`
+- **Admin Groups**: `Team-Leads, Managers`
+- **Member Groups**: `Everyone` or leave blank
+
+Mapping is case-sensitive and matches exact group claim values from the IdP. When a user belongs to multiple mapped groups, the highest role wins (`super_admin` > `admin` > `member`). If no groups match, the Default Role is used.
+
+---
+
+## 7. Troubleshooting
+
+### Provider not appearing on login page
+
+- **YAML mode:** Check that required environment variables are set (e.g., `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`)
+- **DB mode:** Verify provider is enabled in `/admin/sso_providers`
+- Check application logs for provider loading messages
+- Verify `AUTH_PROVIDERS_SOURCE` is set correctly
+
+### Discovery endpoint validation fails
+
+When adding an OIDC provider, Sure validates the `.well-known/openid-configuration` endpoint:
+
+- Ensure the issuer URL is correct and accessible
+- Check firewall rules allow outbound HTTPS to the issuer
+- Verify the issuer returns valid JSON with an `issuer` field
+- For self-signed certificates, you may need to configure SSL verification
+
+### Rate limiting errors (429)
+
+Admin endpoints are rate-limited to 10 requests per minute per IP:
+
+- Wait 60 seconds before retrying
+- If legitimate traffic is being blocked, adjust limits in `config/initializers/rack_attack.rb`
+
+### Callback URL mismatch
+
+Each provider requires a callback URL configured in your identity provider:
+
+- **Format:** `https://yourdomain.com/auth//callback`
+- **Example:** For a provider with `name: "keycloak"`, use `https://yourdomain.com/auth/keycloak/callback`
+- The callback URL is shown in the admin UI when editing a provider (with copy button)
+
+---
+
+## 8. Security Considerations
+
+### Encryption
+
+- Client secrets are encrypted at rest using Rails 7.2 ActiveRecord Encryption
+- Encryption keys are derived from `SECRET_KEY_BASE` by default
+- For additional security, set custom encryption keys (see `.env` for `ACTIVE_RECORD_ENCRYPTION_*` variables)
+
+### Issuer validation
+
+- OIDC identities store the issuer claim from the ID token
+- On subsequent logins, Sure verifies the issuer matches the configured provider
+- This prevents issuer impersonation attacks
+
+### Admin access
+
+- SSO provider management requires `super_admin` role
+- Regular `admin` users (family admins) cannot access `/admin/sso_providers`
+- All provider changes are logged with user ID
+
+### Rate limiting
+
+- Admin endpoints: 10 requests/minute per IP
+- OAuth token endpoint: 10 requests/minute per IP
+- Failed login attempts should be monitored separately
+
+---
+
+## 9. SAML 2.0 Support
+
+Sure supports SAML 2.0 via database-backed providers. Select **"SAML 2.0"** as the strategy when adding a provider at `/admin/sso_providers`.
+
+Configure with either:
+- **IdP Metadata URL** (recommended) - auto-fetches configuration
+- **Manual config** - IdP SSO URL + certificate
+
+In your IdP, set:
+- **ACS URL**: `https://yourdomain.com/auth//callback`
+- **Entity ID**: `https://yourdomain.com` (your `APP_URL`)
+- **Name ID**: Email Address
+
+---
+
+## 10. User Administration
+
+Super‑admins can manage user roles at `/admin/users`.
+
+Roles: `member` (standard), `admin` (family admin), `super_admin` (platform admin).
+
+Note: Super‑admins cannot change their own role.
+
+---
+
+## 11. Audit Logging
+
+SSO events are logged to `sso_audit_logs`: `login`, `login_failed`, `logout`, `logout_idp` (federated logout), `link`, `unlink`, `jit_account_created`.
+
+Query via console:
+
+```ruby
+SsoAuditLog.by_event("login").recent.limit(50)
+SsoAuditLog.by_event("login_failed").where("created_at > ?", 24.hours.ago)
+```
+
+---
+
+## 12. User SSO Identity Management
+
+Users manage linked SSO identities at **Settings > Security**.
+
+SSO-only users (no password) cannot unlink their last identity.
+
+---
+
+For additional help, see the main [hosting documentation](../README.md) or open an issue on GitHub.
diff --git a/lib/tasks/simplefin_pending_cleanup.rake b/lib/tasks/simplefin_pending_cleanup.rake
new file mode 100644
index 000000000..147adda8b
--- /dev/null
+++ b/lib/tasks/simplefin_pending_cleanup.rake
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+namespace :simplefin do
+ desc "Find and optionally remove duplicate pending transactions that have matching posted versions"
+ task pending_cleanup: :environment do
+ dry_run = ENV["DRY_RUN"] != "false"
+ date_window = (ENV["DATE_WINDOW"] || 8).to_i
+
+ puts "SimpleFIN Pending Transaction Cleanup"
+ puts "======================================"
+ puts "Mode: #{dry_run ? 'DRY RUN (no changes)' : 'LIVE (will delete duplicates)'}"
+ puts "Date window: #{date_window} days"
+ puts ""
+
+ # Find all pending SimpleFIN transactions
+ pending_entries = Entry.joins(
+ "INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'"
+ ).where(source: "simplefin")
+ .where("transactions.extra -> 'simplefin' ->> 'pending' = ?", "true")
+ .includes(:account)
+
+ puts "Found #{pending_entries.count} pending SimpleFIN transactions"
+ puts ""
+
+ duplicates_found = 0
+ duplicates_to_delete = []
+
+ pending_entries.find_each do |pending_entry|
+ # Look for a matching posted transaction
+ posted_match = Entry.joins(
+ "INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'"
+ ).where(account_id: pending_entry.account_id)
+ .where(source: "simplefin")
+ .where(amount: pending_entry.amount)
+ .where(currency: pending_entry.currency)
+ .where(date: pending_entry.date..(pending_entry.date + date_window.days)) # Posted must be ON or AFTER pending
+ .where.not(id: pending_entry.id)
+ .where("transactions.extra -> 'simplefin' ->> 'pending' != ? OR transactions.extra -> 'simplefin' ->> 'pending' IS NULL", "true")
+ .first
+
+ if posted_match
+ duplicates_found += 1
+ duplicates_to_delete << pending_entry
+
+ puts "DUPLICATE FOUND:"
+ puts " Pending: ID=#{pending_entry.id} | #{pending_entry.date} | #{pending_entry.name} | #{pending_entry.amount} #{pending_entry.currency}"
+ puts " Posted: ID=#{posted_match.id} | #{posted_match.date} | #{posted_match.name} | #{posted_match.amount} #{posted_match.currency}"
+ puts " Account: #{pending_entry.account.name}"
+ puts ""
+ end
+ end
+
+ puts "======================================"
+ puts "Summary: #{duplicates_found} duplicate pending transactions found"
+ puts ""
+
+ if duplicates_found > 0
+ if dry_run
+ puts "To delete these duplicates, run:"
+ puts " rails simplefin:pending_cleanup DRY_RUN=false"
+ puts ""
+ puts "To adjust the date matching window (default 8 days):"
+ puts " rails simplefin:pending_cleanup DATE_WINDOW=14"
+ else
+ print "Deleting #{duplicates_to_delete.count} duplicate pending entries... "
+ Entry.where(id: duplicates_to_delete.map(&:id)).destroy_all
+ puts "Done!"
+ end
+ else
+ puts "No duplicates found. Nothing to clean up."
+ end
+ end
+
+ desc "Un-exclude pending transactions that were wrongly matched (fixes direction bug)"
+ task pending_restore: :environment do
+ dry_run = ENV["DRY_RUN"] != "false"
+ date_window = (ENV["DATE_WINDOW"] || 8).to_i
+
+ puts "Restore Wrongly Excluded Pending Transactions"
+ puts "=============================================="
+ puts "Mode: #{dry_run ? 'DRY RUN (no changes)' : 'LIVE (will restore)'}"
+ puts "Date window: #{date_window} days (forward only)"
+ puts ""
+
+ # Find all EXCLUDED pending transactions (these may have been wrongly excluded)
+ excluded_pending = Entry.joins(
+ "INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'"
+ ).where(excluded: true)
+ .where(<<~SQL.squish)
+ (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
+ OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
+ SQL
+
+ puts "Found #{excluded_pending.count} excluded pending transactions to evaluate"
+ puts ""
+
+ to_restore = []
+
+ excluded_pending.includes(:account).find_each do |pending_entry|
+ # Check if there's a VALID posted match using CORRECT logic (forward-only dates)
+ # Posted date must be ON or AFTER pending date
+ valid_match = pending_entry.account.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))
+ .where(<<~SQL.squish)
+ (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE
+ AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE
+ SQL
+ .exists?
+
+ unless valid_match
+ to_restore << pending_entry
+ puts "SHOULD RESTORE (no valid match):"
+ puts " ID=#{pending_entry.id} | #{pending_entry.date} | #{pending_entry.name} | #{pending_entry.amount} #{pending_entry.currency}"
+ puts " Account: #{pending_entry.account.name}"
+ puts ""
+ end
+ end
+
+ puts "=============================================="
+ puts "Summary: #{to_restore.count} transactions should be restored"
+ puts ""
+
+ if to_restore.any?
+ if dry_run
+ puts "To restore these transactions, run:"
+ puts " rails simplefin:pending_restore DRY_RUN=false"
+ else
+ Entry.where(id: to_restore.map(&:id)).update_all(excluded: false)
+ puts "Restored #{to_restore.count} transactions!"
+ end
+ else
+ puts "No wrongly excluded transactions found."
+ end
+ end
+
+ desc "List all pending SimpleFIN transactions (for review)"
+ task pending_list: :environment do
+ pending_entries = Entry.joins(
+ "INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'"
+ ).where(source: "simplefin")
+ .where("transactions.extra -> 'simplefin' ->> 'pending' = ?", "true")
+ .includes(:account)
+ .order(date: :desc)
+
+ puts "All Pending SimpleFIN Transactions"
+ puts "==================================="
+ puts "Total: #{pending_entries.count}"
+ puts ""
+
+ pending_entries.find_each do |entry|
+ puts "ID=#{entry.id} | #{entry.date} | #{entry.name.truncate(40)} | #{entry.amount} #{entry.currency} | Account: #{entry.account.name}"
+ end
+ end
+end
diff --git a/lib/tasks/sso_providers.rake b/lib/tasks/sso_providers.rake
new file mode 100644
index 000000000..9b5762aac
--- /dev/null
+++ b/lib/tasks/sso_providers.rake
@@ -0,0 +1,154 @@
+# frozen_string_literal: true
+
+namespace :sso_providers do
+ desc "Seed SSO providers from config/auth.yml into the database"
+ task seed: :environment do
+ dry_run = ENV["DRY_RUN"] == "true"
+
+ puts "=" * 80
+ puts "SSO Provider Seeding Task"
+ puts "=" * 80
+ puts "Mode: #{dry_run ? 'DRY RUN (no changes will be saved)' : 'LIVE (changes will be saved)'}"
+ puts "Source: config/auth.yml"
+ puts "-" * 80
+
+ begin
+ # Load auth.yml safely
+ auth_config_path = Rails.root.join("config", "auth.yml")
+ unless File.exist?(auth_config_path)
+ puts "ERROR: config/auth.yml not found"
+ exit 1
+ end
+
+ # Use safe_load to prevent code injection
+ auth_config = YAML.safe_load(
+ ERB.new(File.read(auth_config_path)).result,
+ permitted_classes: [ Symbol ],
+ aliases: true
+ )
+
+ # Get providers for current environment
+ env_config = auth_config[Rails.env] || auth_config["default"]
+ providers = env_config&.dig("providers") || []
+
+ if providers.empty?
+ puts "WARNING: No providers found in config/auth.yml for #{Rails.env} environment"
+ exit 0
+ end
+
+ puts "Found #{providers.count} provider(s) in config/auth.yml"
+ puts "-" * 80
+
+ created_count = 0
+ updated_count = 0
+ skipped_count = 0
+ errors = []
+
+ ActiveRecord::Base.transaction do
+ providers.each do |provider_config|
+ provider_config = provider_config.deep_symbolize_keys
+
+ # Extract provider attributes
+ name = provider_config[:name] || provider_config[:id]
+ strategy = provider_config[:strategy]
+
+ unless name.present? && strategy.present?
+ puts "SKIP: Provider missing name or strategy: #{provider_config.inspect}"
+ skipped_count += 1
+ next
+ end
+
+ # Find or initialize provider
+ provider = SsoProvider.find_or_initialize_by(name: name)
+ is_new = provider.new_record?
+
+ # Build attributes hash
+ attributes = {
+ strategy: strategy,
+ label: provider_config[:label] || name.titleize,
+ icon: provider_config[:icon],
+ enabled: provider_config.key?(:enabled) ? provider_config[:enabled] : true,
+ issuer: provider_config[:issuer],
+ client_id: provider_config[:client_id],
+ redirect_uri: provider_config[:redirect_uri],
+ settings: provider_config[:settings] || {}
+ }
+
+ # Only set client_secret if provided (don't overwrite existing)
+ if provider_config[:client_secret].present?
+ attributes[:client_secret] = provider_config[:client_secret]
+ end
+
+ # Assign attributes
+ provider.assign_attributes(attributes.compact)
+
+ # Check if changed
+ if provider.changed?
+ if dry_run
+ puts "#{is_new ? 'CREATE' : 'UPDATE'} (dry-run): #{name} (#{strategy})"
+ puts " Changes: #{provider.changes.keys.join(', ')}"
+ else
+ if provider.save
+ puts "#{is_new ? 'CREATE' : 'UPDATE'}: #{name} (#{strategy})"
+ is_new ? created_count += 1 : updated_count += 1
+ else
+ error_msg = "Failed to save #{name}: #{provider.errors.full_messages.join(', ')}"
+ puts "ERROR: #{error_msg}"
+ errors << error_msg
+ end
+ end
+ else
+ puts "SKIP: #{name} (no changes)"
+ skipped_count += 1
+ end
+ end
+
+ # Rollback transaction if dry run
+ raise ActiveRecord::Rollback if dry_run
+ end
+
+ puts "-" * 80
+ puts "Summary:"
+ puts " Created: #{created_count}"
+ puts " Updated: #{updated_count}"
+ puts " Skipped: #{skipped_count}"
+ puts " Errors: #{errors.count}"
+
+ if errors.any?
+ puts "\nErrors encountered:"
+ errors.each { |error| puts " - #{error}" }
+ end
+
+ if dry_run
+ puts "\nDRY RUN: No changes were saved to the database"
+ puts "Run without DRY_RUN=true to apply changes"
+ else
+ puts "\nSeeding completed successfully!"
+ puts "Note: Clear provider cache or restart server for changes to take effect"
+ end
+
+ puts "=" * 80
+
+ rescue => e
+ puts "ERROR: #{e.class}: #{e.message}"
+ puts e.backtrace.first(5).join("\n")
+ exit 1
+ end
+ end
+
+ desc "List all SSO providers in the database"
+ task list: :environment do
+ providers = SsoProvider.order(:name)
+
+ if providers.empty?
+ puts "No SSO providers found in database"
+ else
+ puts "SSO Providers (#{providers.count}):"
+ puts "-" * 80
+ providers.each do |provider|
+ status = provider.enabled? ? "✓ enabled" : "✗ disabled"
+ puts "#{provider.name.ljust(20)} | #{provider.strategy.ljust(20)} | #{status}"
+ end
+ end
+ end
+end
diff --git a/mobile/docs/OFFLINE_FEATURES.md b/mobile/docs/OFFLINE_FEATURES.md
new file mode 100644
index 000000000..2ab80a79c
--- /dev/null
+++ b/mobile/docs/OFFLINE_FEATURES.md
@@ -0,0 +1,354 @@
+# Offline Features Documentation
+
+## Overview
+
+The Sure mobile app implements a comprehensive offline-first architecture that allows users to continue using the app even when they don't have an internet connection. All transactions created offline are automatically synced to the server when the connection is restored.
+
+## Key Features
+
+### 1. Offline Data Storage
+
+- **Local SQLite Database**: All transaction and account data is stored locally using SQLite
+- **Automatic Caching**: Server data is automatically cached for offline access
+- **Persistent Storage**: Data persists across app restarts
+
+### 2. Offline Transaction Management
+
+- **Create Transactions Offline**: Users can create new transactions even without internet
+- **View Cached Data**: Access previously synced transactions and accounts offline
+- **Pending Sync Indicator**: Transactions created offline are marked as "pending" until synced
+
+### 3. Automatic Synchronization
+
+- **Network Detection**: App automatically detects when network connectivity is restored
+- **Background Sync**: Pending transactions are automatically uploaded when online
+- **Server Data Download**: Latest server data is downloaded and cached locally
+- **Conflict Resolution**: Server data takes precedence during sync
+
+### 4. Visual Indicators
+
+- **Connectivity Banner**: Shows current connection status and pending transaction count
+- **Sync Status Badges**: Individual transactions show their sync status (pending/failed/synced)
+- **Manual Sync Button**: Users can trigger sync manually when online
+
+## Architecture
+
+### Data Flow
+
+```
+┌─────────────────┐
+│ User Interface │
+└────────┬────────┘
+ │
+ ▼
+┌─────────────────────────┐
+│ TransactionsProvider │
+│ (State Management) │
+└────────┬───────┬────────┘
+ │ │
+ │ ▼
+ │ ┌──────────────────────┐
+ │ │ ConnectivityService │
+ │ │ (Network Detection) │
+ │ └──────────────────────┘
+ │
+ ▼
+┌─────────────────────────┐
+│ OfflineStorageService │
+│ (Local SQLite DB) │
+└─────────────────────────┘
+ ▲
+ │
+ ▼
+┌─────────────────────────┐
+│ SyncService │
+│ (Server Sync Logic) │
+└────────┬────────────────┘
+ │
+ ▼
+┌─────────────────────────┐
+│ TransactionsService │
+│ (HTTP API Client) │
+└─────────────────────────┘
+```
+
+### Database Schema
+
+#### Transactions Table
+```sql
+CREATE TABLE transactions (
+ local_id TEXT PRIMARY KEY, -- UUID generated locally
+ server_id TEXT, -- Server ID after sync
+ account_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ date TEXT NOT NULL,
+ amount TEXT NOT NULL,
+ currency TEXT NOT NULL,
+ nature TEXT NOT NULL,
+ notes TEXT,
+ sync_status TEXT NOT NULL, -- 'synced', 'pending', 'failed'
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+)
+```
+
+#### Accounts Table (Cache)
+```sql
+CREATE TABLE accounts (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ balance TEXT NOT NULL,
+ currency TEXT NOT NULL,
+ classification TEXT,
+ account_type TEXT NOT NULL,
+ synced_at TEXT NOT NULL
+)
+```
+
+## Components
+
+### Services
+
+#### 1. ConnectivityService (`lib/services/connectivity_service.dart`)
+- Monitors network connectivity status
+- Provides real-time connectivity updates
+- Uses `connectivity_plus` package
+
+#### 2. DatabaseHelper (`lib/services/database_helper.dart`)
+- Manages SQLite database operations
+- Handles table creation and migrations
+- Provides CRUD operations for local data
+
+#### 3. OfflineStorageService (`lib/services/offline_storage_service.dart`)
+- High-level API for offline data management
+- Converts between app models and database records
+- Manages transaction sync status
+
+#### 4. SyncService (`lib/services/sync_service.dart`)
+- Coordinates data synchronization with server
+- Uploads pending transactions
+- Downloads and caches server data
+- Handles sync errors and retries
+
+### Models
+
+#### OfflineTransaction (`lib/models/offline_transaction.dart`)
+```dart
+class OfflineTransaction extends Transaction {
+ final String localId; // Local UUID
+ final SyncStatus syncStatus; // Sync state
+ final DateTime createdAt; // Local creation time
+ final DateTime updatedAt; // Last update time
+}
+
+enum SyncStatus {
+ synced, // Successfully synced with server
+ pending, // Waiting to be synced
+ failed, // Last sync attempt failed
+}
+```
+
+### UI Components
+
+#### 1. ConnectivityBanner (`lib/widgets/connectivity_banner.dart`)
+- Displays at top of screen when offline or has pending transactions
+- Shows "Sync Now" button when online with pending items
+- Auto-hides when online and all synced
+
+#### 2. SyncStatusBadge (`lib/widgets/sync_status_badge.dart`)
+- Shows sync status for individual transactions
+- Compact mode for list items
+- Full mode for transaction details
+
+## Usage Examples
+
+### Creating a Transaction Offline
+
+```dart
+final transactionsProvider = Provider.of(context, listen: false);
+final authProvider = Provider.of(context, listen: false);
+
+await transactionsProvider.createTransaction(
+ accessToken: authProvider.tokens!.accessToken,
+ accountId: account.id,
+ name: 'Coffee',
+ date: '2024-01-15',
+ amount: '5.50',
+ currency: 'USD',
+ nature: 'expense',
+ notes: 'Morning coffee',
+);
+
+// Transaction is saved locally with status 'pending'
+// Will auto-sync when connection is restored
+```
+
+### Manual Sync
+
+```dart
+final transactionsProvider = Provider.of(context, listen: false);
+final authProvider = Provider.of(context, listen: false);
+
+await transactionsProvider.syncTransactions(
+ accessToken: authProvider.tokens!.accessToken,
+);
+```
+
+### Checking Connectivity Status
+
+```dart
+final connectivityService = Provider.of(context);
+
+if (connectivityService.isOnline) {
+ // App is online
+} else {
+ // App is offline
+}
+```
+
+### Checking Pending Transactions
+
+```dart
+final transactionsProvider = Provider.of(context);
+
+if (transactionsProvider.hasPendingTransactions) {
+ print('Pending count: ${transactionsProvider.pendingCount}');
+}
+```
+
+## Sync Behavior
+
+### When Creating a Transaction
+
+1. **Online**:
+ - Attempts to create on server immediately
+ - On success: Saves to local DB with status 'synced'
+ - On failure: Saves to local DB with status 'pending'
+
+2. **Offline**:
+ - Saves to local DB with status 'pending'
+ - Shows success to user (transaction is saved locally)
+ - Will sync automatically when connection restored
+
+### When Loading Transactions
+
+1. **Always** loads from local SQLite first (instant display)
+2. **If online** and local is empty, syncs from server
+3. **If force refresh**, syncs from server and updates local cache
+
+### Automatic Sync
+
+The app automatically syncs in these scenarios:
+- App starts with internet connection
+- Network connection is restored after being offline
+- User manually triggers sync via "Sync Now" button
+- User pulls to refresh on dashboard or transaction list
+
+### Sync Process
+
+1. **Upload Phase**:
+ - Gets all pending transactions from local DB
+ - Uploads each to server sequentially
+ - Updates local records with server IDs on success
+ - Marks as 'failed' if upload fails
+
+2. **Download Phase**:
+ - Fetches all transactions from server
+ - Updates local cache with server data
+ - Server data takes precedence over local changes
+
+3. **Account Sync**:
+ - Updates local account cache with latest balances
+ - Ensures account dropdown has current data
+
+## Error Handling
+
+### Network Errors
+- Transactions remain marked as 'pending'
+- User can retry sync manually
+- Visual indicator shows sync failure
+
+### Sync Conflicts
+- Server data always takes precedence
+- Local pending transactions are uploaded first
+- Then server data is downloaded and cached
+
+### Database Errors
+- Errors are logged and reported to user
+- App continues to function with potentially stale data
+- User can force refresh to retry
+
+## Testing Offline Functionality
+
+### Simulating Offline Mode
+
+1. **Android Emulator**:
+ - Swipe down notification panel
+ - Toggle Airplane Mode
+
+2. **iOS Simulator**:
+ - Settings → Airplane Mode → ON
+
+3. **Physical Device**:
+ - Enable Airplane Mode in device settings
+
+### Test Scenarios
+
+1. **Create Transaction Offline**:
+ - Turn on airplane mode
+ - Create a new transaction
+ - Verify it appears in the list with "pending" badge
+ - Turn off airplane mode
+ - Verify automatic sync occurs
+
+2. **View Cached Data**:
+ - Use app while online
+ - Turn on airplane mode
+ - Verify all previously viewed data is still accessible
+
+3. **Manual Sync**:
+ - Create transactions offline
+ - Turn off airplane mode
+ - Tap "Sync Now" button
+ - Verify transactions sync successfully
+
+## Performance Considerations
+
+- **Database Size**: SQLite can handle millions of records efficiently
+- **Sync Batching**: Pending transactions are uploaded sequentially to avoid overwhelming the server
+- **Cache Invalidation**: Account cache is refreshed on each sync to ensure accurate balances
+- **Memory Usage**: Only active transactions are kept in memory; database queries are paginated
+
+## Future Enhancements
+
+Potential improvements for future versions:
+
+1. **Conflict Resolution UI**: Allow users to choose which version to keep when conflicts occur
+2. **Selective Sync**: Sync only specific accounts or date ranges
+3. **Background Sync**: Use platform background tasks for periodic syncing
+4. **Offline Editing**: Support editing transactions offline
+5. **Offline Deletion**: Support deleting transactions offline with sync
+6. **Export Offline Data**: Export local database for backup
+7. **Data Compression**: Compress large sync payloads for better performance
+
+## Troubleshooting
+
+### Transactions Not Syncing
+
+1. Check internet connection
+2. Verify you're logged in (tokens are valid)
+3. Check sync status in app (ConnectivityBanner)
+4. Try manual sync via "Sync Now" button
+5. Check server logs for API errors
+
+### Database Issues
+
+1. Clear app data (will lose offline transactions)
+2. Reinstall app
+3. Contact support if issue persists
+
+### Performance Issues
+
+1. Check device storage (database needs space)
+2. Consider clearing old synced transactions
+3. Reduce number of accounts if possible
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 36b71a378..d9ef30c70 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -3,14 +3,21 @@ import 'package:provider/provider.dart';
import 'providers/auth_provider.dart';
import 'providers/accounts_provider.dart';
import 'providers/transactions_provider.dart';
+import 'providers/chat_provider.dart';
import 'screens/backend_config_screen.dart';
import 'screens/login_screen.dart';
-import 'screens/dashboard_screen.dart';
+import 'screens/main_navigation_screen.dart';
import 'services/api_config.dart';
+import 'services/connectivity_service.dart';
+import 'services/log_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await ApiConfig.initialize();
+
+ // Add initial log entry
+ LogService.instance.info('App', 'Sure Finance app starting...');
+
runApp(const SureApp());
}
@@ -21,9 +28,36 @@ class SureApp extends StatelessWidget {
Widget build(BuildContext context) {
return MultiProvider(
providers: [
+ ChangeNotifierProvider(create: (_) => LogService.instance),
+ ChangeNotifierProvider(create: (_) => ConnectivityService()),
ChangeNotifierProvider(create: (_) => AuthProvider()),
- ChangeNotifierProvider(create: (_) => AccountsProvider()),
- ChangeNotifierProvider(create: (_) => TransactionsProvider()),
+ ChangeNotifierProvider(create: (_) => ChatProvider()),
+ ChangeNotifierProxyProvider(
+ create: (_) => AccountsProvider(),
+ update: (_, connectivityService, accountsProvider) {
+ if (accountsProvider == null) {
+ final provider = AccountsProvider();
+ provider.setConnectivityService(connectivityService);
+ return provider;
+ } else {
+ accountsProvider.setConnectivityService(connectivityService);
+ return accountsProvider;
+ }
+ },
+ ),
+ ChangeNotifierProxyProvider(
+ create: (_) => TransactionsProvider(),
+ update: (_, connectivityService, transactionsProvider) {
+ if (transactionsProvider == null) {
+ final provider = TransactionsProvider();
+ provider.setConnectivityService(connectivityService);
+ return provider;
+ } else {
+ transactionsProvider.setConnectivityService(connectivityService);
+ return transactionsProvider;
+ }
+ },
+ ),
],
child: MaterialApp(
title: 'Sure Finance',
@@ -94,7 +128,7 @@ class SureApp extends StatelessWidget {
routes: {
'/config': (context) => const BackendConfigScreen(),
'/login': (context) => const LoginScreen(),
- '/dashboard': (context) => const DashboardScreen(),
+ '/home': (context) => const MainNavigationScreen(),
},
home: const AppWrapper(),
),
@@ -169,7 +203,7 @@ class _AppWrapperState extends State {
}
if (authProvider.isAuthenticated) {
- return const DashboardScreen();
+ return const MainNavigationScreen();
}
return LoginScreen(
diff --git a/mobile/lib/models/chat.dart b/mobile/lib/models/chat.dart
new file mode 100644
index 000000000..480398f94
--- /dev/null
+++ b/mobile/lib/models/chat.dart
@@ -0,0 +1,77 @@
+import 'message.dart';
+
+class Chat {
+ final String id;
+ final String title;
+ final String? error;
+ final DateTime createdAt;
+ final DateTime updatedAt;
+ final List messages;
+ final int? messageCount;
+ final DateTime? lastMessageAt;
+
+ Chat({
+ required this.id,
+ required this.title,
+ this.error,
+ required this.createdAt,
+ required this.updatedAt,
+ this.messages = const [],
+ this.messageCount,
+ this.lastMessageAt,
+ });
+
+ factory Chat.fromJson(Map json) {
+ return Chat(
+ id: json['id'].toString(),
+ title: json['title'] as String,
+ error: json['error'] as String?,
+ createdAt: DateTime.parse(json['created_at'] as String),
+ updatedAt: DateTime.parse(json['updated_at'] as String),
+ messages: json['messages'] != null
+ ? (json['messages'] as List)
+ .map((m) => Message.fromJson(m as Map))
+ .toList()
+ : [],
+ messageCount: json['message_count'] as int?,
+ lastMessageAt: json['last_message_at'] != null
+ ? DateTime.parse(json['last_message_at'] as String)
+ : null,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'id': id,
+ 'title': title,
+ 'error': error,
+ 'created_at': createdAt.toIso8601String(),
+ 'updated_at': updatedAt.toIso8601String(),
+ 'messages': messages.map((m) => m.toJson()).toList(),
+ 'message_count': messageCount,
+ 'last_message_at': lastMessageAt?.toIso8601String(),
+ };
+ }
+
+ Chat copyWith({
+ String? id,
+ String? title,
+ String? error,
+ DateTime? createdAt,
+ DateTime? updatedAt,
+ List? messages,
+ int? messageCount,
+ DateTime? lastMessageAt,
+ }) {
+ return Chat(
+ id: id ?? this.id,
+ title: title ?? this.title,
+ error: error ?? this.error,
+ createdAt: createdAt ?? this.createdAt,
+ updatedAt: updatedAt ?? this.updatedAt,
+ messages: messages ?? this.messages,
+ messageCount: messageCount ?? this.messageCount,
+ lastMessageAt: lastMessageAt ?? this.lastMessageAt,
+ );
+ }
+}
diff --git a/mobile/lib/models/message.dart b/mobile/lib/models/message.dart
new file mode 100644
index 000000000..d3b71949e
--- /dev/null
+++ b/mobile/lib/models/message.dart
@@ -0,0 +1,56 @@
+import 'tool_call.dart';
+
+class Message {
+ final String id;
+ final String type;
+ final String role;
+ final String content;
+ final String? model;
+ final DateTime createdAt;
+ final DateTime updatedAt;
+ final List? toolCalls;
+
+ Message({
+ required this.id,
+ required this.type,
+ required this.role,
+ required this.content,
+ this.model,
+ required this.createdAt,
+ required this.updatedAt,
+ this.toolCalls,
+ });
+
+ factory Message.fromJson(Map json) {
+ return Message(
+ id: json['id'].toString(),
+ type: json['type'] as String,
+ role: json['role'] as String,
+ content: json['content'] as String,
+ model: json['model'] as String?,
+ createdAt: DateTime.parse(json['created_at'] as String),
+ updatedAt: DateTime.parse(json['updated_at'] as String),
+ toolCalls: json['tool_calls'] != null
+ ? (json['tool_calls'] as List)
+ .map((tc) => ToolCall.fromJson(tc as Map))
+ .toList()
+ : null,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'id': id,
+ 'type': type,
+ 'role': role,
+ 'content': content,
+ 'model': model,
+ 'created_at': createdAt.toIso8601String(),
+ 'updated_at': updatedAt.toIso8601String(),
+ 'tool_calls': toolCalls?.map((tc) => tc.toJson()).toList(),
+ };
+ }
+
+ bool get isUser => role == 'user';
+ bool get isAssistant => role == 'assistant';
+}
diff --git a/mobile/lib/models/offline_transaction.dart b/mobile/lib/models/offline_transaction.dart
new file mode 100644
index 000000000..4259f40a3
--- /dev/null
+++ b/mobile/lib/models/offline_transaction.dart
@@ -0,0 +1,159 @@
+import 'transaction.dart';
+
+enum SyncStatus {
+ synced, // Transaction is synced with server
+ pending, // Transaction is waiting to be synced (create)
+ failed, // Last sync attempt failed
+ pendingDelete, // Transaction is waiting to be deleted on server
+}
+
+class OfflineTransaction extends Transaction {
+ final String localId;
+ final SyncStatus syncStatus;
+ final DateTime createdAt;
+ final DateTime updatedAt;
+
+ OfflineTransaction({
+ super.id,
+ required this.localId,
+ required super.accountId,
+ required super.name,
+ required super.date,
+ required super.amount,
+ required super.currency,
+ required super.nature,
+ super.notes,
+ this.syncStatus = SyncStatus.pending,
+ DateTime? createdAt,
+ DateTime? updatedAt,
+ }) : createdAt = createdAt ?? DateTime.now(),
+ updatedAt = updatedAt ?? DateTime.now();
+
+ factory OfflineTransaction.fromTransaction(
+ Transaction transaction, {
+ required String localId,
+ SyncStatus syncStatus = SyncStatus.synced,
+ }) {
+ return OfflineTransaction(
+ id: transaction.id,
+ localId: localId,
+ accountId: transaction.accountId,
+ name: transaction.name,
+ date: transaction.date,
+ amount: transaction.amount,
+ currency: transaction.currency,
+ nature: transaction.nature,
+ notes: transaction.notes,
+ syncStatus: syncStatus,
+ );
+ }
+
+ factory OfflineTransaction.fromDatabaseMap(Map map) {
+ return OfflineTransaction(
+ id: map['server_id'] as String?,
+ localId: map['local_id'] as String,
+ accountId: map['account_id'] as String,
+ name: map['name'] as String,
+ date: map['date'] as String,
+ amount: map['amount'] as String,
+ currency: map['currency'] as String,
+ nature: map['nature'] as String,
+ notes: map['notes'] as String?,
+ syncStatus: _parseSyncStatus(map['sync_status'] as String),
+ createdAt: DateTime.parse(map['created_at'] as String),
+ updatedAt: DateTime.parse(map['updated_at'] as String),
+ );
+ }
+
+ Map toDatabaseMap() {
+ return {
+ 'local_id': localId,
+ 'server_id': id,
+ 'account_id': accountId,
+ 'name': name,
+ 'date': date,
+ 'amount': amount,
+ 'currency': currency,
+ 'nature': nature,
+ 'notes': notes,
+ 'sync_status': _syncStatusToString(syncStatus),
+ 'created_at': createdAt.toIso8601String(),
+ 'updated_at': updatedAt.toIso8601String(),
+ };
+ }
+
+ Transaction toTransaction() {
+ return Transaction(
+ id: id,
+ accountId: accountId,
+ name: name,
+ date: date,
+ amount: amount,
+ currency: currency,
+ nature: nature,
+ notes: notes,
+ );
+ }
+
+ OfflineTransaction copyWith({
+ String? id,
+ String? localId,
+ String? accountId,
+ String? name,
+ String? date,
+ String? amount,
+ String? currency,
+ String? nature,
+ String? notes,
+ SyncStatus? syncStatus,
+ DateTime? createdAt,
+ DateTime? updatedAt,
+ }) {
+ return OfflineTransaction(
+ id: id ?? this.id,
+ localId: localId ?? this.localId,
+ accountId: accountId ?? this.accountId,
+ name: name ?? this.name,
+ date: date ?? this.date,
+ amount: amount ?? this.amount,
+ currency: currency ?? this.currency,
+ nature: nature ?? this.nature,
+ notes: notes ?? this.notes,
+ syncStatus: syncStatus ?? this.syncStatus,
+ createdAt: createdAt ?? this.createdAt,
+ updatedAt: updatedAt ?? this.updatedAt,
+ );
+ }
+
+ bool get isSynced => syncStatus == SyncStatus.synced && id != null;
+ bool get isPending => syncStatus == SyncStatus.pending;
+ bool get hasFailed => syncStatus == SyncStatus.failed;
+
+ static SyncStatus _parseSyncStatus(String status) {
+ switch (status) {
+ case 'synced':
+ return SyncStatus.synced;
+ case 'pending':
+ return SyncStatus.pending;
+ case 'failed':
+ return SyncStatus.failed;
+ case 'pending_delete':
+ return SyncStatus.pendingDelete;
+ default:
+ return SyncStatus.pending;
+ }
+ }
+
+ static String _syncStatusToString(SyncStatus status) {
+ switch (status) {
+ case SyncStatus.synced:
+ return 'synced';
+ case SyncStatus.pending:
+ return 'pending';
+ case SyncStatus.failed:
+ return 'failed';
+ case SyncStatus.pendingDelete:
+ return 'pending_delete';
+ }
+ }
+}
diff --git a/mobile/lib/models/tool_call.dart b/mobile/lib/models/tool_call.dart
new file mode 100644
index 000000000..606c3d0fb
--- /dev/null
+++ b/mobile/lib/models/tool_call.dart
@@ -0,0 +1,53 @@
+import 'dart:convert';
+
+class ToolCall {
+ final String id;
+ final String functionName;
+ final Map functionArguments;
+ final Map? functionResult;
+ final DateTime createdAt;
+
+ ToolCall({
+ required this.id,
+ required this.functionName,
+ required this.functionArguments,
+ this.functionResult,
+ required this.createdAt,
+ });
+
+ factory ToolCall.fromJson(Map json) {
+ return ToolCall(
+ id: json['id'].toString(),
+ functionName: json['function_name'] as String,
+ functionArguments: _parseJsonField(json['function_arguments']),
+ functionResult: json['function_result'] != null
+ ? _parseJsonField(json['function_result'])
+ : null,
+ createdAt: DateTime.parse(json['created_at'] as String),
+ );
+ }
+
+ static Map _parseJsonField(dynamic field) {
+ if (field == null) return {};
+ if (field is Map) return field;
+ if (field is String) {
+ try {
+ final parsed = jsonDecode(field);
+ return parsed is Map ? parsed : {};
+ } catch (e) {
+ return {};
+ }
+ }
+ return {};
+ }
+
+ Map toJson() {
+ return {
+ 'id': id,
+ 'function_name': functionName,
+ 'function_arguments': functionArguments,
+ 'function_result': functionResult,
+ 'created_at': createdAt.toIso8601String(),
+ };
+ }
+}
diff --git a/mobile/lib/models/transaction.dart b/mobile/lib/models/transaction.dart
index 8d2e93b3a..291f571c0 100644
--- a/mobile/lib/models/transaction.dart
+++ b/mobile/lib/models/transaction.dart
@@ -20,14 +20,33 @@ class Transaction {
});
factory Transaction.fromJson(Map json) {
+ // Handle both API formats:
+ // 1. New format: {"account": {"id": "xxx", "name": "..."}}
+ // 2. Old format: {"account_id": "xxx"}
+ String accountId = '';
+ if (json['account'] != null && json['account'] is Map) {
+ accountId = json['account']['id']?.toString() ?? '';
+ } else if (json['account_id'] != null) {
+ accountId = json['account_id']?.toString() ?? '';
+ }
+
+ // Handle classification (from backend) or nature (from mobile)
+ String nature = 'expense';
+ if (json['classification'] != null) {
+ final classification = json['classification']?.toString().toLowerCase() ?? '';
+ nature = classification == 'income' ? 'income' : 'expense';
+ } else if (json['nature'] != null) {
+ nature = json['nature']?.toString() ?? 'expense';
+ }
+
return Transaction(
id: json['id']?.toString(),
- accountId: json['account_id']?.toString() ?? '',
+ accountId: accountId,
name: json['name']?.toString() ?? '',
date: json['date']?.toString() ?? '',
amount: json['amount']?.toString() ?? '0',
currency: json['currency']?.toString() ?? '',
- nature: json['nature']?.toString() ?? 'expense',
+ nature: nature,
notes: json['notes']?.toString(),
);
}
diff --git a/mobile/lib/providers/accounts_provider.dart b/mobile/lib/providers/accounts_provider.dart
index b5d465443..efa6e65bd 100644
--- a/mobile/lib/providers/accounts_provider.dart
+++ b/mobile/lib/providers/accounts_provider.dart
@@ -1,19 +1,27 @@
+import 'dart:io';
+import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/account.dart';
import '../services/accounts_service.dart';
+import '../services/offline_storage_service.dart';
+import '../services/connectivity_service.dart';
+import '../services/log_service.dart';
class AccountsProvider with ChangeNotifier {
final AccountsService _accountsService = AccountsService();
+ final OfflineStorageService _offlineStorage = OfflineStorageService();
+ final LogService _log = LogService.instance;
List _accounts = [];
bool _isLoading = false;
- bool _isInitializing = true; // Track if we've fetched accounts at least once
+ bool _isInitializing = true;
String? _errorMessage;
Map? _pagination;
+ ConnectivityService? _connectivityService;
List get accounts => _accounts;
bool get isLoading => _isLoading;
- bool get isInitializing => _isInitializing; // Expose initialization state
+ bool get isInitializing => _isInitializing;
String? get errorMessage => _errorMessage;
Map? get pagination => _pagination;
@@ -45,6 +53,10 @@ class AccountsProvider with ChangeNotifier {
return totals;
}
+ void setConnectivityService(ConnectivityService service) {
+ _connectivityService = service;
+ }
+
void _sortAccounts(List accounts) {
accounts.sort((a, b) {
// 1. Sort by account type
@@ -64,42 +76,91 @@ class AccountsProvider with ChangeNotifier {
});
}
+ /// Fetch accounts (offline-first approach)
Future fetchAccounts({
required String accessToken,
int page = 1,
int perPage = 25,
+ bool forceSync = false,
}) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
- final result = await _accountsService.getAccounts(
- accessToken: accessToken,
- page: page,
- perPage: perPage,
- );
-
- if (result['success'] == true && result.containsKey('accounts')) {
- _accounts = (result['accounts'] as List?)?.cast() ?? [];
- _pagination = result['pagination'] as Map?;
- _isLoading = false;
- _isInitializing = false; // Mark as initialized after first fetch
+ // Always load from local storage first for instant display
+ final cachedAccounts = await _offlineStorage.getAccounts();
+ if (cachedAccounts.isNotEmpty) {
+ _accounts = cachedAccounts;
+ _isInitializing = false;
notifyListeners();
- return true;
- } else {
- _errorMessage = result['error'] as String? ?? 'Failed to fetch accounts';
- _isLoading = false;
- _isInitializing = false; // Mark as initialized even on error
- notifyListeners();
- return false;
}
- } catch (e) {
- _errorMessage = 'Connection error. Please check your internet connection.';
+
+ // If online and (force sync or no cached data), fetch from server
+ final isOnline = _connectivityService?.isOnline ?? false;
+ if (isOnline && (forceSync || cachedAccounts.isEmpty)) {
+ final result = await _accountsService.getAccounts(
+ accessToken: accessToken,
+ page: page,
+ perPage: perPage,
+ );
+
+ if (result['success'] == true && result.containsKey('accounts')) {
+ final serverAccounts = (result['accounts'] as List?)?.cast() ?? [];
+ _pagination = result['pagination'] as Map?;
+
+ // Save to local cache
+ await _offlineStorage.clearAccounts();
+ await _offlineStorage.saveAccounts(serverAccounts);
+
+ // Update in-memory accounts
+ _accounts = serverAccounts;
+ _errorMessage = null;
+ } else {
+ // If server fetch failed but we have cached data, that's OK
+ if (_accounts.isEmpty) {
+ _errorMessage = result['error'] as String? ?? 'Failed to fetch accounts';
+ }
+ }
+ } else if (!isOnline && _accounts.isEmpty) {
+ _errorMessage = 'You are offline. Please connect to the internet to load accounts.';
+ }
+
_isLoading = false;
- _isInitializing = false; // Mark as initialized even on error
+ _isInitializing = false;
notifyListeners();
- return false;
+ return _accounts.isNotEmpty;
+ } catch (e) {
+ _log.error('AccountsProvider', 'Error in fetchAccounts: $e');
+ // If we have cached accounts, show them even if sync fails
+ if (_accounts.isEmpty) {
+ // Provide more specific error messages based on exception type
+ if (e is SocketException) {
+ _errorMessage = 'Network error. Please check your internet connection and try again.';
+ _log.error('AccountsProvider', 'SocketException: $e');
+ } else if (e is TimeoutException) {
+ _errorMessage = 'Request timed out. Please check your connection and try again.';
+ _log.error('AccountsProvider', 'TimeoutException: $e');
+ } else if (e is FormatException) {
+ _errorMessage = 'Server response error. Please try again later.';
+ _log.error('AccountsProvider', 'FormatException: $e');
+ } else if (e.toString().contains('401') || e.toString().contains('unauthorized')) {
+ _errorMessage = 'unauthorized';
+ _log.error('AccountsProvider', 'Unauthorized error: $e');
+ } else if (e.toString().contains('HandshakeException') ||
+ e.toString().contains('certificate') ||
+ e.toString().contains('SSL')) {
+ _errorMessage = 'Secure connection error. Please check your internet connection and try again.';
+ _log.error('AccountsProvider', 'SSL/Certificate error: $e');
+ } else {
+ _errorMessage = 'Something went wrong. Please try again.';
+ _log.error('AccountsProvider', 'Unhandled exception: $e');
+ }
+ }
+ _isLoading = false;
+ _isInitializing = false;
+ notifyListeners();
+ return _accounts.isNotEmpty;
}
}
@@ -107,7 +168,7 @@ class AccountsProvider with ChangeNotifier {
_accounts = [];
_pagination = null;
_errorMessage = null;
- _isInitializing = true; // Reset initialization state on clear
+ _isInitializing = true;
notifyListeners();
}
diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart
index 24b0a4e6f..8e58589e0 100644
--- a/mobile/lib/providers/auth_provider.dart
+++ b/mobile/lib/providers/auth_provider.dart
@@ -3,6 +3,7 @@ import '../models/user.dart';
import '../models/auth_tokens.dart';
import '../services/auth_service.dart';
import '../services/device_service.dart';
+import '../services/log_service.dart';
class AuthProvider with ChangeNotifier {
final AuthService _authService = AuthService();
@@ -75,7 +76,7 @@ class AuthProvider with ChangeNotifier {
otpCode: otpCode,
);
- debugPrint('Login result: $result'); // Debug log
+ LogService.instance.debug('AuthProvider', 'Login result: $result');
if (result['success'] == true) {
_tokens = result['tokens'] as AuthTokens?;
@@ -89,7 +90,7 @@ class AuthProvider with ChangeNotifier {
if (result['mfa_required'] == true) {
_mfaRequired = true;
_showMfaInput = true; // Show MFA input field
- debugPrint('MFA required! Setting _showMfaInput to true'); // Debug log
+ LogService.instance.debug('AuthProvider', 'MFA required! Setting _showMfaInput to true');
// If user already submitted an OTP code, this is likely an invalid OTP error
// Show the error message so user knows the code was wrong
diff --git a/mobile/lib/providers/chat_provider.dart b/mobile/lib/providers/chat_provider.dart
new file mode 100644
index 000000000..2760aec23
--- /dev/null
+++ b/mobile/lib/providers/chat_provider.dart
@@ -0,0 +1,301 @@
+import 'dart:async';
+import 'package:flutter/foundation.dart';
+import '../models/chat.dart';
+import '../models/message.dart';
+import '../services/chat_service.dart';
+
+class ChatProvider with ChangeNotifier {
+ final ChatService _chatService = ChatService();
+
+ List _chats = [];
+ Chat? _currentChat;
+ bool _isLoading = false;
+ bool _isSendingMessage = false;
+ String? _errorMessage;
+ Timer? _pollingTimer;
+
+ List get chats => _chats;
+ Chat? get currentChat => _currentChat;
+ bool get isLoading => _isLoading;
+ bool get isSendingMessage => _isSendingMessage;
+ String? get errorMessage => _errorMessage;
+
+ /// Fetch list of chats
+ Future fetchChats({
+ required String accessToken,
+ int page = 1,
+ int perPage = 25,
+ }) async {
+ _isLoading = true;
+ _errorMessage = null;
+ notifyListeners();
+
+ try {
+ final result = await _chatService.getChats(
+ accessToken: accessToken,
+ page: page,
+ perPage: perPage,
+ );
+
+ if (result['success'] == true) {
+ _chats = result['chats'] as List;
+ _errorMessage = null;
+ } else {
+ _errorMessage = result['error'] ?? 'Failed to fetch chats';
+ }
+ } catch (e) {
+ _errorMessage = 'Error: ${e.toString()}';
+ } finally {
+ _isLoading = false;
+ notifyListeners();
+ }
+ }
+
+ /// Fetch a specific chat with messages
+ Future fetchChat({
+ required String accessToken,
+ required String chatId,
+ }) async {
+ _isLoading = true;
+ _errorMessage = null;
+ notifyListeners();
+
+ try {
+ final result = await _chatService.getChat(
+ accessToken: accessToken,
+ chatId: chatId,
+ );
+
+ if (result['success'] == true) {
+ _currentChat = result['chat'] as Chat;
+ _errorMessage = null;
+ } else {
+ _errorMessage = result['error'] ?? 'Failed to fetch chat';
+ }
+ } catch (e) {
+ _errorMessage = 'Error: ${e.toString()}';
+ } finally {
+ _isLoading = false;
+ notifyListeners();
+ }
+ }
+
+ /// Create a new chat
+ Future createChat({
+ required String accessToken,
+ String? title,
+ String? initialMessage,
+ String model = 'gpt-4',
+ }) async {
+ _isLoading = true;
+ _errorMessage = null;
+ notifyListeners();
+
+ try {
+ final result = await _chatService.createChat(
+ accessToken: accessToken,
+ title: title,
+ initialMessage: initialMessage,
+ model: model,
+ );
+
+ if (result['success'] == true) {
+ final chat = result['chat'] as Chat;
+ _currentChat = chat;
+ _chats.insert(0, chat);
+ _errorMessage = null;
+
+ // Start polling for AI response if initial message was sent
+ if (initialMessage != null) {
+ _startPolling(accessToken, chat.id);
+ }
+
+ _isLoading = false;
+ notifyListeners();
+ return chat;
+ } else {
+ _errorMessage = result['error'] ?? 'Failed to create chat';
+ _isLoading = false;
+ notifyListeners();
+ return null;
+ }
+ } catch (e) {
+ _errorMessage = 'Error: ${e.toString()}';
+ _isLoading = false;
+ notifyListeners();
+ return null;
+ }
+ }
+
+ /// Send a message to the current chat
+ Future sendMessage({
+ required String accessToken,
+ required String chatId,
+ required String content,
+ }) async {
+ _isSendingMessage = true;
+ _errorMessage = null;
+ notifyListeners();
+
+ try {
+ final result = await _chatService.sendMessage(
+ accessToken: accessToken,
+ chatId: chatId,
+ content: content,
+ );
+
+ if (result['success'] == true) {
+ final message = result['message'] as Message;
+
+ // Add the message to current chat if it's loaded
+ if (_currentChat != null && _currentChat!.id == chatId) {
+ _currentChat = _currentChat!.copyWith(
+ messages: [..._currentChat!.messages, message],
+ );
+ }
+
+ _errorMessage = null;
+
+ // Start polling for AI response
+ _startPolling(accessToken, chatId);
+ } else {
+ _errorMessage = result['error'] ?? 'Failed to send message';
+ }
+ } catch (e) {
+ _errorMessage = 'Error: ${e.toString()}';
+ } finally {
+ _isSendingMessage = false;
+ notifyListeners();
+ }
+ }
+
+ /// Update chat title
+ Future updateChatTitle({
+ required String accessToken,
+ required String chatId,
+ required String title,
+ }) async {
+ try {
+ final result = await _chatService.updateChat(
+ accessToken: accessToken,
+ chatId: chatId,
+ title: title,
+ );
+
+ if (result['success'] == true) {
+ final updatedChat = result['chat'] as Chat;
+
+ // Update in the list
+ final index = _chats.indexWhere((c) => c.id == chatId);
+ if (index != -1) {
+ _chats[index] = updatedChat;
+ }
+
+ // Update current chat if it's the same
+ if (_currentChat != null && _currentChat!.id == chatId) {
+ _currentChat = updatedChat;
+ }
+
+ notifyListeners();
+ }
+ } catch (e) {
+ _errorMessage = 'Error: ${e.toString()}';
+ notifyListeners();
+ }
+ }
+
+ /// Delete a chat
+ Future deleteChat({
+ required String accessToken,
+ required String chatId,
+ }) async {
+ try {
+ final result = await _chatService.deleteChat(
+ accessToken: accessToken,
+ chatId: chatId,
+ );
+
+ if (result['success'] == true) {
+ _chats.removeWhere((c) => c.id == chatId);
+
+ if (_currentChat != null && _currentChat!.id == chatId) {
+ _currentChat = null;
+ }
+
+ notifyListeners();
+ return true;
+ } else {
+ _errorMessage = result['error'] ?? 'Failed to delete chat';
+ notifyListeners();
+ return false;
+ }
+ } catch (e) {
+ _errorMessage = 'Error: ${e.toString()}';
+ notifyListeners();
+ return false;
+ }
+ }
+
+ /// Start polling for new messages (AI responses)
+ void _startPolling(String accessToken, String chatId) {
+ _stopPolling();
+
+ _pollingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async {
+ await _pollForUpdates(accessToken, chatId);
+ });
+ }
+
+ /// Stop polling
+ void _stopPolling() {
+ _pollingTimer?.cancel();
+ _pollingTimer = null;
+ }
+
+ /// Poll for updates
+ Future _pollForUpdates(String accessToken, String chatId) async {
+ try {
+ final result = await _chatService.getChat(
+ accessToken: accessToken,
+ chatId: chatId,
+ );
+
+ if (result['success'] == true) {
+ final updatedChat = result['chat'] as Chat;
+
+ // Check if we have new messages
+ if (_currentChat != null && _currentChat!.id == chatId) {
+ final oldMessageCount = _currentChat!.messages.length;
+ final newMessageCount = updatedChat.messages.length;
+
+ if (newMessageCount > oldMessageCount) {
+ _currentChat = updatedChat;
+ notifyListeners();
+
+ // Check if the last message is from assistant and complete
+ final lastMessage = updatedChat.messages.lastOrNull;
+ if (lastMessage != null && lastMessage.isAssistant) {
+ // Stop polling after getting assistant response
+ _stopPolling();
+ }
+ }
+ }
+ }
+ } catch (e) {
+ // Silently fail polling errors to avoid interrupting user experience
+ debugPrint('Polling error: ${e.toString()}');
+ }
+ }
+
+ /// Clear current chat
+ void clearCurrentChat() {
+ _currentChat = null;
+ _stopPolling();
+ notifyListeners();
+ }
+
+ @override
+ void dispose() {
+ _stopPolling();
+ super.dispose();
+ }
+}
diff --git a/mobile/lib/providers/transactions_provider.dart b/mobile/lib/providers/transactions_provider.dart
index 5e4f2f01c..54e682178 100644
--- a/mobile/lib/providers/transactions_provider.dart
+++ b/mobile/lib/providers/transactions_provider.dart
@@ -1,87 +1,411 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import '../models/transaction.dart';
+import '../models/offline_transaction.dart';
import '../services/transactions_service.dart';
+import '../services/offline_storage_service.dart';
+import '../services/sync_service.dart';
+import '../services/connectivity_service.dart';
+import '../services/log_service.dart';
class TransactionsProvider with ChangeNotifier {
final TransactionsService _transactionsService = TransactionsService();
+ final OfflineStorageService _offlineStorage = OfflineStorageService();
+ final SyncService _syncService = SyncService();
+ final LogService _log = LogService.instance;
- List _transactions = [];
+ List _transactions = [];
bool _isLoading = false;
String? _error;
+ ConnectivityService? _connectivityService;
+ String? _lastAccessToken;
+ String? _currentAccountId; // Track current account for filtering
+ bool _isAutoSyncing = false;
+ bool _isListenerAttached = false;
+ bool _isDisposed = false;
+
+ List get transactions =>
+ UnmodifiableListView(_transactions.map((t) => t.toTransaction()));
+
+ List get offlineTransactions =>
+ UnmodifiableListView(_transactions);
- List get transactions => UnmodifiableListView(_transactions);
bool get isLoading => _isLoading;
String? get error => _error;
+ bool get hasPendingTransactions =>
+ _transactions.any((t) => t.syncStatus == SyncStatus.pending || t.syncStatus == SyncStatus.pendingDelete);
+ int get pendingCount =>
+ _transactions.where((t) => t.syncStatus == SyncStatus.pending || t.syncStatus == SyncStatus.pendingDelete).length;
+ SyncService get syncService => _syncService;
+
+ void setConnectivityService(ConnectivityService service) {
+ _connectivityService = service;
+ if (!_isListenerAttached) {
+ _connectivityService?.addListener(_onConnectivityChanged);
+ _isListenerAttached = true;
+ }
+ }
+
+ void _onConnectivityChanged() {
+ if (_isDisposed) return;
+
+ // Auto-sync when connectivity is restored
+ if (_connectivityService?.isOnline == true &&
+ hasPendingTransactions &&
+ _lastAccessToken != null &&
+ !_isAutoSyncing) {
+ _log.info('TransactionsProvider', 'Connectivity restored, auto-syncing $pendingCount pending transactions');
+ _isAutoSyncing = true;
+
+ // Fire and forget - we don't await to avoid blocking connectivity listener
+ // Use callbacks to handle completion and errors asynchronously
+ syncTransactions(accessToken: _lastAccessToken!)
+ .then((_) {
+ if (!_isDisposed) {
+ _log.info('TransactionsProvider', 'Auto-sync completed successfully');
+ }
+ })
+ .catchError((e) {
+ if (!_isDisposed) {
+ _log.error('TransactionsProvider', 'Auto-sync failed: $e');
+ }
+ })
+ .whenComplete(() {
+ if (!_isDisposed) {
+ _isAutoSyncing = false;
+ }
+ });
+ }
+ }
+
+ // Helper to check if object is still valid
+ bool get mounted => !_isDisposed;
+
+ /// Fetch transactions (offline-first approach)
Future fetchTransactions({
required String accessToken,
String? accountId,
+ bool forceSync = false,
}) async {
+ _lastAccessToken = accessToken; // Store for auto-sync
+ _currentAccountId = accountId; // Track current account
_isLoading = true;
_error = null;
notifyListeners();
- final result = await _transactionsService.getTransactions(
- accessToken: accessToken,
- accountId: accountId,
- );
+ try {
+ // Always load from local storage first
+ final localTransactions = await _offlineStorage.getTransactions(
+ accountId: accountId,
+ );
- _isLoading = false;
+ _log.debug('TransactionsProvider', 'Loaded ${localTransactions.length} transactions from local storage (accountId: $accountId)');
- if (result['success'] == true && result.containsKey('transactions')) {
- _transactions = (result['transactions'] as List?)?.cast() ?? [];
- _error = null;
- } else {
- _error = result['error'] as String? ?? 'Failed to fetch transactions';
+ _transactions = localTransactions;
+ notifyListeners();
+
+ // If online and force sync, or if local storage is empty, sync from server
+ final isOnline = _connectivityService?.isOnline ?? true;
+ _log.debug('TransactionsProvider', 'Online: $isOnline, ForceSync: $forceSync, LocalEmpty: ${localTransactions.isEmpty}');
+
+ if (isOnline && (forceSync || localTransactions.isEmpty)) {
+ _log.debug('TransactionsProvider', 'Syncing from server for accountId: $accountId');
+ final result = await _syncService.syncFromServer(
+ accessToken: accessToken,
+ accountId: accountId,
+ );
+
+ if (result.success) {
+ _log.info('TransactionsProvider', 'Sync successful, synced ${result.syncedCount} transactions');
+ // Reload from local storage after sync
+ final updatedTransactions = await _offlineStorage.getTransactions(
+ accountId: accountId,
+ );
+ _log.debug('TransactionsProvider', 'After sync, loaded ${updatedTransactions.length} transactions from local storage');
+ _transactions = updatedTransactions;
+ _error = null;
+ } else {
+ _log.error('TransactionsProvider', 'Sync failed: ${result.error}');
+ _error = result.error;
+ }
+ }
+ } catch (e) {
+ _log.error('TransactionsProvider', 'Error in fetchTransactions: $e');
+ _error = 'Something went wrong. Please try again.';
+ } finally {
+ _isLoading = false;
+ notifyListeners();
}
-
- notifyListeners();
}
+ /// Create a new transaction (offline-first)
+ Future createTransaction({
+ required String accessToken,
+ required String accountId,
+ required String name,
+ required String date,
+ required String amount,
+ required String currency,
+ required String nature,
+ String? notes,
+ }) async {
+ _lastAccessToken = accessToken; // Store for auto-sync
+
+ try {
+ final isOnline = _connectivityService?.isOnline ?? false;
+
+ _log.info('TransactionsProvider', 'Creating transaction: $name, amount: $amount, online: $isOnline');
+
+ // ALWAYS save locally first (offline-first strategy)
+ final localTransaction = await _offlineStorage.saveTransaction(
+ accountId: accountId,
+ name: name,
+ date: date,
+ amount: amount,
+ currency: currency,
+ nature: nature,
+ notes: notes,
+ syncStatus: SyncStatus.pending, // Start as pending
+ );
+
+ _log.info('TransactionsProvider', 'Transaction saved locally with ID: ${localTransaction.localId}');
+
+ // Reload transactions to show the new one immediately
+ await fetchTransactions(accessToken: accessToken, accountId: accountId);
+
+ // If online, try to upload in background
+ if (isOnline) {
+ _log.info('TransactionsProvider', 'Attempting to upload transaction to server...');
+
+ // Don't await - upload in background
+ _transactionsService.createTransaction(
+ accessToken: accessToken,
+ accountId: accountId,
+ name: name,
+ date: date,
+ amount: amount,
+ currency: currency,
+ nature: nature,
+ notes: notes,
+ ).then((result) async {
+ if (_isDisposed) return;
+
+ if (result['success'] == true) {
+ _log.info('TransactionsProvider', 'Transaction uploaded successfully');
+ final serverTransaction = result['transaction'] as Transaction;
+ // Update local transaction with server ID and mark as synced
+ await _offlineStorage.updateTransactionSyncStatus(
+ localId: localTransaction.localId,
+ syncStatus: SyncStatus.synced,
+ serverId: serverTransaction.id,
+ );
+ // Reload to update UI
+ await fetchTransactions(accessToken: accessToken, accountId: accountId);
+ } else {
+ _log.warning('TransactionsProvider', 'Server upload failed: ${result['error']}. Transaction will sync later.');
+ }
+ }).catchError((e) {
+ if (_isDisposed) return;
+
+ _log.error('TransactionsProvider', 'Exception during upload: $e');
+ _error = 'Failed to upload transaction. It will sync when online.';
+ notifyListeners();
+ });
+ } else {
+ _log.info('TransactionsProvider', 'Offline: Transaction will sync when online');
+ }
+
+ return true; // Always return true because it's saved locally
+ } catch (e) {
+ _log.error('TransactionsProvider', 'Failed to create transaction: $e');
+ _error = 'Something went wrong. Please try again.';
+ notifyListeners();
+ return false;
+ }
+ }
+
+ /// Delete a transaction
Future deleteTransaction({
required String accessToken,
required String transactionId,
}) async {
- final result = await _transactionsService.deleteTransaction(
- accessToken: accessToken,
- transactionId: transactionId,
- );
+ try {
+ final isOnline = _connectivityService?.isOnline ?? false;
- if (result['success'] == true) {
- _transactions.removeWhere((t) => t.id == transactionId);
- notifyListeners();
- return true;
- } else {
- _error = result['error'] as String? ?? 'Failed to delete transaction';
+ if (isOnline) {
+ // Try to delete on server
+ final result = await _transactionsService.deleteTransaction(
+ accessToken: accessToken,
+ transactionId: transactionId,
+ );
+
+ if (result['success'] == true) {
+ // Delete from local storage
+ await _offlineStorage.deleteTransactionByServerId(transactionId);
+ _transactions.removeWhere((t) => t.id == transactionId);
+ notifyListeners();
+ return true;
+ } else {
+ _error = result['error'] as String? ?? 'Failed to delete transaction';
+ notifyListeners();
+ return false;
+ }
+ } else {
+ // Offline - mark for deletion and sync later
+ _log.info('TransactionsProvider', 'Offline: Marking transaction for deletion');
+ await _offlineStorage.markTransactionForDeletion(transactionId);
+
+ // Reload from storage to update UI with pending delete status
+ final updatedTransactions = await _offlineStorage.getTransactions(
+ accountId: _currentAccountId,
+ );
+ _transactions = updatedTransactions;
+ notifyListeners();
+ return true;
+ }
+ } catch (e) {
+ _log.error('TransactionsProvider', 'Failed to delete transaction: $e');
+ _error = 'Something went wrong. Please try again.';
notifyListeners();
return false;
}
}
+ /// Delete multiple transactions
Future deleteMultipleTransactions({
required String accessToken,
required List transactionIds,
}) async {
- final result = await _transactionsService.deleteMultipleTransactions(
- accessToken: accessToken,
- transactionIds: transactionIds,
- );
+ try {
+ final isOnline = _connectivityService?.isOnline ?? false;
- if (result['success'] == true) {
- _transactions.removeWhere((t) => transactionIds.contains(t.id));
- notifyListeners();
- return true;
- } else {
- _error = result['error'] as String? ?? 'Failed to delete transactions';
+ if (isOnline) {
+ final result = await _transactionsService.deleteMultipleTransactions(
+ accessToken: accessToken,
+ transactionIds: transactionIds,
+ );
+
+ if (result['success'] == true) {
+ // Delete from local storage
+ for (final id in transactionIds) {
+ await _offlineStorage.deleteTransactionByServerId(id);
+ }
+ _transactions.removeWhere((t) => transactionIds.contains(t.id));
+ notifyListeners();
+ return true;
+ } else {
+ _error = result['error'] as String? ?? 'Failed to delete transactions';
+ notifyListeners();
+ return false;
+ }
+ } else {
+ // Offline - mark all for deletion and sync later
+ _log.info('TransactionsProvider', 'Offline: Marking ${transactionIds.length} transactions for deletion');
+ for (final id in transactionIds) {
+ await _offlineStorage.markTransactionForDeletion(id);
+ }
+
+ // Reload from storage to update UI with pending delete status
+ final updatedTransactions = await _offlineStorage.getTransactions(
+ accountId: _currentAccountId,
+ );
+ _transactions = updatedTransactions;
+ notifyListeners();
+ return true;
+ }
+ } catch (e) {
+ _log.error('TransactionsProvider', 'Failed to delete multiple transactions: $e');
+ _error = 'Something went wrong. Please try again.';
notifyListeners();
return false;
}
}
+ /// Undo a pending transaction (either pending create or pending delete)
+ Future undoPendingTransaction({
+ required String localId,
+ required SyncStatus syncStatus,
+ }) async {
+ _log.info('TransactionsProvider', 'Undoing transaction $localId with status $syncStatus');
+
+ try {
+ final success = await _offlineStorage.undoPendingTransaction(localId, syncStatus);
+
+ if (success) {
+ // Reload from storage to update UI
+ final updatedTransactions = await _offlineStorage.getTransactions(
+ accountId: _currentAccountId,
+ );
+ _transactions = updatedTransactions;
+ _error = null;
+ notifyListeners();
+ return true;
+ } else {
+ _error = 'Failed to undo transaction';
+ notifyListeners();
+ return false;
+ }
+ } catch (e) {
+ _log.error('TransactionsProvider', 'Failed to undo transaction: $e');
+ _error = 'Something went wrong. Please try again.';
+ notifyListeners();
+ return false;
+ }
+ }
+
+ /// Manually trigger sync
+ Future syncTransactions({
+ required String accessToken,
+ }) async {
+ if (_connectivityService?.isOffline == true) {
+ _error = 'Cannot sync while offline';
+ notifyListeners();
+ return;
+ }
+
+ _isLoading = true;
+ notifyListeners();
+
+ try {
+ final result = await _syncService.performFullSync(accessToken);
+
+ if (result.success) {
+ // Reload from local storage
+ final updatedTransactions = await _offlineStorage.getTransactions();
+ _transactions = updatedTransactions;
+ _error = null;
+ } else {
+ _error = result.error;
+ }
+ } catch (e) {
+ _log.error('TransactionsProvider', 'Failed to sync transactions: $e');
+ _error = 'Something went wrong. Please try again.';
+ } finally {
+ _isLoading = false;
+ notifyListeners();
+ }
+ }
+
void clearTransactions() {
_transactions = [];
_error = null;
notifyListeners();
}
+
+ void clearError() {
+ _error = null;
+ notifyListeners();
+ }
+
+ @override
+ void dispose() {
+ _isDisposed = true;
+ if (_isListenerAttached && _connectivityService != null) {
+ _connectivityService!.removeListener(_onConnectivityChanged);
+ _isListenerAttached = false;
+ }
+ _connectivityService = null;
+ super.dispose();
+ }
}
diff --git a/mobile/lib/screens/calendar_screen.dart b/mobile/lib/screens/calendar_screen.dart
new file mode 100644
index 000000000..26754e08f
--- /dev/null
+++ b/mobile/lib/screens/calendar_screen.dart
@@ -0,0 +1,498 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:intl/intl.dart';
+import '../models/account.dart';
+import '../models/transaction.dart';
+import '../providers/accounts_provider.dart';
+import '../providers/transactions_provider.dart';
+import '../providers/auth_provider.dart';
+import '../services/log_service.dart';
+
+class CalendarScreen extends StatefulWidget {
+ const CalendarScreen({super.key});
+
+ @override
+ State createState() => _CalendarScreenState();
+}
+
+class _CalendarScreenState extends State {
+ final LogService _log = LogService.instance;
+ Account? _selectedAccount;
+ DateTime _currentMonth = DateTime.now();
+ Map _dailyChanges = {};
+ bool _isLoading = false;
+ String _accountType = 'asset'; // 'asset' or 'liability'
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _loadInitialData();
+ });
+ }
+
+ Future _loadInitialData() async {
+ final accountsProvider = context.read();
+ final authProvider = context.read();
+
+ final accessToken = await authProvider.getValidAccessToken();
+
+ if (accountsProvider.accounts.isEmpty && accessToken != null) {
+ await accountsProvider.fetchAccounts(
+ accessToken: accessToken,
+ forceSync: false,
+ );
+ }
+
+ if (accountsProvider.accounts.isNotEmpty) {
+ // Select first account of the selected type
+ final filteredAccounts = _getFilteredAccounts(accountsProvider.accounts);
+ setState(() {
+ _selectedAccount = filteredAccounts.isNotEmpty ? filteredAccounts.first : null;
+ });
+ if (_selectedAccount != null) {
+ await _loadTransactionsForAccount();
+ }
+ }
+ }
+
+ List _getFilteredAccounts(List accounts) {
+ if (_accountType == 'asset') {
+ return accounts.where((a) => a.isAsset).toList();
+ } else {
+ return accounts.where((a) => a.isLiability).toList();
+ }
+ }
+
+ Future _loadTransactionsForAccount() async {
+ if (_selectedAccount == null) return;
+
+ setState(() {
+ _isLoading = true;
+ });
+
+ final authProvider = context.read();
+ final transactionsProvider = context.read();
+
+ final accessToken = await authProvider.getValidAccessToken();
+
+ if (accessToken != null) {
+ await transactionsProvider.fetchTransactions(
+ accessToken: accessToken,
+ accountId: _selectedAccount!.id,
+ forceSync: false,
+ );
+
+ final transactions = transactionsProvider.transactions;
+ _log.info('CalendarScreen', 'Loaded ${transactions.length} transactions for account ${_selectedAccount!.name}');
+
+ if (transactions.isNotEmpty) {
+ _log.debug('CalendarScreen', 'Sample transaction - name: ${transactions.first.name}, amount: ${transactions.first.amount}, nature: ${transactions.first.nature}');
+ }
+
+ _calculateDailyChanges(transactions);
+ _log.info('CalendarScreen', 'Calculated ${_dailyChanges.length} days with changes');
+ }
+
+ setState(() {
+ _isLoading = false;
+ });
+ }
+
+ void _calculateDailyChanges(List transactions) {
+ final changes = {};
+
+ _log.debug('CalendarScreen', 'Starting to calculate daily changes for ${transactions.length} transactions');
+
+ for (var transaction in transactions) {
+ try {
+ final date = DateTime.parse(transaction.date);
+ final dateKey = DateFormat('yyyy-MM-dd').format(date);
+
+ // Parse amount with proper sign handling
+ String trimmedAmount = transaction.amount.trim();
+ trimmedAmount = trimmedAmount.replaceAll('\u2212', '-'); // Normalize minus sign
+
+ // Detect if the amount has a negative sign
+ bool hasNegativeSign = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-');
+
+ // Remove all non-numeric characters except decimal point and minus sign
+ String numericString = trimmedAmount.replaceAll(RegExp(r'[^\d.\-]'), '');
+
+ // Parse the numeric value
+ double amount = double.tryParse(numericString.replaceAll('-', '')) ?? 0.0;
+
+ // Apply the sign from the string
+ if (hasNegativeSign) {
+ amount = -amount;
+ }
+
+ // For asset accounts, flip the sign to match accounting conventions
+ // For liability accounts, also flip the sign
+ if (_selectedAccount?.isAsset == true || _selectedAccount?.isLiability == true) {
+ amount = -amount;
+ }
+
+ _log.debug('CalendarScreen', 'Processing transaction ${transaction.name} - date: $dateKey, raw amount: ${transaction.amount}, parsed: $amount, isAsset: ${_selectedAccount?.isAsset}, isLiability: ${_selectedAccount?.isLiability}');
+
+ changes[dateKey] = (changes[dateKey] ?? 0.0) + amount;
+ _log.debug('CalendarScreen', 'Date $dateKey now has total: ${changes[dateKey]}');
+ } catch (e) {
+ _log.error('CalendarScreen', 'Failed to parse transaction date: ${transaction.date}, error: $e');
+ }
+ }
+
+ _log.info('CalendarScreen', 'Final changes map has ${changes.length} entries');
+ changes.forEach((date, amount) {
+ _log.debug('CalendarScreen', '$date -> $amount');
+ });
+
+ setState(() {
+ _dailyChanges = changes;
+ });
+ }
+
+ void _previousMonth() {
+ setState(() {
+ _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1);
+ });
+ }
+
+ void _nextMonth() {
+ setState(() {
+ _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1);
+ });
+ }
+
+ double _getTotalForMonth() {
+ double total = 0.0;
+ final yearMonth = DateFormat('yyyy-MM').format(_currentMonth);
+
+ _dailyChanges.forEach((date, change) {
+ if (date.startsWith(yearMonth)) {
+ total += change;
+ }
+ });
+
+ return total;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final accountsProvider = context.watch();
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Account Calendar'),
+ ),
+ body: Column(
+ children: [
+ // Account type selector
+ Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: colorScheme.surface,
+ border: Border(
+ bottom: BorderSide(
+ color: colorScheme.outlineVariant,
+ width: 1,
+ ),
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Account Type',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 8),
+ SegmentedButton(
+ segments: const [
+ ButtonSegment(
+ value: 'asset',
+ label: Text('Assets'),
+ icon: Icon(Icons.account_balance_wallet),
+ ),
+ ButtonSegment(
+ value: 'liability',
+ label: Text('Liabilities'),
+ icon: Icon(Icons.credit_card),
+ ),
+ ],
+ selected: {_accountType},
+ onSelectionChanged: (Set newSelection) {
+ setState(() {
+ _accountType = newSelection.first;
+ // Switch to first account of new type
+ final filteredAccounts = _getFilteredAccounts(accountsProvider.accounts);
+ _selectedAccount = filteredAccounts.isNotEmpty ? filteredAccounts.first : null;
+ _dailyChanges = {};
+ });
+ if (_selectedAccount != null) {
+ _loadTransactionsForAccount();
+ }
+ },
+ ),
+ ],
+ ),
+ ),
+
+ // Account selector
+ Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: colorScheme.surface,
+ border: Border(
+ bottom: BorderSide(
+ color: colorScheme.outlineVariant,
+ width: 1,
+ ),
+ ),
+ ),
+ child: DropdownButtonFormField(
+ value: _selectedAccount,
+ decoration: InputDecoration(
+ labelText: 'Select Account',
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 12,
+ ),
+ ),
+ items: _getFilteredAccounts(accountsProvider.accounts).map((account) {
+ return DropdownMenuItem(
+ value: account,
+ child: Text('${account.name} (${account.currency})'),
+ );
+ }).toList(),
+ onChanged: (Account? newAccount) {
+ setState(() {
+ _selectedAccount = newAccount;
+ _dailyChanges = {};
+ });
+ _loadTransactionsForAccount();
+ },
+ ),
+ ),
+
+ // Month selector
+ Container(
+ padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest,
+ border: Border(
+ bottom: BorderSide(
+ color: colorScheme.outlineVariant,
+ width: 1,
+ ),
+ ),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ IconButton(
+ icon: const Icon(Icons.chevron_left),
+ onPressed: _previousMonth,
+ ),
+ Text(
+ DateFormat('yyyy-MM').format(_currentMonth),
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ IconButton(
+ icon: const Icon(Icons.chevron_right),
+ onPressed: _nextMonth,
+ ),
+ ],
+ ),
+ ),
+
+ // Monthly total
+ Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest,
+ border: Border(
+ bottom: BorderSide(
+ color: colorScheme.outlineVariant,
+ width: 1,
+ ),
+ ),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ 'Monthly Change',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ Text(
+ _formatCurrency(_getTotalForMonth()),
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ color: _getTotalForMonth() >= 0
+ ? Colors.green
+ : Colors.red,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // Calendar
+ Expanded(
+ child: _isLoading
+ ? const Center(child: CircularProgressIndicator())
+ : _buildCalendar(colorScheme),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildCalendar(ColorScheme colorScheme) {
+ final firstDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month, 1);
+ final lastDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
+ final daysInMonth = lastDayOfMonth.day;
+ final startWeekday = firstDayOfMonth.weekday % 7; // 0 = Sunday
+
+ return SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.all(8),
+ child: Column(
+ children: [
+ // Weekday headers
+ SizedBox(
+ height: 40,
+ child: Row(
+ children: ['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day) {
+ return Expanded(
+ child: Center(
+ child: Text(
+ day,
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+
+ // Calendar grid
+ ...List.generate((daysInMonth + startWeekday + 6) ~/ 7, (weekIndex) {
+ return SizedBox(
+ height: 70,
+ child: Row(
+ children: List.generate(7, (dayIndex) {
+ final dayNumber = weekIndex * 7 + dayIndex - startWeekday + 1;
+
+ if (dayNumber < 1 || dayNumber > daysInMonth) {
+ return const Expanded(child: SizedBox.shrink());
+ }
+
+ final date = DateTime(_currentMonth.year, _currentMonth.month, dayNumber);
+ final dateKey = DateFormat('yyyy-MM-dd').format(date);
+ final change = _dailyChanges[dateKey] ?? 0.0;
+ final hasChange = _dailyChanges.containsKey(dateKey);
+
+ return Expanded(
+ child: _buildDayCell(
+ dayNumber,
+ change,
+ hasChange,
+ colorScheme,
+ ),
+ );
+ }).toList(),
+ ),
+ );
+ }),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildDayCell(int day, double change, bool hasChange, ColorScheme colorScheme) {
+ Color? backgroundColor;
+ Color? textColor;
+
+ if (hasChange) {
+ if (change > 0) {
+ backgroundColor = Colors.green.withValues(alpha: 0.2);
+ textColor = Colors.green.shade700;
+ } else if (change < 0) {
+ backgroundColor = Colors.red.withValues(alpha: 0.2);
+ textColor = Colors.red.shade700;
+ }
+ }
+
+ return Container(
+ margin: const EdgeInsets.all(2),
+ decoration: BoxDecoration(
+ color: backgroundColor ?? colorScheme.surface,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: colorScheme.outlineVariant,
+ width: 1,
+ ),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(4),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ day.toString(),
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ color: colorScheme.onSurface,
+ ),
+ ),
+ if (hasChange) ...[
+ const SizedBox(height: 2),
+ Flexible(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ _formatAmount(change),
+ style: TextStyle(
+ fontSize: 10,
+ color: textColor,
+ fontWeight: FontWeight.bold,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ );
+ }
+
+ String _formatAmount(double amount) {
+ // Support up to 8 decimal places, but omit unnecessary trailing zeros
+ final formatter = NumberFormat('#,##0.########');
+ final sign = amount >= 0 ? '+' : '';
+ return '$sign${formatter.format(amount)}';
+ }
+
+ String _formatCurrency(double amount) {
+ final currencySymbol = _selectedAccount?.currency ?? '';
+ // Support up to 8 decimal places for monthly total
+ final formatter = NumberFormat('#,##0.########');
+ final sign = amount >= 0 ? '+' : '';
+ return '$sign$currencySymbol${formatter.format(amount.abs())}';
+ }
+}
diff --git a/mobile/lib/screens/chat_conversation_screen.dart b/mobile/lib/screens/chat_conversation_screen.dart
new file mode 100644
index 000000000..0de3fee61
--- /dev/null
+++ b/mobile/lib/screens/chat_conversation_screen.dart
@@ -0,0 +1,436 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../providers/auth_provider.dart';
+import '../providers/chat_provider.dart';
+import '../models/message.dart';
+
+class ChatConversationScreen extends StatefulWidget {
+ final String chatId;
+
+ const ChatConversationScreen({
+ super.key,
+ required this.chatId,
+ });
+
+ @override
+ State createState() => _ChatConversationScreenState();
+}
+
+class _ChatConversationScreenState extends State {
+ final TextEditingController _messageController = TextEditingController();
+ final ScrollController _scrollController = ScrollController();
+
+ @override
+ void initState() {
+ super.initState();
+ _loadChat();
+ }
+
+ @override
+ void dispose() {
+ _messageController.dispose();
+ _scrollController.dispose();
+ super.dispose();
+ }
+
+ Future _loadChat() async {
+ final authProvider = Provider.of(context, listen: false);
+ final chatProvider = Provider.of(context, listen: false);
+
+ final accessToken = await authProvider.getValidAccessToken();
+ if (accessToken == null) {
+ await authProvider.logout();
+ return;
+ }
+
+ await chatProvider.fetchChat(
+ accessToken: accessToken,
+ chatId: widget.chatId,
+ );
+
+ // Scroll to bottom after loading
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (_scrollController.hasClients) {
+ _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
+ }
+ });
+ }
+
+ Future _sendMessage() async {
+ final content = _messageController.text.trim();
+ if (content.isEmpty) return;
+
+ final authProvider = Provider.of(context, listen: false);
+ final chatProvider = Provider.of(context, listen: false);
+
+ final accessToken = await authProvider.getValidAccessToken();
+ if (accessToken == null) {
+ await authProvider.logout();
+ return;
+ }
+
+ // Clear input field immediately
+ _messageController.clear();
+
+ await chatProvider.sendMessage(
+ accessToken: accessToken,
+ chatId: widget.chatId,
+ content: content,
+ );
+
+ // Scroll to bottom after sending
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (_scrollController.hasClients) {
+ _scrollController.animateTo(
+ _scrollController.position.maxScrollExtent,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeOut,
+ );
+ }
+ });
+ }
+
+ Future _editTitle() async {
+ final chatProvider = Provider.of(context, listen: false);
+ final currentTitle = chatProvider.currentChat?.title ?? '';
+
+ final newTitle = await showDialog(
+ context: context,
+ builder: (context) {
+ final controller = TextEditingController(text: currentTitle);
+ return AlertDialog(
+ title: const Text('Edit Title'),
+ content: TextField(
+ controller: controller,
+ decoration: const InputDecoration(
+ labelText: 'Chat Title',
+ border: OutlineInputBorder(),
+ ),
+ autofocus: true,
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, controller.text.trim()),
+ child: const Text('Save'),
+ ),
+ ],
+ );
+ },
+ );
+
+ if (newTitle != null && newTitle.isNotEmpty && newTitle != currentTitle && mounted) {
+ final authProvider = Provider.of(context, listen: false);
+ final accessToken = await authProvider.getValidAccessToken();
+ if (accessToken != null) {
+ await chatProvider.updateChatTitle(
+ accessToken: accessToken,
+ chatId: widget.chatId,
+ title: newTitle,
+ );
+ }
+ }
+ }
+
+ String _formatTime(DateTime dateTime) {
+ final hour = dateTime.hour.toString().padLeft(2, '0');
+ final minute = dateTime.minute.toString().padLeft(2, '0');
+ return '$hour:$minute';
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Scaffold(
+ appBar: AppBar(
+ title: Consumer(
+ builder: (context, chatProvider, _) {
+ return GestureDetector(
+ onTap: _editTitle,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Flexible(
+ child: Text(
+ chatProvider.currentChat?.title ?? 'Chat',
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ const SizedBox(width: 4),
+ const Icon(Icons.edit, size: 18),
+ ],
+ ),
+ );
+ },
+ ),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.refresh),
+ onPressed: _loadChat,
+ tooltip: 'Refresh',
+ ),
+ ],
+ ),
+ body: Consumer(
+ builder: (context, chatProvider, _) {
+ if (chatProvider.isLoading && chatProvider.currentChat == null) {
+ return const Center(
+ child: CircularProgressIndicator(),
+ );
+ }
+
+ if (chatProvider.errorMessage != null && chatProvider.currentChat == null) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.error_outline,
+ size: 64,
+ color: colorScheme.error,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Failed to load chat',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ chatProvider.errorMessage!,
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ ElevatedButton.icon(
+ onPressed: _loadChat,
+ icon: const Icon(Icons.refresh),
+ label: const Text('Try Again'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ final messages = chatProvider.currentChat?.messages ?? [];
+
+ return Column(
+ children: [
+ // Messages list
+ Expanded(
+ child: messages.isEmpty
+ ? Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.chat_bubble_outline,
+ size: 64,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Start a conversation',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Send a message to begin chatting with the AI assistant.',
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ )
+ : ListView.builder(
+ controller: _scrollController,
+ padding: const EdgeInsets.all(16),
+ itemCount: messages.length,
+ itemBuilder: (context, index) {
+ final message = messages[index];
+ return _MessageBubble(
+ message: message,
+ formatTime: _formatTime,
+ );
+ },
+ ),
+ ),
+
+ // Loading indicator when sending
+ if (chatProvider.isSendingMessage)
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Row(
+ children: [
+ const SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ ),
+ const SizedBox(width: 12),
+ Text(
+ 'AI is thinking...',
+ style: TextStyle(
+ color: colorScheme.onSurfaceVariant,
+ fontStyle: FontStyle.italic,
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // Message input
+ Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: colorScheme.surface,
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.05),
+ blurRadius: 10,
+ offset: const Offset(0, -2),
+ ),
+ ],
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _messageController,
+ decoration: InputDecoration(
+ hintText: 'Type a message...',
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(24),
+ ),
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 12,
+ ),
+ ),
+ maxLines: null,
+ textCapitalization: TextCapitalization.sentences,
+ onSubmitted: (_) => _sendMessage(),
+ ),
+ ),
+ const SizedBox(width: 8),
+ IconButton(
+ icon: const Icon(Icons.send),
+ onPressed: chatProvider.isSendingMessage ? null : _sendMessage,
+ color: colorScheme.primary,
+ iconSize: 28,
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _MessageBubble extends StatelessWidget {
+ final Message message;
+ final String Function(DateTime) formatTime;
+
+ const _MessageBubble({
+ required this.message,
+ required this.formatTime,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final isUser = message.isUser;
+
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: Row(
+ mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (!isUser)
+ CircleAvatar(
+ radius: 16,
+ backgroundColor: colorScheme.primaryContainer,
+ child: Icon(
+ Icons.smart_toy,
+ size: 18,
+ color: colorScheme.onPrimaryContainer,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Flexible(
+ child: Column(
+ crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
+ children: [
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ decoration: BoxDecoration(
+ color: isUser ? colorScheme.primary : colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ message.content,
+ style: TextStyle(
+ color: isUser ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
+ ),
+ ),
+ if (message.toolCalls != null && message.toolCalls!.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 8),
+ child: Wrap(
+ spacing: 4,
+ runSpacing: 4,
+ children: message.toolCalls!.map((toolCall) {
+ return Chip(
+ label: Text(
+ toolCall.functionName,
+ style: const TextStyle(fontSize: 11),
+ ),
+ padding: EdgeInsets.zero,
+ visualDensity: VisualDensity.compact,
+ );
+ }).toList(),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ formatTime(message.createdAt),
+ style: TextStyle(
+ fontSize: 11,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ if (isUser)
+ CircleAvatar(
+ radius: 16,
+ backgroundColor: colorScheme.primary,
+ child: Icon(
+ Icons.person,
+ size: 18,
+ color: colorScheme.onPrimary,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/chat_list_screen.dart b/mobile/lib/screens/chat_list_screen.dart
new file mode 100644
index 000000000..1039b3309
--- /dev/null
+++ b/mobile/lib/screens/chat_list_screen.dart
@@ -0,0 +1,298 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../providers/auth_provider.dart';
+import '../providers/chat_provider.dart';
+import 'chat_conversation_screen.dart';
+
+class ChatListScreen extends StatefulWidget {
+ const ChatListScreen({super.key});
+
+ @override
+ State createState() => _ChatListScreenState();
+}
+
+class _ChatListScreenState extends State {
+ @override
+ void initState() {
+ super.initState();
+ _loadChats();
+ }
+
+ Future _loadChats() async {
+ final authProvider = Provider.of(context, listen: false);
+ final chatProvider = Provider.of(context, listen: false);
+
+ final accessToken = await authProvider.getValidAccessToken();
+ if (accessToken == null) {
+ await authProvider.logout();
+ return;
+ }
+
+ await chatProvider.fetchChats(accessToken: accessToken);
+ }
+
+ Future _handleRefresh() async {
+ await _loadChats();
+ }
+
+ Future _createNewChat() async {
+ final authProvider = Provider.of(context, listen: false);
+ final chatProvider = Provider.of(context, listen: false);
+
+ final accessToken = await authProvider.getValidAccessToken();
+ if (accessToken == null) {
+ await authProvider.logout();
+ return;
+ }
+
+ // Show loading dialog
+ if (mounted) {
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (context) => const Center(
+ child: CircularProgressIndicator(),
+ ),
+ );
+ }
+
+ final chat = await chatProvider.createChat(
+ accessToken: accessToken,
+ title: 'New Chat',
+ );
+
+ // Close loading dialog
+ if (mounted) {
+ Navigator.pop(context);
+ }
+
+ if (chat != null && mounted) {
+ // Navigate to chat conversation
+ await Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => ChatConversationScreen(chatId: chat.id),
+ ),
+ );
+
+ // Refresh list after returning
+ _loadChats();
+ } else if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(chatProvider.errorMessage ?? 'Failed to create chat'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+
+ String _formatDateTime(DateTime dateTime) {
+ final now = DateTime.now();
+ final difference = now.difference(dateTime);
+
+ if (difference.inMinutes < 1) {
+ return 'Just now';
+ } else if (difference.inHours < 1) {
+ return '${difference.inMinutes}m ago';
+ } else if (difference.inDays < 1) {
+ return '${difference.inHours}h ago';
+ } else if (difference.inDays < 7) {
+ return '${difference.inDays}d ago';
+ } else {
+ return '${dateTime.day}/${dateTime.month}/${dateTime.year}';
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('AI Assistant'),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.refresh),
+ onPressed: _handleRefresh,
+ tooltip: 'Refresh',
+ ),
+ ],
+ ),
+ body: Consumer(
+ builder: (context, chatProvider, _) {
+ if (chatProvider.isLoading && chatProvider.chats.isEmpty) {
+ return const Center(
+ child: CircularProgressIndicator(),
+ );
+ }
+
+ if (chatProvider.errorMessage != null && chatProvider.chats.isEmpty) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.error_outline,
+ size: 64,
+ color: colorScheme.error,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Failed to load chats',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ chatProvider.errorMessage!,
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ ElevatedButton.icon(
+ onPressed: _handleRefresh,
+ icon: const Icon(Icons.refresh),
+ label: const Text('Try Again'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ if (chatProvider.chats.isEmpty) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.chat_bubble_outline,
+ size: 64,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'No chats yet',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Start a new conversation with the AI assistant.',
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ return RefreshIndicator(
+ onRefresh: _handleRefresh,
+ child: ListView.builder(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ itemCount: chatProvider.chats.length,
+ itemBuilder: (context, index) {
+ final chat = chatProvider.chats[index];
+ return Dismissible(
+ key: Key(chat.id),
+ direction: DismissDirection.endToStart,
+ background: Container(
+ color: Colors.red,
+ alignment: Alignment.centerRight,
+ padding: const EdgeInsets.only(right: 16),
+ child: const Icon(
+ Icons.delete,
+ color: Colors.white,
+ ),
+ ),
+ confirmDismiss: (direction) async {
+ return await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Delete Chat'),
+ content: Text('Are you sure you want to delete "${chat.title}"?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, true),
+ child: const Text('Delete', style: TextStyle(color: Colors.red)),
+ ),
+ ],
+ ),
+ );
+ },
+ onDismissed: (direction) async {
+ final authProvider = Provider.of(context, listen: false);
+ final accessToken = await authProvider.getValidAccessToken();
+ if (accessToken != null) {
+ await chatProvider.deleteChat(
+ accessToken: accessToken,
+ chatId: chat.id,
+ );
+ }
+ },
+ child: ListTile(
+ leading: CircleAvatar(
+ backgroundColor: colorScheme.primaryContainer,
+ child: Icon(
+ Icons.chat,
+ color: colorScheme.onPrimaryContainer,
+ ),
+ ),
+ title: Text(
+ chat.title,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ subtitle: chat.lastMessageAt != null
+ ? Text(_formatDateTime(chat.lastMessageAt!))
+ : null,
+ trailing: chat.messageCount != null
+ ? Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: colorScheme.secondaryContainer,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ '${chat.messageCount}',
+ style: TextStyle(
+ color: colorScheme.onSecondaryContainer,
+ fontSize: 12,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ )
+ : null,
+ onTap: () async {
+ await Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => ChatConversationScreen(chatId: chat.id),
+ ),
+ );
+ _loadChats();
+ },
+ ),
+ );
+ },
+ ),
+ );
+ },
+ ),
+ floatingActionButton: FloatingActionButton(
+ onPressed: _createNewChat,
+ tooltip: 'New Chat',
+ child: const Icon(Icons.add),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart
index ca2afd5fd..e62717c40 100644
--- a/mobile/lib/screens/dashboard_screen.dart
+++ b/mobile/lib/screens/dashboard_screen.dart
@@ -3,9 +3,13 @@ import 'package:provider/provider.dart';
import '../models/account.dart';
import '../providers/auth_provider.dart';
import '../providers/accounts_provider.dart';
+import '../providers/transactions_provider.dart';
+import '../services/log_service.dart';
import '../widgets/account_card.dart';
+import '../widgets/connectivity_banner.dart';
import 'transaction_form_screen.dart';
import 'transactions_list_screen.dart';
+import 'log_viewer_screen.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@@ -15,13 +19,58 @@ class DashboardScreen extends StatefulWidget {
}
class _DashboardScreenState extends State {
+ final LogService _log = LogService.instance;
bool _assetsExpanded = true;
bool _liabilitiesExpanded = true;
+ bool _showSyncSuccess = false;
+ int _previousPendingCount = 0;
+ TransactionsProvider? _transactionsProvider;
@override
void initState() {
super.initState();
_loadAccounts();
+
+ // Listen for sync completion to show success indicator
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ _transactionsProvider = Provider.of(context, listen: false);
+ _previousPendingCount = _transactionsProvider?.pendingCount ?? 0;
+ _transactionsProvider?.addListener(_onTransactionsChanged);
+ });
+ }
+
+ @override
+ void dispose() {
+ _transactionsProvider?.removeListener(_onTransactionsChanged);
+ super.dispose();
+ }
+
+ void _onTransactionsChanged() {
+ final transactionsProvider = _transactionsProvider;
+ if (transactionsProvider == null || !mounted) {
+ return;
+ }
+
+ final currentPendingCount = transactionsProvider.pendingCount;
+
+ // If pending count decreased, it means transactions were synced
+ if (_previousPendingCount > 0 && currentPendingCount < _previousPendingCount) {
+ setState(() {
+ _showSyncSuccess = true;
+ });
+
+ // Hide the success indicator after 3 seconds
+ Future.delayed(const Duration(seconds: 3), () {
+ if (mounted) {
+ setState(() {
+ _showSyncSuccess = false;
+ });
+ }
+ });
+ }
+
+ _previousPendingCount = currentPendingCount;
}
Future _loadAccounts() async {
@@ -44,7 +93,84 @@ class _DashboardScreenState extends State {
}
Future _handleRefresh() async {
- await _loadAccounts();
+ await _performManualSync();
+ }
+
+ Future _performManualSync() async {
+ final authProvider = Provider.of(context, listen: false);
+ final transactionsProvider = Provider.of(context, listen: false);
+
+ final accessToken = await authProvider.getValidAccessToken();
+ if (accessToken == null) {
+ await authProvider.logout();
+ return;
+ }
+
+ // Show syncing indicator
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Row(
+ children: [
+ SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ valueColor: AlwaysStoppedAnimation(Colors.white),
+ ),
+ ),
+ SizedBox(width: 12),
+ Text('Syncing data from server...'),
+ ],
+ ),
+ duration: Duration(seconds: 30),
+ ),
+ );
+ }
+
+ try {
+ // Perform full sync: upload pending, download from server, sync accounts
+ await transactionsProvider.syncTransactions(accessToken: accessToken);
+
+ // Reload accounts to show updated balances
+ await _loadAccounts();
+
+ if (mounted) {
+ ScaffoldMessenger.of(context).clearSnackBars();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Row(
+ children: [
+ Icon(Icons.check_circle, color: Colors.white),
+ SizedBox(width: 12),
+ Text('Sync completed successfully'),
+ ],
+ ),
+ backgroundColor: Colors.green,
+ duration: Duration(seconds: 2),
+ ),
+ );
+ }
+ } catch (e) {
+ _log.error('DashboardScreen', 'Error in _performManualSync: $e');
+ if (mounted) {
+ ScaffoldMessenger.of(context).clearSnackBars();
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Row(
+ children: [
+ Icon(Icons.error, color: Colors.white),
+ SizedBox(width: 12),
+ Expanded(child: Text('Sync failed. Please try again.')),
+ ],
+ ),
+ backgroundColor: Colors.red,
+ duration: Duration(seconds: 3),
+ ),
+ );
+ }
+ }
}
List _formatCurrencyItem(String currency, double amount) {
@@ -193,6 +319,33 @@ class _DashboardScreenState extends State {
appBar: AppBar(
title: const Text('Dashboard'),
actions: [
+ if (_showSyncSuccess)
+ Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: AnimatedOpacity(
+ opacity: _showSyncSuccess ? 1.0 : 0.0,
+ duration: const Duration(milliseconds: 300),
+ child: const Icon(
+ Icons.cloud_done,
+ color: Colors.green,
+ size: 28,
+ ),
+ ),
+ ),
+ Semantics(
+ label: 'Open debug logs',
+ button: true,
+ child: IconButton(
+ icon: const Icon(Icons.bug_report),
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(builder: (context) => const LogViewerScreen()),
+ );
+ },
+ tooltip: 'Debug Logs',
+ ),
+ ),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _handleRefresh,
@@ -205,14 +358,18 @@ class _DashboardScreenState extends State {
),
],
),
- body: Consumer2(
- builder: (context, authProvider, accountsProvider, _) {
- // Show loading state during initialization or when loading
- if (accountsProvider.isInitializing || accountsProvider.isLoading) {
- return const Center(
- child: CircularProgressIndicator(),
- );
- }
+ body: Column(
+ children: [
+ const ConnectivityBanner(),
+ Expanded(
+ child: Consumer2(
+ builder: (context, authProvider, accountsProvider, _) {
+ // Show loading state during initialization or when loading
+ if (accountsProvider.isInitializing || accountsProvider.isLoading) {
+ return const Center(
+ child: CircularProgressIndicator(),
+ );
+ }
// Show error state
if (accountsProvider.errorMessage != null &&
@@ -418,7 +575,10 @@ class _DashboardScreenState extends State {
],
),
);
- },
+ },
+ ),
+ ),
+ ],
),
);
}
diff --git a/mobile/lib/screens/log_viewer_screen.dart b/mobile/lib/screens/log_viewer_screen.dart
new file mode 100644
index 000000000..cbd162793
--- /dev/null
+++ b/mobile/lib/screens/log_viewer_screen.dart
@@ -0,0 +1,229 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:provider/provider.dart';
+import '../services/log_service.dart';
+
+class LogViewerScreen extends StatefulWidget {
+ const LogViewerScreen({super.key});
+
+ @override
+ State createState() => _LogViewerScreenState();
+}
+
+class _LogViewerScreenState extends State {
+ String _selectedLevel = 'ALL';
+ final ScrollController _scrollController = ScrollController();
+ bool _autoScroll = true;
+
+ @override
+ void initState() {
+ super.initState();
+ // Set log viewer as active to enable notifications
+ LogService.instance.setLogViewerActive(true);
+ // Add a test log to confirm logging is working
+ LogService.instance.info('LogViewer', 'Log viewer screen opened');
+ }
+
+ @override
+ void dispose() {
+ // Set log viewer as inactive to disable notifications
+ LogService.instance.setLogViewerActive(false);
+ _scrollController.dispose();
+ super.dispose();
+ }
+
+ Color _getLevelColor(String level) {
+ switch (level) {
+ case 'ERROR':
+ return Colors.red;
+ case 'WARNING':
+ return Colors.orange;
+ case 'INFO':
+ return Colors.blue;
+ case 'DEBUG':
+ return Colors.grey;
+ default:
+ return Colors.black;
+ }
+ }
+
+ IconData _getLevelIcon(String level) {
+ switch (level) {
+ case 'ERROR':
+ return Icons.error;
+ case 'WARNING':
+ return Icons.warning;
+ case 'INFO':
+ return Icons.info;
+ case 'DEBUG':
+ return Icons.bug_report;
+ default:
+ return Icons.text_snippet;
+ }
+ }
+
+ void _scrollToBottom() {
+ if (_autoScroll && _scrollController.hasClients) {
+ _scrollController.animateTo(
+ _scrollController.position.maxScrollExtent,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeOut,
+ );
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Debug Logs'),
+ actions: [
+ PopupMenuButton(
+ initialValue: _selectedLevel,
+ onSelected: (value) {
+ setState(() {
+ _selectedLevel = value;
+ });
+ },
+ itemBuilder: (context) => [
+ const PopupMenuItem(value: 'ALL', child: Text('All Levels')),
+ const PopupMenuItem(value: 'ERROR', child: Text('Errors Only')),
+ const PopupMenuItem(value: 'WARNING', child: Text('Warnings Only')),
+ const PopupMenuItem(value: 'INFO', child: Text('Info Only')),
+ const PopupMenuItem(value: 'DEBUG', child: Text('Debug Only')),
+ ],
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Row(
+ children: [
+ const Icon(Icons.filter_list),
+ const SizedBox(width: 4),
+ Text(_selectedLevel),
+ ],
+ ),
+ ),
+ ),
+ IconButton(
+ icon: Icon(_autoScroll ? Icons.lock_open : Icons.lock),
+ onPressed: () {
+ setState(() {
+ _autoScroll = !_autoScroll;
+ });
+ },
+ tooltip: _autoScroll ? 'Disable Auto-scroll' : 'Enable Auto-scroll',
+ ),
+ IconButton(
+ icon: const Icon(Icons.copy),
+ onPressed: () {
+ final logs = LogService.instance.exportLogs();
+ Clipboard.setData(ClipboardData(text: logs));
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Logs copied to clipboard'),
+ duration: Duration(seconds: 2),
+ ),
+ );
+ },
+ tooltip: 'Copy Logs',
+ ),
+ IconButton(
+ icon: const Icon(Icons.delete),
+ onPressed: () {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Clear Logs'),
+ content: const Text('Are you sure you want to clear all logs?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () {
+ LogService.instance.clear();
+ Navigator.pop(context);
+ },
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
+ child: const Text('Clear'),
+ ),
+ ],
+ ),
+ );
+ },
+ tooltip: 'Clear Logs',
+ ),
+ ],
+ ),
+ body: Consumer(
+ builder: (context, logService, child) {
+ final logs = _selectedLevel == 'ALL'
+ ? logService.logs
+ : logService.logs.where((log) => log.level == _selectedLevel).toList();
+
+ WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
+
+ if (logs.isEmpty) {
+ return const Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.text_snippet_outlined, size: 64, color: Colors.grey),
+ SizedBox(height: 16),
+ Text(
+ 'No logs yet',
+ style: TextStyle(fontSize: 16, color: Colors.grey),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return ListView.builder(
+ controller: _scrollController,
+ itemCount: logs.length,
+ itemBuilder: (context, index) {
+ final log = logs[index];
+ final color = _getLevelColor(log.level);
+
+ return Container(
+ decoration: BoxDecoration(
+ border: Border(
+ bottom: BorderSide(
+ color: Colors.grey.withValues(alpha: 0.2),
+ width: 1,
+ ),
+ ),
+ ),
+ child: ListTile(
+ dense: true,
+ leading: Icon(
+ _getLevelIcon(log.level),
+ color: color,
+ size: 20,
+ ),
+ title: Text(
+ '[${log.tag}] ${log.message}',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 12,
+ color: color,
+ ),
+ ),
+ subtitle: Text(
+ log.formattedTime,
+ style: const TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 10,
+ color: Colors.grey,
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ },
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/main_navigation_screen.dart b/mobile/lib/screens/main_navigation_screen.dart
new file mode 100644
index 000000000..a253bf614
--- /dev/null
+++ b/mobile/lib/screens/main_navigation_screen.dart
@@ -0,0 +1,63 @@
+import 'package:flutter/material.dart';
+import 'dashboard_screen.dart';
+import 'chat_list_screen.dart';
+import 'more_screen.dart';
+import 'settings_screen.dart';
+
+class MainNavigationScreen extends StatefulWidget {
+ const MainNavigationScreen({super.key});
+
+ @override
+ State createState() => _MainNavigationScreenState();
+}
+
+class _MainNavigationScreenState extends State {
+ int _currentIndex = 0;
+
+ final List _screens = [
+ const DashboardScreen(),
+ const ChatListScreen(),
+ const MoreScreen(),
+ const SettingsScreen(),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: IndexedStack(
+ index: _currentIndex,
+ children: _screens,
+ ),
+ bottomNavigationBar: NavigationBar(
+ selectedIndex: _currentIndex,
+ onDestinationSelected: (index) {
+ setState(() {
+ _currentIndex = index;
+ });
+ },
+ destinations: const [
+ NavigationDestination(
+ icon: Icon(Icons.home_outlined),
+ selectedIcon: Icon(Icons.home),
+ label: 'Home',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.chat_bubble_outline),
+ selectedIcon: Icon(Icons.chat_bubble),
+ label: 'AI Chat',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.more_horiz),
+ selectedIcon: Icon(Icons.more_horiz),
+ label: 'More',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.settings_outlined),
+ selectedIcon: Icon(Icons.settings),
+ label: 'Settings',
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/more_screen.dart b/mobile/lib/screens/more_screen.dart
new file mode 100644
index 000000000..80cf4ba4a
--- /dev/null
+++ b/mobile/lib/screens/more_screen.dart
@@ -0,0 +1,88 @@
+import 'package:flutter/material.dart';
+import 'calendar_screen.dart';
+import 'recent_transactions_screen.dart';
+
+class MoreScreen extends StatelessWidget {
+ const MoreScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('More'),
+ ),
+ body: ListView(
+ children: [
+ _buildMenuItem(
+ context: context,
+ icon: Icons.calendar_month,
+ title: 'Account Calendar',
+ subtitle: 'View monthly balance changes by account',
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const CalendarScreen(),
+ ),
+ );
+ },
+ ),
+ Divider(height: 1, color: colorScheme.outlineVariant),
+ _buildMenuItem(
+ context: context,
+ icon: Icons.receipt_long,
+ title: 'Recent Transactions',
+ subtitle: 'View recent transactions across all accounts',
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const RecentTransactionsScreen(),
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildMenuItem({
+ required BuildContext context,
+ required IconData icon,
+ required String title,
+ required String subtitle,
+ required VoidCallback onTap,
+ }) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return ListTile(
+ leading: Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Icon(
+ icon,
+ color: colorScheme.onPrimaryContainer,
+ ),
+ ),
+ title: Text(
+ title,
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ subtitle: Text(
+ subtitle,
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ ),
+ trailing: Icon(
+ Icons.chevron_right,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ onTap: onTap,
+ );
+ }
+}
diff --git a/mobile/lib/screens/recent_transactions_screen.dart b/mobile/lib/screens/recent_transactions_screen.dart
new file mode 100644
index 000000000..8decc3071
--- /dev/null
+++ b/mobile/lib/screens/recent_transactions_screen.dart
@@ -0,0 +1,292 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:intl/intl.dart';
+import '../models/transaction.dart';
+import '../models/account.dart';
+import '../providers/transactions_provider.dart';
+import '../providers/accounts_provider.dart';
+import '../providers/auth_provider.dart';
+
+class RecentTransactionsScreen extends StatefulWidget {
+ const RecentTransactionsScreen({super.key});
+
+ @override
+ State createState() => _RecentTransactionsScreenState();
+}
+
+class _RecentTransactionsScreenState extends State {
+ int _transactionLimit = 20;
+ final List _limitOptions = [10, 20, 50, 100];
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _loadAllTransactions();
+ });
+ }
+
+ Future _loadAllTransactions() async {
+ final authProvider = context.read();
+ final transactionsProvider = context.read();
+
+ final accessToken = await authProvider.getValidAccessToken();
+
+ if (accessToken != null) {
+ // Load transactions for all accounts
+ await transactionsProvider.fetchTransactions(
+ accessToken: accessToken,
+ forceSync: false,
+ );
+ }
+ }
+
+ Future _refreshTransactions() async {
+ final authProvider = context.read();
+ final transactionsProvider = context.read();
+
+ final accessToken = await authProvider.getValidAccessToken();
+
+ if (accessToken != null) {
+ await transactionsProvider.fetchTransactions(
+ accessToken: accessToken,
+ forceSync: true,
+ );
+ }
+ }
+
+ Account? _getAccount(String accountId) {
+ final accountsProvider = context.read();
+ try {
+ return accountsProvider.accounts.firstWhere(
+ (a) => a.id == accountId,
+ );
+ } catch (e) {
+ return null;
+ }
+ }
+
+ List _getSortedTransactions(List transactions) {
+ final sorted = List.from(transactions);
+ sorted.sort((a, b) {
+ try {
+ final dateA = DateTime.parse(a.date);
+ final dateB = DateTime.parse(b.date);
+ return dateB.compareTo(dateA); // Most recent first
+ } catch (e) {
+ return 0;
+ }
+ });
+ return sorted.take(_transactionLimit).toList();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final transactionsProvider = context.watch();
+
+ final recentTransactions = _getSortedTransactions(
+ transactionsProvider.transactions,
+ );
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Recent Transactions'),
+ actions: [
+ PopupMenuButton(
+ initialValue: _transactionLimit,
+ icon: const Icon(Icons.filter_list),
+ tooltip: 'Display Limit',
+ onSelected: (int value) {
+ setState(() {
+ _transactionLimit = value;
+ });
+ },
+ itemBuilder: (context) => _limitOptions.map((limit) {
+ return PopupMenuItem(
+ value: limit,
+ child: Row(
+ children: [
+ if (limit == _transactionLimit)
+ Icon(Icons.check, color: colorScheme.primary, size: 20)
+ else
+ const SizedBox(width: 20),
+ const SizedBox(width: 8),
+ Text('Show $limit'),
+ ],
+ ),
+ );
+ }).toList(),
+ ),
+ ],
+ ),
+ body: RefreshIndicator(
+ onRefresh: _refreshTransactions,
+ child: transactionsProvider.isLoading
+ ? const Center(child: CircularProgressIndicator())
+ : recentTransactions.isEmpty
+ ? _buildEmptyState(colorScheme)
+ : ListView.separated(
+ itemCount: recentTransactions.length,
+ separatorBuilder: (context, index) => Divider(
+ height: 1,
+ color: colorScheme.outlineVariant,
+ ),
+ itemBuilder: (context, index) {
+ final transaction = recentTransactions[index];
+ return _buildTransactionItem(
+ transaction,
+ colorScheme,
+ );
+ },
+ ),
+ ),
+ );
+ }
+
+ Widget _buildEmptyState(ColorScheme colorScheme) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.receipt_long_outlined,
+ size: 64,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'No Transactions',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Pull to refresh',
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildTransactionItem(Transaction transaction, ColorScheme colorScheme) {
+ final account = _getAccount(transaction.accountId);
+ final accountName = account?.name ?? 'Unknown Account';
+
+ // Parse amount with proper sign handling (same logic as transactions_list_screen.dart)
+ String trimmedAmount = transaction.amount.trim();
+ trimmedAmount = trimmedAmount.replaceAll('\u2212', '-'); // Normalize minus sign
+
+ // Detect if the amount has a negative sign
+ bool hasNegativeSign = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-');
+
+ // Remove all non-numeric characters except decimal point and minus sign
+ String numericString = trimmedAmount.replaceAll(RegExp(r'[^\d.\-]'), '');
+
+ // Parse the numeric value
+ double amount = double.tryParse(numericString.replaceAll('-', '')) ?? 0.0;
+
+ // Apply the sign from the string
+ if (hasNegativeSign) {
+ amount = -amount;
+ }
+
+ // For asset accounts and liability accounts, flip the sign to match accounting conventions
+ if (account?.isAsset == true || account?.isLiability == true) {
+ amount = -amount;
+ }
+
+ // Determine display properties based on final amount
+ final isPositive = amount >= 0;
+ Color amountColor;
+ String sign;
+
+ if (isPositive) {
+ amountColor = Colors.green.shade700;
+ sign = '+';
+ } else {
+ amountColor = Colors.red.shade700;
+ sign = '-';
+ }
+
+ String formattedDate;
+ try {
+ final date = DateTime.parse(transaction.date);
+ formattedDate = DateFormat('yyyy-MM-dd HH:mm').format(date);
+ } catch (e) {
+ formattedDate = transaction.date;
+ }
+
+ return ListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ leading: Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: isPositive
+ ? Colors.green.withValues(alpha: 0.1)
+ : Colors.red.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Icon(
+ isPositive ? Icons.arrow_upward : Icons.arrow_downward,
+ color: amountColor,
+ ),
+ ),
+ title: Text(
+ transaction.name,
+ style: const TextStyle(fontWeight: FontWeight.w500),
+ ),
+ subtitle: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SizedBox(height: 4),
+ Text(
+ accountName,
+ style: TextStyle(
+ fontSize: 12,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ formattedDate,
+ style: TextStyle(
+ fontSize: 11,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ if (transaction.notes != null && transaction.notes!.isNotEmpty) ...[
+ const SizedBox(height: 2),
+ Text(
+ transaction.notes!,
+ style: TextStyle(
+ fontSize: 11,
+ color: colorScheme.onSurfaceVariant,
+ fontStyle: FontStyle.italic,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ],
+ ),
+ trailing: Text(
+ '$sign${transaction.currency} ${_formatAmount(amount.abs())}',
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 16,
+ color: amountColor,
+ ),
+ ),
+ );
+ }
+
+ String _formatAmount(double amount) {
+ // Support up to 8 decimal places, but omit unnecessary trailing zeros
+ final formatter = NumberFormat('#,##0.########');
+ return formatter.format(amount);
+ }
+}
diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart
new file mode 100644
index 000000000..0f5518c8c
--- /dev/null
+++ b/mobile/lib/screens/settings_screen.dart
@@ -0,0 +1,208 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../providers/auth_provider.dart';
+import '../services/offline_storage_service.dart';
+import '../services/log_service.dart';
+
+class SettingsScreen extends StatelessWidget {
+ const SettingsScreen({super.key});
+
+ Future _handleClearLocalData(BuildContext context) async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Clear Local Data'),
+ content: const Text(
+ 'This will delete all locally cached transactions and accounts. '
+ 'Your data on the server will not be affected. '
+ 'Are you sure you want to continue?'
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, true),
+ style: TextButton.styleFrom(
+ foregroundColor: Theme.of(context).colorScheme.error,
+ ),
+ child: const Text('Clear Data'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed == true && context.mounted) {
+ try {
+ final offlineStorage = OfflineStorageService();
+ final log = LogService.instance;
+
+ log.info('Settings', 'Clearing all local data...');
+ await offlineStorage.clearAllData();
+ log.info('Settings', 'Local data cleared successfully');
+
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Local data cleared successfully. Pull to refresh to sync from server.'),
+ backgroundColor: Colors.green,
+ duration: Duration(seconds: 3),
+ ),
+ );
+ }
+ } catch (e) {
+ final log = LogService.instance;
+ log.error('Settings', 'Failed to clear local data: $e');
+
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Failed to clear local data: $e'),
+ backgroundColor: Colors.red,
+ duration: const Duration(seconds: 3),
+ ),
+ );
+ }
+ }
+ }
+ }
+
+ Future _handleLogout(BuildContext context) async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Sign Out'),
+ content: const Text('Are you sure you want to sign out?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, true),
+ child: const Text('Sign Out'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed == true && context.mounted) {
+ final authProvider = Provider.of(context, listen: false);
+ await authProvider.logout();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final authProvider = Provider.of(context);
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Settings'),
+ ),
+ body: ListView(
+ children: [
+ // User info section
+ Container(
+ padding: const EdgeInsets.all(16),
+ child: Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ CircleAvatar(
+ radius: 30,
+ backgroundColor: colorScheme.primary,
+ child: Text(
+ authProvider.user?.displayName[0].toUpperCase() ?? 'U',
+ style: TextStyle(
+ fontSize: 24,
+ color: colorScheme.onPrimary,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ authProvider.user?.displayName ?? 'User',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ authProvider.user?.email ?? '',
+ style: TextStyle(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+
+ // App version
+ const ListTile(
+ leading: Icon(Icons.info_outline),
+ title: Text('App Version'),
+ subtitle: Text('1.0.0'),
+ ),
+
+ const Divider(),
+
+ // Data Management Section
+ const Padding(
+ padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
+ child: Text(
+ 'Data Management',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.bold,
+ color: Colors.grey,
+ ),
+ ),
+ ),
+
+ // Clear local data button
+ ListTile(
+ leading: const Icon(Icons.delete_outline),
+ title: const Text('Clear Local Data'),
+ subtitle: const Text('Remove all cached transactions and accounts'),
+ onTap: () => _handleClearLocalData(context),
+ ),
+
+ const Divider(),
+
+ // Sign out button
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: ElevatedButton.icon(
+ onPressed: () => _handleLogout(context),
+ icon: const Icon(Icons.logout),
+ label: const Text('Sign Out'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: colorScheme.error,
+ foregroundColor: colorScheme.onError,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/screens/transaction_form_screen.dart b/mobile/lib/screens/transaction_form_screen.dart
index a0d5c7492..cc4743080 100644
--- a/mobile/lib/screens/transaction_form_screen.dart
+++ b/mobile/lib/screens/transaction_form_screen.dart
@@ -3,7 +3,9 @@ import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../models/account.dart';
import '../providers/auth_provider.dart';
-import '../services/transactions_service.dart';
+import '../providers/transactions_provider.dart';
+import '../services/log_service.dart';
+import '../services/connectivity_service.dart';
class TransactionFormScreen extends StatefulWidget {
final Account account;
@@ -22,7 +24,7 @@ class _TransactionFormScreenState extends State {
final _amountController = TextEditingController();
final _dateController = TextEditingController();
final _nameController = TextEditingController();
- final _transactionsService = TransactionsService();
+ final _log = LogService.instance;
String _nature = 'expense';
bool _showMoreFields = false;
@@ -87,11 +89,15 @@ class _TransactionFormScreenState extends State {
_isSubmitting = true;
});
+ _log.info('TransactionForm', 'Starting transaction creation...');
+
try {
final authProvider = Provider.of(context, listen: false);
+ final transactionsProvider = Provider.of(context, listen: false);
final accessToken = await authProvider.getValidAccessToken();
if (accessToken == null) {
+ _log.warning('TransactionForm', 'Access token is null, session expired');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -108,7 +114,10 @@ class _TransactionFormScreenState extends State {
final parsedDate = DateFormat('yyyy/MM/dd').parse(_dateController.text);
final apiDate = DateFormat('yyyy-MM-dd').format(parsedDate);
- final result = await _transactionsService.createTransaction(
+ _log.info('TransactionForm', 'Calling TransactionsProvider.createTransaction (offline-first)');
+
+ // Use TransactionsProvider for offline-first transaction creation
+ final success = await transactionsProvider.createTransaction(
accessToken: accessToken,
accountId: widget.account.id,
name: _nameController.text.trim(),
@@ -120,29 +129,36 @@ class _TransactionFormScreenState extends State {
);
if (mounted) {
- if (result['success'] == true) {
+ if (success) {
+ _log.info('TransactionForm', 'Transaction created successfully (saved locally)');
+
+ // Check current connectivity status to show appropriate message
+ final connectivityService = Provider.of(context, listen: false);
+ final isOnline = connectivityService.isOnline;
+
ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(
- content: Text('Transaction created successfully'),
+ SnackBar(
+ content: Text(
+ isOnline
+ ? 'Transaction created successfully'
+ : 'Transaction saved (will sync when online)'
+ ),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, true); // Return true to indicate success
} else {
- final error = result['error'] ?? 'Failed to create transaction';
+ _log.error('TransactionForm', 'Failed to create transaction');
ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(error),
+ const SnackBar(
+ content: Text('Failed to create transaction'),
backgroundColor: Colors.red,
),
);
-
- if (error == 'unauthorized') {
- await authProvider.logout();
- }
}
}
} catch (e) {
+ _log.error('TransactionForm', 'Exception during transaction creation: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart
index 4f62675ad..df5bced82 100644
--- a/mobile/lib/screens/transactions_list_screen.dart
+++ b/mobile/lib/screens/transactions_list_screen.dart
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/account.dart';
import '../models/transaction.dart';
+import '../models/offline_transaction.dart';
import '../providers/auth_provider.dart';
import '../providers/transactions_provider.dart';
import '../screens/transaction_form_screen.dart';
+import '../widgets/sync_status_badge.dart';
+import '../services/log_service.dart';
class TransactionsListScreen extends StatefulWidget {
final Account account;
@@ -77,7 +80,7 @@ class _TransactionsListScreenState extends State {
};
} catch (e) {
// Fallback if parsing fails - log and return neutral state
- debugPrint('Failed to parse amount "$amount": $e');
+ LogService.instance.error('TransactionsListScreen', 'Failed to parse amount "$amount": $e');
return {
'isPositive': true,
'displayAmount': amount,
@@ -188,10 +191,64 @@ class _TransactionsListScreenState extends State {
}
}
+ Future _undoTransaction(OfflineTransaction transaction) async {
+ final transactionsProvider = Provider.of(context, listen: false);
+ final scaffoldMessenger = ScaffoldMessenger.of(context);
+
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Undo Transaction'),
+ content: Text(
+ transaction.syncStatus == SyncStatus.pending
+ ? 'Remove this pending transaction?'
+ : 'Restore this transaction?',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, true),
+ child: const Text('Undo'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed != true) return;
+
+ final success = await transactionsProvider.undoPendingTransaction(
+ localId: transaction.localId,
+ syncStatus: transaction.syncStatus,
+ );
+
+ if (mounted) {
+ scaffoldMessenger.showSnackBar(
+ SnackBar(
+ content: Text(
+ success
+ ? (transaction.syncStatus == SyncStatus.pending
+ ? 'Pending transaction removed'
+ : 'Transaction restored')
+ : 'Failed to undo transaction',
+ ),
+ backgroundColor: success ? Colors.green : Colors.red,
+ ),
+ );
+ }
+ }
+
Future _confirmAndDeleteTransaction(Transaction transaction) async {
if (transaction.id == null) return false;
// Show confirmation dialog
+ // Capture providers before async gap
+ final scaffoldMessenger = ScaffoldMessenger.of(context);
+ final authProvider = Provider.of(context, listen: false);
+ final transactionsProvider = Provider.of(context, listen: false);
+
final confirmed = await showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -214,9 +271,6 @@ class _TransactionsListScreenState extends State {
if (confirmed != true) return false;
// Perform the deletion
- final scaffoldMessenger = ScaffoldMessenger.of(context);
- final authProvider = Provider.of(context, listen: false);
- final transactionsProvider = Provider.of(context, listen: false);
final accessToken = await authProvider.getValidAccessToken();
if (accessToken == null) {
@@ -314,7 +368,7 @@ class _TransactionsListScreenState extends State {
);
}
- final transactions = transactionsProvider.transactions;
+ final transactions = transactionsProvider.offlineTransactions;
if (transactions.isEmpty) {
return RefreshIndicator(
@@ -365,6 +419,11 @@ class _TransactionsListScreenState extends State {
final transaction = transactions[index];
final isSelected = transaction.id != null &&
_selectedTransactions.contains(transaction.id);
+ final isPending = transaction.syncStatus == SyncStatus.pending;
+ final isPendingDelete = transaction.syncStatus == SyncStatus.pendingDelete;
+ final isFailed = transaction.syncStatus == SyncStatus.failed;
+ final hasPendingStatus = isPending || isPendingDelete;
+
// Compute display info once to avoid duplicate parsing
final displayInfo = _getAmountDisplayInfo(
transaction.amount,
@@ -386,17 +445,19 @@ class _TransactionsListScreenState extends State {
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (direction) => _confirmAndDeleteTransaction(transaction),
- child: Card(
- margin: const EdgeInsets.only(bottom: 12),
- child: InkWell(
- onTap: _isSelectionMode && transaction.id != null
- ? () => _toggleTransactionSelection(transaction.id!)
- : null,
- borderRadius: BorderRadius.circular(12),
- child: Padding(
- padding: const EdgeInsets.all(16),
- child: Row(
- children: [
+ child: Opacity(
+ opacity: hasPendingStatus ? 0.5 : 1.0,
+ child: Card(
+ margin: const EdgeInsets.only(bottom: 12),
+ child: InkWell(
+ onTap: _isSelectionMode && transaction.id != null
+ ? () => _toggleTransactionSelection(transaction.id!)
+ : null,
+ borderRadius: BorderRadius.circular(12),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
if (_isSelectionMode)
Padding(
padding: const EdgeInsets.only(right: 12),
@@ -443,13 +504,51 @@ class _TransactionsListScreenState extends State {
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
- Text(
- '${displayInfo['prefix']}${displayInfo['displayAmount']}',
- style: Theme.of(context).textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.bold,
- color: displayInfo['color'] as Color,
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (hasPendingStatus || isFailed)
+ Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: SyncStatusBadge(
+ syncStatus: transaction.syncStatus,
+ compact: true,
+ ),
),
+ Text(
+ '${displayInfo['prefix']}${displayInfo['displayAmount']}',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: displayInfo['color'] as Color,
+ ),
+ ),
+ ],
),
+ if (hasPendingStatus) ...[
+ const SizedBox(height: 4),
+ InkWell(
+ onTap: () => _undoTransaction(transaction),
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+ decoration: BoxDecoration(
+ color: Colors.blue.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: Colors.blue.withValues(alpha: 0.3),
+ width: 1,
+ ),
+ ),
+ child: const Text(
+ 'Undo',
+ style: TextStyle(
+ color: Colors.blue,
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ),
+ ],
const SizedBox(height: 4),
Text(
transaction.currency,
@@ -461,6 +560,7 @@ class _TransactionsListScreenState extends State {
),
],
),
+ ),
),
),
),
diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart
index 07d94de8c..789030abe 100644
--- a/mobile/lib/services/auth_service.dart
+++ b/mobile/lib/services/auth_service.dart
@@ -1,12 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
-import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../models/auth_tokens.dart';
import '../models/user.dart';
import 'api_config.dart';
+import 'log_service.dart';
class AuthService {
final FlutterSecureStorage _storage = const FlutterSecureStorage();
@@ -41,8 +41,8 @@ class AuthService {
body: jsonEncode(body),
).timeout(const Duration(seconds: 30));
- debugPrint('Login response status: ${response.statusCode}');
- debugPrint('Login response body: ${response.body}');
+ LogService.instance.debug('AuthService', 'Login response status: ${response.statusCode}');
+ LogService.instance.debug('AuthService', 'Login response body: ${response.body}');
final responseData = jsonDecode(response.body);
@@ -76,37 +76,37 @@ class AuthService {
};
}
} on SocketException catch (e, stackTrace) {
- debugPrint('Login SocketException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Login SocketException: $e\n$stackTrace');
return {
'success': false,
'error': 'Network unavailable',
};
} on TimeoutException catch (e, stackTrace) {
- debugPrint('Login TimeoutException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Login TimeoutException: $e\n$stackTrace');
return {
'success': false,
'error': 'Request timed out',
};
} on HttpException catch (e, stackTrace) {
- debugPrint('Login HttpException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Login HttpException: $e\n$stackTrace');
return {
'success': false,
'error': 'Invalid response from server',
};
} on FormatException catch (e, stackTrace) {
- debugPrint('Login FormatException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Login FormatException: $e\n$stackTrace');
return {
'success': false,
'error': 'Invalid response from server',
};
} on TypeError catch (e, stackTrace) {
- debugPrint('Login TypeError: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Login TypeError: $e\n$stackTrace');
return {
'success': false,
'error': 'Invalid response from server',
};
} catch (e, stackTrace) {
- debugPrint('Login unexpected error: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Login unexpected error: $e\n$stackTrace');
return {
'success': false,
'error': 'An unexpected error occurred',
@@ -174,37 +174,37 @@ class AuthService {
};
}
} on SocketException catch (e, stackTrace) {
- debugPrint('Signup SocketException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Signup SocketException: $e\n$stackTrace');
return {
'success': false,
'error': 'Network unavailable',
};
} on TimeoutException catch (e, stackTrace) {
- debugPrint('Signup TimeoutException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Signup TimeoutException: $e\n$stackTrace');
return {
'success': false,
'error': 'Request timed out',
};
} on HttpException catch (e, stackTrace) {
- debugPrint('Signup HttpException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Signup HttpException: $e\n$stackTrace');
return {
'success': false,
'error': 'Invalid response from server',
};
} on FormatException catch (e, stackTrace) {
- debugPrint('Signup FormatException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Signup FormatException: $e\n$stackTrace');
return {
'success': false,
'error': 'Invalid response from server',
};
} on TypeError catch (e, stackTrace) {
- debugPrint('Signup TypeError: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Signup TypeError: $e\n$stackTrace');
return {
'success': false,
'error': 'Invalid response from server',
};
} catch (e, stackTrace) {
- debugPrint('Signup unexpected error: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'Signup unexpected error: $e\n$stackTrace');
return {
'success': false,
'error': 'An unexpected error occurred',
@@ -248,37 +248,37 @@ class AuthService {
};
}
} on SocketException catch (e, stackTrace) {
- debugPrint('RefreshToken SocketException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'RefreshToken SocketException: $e\n$stackTrace');
return {
'success': false,
'error': 'Network unavailable',
};
} on TimeoutException catch (e, stackTrace) {
- debugPrint('RefreshToken TimeoutException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'RefreshToken TimeoutException: $e\n$stackTrace');
return {
'success': false,
'error': 'Request timed out',
};
} on HttpException catch (e, stackTrace) {
- debugPrint('RefreshToken HttpException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'RefreshToken HttpException: $e\n$stackTrace');
return {
'success': false,
'error': 'Invalid response from server',
};
} on FormatException catch (e, stackTrace) {
- debugPrint('RefreshToken FormatException: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'RefreshToken FormatException: $e\n$stackTrace');
return {
'success': false,
'error': 'Invalid response from server',
};
} on TypeError catch (e, stackTrace) {
- debugPrint('RefreshToken TypeError: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'RefreshToken TypeError: $e\n$stackTrace');
return {
'success': false,
'error': 'Invalid response from server',
};
} catch (e, stackTrace) {
- debugPrint('RefreshToken unexpected error: $e\n$stackTrace');
+ LogService.instance.error('AuthService', 'RefreshToken unexpected error: $e\n$stackTrace');
return {
'success': false,
'error': 'An unexpected error occurred',
diff --git a/mobile/lib/services/chat_service.dart b/mobile/lib/services/chat_service.dart
new file mode 100644
index 000000000..bb2366c15
--- /dev/null
+++ b/mobile/lib/services/chat_service.dart
@@ -0,0 +1,395 @@
+import 'dart:convert';
+import 'package:http/http.dart' as http;
+import '../models/chat.dart';
+import '../models/message.dart';
+import 'api_config.dart';
+
+class ChatService {
+ /// Get list of chats with pagination
+ Future> getChats({
+ required String accessToken,
+ int page = 1,
+ int perPage = 25,
+ }) async {
+ try {
+ final url = Uri.parse(
+ '${ApiConfig.baseUrl}/api/v1/chats?page=$page&per_page=$perPage',
+ );
+
+ final response = await http.get(
+ url,
+ headers: {
+ 'Authorization': 'Bearer $accessToken',
+ 'Accept': 'application/json',
+ },
+ ).timeout(const Duration(seconds: 30));
+
+ if (response.statusCode == 200) {
+ final responseData = jsonDecode(response.body);
+
+ final chatsList = (responseData['chats'] as List)
+ .map((json) => Chat.fromJson(json))
+ .toList();
+
+ return {
+ 'success': true,
+ 'chats': chatsList,
+ 'pagination': responseData['pagination'],
+ };
+ } else if (response.statusCode == 401) {
+ return {
+ 'success': false,
+ 'error': 'unauthorized',
+ 'message': 'Session expired. Please login again.',
+ };
+ } else if (response.statusCode == 403) {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': 'feature_disabled',
+ 'message': responseData['message'] ?? 'AI features not enabled',
+ };
+ } else {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? 'Failed to fetch chats',
+ };
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+
+ /// Get a specific chat with messages
+ Future> getChat({
+ required String accessToken,
+ required String chatId,
+ int page = 1,
+ int perPage = 50,
+ }) async {
+ try {
+ final url = Uri.parse(
+ '${ApiConfig.baseUrl}/api/v1/chats/$chatId?page=$page&per_page=$perPage',
+ );
+
+ final response = await http.get(
+ url,
+ headers: {
+ 'Authorization': 'Bearer $accessToken',
+ 'Accept': 'application/json',
+ },
+ ).timeout(const Duration(seconds: 30));
+
+ if (response.statusCode == 200) {
+ final responseData = jsonDecode(response.body);
+ final chat = Chat.fromJson(responseData);
+
+ return {
+ 'success': true,
+ 'chat': chat,
+ };
+ } else if (response.statusCode == 401) {
+ return {
+ 'success': false,
+ 'error': 'unauthorized',
+ 'message': 'Session expired. Please login again.',
+ };
+ } else if (response.statusCode == 404) {
+ return {
+ 'success': false,
+ 'error': 'not_found',
+ 'message': 'Chat not found',
+ };
+ } else {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? 'Failed to fetch chat',
+ };
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+
+ /// Create a new chat with optional initial message
+ Future> createChat({
+ required String accessToken,
+ String? title,
+ String? initialMessage,
+ String model = 'gpt-4',
+ }) async {
+ try {
+ final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats');
+
+ final body = {
+ 'model': model,
+ };
+
+ if (title != null) {
+ body['title'] = title;
+ }
+
+ if (initialMessage != null) {
+ body['message'] = initialMessage;
+ }
+
+ final response = await http.post(
+ url,
+ headers: {
+ 'Authorization': 'Bearer $accessToken',
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: jsonEncode(body),
+ ).timeout(const Duration(seconds: 30));
+
+ if (response.statusCode == 201) {
+ final responseData = jsonDecode(response.body);
+ final chat = Chat.fromJson(responseData);
+
+ return {
+ 'success': true,
+ 'chat': chat,
+ };
+ } else if (response.statusCode == 401) {
+ return {
+ 'success': false,
+ 'error': 'unauthorized',
+ 'message': 'Session expired. Please login again.',
+ };
+ } else if (response.statusCode == 403) {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': 'feature_disabled',
+ 'message': responseData['message'] ?? 'AI features not enabled',
+ };
+ } else {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? 'Failed to create chat',
+ };
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+
+ /// Send a message to a chat
+ Future> sendMessage({
+ required String accessToken,
+ required String chatId,
+ required String content,
+ }) async {
+ try {
+ final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId/messages');
+
+ final response = await http.post(
+ url,
+ headers: {
+ 'Authorization': 'Bearer $accessToken',
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: jsonEncode({
+ 'content': content,
+ }),
+ ).timeout(const Duration(seconds: 30));
+
+ if (response.statusCode == 201) {
+ final responseData = jsonDecode(response.body);
+ final message = Message.fromJson(responseData);
+
+ return {
+ 'success': true,
+ 'message': message,
+ };
+ } else if (response.statusCode == 401) {
+ return {
+ 'success': false,
+ 'error': 'unauthorized',
+ 'message': 'Session expired. Please login again.',
+ };
+ } else if (response.statusCode == 404) {
+ return {
+ 'success': false,
+ 'error': 'not_found',
+ 'message': 'Chat not found',
+ };
+ } else {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? 'Failed to send message',
+ };
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+
+ /// Update chat title
+ Future> updateChat({
+ required String accessToken,
+ required String chatId,
+ required String title,
+ }) async {
+ try {
+ final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId');
+
+ final response = await http.patch(
+ url,
+ headers: {
+ 'Authorization': 'Bearer $accessToken',
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: jsonEncode({
+ 'title': title,
+ }),
+ ).timeout(const Duration(seconds: 30));
+
+ if (response.statusCode == 200) {
+ final responseData = jsonDecode(response.body);
+ final chat = Chat.fromJson(responseData);
+
+ return {
+ 'success': true,
+ 'chat': chat,
+ };
+ } else if (response.statusCode == 401) {
+ return {
+ 'success': false,
+ 'error': 'unauthorized',
+ 'message': 'Session expired. Please login again.',
+ };
+ } else if (response.statusCode == 404) {
+ return {
+ 'success': false,
+ 'error': 'not_found',
+ 'message': 'Chat not found',
+ };
+ } else {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? 'Failed to update chat',
+ };
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+
+ /// Delete a chat
+ Future> deleteChat({
+ required String accessToken,
+ required String chatId,
+ }) async {
+ try {
+ final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId');
+
+ final response = await http.delete(
+ url,
+ headers: {
+ 'Authorization': 'Bearer $accessToken',
+ 'Accept': 'application/json',
+ },
+ ).timeout(const Duration(seconds: 30));
+
+ if (response.statusCode == 204) {
+ return {
+ 'success': true,
+ };
+ } else if (response.statusCode == 401) {
+ return {
+ 'success': false,
+ 'error': 'unauthorized',
+ 'message': 'Session expired. Please login again.',
+ };
+ } else if (response.statusCode == 404) {
+ return {
+ 'success': false,
+ 'error': 'not_found',
+ 'message': 'Chat not found',
+ };
+ } else {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? 'Failed to delete chat',
+ };
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+
+ /// Retry the last assistant response in a chat
+ Future> retryMessage({
+ required String accessToken,
+ required String chatId,
+ }) async {
+ try {
+ final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId/messages/retry');
+
+ final response = await http.post(
+ url,
+ headers: {
+ 'Authorization': 'Bearer $accessToken',
+ 'Accept': 'application/json',
+ },
+ ).timeout(const Duration(seconds: 30));
+
+ if (response.statusCode == 202) {
+ return {
+ 'success': true,
+ };
+ } else if (response.statusCode == 401) {
+ return {
+ 'success': false,
+ 'error': 'unauthorized',
+ 'message': 'Session expired. Please login again.',
+ };
+ } else if (response.statusCode == 404) {
+ return {
+ 'success': false,
+ 'error': 'not_found',
+ 'message': 'Chat not found',
+ };
+ } else {
+ final responseData = jsonDecode(response.body);
+ return {
+ 'success': false,
+ 'error': responseData['error'] ?? 'Failed to retry message',
+ };
+ }
+ } catch (e) {
+ return {
+ 'success': false,
+ 'error': 'Network error: ${e.toString()}',
+ };
+ }
+ }
+}
diff --git a/mobile/lib/services/connectivity_service.dart b/mobile/lib/services/connectivity_service.dart
new file mode 100644
index 000000000..35b856c27
--- /dev/null
+++ b/mobile/lib/services/connectivity_service.dart
@@ -0,0 +1,70 @@
+import 'dart:async';
+import 'package:connectivity_plus/connectivity_plus.dart';
+import 'package:flutter/foundation.dart';
+import 'log_service.dart';
+
+class ConnectivityService with ChangeNotifier {
+ final Connectivity _connectivity = Connectivity();
+ final LogService _log = LogService.instance;
+ StreamSubscription>? _connectivitySubscription;
+
+ bool _isOnline = true;
+ bool get isOnline => _isOnline;
+ bool get isOffline => !_isOnline;
+
+ ConnectivityService() {
+ _log.info('ConnectivityService', 'Initializing connectivity service');
+ _initConnectivity().then((_) {
+ _connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
+ });
+ }
+
+ Future _initConnectivity() async {
+ try {
+ final result = await _connectivity.checkConnectivity();
+ _log.info('ConnectivityService', 'Initial connectivity check: $result');
+ _updateConnectionStatus(result);
+ } catch (e) {
+ // If we can't determine connectivity, assume we're offline
+ _log.error('ConnectivityService', 'Failed to check connectivity: $e');
+ _isOnline = false;
+ notifyListeners();
+ }
+ }
+
+ void _updateConnectionStatus(List results) {
+ final wasOnline = _isOnline;
+
+ // Check if any result indicates connectivity
+ _isOnline = results.any((result) =>
+ result == ConnectivityResult.mobile ||
+ result == ConnectivityResult.wifi ||
+ result == ConnectivityResult.ethernet ||
+ result == ConnectivityResult.vpn ||
+ result == ConnectivityResult.bluetooth);
+
+ _log.info('ConnectivityService', 'Connectivity changed: $results -> ${_isOnline ? "ONLINE" : "OFFLINE"}');
+
+ // Only notify if the status changed
+ if (wasOnline != _isOnline) {
+ _log.info('ConnectivityService', 'Connection status changed from ${wasOnline ? "ONLINE" : "OFFLINE"} to ${_isOnline ? "ONLINE" : "OFFLINE"}');
+ notifyListeners();
+ }
+ }
+
+ Future checkConnectivity() async {
+ try {
+ final result = await _connectivity.checkConnectivity();
+ _updateConnectionStatus(result);
+ return _isOnline;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ @override
+ void dispose() {
+ _connectivitySubscription?.cancel();
+ super.dispose();
+ }
+}
diff --git a/mobile/lib/services/database_helper.dart b/mobile/lib/services/database_helper.dart
new file mode 100644
index 000000000..d483a034b
--- /dev/null
+++ b/mobile/lib/services/database_helper.dart
@@ -0,0 +1,309 @@
+import 'package:flutter/foundation.dart';
+import 'package:sqflite/sqflite.dart';
+import 'package:path/path.dart';
+import 'log_service.dart';
+
+class DatabaseHelper {
+ static final DatabaseHelper instance = DatabaseHelper._init();
+ static Database? _database;
+ final LogService _log = LogService.instance;
+
+ DatabaseHelper._init();
+
+ Future get database async {
+ if (_database != null) return _database!;
+
+ try {
+ _database = await _initDB('sure_offline.db');
+ return _database!;
+ } catch (e, stackTrace) {
+ _log.error('DatabaseHelper', 'Error initializing local database sure_offline.db: $e');
+ FlutterError.reportError(
+ FlutterErrorDetails(
+ exception: e,
+ stack: stackTrace,
+ library: 'database_helper',
+ context: ErrorDescription('while opening sure_offline.db'),
+ ),
+ );
+ rethrow;
+ }
+ }
+
+ Future _initDB(String filePath) async {
+ try {
+ final dbPath = await getDatabasesPath();
+ final path = join(dbPath, filePath);
+
+ return await openDatabase(
+ path,
+ version: 1,
+ onCreate: _createDB,
+ );
+ } catch (e, stackTrace) {
+ _log.error('DatabaseHelper', 'Error opening database file "$filePath": $e');
+ FlutterError.reportError(
+ FlutterErrorDetails(
+ exception: e,
+ stack: stackTrace,
+ library: 'database_helper',
+ context: ErrorDescription('while initializing the sqflite database'),
+ ),
+ );
+ rethrow;
+ }
+ }
+
+ Future _createDB(Database db, int version) async {
+ try {
+ // Transactions table
+ await db.execute('''
+ CREATE TABLE transactions (
+ local_id TEXT PRIMARY KEY,
+ server_id TEXT,
+ account_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ date TEXT NOT NULL,
+ amount TEXT NOT NULL,
+ currency TEXT NOT NULL,
+ nature TEXT NOT NULL,
+ notes TEXT,
+ sync_status TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ ''');
+
+ // Accounts table (cached from server)
+ await db.execute('''
+ CREATE TABLE accounts (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ balance TEXT NOT NULL,
+ currency TEXT NOT NULL,
+ classification TEXT,
+ account_type TEXT NOT NULL,
+ synced_at TEXT NOT NULL
+ )
+ ''');
+
+ // Create indexes for better query performance
+ await db.execute('''
+ CREATE INDEX idx_transactions_sync_status
+ ON transactions(sync_status)
+ ''');
+
+ await db.execute('''
+ CREATE INDEX idx_transactions_account_id
+ ON transactions(account_id)
+ ''');
+
+ await db.execute('''
+ CREATE INDEX idx_transactions_date
+ ON transactions(date DESC)
+ ''');
+
+ // Index on server_id for faster lookups by server ID
+ await db.execute('''
+ CREATE INDEX idx_transactions_server_id
+ ON transactions(server_id)
+ ''');
+ } catch (e, stackTrace) {
+ _log.error('DatabaseHelper', 'Error creating local database schema: $e');
+ FlutterError.reportError(
+ FlutterErrorDetails(
+ exception: e,
+ stack: stackTrace,
+ library: 'database_helper',
+ context: ErrorDescription('while creating tables and indexes'),
+ ),
+ );
+ rethrow;
+ }
+ }
+
+ // Transaction CRUD operations
+ Future insertTransaction(Map transaction) async {
+ final db = await database;
+ _log.debug('DatabaseHelper', 'Inserting transaction: local_id=${transaction['local_id']}, account_id="${transaction['account_id']}", server_id=${transaction['server_id']}');
+ await db.insert(
+ 'transactions',
+ transaction,
+ conflictAlgorithm: ConflictAlgorithm.replace,
+ );
+ _log.debug('DatabaseHelper', 'Transaction inserted successfully');
+ return transaction['local_id'] as String;
+ }
+
+ Future>> getTransactions({String? accountId}) async {
+ final db = await database;
+
+ if (accountId != null) {
+ _log.debug('DatabaseHelper', 'Querying transactions WHERE account_id = "$accountId"');
+ final results = await db.query(
+ 'transactions',
+ where: 'account_id = ?',
+ whereArgs: [accountId],
+ orderBy: 'date DESC, created_at DESC',
+ );
+ _log.debug('DatabaseHelper', 'Query returned ${results.length} results');
+ return results;
+ } else {
+ _log.debug('DatabaseHelper', 'Querying ALL transactions');
+ final results = await db.query(
+ 'transactions',
+ orderBy: 'date DESC, created_at DESC',
+ );
+ _log.debug('DatabaseHelper', 'Query returned ${results.length} results');
+ return results;
+ }
+ }
+
+ Future?> getTransactionByLocalId(String localId) async {
+ final db = await database;
+ final results = await db.query(
+ 'transactions',
+ where: 'local_id = ?',
+ whereArgs: [localId],
+ limit: 1,
+ );
+
+ return results.isNotEmpty ? results.first : null;
+ }
+
+ Future?> getTransactionByServerId(String serverId) async {
+ final db = await database;
+ final results = await db.query(
+ 'transactions',
+ where: 'server_id = ?',
+ whereArgs: [serverId],
+ limit: 1,
+ );
+
+ return results.isNotEmpty ? results.first : null;
+ }
+
+ Future>> getPendingTransactions() async {
+ final db = await database;
+ return await db.query(
+ 'transactions',
+ where: 'sync_status = ?',
+ whereArgs: ['pending'],
+ orderBy: 'created_at ASC',
+ );
+ }
+
+ Future>> getPendingDeletes() async {
+ final db = await database;
+ return await db.query(
+ 'transactions',
+ where: 'sync_status = ?',
+ whereArgs: ['pending_delete'],
+ orderBy: 'updated_at ASC',
+ );
+ }
+
+ Future updateTransaction(String localId, Map transaction) async {
+ final db = await database;
+ return await db.update(
+ 'transactions',
+ transaction,
+ where: 'local_id = ?',
+ whereArgs: [localId],
+ );
+ }
+
+ Future deleteTransaction(String localId) async {
+ final db = await database;
+ return await db.delete(
+ 'transactions',
+ where: 'local_id = ?',
+ whereArgs: [localId],
+ );
+ }
+
+ Future deleteTransactionByServerId(String serverId) async {
+ final db = await database;
+ return await db.delete(
+ 'transactions',
+ where: 'server_id = ?',
+ whereArgs: [serverId],
+ );
+ }
+
+ Future clearTransactions() async {
+ final db = await database;
+ await db.delete('transactions');
+ }
+
+ Future clearSyncedTransactions() async {
+ final db = await database;
+ _log.debug('DatabaseHelper', 'Clearing only synced transactions, keeping pending/failed');
+ await db.delete(
+ 'transactions',
+ where: 'sync_status = ?',
+ whereArgs: ['synced'],
+ );
+ }
+
+ // Account CRUD operations (for caching)
+ Future insertAccount(Map account) async {
+ final db = await database;
+ await db.insert(
+ 'accounts',
+ account,
+ conflictAlgorithm: ConflictAlgorithm.replace,
+ );
+ }
+
+ Future insertAccounts(List> accounts) async {
+ final db = await database;
+ final batch = db.batch();
+
+ for (final account in accounts) {
+ batch.insert(
+ 'accounts',
+ account,
+ conflictAlgorithm: ConflictAlgorithm.replace,
+ );
+ }
+
+ await batch.commit(noResult: true);
+ }
+
+ Future>> getAccounts() async {
+ final db = await database;
+ return await db.query('accounts', orderBy: 'name ASC');
+ }
+
+ Future?> getAccountById(String id) async {
+ final db = await database;
+ final results = await db.query(
+ 'accounts',
+ where: 'id = ?',
+ whereArgs: [id],
+ limit: 1,
+ );
+
+ return results.isNotEmpty ? results.first : null;
+ }
+
+ Future clearAccounts() async {
+ final db = await database;
+ await db.delete('accounts');
+ }
+
+ // Utility methods
+ Future clearAllData() async {
+ final db = await database;
+ await db.delete('transactions');
+ await db.delete('accounts');
+ }
+
+ Future close() async {
+ if (_database != null) {
+ await _database!.close();
+ _database = null;
+ }
+ }
+}
diff --git a/mobile/lib/services/log_service.dart b/mobile/lib/services/log_service.dart
new file mode 100644
index 000000000..83c0a389d
--- /dev/null
+++ b/mobile/lib/services/log_service.dart
@@ -0,0 +1,81 @@
+import 'package:flutter/foundation.dart';
+
+class LogEntry {
+ final DateTime timestamp;
+ final String level; // INFO, DEBUG, ERROR, WARNING
+ final String tag;
+ final String message;
+
+ LogEntry({
+ required this.timestamp,
+ required this.level,
+ required this.tag,
+ required this.message,
+ });
+
+ String get formattedTime {
+ return '${timestamp.hour.toString().padLeft(2, '0')}:'
+ '${timestamp.minute.toString().padLeft(2, '0')}:'
+ '${timestamp.second.toString().padLeft(2, '0')}.'
+ '${timestamp.millisecond.toString().padLeft(3, '0')}';
+ }
+}
+
+class LogService with ChangeNotifier {
+ static final LogService instance = LogService._internal();
+ factory LogService() => instance;
+ LogService._internal();
+
+ final List _logs = [];
+ final int _maxLogs = 500; // Reduced from 1000 to save memory
+ bool _isLogViewerActive = false;
+
+ List get logs => List.unmodifiable(_logs);
+
+ /// Call this when log viewer screen becomes active
+ void setLogViewerActive(bool active) {
+ _isLogViewerActive = active;
+ }
+
+ void log(String tag, String message, {String level = 'INFO'}) {
+ final entry = LogEntry(
+ timestamp: DateTime.now(),
+ level: level,
+ tag: tag,
+ message: message,
+ );
+
+ _logs.add(entry);
+
+ // Keep only the last _maxLogs entries
+ if (_logs.length > _maxLogs) {
+ _logs.removeAt(0);
+ }
+
+ // Also print to console for development
+ debugPrint('[$level][$tag] $message');
+
+ // Only notify listeners if log viewer is active to avoid unnecessary rebuilds
+ if (_isLogViewerActive) {
+ notifyListeners();
+ }
+ }
+
+ void debug(String tag, String message) => log(tag, message, level: 'DEBUG');
+ void info(String tag, String message) => log(tag, message, level: 'INFO');
+ void warning(String tag, String message) => log(tag, message, level: 'WARNING');
+ void error(String tag, String message) => log(tag, message, level: 'ERROR');
+
+ void clear() {
+ _logs.clear();
+ notifyListeners();
+ }
+
+ String exportLogs() {
+ final buffer = StringBuffer();
+ for (final log in _logs) {
+ buffer.writeln('${log.formattedTime} [${log.level}][${log.tag}] ${log.message}');
+ }
+ return buffer.toString();
+ }
+}
diff --git a/mobile/lib/services/offline_storage_service.dart b/mobile/lib/services/offline_storage_service.dart
new file mode 100644
index 000000000..9f5567dc6
--- /dev/null
+++ b/mobile/lib/services/offline_storage_service.dart
@@ -0,0 +1,317 @@
+import 'package:uuid/uuid.dart';
+import '../models/offline_transaction.dart';
+import '../models/transaction.dart';
+import '../models/account.dart';
+import 'database_helper.dart';
+import 'log_service.dart';
+
+class OfflineStorageService {
+ final DatabaseHelper _dbHelper = DatabaseHelper.instance;
+ final Uuid _uuid = const Uuid();
+ final LogService _log = LogService.instance;
+
+ // Transaction operations
+ Future saveTransaction({
+ required String accountId,
+ required String name,
+ required String date,
+ required String amount,
+ required String currency,
+ required String nature,
+ String? notes,
+ String? serverId,
+ SyncStatus syncStatus = SyncStatus.pending,
+ }) async {
+ _log.info('OfflineStorage', 'saveTransaction called: name=$name, amount=$amount, accountId=$accountId, syncStatus=$syncStatus');
+
+ final localId = _uuid.v4();
+ final transaction = OfflineTransaction(
+ id: serverId,
+ localId: localId,
+ accountId: accountId,
+ name: name,
+ date: date,
+ amount: amount,
+ currency: currency,
+ nature: nature,
+ notes: notes,
+ syncStatus: syncStatus,
+ );
+
+ try {
+ await _dbHelper.insertTransaction(transaction.toDatabaseMap());
+ _log.info('OfflineStorage', 'Transaction saved successfully with localId: $localId');
+ return transaction;
+ } catch (e) {
+ _log.error('OfflineStorage', 'Failed to save transaction: $e');
+ rethrow;
+ }
+ }
+
+ Future> getTransactions({String? accountId}) async {
+ _log.debug('OfflineStorage', 'getTransactions called with accountId: $accountId');
+ final transactionMaps = await _dbHelper.getTransactions(accountId: accountId);
+ _log.debug('OfflineStorage', 'Retrieved ${transactionMaps.length} transaction maps from database');
+
+ if (transactionMaps.isNotEmpty && accountId != null) {
+ _log.debug('OfflineStorage', 'Sample transaction account_ids:');
+ for (int i = 0; i < transactionMaps.take(3).length; i++) {
+ final map = transactionMaps[i];
+ _log.debug('OfflineStorage', ' - Transaction ${map['server_id']}: account_id="${map['account_id']}"');
+ }
+ }
+
+ final transactions = transactionMaps
+ .map((map) => OfflineTransaction.fromDatabaseMap(map))
+ .toList();
+ _log.debug('OfflineStorage', 'Returning ${transactions.length} transactions');
+ return transactions;
+ }
+
+ Future getTransactionByLocalId(String localId) async {
+ final map = await _dbHelper.getTransactionByLocalId(localId);
+ return map != null ? OfflineTransaction.fromDatabaseMap(map) : null;
+ }
+
+ Future getTransactionByServerId(String serverId) async {
+ final map = await _dbHelper.getTransactionByServerId(serverId);
+ return map != null ? OfflineTransaction.fromDatabaseMap(map) : null;
+ }
+
+ Future> getPendingTransactions() async {
+ final transactionMaps = await _dbHelper.getPendingTransactions();
+ return transactionMaps
+ .map((map) => OfflineTransaction.fromDatabaseMap(map))
+ .toList();
+ }
+
+ Future> getPendingDeletes() async {
+ final transactionMaps = await _dbHelper.getPendingDeletes();
+ return transactionMaps
+ .map((map) => OfflineTransaction.fromDatabaseMap(map))
+ .toList();
+ }
+
+ Future updateTransactionSyncStatus({
+ required String localId,
+ required SyncStatus syncStatus,
+ String? serverId,
+ }) async {
+ final existing = await getTransactionByLocalId(localId);
+ if (existing == null) return;
+
+ final updated = existing.copyWith(
+ syncStatus: syncStatus,
+ id: serverId ?? existing.id,
+ updatedAt: DateTime.now(),
+ );
+
+ await _dbHelper.updateTransaction(localId, updated.toDatabaseMap());
+ }
+
+ Future deleteTransaction(String localId) async {
+ await _dbHelper.deleteTransaction(localId);
+ }
+
+ Future