diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..34b5d856 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,253 @@ +## Overview + +Wise — the global money-transfer brand — wears its identity in a single signature pairing: a vivid lime-green `{colors.primary}` (`#9fe870`) used as the CTA pill and brand accent, set against a pale sage-tinted canvas `{colors.canvas-soft}` (`#e8ebe6`) that runs across the hero band, and a near-black ink `{colors.ink}` (`#0e0f0c`) with a hint of warmth from the brand's underlying olive cast. The brand reads more like a calm Scandinavian magazine than a bank — generous whitespace, large rounded cards, and an unusually heavy display sans set at weight 900 carrying every hero headline. + +Display typography is the second decisive voice. The proprietary `Wise Sans` family carries hero displays at weight 900 in scales from 64 px up to 126 px on the largest hero. The brand pairs Wise Sans 900 with Inter at weight 600 for sub-displays — the contrast between the chunky proprietary face and Inter's neutrality creates a particular hierarchy: Wise Sans for the brand moment, Inter for everything else. + +Cards are universally pill-rounded — `{rounded.xl}` 24 px is the brand's signature card radius. Buttons take the same 24 px pill-rectangle shape. The brand never uses sharp corners on UI elements; the visual softness is part of the friendly fintech voice. + +**Key Characteristics:** +- A single lime-green CTA accent `{colors.primary}` (`#9fe870`) — the brand's universal primary action color. No second accent. +- Two-face display typography — Wise Sans (proprietary, weight 900, hero scale) + Inter (weight 600, sub-display scale). The contrast is the brand's typographic story. +- `{rounded.xl}` 24 px is the canonical card and button radius. Generous, friendly. +- Sage-tinted canvas `{colors.canvas-soft}` (`#e8ebe6`) is the brand's hero surface; white `{colors.canvas}` is reserved for cards within the sage band. +- A full semantic palette: positive green family, warning yellow family, negative red family — each documented with content / hover / active variants for in-product use. +- Currency-converter card on the hero — the brand's signature interactive component, hosting from/to amount inputs. + +## Colors + +### Brand & Accent +- **Wise Green** (`{colors.primary}` — `#9fe870`): The brand's universal CTA color. Every primary button, every "Send money" pill, the brand's logo accent. +- **Wise Green Hover** (`{colors.primary-active}` — `#cdffad`): The lighter green for active state. +- **Wise Green Neutral** (`{colors.primary-neutral}` — `#c5edab`): A mid-saturation green used as a neutral active fill. +- **Wise Green Pale** (`{colors.primary-pale}` — `#e2f6d5`): The lightest green for soft surface tints / badge backgrounds. + +### Surface +- **Canvas** (`{colors.canvas}` — `#ffffff`): Pure white for card interiors. +- **Canvas Soft** (`{colors.canvas-soft}` — `#e8ebe6`): The sage-tinted page background. Defining mood of the brand. + +### Text +- **Ink** (`{colors.ink}` — `#0e0f0c`): Near-black with a hint of olive warmth — the brand's default text and headings color. +- **Ink Deep** (`{colors.ink-deep}` — `#163300`): A deep forest-green ink used on positive-state surfaces. +- **Body** (`{colors.body}` — `#454745`): Secondary body text. +- **Mute** (`{colors.mute}` — `#868685`): Lowest-priority text — captions, placeholder, fine print. + +### Semantic +- **Positive** (`{colors.positive}` — `#2ead4b`): Success indicator. +- **Positive Deep** (`{colors.positive-deep}` — `#054d28`): Pressed positive state. +- **Warning** (`{colors.warning}` — `#ffd11a`): Caution indicator. +- **Warning Deep** (`{colors.warning-deep}` — `#b86700`): Pressed warning. +- **Warning Content** (`{colors.warning-content}` — `#4a3b1c`): Text on warning surfaces. +- **Negative** (`{colors.negative}` — `#d03238`): Destructive / error red. +- **Negative Deep** (`{colors.negative-deep}` — `#a72027`): Pressed destructive. +- **Negative Darkest** (`{colors.negative-darkest}` — `#a7000d`): Highest-emphasis destructive text. +- **Negative Bg** (`{colors.negative-bg}` — `#320707`): Dark maroon for destructive callout backgrounds. + +### Brand Accent — Tertiary +- **Accent Orange** (`{colors.accent-orange}` — `#ffc091`): Bright peach used inside illustrative content / pricing cards. +- **Accent Cyan** (`{colors.accent-cyan}` — `#38c8ff`): Bright sky-blue used as a tertiary illustration accent. + +## Typography + +### Font Family +Two faces ladder the system: +1. **Wise Sans** — proprietary geometric sans with an unusually heavy weight 900 used for all hero displays. The face is the brand's typographic signature. Always at weight 900, never lighter on the marketing surface. +2. **Inter** — used for sub-displays (weight 600), all body, and form labels. Loaded with `font-feature-settings: "calt"` for contextual alternates. + +### Hierarchy + +| Token | Size | Weight | Line Height | Letter Spacing | Use | +|---|---|---|---|---|---| +| `{typography.display-mega}` | 126px | 900 | 107.1px | 0 | Hero stencil at maximum scale. | +| `{typography.display-xxl}` | 96px | 900 | 81.6px | 0 | Sub-hero scale. | +| `{typography.display-xl}` | 64px | 900 | 54.4px | 0 | Standard hero headline. | +| `{typography.display-lg}` | 47px | 400 | 70.5px | -0.108px | Lighter sub-display. | +| `{typography.display-md}` | 40px | 900 | 34px | 0 | Section / card headlines. | +| `{typography.display-sm}` | 32px | 600 | 38.4px | -0.96px | Inter-rendered section headings. | +| `{typography.display-xs}` | 24px | 600 | 31.2px | -0.48px | Sub-section displays. | +| `{typography.body-lg}` | 20px | 400 | 30px | 0 | Lead paragraphs. | +| `{typography.body-md}` | 16px | 400 | 24px | 0 | Default body. | +| `{typography.body-md-strong}` | 16px | 600 | 24px | 0 | Bold inline body. | +| `{typography.body-sm}` | 14px | 400 | 20px | 0 | Secondary body. | +| `{typography.body-sm-strong}` | 14px | 600 | 20px | 0 | Bold caption / nav-link. | +| `{typography.caption}` | 12px | 400 | 16px | 0 | Fine print. | +| `{typography.button-md}` | 16px | 600 | 24px | 0 | Button label. | + +### Principles +- **Weight 900 for hero, weight 600 for everything else.** The brand's display ceiling is full-black weight; everything below is semibold. +- **Wise Sans for the brand voice, Inter for utility.** Strict role separation. + +### Note on Font Substitutes +Wise Sans is proprietary. Open-source substitutes: +- **Display** — *Inter* at weight 900 or *Manrope* at weight 800 / 900 captures the geometric heaviness. *Geist* weight 800 is a passable second choice. +- **Sub-display + body** — *Inter* is the brand's actual second face. + +## Layout + +### Spacing System +- **Base unit**: 4 px. +- **Tokens**: `{spacing.xxs}` 2 px · `{spacing.xs}` 4 px · `{spacing.sm}` 8 px · `{spacing.md}` 12 px · `{spacing.lg}` 16 px · `{spacing.xl}` 24 px · `{spacing.2xl}` 32 px · `{spacing.3xl}` 48 px. +- **Section padding**: bands use `{spacing.3xl}` 48 px top/bottom on desktop. +- **Card interior**: cards at `{spacing.xl}` 24 px. + +### Grid & Container +- Marketing container centres at ~1200 px. +- Hero: split layout (headline left, currency-converter card right) at desktop; stacked at mobile. +- Feature grids: 2-up / 3-up at desktop. + +### Responsive Strategy + +#### Breakpoints + +| Name | Width | Key Changes | +|---|---|---| +| Mobile | < 768px | Hero stacks; converter card full-width below headline; grids 1-up. | +| Tablet | 768–1023px | Grids 2-up. | +| Desktop | ≥ 1024px | Hero split; full grids. | + +#### Touch Targets +Buttons render ~48 px tall (12 vertical padding + 24 line). WCAG AAA at all widths. + +#### Image Behavior +Photography is sparse; the brand prefers illustrative SVGs and product mockups inside cards. Country flag thumbnails appear inside currency rows. + +## Elevation & Depth + +| Level | Treatment | Use | +|---|---|---| +| Level 0 — Flat | No shadow, no border. | Default. | +| Level 1 — Hairline on Dark | 1 px solid `{colors.ink}` border. | Tertiary outline buttons, form inputs. | +| Level 2 — Soft Card | Implicit Level 0 white card sitting on sage canvas — the surface contrast IS the elevation. | Cards on the sage hero band. | + +The brand uses surface contrast (`{colors.canvas-soft}` background vs `{colors.canvas}` cards) as the primary elevation cue. + +## Shapes + +### Border Radius Scale + +| Token | Value | Use | +|---|---|---| +| `{rounded.none}` | 0px | Full-bleed bands. | +| `{rounded.sm}` | 8px | Inline pills, small badges. | +| `{rounded.md}` | 12px | Form inputs, smaller chrome. | +| `{rounded.lg}` | 16px | Mid-size cards. | +| `{rounded.xl}` | 24px | The brand's canonical button + card radius. | +| `{rounded.pill}` | 9999px | Status pills and full-radius accents. | +| `{rounded.full}` | 9999px | Circular icon containers. | + +## Components + +### Buttons + +**`button-primary`** — the lime-green CTA pill. +- Background `{colors.primary}`, text `{colors.on-primary}`, label `{typography.button-md}`, padding `{spacing.md} {spacing.xl}`, shape `{rounded.xl}` 24 px. + +**`button-secondary`** — the sage-tinted secondary. +- Background `{colors.canvas-soft}`, text `{colors.ink}`, same typography / padding / shape. + +**`button-tertiary`** — the white outline tertiary. +- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.ink}` border, same typography / padding / shape. + +**`button-icon-circular`** — the circular icon button. +- Background `{colors.canvas}`, ink icon, shape `{rounded.full}`. + +### Cards & Containers + +**`card-content`** — the default white card. +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.xl}`, shape `{rounded.xl}`. No border, sits on sage canvas. + +**`card-feature-sage`** — the sage-tinted feature card. +- Background `{colors.canvas-soft}`, text `{colors.ink}`, padding `{spacing.xl}`, shape `{rounded.xl}`. + +**`card-feature-green`** — the soft-green feature card. +- Background `{colors.primary-pale}`, text `{colors.ink}`, padding `{spacing.xl}`, shape `{rounded.xl}`. + +**`card-feature-dark`** — the polarity-flipped dark card with green text. +- Background `{colors.ink}`, text `{colors.primary}` (Wise green!), padding `{spacing.xl}`, shape `{rounded.xl}`. Used for promotional moments. + +**`currency-converter-card`** — the brand's signature interactive widget. +- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.ink}` border, padding `{spacing.xl}`, shape `{rounded.xl}`. Hosts from/to amount inputs + currency selectors. + +### Inputs & Forms + +**`text-input`** — the canonical text input. +- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.ink}` border, body in `{typography.body-md}`, padding `{spacing.md} {spacing.lg}`, shape `{rounded.md}`. + +### Navigation + +**`nav-bar`** — the sticky top nav. +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md} {spacing.xl}`. + +**`nav-link`** — link items inside nav. +- Text `{colors.ink}`, set in `{typography.body-sm-strong}`. + +**`footer`** — the dark footer band. +- Background `{colors.ink}`, text `{colors.canvas-soft}`, padding `{spacing.3xl} {spacing.xl}`. Body in `{typography.body-sm}`. + +### Signature Components + +**`hero-band`** — the sage-canvas hero band. +- Background `{colors.canvas-soft}`, text `{colors.ink}`, padding `{spacing.3xl} {spacing.xl}`. Headline in `{typography.display-mega}` (Wise Sans weight 900). + +**`hero-band-dark`** — the polarity-flipped dark hero. +- Background `{colors.ink}`, text `{colors.primary}` (Wise green headline on near-black!), same padding / scale. + +**`content-band`** — the white content band that follows hero. +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.3xl} {spacing.xl}`. Section headline in `{typography.display-md}`. + +**`badge-positive`** — the positive status pill. +- Background `{colors.primary-pale}`, text `{colors.positive-deep}`, body in `{typography.body-sm-strong}`, padding `{spacing.xs} {spacing.md}`, shape `{rounded.pill}`. + +**`badge-negative`** — the negative status pill. +- Background `{colors.negative-bg}`, text white, body in `{typography.body-sm-strong}`, padding `{spacing.xs} {spacing.md}`, shape `{rounded.pill}`. + +### Examples (illustrative) + +> Auto-derived kit-mirror demonstration surfaces (`scripts/derive-examples-block.mjs`). Each `ex-*` entry references brand-native primitives so downstream consumers (`/preview-design`, `/generate-kit`) re-skin the same 10 surfaces consistently. `TO_FILL` markers indicate missing primitives — resolve in the LLM judgment pass. + +**`ex-pricing-tier`** — Default Pricing tier card. Re-uses feature-card chrome with brand canvas-soft surface. +- Properties: `backgroundColor`, `textColor`, `borderColor`, `rounded`, `padding` + +**`ex-pricing-tier-featured`** — Featured/highlighted tier — polarity-flipped surface (dark fill + light text in light mode, light fill + dark text in dark mode). +- Properties: `backgroundColor`, `textColor`, `rounded`, `padding` + +**`ex-product-selector`** — What's Included summary card — re-purposed for SaaS / B2B verticals (NOT a literal product gallery). +- Properties: `backgroundColor`, `rounded`, `padding` + +**`ex-cart-drawer`** — Subscription summary — re-purposed for SaaS / B2B (line items per add-on, not literal cart). +- Properties: `backgroundColor`, `rounded`, `padding`, `item-divider` + +**`ex-app-shell-row`** — Sidebar nav row inside the App Shell example. Active state uses brand primary as the indicator. +- Properties: `backgroundColor`, `activeIndicator`, `rounded`, `padding` + +**`ex-data-table-cell`** — Default data-table th + td chrome. Header uses mono-caps eyebrow typography; body uses body-sm. +- Properties: `headerBackground`, `headerTypography`, `bodyTypography`, `cellPadding`, `rowBorder` + +**`ex-auth-form-card`** — Sign-in / sign-up card. Re-uses feature-card chrome with text-input primitives inside. +- Properties: `backgroundColor`, `rounded`, `padding` + +**`ex-modal-card`** — Modal dialog surface — same chrome as feature-card with elevated shadow. +- Properties: `backgroundColor`, `rounded`, `padding` + +**`ex-empty-state-card`** — Empty-state illustration frame. +- Properties: `backgroundColor`, `rounded`, `padding`, `captionTypography` + +**`ex-toast`** — Toast notification surface — feature-card shape + medium shadow. +- Properties: `backgroundColor`, `rounded`, `padding`, `typography` + + +## Do's and Don'ts + +### Do +- Reserve `{colors.primary}` Wise green for every primary CTA. The lime-green pill IS the brand's conversion signature. +- Set hero headlines in `{typography.display-mega}` / `{typography.display-xl}` Wise Sans weight 900. Never lighter. +- Use `{rounded.xl}` 24 px for buttons and cards. The generous radius is the brand's friendliness signature. +- Cycle page surfaces in `{colors.canvas-soft}` sage canvas → `{colors.canvas}` white cards. Surface contrast carries elevation. +- Use the full semantic palette (positive / warning / negative) for in-product status — never repurpose Wise green as success indicator since it IS the brand CTA. + +### Don't +- Don't introduce a second brand accent. Wise green is the sole identity colour. +- Don't render the hero in weight 700 or lighter. The brand's display weight is 900. +- Don't render CTAs as sharp rectangles. The 24 px pill geometry is non-negotiable. +- Don't pair the green CTA with a green background. The brand always sits Wise green on neutral surfaces (sage / white / ink). +- Don't replace Wise Sans with a generic geometric sans for hero typography — the proprietary face IS the brand's voice. diff --git a/Procfile.dev b/Procfile.dev index e4297930..8ec6c4b2 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -5,7 +5,7 @@ # Web server with YJIT enabled # OBJC_DISABLE_INITIALIZE_FORK_SAFETY fixes macOS fork issues with Puma workers # PORT=3000 ensures Rails uses port 3000 (foreman defaults to 5000) -web: OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES RUBY_YJIT_ENABLE=1 WEB_CONCURRENCY=0 PORT=3000 bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0 +web: OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES RUBY_YJIT_ENABLE=1 WEB_CONCURRENCY=0 PORT=${PORT:-3000} bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0 # CSS watcher - use [always] to keep watching even with no changes css: bundle exec bin/rails tailwindcss:watch[always] diff --git a/app/controllers/lunchflow_items_controller.rb b/app/controllers/lunchflow_items_controller.rb index eacdcf6e..3b5c21ca 100644 --- a/app/controllers/lunchflow_items_controller.rb +++ b/app/controllers/lunchflow_items_controller.rb @@ -1,4 +1,5 @@ class LunchflowItemsController < ApplicationController + before_action :ensure_lunchflow_enabled, only: [ :index, :show, :edit, :update, :new, :create, :destroy, :sync, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account ] before_action :set_lunchflow_item, only: [ :show, :edit, :update, :destroy, :sync ] def index @@ -332,7 +333,10 @@ def link_existing_account end def new - @lunchflow_item = Current.family.lunchflow_items.build + redirect_to select_accounts_lunchflow_items_path( + accountable_type: params[:accountable_type], + return_to: params[:return_to] + ) end def create @@ -379,6 +383,10 @@ def sync end private + def ensure_lunchflow_enabled + redirect_to settings_providers_path, alert: "Lunch Flow integration is disabled." unless Setting.lunchflow_enabled + end + def set_lunchflow_item @lunchflow_item = Current.family.lunchflow_items.find(params[:id]) end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index f2846d04..668c92a9 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -1,4 +1,5 @@ class PlaidItemsController < ApplicationController + before_action :ensure_plaid_enabled, only: %i[new edit create destroy sync select_existing_account link_existing_account] before_action :set_plaid_item, only: %i[edit destroy sync] def new @@ -91,6 +92,10 @@ def link_existing_account end private + def ensure_plaid_enabled + redirect_to settings_providers_path, alert: "Plaid integration is disabled." unless Setting.plaid_enabled + end + def set_plaid_item @plaid_item = Current.family.plaid_items.find(params[:id]) end diff --git a/app/controllers/settings/bank_sync_controller.rb b/app/controllers/settings/bank_sync_controller.rb index 29537ee7..efabe735 100644 --- a/app/controllers/settings/bank_sync_controller.rb +++ b/app/controllers/settings/bank_sync_controller.rb @@ -1,31 +1,9 @@ class Settings::BankSyncController < ApplicationController layout "settings" + # The bank_sync settings page has been consolidated into the providers page. + # This redirect preserves old bookmarks and any links that still point here. def show - @providers = [ - { - name: "Lunch Flow", - description: "US, Canada, UK, EU, Brazil and Asia through multiple open banking providers.", - path: "https://lunchflow.app/features/sure-integration", - target: "_blank", - rel: "noopener noreferrer", - data_turbo: false - }, - { - name: "Plaid", - description: "US & Canada bank connections with transactions, investments, and liabilities.", - path: "https://github.com/hendripermana/permoney/blob/main/docs/hosting/plaid.md", - target: "_blank", - rel: "noopener noreferrer", - data_turbo: false - }, - { - name: "SimpleFIN", - description: "US & Canada connections via SimpleFin protocol.", - path: "https://beta-bridge.simplefin.org", - target: "_blank", - rel: "noopener noreferrer" - } - ] + redirect_to settings_providers_path, status: :moved_permanently end end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 94889a00..4ef0266e 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -1,9 +1,7 @@ class Settings::ProvidersController < ApplicationController layout "settings" - guard_feature unless: -> { self_hosted? } - - before_action :ensure_admin, only: [ :show, :update ] + before_action :ensure_admin, only: [ :show, :update, :test_connection ] def show @breadcrumbs = [ @@ -31,7 +29,17 @@ def update # Perform all updates within a transaction for consistency Setting.transaction do + # Handle global toggles first + %w[plaid_enabled simplefin_enabled lunchflow_enabled].each do |toggle| + next unless provider_params.key?(toggle) + value = provider_params[toggle] == "1" + Setting.public_send("#{toggle}=", value) + updated_fields << toggle + end + provider_params.each do |param_key, param_value| + # Skip toggle params as we handled them above + next if %w[plaid_enabled simplefin_enabled lunchflow_enabled].include?(param_key.to_s) # Only process keys that exist in the configuration registry field = valid_fields[param_key.to_s] next unless field @@ -98,11 +106,139 @@ def update render :show, status: :unprocessable_entity end + def test_connection + provider_key = params[:provider_key].to_s.downcase + + success = false + message = "" + + case provider_key + when "plaid" + client_id = Setting["plaid_client_id"].presence || ENV["PLAID_CLIENT_ID"] + secret = Setting["plaid_secret"].presence || ENV["PLAID_SECRET"] + env = Setting["plaid_environment"].presence || ENV["PLAID_ENV"] || "sandbox" + + if client_id.blank? || secret.blank? + message = "Client ID and Secret Key are not configured." + else + begin + config = Plaid::Configuration.new + config.server_index = Plaid::Configuration::Environment[env] + config.api_key["PLAID-CLIENT-ID"] = client_id + config.api_key["PLAID-SECRET"] = secret + + client = Plaid::PlaidApi.new(Plaid::ApiClient.new(config)) + client.categories_get({}) + success = true + message = "Connection successful! Plaid US API credentials are valid." + rescue Plaid::ApiError => e + body = JSON.parse(e.response_body || "{}") + error_message = body["error_message"] || body["error_code"] || e.message + message = "Plaid API error: #{error_message}" + rescue => e + message = "Connection failed: #{e.message}" + end + end + + when "plaid_eu" + client_id = Setting["plaid_eu_client_id"].presence || ENV["PLAID_EU_CLIENT_ID"] + secret = Setting["plaid_eu_secret"].presence || ENV["PLAID_EU_SECRET"] + env = Setting["plaid_eu_environment"].presence || ENV["PLAID_EU_ENV"] || "sandbox" + + if client_id.blank? || secret.blank? + message = "Client ID and Secret Key are not configured." + else + begin + config = Plaid::Configuration.new + config.server_index = Plaid::Configuration::Environment[env] + config.api_key["PLAID-CLIENT-ID"] = client_id + config.api_key["PLAID-SECRET"] = secret + + client = Plaid::PlaidApi.new(Plaid::ApiClient.new(config)) + client.categories_get({}) + success = true + message = "Connection successful! Plaid EU API credentials are valid." + rescue Plaid::ApiError => e + body = JSON.parse(e.response_body || "{}") + error_message = body["error_message"] || body["error_code"] || e.message + message = "Plaid API error: #{error_message}" + rescue => e + message = "Connection failed: #{e.message}" + end + end + + when "lunchflow" + api_key = Setting["lunchflow_api_key"].presence || ENV["LUNCHFLOW_API_KEY"] + base_url = Setting["lunchflow_base_url"].presence || ENV["LUNCHFLOW_BASE_URL"] || "https://lunchflow.app/api/v1" + + if api_key.blank? + message = "API Key is not configured." + else + begin + provider = Provider::Lunchflow.new(api_key, base_url: base_url) + provider.get_accounts + success = true + message = "Connection successful! Lunch Flow API key is valid." + rescue Provider::Lunchflow::LunchflowError => e + message = "Lunch Flow error: #{e.message}" + rescue => e + message = "Connection failed: #{e.message}" + end + end + + when "simplefin" + items = Current.family.simplefin_items.active + if items.empty? + message = "No active SimpleFIN connections found to test. Please add a connection first." + else + success_count = 0 + failed_messages = [] + + items.each do |item| + begin + next if item.access_url.blank? + uri = URI.parse(item.access_url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") + http.open_timeout = 10 + http.read_timeout = 10 + + request = Net::HTTP::Get.new(uri.request_uri) + if uri.user.present? && uri.password.present? + request.basic_auth(uri.user, uri.password) + end + + response = http.request(request) + if response.code.to_i == 200 + success_count += 1 + else + failed_messages << "#{item.name}: HTTP #{response.code}" + end + rescue => e + failed_messages << "#{item.name}: #{e.message}" + end + end + + if success_count == items.count + success = true + message = "Connection successful! All #{success_count} SimpleFIN connections are active." + else + message = "Connection partially failed: #{success_count} succeeded, #{items.count - success_count} failed. Errors: #{failed_messages.join(', ')}" + end + end + + else + message = "Unknown provider: #{provider_key}" + end + + render json: { success: success, message: message } + end + private def provider_params # Dynamically permit all provider configuration fields Provider::Factory.ensure_adapters_loaded - permitted_fields = [] + permitted_fields = [ :plaid_enabled, :simplefin_enabled, :lunchflow_enabled ] Provider::ConfigurationRegistry.all.each do |config| config.fields.each do |field| @@ -142,9 +278,12 @@ def reload_provider_configs(updated_fields) def prepare_show_context Provider::Factory.ensure_adapters_loaded - @provider_configurations = Provider::ConfigurationRegistry.all.reject do |config| - config.provider_key.to_s.casecmp("simplefin").zero? - end - @simplefin_items = Current.family.simplefin_items.ordered.select(:id) + @plaid_config = Provider::ConfigurationRegistry.get("plaid") + @plaid_eu_config = Provider::ConfigurationRegistry.get("plaid_eu") + @lunchflow_config = Provider::ConfigurationRegistry.get("lunchflow") + + @plaid_items = Current.family.plaid_items.active.ordered + @simplefin_items = Current.family.simplefin_items.active.ordered + @lunchflow_items = Current.family.lunchflow_items.active.ordered end end diff --git a/app/controllers/settings/sync_monitors_controller.rb b/app/controllers/settings/sync_monitors_controller.rb new file mode 100644 index 00000000..80926fc6 --- /dev/null +++ b/app/controllers/settings/sync_monitors_controller.rb @@ -0,0 +1,123 @@ +class Settings::SyncMonitorsController < ApplicationController + layout "settings" + before_action :ensure_admin + + def show + # Fetch the latest sync for each syncable target + latest_syncs_query = Sync.for_family(Current.family) + .select("DISTINCT ON (syncable_type, syncable_id) syncs.*") + .order("syncable_type, syncable_id, created_at DESC") + .includes(:syncable) + + # Sort them in memory by created_at descending so the most recent overall is at the top + @syncs = latest_syncs_query.to_a.sort_by { |s| s.created_at }.reverse + + if params[:status].present? && params[:status] != "all" + @syncs = @syncs.select { |s| s.status == params[:status] } + end + + # Summary is now based on the LATEST status of each target, not historical counts + @summary = { + pending: @syncs.count { |s| s.status == "pending" }, + syncing: @syncs.count { |s| s.status == "syncing" }, + failed: @syncs.count { |s| s.status == "failed" }, + stale: @syncs.count { |s| s.status == "stale" }, + completed: @syncs.count { |s| s.status == "completed" && s.created_at > 24.hours.ago } + } + end + + def retry_sync + sync = Sync.for_family(Current.family).find(params[:id]) + if sync.retry! + redirect_to settings_sync_monitor_path, notice: "Sync re-queued successfully." + else + redirect_to settings_sync_monitor_path, alert: "Sync cannot be retried in its current state." + end + end + + def retry_all_failed + failed_syncs = Sync.for_family(Current.family).where(status: "failed") + count = failed_syncs.count + if count > 0 + failed_syncs.find_each(&:retry!) + redirect_to settings_sync_monitor_path, notice: "Successfully re-queued #{count} failed syncs." + else + redirect_to settings_sync_monitor_path, alert: "No failed syncs found to retry." + end + end + + def dismiss_sync + sync = Sync.for_family(Current.family).find(params[:id]) + if sync.dismiss! + redirect_to settings_sync_monitor_path, notice: "Sync dismissed." + else + redirect_to settings_sync_monitor_path, alert: "Sync cannot be dismissed in its current state." + end + end + + def dismiss_all_stale + stale_or_failed = Sync.for_family(Current.family).where(status: %w[stale failed]) + count = stale_or_failed.count + if count > 0 + stale_or_failed.destroy_all + redirect_to settings_sync_monitor_path, notice: "Cleared #{count} stale/failed syncs." + else + redirect_to settings_sync_monitor_path, alert: "No stale or failed syncs to clear." + end + end + + def sync_all + Current.family.sync_later + redirect_to settings_sync_monitor_path, notice: "Full sync started for all accounts." + end + + def sync_target + syncable = nil + + if params[:id].present? + sync = Sync.for_family(Current.family).find_by(id: params[:id]) + syncable = sync&.syncable + elsif params[:syncable_key].present? + type, id = params[:syncable_key].split(":") + + case type + when "Family" + syncable = Current.family if id == Current.family.id.to_s + when "Account" + syncable = Current.family.accounts.find_by(id: id) + when "PlaidItem" + syncable = Current.family.plaid_items.find_by(id: id) + when "SimpleFinItem" + syncable = Current.family.simplefin_items.find_by(id: id) + when "LunchflowItem" + syncable = Current.family.lunchflow_items.find_by(id: id) + end + end + + if syncable.present? && syncable.respond_to?(:sync_later) + syncable.sync_later + + label = if syncable.is_a?(Family) + "Family Sync" + elsif syncable.is_a?(Account) + provider = syncable.account_providers.first&.adapter&.item + provider_name = provider&.class&.name&.gsub("Item", "") || "Manual" + "#{provider_name} - #{syncable.name}" + elsif syncable.respond_to?(:name) && syncable.name.present? + "#{syncable.class.name.gsub('Item', '')} connection (#{syncable.name})" + else + "#{syncable.class.name.gsub('Item', '')} connection" + end + + redirect_to settings_sync_monitor_path, notice: "Sync triggered for #{label}." + else + redirect_to settings_sync_monitor_path, alert: "Sync target not found, unauthorized, or cannot be synced." + end + end + + private + + def ensure_admin + redirect_to root_path, alert: "Not authorized" unless Current.user.admin? + end +end diff --git a/app/controllers/simplefin_items_controller.rb b/app/controllers/simplefin_items_controller.rb index 76663089..ddb82094 100644 --- a/app/controllers/simplefin_items_controller.rb +++ b/app/controllers/simplefin_items_controller.rb @@ -1,5 +1,6 @@ class SimplefinItemsController < ApplicationController include SimplefinItems::MapsHelper + before_action :ensure_simplefin_enabled, only: [ :index, :show, :edit, :update, :new, :create, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup, :errors, :select_existing_account, :link_existing_account ] before_action :set_simplefin_item, only: [ :show, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup, :errors ] def index @@ -470,6 +471,9 @@ def errors private + def ensure_simplefin_enabled + redirect_to settings_providers_path, alert: "SimpleFIN integration is disabled." unless Setting.simplefin_enabled + end def set_simplefin_item @simplefin_item = Current.family.simplefin_items.find(params[:id]) diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 9fda9991..4ba98545 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -17,6 +17,7 @@ module SettingsHelper # Advanced section { name: "AI Prompts", path: :settings_ai_prompts_path, condition: :admin_user? }, { name: "LLM Usage", path: :settings_llm_usage_path, condition: :admin_user? }, + { name: "Sync Monitor", path: :settings_sync_monitor_path, condition: :admin_user? }, { name: "API Key", path: :settings_api_key_path, condition: :admin_user? }, { name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted_and_admin? }, { name: "Bank Sync Providers", path: :settings_providers_path, condition: :admin_user? }, @@ -69,6 +70,31 @@ def settings_nav_footer_mobile end end + def sync_target_options + options = [ [ "Family Sync (All Accounts)", "Family:#{Current.family.id}" ] ] + + # Add items/connections + items = [] + Current.family.plaid_items.each { |item| items << [ "Plaid - #{item.name.presence || item.institution_id || 'Active'}", "PlaidItem:#{item.id}" ] } + Current.family.simplefin_items.each { |item| items << [ "SimpleFIN - #{item.name.presence || item.institution_name.presence || 'Active'}", "SimpleFinItem:#{item.id}" ] } + Current.family.lunchflow_items.each { |item| items << [ "Lunchflow - #{item.name.presence || item.institution_name.presence || 'Active'}", "LunchflowItem:#{item.id}" ] } + + options << [ "-- Connections --", "", { disabled: true } ] if items.any? + options += items + + # Add accounts + accounts = Current.family.accounts.order(:name).map do |account| + provider = account.account_providers.first&.adapter&.item + provider_name = provider&.class&.name&.gsub("Item", "") || "Manual" + [ "#{provider_name} - #{account.name}", "Account:#{account.id}" ] + end + + options << [ "-- Accounts --", "", { disabled: true } ] if accounts.any? + options += accounts + + options + end + private def not_self_hosted? !self_hosted? diff --git a/app/javascript/controllers/cashflow_fullscreen_controller.js b/app/javascript/controllers/cashflow_fullscreen_controller.js index 1f240f4c..e39b5ee4 100644 --- a/app/javascript/controllers/cashflow_fullscreen_controller.js +++ b/app/javascript/controllers/cashflow_fullscreen_controller.js @@ -18,7 +18,7 @@ export default class extends Controller { this.isUnmounted = false; // Ensure we have required data - if (!this.sankeyDataValue || !this.sankeyDataValue.nodes || !this.sankeyDataValue.links) { + if (!this.sankeyDataValue?.nodes || !this.sankeyDataValue.links) { console.warn("Cashflow fullscreen: Missing required sankey data"); this.element.style.display = "none"; } diff --git a/app/javascript/controllers/connection_tester_controller.js b/app/javascript/controllers/connection_tester_controller.js new file mode 100644 index 00000000..fbd3aeac --- /dev/null +++ b/app/javascript/controllers/connection_tester_controller.js @@ -0,0 +1,68 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["status", "button"]; + static values = { + providerKey: String, + }; + + async test() { + this.statusTarget.innerHTML = ` +
+ + + + + Testing connection... +
+ `; + this.buttonTarget.disabled = true; + + try { + const response = await fetch("/settings/providers/test_connection", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content"), + }, + body: JSON.stringify({ provider_key: this.providerKeyValue }), + }); + + const data = await response.json(); + + if (data.success) { + this.statusTarget.innerHTML = ` +
+ +
+

Connection Valid

+

${data.message}

+
+
+ `; + } else { + this.statusTarget.innerHTML = ` +
+ +
+

Connection Failed

+

${data.message}

+
+
+ `; + } + } catch (_error) { + this.statusTarget.innerHTML = ` +
+ +
+

Error

+

Failed to contact the test endpoint.

+
+
+ `; + } finally { + this.buttonTarget.disabled = false; + } + } +} diff --git a/app/javascript/controllers/loan_wizard_controller.js b/app/javascript/controllers/loan_wizard_controller.js index b207dcdb..5a137ac3 100644 --- a/app/javascript/controllers/loan_wizard_controller.js +++ b/app/javascript/controllers/loan_wizard_controller.js @@ -392,7 +392,7 @@ export default class extends Controller { return false; } - if (!counterpartyName.value || !counterpartyName.value.trim()) { + if (!counterpartyName.value?.trim()) { this.showError("Please enter the lender name"); this.highlightField(counterpartyName); return false; diff --git a/app/javascript/controllers/sync_monitor_controller.js b/app/javascript/controllers/sync_monitor_controller.js new file mode 100644 index 00000000..f9ff99d3 --- /dev/null +++ b/app/javascript/controllers/sync_monitor_controller.js @@ -0,0 +1,75 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["tab", "tbody"]; + static values = { + active: Boolean, + }; + + connect() { + this.startPollingIfActive(); + } + + disconnect() { + this.stopPolling(); + } + + activeValueChanged() { + this.startPollingIfActive(); + } + + startPollingIfActive() { + this.stopPolling(); + if (this.activeValue) { + this.pollingTimer = setInterval(() => { + const frame = document.getElementById("sync_monitor_frame"); + if (frame) { + if (typeof frame.reload === "function") { + frame.reload(); + } else { + const currentSrc = frame.src; + frame.src = currentSrc; + } + } + }, 30000); // Poll every 30 seconds + } + } + + stopPolling() { + if (this.pollingTimer) { + clearInterval(this.pollingTimer); + this.pollingTimer = null; + } + } + + filter(event) { + const filter = event.currentTarget.dataset.filter; + const activeClasses = ["bg-container", "text-primary", "shadow-xs"]; + const inactiveClasses = ["text-secondary", "hover:text-primary"]; + + // Update active state of tabs + this.tabTargets.forEach((tab) => { + const isSelected = tab.dataset.filter === filter; + if (isSelected) { + tab.classList.add(...activeClasses); + tab.classList.remove(...inactiveClasses); + } else { + tab.classList.remove(...activeClasses); + tab.classList.add(...inactiveClasses); + } + }); + + // Filter table rows + if (this.hasTbodyTarget) { + const rows = this.tbodyTarget.querySelectorAll("tr"); + rows.forEach((row) => { + const status = row.dataset.status; + if (filter === "all" || status === filter) { + row.classList.remove("hidden"); + } else { + row.classList.add("hidden"); + } + }); + } + } +} diff --git a/app/models/family/lunchflow_connectable.rb b/app/models/family/lunchflow_connectable.rb index bfc2e204..9842ecb6 100644 --- a/app/models/family/lunchflow_connectable.rb +++ b/app/models/family/lunchflow_connectable.rb @@ -6,7 +6,6 @@ module Family::LunchflowConnectable end def can_connect_lunchflow? - # Check if the API key is configured - Provider::LunchflowAdapter.configured? + Setting.lunchflow_enabled && Provider::LunchflowAdapter.configured? end end diff --git a/app/models/family/plaid_connectable.rb b/app/models/family/plaid_connectable.rb index b0f1f80d..44ab5ded 100644 --- a/app/models/family/plaid_connectable.rb +++ b/app/models/family/plaid_connectable.rb @@ -6,12 +6,12 @@ module Family::PlaidConnectable end def can_connect_plaid_us? - plaid(:us).present? + Setting.plaid_enabled && plaid(:us).present? end # If Plaid provider is configured and user is in the EU region def can_connect_plaid_eu? - plaid(:eu).present? && self.eu? + Setting.plaid_enabled && plaid(:eu).present? && self.eu? end def create_plaid_item!(public_token:, item_name:, region:) diff --git a/app/models/family/simplefin_connectable.rb b/app/models/family/simplefin_connectable.rb index 2c48eca8..ce4869bc 100644 --- a/app/models/family/simplefin_connectable.rb +++ b/app/models/family/simplefin_connectable.rb @@ -6,7 +6,7 @@ module Family::SimplefinConnectable end def can_connect_simplefin? - true # SimpleFin doesn't have regional restrictions like Plaid + Setting.simplefin_enabled end def create_simplefin_item!(setup_token:, item_name: nil) diff --git a/app/models/lunchflow_item/syncer.rb b/app/models/lunchflow_item/syncer.rb index b1315a12..30fee82a 100644 --- a/app/models/lunchflow_item/syncer.rb +++ b/app/models/lunchflow_item/syncer.rb @@ -8,6 +8,10 @@ def initialize(lunchflow_item) end def perform_sync(sync) + unless Setting.lunchflow_enabled + raise "Lunch Flow sync is disabled in settings" + end + # Phase 1: Import data from Lunchflow API sync.update!(status_text: "Importing accounts from Lunchflow...") if sync.respond_to?(:status_text) lunchflow_item.import_latest_lunchflow_data diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb index 9d7e3164..f021baba 100644 --- a/app/models/plaid_item/syncer.rb +++ b/app/models/plaid_item/syncer.rb @@ -8,6 +8,10 @@ def initialize(plaid_item) end def perform_sync(sync) + unless Setting.plaid_enabled + raise "Plaid sync is disabled in settings" + end + # Phase 1: Import data from Plaid API sync.update!(status_text: "Importing accounts from Plaid...") if sync.respond_to?(:status_text) plaid_item.import_latest_plaid_data diff --git a/app/models/provider/plaid_adapter.rb b/app/models/provider/plaid_adapter.rb index 0e60a4e8..805397d5 100644 --- a/app/models/provider/plaid_adapter.rb +++ b/app/models/provider/plaid_adapter.rb @@ -28,13 +28,13 @@ class Provider::PlaidAdapter < Provider::Base field :client_id, label: "Client ID", - required: false, + required: true, env_key: "PLAID_CLIENT_ID", description: "Your Plaid Client ID from the Plaid Dashboard" field :secret, label: "Secret Key", - required: false, + required: true, secret: true, env_key: "PLAID_SECRET", description: "Your Plaid Secret from the Plaid Dashboard" diff --git a/app/models/provider/plaid_eu_adapter.rb b/app/models/provider/plaid_eu_adapter.rb index 36c2eae4..a1baf256 100644 --- a/app/models/provider/plaid_eu_adapter.rb +++ b/app/models/provider/plaid_eu_adapter.rb @@ -24,13 +24,13 @@ class Provider::PlaidEuAdapter field :client_id, label: "Client ID", - required: false, + required: true, env_key: "PLAID_EU_CLIENT_ID", description: "Your Plaid Client ID from the Plaid Dashboard for EU region" field :secret, label: "Secret Key", - required: false, + required: true, secret: true, env_key: "PLAID_EU_SECRET", description: "Your Plaid Secret from the Plaid Dashboard for EU region" diff --git a/app/models/setting.rb b/app/models/setting.rb index 571070a6..535f1d74 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -16,6 +16,11 @@ class ValidationError < StandardError; end pending_env = ENV["SIMPLEFIN_INCLUDE_PENDING"].to_s.strip.downcase field :syncs_include_pending, type: :boolean, default: pending_env.blank? ? true : !%w[0 false no off].include?(pending_env) + # Sync provider global enable/disable toggles + field :plaid_enabled, type: :boolean, default: ENV["DISABLE_PLAID"] != "true" + field :simplefin_enabled, type: :boolean, default: ENV["DISABLE_SIMPLEFIN"] != "true" + field :lunchflow_enabled, type: :boolean, default: ENV["DISABLE_LUNCHFLOW"] != "true" + # Upstream: Single hash field for all dynamic provider credentials and other dynamic settings # This allows unlimited dynamic fields without declaring them upfront field :dynamic_fields, type: :hash, default: {} diff --git a/app/models/simplefin_item/syncer.rb b/app/models/simplefin_item/syncer.rb index 977112e0..411fd4a0 100644 --- a/app/models/simplefin_item/syncer.rb +++ b/app/models/simplefin_item/syncer.rb @@ -8,6 +8,10 @@ def initialize(simplefin_item) end def perform_sync(sync) + unless Setting.simplefin_enabled + raise "SimpleFIN sync is disabled in settings" + end + if sync.respond_to?(:sync_stats) && (sync.sync_stats || {})["balances_only"] sync.update!(status_text: "Refreshing balances only...") if sync.respond_to?(:status_text) mark_import_started(sync) diff --git a/app/models/sync.rb b/app/models/sync.rb index 76b925a5..a0ccd01f 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -18,6 +18,25 @@ class Sync < ApplicationRecord scope :ordered, -> { order(created_at: :desc) } scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) } scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) } + scope :recent, -> { order(created_at: :desc).limit(100) } + scope :with_status, ->(status) { where(status: status) } + + # Returns syncs belonging to the given family — either directly (Family syncable) + # or through accounts and provider items owned by the family. + scope :for_family, ->(family) { + where( + "(syncs.syncable_type = 'Family' AND syncs.syncable_id = :family_id) OR " \ + "(syncs.syncable_type = 'Account' AND syncs.syncable_id IN (:account_ids)) OR " \ + "(syncs.syncable_type = 'PlaidItem' AND syncs.syncable_id IN (:plaid_item_ids)) OR " \ + "(syncs.syncable_type = 'SimpleFinItem' AND syncs.syncable_id IN (:simplefin_item_ids)) OR " \ + "(syncs.syncable_type = 'LunchflowItem' AND syncs.syncable_id IN (:lunchflow_item_ids))", + family_id: family.id, + account_ids: family.accounts.select(:id), + plaid_item_ids: family.plaid_items.select(:id), + simplefin_item_ids: family.simplefin_items.select(:id), + lunchflow_item_ids: family.lunchflow_items.select(:id) + ) + } after_commit :update_family_sync_timestamp after_commit :enqueue_sync_job, on: :create @@ -204,6 +223,46 @@ def expand_window_if_needed(new_window_start_date, new_window_end_date) ) end + def retry! + return false unless failed? || stale? + update!(status: "pending", error: nil, failed_at: nil, syncing_at: nil, pending_at: Time.current) + enqueue_sync_job + true + end + + def dismiss! + return false unless failed? || stale? + destroy! + true + end + + def duration + return nil unless syncing_at + end_time = completed_at || failed_at || Time.current + (end_time - syncing_at).round + end + + def syncable_label + return "#{syncable_type} (Deleted)" if syncable.nil? + + case syncable_type + when "Family" + "Family Sync" + when "Account" + provider = syncable.account_providers.first&.adapter&.item + provider_name = provider&.class&.name&.gsub("Item", "") || "Manual" + "#{provider_name} - #{syncable.name}" + when "PlaidItem" + "Plaid Connection (#{syncable.name.presence || syncable.institution_id || 'Active'})" + when "SimpleFinItem" + "SimpleFIN Connection (#{syncable.name.presence || syncable.institution_name.presence || 'Active'})" + when "LunchflowItem" + "Lunchflow Connection (#{syncable.name.presence || syncable.institution_name.presence || 'Active'})" + else + "#{syncable_type} #{syncable_id}" + end + end + private def log_status_change Rails.logger.info("changing from #{aasm.from_state} to #{aasm.to_state} (event: #{aasm.current_event})") diff --git a/app/views/layouts/settings.html.erb b/app/views/layouts/settings.html.erb index db881e9f..b3679981 100644 --- a/app/views/layouts/settings.html.erb +++ b/app/views/layouts/settings.html.erb @@ -6,7 +6,7 @@
-
+
<% if content_for?(:breadcrumbs) %> <%= yield :breadcrumbs %> <% else %> diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index b479d55c..ff0590b6 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -28,8 +28,6 @@ <% end %>
- <%= render "shared/sync_health_banner" %> - <% if Current.family.accounts.any? %> <%# KPI Cards Section - Overview Metrics %> <%= render "pages/dashboard/kpi_cards", diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index e5cca649..9ef015c1 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -5,7 +5,7 @@ nav_sections = [ items: [ { label: t(".accounts_label"), path: accounts_path, icon: "layers" }, { label: "Providers", path: settings_provider_directories_path, icon: "building-2" }, - { label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" }, + { label: "Bank Sync", path: settings_providers_path, icon: "banknote" }, { label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" }, { label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" }, { label: t(".security_label"), path: settings_security_path, icon: "shield-check" }, @@ -28,11 +28,10 @@ nav_sections = [ items: [ { label: t(".ai_prompts_label"), path: settings_ai_prompts_path, icon: "bot" }, { label: "LLM Usage", path: settings_llm_usage_path, icon: "activity" }, + { label: "Sync Monitor", path: settings_sync_monitor_path, icon: "monitor" }, { 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: "Bank Sync Providers", path: settings_providers_path, icon: "plug" }, - { label: t(".imports_label"), path: imports_path, icon: "download" }, - { label: "SimpleFin", path: simplefin_items_path, icon: "building-2" } + { label: t(".imports_label"), path: imports_path, icon: "download" } ] } : nil ), @@ -47,6 +46,7 @@ nav_sections = [ ] %> +