Merge branch 'we-promise:main' into Transfer-charges

This commit is contained in:
Shibu M
2026-06-21 01:29:31 +05:30
committed by GitHub
65 changed files with 5016 additions and 278 deletions

View File

@@ -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"]`).

View File

@@ -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 = {}

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -7,6 +7,7 @@ class DataEnrichment < ApplicationRecord
simplefin: "simplefin",
lunchflow: "lunchflow",
akahu: "akahu",
up: "up",
synth: "synth",
ai: "ai",
enable_banking: "enable_banking",

View File

@@ -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" ],

View File

@@ -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)

View File

@@ -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

View File

@@ -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" },

View File

@@ -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
# ================================

224
app/models/provider/up.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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 },

View File

@@ -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

View File

@@ -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.

93
app/models/up_account.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

201
app/models/up_item.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 %>
<div class="space-y-2">
@@ -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 %>

View File

@@ -0,0 +1,123 @@
<div id="up-providers-panel" class="space-y-4">
<% 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? %>
<div class="space-y-3">
<% active_items.each do |item| %>
<%= render DS::Disclosure.new(variant: :card) do |disclosure| %>
<% disclosure.with_summary_content do %>
<div class="flex items-center justify-between gap-2 w-full">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center h-8 w-8 bg-surface rounded-full">
<p class="text-primary text-xs font-medium"><%= item.name.to_s.first.to_s.upcase %></p>
</div>
<div>
<p class="font-medium text-primary"><%= item.name %></p>
<p class="text-xs text-secondary"><%= item.sync_status_summary %></p>
</div>
</div>
</div>
<% end %>
<div class="mt-4 space-y-4">
<div class="flex items-center gap-2">
<%= 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) }
) %>
</div>
<%= 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 %>
<div class="flex flex-wrap justify-end gap-2">
<%= 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"
) %>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
<% end %>
<% up_item = Current.family.up_items.build(name: t("up_items.provider_panel.default_connection_name")) %>
<% if active_items.any? %>
<h3 class="flex items-center gap-2 text-sm font-medium text-primary mt-4">
<%= icon "plus", size: "sm" %>
<%= t("up_items.provider_panel.add_connection") %>
</h3>
<% 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 %>
<div class="flex justify-end">
<%= render DS::Button.new(
text: t("up_items.provider_panel.add_connection"),
type: "submit"
) %>
</div>
<% end %>
</div>

View File

@@ -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 %>
<div class="flex items-center justify-between gap-2 w-full">
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %>
<div class="flex items-center justify-center h-8 w-8 bg-surface rounded-full">
<p class="text-primary text-xs font-medium"><%= up_item.name.to_s.first.to_s.upcase %></p>
</div>
<div class="pl-1 text-sm">
<div class="flex items-center gap-2">
<%= tag.p up_item.name, class: "font-medium text-primary" %>
<% if up_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
</div>
<% if up_item.accounts.any? %>
<p class="text-xs text-secondary"><%= up_item.institution_summary %></p>
<% end %>
<% if up_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-spin" %>
<%= tag.span t(".syncing") %>
</div>
<% elsif up_item.sync_error.present? %>
<div class="text-secondary flex items-center gap-1">
<%= 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" %>
</div>
<% else %>
<p class="text-secondary">
<% 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 %>
</p>
<% end %>
</div>
</div>
<% if Current.user&.admin? %>
<div class="flex items-center gap-2">
<%= 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 %>
</div>
<% end %>
</div>
<% end %>
<% unless up_item.scheduled_for_deletion? %>
<div class="space-y-4 mt-4">
<% 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 %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_description", linked: linked_count, total: total_count) %></p>
<%= render DS::Link.new(
text: t(".setup_action"),
icon: "settings",
variant: "primary",
href: setup_accounts_up_item_path(up_item),
frame: :modal
) %>
</div>
<% elsif up_item.accounts.empty? && total_count == 0 %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
<p class="text-secondary text-sm"><%= t(".no_accounts_description") %></p>
<%= render DS::Link.new(
text: t(".setup_action"),
icon: "settings",
variant: "primary",
href: setup_accounts_up_item_path(up_item),
frame: :modal
) %>
</div>
<% end %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -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 %>
<div class="space-y-4">
<% if @api_error.present? %>
<%= render DS::Alert.new(message: @api_error, variant: :error) %>
<% elsif @up_accounts.empty? %>
<p class="text-sm text-secondary"><%= t(".no_accounts_found") %></p>
<% else %>
<p class="text-sm text-secondary"><%= t(".description") %></p>
<%= 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 %>
<div class="space-y-2">
<% @up_accounts.each do |up_account| %>
<label class="flex items-start gap-3 p-3 border border-primary rounded-lg hover:bg-surface cursor-pointer transition-colors">
<%= check_box_tag "account_ids[]", up_account.id, false, class: "mt-1" %>
<div class="flex-1">
<div class="font-medium text-sm text-primary"><%= up_account.name %></div>
<div class="text-xs text-secondary mt-1">
<%= [up_account.currency, up_account.account_type].compact.join(" · ") %>
</div>
</div>
</label>
<% end %>
</div>
<div class="flex gap-2 justify-end pt-4">
<%= 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") %>
</div>
<% end %>
<% end %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -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 %>
<div class="space-y-4">
<% if @api_error.present? %>
<%= render DS::Alert.new(message: @api_error, variant: :error) %>
<% elsif @up_accounts.empty? %>
<p class="text-sm text-secondary"><%= t(".no_accounts_found") %></p>
<% else %>
<p class="text-sm text-secondary"><%= t(".description") %></p>
<%= 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 %>
<div class="space-y-2">
<% @up_accounts.each do |up_account| %>
<label class="flex items-start gap-3 p-3 border border-primary rounded-lg hover:bg-surface cursor-pointer transition-colors">
<%= radio_button_tag "up_account_id", up_account.id, false, class: "mt-1", required: true %>
<div class="flex-1">
<div class="font-medium text-sm text-primary"><%= up_account.name %></div>
<div class="text-xs text-secondary mt-1">
<%= [up_account.currency, up_account.account_type].compact.join(" · ") %>
</div>
</div>
</label>
<% end %>
</div>
<div class="flex gap-2 justify-end pt-4">
<%= 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") %>
</div>
<% end %>
<% end %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,72 @@
<% content_for :title, t(".title") %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title")) do %>
<div class="flex items-center gap-2">
<%= icon "landmark", class: "text-primary" %>
<span class="text-primary"><%= t(".subtitle") %></span>
</div>
<% 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 %>
<div class="space-y-4">
<% if @api_error.present? %>
<div class="p-8 flex flex-col gap-3 items-center justify-center text-center">
<%= icon "alert-circle", size: "lg", class: "text-destructive" %>
<p class="text-primary font-medium"><%= t(".fetch_failed") %></p>
<p class="text-destructive text-sm"><%= @api_error %></p>
</div>
<% elsif @up_accounts.empty? %>
<div class="p-8 flex flex-col gap-3 items-center justify-center text-center">
<%= icon "check-circle", size: "lg", class: "text-success" %>
<p class="text-primary font-medium"><%= t(".no_accounts_to_setup") %></p>
<p class="text-secondary text-sm"><%= t(".all_accounts_linked") %></p>
</div>
<% else %>
<div class="bg-surface border border-primary p-4 rounded-lg">
<p class="text-sm text-primary"><strong><%= t(".choose_account_type") %></strong></p>
<p class="text-xs text-secondary mt-1"><%= t(".choose_account_type_description") %></p>
</div>
<% @up_accounts.each do |up_account| %>
<div class="border border-primary rounded-lg p-4">
<div class="mb-3">
<h3 class="font-medium text-primary"><%= up_account.name %></h3>
<p class="text-xs text-secondary">
<%= [up_account.currency, up_account.account_type].compact.join(" · ") %>
</p>
</div>
<%= 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" %>
</div>
<% end %>
<% end %>
</div>
<div class="flex gap-3">
<%= 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
) %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -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

View File

@@ -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."

View File

@@ -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.

View File

@@ -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

View File

@@ -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

47
db/schema.rb generated
View File

@@ -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"

View File

@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

View File

@@ -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),
);
}
}

View File

@@ -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<TransactionFormScreen> createState() => _TransactionFormScreenState();
@@ -47,7 +45,10 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
Future<void> _fetchCategories() async {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final categoriesProvider = Provider.of<CategoriesProvider>(context, listen: false);
final categoriesProvider = Provider.of<CategoriesProvider>(
context,
listen: false,
);
final accessToken = await authProvider.getValidAccessToken();
if (accessToken != null) {
categoriesProvider.fetchCategories(accessToken: accessToken);
@@ -114,11 +115,17 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
try {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
final transactionsProvider = Provider.of<TransactionsProvider>(
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<TransactionFormScreen> {
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<TransactionFormScreen> {
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<ConnectivityService>(context, listen: false);
final connectivityService = Provider.of<ConnectivityService>(
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<TransactionFormScreen> {
}
}
} 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<TransactionFormScreen> {
),
// 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<TransactionFormScreen> {
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<TransactionFormScreen> {
// 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<String>(
SureSegmentedControl<String>(
selected: _nature,
onChanged: (value) {
setState(() {
_nature = value;
});
},
segments: const [
ButtonSegment<String>(
SureSegment<String>(
value: 'expense',
label: Text('Expense'),
label: 'Expense',
icon: Icon(Icons.arrow_downward),
),
ButtonSegment<String>(
SureSegment<String>(
value: 'income',
label: Text('Income'),
label: 'Income',
icon: Icon(Icons.arrow_upward),
),
],
selected: {_nature},
onSelectionChanged: (Set<String> 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<TransactionFormScreen> {
_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<TransactionFormScreen> {
if (value == null) {
_selectedCategory = null;
} else {
_selectedCategory = categories
.firstWhere((c) => c.id == value);
_selectedCategory = categories.firstWhere(
(c) => c.id == value,
);
}
});
},

View File

@@ -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,

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'sure_chip.dart';
class CurrencyFilter extends StatelessWidget {
final Set<String> availableCurrencies;
final Set<String> 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<String>.from(selectedCurrencies);
final newSelection = Set<String>.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),
),
);
}),

View File

@@ -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<CustomProxyHeader> initialHeaders;
@@ -13,7 +14,8 @@ class CustomProxyHeadersEditor extends StatefulWidget {
});
@override
State<CustomProxyHeadersEditor> createState() => _CustomProxyHeadersEditorState();
State<CustomProxyHeadersEditor> createState() =>
_CustomProxyHeadersEditorState();
}
class _CustomProxyHeadersEditorState extends State<CustomProxyHeadersEditor> {
@@ -30,7 +32,12 @@ class _CustomProxyHeadersEditorState extends State<CustomProxyHeadersEditor> {
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();

View File

@@ -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,
);
}
}

View File

@@ -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<bool>? 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,
),
),
),
);
}
}

View File

@@ -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';
}

View File

@@ -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<Widget> 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 = <Widget>[];
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;
}
}

View File

@@ -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<T> {
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<String>(
/// 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<T> extends StatelessWidget {
const SureSegmentedControl({
super.key,
required this.segments,
required this.selected,
required this.onChanged,
});
final List<SureSegment<T>> segments;
final T selected;
final ValueChanged<T> 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<T>(
segment: segment,
selected: segment.value == selected,
selectedBg: selectedBg,
palette: palette,
textStyle: theme.textTheme.labelLarge,
onTap: () => onChanged(segment.value),
),
),
],
),
);
}
}
class _Segment<T> extends StatefulWidget {
const _Segment({
required this.segment,
required this.selected,
required this.selectedBg,
required this.palette,
required this.textStyle,
required this.onTap,
});
final SureSegment<T> segment;
final bool selected;
final Color selectedBg;
final SureTokenPalette palette;
final TextStyle? textStyle;
final VoidCallback onTap;
@override
State<_Segment<T>> createState() => _SegmentState<T>();
}
class _SegmentState<T> extends State<_Segment<T>> {
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: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
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,
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -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<TextInputFormatter>? inputFormatters;
final int maxLines;
final int? minLines;
final int? maxLength;
final FormFieldValidator<String>? validator;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? 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),
],
);
}
}

View File

@@ -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<void> pump(
WidgetTester tester, {
required Set<String> selected,
required ValueChanged<Set<String>> 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<String>? 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<String>? latest;
await pump(tester, selected: const {'EUR'}, onChanged: (s) => latest = s);
await tester.tap(find.text('All'));
expect(latest, isEmpty);
});
}

View File

@@ -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<void> 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<CustomProxyHeader>? 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');
});
}

View File

@@ -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<void> 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<Container>(
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<InkWell>(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);
});
}

View File

@@ -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<void> 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<Material>(
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<Text>(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<Text>(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<InkWell>(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<InkWell>(find.byType(InkWell)).onTap, isNull);
expect(tester.widget<Opacity>(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));
});
}

View File

@@ -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<void> 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<Container>(
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<Divider>(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<Text>(find.text('Calendar'));
final subtitle = tester.widget<Text>(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<Text>(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<SureIcon>(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);
});
}

View File

@@ -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<void> 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<String> onChanged) =>
SureSegmentedControl<String>(
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<Container>(find
.descendant(
of: find.byType(SureSegmentedControl<String>),
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<AnimatedContainer>(
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<AnimatedContainer>(
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<Text>(find.text('Expense')).style?.color,
SureTokens.light.textPrimary,
);
expect(
tester.widget<Text>(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<AnimatedContainer>(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();
});
}

View File

@@ -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<void> 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<TextField>(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<Text>(find.text('Email')).style?.color,
SureTokens.light.textSecondary);
await pump(tester, const SureTextField(label: 'Email', enabled: false));
expect(tester.widget<Text>(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<TextField>(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<TextField>(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<FormState>();
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<Text>(find.text('Required'));
expect(error.style?.color, SureTokens.light.destructive);
});
}
String? _required(String? v) => (v == null || v.isEmpty) ? 'Required' : null;

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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