diff --git a/CLAUDE.md b/CLAUDE.md index 5464699de..a1da3b7bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,7 @@ Two primary data ingestion methods: - 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. + - Set `UP_DEBUG_RAW=1` to enable raw Up payload debug logging. DEV-ONLY: the dump contains PII and is gated to local environments, so it never logs in managed/production. Provider support notes: - SimpleFIN: supports pending + FX metadata (stored under `extra["simplefin"]`). diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 6ce5e02c6..bd62cd9f8 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -16,6 +16,7 @@ class AccountsController < ApplicationController @simplefin_items = visible_provider_items(family.simplefin_items.ordered.includes(:syncs)) @lunchflow_items = visible_provider_items(family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts)) @akahu_items = visible_provider_items(family.akahu_items.ordered.includes(:syncs, :akahu_accounts)) + @up_items = visible_provider_items(family.up_items.ordered.includes(:syncs, :up_accounts)) @enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.includes(:syncs)) @coinstats_items = visible_provider_items(family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs)) @mercury_items = visible_provider_items(family.mercury_items.ordered.includes(:syncs, :mercury_accounts)) @@ -334,6 +335,13 @@ class AccountsController < ApplicationController @akahu_sync_stats_map[item.id] = latest_sync&.sync_stats || {} end + # Up sync stats + @up_sync_stats_map = {} + @up_items.each do |item| + latest_sync = item.syncs.ordered.first + @up_sync_stats_map[item.id] = latest_sync&.sync_stats || {} + end + # Enable Banking sync stats @enable_banking_sync_stats_map = {} @enable_banking_latest_sync_error_map = {} diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 1a3a03ca1..8af7dd0ce 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -183,6 +183,7 @@ class Settings::ProvidersController < ApplicationController # them (see prepare_show_context). FAMILY_PANELS = [ { key: "akahu", title: "Akahu", turbo_id: "akahu", partial: "akahu_panel" }, + { key: "up", title: "Up", turbo_id: "up", partial: "up_panel" }, { key: "lunchflow", title: "Lunch Flow", turbo_id: "lunchflow", partial: "lunchflow_panel" }, { key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" }, { key: "enable_banking", title: "Enable Banking", turbo_id: "enable_banking", partial: "enable_banking_panel" }, @@ -203,6 +204,7 @@ class Settings::ProvidersController < ApplicationController # Maps panel key → ActiveRecord model name for sync health queries PANEL_SYNCABLE_TYPES = { "akahu" => "AkahuItem", + "up" => "UpItem", "simplefin" => "SimplefinItem", "lunchflow" => "LunchflowItem", "enable_banking" => "EnableBankingItem", @@ -222,6 +224,8 @@ class Settings::ProvidersController < ApplicationController case provider_key when "akahu" @akahu_items = Current.family.akahu_items.active.ordered + when "up" + @up_items = Current.family.up_items.active.ordered when "simplefin" @simplefin_items = Current.family.simplefin_items.ordered when "lunchflow" @@ -260,6 +264,7 @@ class Settings::ProvidersController < ApplicationController end @akahu_items = Current.family.akahu_items.active.ordered + @up_items = Current.family.up_items.active.ordered # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials @simplefin_items = Current.family.simplefin_items.where.not(access_url: [ nil, "" ]).ordered.select(:id) @lunchflow_items = Current.family.lunchflow_items.where.not(api_key: [ nil, "" ]).ordered.select(:id) @@ -293,6 +298,7 @@ class Settings::ProvidersController < ApplicationController def family_panel_items { "akahu" => @akahu_items, + "up" => @up_items, "simplefin" => @simplefin_items, "lunchflow" => @lunchflow_items, "enable_banking" => @enable_banking_items, diff --git a/app/controllers/up_items_controller.rb b/app/controllers/up_items_controller.rb new file mode 100644 index 000000000..19998bd1c --- /dev/null +++ b/app/controllers/up_items_controller.rb @@ -0,0 +1,442 @@ +class UpItemsController < ApplicationController + before_action :set_up_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, only: [ + :new, :create, :preload_accounts, :select_accounts, :link_accounts, + :select_existing_account, :link_existing_account, :edit, :update, + :destroy, :sync, :setup_accounts, :complete_account_setup + ] + + # List the family's active Up connections in settings. + def index + @up_items = Current.family.up_items.active.ordered + render layout: "settings" + end + + # Show a single Up connection. + def show + end + + # Render the new-connection form. + def new + @up_item = Current.family.up_items.build + end + + # Render the edit-connection form. + def edit + end + + # Create an Up connection and kick off its first sync. + def create + @up_item = Current.family.up_items.build(up_item_params) + @up_item.name = t("up_items.provider_panel.default_connection_name") if @up_item.name.blank? + + if @up_item.save + @up_item.sync_later + render_provider_panel(:notice, t(".success")) + else + render_provider_panel_error(@up_item.errors.full_messages.join(", ")) + end + end + + # Update connection settings (name/token/start date). + def update + if @up_item.update(update_params) + render_provider_panel(:notice, t(".success")) + else + render_provider_panel_error(@up_item.errors.full_messages.join(", ")) + end + end + + # Unlink all accounts then schedule deletion of the connection. + def destroy + results = @up_item.unlink_all!(dry_run: false) + + if results.any? { |result| result[:error].present? } + DebugLogEntry.capture( + category: "provider_sync_error", + level: "warn", + message: "Up unlink during destroy failed", + source: self.class.name, + provider_key: "up", + family: @up_item.family, + metadata: { up_item_id: @up_item.id, failures: results.select { |r| r[:error].present? } } + ) + redirect_to settings_providers_path, alert: t(".unlink_failed"), status: :see_other + return + end + + @up_item.destroy_later + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + rescue => e + DebugLogEntry.capture( + category: "provider_sync_error", + level: "warn", + message: "Up unlink during destroy failed", + source: self.class.name, + provider_key: "up", + family: @up_item&.family, + metadata: { up_item_id: @up_item&.id, error_class: e.class.name, error_message: e.message } + ) + redirect_to settings_providers_path, alert: t(".unlink_failed"), status: :see_other + end + + # Trigger a manual sync unless one is already running. + def sync + @up_item.sync_later unless @up_item.syncing? + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + # Fetch accounts from the API (JSON) so the UI can show whether any exist. + def preload_accounts + up_item = requested_up_item + return render json: { success: false, error: "no_credentials", has_accounts: false } unless up_item.credentials_configured? + + error = fetch_up_accounts_from_api(up_item) + render json: { success: error.blank?, error_message: error, has_accounts: up_item.up_accounts.exists? } + end + + # Render the picker of unlinked Up accounts for a new Sure account. + def select_accounts + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + @up_item = requested_up_item + + unless @up_item.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_credentials_configured") + return + end + + @api_error = fetch_up_accounts_from_api(@up_item) + @up_accounts = @up_item.up_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .order(:name) + + render layout: false + end + + # Create new Sure accounts for the selected Up accounts and link them. + def link_accounts + up_item = requested_up_item + unless up_item.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_credentials_configured") + return + end + + selected_ids = Array(params[:account_ids]).compact_blank + if selected_ids.empty? + redirect_to select_accounts_up_items_path(up_item_id: up_item.id, accountable_type: params[:accountable_type], return_to: safe_return_to_path), alert: t(".no_accounts_selected") + return + end + + account_type = params[:accountable_type].presence || "Depository" + unless Provider::UpAdapter.supported_account_types.include?(account_type) + redirect_to new_account_path, alert: t(".unsupported_account_type") + return + end + + created_accounts = [] + + ActiveRecord::Base.transaction do + up_item.up_accounts.where(id: selected_ids).find_each do |up_account| + next if up_account.account_provider.present? + + account = create_account_from_up(up_account, account_type) + AccountProvider.create!(account: account, provider: up_account) + created_accounts << account + end + end + + up_item.sync_later if created_accounts.any? + + if created_accounts.any? + redirect_to safe_return_to_path || accounts_path, notice: t(".success", count: created_accounts.count) + else + redirect_to select_accounts_up_items_path(up_item_id: up_item.id, accountable_type: account_type, return_to: safe_return_to_path), alert: t(".link_failed") + end + end + + # Render the picker to attach an Up account to an existing Sure account. + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + + if @account.account_providers.exists? + redirect_to accounts_path, alert: t(".account_already_linked") + return + end + + @up_item = requested_up_item + unless @up_item.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_credentials_configured") + return + end + + @api_error = fetch_up_accounts_from_api(@up_item) + @up_accounts = @up_item.up_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .order(:name) + @return_to = safe_return_to_path + + render layout: false + end + + # Link a selected Up account to an existing Sure account and sync. + def link_existing_account + account = Current.family.accounts.find(params[:account_id]) + up_item = requested_up_item + + unless up_item.credentials_configured? + redirect_to settings_providers_path, alert: t("up_items.select_existing_account.no_credentials_configured") + return + end + + if params[:up_account_id].blank? + redirect_to accounts_path, alert: t(".no_account_selected") + return + end + + up_account = up_item.up_accounts.find_by(id: params[:up_account_id]) + unless up_account + redirect_to accounts_path, alert: t(".no_account_selected") + return + end + + if account.account_providers.exists? + redirect_to accounts_path, alert: t(".account_already_linked") + return + end + + if up_account.account_provider.present? + redirect_to accounts_path, alert: t(".up_account_already_linked") + return + end + + AccountProvider.create!(account: account, provider: up_account) + up_item.sync_later + + redirect_to safe_return_to_path || accounts_path, notice: t(".success", account_name: account.name) + end + + # Render the post-sync setup screen for accounts still needing a decision. + def setup_accounts + @api_error = fetch_up_accounts_from_api(@up_item) + @up_accounts = @up_item.up_accounts.needs_setup.order(:name) + @account_type_options = [ + [ t(".account_types.skip"), "skip" ], + [ t(".account_types.depository"), "Depository" ], + [ t(".account_types.loan"), "Loan" ] + ] + @up_account_type_suggestions = @up_accounts.each_with_object({}) do |up_account, suggestions| + suggestions[up_account.id] = up_account.suggested_account_type || "skip" + end + end + + # Apply the user's per-account setup choices (create/link or skip). + def complete_account_setup + account_types = params[:account_types] || {} + created_accounts = [] + skipped_count = 0 + + ActiveRecord::Base.transaction do + account_types.each do |up_account_id, selected_type| + up_account = @up_item.up_accounts.find_by(id: up_account_id) + next unless up_account + + if selected_type.blank? || selected_type == "skip" + # Persist the skip so the account stops resurfacing as "needs setup" on every sync. + up_account.update!(ignored: true) unless up_account.account_provider.present? + skipped_count += 1 + next + end + + next unless Provider::UpAdapter.supported_account_types.include?(selected_type) + next if up_account.account_provider.present? + + account = create_account_from_up(up_account, selected_type) + AccountProvider.create!(account: account, provider: up_account) + created_accounts << account + end + end + + @up_item.sync_later if created_accounts.any? + + flash[:notice] = if created_accounts.any? + t(".success", count: created_accounts.count) + elsif skipped_count.positive? + t(".all_skipped") + else + t(".no_accounts") + end + + redirect_to accounts_path, status: :see_other + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + DebugLogEntry.capture( + category: "provider_sync_error", + level: "error", + message: "Up account setup failed", + source: self.class.name, + provider_key: "up", + family: @up_item&.family, + metadata: { up_item_id: @up_item&.id, error_class: e.class.name, error_message: e.message } + ) + redirect_to accounts_path, alert: t(".creation_failed"), status: :see_other + end + + private + + # Load the requested item scoped to the current family. + def set_up_item + @up_item = Current.family.up_items.find(params[:id]) + end + + # Strong params for creating/updating a connection. + def up_item_params + params.require(:up_item).permit(:name, :sync_start_date, :access_token) + end + + # Params for update, dropping a blank token so it isn't overwritten. + def update_params + permitted = up_item_params + permitted = permitted.except(:access_token) if permitted[:access_token].blank? + permitted + end + + # Load the active item referenced by up_item_id, scoped to the family. + def requested_up_item + Current.family.up_items.active.find_by!(id: params[:up_item_id]) + end + + # Fetch and upsert account snapshots from the API; returns an error string or nil. + def fetch_up_accounts_from_api(up_item) + return t("up_items.setup_accounts.no_credentials") unless up_item.credentials_configured? + + provider = up_item.up_provider + accounts = provider.get_accounts + accounts.each do |account_data| + account = account_data.with_indifferent_access + account_id = account[:id].presence + next if account_id.blank? || account[:displayName].blank? + + up_account = up_item.up_accounts.find_or_initialize_by(account_id: account_id.to_s) + up_account.upsert_up_snapshot!(account) + end + + nil + rescue Provider::Up::UpError => e + DebugLogEntry.capture( + category: "provider_sync_error", + level: "error", + message: "Up API error while fetching accounts", + source: self.class.name, + provider_key: "up", + family: up_item.family, + metadata: { up_item_id: up_item.id, error_class: e.class.name, error_message: e.message } + ) + t("up_items.setup_accounts.api_error") + rescue StandardError => e + DebugLogEntry.capture( + category: "provider_sync_error", + level: "error", + message: "Unexpected error fetching Up accounts", + source: self.class.name, + provider_key: "up", + family: up_item.family, + metadata: { up_item_id: up_item.id, error_class: e.class.name, error_message: e.message } + ) + t("up_items.setup_accounts.api_error") + end + + # Create and sync a Sure account from an Up account snapshot. + def create_account_from_up(up_account, account_type) + # Linking an account clears any prior skip so a future unlink re-prompts for setup. + up_account.update!(ignored: false) if up_account.ignored? + + balance = up_account.current_balance || 0 + balance = balance.abs if account_type == "Loan" + subtype = if account_type == "Depository" && up_account.suggested_account_type == account_type + up_account.suggested_subtype + end + + Account.create_and_sync( + { + family: Current.family, + name: up_account.name, + balance: balance, + cash_balance: balance, + currency: up_account.currency || "AUD", + accountable_type: account_type, + accountable_attributes: subtype.present? ? { subtype: subtype } : {} + }, + skip_initial_sync: true + ) + end + + # Re-render the providers settings panel (Turbo) or redirect with a flash. + def render_provider_panel(flash_type, message) + if turbo_frame_request? + flash.now[flash_type] = message + @up_items = Current.family.up_items.active.ordered + render turbo_stream: [ + turbo_stream.replace( + "up-providers-panel", + partial: "settings/providers/up_panel", + locals: { up_items: @up_items } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, { flash_type => message, status: :see_other } + end + end + + # Re-render the providers panel with an error (Turbo) or redirect with alert. + def render_provider_panel_error(message) + @error_message = message + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "up-providers-panel", + partial: "settings/providers/up_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :see_other + end + end + + # Validate the return_to param as a safe in-app relative path, or nil. + def safe_return_to_path + return nil if params[:return_to].blank? + + return_to = params[:return_to].to_s.strip + return nil unless return_to.start_with?("/") + return nil if return_to[1] == "/" || return_to[1] == "\\" + return nil if return_to.include?("\\") || return_to.match?(/[[:cntrl:]]/) + return nil if encoded_path_separator?(return_to) + + uri = URI.parse(return_to) + return nil unless uri.relative? + + Rails.application.routes.recognize_path(uri.path, method: :get) + + return_to + rescue URI::InvalidURIError, ActionController::RoutingError + nil + end + + # True if the path's second char is a percent-encoded slash/backslash + # (used to block protocol-relative redirect bypasses). + def encoded_path_separator?(return_to) + encoded_second_character = return_to[1, 3] + return false unless encoded_second_character&.start_with?("%") + + decoded = URI.decode_www_form_component(encoded_second_character) + decoded == "/" || decoded == "\\" + rescue ArgumentError + true + end +end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 7d006ad58..6d821ecdb 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -60,6 +60,9 @@ module SettingsHelper when "akahu" return { status: :off } unless @akahu_items&.any? sync_based_summary(key) + when "up" + return { status: :off } unless @up_items&.any? + sync_based_summary(key) when "simplefin" return { status: :off } unless @simplefin_items&.any? sync_based_summary(key) diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index acd2fd7cf..61a095e32 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -770,6 +770,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true + OR (transactions.extra -> 'up' ->> 'pending')::boolean = true SQL .order(date: :desc) # Prefer most recent pending transaction @@ -818,6 +819,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true + OR (transactions.extra -> 'up' ->> 'pending')::boolean = true SQL # If merchant_id is provided, prioritize matching by merchant @@ -889,6 +891,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true + OR (transactions.extra -> 'up' ->> 'pending')::boolean = true SQL # For low confidence, require BOTH merchant AND name match (stronger signal needed) diff --git a/app/models/coinstats_item/importer.rb b/app/models/coinstats_item/importer.rb index b8fec5209..220b69d2a 100644 --- a/app/models/coinstats_item/importer.rb +++ b/app/models/coinstats_item/importer.rb @@ -145,8 +145,13 @@ class CoinstatsItem::Importer return [] if wallets.empty? Rails.logger.info "CoinstatsItem::Importer - Fetching balances for #{wallets.size} wallet(s) via bulk endpoint" - # Build comma-separated string in format "blockchain:address" - wallets_param = wallets.map { |w| "#{w[:blockchain]}:#{w[:address]}" }.join(",") + # Build comma-separated string in format "blockchain:address". Sort for a + # deterministic batch order so the request is stable regardless of the + # account query order (otherwise the bulk-endpoint param varies run to run). + wallets_param = wallets + .sort_by { |w| "#{w[:blockchain]}:#{w[:address]}".downcase } + .map { |w| "#{w[:blockchain]}:#{w[:address]}" } + .join(",") response = coinstats_provider.get_wallet_balances(wallets_param) response.success? ? response.data : FETCH_FAILED rescue => e @@ -192,8 +197,13 @@ class CoinstatsItem::Importer return [] if wallets.empty? Rails.logger.info "CoinstatsItem::Importer - Fetching transactions for #{wallets.size} wallet(s) via bulk endpoint" - # Build comma-separated string in format "blockchain:address" - wallets_param = wallets.map { |w| "#{w[:blockchain]}:#{w[:address]}" }.join(",") + # Build comma-separated string in format "blockchain:address". Sort for a + # deterministic batch order so the request is stable regardless of the + # account query order (otherwise the bulk-endpoint param varies run to run). + wallets_param = wallets + .sort_by { |w| "#{w[:blockchain]}:#{w[:address]}".downcase } + .map { |w| "#{w[:blockchain]}:#{w[:address]}" } + .join(",") response = coinstats_provider.get_wallet_transactions(wallets_param) response.success? ? response.data : FETCH_FAILED rescue => e diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index 66c364660..59de42251 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -7,6 +7,7 @@ class DataEnrichment < ApplicationRecord simplefin: "simplefin", lunchflow: "lunchflow", akahu: "akahu", + up: "up", synth: "synth", ai: "ai", enable_banking: "enable_banking", diff --git a/app/models/family.rb b/app/models/family.rb index c5f8f2252..082316c1e 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -3,6 +3,7 @@ class Family < ApplicationRecord include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, AkahuConnectable, EnableBankingConnectable include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, BrexConnectable, SophtronConnectable include IndexaCapitalConnectable, IbkrConnectable + include UpConnectable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], diff --git a/app/models/family/financial_data_reset.rb b/app/models/family/financial_data_reset.rb index 021f1eab9..75141a5e4 100644 --- a/app/models/family/financial_data_reset.rb +++ b/app/models/family/financial_data_reset.rb @@ -54,6 +54,7 @@ class Family::FinancialDataReset simplefin_items snaptrade_items sophtron_items + up_items ].freeze Result = Struct.new(:user, :family, :dry_run, :before_counts, :deleted_counts, :after_counts, keyword_init: true) diff --git a/app/models/family/up_connectable.rb b/app/models/family/up_connectable.rb new file mode 100644 index 000000000..4f56e2398 --- /dev/null +++ b/app/models/family/up_connectable.rb @@ -0,0 +1,28 @@ +module Family::UpConnectable + extend ActiveSupport::Concern + + included do + has_many :up_items, dependent: :destroy + end + + # Whether this family may connect Up Bank accounts (always true). + def can_connect_up? + true + end + + # Create an Up item with the given token and trigger an initial sync. + def create_up_item!(access_token:, item_name: nil) + up_item = up_items.create!( + name: item_name || I18n.t("family.up.create_up_item.default_name"), + access_token: access_token + ) + + up_item.sync_later + up_item + end + + # True when any active Up item has usable credentials. + def has_up_credentials? + up_items.active.any?(&:credentials_configured?) + end +end diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb index 805e84397..21d9436bf 100644 --- a/app/models/provider/metadata.rb +++ b/app/models/provider/metadata.rb @@ -4,6 +4,7 @@ class Provider akahu: { region: "NZ", kinds: %w[Bank Investment], maturity: :beta, logo_text: "AK", logo_bg: "bg-emerald-600" }, simplefin: { region: "US", kinds: %w[Bank Investment], maturity: :stable, logo_text: "SF", logo_bg: "bg-blue-600" }, lunchflow: { region: "Global", kinds: %w[Bank], maturity: :stable, logo_text: "LF", logo_bg: "bg-orange-500" }, + up: { region: "AU", kinds: %w[Bank], maturity: :beta, logo_text: "UP", logo_bg: "bg-orange-600" }, enable_banking: { region: "EU", kinds: %w[Bank], maturity: :beta, logo_text: "EB", logo_bg: "bg-purple-600" }, coinstats: { region: "Global", kinds: %w[Crypto], maturity: :beta, logo_text: "CS", logo_bg: "bg-pink-600" }, mercury: { region: "US", kinds: %w[Bank], maturity: :beta, logo_text: "ME", logo_bg: "bg-cyan-600" }, diff --git a/app/models/provider/tinkoff_invest.rb b/app/models/provider/tinkoff_invest.rb index 9f1ba03b6..d6313816c 100644 --- a/app/models/provider/tinkoff_invest.rb +++ b/app/models/provider/tinkoff_invest.rb @@ -77,6 +77,7 @@ class Provider::TinkoffInvest < Provider next [] if query.empty? find_instruments(query).filter_map do |row| + next nil unless row["apiTradeAvailableFlag"] # only surface instruments the API can actually price next nil unless surfaced_kind(row["instrumentType"]) Provider::SecurityConcept::Security.new( @@ -137,26 +138,34 @@ class Provider::TinkoffInvest < Provider bond = short["instrumentType"].to_s == "bond" currency = short["currency"].to_s.upcase mic = mic_for(short["classCode"], short["exchange"]) + # Bonds quote in % of par; multiply by nominal to get a money price. A # missing/invalid nominal is a provider-data failure, not a zero price. - nominal = bond ? instrument_nominal(uid) : nil - if bond && (nominal.nil? || nominal <= 0) - raise InvalidSecurityPriceError, "Missing or invalid T-Invest bond nominal for #{symbol}" + nominal = nil + amortizing = false + if bond + info = bond_info(uid) + nominal = info[:nominal] + amortizing = info[:amortizing] + raise InvalidSecurityPriceError, "Missing or invalid T-Invest bond nominal for #{symbol}" if nominal.nil? || nominal <= 0 end - prices = candle_closes(uid, start_date, end_date).map do |date, close| - value = bond ? (close / 100) * nominal : close - Price.new(symbol: short["ticker"].to_s, date: date, price: value, currency: currency, exchange_operating_mic: mic) - end + ticker = short["ticker"].to_s + build = ->(date, raw) { Price.new(symbol: ticker, date: date, price: (bond ? (raw / 100) * nominal : raw), currency: currency, exchange_operating_mic: mic) } + + # BondBy returns only the CURRENT nominal. For an amortizing bond the par + # shrinks over time, so applying today's nominal to historical percent-of- + # par closes would underprice them — skip the candle history and return + # just the live price. Fixed-par bonds and equities use full history. + prices = (bond && amortizing) ? [] : candle_closes(uid, start_date, end_date).map { |date, close| build.call(date, close) } # The candle endpoint lags the live session; append the last price for a # range reaching today. if end_date >= Date.current last = last_price(uid) if last - value = bond ? (last / 100) * nominal : last prices.reject! { |p| p.date == Date.current } - prices << Price.new(symbol: short["ticker"].to_s, date: Date.current, price: value, currency: currency, exchange_operating_mic: mic) + prices << build.call(Date.current, last) end end @@ -220,30 +229,49 @@ class Provider::TinkoffInvest < Provider # Instruments # ================================ + # Users (and the MoexPublic resolver) paste exchange-suffixed tickers like + # "T.MOEX"/"SBER.ME"; T-Invest only knows the bare SECID, so strip the suffix + # before querying. + SUFFIX = /\.(ME|MOEX|MISX|MCX)\z/i + def find_instruments(query) - Rails.cache.fetch("tinkoff_invest:find:#{query.downcase}", expires_in: SEARCH_CACHE_TTL) do - body = post("InstrumentsService", "FindInstrument", query: query, apiTradeAvailableFlag: false) + q = query.to_s.strip.sub(SUFFIX, "") + Rails.cache.fetch("tinkoff_invest:find:#{q.downcase}", expires_in: SEARCH_CACHE_TTL) do + # Return all matches (not just apiTradeAvailableFlag) — a ticker can have + # several non-tradeable listings plus one tradeable board; resolve_short + # ranks the tradeable one first while keeping a fallback for instruments + # Tinkoff lists but can't trade via API. + body = post("InstrumentsService", "FindInstrument", query: q) body["instruments"] || [] end end - # Best FindInstrument hit for a ticker/ISIN: exact ticker (or ISIN) match, - # preferring a requested MIC, then a surfaced kind, then any result. + # Best FindInstrument hit for a ticker/ISIN. A ticker like SBER returns many + # listings (TQBR, SPB, dark boards). When the caller supplied a MIC, honor it + # FIRST so a security is never priced off another exchange's listing; then + # prefer the API-tradeable board (the one with live prices), then an exact + # ticker/ISIN match. def resolve_short(symbol, exchange_operating_mic) - key = symbol.to_s.strip - Rails.cache.fetch("tinkoff_invest:short:#{key.downcase}:#{exchange_operating_mic}", expires_in: INSTRUMENT_CACHE_TTL) do + key = symbol.to_s.strip.sub(SUFFIX, "") + # skip_nil so a transient empty/no-match response isn't cached as "unknown" + # for the full 24h TTL. + Rails.cache.fetch("tinkoff_invest:short:#{key.downcase}:#{exchange_operating_mic}", expires_in: INSTRUMENT_CACHE_TTL, skip_nil: true) do rows = find_instruments(key) up = key.upcase exact = rows.select { |r| r["ticker"].to_s.upcase == up || r["isin"].to_s.upcase == up } pool = exact.any? ? exact : rows - pool = pool.select { |r| surfaced_kind(r["instrumentType"]) } if pool.any? { |r| surfaced_kind(r["instrumentType"]) } + typed = pool.select { |r| surfaced_kind(r["instrumentType"]) } + pool = typed if typed.any? + next nil if pool.empty? - if exchange_operating_mic.present? - preferred = pool.find { |r| mic_for(r["classCode"], r["exchange"]) == exchange_operating_mic.upcase } - preferred || pool.first - else - pool.first + mic = exchange_operating_mic.to_s.upcase + pool.min_by do |r| + [ + (mic.present? && mic_for(r["classCode"], r["exchange"]) == mic) ? 0 : 1, + r["apiTradeAvailableFlag"] ? 0 : 1, + (r["ticker"].to_s.upcase == up || r["isin"].to_s.upcase == up) ? 0 : 1 + ] end end end @@ -256,8 +284,13 @@ class Provider::TinkoffInvest < Provider end end - def instrument_nominal(uid) - quotation_to_d(instrument_detail(uid)["nominal"]) + # Bond nominal + amortization flag from BondBy (the generic GetInstrumentBy + # omits nominal). `nominal` is the current (possibly amortized) par; + # `amortizing` tells the price path that historical par differs from today's. + def bond_info(uid) + body = post("InstrumentsService", "BondBy", idType: "INSTRUMENT_ID_TYPE_UID", id: uid) + ins = body["instrument"] || {} + { nominal: quotation_to_d(ins["nominal"]), amortizing: ins["amortizationFlag"] == true } end # ================================ diff --git a/app/models/provider/up.rb b/app/models/provider/up.rb new file mode 100644 index 000000000..1346b03c8 --- /dev/null +++ b/app/models/provider/up.rb @@ -0,0 +1,224 @@ +class Provider::Up + include HTTParty + extend SslConfigurable + + DEFAULT_BASE_URL = "https://api.up.com.au/api/v1".freeze + DEFAULT_PAGE_SIZE = 100 + # Host that authenticated requests (bearer token) may be sent to. Absolute URLs + # taken from API responses (links.next) are validated against this. + ALLOWED_HOST = URI.parse(DEFAULT_BASE_URL).host.freeze + + headers "User-Agent" => "Sure Finance Up Client" + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) + + attr_reader :access_token + + # Build a client with the family's Up personal access token. Raises if blank. + def initialize(access_token) + @access_token = access_token.to_s.strip + + raise UpError.new("Up access token is required", :configuration_error) if @access_token.blank? + end + + # GET /util/ping - validates the personal access token. + # Returns the parsed payload (contains meta.id / meta.statusEmoji) or raises UpError. + def ping + get("util/ping") + end + + # GET /accounts - returns an array of flattened account hashes. + # Each hash: { id:, displayName:, accountType:, ownershipType:, balance: {...}, createdAt: } + def get_accounts + fetch_all_resources("accounts").map { |resource| flatten_account(resource) } + end + + # GET /accounts/{id}/transactions - returns an array of flattened transaction hashes. + # Both HELD (pending) and SETTLED (posted) transactions are returned; callers derive + # pending status from the :status field. + def get_account_transactions(account_id:, since: nil, until_date: nil, page_size: DEFAULT_PAGE_SIZE) + query = { "page[size]" => page_size } + query["filter[since]"] = format_api_time(since) if since.present? + query["filter[until]"] = format_api_time(until_date) if until_date.present? + + path = "accounts/#{ERB::Util.url_encode(account_id.to_s)}/transactions" + fetch_all_resources(path, query: query).map { |resource| flatten_transaction(resource) } + end + + private + + RETRYABLE_ERRORS = [ + SocketError, + Net::OpenTimeout, + Net::ReadTimeout, + Errno::ECONNRESET, + Errno::ECONNREFUSED, + Errno::ETIMEDOUT, + EOFError + ].freeze + + MAX_RETRIES = 3 + INITIAL_RETRY_DELAY = 2 + + # Follows JSON:API cursor pagination via links.next (absolute URLs) until exhausted, + # concatenating each page's `data` array. + def fetch_all_resources(path, query: {}) + results = [] + payload = get(path, query: query.presence) + seen_urls = Set.new + + loop do + results.concat(Array(payload[:data])) + next_url = payload.dig(:links, :next) + break if next_url.blank? + # Guard against an API that returns the same cursor repeatedly. + break unless seen_urls.add?(next_url) + + payload = get(next_url) + end + + results + end + + # Flattens a JSON:API account resource into a single hash with id + attributes. + def flatten_account(resource) + data = resource.with_indifferent_access + attributes = data[:attributes].is_a?(Hash) ? data[:attributes] : {} + + attributes.merge( + id: data[:id], + type: data[:type] + ).with_indifferent_access + end + + # Flattens a JSON:API transaction resource, lifting attributes to the top level and + # extracting the related account/category ids from relationships. + def flatten_transaction(resource) + data = resource.with_indifferent_access + attributes = data[:attributes].is_a?(Hash) ? data[:attributes] : {} + + attributes.merge( + id: data[:id], + account_id: data.dig(:relationships, :account, :data, :id), + category_id: data.dig(:relationships, :category, :data, :id) + ).with_indifferent_access + end + + def format_api_time(value) + return value if value.is_a?(String) + # A bare Date has no time/zone, so interpret it as UTC midnight rather than + # the server's local zone (which would shift `filter[since]` by the offset). + return value.to_time(:utc).iso8601 if value.instance_of?(Date) + + value.to_time.utc.iso8601 + end + + # Issues a GET request. `path_or_url` may be a relative path (prefixed with the base URL) + # or an absolute URL (used when following pagination links). + def get(path_or_url, query: nil) + with_retries("GET #{path_or_url}") do + url = resolve_url(path_or_url) + response = self.class.get(url, headers: auth_headers, query: query) + handle_response(response) + end + end + + # Resolves a relative path against the base URL, or validates an absolute URL + # so the bearer token is only ever sent to Up's HTTPS host. + def resolve_url(path_or_url) + value = path_or_url.to_s + return "#{DEFAULT_BASE_URL}/#{value}" unless value.start_with?("http") + + uri = URI.parse(value) + unless uri.scheme == "https" && uri.host == ALLOWED_HOST + raise UpError.new("Refusing to send credentials to untrusted host: #{uri.host.inspect}", :invalid_url) + end + + value + rescue URI::InvalidURIError + raise UpError.new("Invalid Up API URL", :invalid_url) + end + + # Bearer-auth headers sent with every request. + def auth_headers + { + "Authorization" => "Bearer #{access_token}", + "Accept" => "application/json" + } + end + + # Run the block, retrying transient network errors with exponential backoff. + def with_retries(operation_name, max_retries: MAX_RETRIES) + retries = 0 + + begin + yield + rescue *RETRYABLE_ERRORS => e + retries += 1 + if retries <= max_retries + delay = calculate_retry_delay(retries) + Rails.logger.warn( + "Up API: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \ + "#{e.class}: #{e.message}. Retrying in #{delay}s..." + ) + Kernel.sleep(delay) + retry + end + + Rails.logger.error("Up API: #{operation_name} failed after #{max_retries} retries: #{e.class}: #{e.message}") + raise UpError.new("Network error after #{max_retries} retries: #{e.message}", :network_error) + end + end + + # Exponential backoff delay (with jitter), capped at 30 seconds. + def calculate_retry_delay(retry_count) + base_delay = INITIAL_RETRY_DELAY * (2 ** (retry_count - 1)) + jitter = base_delay * rand * 0.25 + [ base_delay + jitter, 30 ].min + end + + # Map an HTTP response to parsed data or a typed UpError by status code. + def handle_response(response) + case response.code + when 200, 201 + parse_response_body(response) + when 204 + {} + when 400 + raise UpError.new("Bad request to Up API (status=#{response.code})", :bad_request) + when 401 + raise UpError.new("Invalid Up access token", :unauthorized) + when 403 + raise UpError.new("Up access forbidden - check token permissions", :access_forbidden) + when 404 + raise UpError.new("Up resource not found", :not_found) + when 429 + raise UpError.new("Up rate limit exceeded. Please try again later.", :rate_limited) + when 500..599 + raise UpError.new("Up server error (#{response.code}). Please try again later.", :server_error) + else + Rails.logger.error "Up API: Unexpected response status=#{response.code}" + raise UpError.new("Failed to fetch Up data", :fetch_failed) + end + end + + # Parse a JSON response body into a symbol-keyed hash, raising on bad JSON. + def parse_response_body(response) + return {} if response.body.blank? + + JSON.parse(response.body, symbolize_names: true) + rescue JSON::ParserError => e + Rails.logger.error "Up API: Failed to parse response: #{e.class}" + raise UpError.new("Failed to parse Up API response", :parse_error) + end + + # Error raised for Up API failures, tagged with a symbolic +error_type+. + class UpError < StandardError + attr_reader :error_type + + # Build the error with a +message+ and a categorizing +error_type+. + def initialize(message, error_type = :unknown) + super(message) + @error_type = error_type + end + end +end diff --git a/app/models/provider/up_adapter.rb b/app/models/provider/up_adapter.rb new file mode 100644 index 000000000..1f0c2981b --- /dev/null +++ b/app/models/provider/up_adapter.rb @@ -0,0 +1,111 @@ +class Provider::UpAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + Provider::Factory.register("UpAccount", self) + + # Sure accountable types that can be created from Up accounts. + def self.supported_account_types + %w[Depository Loan] + end + + # Connection config hashes for each of the family's configured Up items. + def self.connection_configs(family:) + return [] unless family.can_connect_up? + + family.up_items.active.ordered.select(&:credentials_configured?).map do |up_item| + connection_config_for(up_item) + end + end + + # Build an Up API client for the resolved item, or nil if none is usable. + def self.build_provider(family: nil, up_item_id: nil) + return nil unless family.present? + + up_item = resolve_up_item(family, up_item_id) + return nil unless up_item&.credentials_configured? + + Provider::Up.new(up_item.access_token) + end + + # Build the settings connection-config hash for a single Up item. + def self.connection_config_for(up_item) + path_params = ->(extra = {}) { extra.merge(up_item_id: up_item.id) } + + { + key: "up_#{up_item.id}", + name: up_item.name.presence || I18n.t("providers.up.name"), + description: I18n.t("providers.up.description"), + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_up_items_path( + path_params.call(accountable_type: accountable_type, return_to: return_to) + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_up_items_path( + path_params.call(account_id: account_id) + ) + } + } + end + private_class_method :connection_config_for + + # Provider key used across the sync/account-provider machinery. + def provider_name + "up" + end + + # Route to trigger a manual sync for this provider account's item. + def sync_path + Rails.application.routes.url_helpers.sync_up_item_path(item) + end + + # The UpItem backing this provider account. + def item + provider_account.up_item + end + + # Up holdings are never deletable by the sync machinery. + def can_delete_holdings? + false + end + + # Institution domain from account metadata, or nil. + def institution_domain + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["domain"] + end + + # Institution name from account metadata, falling back to the item's. + def institution_name + metadata = provider_account.institution_metadata + metadata&.dig("name").presence || item&.institution_name + end + + # Institution URL from account metadata, falling back to the item's. + def institution_url + metadata = provider_account.institution_metadata + metadata&.dig("url").presence || item&.institution_url + end + + # Brand color for the institution, from the item. + def institution_color + item&.institution_color + end + + # Resolve the target Up item: the requested one, else the first configured. + def self.resolve_up_item(family, up_item_id) + if up_item_id.present? + item = family.up_items.active.find_by(id: up_item_id) + return item if item&.credentials_configured? + + return nil + end + + family.up_items.active.ordered.find(&:credentials_configured?) + end + private_class_method :resolve_up_item +end diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index 0c3720323..a995ace61 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -3,6 +3,7 @@ class ProviderConnectionStatus PROVIDERS = [ { key: "akahu", type: "AkahuItem", association: :akahu_items, accounts: :akahu_accounts }, + { key: "up", type: "UpItem", association: :up_items, accounts: :up_accounts }, { key: "plaid", type: "PlaidItem", association: :plaid_items, accounts: :plaid_accounts }, { key: "simplefin", type: "SimplefinItem", association: :simplefin_items, accounts: :simplefin_accounts }, { key: "lunchflow", type: "LunchflowItem", association: :lunchflow_items, accounts: :lunchflow_accounts }, diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index 261a1e660..719c6800f 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -1,5 +1,5 @@ class ProviderMerchant < Merchant - enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", akahu: "akahu", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", brex: "brex", indexa_capital: "indexa_capital", sophtron: "sophtron" } + enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", akahu: "akahu", up: "up", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", brex: "brex", indexa_capital: "indexa_capital", sophtron: "sophtron" } validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/models/transaction.rb b/app/models/transaction.rb index e564e3ea1..040a1ea69 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -94,7 +94,7 @@ class Transaction < ApplicationRecord INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].freeze # Providers that support pending transaction flags - PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking akahu].freeze + PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking akahu up].freeze # Pre-computed SQL fragment for subqueries that check if a transaction (aliased as "t") is pending. # Stored as a constant so static analysis can verify it contains no user input. diff --git a/app/models/up_account.rb b/app/models/up_account.rb new file mode 100644 index 000000000..d88a35c80 --- /dev/null +++ b/app/models/up_account.rb @@ -0,0 +1,93 @@ +class UpAccount < ApplicationRecord + include CurrencyNormalizable, Encryptable + + # Maps Up `accountType` values to Sure accountable types/subtypes. + UP_ACCOUNT_TYPE_MAP = { + "TRANSACTIONAL" => { accountable_type: "Depository", subtype: "checking" }, + "SAVER" => { accountable_type: "Depository", subtype: "savings" }, + "HOME_LOAN" => { accountable_type: "Loan" } + }.freeze + + INSTITUTION_NAME = "Up".freeze + INSTITUTION_DOMAIN = "up.com.au".freeze + + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end + + belongs_to :up_item + + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + validates :account_id, uniqueness: { scope: :up_item_id, allow_nil: true } + + # Up accounts with no linked Sure account. + scope :unlinked, -> { left_joins(:account_provider).where(account_providers: { id: nil }) } + # Unlinked accounts that still need a setup decision (i.e. not explicitly skipped). + scope :needs_setup, -> { unlinked.where(ignored: false) } + + # The linked Sure account, if any. + def current_account + account + end + + # Suggested Sure accountable type derived from Up's account type, or nil. + def suggested_account_type + UP_ACCOUNT_TYPE_MAP[account_type.to_s.upcase]&.fetch(:accountable_type) + end + + # Suggested Sure subtype (e.g. checking/savings) for this Up account, or nil. + def suggested_subtype + UP_ACCOUNT_TYPE_MAP[account_type.to_s.upcase]&.[](:subtype) + end + + # Persist the latest Up account snapshot, normalizing balance/currency/metadata. + def upsert_up_snapshot!(account_snapshot) + snapshot = account_snapshot.with_indifferent_access + balance = snapshot[:balance].is_a?(Hash) ? snapshot[:balance].with_indifferent_access : {} + + assign_attributes( + current_balance: parse_balance(balance[:value]), + currency: parse_currency(balance[:currencyCode]) || "AUD", + name: snapshot[:displayName].presence || I18n.t("up_account.fallback"), + account_id: snapshot[:id], + account_status: snapshot[:accountType], + account_type: snapshot[:accountType], + ownership_type: snapshot[:ownershipType], + provider: "up", + institution_metadata: { + name: INSTITUTION_NAME, + domain: INSTITUTION_DOMAIN + }.compact, + raw_payload: account_snapshot + ) + + save! + end + + # Persist the latest raw transactions payload for this account. + def upsert_up_transactions_snapshot!(transactions_snapshot) + assign_attributes(raw_transactions_payload: transactions_snapshot) + save! + end + + private + + # Parse an Up balance string into a BigDecimal, defaulting to 0 on bad input. + def parse_balance(value) + return 0 if value.blank? + + BigDecimal(value.to_s) + rescue ArgumentError + 0 + end + + # CurrencyNormalizable hook: warn when an Up currency code is unrecognized. + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for Up account #{id}, defaulting to AUD") + end +end diff --git a/app/models/up_account/processor.rb b/app/models/up_account/processor.rb new file mode 100644 index 000000000..23d0f578b --- /dev/null +++ b/app/models/up_account/processor.rb @@ -0,0 +1,91 @@ +class UpAccount::Processor + include CurrencyNormalizable + + SanitizedProcessingError = Class.new(StandardError) + + attr_reader :up_account + + # Build a processor for the given +up_account+. + def initialize(up_account) + @up_account = up_account + end + + # Sync the linked account's balance and process its transactions. No-op when + # the Up account isn't linked to a Sure account. + def process + unless up_account.current_account.present? + Rails.logger.info "UpAccount::Processor - No linked account for up_account #{up_account.id}, skipping processing" + return + end + + process_account! + process_transactions + rescue StandardError => e + Rails.logger.error "UpAccount::Processor - Failed to process account up_account_id=#{up_account.id} error_class=#{e.class.name}" + report_exception(e, "account") + raise + end + + private + + # Update the linked Sure account's balance/currency from the Up snapshot. + def process_account! + account = up_account.current_account + balance = up_account.current_balance || 0 + + # Loan balances are stored as positive debt in Sure regardless of Up's sign. + balance = balance.abs if account.accountable_type == "Loan" + currency = parse_currency(up_account.currency) || account.currency || "AUD" + + account.update!( + balance: balance, + cash_balance: balance, + currency: currency + ) + end + + # Delegate to the transactions processor, capturing and logging failures. + def process_transactions + UpAccount::Transactions::Processor.new(up_account).process + rescue => e + report_exception(e, "transactions") + Rails.logger.error "UpAccount::Processor - Failed to process transactions up_account_id=#{up_account.id} error_class=#{e.class.name}" + DebugLogEntry.capture( + category: "provider_sync_error", + level: "error", + message: "Failed to process transactions", + source: self.class.name, + provider_key: "up", + family: up_account.up_item.family, + account_provider: up_account.account_provider, + metadata: { up_account_id: up_account.id, error_class: e.class.name, error_message: e.message } + ) + { success: false, failed: 1, errors: [ { error: I18n.t("up_item.errors.account_processing_failed") } ] } + end + + # Report a processing error to Sentry with a sanitized message and tags. + def report_exception(error, context) + safe_error = SanitizedProcessingError.new("Up account processing failed") + + Sentry.capture_exception(safe_error) do |scope| + scope.set_tags( + up_account_id: up_account.id, + context: context, + error_class: error.class.name + ) + scope.set_context( + "up_account_processor", + { + up_account_id: up_account.id, + context: context, + error_class: error.class.name + } + ) + end + end + + # CurrencyNormalizable hook: warn when an Up currency code is unrecognized. + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for Up account #{up_account.id}, falling back to account currency") + end +end diff --git a/app/models/up_account/transactions/processor.rb b/app/models/up_account/transactions/processor.rb new file mode 100644 index 000000000..c56fadb4d --- /dev/null +++ b/app/models/up_account/transactions/processor.rb @@ -0,0 +1,91 @@ +class UpAccount::Transactions::Processor + attr_reader :up_account + + # Build a transactions processor for the given +up_account+. + def initialize(up_account) + @up_account = up_account + end + + # Process each stored raw transaction into a Sure entry, prune stale pending + # entries, and return a stats hash (total/imported/failed/pruned/errors). + def process + unless up_account.raw_transactions_payload.present? + Rails.logger.info "UpAccount::Transactions::Processor - No Up transactions available to process" + pruned_count = prune_stale_pending_entries([]) + return { success: true, total: 0, imported: 0, failed: 0, pruned_pending: pruned_count, errors: [] } + end + + total_count = up_account.raw_transactions_payload.count + imported_count = 0 + failed_count = 0 + errors = [] + current_pending_external_ids = pending_external_ids + + up_account.raw_transactions_payload.each_with_index do |transaction_data, index| + result = UpEntry::Processor.new( + transaction_data, + up_account: up_account + ).process + + if result.nil? + failed_count += 1 + errors << { index: index, transaction_id: transaction_id(transaction_data), error: "No linked account" } + else + imported_count += 1 + end + rescue ArgumentError => e + failed_count += 1 + errors << { index: index, transaction_id: transaction_id(transaction_data), error: "Validation error: #{e.message}" } + Rails.logger.error "UpAccount::Transactions::Processor - Validation error processing transaction #{transaction_id(transaction_data)}: #{e.message}" + rescue => e + failed_count += 1 + errors << { index: index, transaction_id: transaction_id(transaction_data), error: "#{e.class}: #{e.message}" } + Rails.logger.error "UpAccount::Transactions::Processor - Error processing transaction #{transaction_id(transaction_data)}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + end + pruned_count = prune_stale_pending_entries(current_pending_external_ids) + + { + success: failed_count.zero?, + total: total_count, + imported: imported_count, + failed: failed_count, + pruned_pending: pruned_count, + errors: errors + } + end + + private + + # Extract the Up transaction id from raw data, or "unknown". + def transaction_id(transaction_data) + transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + end + + # Canonical external ids of the currently-HELD (pending) transactions. + def pending_external_ids + up_account.raw_transactions_payload.filter_map do |transaction_data| + next unless transaction_data.is_a?(Hash) + next unless UpEntry::Processor.pending?(transaction_data) + + UpEntry::Processor.canonical_external_id(transaction_data) + end + end + + # Delete previously-imported pending entries no longer present in the latest + # fetch (cancelled/settled holds), returning how many were removed. + def prune_stale_pending_entries(current_pending_external_ids) + account = up_account.current_account + return 0 unless account.present? + + stale_pending_entries = account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(source: "up") + .where("(transactions.extra -> 'up' ->> 'pending')::boolean = true") + stale_pending_entries = stale_pending_entries.where.not(external_id: current_pending_external_ids) if current_pending_external_ids.any? + + count = stale_pending_entries.count + stale_pending_entries.find_each(&:destroy!) if count.positive? + count + end +end diff --git a/app/models/up_entry/processor.rb b/app/models/up_entry/processor.rb new file mode 100644 index 000000000..52d6c760c --- /dev/null +++ b/app/models/up_entry/processor.rb @@ -0,0 +1,199 @@ +require "digest/md5" + +class UpEntry::Processor + include CurrencyNormalizable + + # Stable external id for a transaction: its Up id when present, else a content + # hash so pending entries lacking an id stay deduplicated across syncs. + def self.canonical_external_id(up_transaction) + data = up_transaction.with_indifferent_access + id = data[:id].presence + return "up_#{id}" if id.present? + + "up_pending_#{content_hash_for(data)}" + end + + # Up marks unsettled transactions with status "HELD"; settled ones are "SETTLED". + def self.pending?(up_transaction) + data = up_transaction.with_indifferent_access + data[:status].to_s.upcase == "HELD" + end + + # MD5 of account/date/amount/description, used to identify id-less pendings. + def self.content_hash_for(data) + amount = data[:amount].is_a?(Hash) ? data[:amount].with_indifferent_access : {} + attributes = [ + data[:account_id], + data[:createdAt], + amount[:value], + data[:description] + ].compact.join("|") + + Digest::MD5.hexdigest(attributes) + end + + # Build a processor for a single raw Up transaction tied to +up_account+. + def initialize(up_transaction, up_account:) + @up_transaction = up_transaction + @up_account = up_account + end + + # Import the transaction into the linked Sure account via the import adapter. + # Returns nil when the account isn't linked; re-raises on validation/save errors. + def process + unless account.present? + Rails.logger.warn "UpEntry::Processor - No linked account for up_account #{up_account.id}, skipping transaction #{external_id}" + return nil + end + + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "up", + merchant: merchant, + notes: notes, + extra: extra_metadata + ) + rescue ArgumentError => e + Rails.logger.error "UpEntry::Processor - Validation error for transaction #{external_id}: #{e.message}" + raise + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error "UpEntry::Processor - Failed to save transaction #{external_id}: #{e.message}" + raise StandardError.new("Failed to import transaction: #{e.message}") + rescue => e + Rails.logger.error "UpEntry::Processor - Unexpected error processing transaction #{external_id}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + raise StandardError.new("Unexpected error importing transaction: #{e.message}") + end + + private + + attr_reader :up_transaction, :up_account + + # Memoized adapter that writes provider transactions into the Sure account. + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + # The linked Sure account for this transaction, if any. + def account + @account ||= up_account.current_account + end + + # The raw transaction as an indifferent-access hash. + def data + @data ||= up_transaction.with_indifferent_access + end + + # Canonical external id for this transaction (see .canonical_external_id). + def external_id + @external_id ||= self.class.canonical_external_id(data) + end + + # Display name: the Up description, or a generic fallback. + def name + data[:description].presence || I18n.t("transactions.unknown_name") + end + + # Optional user-entered message attached to the transaction. + def notes + data[:message].presence + end + + # Find or create the merchant for this transaction's description, or nil. + def merchant + merchant_name = data[:description].to_s.strip.presence + return nil unless merchant_name + + provider_merchant_id = "up_merchant_#{Digest::MD5.hexdigest(merchant_name.downcase)}" + + @merchant ||= import_adapter.find_or_create_merchant( + provider_merchant_id: provider_merchant_id, + name: merchant_name, + source: "up" + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "UpEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}" + nil + end + + # Up amounts use banking convention: negative is money out, positive is money in. + # Sure stores expenses as positive and income as negative, so the sign is flipped. + def amount + raw_value = amount_data[:value] + parsed_amount = case raw_value + when String + BigDecimal(raw_value) + when Numeric + BigDecimal(raw_value.to_s) + else + BigDecimal("0") + end + + -parsed_amount + rescue ArgumentError => e + Rails.logger.error "Failed to parse Up transaction amount: #{e.class}" + raise ArgumentError, "Invalid transaction amount" + end + + # Transaction currency, falling back to the account/default currency. + def currency + parse_currency(amount_data[:currencyCode]) || up_account.currency || account&.currency || "AUD" + end + + # Settlement date (or creation date for pendings) as a Date. + def date + value = data[:settledAt].presence || data[:createdAt].presence + case value + when String + Time.parse(value).to_date + when Time, DateTime + value.to_date + when Date + value + else + Rails.logger.error("Up transaction has no usable date value") + raise ArgumentError, "Invalid date format" + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse Up transaction date: #{e.class}") + raise ArgumentError, "Unable to parse transaction date" + end + + # Provider metadata persisted on Transaction#extra (pending/status/fx/etc.). + def extra_metadata + { + "up" => { + "pending" => pending?, + "status" => data[:status], + "category_id" => data[:category_id], + "raw_text" => data[:rawText], + "fx_from" => foreign_amount_data[:currencyCode], + "fx_amount" => foreign_amount_data[:value] + }.compact + } + end + + # Whether this transaction is still HELD (unsettled) on Up. + def pending? + self.class.pending?(data) + end + + # The native amount object ({ value:, currencyCode: }) as a hash. + def amount_data + @amount_data ||= data[:amount].is_a?(Hash) ? data[:amount].with_indifferent_access : {} + end + + # The foreign-currency amount object for FX transactions, or empty hash. + def foreign_amount_data + @foreign_amount_data ||= data[:foreignAmount].is_a?(Hash) ? data[:foreignAmount].with_indifferent_access : {} + end + + # CurrencyNormalizable hook: warn when an Up currency code is unrecognized. + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' in Up transaction #{external_id}, falling back to account currency") + end +end diff --git a/app/models/up_item.rb b/app/models/up_item.rb new file mode 100644 index 000000000..27c4e6ce9 --- /dev/null +++ b/app/models/up_item.rb @@ -0,0 +1,201 @@ +class UpItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + if encryption_ready? + encrypts :access_token, deterministic: true + encrypts :raw_payload + encrypts :raw_institution_payload + end + + belongs_to :family + has_one_attached :logo, dependent: :purge_later + has_many :up_accounts, dependent: :destroy + has_many :accounts, through: :up_accounts + + validates :name, presence: true + validates :access_token, presence: true, on: :create + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + # Mark the item for deletion and enqueue the background destroy job. + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + # Run the importer to fetch the latest accounts/transactions from Up. + def import_latest_up_data + provider = up_provider + unless provider + DebugLogEntry.capture( + category: "provider_sync_error", + level: "error", + message: "Cannot import: Up provider is not configured", + source: self.class.name, + provider_key: "up", + family: family, + metadata: { up_item_id: id } + ) + raise StandardError.new("Up provider is not configured") + end + + UpItem::Importer.new(self, up_provider: provider).import + rescue => e + DebugLogEntry.capture( + category: "provider_sync_error", + level: "error", + message: "Failed to import data", + source: self.class.name, + provider_key: "up", + family: family, + metadata: { up_item_id: id, error_class: e.class.name, error_message: e.message } + ) + raise + end + + # Process each linked, visible Up account, returning a per-account result array. + def process_accounts + return [] if up_accounts.empty? + + up_accounts.joins(:account).merge(Account.visible).map do |up_account| + result = UpAccount::Processor.new(up_account).process + if result.is_a?(Hash) && result.with_indifferent_access[:success] == false + { up_account_id: up_account.id, success: false, error: I18n.t("up_item.errors.account_processing_failed") } + else + { up_account_id: up_account.id, success: true, result: result } + end + rescue => e + DebugLogEntry.capture( + category: "provider_sync_error", + level: "error", + message: "Failed to process account", + source: self.class.name, + provider_key: "up", + family: family, + account_provider: up_account.account_provider, + metadata: { up_item_id: id, up_account_id: up_account.id, error_class: e.class.name, error_message: e.message } + ) + { up_account_id: up_account.id, success: false, error: I18n.t("up_item.errors.account_processing_failed") } + end + end + + # Enqueue a balance sync for each visible linked account, returning per-account results. + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + accounts.visible.map do |account| + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + { account_id: account.id, success: true } + rescue => e + DebugLogEntry.capture( + category: "provider_sync_error", + level: "error", + message: "Failed to schedule sync for account", + source: self.class.name, + provider_key: "up", + family: family, + account: account, + metadata: { up_item_id: id, account_id: account.id, error_class: e.class.name, error_message: e.message } + ) + { account_id: account.id, success: false, error: I18n.t("up_item.errors.account_sync_schedule_failed") } + end + end + + # Persist the latest raw accounts payload for this item. + def upsert_up_snapshot!(accounts_snapshot) + assign_attributes(raw_payload: accounts_snapshot) + save! + end + + # True once at least one Up account has been linked to a Sure account. + def has_completed_initial_setup? + accounts.any? + end + + # Human-readable summary of linked vs. unlinked account counts. + def sync_status_summary + total_accounts = total_accounts_count + linked_count = linked_accounts_count + unlinked_count = unlinked_accounts_count + + if total_accounts.zero? + I18n.t("up_item.sync_status.no_accounts") + elsif unlinked_count.zero? + I18n.t("up_item.sync_status.all_synced", count: linked_count) + else + I18n.t("up_item.sync_status.partial", linked: linked_count, unlinked: unlinked_count) + end + end + + # Number of Up accounts linked to a Sure account. + def linked_accounts_count + account_counts[:linked] + end + + # Number of unlinked Up accounts still awaiting setup. + def unlinked_accounts_count + account_counts[:unlinked] + end + + # Total number of Up accounts under this item. + def total_accounts_count + account_counts[:total] + end + + # Best available display name for the connected institution. + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + # Distinct institution metadata across this item's accounts. + def connected_institutions + up_accounts.includes(:account) + .where.not(institution_metadata: nil) + .map(&:institution_metadata) + .uniq { |inst| inst["name"] || inst["domain"] } + end + + # Human-readable summary of connected institutions (none/one/count). + def institution_summary + institutions = connected_institutions + case institutions.count + when 0 + I18n.t("up_item.institution_summary.none") + when 1 + institutions.first["name"].presence || I18n.t("up_item.institution_summary.one") + else + I18n.t("up_item.institution_summary.count", count: institutions.count) + end + end + + # True when an access token is present and the item can call the Up API. + def credentials_configured? + access_token.present? + end + + private + + # Single query for all three account counts, reused across sync_status_summary + # and the settings partial to avoid 3+ separate COUNT queries per rendered item. + def account_counts + @account_counts ||= begin + rows = up_accounts + .left_joins(:account_provider) + .pluck(Arel.sql("account_providers.id IS NOT NULL"), :ignored) + + linked = rows.count { |has_provider, _ignored| has_provider } + unlinked = rows.count { |has_provider, ignored| !has_provider && !ignored } + + { linked: linked, unlinked: unlinked, total: rows.size } + end + end +end diff --git a/app/models/up_item/importer.rb b/app/models/up_item/importer.rb new file mode 100644 index 000000000..cc4b816ea --- /dev/null +++ b/app/models/up_item/importer.rb @@ -0,0 +1,250 @@ +# Imports Up Bank accounts and transactions for a single UpItem connection. +# Fetches account snapshots and per-account transaction history from the Up +# provider, persisting raw snapshots and returning aggregate import statistics. +class UpItem::Importer + attr_reader :up_item, :up_provider + + # Build an importer for the given +up_item+ using the supplied +up_provider+ client. + def initialize(up_item, up_provider:) + @up_item = up_item + @up_provider = up_provider + end + + # Run the full import (accounts then transactions) and return a result hash + # of success flag and per-entity counts. On a failed accounts fetch, returns + # a +failed_result+ with the same shape and zeroed counts. + def import + Rails.logger.info "UpItem::Importer - Starting import for item #{up_item.id}" + + accounts_data = fetch_accounts_data + return failed_result("Failed to fetch accounts data") unless accounts_data + + up_item.upsert_up_snapshot!(accounts_data) + + account_stats = import_accounts(accounts_data) + transaction_stats = import_transactions + + Rails.logger.info( + "UpItem::Importer - Completed import for item #{up_item.id}: " \ + "#{account_stats[:updated]} accounts updated, #{account_stats[:created]} new accounts discovered, " \ + "#{transaction_stats[:imported]} transactions" + ) + + { + success: account_stats[:failed].zero? && transaction_stats[:failed].zero?, + accounts_updated: account_stats[:updated], + accounts_created: account_stats[:created], + accounts_failed: account_stats[:failed], + transactions_imported: transaction_stats[:imported], + transactions_failed: transaction_stats[:failed] + } + end + + private + + # Fetch the current account list from Up, returning a hash of +items+ or + # +nil+ on any provider/parse error (which is logged and captured). + def fetch_accounts_data + items = up_provider.get_accounts + { items: items } + rescue Provider::Up::UpError => e + mark_requires_update! if e.error_type.in?([ :unauthorized, :access_forbidden ]) + Rails.logger.error "UpItem::Importer - Up API error: #{e.error_type}" + capture_sync_error("Failed to fetch accounts data", e, error_type: e.error_type) + nil + rescue JSON::ParserError => e + Rails.logger.error "UpItem::Importer - Failed to parse Up API response: #{e.class}" + capture_sync_error("Failed to parse Up accounts response", e) + nil + rescue => e + Rails.logger.error "UpItem::Importer - Unexpected error fetching accounts: #{e.class}" + Rails.logger.error e.backtrace.join("\n") + capture_sync_error("Unexpected error fetching accounts", e) + nil + end + + # Upsert snapshots for linked accounts and record newly discovered ones, + # returning a stats hash of +updated+, +created+, and +failed+ counts. + def import_accounts(accounts_data) + stats = { updated: 0, created: 0, failed: 0 } + accounts = Array(accounts_data[:items]) + linked_account_ids = up_item.up_accounts.joins(:account_provider).pluck(:account_id).map(&:to_s) + all_existing_ids = up_item.up_accounts.pluck(:account_id).map(&:to_s) + + accounts.each do |account_data| + account = account_data.with_indifferent_access + account_id = account[:id].presence + next if account_id.blank? + next if account[:displayName].blank? + + if linked_account_ids.include?(account_id.to_s) + import_account(account) + stats[:updated] += 1 + elsif !all_existing_ids.include?(account_id.to_s) + up_account = up_item.up_accounts.build(account_id: account_id.to_s) + up_account.upsert_up_snapshot!(account) + stats[:created] += 1 + end + rescue => e + stats[:failed] += 1 + Rails.logger.error "UpItem::Importer - Failed to import account #{account_id}: #{e.message}" + end + + stats + end + + # Upsert the snapshot for a single already-linked Up account. + def import_account(account_data) + account = account_data.with_indifferent_access + up_account = up_item.up_accounts.find_by(account_id: account[:id].to_s) + return unless up_account + + up_account.upsert_up_snapshot!(account) + end + + # Fetch and store transactions for every visible linked account, returning + # a stats hash of +imported+ and +failed+ counts. + def import_transactions + stats = { imported: 0, failed: 0 } + + up_item.up_accounts.joins(:account).merge(Account.visible).each do |up_account| + result = fetch_and_store_transactions(up_account) + if result[:success] + stats[:imported] += result[:transactions_count] + else + stats[:failed] += 1 + end + rescue => e + stats[:failed] += 1 + Rails.logger.error "UpItem::Importer - Failed to fetch/store transactions for Up account #{up_account.id}: #{e.class}" + end + + stats + end + + # Fetch transactions for +up_account+ since its sync start date and persist + # them, returning a result hash with +success+ and +transactions_count+. + def fetch_and_store_transactions(up_account) + start_date = determine_sync_start_date(up_account) + Rails.logger.info "UpItem::Importer - Fetching transactions for Up account #{up_account.id} since #{start_date}" + + transactions = up_provider.get_account_transactions( + account_id: up_account.account_id, + since: start_date + ) + + if Rails.configuration.x.up.debug_raw && Rails.env.local? + Rails.logger.debug "Up raw transactions response: #{transactions.to_json}" + end + + store_transactions(up_account, fresh_transactions: Array(transactions)) + + { success: true, transactions_count: Array(transactions).count } + rescue Provider::Up::UpError => e + mark_requires_update! if e.error_type.in?([ :unauthorized, :access_forbidden ]) + Rails.logger.error "UpItem::Importer - Up API error for account #{up_account.id}: #{e.error_type}" + capture_sync_error("Failed to fetch transactions", e, up_account: up_account, error_type: e.error_type) + { success: false, transactions_count: 0, error: I18n.t("up_item.errors.transactions_failed") } + rescue JSON::ParserError => e + Rails.logger.error "UpItem::Importer - Failed to parse transaction response for account #{up_account.id}: #{e.class}" + capture_sync_error("Failed to parse Up transactions response", e, up_account: up_account) + { success: false, transactions_count: 0, error: "Failed to parse response" } + rescue => e + Rails.logger.error "UpItem::Importer - Unexpected error fetching transactions for account #{up_account.id}: #{e.class}" + Rails.logger.error e.backtrace.join("\n") + capture_sync_error("Unexpected error fetching transactions", e, up_account: up_account) + { success: false, transactions_count: 0, error: I18n.t("up_item.errors.transactions_failed") } + end + + # Up returns both HELD (pending) and SETTLED transactions from the same endpoint. + # Settled history accumulates; HELD transactions are only retained while they remain + # present in the latest fetch, so cancelled/settled holds drop out of storage (and the + # transactions processor prunes their stale pending entries). + def store_transactions(up_account, fresh_transactions:) + existing = up_account.raw_transactions_payload.to_a + existing_settled = existing.reject { |tx| UpEntry::Processor.pending?(tx) } + + by_id = {} + existing_settled.each do |tx| + key = transaction_id(tx) + by_id[key] = tx if key.present? + end + fresh_transactions.each do |tx| + next unless tx.is_a?(Hash) + + key = transaction_id(tx) + by_id[key] = tx if key.present? + end + + final_transactions = by_id.values + + if final_transactions != existing + Rails.logger.info( + "UpItem::Importer - Storing #{final_transactions.count} transactions " \ + "(#{existing.count} existing) for account #{up_account.account_id}" + ) + up_account.upsert_up_transactions_snapshot!(final_transactions) + else + Rails.logger.info "UpItem::Importer - No transaction changes for account #{up_account.account_id}" + end + end + + # Extract the Up transaction id from a raw transaction hash, or +nil+. + def transaction_id(transaction) + data = transaction.with_indifferent_access + data[:id].presence + end + + # Resolve the date from which to fetch transactions for +up_account+, + # preferring explicit per-account/item start dates, then a recent window. + def determine_sync_start_date(up_account) + return up_account.sync_start_date if up_account.sync_start_date.present? + return up_item.sync_start_date if up_item.sync_start_date.present? + + has_stored_transactions = up_account.raw_transactions_payload.to_a.any? + if has_stored_transactions && up_item.last_synced_at + up_item.last_synced_at - 7.days + else + 90.days.ago + end + end + + # Record a provider sync error as a DebugLogEntry with structured metadata + # for support, attaching family and account provider when available. + def capture_sync_error(message, error, up_account: nil, error_type: nil) + metadata = { up_item_id: up_item.id, error_class: error.class.name, error_message: error.message } + metadata[:up_account_id] = up_account.id if up_account + metadata[:error_type] = error_type if error_type + + DebugLogEntry.capture( + category: "provider_sync_error", + level: "error", + message: message, + source: self.class.name, + provider_key: "up", + family: up_item.family, + account_provider: up_account&.account_provider, + metadata: metadata + ) + end + + # Flag the item as requiring re-authorization, swallowing update errors. + def mark_requires_update! + up_item.update!(status: :requires_update) + rescue => e + Rails.logger.error "UpItem::Importer - Failed to update item status: #{e.message}" + end + + # Build a failure result mirroring +import+'s shape with zeroed counts. + def failed_result(error) + { + success: false, + error: error, + accounts_updated: 0, + accounts_created: 0, + accounts_failed: 0, + transactions_imported: 0, + transactions_failed: 0 + } + end +end diff --git a/app/models/up_item/provided.rb b/app/models/up_item/provided.rb new file mode 100644 index 000000000..3efabecb8 --- /dev/null +++ b/app/models/up_item/provided.rb @@ -0,0 +1,15 @@ +module UpItem::Provided + extend ActiveSupport::Concern + + # Build an Up API client from this item's token, or nil if unconfigured. + def up_provider + return nil unless credentials_configured? + + Provider::Up.new(access_token) + end + + # The syncer responsible for importing and processing this item's data. + def syncer + UpItem::Syncer.new(self) + end +end diff --git a/app/models/up_item/sync_complete_event.rb b/app/models/up_item/sync_complete_event.rb new file mode 100644 index 000000000..5c7d4e8d6 --- /dev/null +++ b/app/models/up_item/sync_complete_event.rb @@ -0,0 +1,22 @@ +class UpItem::SyncCompleteEvent + attr_reader :up_item + + # Build the event for the given +up_item+. + def initialize(up_item) + @up_item = up_item + end + + # Broadcast sync-complete Turbo updates for the item, its accounts, and family. + def broadcast + up_item.accounts.each(&:broadcast_sync_complete) + + up_item.broadcast_replace_to( + up_item.family, + target: "up_item_#{up_item.id}", + partial: "up_items/up_item", + locals: { up_item: up_item } + ) + + up_item.family.broadcast_sync_complete + end +end diff --git a/app/models/up_item/syncer.rb b/app/models/up_item/syncer.rb new file mode 100644 index 000000000..b488c6078 --- /dev/null +++ b/app/models/up_item/syncer.rb @@ -0,0 +1,141 @@ +class UpItem::Syncer + include SyncStats::Collector + + SafeSyncError = Class.new(StandardError) + + # Error carrying the structured per-stage sync errors for health reporting. + class SyncError < StandardError + attr_reader :sync_errors + + # Build the error with a +message+ and the collected +sync_errors+. + def initialize(message, sync_errors:) + super(message) + @sync_errors = sync_errors + end + end + + attr_reader :up_item + + # Build a syncer for the given +up_item+. + def initialize(up_item) + @up_item = up_item + end + + # Run the full sync: import, account setup detection, transaction processing, + # balance sync scheduling, and stats/health collection. Raises on failures. + def perform_sync(sync) + sync.update!(status_text: "Importing accounts from Up...") if sync.respond_to?(:status_text) + import_result = up_item.import_latest_up_data + raise_if_failed_result!(import_result, stage: "Up import") + + sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + collect_setup_stats(sync, provider_accounts: up_item.up_accounts) + + linked_accounts = up_item.up_accounts.joins(:account_provider) + unlinked_accounts = up_item.up_accounts.needs_setup + + if unlinked_accounts.any? + up_item.update!(pending_account_setup: true) + sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text) + else + up_item.update!(pending_account_setup: false) + end + + if linked_accounts.any? + sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text) + mark_import_started(sync) + process_results = up_item.process_accounts + raise_if_failed_results!(process_results, stage: "Up account processing") + + sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) + schedule_results = up_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + raise_if_failed_results!(schedule_results, stage: "Up account sync scheduling") + + account_ids = linked_accounts.includes(:account_provider).filter_map { |ua| ua.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "up") + else + Rails.logger.info "UpItem::Syncer - No linked accounts to process" + end + + collect_health_stats(sync, errors: nil) + rescue SyncError => e + collect_health_stats(sync, errors: e.sync_errors) + raise + rescue => e + safe_message = I18n.t("up_item.errors.sync_failed") + DebugLogEntry.capture( + category: "provider_sync_error", + level: "error", + message: "Unexpected sync error", + source: self.class.name, + provider_key: "up", + family: up_item.family, + metadata: { up_item_id: up_item.id, error_class: e.class.name, error_message: e.message } + ) + collect_health_stats(sync, errors: [ { message: safe_message, category: "sync_error" } ]) + raise SafeSyncError.new(safe_message), cause: nil + end + + # Post-sync hook (no work required for Up). + def perform_post_sync + # no-op + end + + private + + # Raise a SyncError if a single result hash indicates failure. + def raise_if_failed_result!(result, stage:) + return unless failed_result?(result) + + errors = errors_from_result(result, stage: stage) + raise SyncError.new(error_message(stage, errors), sync_errors: errors) + end + + # Raise a SyncError if any result in the collection indicates failure. + def raise_if_failed_results!(results, stage:) + errors = Array(results).filter_map do |result| + next unless failed_result?(result) + + errors_from_result(result, stage: stage).first + end + + return if errors.empty? + + raise SyncError.new(error_message(stage, errors), sync_errors: errors) + end + + # True when +result+ is a hash explicitly flagged success: false. + def failed_result?(result) + result.is_a?(Hash) && result.with_indifferent_access[:success] == false + end + + # Normalize a failed result into an array of { message:, category: } errors. + def errors_from_result(result, stage:) + data = result.with_indifferent_access + messages = [] + messages << data[:error] if data[:error].present? + messages << "#{data[:accounts_failed]} accounts failed" if data[:accounts_failed].to_i.positive? + messages << "#{data[:transactions_failed]} transactions failed" if data[:transactions_failed].to_i.positive? + messages.concat(Array(data[:errors]).map { |error| error_message_value(error) }.compact) + messages << "#{stage} failed" if messages.empty? + + messages.map { |message| { message: "#{stage}: #{message}", category: "sync_error" } } + end + + # Join the error messages into a single stage summary string. + def error_message(stage, errors) + messages = errors.map { |error| error[:message] || error["message"] }.compact + messages.presence&.join(", ") || "#{stage} failed" + end + + # Extract a human-readable message from a heterogeneous error value. + def error_message_value(error) + return error[:message].presence || error["message"].presence || error[:error].presence || error["error"].presence if error.is_a?(Hash) + + error.to_s.presence + end +end diff --git a/app/models/up_item/unlinking.rb b/app/models/up_item/unlinking.rb new file mode 100644 index 000000000..fdd3d63c1 --- /dev/null +++ b/app/models/up_item/unlinking.rb @@ -0,0 +1,54 @@ +module UpItem::Unlinking + extend ActiveSupport::Concern + + # Unlink every Up account from its Sure account, detaching holdings and + # destroying the AccountProvider links. With +dry_run+, only reports what + # would be unlinked. Returns a per-account result array (with :error on failure). + def unlink_all!(dry_run: false) + results = [] + + up_accounts.find_each do |provider_account| + links = AccountProvider.joins(:account) + .where(provider: provider_account, accounts: { family_id: family_id }) + .to_a + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + links.each do |link| + Holding.where(account_id: link.account_id, account_provider_id: link.id).update_all(account_provider_id: nil) + link.destroy! + end + end + rescue StandardError => e + DebugLogEntry.capture( + category: "provider_sync_error", + level: "warn", + message: "Failed to fully unlink provider account", + source: self.class.name, + provider_key: "up", + family: family, + account_provider: links.first, + metadata: { + up_item_id: id, + up_account_id: provider_account.id, + link_ids: link_ids, + error_class: e.class.name, + error_message: e.message + } + ) + result[:error] = e.message + end + end + + results + end +end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 609d757eb..0145d1830 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -17,7 +17,7 @@ ) %> <% end %> -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @akahu_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? && @binance_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @akahu_items.empty? && @up_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? && @binance_items.empty? %> <%= render "empty" %> <% else %>
@@ -37,6 +37,10 @@ <%= render @akahu_items.sort_by(&:created_at) %> <% end %> + <% if @up_items.any? %> + <%= render @up_items.sort_by(&:created_at) %> + <% end %> + <% if @enable_banking_items.any? %> <%= render @enable_banking_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/settings/providers/_up_panel.html.erb b/app/views/settings/providers/_up_panel.html.erb new file mode 100644 index 000000000..e11604811 --- /dev/null +++ b/app/views/settings/providers/_up_panel.html.erb @@ -0,0 +1,123 @@ +
+ <% active_items = local_assigns[:up_items] || @up_items || Current.family.up_items.active.ordered %> + + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.up_panel.step_1_html", link: link_to(t("providers.up.name"), "https://api.up.com.au", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline")), + t("settings.providers.up_panel.step_2"), + t("settings.providers.up_panel.step_3") + ] %> + + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> + <%= render DS::Alert.new(message: error_msg, variant: :error) %> + <% end %> + + <% if active_items.any? %> +
+ <% active_items.each do |item| %> + <%= render DS::Disclosure.new(variant: :card) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+
+

<%= item.name.to_s.first.to_s.upcase %>

+
+
+

<%= item.name %>

+

<%= item.sync_status_summary %>

+
+
+
+ <% end %> + +
+
+ <%= render DS::Button.new( + text: t("up_items.provider_panel.sync"), + icon: "refresh-cw", + variant: :outline, + size: :sm, + href: sync_up_item_path(item), + method: :post, + disabled: item.syncing? + ) %> + <%= render DS::Button.new( + text: t("up_items.provider_panel.disconnect"), + icon: "trash-2", + variant: :outline_destructive, + size: :sm, + href: up_item_path(item), + method: :delete, + data: { turbo_confirm: t("up_items.provider_panel.disconnect_confirm", name: item.name) } + ) %> +
+ + <%= styled_form_with model: item, + url: up_item_path(item), + scope: :up_item, + method: :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :name, + label: t("up_items.provider_panel.connection_name_label"), + placeholder: t("up_items.provider_panel.connection_name_placeholder") %> + + <%= form.text_field :access_token, + label: t("up_items.provider_panel.access_token_label"), + placeholder: t("up_items.provider_panel.keep_access_token_placeholder"), + type: :password, + value: nil %> + +
+ <%= render DS::Link.new( + text: t("up_items.provider_panel.setup_accounts"), + icon: "settings", + variant: "secondary", + href: setup_accounts_up_item_path(item), + frame: :modal + ) %> + <%= render DS::Button.new( + text: t("up_items.provider_panel.update_connection"), + type: "submit" + ) %> +
+ <% end %> +
+ <% end %> + <% end %> +
+ <% end %> + + <% up_item = Current.family.up_items.build(name: t("up_items.provider_panel.default_connection_name")) %> + <% if active_items.any? %> +

+ <%= icon "plus", size: "sm" %> + <%= t("up_items.provider_panel.add_connection") %> +

+ <% end %> + + <%= styled_form_with model: up_item, + url: up_items_path, + scope: :up_item, + method: :post, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :name, + label: t("up_items.provider_panel.connection_name_label"), + placeholder: t("up_items.provider_panel.connection_name_placeholder") %> + + <%= form.text_field :access_token, + label: t("up_items.provider_panel.access_token_label"), + placeholder: t("up_items.provider_panel.access_token_placeholder"), + type: :password, + value: nil %> + +
+ <%= render DS::Button.new( + text: t("up_items.provider_panel.add_connection"), + type: "submit" + ) %> +
+ <% end %> +
diff --git a/app/views/up_items/_up_item.html.erb b/app/views/up_items/_up_item.html.erb new file mode 100644 index 000000000..ddd637c9f --- /dev/null +++ b/app/views/up_items/_up_item.html.erb @@ -0,0 +1,114 @@ +<%# locals: (up_item:) %> + +<%= tag.div id: dom_id(up_item) do %> + <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+ <%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> + +
+

<%= up_item.name.to_s.first.to_s.upcase %>

+
+ +
+
+ <%= tag.p up_item.name, class: "font-medium text-primary" %> + <% if up_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

+ <% end %> +
+ + <% if up_item.accounts.any? %> +

<%= up_item.institution_summary %>

+ <% end %> + + <% if up_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif up_item.sync_error.present? %> +
+ <%= render DS::Tooltip.new(text: up_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> + <%= tag.span t(".error"), class: "text-destructive" %> +
+ <% else %> +

+ <% if up_item.last_synced_at %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(up_item.last_synced_at), summary: up_item.sync_status_summary) %> + <% else %> + <%= t(".status_never") %> + <% end %> +

+ <% end %> +
+
+ + <% if Current.user&.admin? %> +
+ <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: up_item_path(up_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(up_item.name, high_severity: true) + ) %> + <% end %> +
+ <% end %> +
+ <% end %> + + <% unless up_item.scheduled_for_deletion? %> +
+ <% if up_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: up_item.accounts %> + <% end %> + + <% stats = if defined?(@up_sync_stats_map) && @up_sync_stats_map + @up_sync_stats_map[up_item.id] || {} + else + up_item.syncs.ordered.first&.sync_stats || {} + end %> + <%= render ProviderSyncSummary.new( + stats: stats, + provider_item: up_item, + institutions_count: up_item.connected_institutions.size + ) %> + + <% unlinked_count = up_item.unlinked_accounts_count %> + <% linked_count = up_item.linked_accounts_count %> + <% total_count = up_item.total_accounts_count %> + + <% if unlinked_count > 0 %> +
+

<%= t(".setup_needed") %>

+

<%= t(".setup_description", linked: linked_count, total: total_count) %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_up_item_path(up_item), + frame: :modal + ) %> +
+ <% elsif up_item.accounts.empty? && total_count == 0 %> +
+

<%= t(".no_accounts_title") %>

+

<%= t(".no_accounts_description") %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_up_item_path(up_item), + frame: :modal + ) %> +
+ <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/up_items/select_accounts.html.erb b/app/views/up_items/select_accounts.html.erb new file mode 100644 index 000000000..f9a602a86 --- /dev/null +++ b/app/views/up_items/select_accounts.html.erb @@ -0,0 +1,50 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+ <% if @api_error.present? %> + <%= render DS::Alert.new(message: @api_error, variant: :error) %> + <% elsif @up_accounts.empty? %> +

<%= t(".no_accounts_found") %>

+ <% else %> +

<%= t(".description") %>

+ + <%= form_with url: link_accounts_up_items_path, + method: :post, + data: { turbo_frame: "_top" }, + class: "space-y-4" do %> + <%= hidden_field_tag :up_item_id, @up_item.id %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @up_accounts.each do |up_account| %> + + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t(".cancel"), + href: @return_to || accounts_path, + variant: :secondary, + data: { turbo_frame: "_top", action: "DS--dialog#close" } + ) %> + <%= render DS::Button.new(text: t(".link_accounts"), type: "submit") %> +
+ <% end %> + <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/up_items/select_existing_account.html.erb b/app/views/up_items/select_existing_account.html.erb new file mode 100644 index 000000000..913a59be9 --- /dev/null +++ b/app/views/up_items/select_existing_account.html.erb @@ -0,0 +1,50 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title", account_name: @account.name)) %> + + <% dialog.with_body do %> +
+ <% if @api_error.present? %> + <%= render DS::Alert.new(message: @api_error, variant: :error) %> + <% elsif @up_accounts.empty? %> +

<%= t(".no_accounts_found") %>

+ <% else %> +

<%= t(".description") %>

+ + <%= form_with url: link_existing_account_up_items_path, + method: :post, + data: { turbo_frame: "_top" }, + class: "space-y-4" do %> + <%= hidden_field_tag :up_item_id, @up_item.id %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @up_accounts.each do |up_account| %> + + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t(".cancel"), + href: @return_to || accounts_path, + variant: :secondary, + data: { turbo_frame: "_top", action: "DS--dialog#close" } + ) %> + <%= render DS::Button.new(text: t(".link_account"), type: "submit") %> +
+ <% end %> + <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/up_items/setup_accounts.html.erb b/app/views/up_items/setup_accounts.html.erb new file mode 100644 index 000000000..09b916862 --- /dev/null +++ b/app/views/up_items/setup_accounts.html.erb @@ -0,0 +1,72 @@ +<% content_for :title, t(".title") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "landmark", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> + <%= form_with url: complete_account_setup_up_item_path(@up_item), + method: :post, + local: true, + data: { turbo_frame: "_top" }, + class: "space-y-6" do %> +
+ <% if @api_error.present? %> +
+ <%= icon "alert-circle", size: "lg", class: "text-destructive" %> +

<%= t(".fetch_failed") %>

+

<%= @api_error %>

+
+ <% elsif @up_accounts.empty? %> +
+ <%= icon "check-circle", size: "lg", class: "text-success" %> +

<%= t(".no_accounts_to_setup") %>

+

<%= t(".all_accounts_linked") %>

+
+ <% else %> +
+

<%= t(".choose_account_type") %>

+

<%= t(".choose_account_type_description") %>

+
+ + <% @up_accounts.each do |up_account| %> +
+
+

<%= up_account.name %>

+

+ <%= [up_account.currency, up_account.account_type].compact.join(" · ") %> +

+
+ + <%= label_tag "account_types[#{up_account.id}]", t(".account_type_label"), + class: "block text-sm font-medium text-primary mb-2" %> + <%= select_tag "account_types[#{up_account.id}]", + options_for_select(@account_type_options, @up_account_type_suggestions.fetch(up_account.id, "skip")), + class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full" %> +
+ <% end %> + <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".create_accounts"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + disabled: @api_error.present? || @up_accounts.empty? + ) %> + <%= render DS::Link.new( + text: t(".cancel"), + variant: "secondary", + href: accounts_path + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/config/initializers/up.rb b/config/initializers/up.rb new file mode 100644 index 000000000..8eb414d51 --- /dev/null +++ b/config/initializers/up.rb @@ -0,0 +1,9 @@ +# Up Bank integration runtime configuration +Rails.application.configure do + # Debug logging for raw Up API responses. + # When enabled, logs the full raw payload returned by the Up API. + # DEVELOPMENT-ONLY: the raw dump contains PII (merchant names, amounts, account IDs) + # and is gated to local environments so it never fires in managed/production. + # Default: false (only log summary info) + config.x.up.debug_raw = ENV["UP_DEBUG_RAW"].to_s.strip.downcase.in?(%w[1 true yes]) +end diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 0280986d2..c9913ee8e 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -478,6 +478,10 @@ en: step_1_html: "Go to %{link} and create a personal app." step_2: "Copy your app token and user token." step_3: "Paste the tokens below, save, then link your synced accounts." + up_panel: + step_1_html: "Go to %{link} and generate a personal access token." + step_2: "Copy your personal access token." + step_3: "Paste the token below, save, then link your synced accounts." simplefin_panel: step_1_html: "Go to %{link} for a one-time setup token." step_2: "Paste the token below and connect." diff --git a/config/locales/views/up_items/en.yml b/config/locales/views/up_items/en.yml new file mode 100644 index 000000000..bdc8e07f1 --- /dev/null +++ b/config/locales/views/up_items/en.yml @@ -0,0 +1,116 @@ +--- +en: + providers: + up: + name: Up + description: Connect Australian Up bank accounts via a personal access token + family: + up: + create_up_item: + default_name: Up Connection + up_account: + fallback: Up account + up_item: + errors: + account_processing_failed: Could not sync Up account + account_sync_schedule_failed: Could not schedule Up account sync + transactions_failed: Could not fetch Up transactions + sync_failed: Could not sync Up connection + sync_status: + no_accounts: No accounts found + all_synced: + one: 1 account synced + other: "%{count} accounts synced" + partial: "%{linked} synced, %{unlinked} need setup" + institution_summary: + none: No institutions connected + one: 1 institution + count: + one: 1 institution + other: "%{count} institutions" + up_items: + provider_panel: + default_connection_name: Up Connection + add_connection: Add Up connection + update_connection: Update connection + connection_name_label: Connection name + connection_name_placeholder: Main Up + access_token_label: Personal Access Token + access_token_placeholder: Paste your Up personal access token + keep_access_token_placeholder: Leave blank to keep the existing token + setup_accounts: Setup accounts + syncing: Syncing... + sync: Sync + disconnect: Disconnect + disconnect_confirm: "Are you sure you want to disconnect %{name}?" + create: + success: Up connection saved. + update: + success: Up connection updated. + destroy: + success: Scheduled Up connection for deletion. + unlink_failed: Could not disconnect Up connection + select_accounts: + title: Link Up accounts + description: Choose the Up accounts to add. + no_accounts_found: No unlinked Up accounts were found. + no_credentials_configured: Configure Up in provider settings first. + cancel: Cancel + link_accounts: Link accounts + link_accounts: + success: + one: Linked 1 Up account. + other: "Linked %{count} Up accounts." + no_accounts_selected: Select at least one account. + no_credentials_configured: Configure Up in provider settings first. + unsupported_account_type: Up does not support that account type. + link_failed: No accounts were linked. + select_existing_account: + title: "Link Up account to %{account_name}" + description: Choose an unlinked Up account to connect to this account. + no_accounts_found: No unlinked Up accounts were found. + no_credentials_configured: Configure Up in provider settings first. + account_already_linked: This account is already linked to a provider. + cancel: Cancel + link_account: Link account + link_existing_account: + success: "Linked Up account to %{account_name}." + account_already_linked: This account is already linked to a provider. + up_account_already_linked: This Up account is already linked. + no_account_selected: Select an Up account to link. + setup_accounts: + title: Link Up Accounts + subtitle: Choose how each Up account should appear in Sure. + no_credentials: Configure Up credentials first. + api_error: Could not fetch Up accounts. + fetch_failed: Could not fetch accounts + no_accounts_to_setup: No accounts to set up + all_accounts_linked: All Up accounts are already linked. + choose_account_type: Choose an account type + choose_account_type_description: Skip accounts you do not want to track. + account_type_label: Account type + account_types: + skip: Skip + depository: Cash + loan: Loan + create_accounts: Create accounts + cancel: Cancel + complete_account_setup: + success: + one: Created 1 Up account. + other: "Created %{count} Up accounts." + all_skipped: No Up accounts were created. + no_accounts: No Up accounts were selected. + creation_failed: Could not create Up accounts. + up_item: + deletion_in_progress: Deletion in progress + syncing: Syncing + error: Error + status_with_summary: "Synced %{timestamp} ago · %{summary}" + status_never: Never synced + delete: Delete + setup_needed: Account setup needed + setup_description: "%{linked} of %{total} accounts linked." + setup_action: Setup accounts + no_accounts_title: No accounts imported yet + no_accounts_description: Fetch Up accounts and choose which ones to link. diff --git a/config/routes.rb b/config/routes.rb index 69a096c44..af0e04bc4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -649,6 +649,22 @@ Rails.application.routes.draw do end end + resources :up_items, only: %i[index new create show edit update destroy] do + collection do + get :preload_accounts + get :select_accounts + post :link_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + get :setup_accounts + post :complete_account_setup + end + end + resources :sophtron_items, only: %i[index new create show edit update destroy] do collection do get :preload_accounts diff --git a/db/migrate/20260617120000_create_up_items_and_accounts.rb b/db/migrate/20260617120000_create_up_items_and_accounts.rb new file mode 100644 index 000000000..89288641a --- /dev/null +++ b/db/migrate/20260617120000_create_up_items_and_accounts.rb @@ -0,0 +1,49 @@ +class CreateUpItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + create_table :up_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + t.string :institution_id + t.string :institution_name + t.string :institution_domain + t.string :institution_url + t.string :institution_color + t.string :status, default: "good", null: false + t.boolean :scheduled_for_deletion, default: false, null: false + t.boolean :pending_account_setup, default: false, null: false + t.date :sync_start_date + t.jsonb :raw_payload + t.jsonb :raw_institution_payload + t.text :access_token + t.timestamps + end + + add_index :up_items, :status + + create_table :up_accounts, id: :uuid do |t| + t.references :up_item, null: false, foreign_key: true, type: :uuid + t.string :name, null: false + t.string :account_id + t.string :currency, null: false + t.decimal :current_balance, precision: 19, scale: 4 + t.string :account_status + t.string :account_type + t.string :ownership_type + t.string :provider + t.boolean :ignored, default: false, null: false + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + t.date :sync_start_date + + t.timestamps + end + + add_index :up_accounts, :account_id + add_index :up_accounts, + [ :up_item_id, :account_id ], + unique: true, + where: "account_id IS NOT NULL", + name: "index_up_accounts_on_item_and_account_id" + end +end diff --git a/db/schema.rb b/db/schema.rb index ba470e69d..6999eaacd 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_06_08_160000) do +ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1971,6 +1971,49 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_08_160000) do t.check_constraint "source_fee_amount >= 0::numeric", name: "check_source_fee_non_negative" end + create_table "up_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "up_item_id", null: false + t.string "name", null: false + t.string "account_id" + t.string "currency", null: false + t.decimal "current_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "ownership_type" + t.string "provider" + t.boolean "ignored", default: false, null: false + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.date "sync_start_date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_up_accounts_on_account_id" + t.index ["up_item_id", "account_id"], name: "index_up_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" + t.index ["up_item_id"], name: "index_up_accounts_on_up_item_id" + end + + create_table "up_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_id" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false + t.boolean "pending_account_setup", default: false, null: false + t.date "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.text "access_token" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_up_items_on_family_id" + t.index ["status"], name: "index_up_items_on_status" + end + create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "first_name" @@ -2166,6 +2209,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_08_160000) do add_foreign_key "transactions", "merchants" add_foreign_key "transfers", "transactions", column: "inflow_transaction_id", on_delete: :cascade add_foreign_key "transfers", "transactions", column: "outflow_transaction_id", on_delete: :cascade + add_foreign_key "up_accounts", "up_items" + add_foreign_key "up_items", "families" add_foreign_key "users", "accounts", column: "default_account_id", on_delete: :nullify add_foreign_key "users", "chats", column: "last_viewed_chat_id" add_foreign_key "users", "families" diff --git a/mobile/assets/icons/lucide/chevron-right.svg b/mobile/assets/icons/lucide/chevron-right.svg new file mode 100644 index 000000000..db62a0c20 --- /dev/null +++ b/mobile/assets/icons/lucide/chevron-right.svg @@ -0,0 +1,13 @@ + + + diff --git a/mobile/lib/screens/more_screen.dart b/mobile/lib/screens/more_screen.dart index 970b20eab..3c41c9f81 100644 --- a/mobile/lib/screens/more_screen.dart +++ b/mobile/lib/screens/more_screen.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import '../theme/sure_colors.dart'; +import '../theme/sure_tokens.dart'; +import '../widgets/sure_list_group.dart'; import 'calendar_screen.dart'; import 'recent_transactions_screen.dart'; @@ -7,79 +10,61 @@ class MoreScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Scaffold( - 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(), + // Setting an explicit ListView padding opts out of the scroll view's + // automatic safe-area inset, so restore it with SafeArea (keeps the group + // clear of the status bar / home indicator). + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + SureListGroup( + children: [ + SureListRow( + leading: _iconBadge(context, Icons.calendar_month), + title: 'Account Calendar', + subtitle: 'View monthly balance changes by account', + showChevron: true, + 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(), + SureListRow( + leading: _iconBadge(context, Icons.receipt_long), + title: 'Recent Transactions', + subtitle: 'View recent transactions across all accounts', + showChevron: true, + 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, - ), + Widget _iconBadge(BuildContext context, IconData icon) { + final palette = SureColors.of(context).palette; + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: palette.surfaceInset, + borderRadius: BorderRadius.circular(SureTokens.radiusMd), ), - 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, + child: Icon(icon, color: palette.textPrimary), ); } } diff --git a/mobile/lib/screens/transaction_form_screen.dart b/mobile/lib/screens/transaction_form_screen.dart index 5198b320c..ef54cd6e6 100644 --- a/mobile/lib/screens/transaction_form_screen.dart +++ b/mobile/lib/screens/transaction_form_screen.dart @@ -9,14 +9,12 @@ import '../providers/transactions_provider.dart'; import '../services/log_service.dart'; import '../services/connectivity_service.dart'; import '../utils/amount_parser.dart'; +import '../widgets/sure_segmented_control.dart'; class TransactionFormScreen extends StatefulWidget { final Account account; - const TransactionFormScreen({ - super.key, - required this.account, - }); + const TransactionFormScreen({super.key, required this.account}); @override State createState() => _TransactionFormScreenState(); @@ -47,7 +45,10 @@ class _TransactionFormScreenState extends State { Future _fetchCategories() async { final authProvider = Provider.of(context, listen: false); - final categoriesProvider = Provider.of(context, listen: false); + final categoriesProvider = Provider.of( + context, + listen: false, + ); final accessToken = await authProvider.getValidAccessToken(); if (accessToken != null) { categoriesProvider.fetchCategories(accessToken: accessToken); @@ -114,11 +115,17 @@ class _TransactionFormScreenState extends State { try { final authProvider = Provider.of(context, listen: false); - final transactionsProvider = 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'); + _log.warning( + 'TransactionForm', + 'Access token is null, session expired', + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -139,7 +146,10 @@ class _TransactionFormScreenState extends State { locale: _currentLocaleName(), ); - _log.info('TransactionForm', 'Calling TransactionsProvider.createTransaction (offline-first)'); + _log.info( + 'TransactionForm', + 'Calling TransactionsProvider.createTransaction (offline-first)', + ); // Use TransactionsProvider for offline-first transaction creation final success = await transactionsProvider.createTransaction( @@ -157,18 +167,24 @@ class _TransactionFormScreenState extends State { if (mounted) { if (success) { - _log.info('TransactionForm', 'Transaction created successfully (saved locally)'); - + _log.info( + 'TransactionForm', + 'Transaction created successfully (saved locally)', + ); + // Check current connectivity status to show appropriate message - final connectivityService = Provider.of(context, listen: false); + final connectivityService = Provider.of( + context, + listen: false, + ); final isOnline = connectivityService.isOnline; - + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( isOnline ? 'Transaction created successfully' - : 'Transaction saved (will sync when online)' + : 'Transaction saved (will sync when online)', ), backgroundColor: Colors.green, ), @@ -185,7 +201,10 @@ class _TransactionFormScreenState extends State { } } } catch (e) { - _log.error('TransactionForm', 'Exception during transaction creation: $e'); + _log.error( + 'TransactionForm', + 'Exception during transaction creation: $e', + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -235,7 +254,10 @@ class _TransactionFormScreenState extends State { ), // Title Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -281,20 +303,28 @@ class _TransactionFormScreenState extends State { const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ Text( widget.account.name, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 4), Text( '${widget.account.balance} ${widget.account.currency}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: + colorScheme.onSurfaceVariant, + ), ), ], ), @@ -308,37 +338,38 @@ class _TransactionFormScreenState extends State { // Transaction type selection Text( 'Type', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), - SegmentedButton( + SureSegmentedControl( + selected: _nature, + onChanged: (value) { + setState(() { + _nature = value; + }); + }, segments: const [ - ButtonSegment( + SureSegment( value: 'expense', - label: Text('Expense'), + label: 'Expense', icon: Icon(Icons.arrow_downward), ), - ButtonSegment( + SureSegment( value: 'income', - label: Text('Income'), + label: 'Income', icon: Icon(Icons.arrow_upward), ), ], - selected: {_nature}, - onSelectionChanged: (Set newSelection) { - setState(() { - _nature = newSelection.first; - }); - }, ), const SizedBox(height: 24), // Amount field TextFormField( controller: _amountController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), decoration: InputDecoration( labelText: 'Amount *', prefixIcon: const Icon(Icons.attach_money), @@ -356,7 +387,11 @@ class _TransactionFormScreenState extends State { _showMoreFields = !_showMoreFields; }); }, - icon: Icon(_showMoreFields ? Icons.expand_less : Icons.expand_more), + icon: Icon( + _showMoreFields + ? Icons.expand_less + : Icons.expand_more, + ), label: Text(_showMoreFields ? 'Less' : 'More'), ), @@ -428,8 +463,9 @@ class _TransactionFormScreenState extends State { if (value == null) { _selectedCategory = null; } else { - _selectedCategory = categories - .firstWhere((c) => c.id == value); + _selectedCategory = categories.firstWhere( + (c) => c.id == value, + ); } }); }, diff --git a/mobile/lib/widgets/account_card.dart b/mobile/lib/widgets/account_card.dart index cc070ab75..bb8e13137 100644 --- a/mobile/lib/widgets/account_card.dart +++ b/mobile/lib/widgets/account_card.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import '../models/account.dart'; import '../theme/sure_colors.dart'; +import '../theme/sure_tokens.dart'; import 'money_text.dart'; +import 'sure_card.dart'; import 'sure_icon.dart'; class AccountCard extends StatelessWidget { @@ -43,7 +45,7 @@ class AccountCard extends StatelessWidget { Color _getAccountColor(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + if (account.isAsset) { return SureColors.of(context).palette.success; } else if (account.isLiability) { @@ -57,82 +59,76 @@ class AccountCard extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final accountColor = _getAccountColor(context); - final cardContent = Card( + final cardContent = SureCard( margin: const EdgeInsets.only(bottom: 12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( + onTap: onTap, + child: Row( + children: [ + // Account icon + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: accountColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: SureIcon( + _getAccountIconName(), + color: accountColor, + size: SureIconSize.lg, + ), + ), + const SizedBox(width: 16), + + // Account info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + account.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + account.displayAccountType, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + + // Balance + Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - // Account icon - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: accountColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: SureIcon( - _getAccountIconName(), - color: accountColor, - size: SureIconSize.lg, + Text( + account.balance, + style: SureMoney.tabular( + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + color: account.isLiability + ? SureColors.of(context).palette.destructive + : null, + ), ), ), - const SizedBox(width: 16), - - // Account info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - account.name, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - account.displayAccountType, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], + const SizedBox(height: 4), + Text( + account.currency, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), ), - - // Balance - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - account.balance, - style: SureMoney.tabular( - Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, - color: account.isLiability - ? SureColors.of(context).palette.destructive - : null, - ), - ), - ), - const SizedBox(height: 4), - Text( - account.currency, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), ], ), - ), + ], ), ); @@ -152,7 +148,7 @@ class AccountCard extends StatelessWidget { padding: const EdgeInsets.only(right: 20), decoration: BoxDecoration( color: Colors.blue, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(SureTokens.radiusLg), ), child: const Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/mobile/lib/widgets/currency_filter.dart b/mobile/lib/widgets/currency_filter.dart index ad2e86b47..0b82f379a 100644 --- a/mobile/lib/widgets/currency_filter.dart +++ b/mobile/lib/widgets/currency_filter.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'sure_chip.dart'; + class CurrencyFilter extends StatelessWidget { final Set availableCurrencies; final Set selectedCurrencies; @@ -42,9 +44,12 @@ class CurrencyFilter extends StatelessWidget { } final sortedCurrencies = availableCurrencies.toList()..sort(); - final colorScheme = Theme.of(context).colorScheme; - final isAllSelected = selectedCurrencies.isEmpty || - selectedCurrencies.length == availableCurrencies.length; + // Ignore stale codes that are no longer available, otherwise a leftover + // selection could make length-based "All" detection fire incorrectly. + final normalizedSelected = + selectedCurrencies.intersection(availableCurrencies); + final isAllSelected = normalizedSelected.isEmpty || + normalizedSelected.length == availableCurrencies.length; return Container( height: 44, @@ -55,44 +60,29 @@ class CurrencyFilter extends StatelessWidget { // "All" chip Padding( padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: const Text('All'), + child: SureChip( + label: 'All', selected: isAllSelected, - onSelected: (_) { - onSelectionChanged({}); - }, - backgroundColor: colorScheme.surfaceContainerHighest, - selectedColor: colorScheme.primaryContainer, - checkmarkColor: colorScheme.onPrimaryContainer, - labelStyle: TextStyle( - color: isAllSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: isAllSelected ? FontWeight.bold : FontWeight.normal, - ), - side: BorderSide( - color: isAllSelected - ? colorScheme.primary - : colorScheme.outline.withValues(alpha: 0.3), - ), - padding: const EdgeInsets.symmetric(horizontal: 8), + onSelected: (_) => onSelectionChanged({}), ), ), // Currency chips ...sortedCurrencies.map((currency) { final isSelected = - selectedCurrencies.contains(currency) && !isAllSelected; + normalizedSelected.contains(currency) && !isAllSelected; final symbol = _getCurrencySymbol(currency); - final displayText = symbol.isNotEmpty ? '$currency ($symbol)' : currency; + final displayText = symbol.isNotEmpty + ? '$currency ($symbol)' + : currency; return Padding( padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(displayText), + child: SureChip( + label: displayText, selected: isSelected, onSelected: (_) { - final newSelection = Set.from(selectedCurrencies); + final newSelection = Set.from(normalizedSelected); if (isSelected) { newSelection.remove(currency); } else { @@ -103,27 +93,13 @@ class CurrencyFilter extends StatelessWidget { newSelection.add(currency); } // If all currencies selected, treat as "All" - if (newSelection.length == availableCurrencies.length) { + if (newSelection.length == availableCurrencies.length && + newSelection.containsAll(availableCurrencies)) { onSelectionChanged({}); } else { onSelectionChanged(newSelection); } }, - backgroundColor: colorScheme.surfaceContainerHighest, - selectedColor: colorScheme.primaryContainer, - checkmarkColor: colorScheme.onPrimaryContainer, - labelStyle: TextStyle( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), - side: BorderSide( - color: isSelected - ? colorScheme.primary - : colorScheme.outline.withValues(alpha: 0.3), - ), - padding: const EdgeInsets.symmetric(horizontal: 8), ), ); }), diff --git a/mobile/lib/widgets/custom_proxy_headers_editor.dart b/mobile/lib/widgets/custom_proxy_headers_editor.dart index 29dcc95ed..ce6c4aa03 100644 --- a/mobile/lib/widgets/custom_proxy_headers_editor.dart +++ b/mobile/lib/widgets/custom_proxy_headers_editor.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../models/custom_proxy_header.dart'; +import 'sure_text_field.dart'; class CustomProxyHeadersEditor extends StatefulWidget { final List initialHeaders; @@ -13,7 +14,8 @@ class CustomProxyHeadersEditor extends StatefulWidget { }); @override - State createState() => _CustomProxyHeadersEditorState(); + State createState() => + _CustomProxyHeadersEditorState(); } class _CustomProxyHeadersEditorState extends State { @@ -30,7 +32,12 @@ class _CustomProxyHeadersEditorState extends State { void _notifyChanged() { widget.onChanged( _drafts - .map((draft) => CustomProxyHeader(name: draft.name.text, value: draft.value.text)) + .map( + (draft) => CustomProxyHeader( + name: draft.name.text, + value: draft.value.text, + ), + ) .where((header) => header.isComplete) .toList(), ); @@ -97,24 +104,23 @@ class _HeaderRow extends StatelessWidget { children: [ Expanded( child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TextFormField( + SureTextField( controller: draft.name, - decoration: const InputDecoration( - labelText: 'Header name', - hintText: 'X-Auth-Token', - ), - validator: (value) => CustomProxyHeader.validateName(value ?? ''), + label: 'Header name', + hint: 'X-Auth-Token', + validator: (value) => + CustomProxyHeader.validateName(value ?? ''), onChanged: (_) => onChanged(), ), - const SizedBox(height: 8), - TextFormField( + const SizedBox(height: 12), + SureTextField( controller: draft.value, - decoration: const InputDecoration( - labelText: 'Header value', - ), + label: 'Header value', obscureText: true, - validator: (value) => CustomProxyHeader.validateValue(value ?? ''), + validator: (value) => + CustomProxyHeader.validateValue(value ?? ''), onChanged: (_) => onChanged(), ), ], @@ -135,8 +141,8 @@ class _HeaderDraft { final TextEditingController value; _HeaderDraft({String name = '', String value = ''}) - : name = TextEditingController(text: name), - value = TextEditingController(text: value); + : name = TextEditingController(text: name), + value = TextEditingController(text: value); void dispose() { name.dispose(); diff --git a/mobile/lib/widgets/sure_card.dart b/mobile/lib/widgets/sure_card.dart new file mode 100644 index 000000000..80e4e08b1 --- /dev/null +++ b/mobile/lib/widgets/sure_card.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../theme/sure_colors.dart'; +import '../theme/sure_tokens.dart'; + +/// Sure design-system card — a tokenized content surface mirroring the web card +/// chrome (`bg-container` + a hairline border + rounded corners + the subtle DS +/// shadow). Use it instead of a Material [Card] so the chrome stays in lockstep +/// with `sure.tokens.json` and reads correctly in light and dark. +/// +/// Colors resolve from the active [SureColors] palette (brightness-aware). When +/// [onTap] is provided the whole card is tappable, with a flat ink response +/// clipped to the card's radius. +class SureCard extends StatelessWidget { + const SureCard({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(16), + this.margin, + this.onTap, + this.elevated = true, + }); + + final Widget child; + + /// Inner padding around [child]. + final EdgeInsetsGeometry padding; + + /// Outer spacing around the card (e.g. separation in a list). + final EdgeInsetsGeometry? margin; + + /// When non-null, the card is tappable. + final VoidCallback? onTap; + + /// Apply the subtle DS card shadow. Set false for cards sitting on an inset + /// surface, where the border alone provides enough separation. + final bool elevated; + + @override + Widget build(BuildContext context) { + final palette = SureColors.of(context).palette; + final radius = BorderRadius.circular(SureTokens.radiusLg); + + Widget content = Padding(padding: padding, child: child); + if (onTap != null) { + // Material(transparency) gives InkWell a surface to paint on without + // covering the card's tokenized background or border. + content = Material( + type: MaterialType.transparency, + child: InkWell(onTap: onTap, borderRadius: radius, child: content), + ); + } + + return Container( + margin: margin, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: palette.container, + borderRadius: radius, + border: Border.all(color: palette.borderSecondary), + boxShadow: elevated ? palette.shadowXs : null, + ), + child: content, + ); + } +} diff --git a/mobile/lib/widgets/sure_chip.dart b/mobile/lib/widgets/sure_chip.dart new file mode 100644 index 000000000..a78238658 --- /dev/null +++ b/mobile/lib/widgets/sure_chip.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import '../theme/sure_colors.dart'; + +/// Sure design-system filter chip — a tokenized selectable pill mirroring the web +/// DS pill: a rounded-full chip that reads as bordered/neutral when unselected +/// and filled (neutral `buttonPrimary` + inverse label) when selected. +/// +/// Colors resolve from the active [SureColors] palette, so it's brightness-aware +/// and stays in lockstep with `sure.tokens.json` (and avoids the Material +/// `primaryContainer` tint the raw `FilterChip` falls back to). +/// +/// ```dart +/// SureChip( +/// label: 'USD', +/// selected: isSelected, +/// onSelected: (next) => toggle(next), +/// ) +/// ``` +class SureChip extends StatelessWidget { + const SureChip({ + super.key, + required this.label, + this.selected = false, + this.onSelected, + this.leading, + this.enabled = true, + }); + + final String label; + final bool selected; + + /// Called with the next selected value when tapped. When null the chip is + /// non-interactive (still renders its selected/unselected state). + final ValueChanged? onSelected; + + /// Optional leading widget (e.g. a color dot or icon). + final Widget? leading; + + final bool enabled; + + @override + Widget build(BuildContext context) { + final palette = SureColors.of(context).palette; + final theme = Theme.of(context); + final interactive = enabled && onSelected != null; + + // Selected: filled neutral pill with an inverse label. Unselected: a + // transparent pill with a hairline border. The border lives on the shape so + // Material paints it and clips the ink to the stadium. + final shape = StadiumBorder( + side: selected + ? BorderSide.none + : BorderSide(color: palette.borderSecondary), + ); + + final content = ConstrainedBox( + // Enforce a comfortable minimum tap target regardless of context + // (Material FilterChip parity); the chip still sizes to content otherwise. + constraints: const BoxConstraints(minHeight: 44), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (leading != null) ...[leading!, const SizedBox(width: 6)], + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: selected ? palette.textInverse : palette.textSecondary, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ], + ), + ), + ); + + return Semantics( + // Announce as a button only when it's actually tappable; a display-only + // chip is just a selected indicator, not a disabled button. + button: interactive ? true : null, + enabled: interactive ? true : null, + selected: selected, + child: Opacity( + opacity: enabled ? 1.0 : 0.5, + child: Material( + color: selected ? palette.buttonPrimary : const Color(0x00000000), + shape: shape, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: interactive ? () => onSelected!(!selected) : null, + customBorder: shape, + child: content, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/sure_icon.dart b/mobile/lib/widgets/sure_icon.dart index e332350ed..8510b8dfe 100644 --- a/mobile/lib/widgets/sure_icon.dart +++ b/mobile/lib/widgets/sure_icon.dart @@ -100,4 +100,5 @@ abstract final class SureIcons { static const String refresh = 'refresh-cw'; static const String chevronUp = 'chevron-up'; static const String chevronDown = 'chevron-down'; + static const String chevronRight = 'chevron-right'; } diff --git a/mobile/lib/widgets/sure_list_group.dart b/mobile/lib/widgets/sure_list_group.dart new file mode 100644 index 000000000..3ac7bc7ad --- /dev/null +++ b/mobile/lib/widgets/sure_list_group.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; + +import '../theme/sure_colors.dart'; +import '../theme/sure_tokens.dart'; +import 'sure_icon.dart'; + +/// Sure design-system grouped list — a tokenized container that stacks +/// [SureListRow]s behind a single rounded surface with a hairline border, the +/// subtle DS shadow, and inset dividers between rows. Mirrors the web grouped +/// inset list (`bg-container` + `rounded-lg` + `shadow-border-xs`, rows clipped +/// and separated by `border-divider`). +/// +/// Colors resolve from the active [SureColors] palette, so the chrome stays in +/// lockstep with `sure.tokens.json` and reads correctly in light and dark. +/// +/// ```dart +/// SureListGroup( +/// header: 'Tools', +/// children: [ +/// SureListRow(title: 'Calendar', subtitle: 'Monthly view', showChevron: true, onTap: ...), +/// SureListRow(title: 'Recent', showChevron: true, onTap: ...), +/// ], +/// ) +/// ``` +class SureListGroup extends StatelessWidget { + const SureListGroup({ + super.key, + required this.children, + this.header, + this.margin, + }); + + /// Rows to stack — typically [SureListRow]s. Dividers are inserted between + /// them automatically (never before the first or after the last). + final List children; + + /// Optional uppercase section label rendered above the group. + final String? header; + + /// Outer spacing around the group (e.g. separation between sections). + final EdgeInsetsGeometry? margin; + + @override + Widget build(BuildContext context) { + if (children.isEmpty) return const SizedBox.shrink(); + + final palette = SureColors.of(context).palette; + final radius = BorderRadius.circular(SureTokens.radiusLg); + + final rows = []; + for (var i = 0; i < children.length; i++) { + if (i > 0) { + // Inset hairline between rows, lighter than the group's edge so the + // separators read as internal (classic grouped-list look). + rows.add(Divider( + height: 1, + thickness: 1, + indent: 16, + endIndent: 16, + color: palette.borderSubdued, + )); + } + rows.add(children[i]); + } + + final group = Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: palette.container, + borderRadius: radius, + border: Border.all(color: palette.borderSecondary), + boxShadow: palette.shadowXs, + ), + child: Column(mainAxisSize: MainAxisSize.min, children: rows), + ); + + final content = header == null + ? group + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: Text( + header!.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: palette.textSecondary, + fontWeight: FontWeight.w500, + letterSpacing: 0.4, + ), + ), + ), + group, + ], + ); + + if (margin == null) return content; + return Padding(padding: margin!, child: content); + } +} + +/// A single row inside a [SureListGroup]. Lays out an optional [leading] widget, +/// a [title] (+ optional [subtitle]), and a trailing affordance — either an +/// explicit [trailing] widget or a DS chevron when [showChevron] is set. +/// +/// The row paints no background or border of its own; it relies on the enclosing +/// [SureListGroup] for chrome, dividers, and corner clipping. When [onTap] is +/// provided the whole row is tappable with a flat ink response (clipped to the +/// group's rounded corners by the group's `antiAlias`). +class SureListRow extends StatelessWidget { + const SureListRow({ + super.key, + required this.title, + this.subtitle, + this.leading, + this.trailing, + this.onTap, + this.showChevron = false, + this.destructive = false, + }); + + final String title; + final String? subtitle; + + /// Leading widget (e.g. an icon badge). Caller's choice — the row is + /// icon-agnostic. + final Widget? leading; + + /// Trailing widget (e.g. a value or switch). Takes precedence over + /// [showChevron] when both are provided. + final Widget? trailing; + + /// When non-null, the row is tappable. + final VoidCallback? onTap; + + /// Show a DS chevron disclosure indicator on the trailing edge. Ignored when + /// an explicit [trailing] is provided. + final bool showChevron; + + /// Render the title in the destructive token (for delete/reset actions). + final bool destructive; + + @override + Widget build(BuildContext context) { + final palette = SureColors.of(context).palette; + final theme = Theme.of(context); + + Widget? trailingWidget = trailing; + if (trailingWidget == null && showChevron) { + trailingWidget = SureIcon( + SureIcons.chevronRight, + size: SureIconSize.md, + color: palette.textSubdued, + ); + } + + Widget content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + if (leading != null) ...[leading!, const SizedBox(width: 12)], + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + color: + destructive ? palette.destructive : palette.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + if (trailingWidget != null) ...[ + const SizedBox(width: 12), + trailingWidget, + ], + ], + ), + ); + + if (onTap != null) { + // Material(transparency) gives the InkWell a surface to paint on without + // covering the group's tokenized background. No borderRadius here — the + // group clips the ripple to its rounded corners via clipBehavior. + // MergeSemantics + Semantics(button) restores the button/enabled flags a + // Material ListTile exposes, so screen readers still announce the whole + // row as a single button (parity with the pre-migration ListTile rows). + content = MergeSemantics( + child: Semantics( + button: true, + enabled: true, + child: Material( + type: MaterialType.transparency, + child: InkWell(onTap: onTap, child: content), + ), + ), + ); + } + + return content; + } +} diff --git a/mobile/lib/widgets/sure_segmented_control.dart b/mobile/lib/widgets/sure_segmented_control.dart new file mode 100644 index 000000000..dc943762e --- /dev/null +++ b/mobile/lib/widgets/sure_segmented_control.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; + +import '../theme/sure_colors.dart'; +import '../theme/sure_tokens.dart'; + +/// One segment of a [SureSegmentedControl]. +class SureSegment { + const SureSegment({required this.value, required this.label, this.icon}); + + final T value; + final String label; + + /// Optional leading icon (e.g. an [Icon] or [SureIcon]); tinted to match the + /// segment's selected/unselected label color. + final Widget? icon; +} + +/// Sure design-system segmented control — a tokenized single-select toggle +/// mirroring the web DS `segmented-control`: an inset track holding equal-width +/// segments, where the selected segment is a raised surface (container fill + +/// the subtle DS shadow) and unselected segments are flat `textSecondary` labels. +/// +/// Colors resolve from the active [SureColors] palette, so it's brightness-aware +/// and stays in lockstep with `sure.tokens.json` (and avoids the Material +/// `SegmentedButton` `secondaryContainer` look). Expects a bounded width — the +/// segments share it equally. +/// +/// ```dart +/// SureSegmentedControl( +/// selected: nature, +/// onChanged: (v) => setState(() => nature = v), +/// segments: const [ +/// SureSegment(value: 'expense', label: 'Expense', icon: Icon(Icons.arrow_downward)), +/// SureSegment(value: 'income', label: 'Income', icon: Icon(Icons.arrow_upward)), +/// ], +/// ) +/// ``` +class SureSegmentedControl extends StatelessWidget { + const SureSegmentedControl({ + super.key, + required this.segments, + required this.selected, + required this.onChanged, + }); + + final List> segments; + final T selected; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + // Selection is value-based (segment.value == selected), so duplicate values + // would render multiple segments as selected at once. Guard in debug builds + // (a const constructor can't host this non-constant check). + assert( + segments.map((s) => s.value).toSet().length == segments.length, + 'SureSegmentedControl requires unique segment values.', + ); + final palette = SureColors.of(context).palette; + final theme = Theme.of(context); + // The palette has no single "raised surface" token that reads correctly in + // both modes, so pick the brightness-appropriate one: white-ish in light, a + // lighter-than-track inset in dark — both sit *above* the track. + final isLight = theme.brightness == Brightness.light; + final selectedBg = + isLight ? palette.container : palette.containerInsetHover; + + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: palette.surfaceInset, + borderRadius: BorderRadius.circular(SureTokens.radiusLg), + border: Border.all(color: palette.borderSecondary), + ), + child: Row( + children: [ + for (final segment in segments) + Expanded( + child: _Segment( + segment: segment, + selected: segment.value == selected, + selectedBg: selectedBg, + palette: palette, + textStyle: theme.textTheme.labelLarge, + onTap: () => onChanged(segment.value), + ), + ), + ], + ), + ); + } +} + +class _Segment extends StatefulWidget { + const _Segment({ + required this.segment, + required this.selected, + required this.selectedBg, + required this.palette, + required this.textStyle, + required this.onTap, + }); + + final SureSegment segment; + final bool selected; + final Color selectedBg; + final SureTokenPalette palette; + final TextStyle? textStyle; + final VoidCallback onTap; + + @override + State<_Segment> createState() => _SegmentState(); +} + +class _SegmentState extends State<_Segment> { + bool _focused = false; + + @override + Widget build(BuildContext context) { + final palette = widget.palette; + final selected = widget.selected; + final fg = selected ? palette.textPrimary : palette.textSecondary; + + return Semantics( + button: true, + selected: selected, + // FocusableActionDetector makes each segment keyboard/switch focusable and + // Enter/Space-activatable (parity with the Material SegmentedButton it + // replaces), mirroring SureButton — without the Material ripple. + child: FocusableActionDetector( + mouseCursor: SystemMouseCursors.click, + onShowFocusHighlight: (value) => setState(() => _focused = value), + actions: >{ + ActivateIntent: CallbackAction( + onInvoke: (_) { + widget.onTap(); + return null; + }, + ), + }, + child: GestureDetector( + onTap: widget.onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + decoration: BoxDecoration( + color: selected ? widget.selectedBg : const Color(0x00000000), + borderRadius: BorderRadius.circular(SureTokens.radiusMd), + boxShadow: [ + if (selected) ...palette.shadowXs, + // Non-displacing focus ring so it doesn't shift the layout. + if (_focused) + BoxShadow( + color: palette.focusRing, + blurRadius: 0, + spreadRadius: 2, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.segment.icon != null) ...[ + IconTheme.merge( + data: IconThemeData(color: fg, size: 18), + child: widget.segment.icon!, + ), + const SizedBox(width: 6), + ], + Flexible( + child: Text( + widget.segment.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: widget.textStyle?.copyWith( + color: fg, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/sure_text_field.dart b/mobile/lib/widgets/sure_text_field.dart new file mode 100644 index 000000000..02cf87c98 --- /dev/null +++ b/mobile/lib/widgets/sure_text_field.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../theme/sure_colors.dart'; +import '../theme/sure_tokens.dart'; + +/// Sure design-system text field — a tokenized [TextFormField] wrapper mirroring +/// the web DS form field: an optional label above a filled `bg-container` input +/// with a hairline border, rounded corners, a `textSubdued` placeholder, a +/// stronger border on focus, and the destructive token for errors. +/// +/// It builds a complete [InputDecoration] from the active [SureColors] palette +/// (rather than leaning on theme defaults), so the chrome is brightness-aware, +/// self-contained, and stays in lockstep with `sure.tokens.json`. +/// +/// ```dart +/// SureTextField( +/// controller: _email, +/// label: 'Email', +/// hint: 'you@example.com', +/// keyboardType: TextInputType.emailAddress, +/// prefixIcon: const Icon(Icons.mail_outline), +/// validator: (v) => (v == null || v.isEmpty) ? 'Required' : null, +/// ) +/// ``` +class SureTextField extends StatelessWidget { + const SureTextField({ + super.key, + this.controller, + this.label, + this.hint, + this.helperText, + this.prefixIcon, + this.suffixIcon, + this.obscureText = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.inputFormatters, + this.maxLines = 1, + this.minLines, + this.maxLength, + this.validator, + this.onChanged, + this.onFieldSubmitted, + this.onTap, + this.focusNode, + this.autovalidateMode, + }); + + final TextEditingController? controller; + + /// Optional label rendered above the field (DS style — not a Material floating + /// label, so it stays put and reads like the web `form-field__label`). + final String? label; + + /// Placeholder text shown when empty (rendered in `textSubdued`). + final String? hint; + + /// Optional helper text below the field. + final String? helperText; + + final Widget? prefixIcon; + final Widget? suffixIcon; + + final bool obscureText; + final bool enabled; + final bool readOnly; + final bool autofocus; + + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final TextCapitalization textCapitalization; + final List? inputFormatters; + + final int maxLines; + final int? minLines; + final int? maxLength; + + final FormFieldValidator? validator; + final ValueChanged? onChanged; + final ValueChanged? onFieldSubmitted; + final VoidCallback? onTap; + final FocusNode? focusNode; + final AutovalidateMode? autovalidateMode; + + @override + Widget build(BuildContext context) { + final palette = SureColors.of(context).palette; + final theme = Theme.of(context); + + OutlineInputBorder borderOf(Color color, [double width = 1]) { + return OutlineInputBorder( + borderRadius: BorderRadius.circular(SureTokens.radiusLg), + borderSide: BorderSide(color: color, width: width), + ); + } + + // An obscured field is single-line by definition; otherwise grow maxLines to + // cover minLines so a caller passing only minLines can't trip Flutter's + // `minLines <= maxLines` assert. + final resolvedMinLines = obscureText ? null : minLines; + final resolvedMaxLines = obscureText + ? 1 + : (minLines != null && minLines! > maxLines ? minLines : maxLines); + + final field = TextFormField( + controller: controller, + focusNode: focusNode, + enabled: enabled, + readOnly: readOnly, + autofocus: autofocus, + obscureText: obscureText, + keyboardType: keyboardType, + textInputAction: textInputAction, + textCapitalization: textCapitalization, + inputFormatters: inputFormatters, + maxLines: resolvedMaxLines, + minLines: resolvedMinLines, + maxLength: maxLength, + validator: validator, + onChanged: onChanged, + onFieldSubmitted: onFieldSubmitted, + onTap: onTap, + autovalidateMode: autovalidateMode, + style: theme.textTheme.bodyLarge?.copyWith(color: palette.textPrimary), + cursorColor: palette.textPrimary, + decoration: InputDecoration( + hintText: hint, + helperText: helperText, + prefixIcon: prefixIcon, + suffixIcon: suffixIcon, + filled: true, + fillColor: palette.container, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + hintStyle: theme.textTheme.bodyLarge?.copyWith( + color: palette.textSubdued, + ), + helperStyle: theme.textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + ), + errorStyle: theme.textTheme.bodySmall?.copyWith( + color: palette.destructive, + ), + errorMaxLines: 2, + prefixIconColor: palette.textSecondary, + suffixIconColor: palette.textSecondary, + border: borderOf(palette.borderSecondary), + enabledBorder: borderOf(palette.borderSecondary), + focusedBorder: borderOf(palette.borderPrimary, 1.5), + disabledBorder: borderOf(palette.borderSubdued), + errorBorder: borderOf(palette.destructive), + focusedErrorBorder: borderOf(palette.destructive, 1.5), + ), + ); + + if (label == null) return field; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // The label is shown visually but excluded from semantics; it's attached + // to the field below instead, so screen readers announce the field with + // its name (parity with Material's `labelText`) rather than reading a + // detached label node. + ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.only(left: 2, bottom: 6), + child: Text( + label!, + style: theme.textTheme.labelMedium?.copyWith( + color: enabled ? palette.textSecondary : palette.textSubdued, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + Semantics(label: label, child: field), + ], + ); + } +} diff --git a/mobile/test/widgets/currency_filter_test.dart b/mobile/test/widgets/currency_filter_test.dart new file mode 100644 index 000000000..cc62cf4df --- /dev/null +++ b/mobile/test/widgets/currency_filter_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/theme/sure_theme.dart'; +import 'package:sure_mobile/widgets/currency_filter.dart'; +import 'package:sure_mobile/widgets/sure_chip.dart'; + +// Proof that migrating the filter chips to SureChip preserved the selection +// behavior (per-currency toggle + the "All" reset). +void main() { + Future pump( + WidgetTester tester, { + required Set selected, + required ValueChanged> onChanged, + }) { + return tester.pumpWidget( + MaterialApp( + theme: SureTheme.light, + home: Scaffold( + body: CurrencyFilter( + availableCurrencies: const {'USD', 'EUR', 'GBP'}, + selectedCurrencies: selected, + onSelectionChanged: onChanged, + ), + ), + ), + ); + } + + testWidgets('renders an "All" chip plus one SureChip per currency', + (tester) async { + await pump(tester, selected: const {}, onChanged: (_) {}); + expect(find.byType(SureChip), findsNWidgets(4)); // All + 3 currencies + expect(find.text('All'), findsOneWidget); + }); + + testWidgets('selecting a currency reports just that currency', + (tester) async { + Set? latest; + await pump(tester, selected: const {}, onChanged: (s) => latest = s); + await tester.tap(find.text('EUR (€)')); + expect(latest, {'EUR'}); + }); + + testWidgets('tapping "All" resets the selection', (tester) async { + Set? latest; + await pump(tester, selected: const {'EUR'}, onChanged: (s) => latest = s); + await tester.tap(find.text('All')); + expect(latest, isEmpty); + }); +} diff --git a/mobile/test/widgets/custom_proxy_headers_editor_test.dart b/mobile/test/widgets/custom_proxy_headers_editor_test.dart new file mode 100644 index 000000000..119457b7f --- /dev/null +++ b/mobile/test/widgets/custom_proxy_headers_editor_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/models/custom_proxy_header.dart'; +import 'package:sure_mobile/theme/sure_theme.dart'; +import 'package:sure_mobile/widgets/custom_proxy_headers_editor.dart'; +import 'package:sure_mobile/widgets/sure_text_field.dart'; + +// Proof that migrating the editor's fields to SureTextField preserved behavior: +// the fields still render, onChanged still fires, and validators still run. +void main() { + Future pump(WidgetTester tester, Widget child) { + return tester.pumpWidget( + MaterialApp(theme: SureTheme.light, home: Scaffold(body: child)), + ); + } + + testWidgets('renders SureTextField rows for each header', (tester) async { + await pump( + tester, + CustomProxyHeadersEditor( + initialHeaders: [CustomProxyHeader(name: 'X-Token', value: 'abc')], + onChanged: (_) {}, + ), + ); + // Name + value field per header row. + expect(find.byType(SureTextField), findsNWidgets(2)); + expect(find.text('Header name'), findsOneWidget); + expect(find.text('Header value'), findsOneWidget); + }); + + testWidgets('editing a field fires onChanged with the parsed headers', + (tester) async { + List? latest; + await pump( + tester, + CustomProxyHeadersEditor( + initialHeaders: [CustomProxyHeader(name: 'X-Token', value: 'abc')], + onChanged: (headers) => latest = headers, + ), + ); + + await tester.enterText(find.byType(TextField).first, 'X-Renamed'); + await tester.pump(); + + expect(latest, isNotNull); + expect(latest!.single.name, 'X-Renamed'); + expect(latest!.single.value, 'abc'); + }); +} diff --git a/mobile/test/widgets/sure_card_test.dart b/mobile/test/widgets/sure_card_test.dart new file mode 100644 index 000000000..7df369e74 --- /dev/null +++ b/mobile/test/widgets/sure_card_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/theme/sure_theme.dart'; +import 'package:sure_mobile/theme/sure_tokens.dart'; +import 'package:sure_mobile/widgets/sure_card.dart'; + +void main() { + Future pump(WidgetTester tester, Widget child, + {Brightness brightness = Brightness.light}) { + return tester.pumpWidget( + MaterialApp( + theme: + brightness == Brightness.light ? SureTheme.light : SureTheme.dark, + home: Scaffold(body: child), + ), + ); + } + + BoxDecoration decorationOf(WidgetTester tester) => tester + .widget( + find + .descendant(of: find.byType(SureCard), matching: find.byType(Container)) + .first, + ) + .decoration as BoxDecoration; + + // Brightness-aware by contract — assert the chrome resolves the right palette + // in both themes so a token regression in either mode is caught. + for (final (brightness, tokens) in [ + (Brightness.light, SureTokens.light), + (Brightness.dark, SureTokens.dark), + ]) { + testWidgets('paints the Sure card chrome from tokens (${brightness.name})', + (tester) async { + await pump(tester, const SureCard(child: Text('Body')), + brightness: brightness); + + final deco = decorationOf(tester); + expect(deco.color, tokens.container); + expect((deco.border as Border).top.color, tokens.borderSecondary); + expect(deco.borderRadius, BorderRadius.circular(SureTokens.radiusLg)); + expect(deco.boxShadow, tokens.shadowXs); + expect(find.text('Body'), findsOneWidget); + }); + } + + testWidgets('elevated: false drops the shadow', (tester) async { + await pump(tester, const SureCard(elevated: false, child: Text('Body'))); + expect(decorationOf(tester).boxShadow, isNull); + }); + + testWidgets('onTap fires and is clipped to the card (InkWell present)', + (tester) async { + var taps = 0; + await pump( + tester, + SureCard(onTap: () => taps++, child: const Text('Tap me')), + ); + final inkWell = tester.widget(find.byType(InkWell)); + expect(inkWell.borderRadius, BorderRadius.circular(SureTokens.radiusLg)); + await tester.tap(find.text('Tap me')); + expect(taps, 1); + }); + + testWidgets('is non-interactive without onTap (no InkWell)', (tester) async { + await pump(tester, const SureCard(child: Text('Body'))); + expect(find.byType(InkWell), findsNothing); + }); +} diff --git a/mobile/test/widgets/sure_chip_test.dart b/mobile/test/widgets/sure_chip_test.dart new file mode 100644 index 000000000..6fb27625f --- /dev/null +++ b/mobile/test/widgets/sure_chip_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/theme/sure_theme.dart'; +import 'package:sure_mobile/theme/sure_tokens.dart'; +import 'package:sure_mobile/widgets/sure_chip.dart'; + +void main() { + Future pump( + WidgetTester tester, + Widget child, { + Brightness brightness = Brightness.light, + }) { + return tester.pumpWidget( + MaterialApp( + theme: + brightness == Brightness.light ? SureTheme.light : SureTheme.dark, + home: Scaffold(body: Center(child: child)), + ), + ); + } + + Material materialOf(WidgetTester tester) => tester.widget( + find + .descendant( + of: find.byType(SureChip), matching: find.byType(Material)) + .first, + ); + + // Brightness-aware by contract — assert both selection states resolve the + // right tokens in light and dark. + for (final (brightness, tokens) in [ + (Brightness.light, SureTokens.light), + (Brightness.dark, SureTokens.dark), + ]) { + testWidgets( + 'selected chip fills with the neutral token (${brightness.name})', + (tester) async { + await pump( + tester, + const SureChip(label: 'USD', selected: true), + brightness: brightness, + ); + expect(materialOf(tester).color, tokens.buttonPrimary); + final label = tester.widget(find.text('USD')); + expect(label.style?.color, tokens.textInverse); + expect(label.style?.fontWeight, FontWeight.w600); + // Filled chip drops the border. + expect( + (materialOf(tester).shape as StadiumBorder).side, + BorderSide.none, + ); + }, + ); + + testWidgets( + 'unselected chip is a bordered transparent pill (${brightness.name})', + (tester) async { + await pump( + tester, + const SureChip(label: 'USD'), + brightness: brightness, + ); + expect(materialOf(tester).color, const Color(0x00000000)); + expect( + (materialOf(tester).shape as StadiumBorder).side.color, + tokens.borderSecondary, + ); + final label = tester.widget(find.text('USD')); + expect(label.style?.color, tokens.textSecondary); + expect(label.style?.fontWeight, FontWeight.w500); + }, + ); + } + + testWidgets('tapping reports the next selected value', (tester) async { + bool? next; + await pump( + tester, + SureChip(label: 'USD', selected: false, onSelected: (v) => next = v), + ); + await tester.tap(find.byType(SureChip)); + expect(next, isTrue); + + await pump( + tester, + SureChip(label: 'USD', selected: true, onSelected: (v) => next = v), + ); + await tester.tap(find.byType(SureChip)); + expect(next, isFalse); + }); + + testWidgets('exposes button + selected semantics', (tester) async { + final handle = tester.ensureSemantics(); + await pump( + tester, + SureChip(label: 'USD', selected: true, onSelected: (_) {}), + ); + final node = tester.getSemantics(find.byType(SureChip)); + expect(node.hasFlag(SemanticsFlag.isButton), isTrue); + expect(node.hasFlag(SemanticsFlag.isSelected), isTrue); + handle.dispose(); + }); + + testWidgets('a chip without onSelected is non-interactive', (tester) async { + await pump(tester, const SureChip(label: 'USD')); + expect(tester.widget(find.byType(InkWell)).onTap, isNull); + }); + + testWidgets('enabled: false is non-interactive and dimmed', (tester) async { + var taps = 0; + await pump( + tester, + SureChip(label: 'USD', enabled: false, onSelected: (_) => taps++), + ); + expect(tester.widget(find.byType(InkWell)).onTap, isNull); + expect(tester.widget(find.byType(Opacity)).opacity, 0.5); + await tester.tap(find.byType(SureChip)); + expect(taps, 0); + }); + + testWidgets('meets the minimum tap-target height', (tester) async { + await pump(tester, const SureChip(label: 'USD')); + expect( + tester.getSize(find.byType(SureChip)).height, greaterThanOrEqualTo(44)); + }); +} diff --git a/mobile/test/widgets/sure_list_group_test.dart b/mobile/test/widgets/sure_list_group_test.dart new file mode 100644 index 000000000..53f5bb566 --- /dev/null +++ b/mobile/test/widgets/sure_list_group_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/theme/sure_theme.dart'; +import 'package:sure_mobile/theme/sure_tokens.dart'; +import 'package:sure_mobile/widgets/sure_icon.dart'; +import 'package:sure_mobile/widgets/sure_list_group.dart'; + +void main() { + Future pump(WidgetTester tester, Widget child, + {Brightness brightness = Brightness.light}) { + return tester.pumpWidget( + MaterialApp( + theme: + brightness == Brightness.light ? SureTheme.light : SureTheme.dark, + home: Scaffold(body: child), + ), + ); + } + + BoxDecoration groupDecorationOf(WidgetTester tester) => tester + .widget( + find + .descendant( + of: find.byType(SureListGroup), + matching: find.byType(Container), + ) + .first, + ) + .decoration as BoxDecoration; + + // Brightness-aware by contract — assert the chrome resolves the right palette + // in both themes so a token regression in either mode is caught. + for (final (brightness, tokens) in [ + (Brightness.light, SureTokens.light), + (Brightness.dark, SureTokens.dark), + ]) { + testWidgets('paints the group chrome from tokens (${brightness.name})', + (tester) async { + await pump( + tester, + const SureListGroup(children: [SureListRow(title: 'Only row')]), + brightness: brightness, + ); + + final deco = groupDecorationOf(tester); + expect(deco.color, tokens.container); + expect((deco.border as Border).top.color, tokens.borderSecondary); + expect(deco.borderRadius, BorderRadius.circular(SureTokens.radiusLg)); + expect(deco.boxShadow, tokens.shadowXs); + }); + } + + for (final (brightness, tokens) in [ + (Brightness.light, SureTokens.light), + (Brightness.dark, SureTokens.dark), + ]) { + testWidgets( + 'inserts subdued interior dividers but not at the edges (${brightness.name})', + (tester) async { + await pump( + tester, + const SureListGroup(children: [ + SureListRow(title: 'One'), + SureListRow(title: 'Two'), + SureListRow(title: 'Three'), + ]), + brightness: brightness, + ); + + // Three rows => exactly two interior dividers. + final dividers = + tester.widgetList(find.byType(Divider)).toList(); + expect(dividers, hasLength(2)); + expect(dividers.first.color, tokens.borderSubdued); + }); + } + + testWidgets('renders an uppercased header above the group', (tester) async { + await pump( + tester, + const SureListGroup( + header: 'Tools', + children: [SureListRow(title: 'Calendar')], + ), + ); + expect(find.text('TOOLS'), findsOneWidget); + }); + + for (final (brightness, tokens) in [ + (Brightness.light, SureTokens.light), + (Brightness.dark, SureTokens.dark), + ]) { + testWidgets( + 'row renders title and subtitle with tokenized colors (${brightness.name})', + (tester) async { + await pump( + tester, + const SureListGroup( + children: [SureListRow(title: 'Calendar', subtitle: 'Monthly view')], + ), + brightness: brightness, + ); + final title = tester.widget(find.text('Calendar')); + final subtitle = tester.widget(find.text('Monthly view')); + expect(title.style?.color, tokens.textPrimary); + expect(subtitle.style?.color, tokens.textSecondary); + }); + + testWidgets( + 'destructive row paints the title in the destructive token (${brightness.name})', + (tester) async { + await pump( + tester, + const SureListGroup( + children: [SureListRow(title: 'Delete account', destructive: true)], + ), + brightness: brightness, + ); + final title = tester.widget(find.text('Delete account')); + expect(title.style?.color, tokens.destructive); + }); + } + + testWidgets( + 'showChevron renders the DS chevron, suppressed by explicit trailing', + (tester) async { + await pump( + tester, + const SureListGroup( + children: [SureListRow(title: 'Go', showChevron: true)]), + ); + final chevron = tester.widget(find.byType(SureIcon)); + expect(chevron.name, SureIcons.chevronRight); + + await pump( + tester, + const SureListGroup(children: [ + SureListRow( + title: 'Go', + showChevron: true, + trailing: Text('value'), + ), + ]), + ); + expect(find.byType(SureIcon), findsNothing); + expect(find.text('value'), findsOneWidget); + }); + + testWidgets('onTap fires and the tappable row uses an InkWell', + (tester) async { + var taps = 0; + await pump( + tester, + SureListGroup( + children: [SureListRow(title: 'Tap me', onTap: () => taps++)], + ), + ); + expect(find.byType(InkWell), findsOneWidget); + await tester.tap(find.text('Tap me')); + expect(taps, 1); + }); + + testWidgets('a row without onTap is non-interactive (no InkWell)', + (tester) async { + await pump( + tester, + const SureListGroup(children: [SureListRow(title: 'Static')]), + ); + expect(find.byType(InkWell), findsNothing); + }); + + testWidgets('a tappable row exposes button semantics (ListTile parity)', + (tester) async { + final handle = tester.ensureSemantics(); + await pump( + tester, + SureListGroup(children: [SureListRow(title: 'Go', onTap: () {})]), + ); + final node = tester.getSemantics(find.text('Go')); + expect(node.hasFlag(SemanticsFlag.isButton), isTrue); + expect(node.hasFlag(SemanticsFlag.isEnabled), isTrue); + handle.dispose(); + }); + + testWidgets('an empty group builds no chrome', (tester) async { + await pump(tester, const SureListGroup(children: [])); + // No decorated container and no dividers — it collapses rather than + // painting an empty bordered/shadowed box. + expect( + find.descendant( + of: find.byType(SureListGroup), + matching: find.byType(Container), + ), + findsNothing, + ); + expect(find.byType(Divider), findsNothing); + }); +} diff --git a/mobile/test/widgets/sure_segmented_control_test.dart b/mobile/test/widgets/sure_segmented_control_test.dart new file mode 100644 index 000000000..aaef2fe52 --- /dev/null +++ b/mobile/test/widgets/sure_segmented_control_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/theme/sure_theme.dart'; +import 'package:sure_mobile/theme/sure_tokens.dart'; +import 'package:sure_mobile/widgets/sure_segmented_control.dart'; + +void main() { + Future pump( + WidgetTester tester, + Widget child, { + Brightness brightness = Brightness.light, + }) { + return tester.pumpWidget( + MaterialApp( + theme: + brightness == Brightness.light ? SureTheme.light : SureTheme.dark, + home: Scaffold(body: Center(child: child)), + ), + ); + } + + Widget control(String selected, ValueChanged onChanged) => + SureSegmentedControl( + selected: selected, + onChanged: onChanged, + segments: const [ + SureSegment(value: 'expense', label: 'Expense'), + SureSegment(value: 'income', label: 'Income'), + ], + ); + + // The selected segment's raised fill is the only chrome that differs by + // brightness, so assert it resolves correctly in both. + for (final (brightness, tokens) in [ + (Brightness.light, SureTokens.light), + (Brightness.dark, SureTokens.dark), + ]) { + testWidgets( + 'selected segment is a raised surface above the track ' + '(${brightness.name})', (tester) async { + await pump(tester, control('expense', (_) {}), brightness: brightness); + + // Track decoration — the control's only plain Container (segments use + // AnimatedContainer), so this is unambiguous. + final track = tester + .widget(find + .descendant( + of: find.byType(SureSegmentedControl), + matching: find.byType(Container), + ) + .first) + .decoration as BoxDecoration; + expect(track.color, tokens.surfaceInset); + + // Selected segment fill = brightness-appropriate raised token + shadow. + final selectedBg = brightness == Brightness.light + ? tokens.container + : tokens.containerInsetHover; + final selectedSeg = tester + .widget( + find + .ancestor( + of: find.text('Expense'), + matching: find.byType(AnimatedContainer), + ) + .first, + ) + .decoration as BoxDecoration; + expect(selectedSeg.color, selectedBg); + expect(selectedSeg.boxShadow, tokens.shadowXs); + + // Unselected segment is flat (transparent, no shadow). + final unselectedSeg = tester + .widget( + find + .ancestor( + of: find.text('Income'), + matching: find.byType(AnimatedContainer), + ) + .first, + ) + .decoration as BoxDecoration; + expect(unselectedSeg.color, const Color(0x00000000)); + expect(unselectedSeg.boxShadow, isEmpty); + }); + } + + testWidgets('selected vs unselected labels use the right tokens', ( + tester, + ) async { + await pump(tester, control('expense', (_) {})); + expect( + tester.widget(find.text('Expense')).style?.color, + SureTokens.light.textPrimary, + ); + expect( + tester.widget(find.text('Income')).style?.color, + SureTokens.light.textSecondary, + ); + }); + + testWidgets('tapping a segment reports its value', (tester) async { + String? picked; + await pump(tester, control('expense', (v) => picked = v)); + await tester.tap(find.text('Income')); + expect(picked, 'income'); + }); + + testWidgets('each segment is keyboard/switch focusable + activatable', + (tester) async { + // FocusableActionDetector per segment = keyboard/switch parity with the + // Material SegmentedButton it replaced (regression guard for the focus gap). + await pump(tester, control('expense', (_) {})); + expect(find.byType(FocusableActionDetector), findsNWidgets(2)); + }); + + testWidgets('a selected value matching no segment highlights nothing', + (tester) async { + await pump(tester, control('neither', (_) {})); + for (final t in tester + .widgetList(find.byType(AnimatedContainer))) { + expect((t.decoration as BoxDecoration).color, const Color(0x00000000)); + } + }); + + testWidgets('each segment exposes button + selected semantics', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pump(tester, control('expense', (_) {})); + expect( + tester + .getSemantics(find.text('Expense')) + .hasFlag(SemanticsFlag.isSelected), + isTrue, + ); + expect( + tester + .getSemantics(find.text('Income')) + .hasFlag(SemanticsFlag.isSelected), + isFalse, + ); + expect( + tester.getSemantics(find.text('Expense')).hasFlag(SemanticsFlag.isButton), + isTrue, + ); + handle.dispose(); + }); +} diff --git a/mobile/test/widgets/sure_text_field_test.dart b/mobile/test/widgets/sure_text_field_test.dart new file mode 100644 index 000000000..2b69f167b --- /dev/null +++ b/mobile/test/widgets/sure_text_field_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/theme/sure_theme.dart'; +import 'package:sure_mobile/theme/sure_tokens.dart'; +import 'package:sure_mobile/widgets/sure_text_field.dart'; + +void main() { + Future pump( + WidgetTester tester, + Widget child, { + Brightness brightness = Brightness.light, + }) { + return tester.pumpWidget( + MaterialApp( + theme: + brightness == Brightness.light ? SureTheme.light : SureTheme.dark, + home: Scaffold(body: child), + ), + ); + } + + InputDecoration decorationOf(WidgetTester tester) => + tester.widget(find.byType(TextField)).decoration!; + + Color sideColorOf(InputBorder? b) => + (b as OutlineInputBorder).borderSide.color; + + // Brightness-aware by contract — the field builds its chrome from the palette, + // so assert it resolves the right tokens in both themes. + for (final (brightness, tokens) in [ + (Brightness.light, SureTokens.light), + (Brightness.dark, SureTokens.dark), + ]) { + testWidgets('builds the field chrome from tokens (${brightness.name})', ( + tester, + ) async { + await pump( + tester, + const SureTextField(hint: 'Search'), + brightness: brightness, + ); + + final deco = decorationOf(tester); + expect(deco.filled, isTrue); + expect(deco.fillColor, tokens.container); + expect(sideColorOf(deco.enabledBorder), tokens.borderSecondary); + expect(sideColorOf(deco.focusedBorder), tokens.borderPrimary); + expect(sideColorOf(deco.errorBorder), tokens.destructive); + expect(sideColorOf(deco.disabledBorder), tokens.borderSubdued); + expect( + (deco.enabledBorder as OutlineInputBorder).borderRadius, + BorderRadius.circular(SureTokens.radiusLg), + ); + expect(deco.hintStyle?.color, tokens.textSubdued); + expect(deco.errorStyle?.color, tokens.destructive); + }); + } + + testWidgets('renders an external label above the field when provided', ( + tester, + ) async { + await pump(tester, const SureTextField(label: 'Email', hint: 'you@x.com')); + expect(find.text('Email'), findsOneWidget); + // DS label, not a Material floating labelText baked into the decoration. + expect(decorationOf(tester).labelText, isNull); + }); + + testWidgets('omits the label column when label is null', (tester) async { + await pump(tester, const SureTextField(hint: 'Search')); + // No DS label wrapper — the field is returned directly. (TextField's own + // internal Columns are descendants of it, never ancestors.) + expect( + find.ancestor( + of: find.byType(TextField), + matching: find.byType(Column), + ), + findsNothing, + ); + }); + + testWidgets('the external label names the field for screen readers', + (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester, const SureTextField(label: 'Email')); + // The field is reachable by its label (Material labelText parity), and the + // visual label is not announced as a separate detached node. + expect(find.bySemanticsLabel('Email'), findsOneWidget); + handle.dispose(); + }); + + testWidgets('label uses secondary when enabled, subdued when disabled', + (tester) async { + await pump(tester, const SureTextField(label: 'Email')); + expect(tester.widget(find.text('Email')).style?.color, + SureTokens.light.textSecondary); + + await pump(tester, const SureTextField(label: 'Email', enabled: false)); + expect(tester.widget(find.text('Email')).style?.color, + SureTokens.light.textSubdued); + }); + + testWidgets('minLines alone does not trip the maxLines assert', + (tester) async { + // maxLines defaults to 1; a caller passing only minLines must not crash. + await pump(tester, const SureTextField(minLines: 3)); + final field = tester.widget(find.byType(TextField)); + expect(field.minLines, 3); + expect(field.maxLines, 3); + expect(tester.takeException(), isNull); + }); + + testWidgets('obscureText is forwarded and forces a single line', ( + tester, + ) async { + await pump(tester, const SureTextField(obscureText: true, maxLines: 4)); + final field = tester.widget(find.byType(TextField)); + expect(field.obscureText, isTrue); + expect(field.maxLines, 1); + }); + + testWidgets('validator surfaces the error in the destructive token', ( + tester, + ) async { + final key = GlobalKey(); + await pump( + tester, + Form( + key: key, + child: const SureTextField(label: 'Name', validator: _required), + ), + ); + key.currentState!.validate(); + await tester.pump(); + expect(find.text('Required'), findsOneWidget); + final error = tester.widget(find.text('Required')); + expect(error.style?.color, SureTokens.light.destructive); + }); +} + +String? _required(String? v) => (v == null || v.isEmpty) ? 'Required' : null; diff --git a/test/models/coinstats_item/importer_test.rb b/test/models/coinstats_item/importer_test.rb index a07a7526e..8f5e235f5 100644 --- a/test/models/coinstats_item/importer_test.rb +++ b/test/models/coinstats_item/importer_test.rb @@ -458,7 +458,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase ] @mock_provider.expects(:get_wallet_balances) - .with("ethereum:0xworking,ethereum:0xfailing") + .with("ethereum:0xfailing,ethereum:0xworking") .returns(success_response(bulk_response)) @mock_provider.expects(:extract_wallet_balance) @@ -475,7 +475,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase ] @mock_provider.expects(:get_wallet_transactions) - .with("ethereum:0xworking,ethereum:0xfailing") + .with("ethereum:0xfailing,ethereum:0xworking") .returns(success_response(bulk_transactions_response)) @mock_provider.expects(:extract_wallet_transactions) @@ -551,7 +551,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase ] @mock_provider.expects(:get_wallet_balances) - .with("ethereum:0xworking,dogecoin:Ddoge123") + .with("dogecoin:Ddoge123,ethereum:0xworking") .returns(success_response(bulk_response)) @mock_provider.expects(:extract_wallet_balance) @@ -568,7 +568,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase ] @mock_provider.expects(:get_wallet_transactions) - .with("ethereum:0xworking,dogecoin:Ddoge123") + .with("dogecoin:Ddoge123,ethereum:0xworking") .returns(success_response(bulk_transactions_response)) @mock_provider.expects(:extract_wallet_transactions) @@ -650,12 +650,12 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase AccountProvider.create!(account: account2, provider: coinstats_account2) @mock_provider.expects(:get_wallet_balances) - .with("ethereum:0xeth123,dogecoin:Ddoge456") + .with("dogecoin:Ddoge456,ethereum:0xeth123") .raises(Provider::Coinstats::Error.new("CoinStats timeout")) bulk_transactions_response = [] @mock_provider.expects(:get_wallet_transactions) - .with("ethereum:0xeth123,dogecoin:Ddoge456") + .with("dogecoin:Ddoge456,ethereum:0xeth123") .returns(success_response(bulk_transactions_response)) assert_difference "DebugLogEntry.count", 3 do @@ -729,7 +729,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase ] @mock_provider.expects(:get_wallet_balances) - .with("ethereum:0xeth123,bitcoin:bc1qbtc456") + .with("bitcoin:bc1qbtc456,ethereum:0xeth123") .returns(success_response(bulk_response)) @mock_provider.expects(:extract_wallet_balance) @@ -756,7 +756,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase ] @mock_provider.expects(:get_wallet_transactions) - .with("ethereum:0xeth123,bitcoin:bc1qbtc456") + .with("bitcoin:bc1qbtc456,ethereum:0xeth123") .returns(success_response(bulk_transactions_response)) @mock_provider.expects(:extract_wallet_transactions) diff --git a/test/models/provider/tinkoff_invest_test.rb b/test/models/provider/tinkoff_invest_test.rb index 7430669e1..64ad247a8 100644 --- a/test/models/provider/tinkoff_invest_test.rb +++ b/test/models/provider/tinkoff_invest_test.rb @@ -88,7 +88,7 @@ class Provider::TinkoffInvestTest < ActiveSupport::TestCase test "fetch_security_prices converts bond percent-of-par to money via nominal" do travel_to Date.new(2026, 6, 18) do stub_find("RU000A10AAQ4", [ instrument_short(ticker: "RU000A10AAQ4", isin: "RU000A10AAQ4", type: "bond", class_code: "TQCB", uid: "uid-bond", currency: "rub") ]) - stub_instrument_by("uid-bond", instrument_full(name: "Bond", nominal: { "units" => "1000", "nano" => 0 })) + stub_bond_by("uid-bond", { "nominal" => { "units" => "1000", "nano" => 0 } }) @provider.stubs(:post).with("MarketDataService", "GetCandles", anything).returns( "candles" => [ candle("2026-06-17", units: 103, nano: 700_000_000) ] # 103.7% of par ) @@ -104,7 +104,7 @@ class Provider::TinkoffInvestTest < ActiveSupport::TestCase test "fetch_security_prices fails (no zero price) when a bond nominal is missing" do travel_to Date.new(2026, 6, 18) do stub_find("RU000A10AAQ4", [ instrument_short(ticker: "RU000A10AAQ4", type: "bond", class_code: "TQCB", uid: "uid-bond", currency: "rub") ]) - stub_instrument_by("uid-bond", instrument_full(name: "Bond")) # no nominal + stub_bond_by("uid-bond", {}) # no nominal @provider.stubs(:post).with("MarketDataService", "GetCandles", anything).returns("candles" => [ candle("2026-06-17", units: 103, nano: 0) ]) @provider.stubs(:post).with("MarketDataService", "GetLastPrices", anything).returns("lastPrices" => []) @@ -115,6 +115,32 @@ class Provider::TinkoffInvestTest < ActiveSupport::TestCase end end + test "fetch_security_prices returns only the live price for an amortizing bond" do + travel_to Date.new(2026, 6, 18) do + stub_find("RU000A10AAQ4", [ instrument_short(ticker: "RU000A10AAQ4", type: "bond", class_code: "TQCB", uid: "uid-bond", currency: "rub") ]) + stub_bond_by("uid-bond", { "nominal" => { "units" => "417", "nano" => 710_000_000 }, "amortizationFlag" => true }) + @provider.stubs(:post).with("MarketDataService", "GetCandles", anything).returns("candles" => [ candle("2026-06-10", units: 105, nano: 0) ]) + @provider.stubs(:post).with("MarketDataService", "GetLastPrices", anything).returns("lastPrices" => [ { "price" => { "units" => "103", "nano" => 0 } } ]) + + response = @provider.fetch_security_prices(symbol: "RU000A10AAQ4", exchange_operating_mic: "MISX", start_date: Date.new(2026, 6, 10), end_date: Date.new(2026, 6, 18)) + + # The historical candle is skipped (today's nominal must not reprice old par); + # only the live price remains, converted with the current nominal. + assert_equal [ Date.new(2026, 6, 18) ], response.data.map(&:date) + assert_equal (BigDecimal("103") / 100 * BigDecimal("417.71")), response.data.first.price + end + end + + test "resolve strips an exchange suffix before querying T-Invest" do + stub_find("T", [ instrument_short(ticker: "T", type: "share", class_code: "TQBR", uid: "uid-t") ]) + stub_instrument_by("uid-t", instrument_full(name: "T-Tech", logo_name: "tcs2.png")) + + response = @provider.fetch_security_info(symbol: "T.MOEX", exchange_operating_mic: "MISX") + + assert response.success? + assert_equal "https://invest-brands.cdn-tinkoff.ru/tcs2x160.png", response.data.logo_url + end + test "fetch_security_prices skips incomplete candles" do travel_to Date.new(2026, 6, 18) do stub_find("SBER", [ instrument_short(ticker: "SBER", type: "share", class_code: "TQBR", uid: "uid-sber", currency: "rub") ]) @@ -143,10 +169,16 @@ class Provider::TinkoffInvestTest < ActiveSupport::TestCase .returns("instrument" => instrument) end - def instrument_short(ticker:, type:, class_code:, name: nil, uid: "uid", isin: "", currency: "rub", country: "RU", exchange: "moex") + def stub_bond_by(uid, instrument) + @provider.stubs(:post) + .with("InstrumentsService", "BondBy", has_entry(id: uid)) + .returns("instrument" => instrument) + end + + def instrument_short(ticker:, type:, class_code:, name: nil, uid: "uid", isin: "", currency: "rub", country: "RU", exchange: "moex", tradeable: true) { "ticker" => ticker, "name" => name || ticker, "instrumentType" => type, - "classCode" => class_code, "uid" => uid, "isin" => isin, + "classCode" => class_code, "uid" => uid, "isin" => isin, "apiTradeAvailableFlag" => tradeable, "currency" => currency, "countryOfRisk" => country, "exchange" => exchange } end diff --git a/test/models/provider/up_test.rb b/test/models/provider/up_test.rb new file mode 100644 index 000000000..332ffc051 --- /dev/null +++ b/test/models/provider/up_test.rb @@ -0,0 +1,160 @@ +require "test_helper" + +class Provider::UpTest < ActiveSupport::TestCase + FakeResponse = Struct.new(:code, :body, :message, keyword_init: true) + + test "fetches paginated account transactions following JSON:API links.next with bearer auth" do + next_url = "https://api.up.com.au/api/v1/accounts/acc_123/transactions?page%5Bafter%5D=cursor2" + responses = [ + FakeResponse.new( + code: 200, + message: "OK", + body: { + data: [ { type: "transactions", id: "tx_1", attributes: { status: "SETTLED" }, relationships: { account: { data: { id: "acc_123" } } } } ], + links: { prev: nil, next: next_url } + }.to_json + ), + FakeResponse.new( + code: 200, + message: "OK", + body: { + data: [ { type: "transactions", id: "tx_2", attributes: { status: "HELD" }, relationships: { account: { data: { id: "acc_123" } } } } ], + links: { prev: nil, next: nil } + }.to_json + ) + ] + requests = [] + + Provider::Up.stub(:get, ->(url, headers:, query: nil) { + requests << { url: url, headers: headers, query: query } + responses.shift + }) do + client = Provider::Up.new("up-access-token") + + transactions = client.get_account_transactions( + account_id: "acc_123", + since: Date.new(2026, 1, 1) + ) + + assert_equal [ "tx_1", "tx_2" ], transactions.map { |tx| tx[:id] } + assert_equal [ "acc_123", "acc_123" ], transactions.map { |tx| tx[:account_id] } + assert_equal "SETTLED", transactions.first[:status] + end + + assert_equal 2, requests.size + assert_match "/accounts/acc_123/transactions", requests.first[:url] + assert_equal "Bearer up-access-token", requests.first[:headers]["Authorization"] + assert_equal "2026-01-01T00:00:00Z", requests.first[:query]["filter[since]"] + assert_equal 100, requests.first[:query]["page[size]"] + # Pagination follows the absolute next URL with no extra query params. + assert_equal next_url, requests.second[:url] + assert_nil requests.second[:query] + end + + test "stops paginating when the API repeats the same next cursor" do + repeating_url = "https://api.up.com.au/api/v1/accounts/acc_123/transactions?page%5Bafter%5D=loop" + page = FakeResponse.new( + code: 200, + message: "OK", + body: { + data: [ { type: "transactions", id: "tx_1", attributes: { status: "SETTLED" }, relationships: { account: { data: { id: "acc_123" } } } } ], + links: { prev: nil, next: repeating_url } + }.to_json + ) + request_count = 0 + + Provider::Up.stub(:get, ->(url, headers:, query: nil) { + request_count += 1 + raise "infinite pagination loop" if request_count > 5 + + page + }) do + client = Provider::Up.new("up-access-token") + transactions = client.get_account_transactions(account_id: "acc_123") + + # First request + one follow of the repeated cursor, then the guard stops. + assert_equal 2, request_count + assert_equal [ "tx_1", "tx_1" ], transactions.map { |tx| tx[:id] } + end + end + + test "refuses to follow a pagination link pointing at a non-Up host" do + evil_url = "https://evil.example.com/api/v1/accounts/acc_123/transactions?page%5Bafter%5D=x" + first_page = FakeResponse.new( + code: 200, + message: "OK", + body: { + data: [ { type: "transactions", id: "tx_1", attributes: { status: "SETTLED" }, relationships: { account: { data: { id: "acc_123" } } } } ], + links: { prev: nil, next: evil_url } + }.to_json + ) + requested_urls = [] + + Provider::Up.stub(:get, ->(url, headers:, query: nil) { + requested_urls << url + first_page + }) do + client = Provider::Up.new("up-access-token") + + error = assert_raises(Provider::Up::UpError) do + client.get_account_transactions(account_id: "acc_123") + end + assert_equal :invalid_url, error.error_type + end + + # The bearer token must never be sent to the foreign host. + assert_not_includes requested_urls, evil_url + end + + test "flattens JSON:API account resources" do + response = FakeResponse.new( + code: 200, + message: "OK", + body: { + data: [ { + type: "accounts", + id: "acc_123", + attributes: { + displayName: "Spending", + accountType: "TRANSACTIONAL", + ownershipType: "INDIVIDUAL", + balance: { currencyCode: "AUD", value: "123.45", valueInBaseUnits: 12345 } + } + } ], + links: { prev: nil, next: nil } + }.to_json + ) + + Provider::Up.stub(:get, ->(_url, headers:, query: nil) { response }) do + accounts = Provider::Up.new("up-access-token").get_accounts + + assert_equal 1, accounts.size + account = accounts.first + assert_equal "acc_123", account[:id] + assert_equal "Spending", account[:displayName] + assert_equal "TRANSACTIONAL", account[:accountType] + assert_equal "AUD", account.dig(:balance, :currencyCode) + assert_equal "123.45", account.dig(:balance, :value) + end + end + + test "raises typed errors for unauthorized responses" do + response = FakeResponse.new(code: 401, message: "Unauthorized", body: "{}") + + Provider::Up.stub(:get, ->(_url, headers:, query: nil) { response }) do + error = assert_raises Provider::Up::UpError do + Provider::Up.new("invalid-token").get_accounts + end + + assert_equal :unauthorized, error.error_type + end + end + + test "raises configuration error when token blank" do + error = assert_raises Provider::Up::UpError do + Provider::Up.new("") + end + + assert_equal :configuration_error, error.error_type + end +end diff --git a/test/models/up_account_test.rb b/test/models/up_account_test.rb new file mode 100644 index 000000000..d751b1563 --- /dev/null +++ b/test/models/up_account_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +class UpAccountTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @up_item = UpItem.create!(family: @family, name: "Test Up", access_token: "up-access-token") + end + + test "needs_setup excludes linked and ignored accounts" do + unlinked = UpAccount.create!(up_item: @up_item, name: "Unlinked", account_id: "acc_unlinked", currency: "AUD") + ignored = UpAccount.create!(up_item: @up_item, name: "Skipped", account_id: "acc_ignored", currency: "AUD", ignored: true) + + linked = UpAccount.create!(up_item: @up_item, name: "Linked", account_id: "acc_linked", currency: "AUD") + account = Account.create!(family: @family, name: "Linked", accountable: Depository.new(subtype: "checking"), balance: 0, currency: "AUD") + AccountProvider.create!(account: account, provider: linked) + + needs_setup = @up_item.up_accounts.needs_setup + + assert_includes needs_setup, unlinked + assert_not_includes needs_setup, ignored + assert_not_includes needs_setup, linked + assert_equal 1, @up_item.unlinked_accounts_count + end +end diff --git a/test/models/up_entry/processor_test.rb b/test/models/up_entry/processor_test.rb new file mode 100644 index 000000000..45ba6228c --- /dev/null +++ b/test/models/up_entry/processor_test.rb @@ -0,0 +1,116 @@ +require "test_helper" + +class UpEntry::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @up_item = UpItem.create!( + family: @family, + name: "Test Up", + access_token: "up-access-token" + ) + @up_account = UpAccount.create!( + up_item: @up_item, + name: "Up Spending", + account_id: "acc_123", + currency: "AUD" + ) + @account = Account.create!( + family: @family, + name: "Spending", + accountable: Depository.new(subtype: "checking"), + balance: 1000, + currency: "AUD" + ) + + AccountProvider.create!(account: @account, provider: @up_account) + end + + test "imports settled Up transaction with sign conversion and provider metadata" do + transaction_data = { + id: "tx_123", + account_id: "acc_123", + status: "SETTLED", + description: "Coffee Shop", + message: "Morning coffee", + rawText: "COFFEE SHOP SYDNEY", + amount: { currencyCode: "AUD", value: "-12.50", valueInBaseUnits: -1250 }, + foreignAmount: nil, + settledAt: "2026-01-15T08:30:00+11:00", + createdAt: "2026-01-15T08:00:00+11:00", + category_id: "restaurants-and-cafes" + } + + entry = UpEntry::Processor.new(transaction_data, up_account: @up_account).process + + assert_equal "up_tx_123", entry.external_id + assert_equal "up", entry.source + assert_equal BigDecimal("12.5"), entry.amount + assert_equal "AUD", entry.currency + assert_equal Date.new(2026, 1, 15), entry.date + assert_equal "Coffee Shop", entry.name + assert_equal "Morning coffee", entry.notes + + transaction = entry.entryable + assert_equal false, transaction.pending? + assert_equal false, transaction.extra.dig("up", "pending") + assert_equal "SETTLED", transaction.extra.dig("up", "status") + assert_equal "restaurants-and-cafes", transaction.extra.dig("up", "category_id") + assert_equal "Coffee Shop", transaction.merchant.name + end + + test "marks HELD transactions as pending and falls back to createdAt for the date" do + entry = UpEntry::Processor.new( + { + id: "tx_held_1", + account_id: "acc_123", + status: "HELD", + description: "Pending card auth", + amount: { currencyCode: "AUD", value: "-8.00", valueInBaseUnits: -800 }, + settledAt: nil, + createdAt: "2026-01-20T12:00:00+11:00" + }, + up_account: @up_account + ).process + + assert entry.entryable.pending? + assert_equal true, entry.entryable.extra.dig("up", "pending") + assert_equal Date.new(2026, 1, 20), entry.date + end + + test "stores foreign amount FX metadata" do + entry = UpEntry::Processor.new( + { + id: "tx_fx_1", + account_id: "acc_123", + status: "SETTLED", + description: "US Merchant", + amount: { currencyCode: "AUD", value: "-15.00", valueInBaseUnits: -1500 }, + foreignAmount: { currencyCode: "USD", value: "-10.00", valueInBaseUnits: -1000 }, + settledAt: "2026-01-18T00:00:00+11:00", + createdAt: "2026-01-18T00:00:00+11:00" + }, + up_account: @up_account + ).process + + transaction = entry.entryable + assert_equal "USD", transaction.extra.dig("up", "fx_from") + assert_equal "-10.00", transaction.extra.dig("up", "fx_amount") + end + + test "income (positive Up amount) is stored as negative in Sure" do + entry = UpEntry::Processor.new( + { + id: "tx_income_1", + account_id: "acc_123", + status: "SETTLED", + description: "Salary", + amount: { currencyCode: "AUD", value: "2500.00", valueInBaseUnits: 250000 }, + settledAt: "2026-01-15T00:00:00+11:00", + createdAt: "2026-01-15T00:00:00+11:00" + }, + up_account: @up_account + ).process + + assert_equal BigDecimal("-2500.0"), entry.amount + end +end diff --git a/test/models/up_item/importer_test.rb b/test/models/up_item/importer_test.rb new file mode 100644 index 000000000..73ff16925 --- /dev/null +++ b/test/models/up_item/importer_test.rb @@ -0,0 +1,156 @@ +require "test_helper" + +class UpItem::ImporterTest < ActiveSupport::TestCase + class FakeUpProvider + attr_reader :transaction_calls + + def initialize(accounts: nil, transactions: nil) + @transaction_calls = [] + @accounts = accounts + @transactions = transactions + end + + def get_accounts + @accounts || [ + { + id: "acc_123", + displayName: "Up Spending", + accountType: "TRANSACTIONAL", + ownershipType: "INDIVIDUAL", + balance: { currencyCode: "AUD", value: "123.45", valueInBaseUnits: 12345 } + } + ] + end + + def get_account_transactions(account_id:, since: nil) + @transaction_calls << { account_id: account_id, since: since } + @transactions || [ settled_transaction ] + end + + private + + def settled_transaction + { + id: "tx_1", + account_id: "acc_123", + status: "SETTLED", + description: "Posted transaction", + amount: { currencyCode: "AUD", value: "-20.00", valueInBaseUnits: -2000 }, + settledAt: "2026-01-19T00:00:00+11:00", + createdAt: "2026-01-19T00:00:00+11:00" + } + end + end + + setup do + @family = families(:empty) + @up_item = UpItem.create!( + family: @family, + name: "Test Up", + access_token: "up-access-token" + ) + @up_account = UpAccount.create!( + up_item: @up_item, + name: "Old name", + account_id: "acc_123", + currency: "AUD" + ) + @account = Account.create!( + family: @family, + name: "Spending", + accountable: Depository.new(subtype: "checking"), + balance: 0, + currency: "AUD" + ) + AccountProvider.create!(account: @account, provider: @up_account) + end + + test "imports account snapshot and stores transactions" do + provider = FakeUpProvider.new + + result = UpItem::Importer.new(@up_item, up_provider: provider).import + + assert result[:success] + assert_equal 1, result[:accounts_updated] + assert_equal 1, result[:transactions_imported] + + @up_account.reload + assert_equal "Up Spending", @up_account.name + assert_equal BigDecimal("123.45"), @up_account.current_balance + assert_equal "TRANSACTIONAL", @up_account.account_type + assert_equal "acc_123", provider.transaction_calls.first[:account_id] + + assert_equal [ "tx_1" ], @up_account.raw_transactions_payload.map { |tx| tx["id"] } + end + + test "held transaction settling under the same id clears pending without duplicates" do + import_with(transactions: [ held_transaction(id: "tx_h1", amount: "-8.00") ]) + process_transactions + + assert_equal 1, pending_entries.count + + import_with(transactions: [ settled(id: "tx_h1", amount: "-8.00") ]) + process_transactions + + assert_empty pending_entries + assert_equal 1, @account.entries.where(source: "up").count + entry = @account.entries.find_by(external_id: "up_tx_h1", source: "up") + assert_equal false, entry.entryable.pending? + end + + test "removes held transactions that disappear from the latest fetch" do + import_with(transactions: [ held_transaction(id: "tx_h2", amount: "-8.00") ]) + process_transactions + + assert_equal 1, pending_entries.count + + import_with(transactions: []) + process_transactions + + @up_account.reload + assert_empty pending_entries + assert_empty @up_account.raw_transactions_payload.select { |tx| UpEntry::Processor.pending?(tx) } + end + + private + + def import_with(transactions:) + provider = FakeUpProvider.new(transactions: transactions) + UpItem::Importer.new(@up_item, up_provider: provider).import + end + + def process_transactions + UpAccount::Transactions::Processor.new(@up_account.reload).process + end + + def pending_entries + @account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(source: "up") + .where("(transactions.extra -> 'up' ->> 'pending')::boolean = true") + end + + def held_transaction(id:, amount:, date: "2026-01-15T00:00:00+11:00") + { + id: id, + account_id: "acc_123", + status: "HELD", + description: "Pending card auth", + amount: { currencyCode: "AUD", value: amount, valueInBaseUnits: (amount.to_f * 100).to_i }, + settledAt: nil, + createdAt: date + } + end + + def settled(id:, amount:, date: "2026-01-16T00:00:00+11:00") + { + id: id, + account_id: "acc_123", + status: "SETTLED", + description: "Posted card auth", + amount: { currencyCode: "AUD", value: amount, valueInBaseUnits: (amount.to_f * 100).to_i }, + settledAt: date, + createdAt: date + } + end +end