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