mirror of
https://github.com/we-promise/sure.git
synced 2026-07-13 05:15:19 +00:00
Merge branch 'we-promise:main' into Transfer-charges
This commit is contained in:
@@ -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"]`).
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
442
app/controllers/up_items_controller.rb
Normal file
442
app/controllers/up_items_controller.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ class DataEnrichment < ApplicationRecord
|
||||
simplefin: "simplefin",
|
||||
lunchflow: "lunchflow",
|
||||
akahu: "akahu",
|
||||
up: "up",
|
||||
synth: "synth",
|
||||
ai: "ai",
|
||||
enable_banking: "enable_banking",
|
||||
|
||||
@@ -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" ],
|
||||
|
||||
@@ -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)
|
||||
|
||||
28
app/models/family/up_connectable.rb
Normal file
28
app/models/family/up_connectable.rb
Normal 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
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
224
app/models/provider/up.rb
Normal 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
|
||||
111
app/models/provider/up_adapter.rb
Normal file
111
app/models/provider/up_adapter.rb
Normal 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
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
93
app/models/up_account.rb
Normal 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
|
||||
91
app/models/up_account/processor.rb
Normal file
91
app/models/up_account/processor.rb
Normal 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
|
||||
91
app/models/up_account/transactions/processor.rb
Normal file
91
app/models/up_account/transactions/processor.rb
Normal 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
|
||||
199
app/models/up_entry/processor.rb
Normal file
199
app/models/up_entry/processor.rb
Normal 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
201
app/models/up_item.rb
Normal 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
|
||||
250
app/models/up_item/importer.rb
Normal file
250
app/models/up_item/importer.rb
Normal 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
|
||||
15
app/models/up_item/provided.rb
Normal file
15
app/models/up_item/provided.rb
Normal 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
|
||||
22
app/models/up_item/sync_complete_event.rb
Normal file
22
app/models/up_item/sync_complete_event.rb
Normal 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
|
||||
141
app/models/up_item/syncer.rb
Normal file
141
app/models/up_item/syncer.rb
Normal 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
|
||||
54
app/models/up_item/unlinking.rb
Normal file
54
app/models/up_item/unlinking.rb
Normal 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
|
||||
@@ -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 %>
|
||||
|
||||
123
app/views/settings/providers/_up_panel.html.erb
Normal file
123
app/views/settings/providers/_up_panel.html.erb
Normal 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>
|
||||
114
app/views/up_items/_up_item.html.erb
Normal file
114
app/views/up_items/_up_item.html.erb
Normal 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 %>
|
||||
50
app/views/up_items/select_accounts.html.erb
Normal file
50
app/views/up_items/select_accounts.html.erb
Normal 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 %>
|
||||
50
app/views/up_items/select_existing_account.html.erb
Normal file
50
app/views/up_items/select_existing_account.html.erb
Normal 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 %>
|
||||
72
app/views/up_items/setup_accounts.html.erb
Normal file
72
app/views/up_items/setup_accounts.html.erb
Normal 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 %>
|
||||
9
config/initializers/up.rb
Normal file
9
config/initializers/up.rb
Normal 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
|
||||
@@ -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."
|
||||
|
||||
116
config/locales/views/up_items/en.yml
Normal file
116
config/locales/views/up_items/en.yml
Normal 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.
|
||||
@@ -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
|
||||
|
||||
49
db/migrate/20260617120000_create_up_items_and_accounts.rb
Normal file
49
db/migrate/20260617120000_create_up_items_and_accounts.rb
Normal 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
47
db/schema.rb
generated
@@ -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"
|
||||
|
||||
13
mobile/assets/icons/lucide/chevron-right.svg
Normal file
13
mobile/assets/icons/lucide/chevron-right.svg
Normal 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 |
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
66
mobile/lib/widgets/sure_card.dart
Normal file
66
mobile/lib/widgets/sure_card.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
102
mobile/lib/widgets/sure_chip.dart
Normal file
102
mobile/lib/widgets/sure_chip.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
220
mobile/lib/widgets/sure_list_group.dart
Normal file
220
mobile/lib/widgets/sure_list_group.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
192
mobile/lib/widgets/sure_segmented_control.dart
Normal file
192
mobile/lib/widgets/sure_segmented_control.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
190
mobile/lib/widgets/sure_text_field.dart
Normal file
190
mobile/lib/widgets/sure_text_field.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
50
mobile/test/widgets/currency_filter_test.dart
Normal file
50
mobile/test/widgets/currency_filter_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
49
mobile/test/widgets/custom_proxy_headers_editor_test.dart
Normal file
49
mobile/test/widgets/custom_proxy_headers_editor_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
69
mobile/test/widgets/sure_card_test.dart
Normal file
69
mobile/test/widgets/sure_card_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
127
mobile/test/widgets/sure_chip_test.dart
Normal file
127
mobile/test/widgets/sure_chip_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
199
mobile/test/widgets/sure_list_group_test.dart
Normal file
199
mobile/test/widgets/sure_list_group_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
150
mobile/test/widgets/sure_segmented_control_test.dart
Normal file
150
mobile/test/widgets/sure_segmented_control_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
140
mobile/test/widgets/sure_text_field_test.dart
Normal file
140
mobile/test/widgets/sure_text_field_test.dart
Normal 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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
160
test/models/provider/up_test.rb
Normal file
160
test/models/provider/up_test.rb
Normal 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
|
||||
24
test/models/up_account_test.rb
Normal file
24
test/models/up_account_test.rb
Normal 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
|
||||
116
test/models/up_entry/processor_test.rb
Normal file
116
test/models/up_entry/processor_test.rb
Normal 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
|
||||
156
test/models/up_item/importer_test.rb
Normal file
156
test/models/up_item/importer_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user