diff --git a/app/components/settings/provider_card.rb b/app/components/settings/provider_card.rb index c57657a2e..a7773d0e9 100644 --- a/app/components/settings/provider_card.rb +++ b/app/components/settings/provider_card.rb @@ -9,13 +9,13 @@ class Settings::ProviderCard < ApplicationComponent I18n.t(key) if key end - def initialize(provider_key:, name:, tagline: nil, region: nil, kind: nil, tier: nil, + def initialize(provider_key:, name:, tagline: nil, region: nil, kinds: nil, tier: nil, maturity: :stable, logo_bg: "bg-gray-500", logo_text: nil) @provider_key = provider_key @name = name @tagline = tagline @region = region - @kind = kind + @kinds = Array(kinds).compact @tier = tier @maturity = maturity.to_sym @logo_bg = logo_bg @@ -29,7 +29,7 @@ class Settings::ProviderCard < ApplicationComponent end def meta_line - [ @region, @kind, @tier ].compact.join(" · ") + [ @region, @kinds.join(" / "), @tier ].compact_blank.join(" · ") end def connect_path @@ -41,7 +41,7 @@ class Settings::ProviderCard < ApplicationComponent providers_filter_target: "card", provider_name: @name.to_s.downcase, provider_region: @region.to_s.downcase, - provider_kind: @kind.to_s.downcase + provider_kind: @kinds.map { |kind| kind.to_s.downcase }.join(" ") } end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index be8f2adf2..6ce5e02c6 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -15,6 +15,7 @@ class AccountsController < ApplicationController @plaid_items = visible_provider_items(family.plaid_items.ordered.includes(:syncs, :plaid_accounts)) @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)) @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)) @@ -326,6 +327,13 @@ class AccountsController < ApplicationController @lunchflow_sync_stats_map[item.id] = latest_sync&.sync_stats || {} end + # Akahu sync stats + @akahu_sync_stats_map = {} + @akahu_items.each do |item| + latest_sync = item.syncs.ordered.first + @akahu_sync_stats_map[item.id] = latest_sync&.sync_stats || {} + end + # Enable Banking sync stats @enable_banking_sync_stats_map = {} @enable_banking_latest_sync_error_map = {} diff --git a/app/controllers/akahu_items_controller.rb b/app/controllers/akahu_items_controller.rb new file mode 100644 index 000000000..51d8edad9 --- /dev/null +++ b/app/controllers/akahu_items_controller.rb @@ -0,0 +1,366 @@ +class AkahuItemsController < ApplicationController + before_action :set_akahu_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 + ] + + def index + @akahu_items = Current.family.akahu_items.active.ordered + render layout: "settings" + end + + def show + end + + def new + @akahu_item = Current.family.akahu_items.build + end + + def edit + end + + def create + @akahu_item = Current.family.akahu_items.build(akahu_item_params) + @akahu_item.name = t("akahu_items.provider_panel.default_connection_name") if @akahu_item.name.blank? + + if @akahu_item.save + @akahu_item.sync_later + render_provider_panel(:notice, t(".success")) + else + render_provider_panel_error(@akahu_item.errors.full_messages.join(", ")) + end + end + + def update + if @akahu_item.update(update_params) + render_provider_panel(:notice, t(".success")) + else + render_provider_panel_error(@akahu_item.errors.full_messages.join(", ")) + end + end + + def destroy + @akahu_item.unlink_all!(dry_run: false) + @akahu_item.destroy_later + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + rescue => e + Rails.logger.warn("Akahu unlink during destroy failed: #{e.class} - #{e.message}") + redirect_to settings_providers_path, alert: t(".unlink_failed"), status: :see_other + end + + def sync + @akahu_item.sync_later unless @akahu_item.syncing? + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + def preload_accounts + akahu_item = requested_akahu_item + return render json: { success: false, error: "no_credentials", has_accounts: false } unless akahu_item.credentials_configured? + + error = fetch_akahu_accounts_from_api(akahu_item) + render json: { success: error.blank?, error_message: error, has_accounts: akahu_item.akahu_accounts.exists? } + end + + def select_accounts + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + @akahu_item = requested_akahu_item + + unless @akahu_item.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_credentials_configured") + return + end + + @api_error = fetch_akahu_accounts_from_api(@akahu_item) + @akahu_accounts = @akahu_item.akahu_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .order(:name) + + render layout: false + end + + def link_accounts + akahu_item = requested_akahu_item + unless akahu_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_akahu_items_path(akahu_item_id: akahu_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::AkahuAdapter.supported_account_types.include?(account_type) + redirect_to new_account_path, alert: t(".unsupported_account_type") + return + end + + created_accounts = [] + + ActiveRecord::Base.transaction do + akahu_item.akahu_accounts.where(id: selected_ids).find_each do |akahu_account| + next if akahu_account.account_provider.present? + + account = create_account_from_akahu(akahu_account, account_type) + AccountProvider.create!(account: account, provider: akahu_account) + created_accounts << account + end + end + + akahu_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_akahu_items_path(akahu_item_id: akahu_item.id, accountable_type: account_type, return_to: safe_return_to_path), alert: t(".link_failed") + end + end + + 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 + + @akahu_item = requested_akahu_item + unless @akahu_item.credentials_configured? + redirect_to settings_providers_path, alert: t(".no_credentials_configured") + return + end + + @api_error = fetch_akahu_accounts_from_api(@akahu_item) + @akahu_accounts = @akahu_item.akahu_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .order(:name) + @return_to = safe_return_to_path + + render layout: false + end + + def link_existing_account + account = Current.family.accounts.find(params[:account_id]) + akahu_item = requested_akahu_item + + unless akahu_item.credentials_configured? + redirect_to settings_providers_path, alert: t("akahu_items.select_existing_account.no_credentials_configured") + return + end + + akahu_account = akahu_item.akahu_accounts.find(params[:akahu_account_id]) + + if account.account_providers.exists? + redirect_to accounts_path, alert: t(".account_already_linked") + return + end + + if akahu_account.account_provider.present? + redirect_to accounts_path, alert: t(".akahu_account_already_linked") + return + end + + AccountProvider.create!(account: account, provider: akahu_account) + akahu_item.sync_later + + redirect_to safe_return_to_path || accounts_path, notice: t(".success", account_name: account.name) + end + + def setup_accounts + @api_error = fetch_akahu_accounts_from_api(@akahu_item) + @akahu_accounts = @akahu_item.akahu_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .order(:name) + @account_type_options = [ + [ t(".account_types.skip"), "skip" ], + [ t(".account_types.depository"), "Depository" ], + [ t(".account_types.credit_card"), "CreditCard" ], + [ t(".account_types.investment"), "Investment" ], + [ t(".account_types.loan"), "Loan" ] + ] + @akahu_account_type_suggestions = @akahu_accounts.each_with_object({}) do |akahu_account, suggestions| + suggestions[akahu_account.id] = akahu_account.suggested_account_type || "skip" + end + end + + def complete_account_setup + account_types = params[:account_types] || {} + created_accounts = [] + skipped_count = 0 + + ActiveRecord::Base.transaction do + account_types.each do |akahu_account_id, selected_type| + if selected_type.blank? || selected_type == "skip" + skipped_count += 1 + next + end + + next unless Provider::AkahuAdapter.supported_account_types.include?(selected_type) + + akahu_account = @akahu_item.akahu_accounts.find_by(id: akahu_account_id) + next unless akahu_account + next if akahu_account.account_provider.present? + + account = create_account_from_akahu(akahu_account, selected_type) + AccountProvider.create!(account: account, provider: akahu_account) + created_accounts << account + end + end + + @akahu_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 + Rails.logger.error("Akahu account setup failed: #{e.class} - #{e.message}") + redirect_to accounts_path, alert: t(".creation_failed"), status: :see_other + end + + private + + def set_akahu_item + @akahu_item = Current.family.akahu_items.find(params[:id]) + end + + def akahu_item_params + params.require(:akahu_item).permit(:name, :sync_start_date, :app_token, :user_token) + end + + def update_params + permitted = akahu_item_params + permitted = permitted.except(:app_token) if permitted[:app_token].blank? + permitted = permitted.except(:user_token) if permitted[:user_token].blank? + permitted + end + + def requested_akahu_item + Current.family.akahu_items.active.find_by!(id: params[:akahu_item_id]) + end + + def fetch_akahu_accounts_from_api(akahu_item) + return t("akahu_items.setup_accounts.no_credentials") unless akahu_item.credentials_configured? + + provider = akahu_item.akahu_provider + accounts = provider.get_accounts + accounts.each do |account_data| + account = account_data.with_indifferent_access + account_id = account[:_id].presence || account[:id].presence + next if account_id.blank? || account[:name].blank? + + akahu_account = akahu_item.akahu_accounts.find_or_initialize_by(account_id: account_id.to_s) + akahu_account.upsert_akahu_snapshot!(account) + end + + nil + rescue Provider::Akahu::AkahuError => e + Rails.logger.error("Akahu API error while fetching accounts: #{e.class}: #{e.message}") + t("akahu_items.setup_accounts.api_error") + rescue StandardError => e + Rails.logger.error("Unexpected error fetching Akahu accounts: #{e.class}: #{e.message}") + t("akahu_items.setup_accounts.api_error") + end + + def create_account_from_akahu(akahu_account, account_type) + balance = akahu_account.current_balance || 0 + balance = balance.abs if account_type.in?(%w[CreditCard Loan]) + subtype = if account_type == "CreditCard" + "credit_card" + elsif account_type == "Depository" && akahu_account.suggested_account_type == account_type + akahu_account.suggested_subtype + elsif account_type == "Investment" && akahu_account.suggested_account_type == account_type + akahu_account.suggested_subtype + end + cash_balance = account_type == "Investment" ? 0 : balance + + Account.create_and_sync( + { + family: Current.family, + name: akahu_account.name, + balance: balance, + cash_balance: cash_balance, + currency: akahu_account.currency || "NZD", + accountable_type: account_type, + accountable_attributes: subtype.present? ? { subtype: subtype } : {} + }, + skip_initial_sync: true + ) + end + + def render_provider_panel(flash_type, message) + if turbo_frame_request? + flash.now[flash_type] = message + @akahu_items = Current.family.akahu_items.active.ordered + render turbo_stream: [ + turbo_stream.replace( + "akahu-providers-panel", + partial: "settings/providers/akahu_panel", + locals: { akahu_items: @akahu_items } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, { flash_type => message, status: :see_other } + end + end + + def render_provider_panel_error(message) + @error_message = message + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "akahu-providers-panel", + partial: "settings/providers/akahu_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity + end + end + + 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 + + def encoded_path_separator?(return_to) + encoded_second_character = return_to[1, 3] + return false unless encoded_second_character&.start_with?("%") + + decoded = URI.decode_www_form_component(encoded_second_character) + decoded == "/" || decoded == "\\" + rescue ArgumentError + true + end +end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 2d123f2ab..1a3a03ca1 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -182,6 +182,7 @@ class Settings::ProvidersController < ApplicationController # status display, and sync actions. The configuration registry excludes # them (see prepare_show_context). FAMILY_PANELS = [ + { key: "akahu", title: "Akahu", turbo_id: "akahu", partial: "akahu_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" }, @@ -201,6 +202,7 @@ class Settings::ProvidersController < ApplicationController # Maps panel key → ActiveRecord model name for sync health queries PANEL_SYNCABLE_TYPES = { + "akahu" => "AkahuItem", "simplefin" => "SimplefinItem", "lunchflow" => "LunchflowItem", "enable_banking" => "EnableBankingItem", @@ -218,6 +220,8 @@ class Settings::ProvidersController < ApplicationController def load_provider_items(provider_key) case provider_key + when "akahu" + @akahu_items = Current.family.akahu_items.active.ordered when "simplefin" @simplefin_items = Current.family.simplefin_items.ordered when "lunchflow" @@ -255,6 +259,7 @@ class Settings::ProvidersController < ApplicationController FAMILY_PANEL_KEYS.any? { |key| config.provider_key.to_s.casecmp(key).zero? } end + @akahu_items = Current.family.akahu_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) @@ -287,6 +292,7 @@ class Settings::ProvidersController < ApplicationController # on instance_variable_get for control flow. def family_panel_items { + "akahu" => @akahu_items, "simplefin" => @simplefin_items, "lunchflow" => @lunchflow_items, "enable_banking" => @enable_banking_items, diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index d9eb42a0e..4b89c3f8d 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -57,6 +57,9 @@ module SettingsHelper when "plaid", "plaid_eu" configured = @provider_configurations&.find { |c| c.provider_key.to_s.casecmp(key).zero? }&.configured? configured ? { status: :ok } : { status: :off } + when "akahu" + return { status: :off } unless @akahu_items&.any? + sync_based_summary(key) when "simplefin" return { status: :off } unless @simplefin_items&.any? sync_based_summary(key) diff --git a/app/javascript/controllers/providers_filter_controller.js b/app/javascript/controllers/providers_filter_controller.js index 54004b2f6..a4d90e2a4 100644 --- a/app/javascript/controllers/providers_filter_controller.js +++ b/app/javascript/controllers/providers_filter_controller.js @@ -22,10 +22,10 @@ export default class extends Controller { this.cardTargets.forEach((card) => { const name = card.dataset.providerName ?? ""; const region = card.dataset.providerRegion ?? ""; - const kind = card.dataset.providerKind ?? ""; - const haystack = `${name} ${region} ${kind}`; + const kindTokens = (card.dataset.providerKind ?? "").split(/\s+/); + const haystack = `${name} ${region} ${kindTokens.join(" ")}`; const matchesQuery = !query || haystack.includes(query); - const matchesKind = activeKind === "all" || kind === activeKind; + const matchesKind = activeKind === "all" || kindTokens.includes(activeKind); const visible = matchesQuery && matchesKind; card.classList.toggle("hidden", !visible); if (visible) visibleCount++; diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index f5bfe202f..acd2fd7cf 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -49,11 +49,10 @@ class Account::ProviderImportAdapter incoming_pending = false if extra.is_a?(Hash) pending_extra = extra.with_indifferent_access - incoming_pending = - ActiveModel::Type::Boolean.new.cast(pending_extra.dig("simplefin", "pending")) || - ActiveModel::Type::Boolean.new.cast(pending_extra.dig("plaid", "pending")) || - ActiveModel::Type::Boolean.new.cast(pending_extra.dig("lunchflow", "pending")) || - ActiveModel::Type::Boolean.new.cast(pending_extra.dig("enable_banking", "pending")) + boolean_type = ActiveModel::Type::Boolean.new + incoming_pending = Transaction::PENDING_PROVIDERS.any? do |provider| + boolean_type.cast(pending_extra.dig(provider, "pending")) + end end # === PROTECTION CHECK: Skip entries that should not be overwritten === @@ -770,6 +769,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true + OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true SQL .order(date: :desc) # Prefer most recent pending transaction @@ -817,6 +817,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true + OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true SQL # If merchant_id is provided, prioritize matching by merchant @@ -887,6 +888,7 @@ class Account::ProviderImportAdapter OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true + OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true SQL # For low confidence, require BOTH merchant AND name match (stronger signal needed) diff --git a/app/models/akahu_account.rb b/app/models/akahu_account.rb new file mode 100644 index 000000000..ec8fd54a3 --- /dev/null +++ b/app/models/akahu_account.rb @@ -0,0 +1,87 @@ +class AkahuAccount < ApplicationRecord + include CurrencyNormalizable, Encryptable + + AKAHU_ACCOUNT_TYPE_MAP = { + "CHECKING" => { accountable_type: "Depository", subtype: "checking" }, + "SAVINGS" => { accountable_type: "Depository", subtype: "savings" }, + "TERMDEPOSIT" => { accountable_type: "Depository", subtype: "cd" }, + "CREDITCARD" => { accountable_type: "CreditCard", subtype: "credit_card" }, + "LOAN" => { accountable_type: "Loan" }, + "KIWISAVER" => { accountable_type: "Investment", subtype: "retirement" }, + "INVESTMENT" => { accountable_type: "Investment" } + }.freeze + + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end + + belongs_to :akahu_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: :akahu_item_id, allow_nil: true } + + def current_account + account + end + + def suggested_account_type + AKAHU_ACCOUNT_TYPE_MAP[account_type.to_s.upcase]&.fetch(:accountable_type) + end + + def suggested_subtype + AKAHU_ACCOUNT_TYPE_MAP[account_type.to_s.upcase]&.[](:subtype) + end + + def upsert_akahu_snapshot!(account_snapshot) + snapshot = account_snapshot.with_indifferent_access + balance = snapshot[:balance].is_a?(Hash) ? snapshot[:balance].with_indifferent_access : {} + connection = snapshot[:connection].is_a?(Hash) ? snapshot[:connection].with_indifferent_access : {} + meta = snapshot[:meta].is_a?(Hash) ? snapshot[:meta].with_indifferent_access : {} + payment_details = meta[:payment_details].is_a?(Hash) ? meta[:payment_details].with_indifferent_access : {} + + display_name = if connection[:name].present? && snapshot[:name].present? + "#{connection[:name]} - #{snapshot[:name]}" + else + snapshot[:name].presence || connection[:name].presence || I18n.t("akahu_account.fallback") + end + + assign_attributes( + current_balance: balance[:current] || 0, + available_balance: balance[:available], + balance_limit: balance[:limit], + currency: parse_currency(balance[:currency]) || "NZD", + name: display_name, + account_id: snapshot[:_id].presence || snapshot[:id].presence, + formatted_account: snapshot[:formatted_account].presence || payment_details[:account_number], + account_status: snapshot[:status], + account_type: snapshot[:type], + provider: "akahu", + institution_metadata: { + id: connection[:_id].presence || connection[:id], + name: connection[:name], + logo: connection[:logo], + account_number: snapshot[:formatted_account].presence || payment_details[:account_number], + holder: meta[:holder].presence || payment_details[:account_holder] + }.compact, + raw_payload: account_snapshot + ) + + save! + end + + def upsert_akahu_transactions_snapshot!(transactions_snapshot) + assign_attributes(raw_transactions_payload: transactions_snapshot) + save! + end + + private + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for Akahu account #{id}, defaulting to NZD") + end +end diff --git a/app/models/akahu_account/processor.rb b/app/models/akahu_account/processor.rb new file mode 100644 index 000000000..0d22c427b --- /dev/null +++ b/app/models/akahu_account/processor.rb @@ -0,0 +1,74 @@ +class AkahuAccount::Processor + include CurrencyNormalizable + + SanitizedProcessingError = Class.new(StandardError) + + attr_reader :akahu_account + + def initialize(akahu_account) + @akahu_account = akahu_account + end + + def process + unless akahu_account.current_account.present? + Rails.logger.info "AkahuAccount::Processor - No linked account for akahu_account #{akahu_account.id}, skipping processing" + return + end + + process_account! + process_transactions + rescue StandardError => e + Rails.logger.error "AkahuAccount::Processor - Failed to process account akahu_account_id=#{akahu_account.id} error_class=#{e.class.name}" + report_exception(e, "account") + raise + end + + private + + def process_account! + account = akahu_account.current_account + balance = akahu_account.current_balance || 0 + + balance = balance.abs if account.accountable_type.in?(%w[CreditCard Loan]) + cash_balance = account.accountable_type == "Investment" ? 0 : balance + currency = parse_currency(akahu_account.currency) || account.currency || "NZD" + + account.update!( + balance: balance, + cash_balance: cash_balance, + currency: currency + ) + end + + def process_transactions + AkahuAccount::Transactions::Processor.new(akahu_account).process + rescue => e + report_exception(e, "transactions") + Rails.logger.error "AkahuAccount::Processor - Failed to process transactions akahu_account_id=#{akahu_account.id} error_class=#{e.class.name}" + { success: false, failed: 1, errors: [ { error: I18n.t("akahu_item.errors.account_processing_failed") } ] } + end + + def report_exception(error, context) + safe_error = SanitizedProcessingError.new("Akahu account processing failed") + + Sentry.capture_exception(safe_error) do |scope| + scope.set_tags( + akahu_account_id: akahu_account.id, + context: context, + error_class: error.class.name + ) + scope.set_context( + "akahu_account_processor", + { + akahu_account_id: akahu_account.id, + context: context, + error_class: error.class.name + } + ) + end + end + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for Akahu account #{akahu_account.id}, falling back to account currency") + end +end diff --git a/app/models/akahu_account/transactions/processor.rb b/app/models/akahu_account/transactions/processor.rb new file mode 100644 index 000000000..48855193d --- /dev/null +++ b/app/models/akahu_account/transactions/processor.rb @@ -0,0 +1,88 @@ +class AkahuAccount::Transactions::Processor + attr_reader :akahu_account + + def initialize(akahu_account) + @akahu_account = akahu_account + end + + def process + unless akahu_account.raw_transactions_payload.present? + Rails.logger.info "AkahuAccount::Transactions::Processor - No Akahu 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 = akahu_account.raw_transactions_payload.count + imported_count = 0 + failed_count = 0 + errors = [] + current_pending_external_ids = pending_external_ids + + akahu_account.raw_transactions_payload.each_with_index do |transaction_data, index| + result = AkahuEntry::Processor.new( + transaction_data, + akahu_account: akahu_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 "AkahuAccount::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 "AkahuAccount::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 + + def transaction_id(transaction_data) + transaction_data.try(:[], :_id) || + transaction_data.try(:[], "_id") || + transaction_data.try(:[], :id) || + transaction_data.try(:[], "id") || + "unknown" + end + + def pending_external_ids + akahu_account.raw_transactions_payload.filter_map do |transaction_data| + next unless transaction_data.is_a?(Hash) + next unless AkahuEntry::Processor.pending?(transaction_data) + + AkahuEntry::Processor.canonical_external_id(transaction_data) + end + end + + def prune_stale_pending_entries(current_pending_external_ids) + account = akahu_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: "akahu") + .where("(transactions.extra -> 'akahu' ->> 'pending')::boolean = true") + stale_pending_entries = stale_pending_entries.where.not(external_id: current_pending_external_ids) if current_pending_external_ids.any? + + count = stale_pending_entries.count + stale_pending_entries.find_each(&:destroy!) if count.positive? + count + end +end diff --git a/app/models/akahu_entry/processor.rb b/app/models/akahu_entry/processor.rb new file mode 100644 index 000000000..a928fab96 --- /dev/null +++ b/app/models/akahu_entry/processor.rb @@ -0,0 +1,241 @@ +require "digest/md5" + +class AkahuEntry::Processor + include CurrencyNormalizable + + def self.canonical_external_id(akahu_transaction) + data = akahu_transaction.with_indifferent_access + id = data[:_id].presence || data[:id].presence + return "akahu_#{id}" if id.present? + + "akahu_pending_#{content_hash_for(data)}" + end + + def self.pending?(akahu_transaction) + data = akahu_transaction.with_indifferent_access + ActiveModel::Type::Boolean.new.cast(data[:_pending]) == true || + ActiveModel::Type::Boolean.new.cast(data[:pending]) == true + end + + def self.content_hash_for(data) + merchant = data[:merchant].is_a?(Hash) ? data[:merchant].with_indifferent_access : {} + attributes = [ + data[:_account], + data[:account], + data[:date], + data[:amount], + data[:description], + merchant[:name].to_s.strip.presence, + data[:type] + ].compact.join("|") + + Digest::MD5.hexdigest(attributes) + end + + def initialize(akahu_transaction, akahu_account:) + @akahu_transaction = akahu_transaction + @akahu_account = akahu_account + end + + def process + unless account.present? + Rails.logger.warn "AkahuEntry::Processor - No linked account for akahu_account #{akahu_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: "akahu", + merchant: merchant, + notes: notes, + extra: extra_metadata + ) + rescue ArgumentError => e + Rails.logger.error "AkahuEntry::Processor - Validation error for transaction #{external_id}: #{e.message}" + raise + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error "AkahuEntry::Processor - Failed to save transaction #{external_id}: #{e.message}" + raise StandardError.new("Failed to import transaction: #{e.message}") + rescue => e + Rails.logger.error "AkahuEntry::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 :akahu_transaction, :akahu_account + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + @account ||= akahu_account.current_account + end + + def data + @data ||= akahu_transaction.with_indifferent_access + end + + def external_id + @external_id ||= begin + id = data[:_id].presence || data[:id].presence + if id.present? + "akahu_#{id}" + else + base_id = self.class.canonical_external_id(data) + if existing_pending_entry?(base_id) + base_id + else + final_id = base_id + counter = 1 + while entry_exists_with_external_id?(final_id) + final_id = "#{base_id}_#{counter}" + counter += 1 + end + + final_id + end + end + end + end + + def existing_pending_entry?(external_id) + existing_entry = account&.entries&.find_by(external_id: external_id, source: "akahu") + existing_entry&.entryable.is_a?(Transaction) && existing_entry.entryable.pending? + end + + def entry_exists_with_external_id?(external_id) + account.present? && account.entries.exists?(external_id: external_id, source: "akahu") + end + + def name + merchant_name.presence || data[:description].presence || I18n.t("transactions.unknown_name") + end + + def notes + meta = meta_data + parts = [] + parts << data[:description] if data[:description].present? && data[:description] != name + parts << "#{t('akahu_entry.notes.reference')}: #{meta[:reference]}" if meta[:reference].present? + parts << "#{t('akahu_entry.notes.particulars')}: #{meta[:particulars]}" if meta[:particulars].present? + parts << "#{t('akahu_entry.notes.code')}: #{meta[:code]}" if meta[:code].present? + parts << "#{t('akahu_entry.notes.other_account')}: #{meta[:other_account]}" if meta[:other_account].present? + parts.presence&.join(" | ") + end + + def merchant + return nil unless merchant_name.present? + + provider_merchant_id = merchant_data[:_id].presence || merchant_data[:id].presence + provider_merchant_id ||= "akahu_merchant_#{Digest::MD5.hexdigest(merchant_name.downcase)}" + + @merchant ||= import_adapter.find_or_create_merchant( + provider_merchant_id: provider_merchant_id, + name: merchant_name, + source: "akahu", + website_url: merchant_data[:website] + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "AkahuEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}" + nil + end + + def amount + parsed_amount = case data[:amount] + when String + BigDecimal(data[:amount]) + when Numeric + BigDecimal(data[:amount].to_s) + else + BigDecimal("0") + end + + # Akahu uses banking convention: negative is money out, positive is money in. + # Sure stores expenses as positive and income as negative. + -parsed_amount + rescue ArgumentError => e + Rails.logger.error "Failed to parse Akahu transaction amount: #{e.class}" + raise ArgumentError, "Invalid transaction amount" + end + + def currency + parse_currency(data[:currency]) || akahu_account.currency || account&.currency || "NZD" + end + + def date + value = data[:date] + case value + when String + Date.parse(value) + when Integer, Float + Time.at(value).to_date + when Time, DateTime + value.to_date + when Date + value + else + Rails.logger.error("Akahu transaction has invalid date value") + raise ArgumentError, "Invalid date format" + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse Akahu transaction date: #{e.class}") + raise ArgumentError, "Unable to parse transaction date" + end + + def extra_metadata + { + "akahu" => { + "pending" => pending?, + "type" => data[:type], + "category" => category_data[:name], + "category_id" => category_data[:_id].presence || category_data[:id], + "category_group" => category_group_name, + "reference" => meta_data[:reference], + "particulars" => meta_data[:particulars], + "code" => meta_data[:code], + "other_account" => meta_data[:other_account] + }.compact + } + end + + def pending? + self.class.pending?(data) + end + + def merchant_data + @merchant_data ||= data[:merchant].is_a?(Hash) ? data[:merchant].with_indifferent_access : {} + end + + def merchant_name + merchant_data[:name].to_s.strip.presence + end + + def category_data + @category_data ||= data[:category].is_a?(Hash) ? data[:category].with_indifferent_access : {} + end + + def category_group_name + groups = category_data[:groups] + return nil unless groups.is_a?(Hash) + + groups.with_indifferent_access.dig(:personal_finance, :name) + end + + def meta_data + @meta_data ||= data[:meta].is_a?(Hash) ? data[:meta].with_indifferent_access : {} + end + + def t(key, **options) + I18n.t(key, **options) + end + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' in Akahu transaction #{external_id}, falling back to account currency") + end +end diff --git a/app/models/akahu_item.rb b/app/models/akahu_item.rb new file mode 100644 index 000000000..5c550420d --- /dev/null +++ b/app/models/akahu_item.rb @@ -0,0 +1,137 @@ +class AkahuItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + if encryption_ready? + encrypts :app_token, deterministic: true + encrypts :user_token, deterministic: true + encrypts :raw_payload + encrypts :raw_institution_payload + end + + belongs_to :family + has_one_attached :logo, dependent: :purge_later + has_many :akahu_accounts, dependent: :destroy + has_many :accounts, through: :akahu_accounts + + validates :name, presence: true + validates :app_token, :user_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) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def import_latest_akahu_data + provider = akahu_provider + unless provider + Rails.logger.error "AkahuItem #{id} - Cannot import: Akahu provider is not configured" + raise StandardError.new("Akahu provider is not configured") + end + + AkahuItem::Importer.new(self, akahu_provider: provider).import + rescue => e + Rails.logger.error "AkahuItem #{id} - Failed to import data: #{e.message}" + raise + end + + def process_accounts + return [] if akahu_accounts.empty? + + akahu_accounts.joins(:account).merge(Account.visible).map do |akahu_account| + result = AkahuAccount::Processor.new(akahu_account).process + if result.is_a?(Hash) && result.with_indifferent_access[:success] == false + { akahu_account_id: akahu_account.id, success: false, error: I18n.t("akahu_item.errors.account_processing_failed") } + else + { akahu_account_id: akahu_account.id, success: true, result: result } + end + rescue => e + Rails.logger.error "AkahuItem #{id} - Failed to process account #{akahu_account.id}: #{e.class} - #{e.message}" + { akahu_account_id: akahu_account.id, success: false, error: I18n.t("akahu_item.errors.account_processing_failed") } + end + end + + 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 + Rails.logger.error "AkahuItem #{id} - Failed to schedule sync for account #{account.id}: #{e.class} - #{e.message}" + { account_id: account.id, success: false, error: I18n.t("akahu_item.errors.account_sync_schedule_failed") } + end + end + + def upsert_akahu_snapshot!(accounts_snapshot) + assign_attributes(raw_payload: accounts_snapshot) + save! + end + + def has_completed_initial_setup? + accounts.any? + end + + 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("akahu_item.sync_status.no_accounts") + elsif unlinked_count.zero? + I18n.t("akahu_item.sync_status.all_synced", count: linked_count) + else + I18n.t("akahu_item.sync_status.partial", linked: linked_count, unlinked: unlinked_count) + end + end + + def linked_accounts_count + akahu_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + akahu_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + akahu_accounts.count + end + + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + def connected_institutions + akahu_accounts.includes(:account) + .where.not(institution_metadata: nil) + .map(&:institution_metadata) + .uniq { |inst| inst["id"] || inst["name"] } + end + + def institution_summary + institutions = connected_institutions + case institutions.count + when 0 + I18n.t("akahu_item.institution_summary.none") + when 1 + institutions.first["name"].presence || I18n.t("akahu_item.institution_summary.one") + else + I18n.t("akahu_item.institution_summary.count", count: institutions.count) + end + end + + def credentials_configured? + app_token.present? && user_token.present? + end +end diff --git a/app/models/akahu_item/importer.rb b/app/models/akahu_item/importer.rb new file mode 100644 index 000000000..2c1f71204 --- /dev/null +++ b/app/models/akahu_item/importer.rb @@ -0,0 +1,252 @@ +require "digest/md5" + +class AkahuItem::Importer + attr_reader :akahu_item, :akahu_provider + + def initialize(akahu_item, akahu_provider:) + @akahu_item = akahu_item + @akahu_provider = akahu_provider + end + + def import + Rails.logger.info "AkahuItem::Importer - Starting import for item #{akahu_item.id}" + + accounts_data = fetch_accounts_data + return failed_result("Failed to fetch accounts data") unless accounts_data + + akahu_item.upsert_akahu_snapshot!(accounts_data) + + account_stats = import_accounts(accounts_data) + pending_result = fetch_pending_transactions_by_account + transaction_stats = import_transactions(pending_result) + + Rails.logger.info( + "AkahuItem::Importer - Completed import for item #{akahu_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 + + def fetch_accounts_data + items = akahu_provider.get_accounts + { items: items } + rescue Provider::Akahu::AkahuError => e + mark_requires_update! if e.error_type.in?([ :unauthorized, :access_forbidden ]) + Rails.logger.error "AkahuItem::Importer - Akahu API error: #{e.error_type}" + nil + rescue JSON::ParserError => e + Rails.logger.error "AkahuItem::Importer - Failed to parse Akahu API response: #{e.class}" + nil + rescue => e + Rails.logger.error "AkahuItem::Importer - Unexpected error fetching accounts: #{e.class}" + Rails.logger.error e.backtrace.join("\n") + nil + end + + def import_accounts(accounts_data) + stats = { updated: 0, created: 0, failed: 0 } + accounts = Array(accounts_data[:items]) + linked_account_ids = akahu_item.akahu_accounts.joins(:account_provider).pluck(:account_id).map(&:to_s) + all_existing_ids = akahu_item.akahu_accounts.pluck(:account_id).map(&:to_s) + + accounts.each do |account_data| + account = account_data.with_indifferent_access + account_id = account[:_id].presence || account[:id].presence + next if account_id.blank? + next if account[:name].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) + akahu_account = akahu_item.akahu_accounts.build(account_id: account_id.to_s) + akahu_account.upsert_akahu_snapshot!(account) + stats[:created] += 1 + end + rescue => e + stats[:failed] += 1 + Rails.logger.error "AkahuItem::Importer - Failed to import account #{account_id}: #{e.message}" + end + + stats + end + + def import_account(account_data) + account = account_data.with_indifferent_access + account_id = account[:_id].presence || account[:id].presence + akahu_account = akahu_item.akahu_accounts.find_by(account_id: account_id.to_s) + return unless akahu_account + + akahu_account.upsert_akahu_snapshot!(account) + end + + def fetch_pending_transactions_by_account + pending_transactions = akahu_provider.get_pending_transactions + + by_account = pending_transactions.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |transaction, grouped| + data = transaction.with_indifferent_access + account_id = data[:_account].presence || data[:account].presence || data[:account_id].presence + next if account_id.blank? + + grouped[account_id.to_s] << data.merge(_pending: true) + end + + { success: true, by_account: by_account } + rescue Provider::Akahu::AkahuError, JSON::ParserError, StandardError => e + error_label = e.respond_to?(:error_type) ? e.error_type : e.class.name + Rails.logger.warn "AkahuItem::Importer - Failed to fetch pending transactions: #{error_label}" + { success: false, by_account: Hash.new { |hash, key| hash[key] = [] }, error: I18n.t("akahu_item.errors.pending_transactions_failed") } + end + + def import_transactions(pending_result) + stats = { imported: 0, failed: 0 } + pending_by_account = pending_result[:by_account] + pending_refresh_succeeded = pending_result[:success] + + akahu_item.akahu_accounts.joins(:account).merge(Account.visible).each do |akahu_account| + result = fetch_and_store_transactions( + akahu_account, + pending_by_account[akahu_account.account_id.to_s], + pending_refresh_succeeded: pending_refresh_succeeded + ) + if result[:success] + stats[:imported] += result[:transactions_count] + else + stats[:failed] += 1 + end + rescue => e + stats[:failed] += 1 + Rails.logger.error "AkahuItem::Importer - Failed to fetch/store transactions for Akahu account #{akahu_account.id}: #{e.class}" + end + + stats + end + + def fetch_and_store_transactions(akahu_account, pending_transactions, pending_refresh_succeeded:) + start_date = determine_sync_start_date(akahu_account) + Rails.logger.info "AkahuItem::Importer - Fetching transactions for Akahu account #{akahu_account.id} from #{start_date}" + + posted_transactions = akahu_provider.get_account_transactions( + account_id: akahu_account.account_id, + start_date: start_date + ) + + store_transactions( + akahu_account, + posted_transactions: Array(posted_transactions), + pending_transactions: Array(pending_transactions), + replace_pending: pending_refresh_succeeded + ) + + { success: true, transactions_count: Array(posted_transactions).count + Array(pending_transactions).count } + rescue Provider::Akahu::AkahuError => e + Rails.logger.error "AkahuItem::Importer - Akahu API error for account #{akahu_account.id}: #{e.error_type}" + { success: false, transactions_count: 0, error: I18n.t("akahu_item.errors.transactions_failed") } + rescue JSON::ParserError => e + Rails.logger.error "AkahuItem::Importer - Failed to parse transaction response for account #{akahu_account.id}: #{e.class}" + { success: false, transactions_count: 0, error: "Failed to parse response" } + rescue => e + Rails.logger.error "AkahuItem::Importer - Unexpected error fetching transactions for account #{akahu_account.id}: #{e.class}" + Rails.logger.error e.backtrace.join("\n") + { success: false, transactions_count: 0, error: I18n.t("akahu_item.errors.transactions_failed") } + end + + def store_transactions(akahu_account, posted_transactions:, pending_transactions:, replace_pending:) + existing_transactions = akahu_account.raw_transactions_payload.to_a + existing_posted_transactions = existing_transactions.reject { |tx| pending_transaction?(tx) } + existing_posted_keys = existing_posted_transactions.map { |tx| transaction_storage_key(tx.with_indifferent_access) }.compact.to_set + seen_posted_keys = existing_posted_keys.dup + + new_posted_transactions = posted_transactions.select do |tx| + next false unless tx.is_a?(Hash) + + key = transaction_storage_key(tx.with_indifferent_access) + key.present? && seen_posted_keys.add?(key) + end + + current_pending_keys = Set.new + current_pending_transactions = pending_transactions.select do |tx| + next false unless tx.is_a?(Hash) + + key = transaction_storage_key(tx.with_indifferent_access) + next false if key.blank? + + key.start_with?("id:") ? current_pending_keys.add?(key) : true + end + + final_transactions = if replace_pending + existing_posted_transactions + new_posted_transactions + current_pending_transactions + else + existing_transactions + new_posted_transactions + end + + if final_transactions != existing_transactions + Rails.logger.info( + "AkahuItem::Importer - Storing #{new_posted_transactions.count} new posted transactions " \ + "and #{current_pending_transactions.count} current pending transactions " \ + "(#{existing_transactions.count} existing) for account #{akahu_account.account_id}" + ) + akahu_account.upsert_akahu_transactions_snapshot!(final_transactions) + else + Rails.logger.info "AkahuItem::Importer - No new transactions for account #{akahu_account.account_id}" + end + end + + def transaction_storage_key(transaction) + id = transaction[:_id].presence || transaction[:id].presence + return "id:#{id}" if id.present? + + attributes = [ + transaction[:_account], + transaction[:account], + transaction[:date], + transaction[:amount], + transaction[:description], + transaction.dig(:merchant, :name), + transaction[:type] + ].compact.join("|") + + return nil if attributes.blank? + + "hash:#{Digest::MD5.hexdigest(attributes)}" + end + + def pending_transaction?(transaction) + data = transaction.with_indifferent_access + ActiveModel::Type::Boolean.new.cast(data[:_pending]) == true || + ActiveModel::Type::Boolean.new.cast(data[:pending]) == true + end + + def determine_sync_start_date(akahu_account) + return akahu_account.sync_start_date if akahu_account.sync_start_date.present? + return akahu_item.sync_start_date if akahu_item.sync_start_date.present? + + has_stored_transactions = akahu_account.raw_transactions_payload.to_a.any? + if has_stored_transactions && akahu_item.last_synced_at + akahu_item.last_synced_at - 7.days + else + 90.days.ago + end + end + + def mark_requires_update! + akahu_item.update!(status: :requires_update) + rescue => e + Rails.logger.error "AkahuItem::Importer - Failed to update item status: #{e.message}" + end + + def failed_result(error) + { success: false, error: error, accounts_imported: 0, transactions_imported: 0 } + end +end diff --git a/app/models/akahu_item/provided.rb b/app/models/akahu_item/provided.rb new file mode 100644 index 000000000..8fa1f7d52 --- /dev/null +++ b/app/models/akahu_item/provided.rb @@ -0,0 +1,16 @@ +module AkahuItem::Provided + extend ActiveSupport::Concern + + def akahu_provider + return nil unless credentials_configured? + + Provider::Akahu.new( + app_token: app_token, + user_token: user_token + ) + end + + def syncer + AkahuItem::Syncer.new(self) + end +end diff --git a/app/models/akahu_item/sync_complete_event.rb b/app/models/akahu_item/sync_complete_event.rb new file mode 100644 index 000000000..73fed67a1 --- /dev/null +++ b/app/models/akahu_item/sync_complete_event.rb @@ -0,0 +1,20 @@ +class AkahuItem::SyncCompleteEvent + attr_reader :akahu_item + + def initialize(akahu_item) + @akahu_item = akahu_item + end + + def broadcast + akahu_item.accounts.each(&:broadcast_sync_complete) + + akahu_item.broadcast_replace_to( + akahu_item.family, + target: "akahu_item_#{akahu_item.id}", + partial: "akahu_items/akahu_item", + locals: { akahu_item: akahu_item } + ) + + akahu_item.family.broadcast_sync_complete + end +end diff --git a/app/models/akahu_item/syncer.rb b/app/models/akahu_item/syncer.rb new file mode 100644 index 000000000..26fe15eaf --- /dev/null +++ b/app/models/akahu_item/syncer.rb @@ -0,0 +1,121 @@ +class AkahuItem::Syncer + include SyncStats::Collector + + SafeSyncError = Class.new(StandardError) + + class SyncError < StandardError + attr_reader :sync_errors + + def initialize(message, sync_errors:) + super(message) + @sync_errors = sync_errors + end + end + + attr_reader :akahu_item + + def initialize(akahu_item) + @akahu_item = akahu_item + end + + def perform_sync(sync) + sync.update!(status_text: "Importing accounts from Akahu...") if sync.respond_to?(:status_text) + import_result = akahu_item.import_latest_akahu_data + raise_if_failed_result!(import_result, stage: "Akahu import") + + sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + collect_setup_stats(sync, provider_accounts: akahu_item.akahu_accounts) + + linked_accounts = akahu_item.akahu_accounts.joins(:account_provider) + unlinked_accounts = akahu_item.akahu_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + + if unlinked_accounts.any? + akahu_item.update!(pending_account_setup: true) + sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text) + else + akahu_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 = akahu_item.process_accounts + raise_if_failed_results!(process_results, stage: "Akahu account processing") + + sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) + schedule_results = akahu_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: "Akahu account sync scheduling") + + account_ids = linked_accounts.includes(:account_provider).filter_map { |aa| aa.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "akahu") + else + Rails.logger.info "AkahuItem::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("akahu_item.errors.sync_failed") + Rails.logger.error "AkahuItem::Syncer - Unexpected sync error: #{e.class}" + collect_health_stats(sync, errors: [ { message: safe_message, category: "sync_error" } ]) + raise SafeSyncError.new(safe_message), cause: nil + end + + def perform_post_sync + # no-op + end + + private + + 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 + + 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 + + def failed_result?(result) + result.is_a?(Hash) && result.with_indifferent_access[:success] == false + end + + 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 + + def error_message(stage, errors) + messages = errors.map { |error| error[:message] || error["message"] }.compact + messages.presence&.join(", ") || "#{stage} failed" + end + + def error_message_value(error) + return error[:message].presence || error["message"].presence || error[:error].presence || error["error"].presence if error.is_a?(Hash) + + error.to_s.presence + end +end diff --git a/app/models/akahu_item/unlinking.rb b/app/models/akahu_item/unlinking.rb new file mode 100644 index 000000000..4823f88eb --- /dev/null +++ b/app/models/akahu_item/unlinking.rb @@ -0,0 +1,39 @@ +module AkahuItem::Unlinking + extend ActiveSupport::Concern + + def unlink_all!(dry_run: false) + results = [] + + akahu_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 + Rails.logger.warn( + "AkahuItem Unlinker: failed to fully unlink provider account ##{provider_account.id} " \ + "(links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index d14b47eb5..66c364660 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -6,6 +6,7 @@ class DataEnrichment < ApplicationRecord plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", + akahu: "akahu", synth: "synth", ai: "ai", enable_banking: "enable_banking", diff --git a/app/models/family.rb b/app/models/family.rb index 0d44fce73..d4c6cf1e4 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,6 +1,6 @@ class Family < ApplicationRecord include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable - include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable + include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, AkahuConnectable, EnableBankingConnectable include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, BrexConnectable, SophtronConnectable include IndexaCapitalConnectable, IbkrConnectable diff --git a/app/models/family/akahu_connectable.rb b/app/models/family/akahu_connectable.rb new file mode 100644 index 000000000..ef933f275 --- /dev/null +++ b/app/models/family/akahu_connectable.rb @@ -0,0 +1,26 @@ +module Family::AkahuConnectable + extend ActiveSupport::Concern + + included do + has_many :akahu_items, dependent: :destroy + end + + def can_connect_akahu? + true + end + + def create_akahu_item!(app_token:, user_token:, item_name: nil) + akahu_item = akahu_items.create!( + name: item_name || I18n.t("family.akahu.create_akahu_item.default_name"), + app_token: app_token, + user_token: user_token + ) + + akahu_item.sync_later + akahu_item + end + + def has_akahu_credentials? + akahu_items.active.any?(&:credentials_configured?) + end +end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 7873c5ff0..66721a1a2 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -12,6 +12,7 @@ class Family::Syncer plaid_items simplefin_items lunchflow_items + akahu_items enable_banking_items indexa_capital_items coinbase_items diff --git a/app/models/provider/akahu.rb b/app/models/provider/akahu.rb new file mode 100644 index 000000000..9d6dda11d --- /dev/null +++ b/app/models/provider/akahu.rb @@ -0,0 +1,200 @@ +class Provider::Akahu + include HTTParty + extend SslConfigurable + + DEFAULT_BASE_URL = "https://api.akahu.io/v1".freeze + headers "User-Agent" => "Sure Finance Akahu Client" + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) + + attr_reader :app_token, :user_token + + def initialize(app_token:, user_token:) + @app_token = app_token.to_s.strip + @user_token = user_token.to_s.strip + + raise AkahuError.new("Akahu app token is required", :configuration_error) if @app_token.blank? + raise AkahuError.new("Akahu user token is required", :configuration_error) if @user_token.blank? + end + + def get_me + payload = get("me") + payload[:item] || payload + end + + def get_accounts + payload = get("accounts") + payload[:items] || [] + end + + def get_account(account_id) + payload = get("accounts/#{ERB::Util.url_encode(account_id.to_s)}") + payload[:item] || payload + end + + def get_transactions(start_date: nil, end_date: nil) + fetch_all("transactions", start_date: start_date, end_date: end_date) + end + + def get_account_transactions(account_id:, start_date: nil, end_date: nil) + fetch_all( + "accounts/#{ERB::Util.url_encode(account_id.to_s)}/transactions", + start_date: start_date, + end_date: end_date + ) + end + + def get_pending_transactions + payload = get("transactions/pending") + payload[:items] || [] + end + + def refresh(account_id: nil) + path = account_id.present? ? "refresh/#{ERB::Util.url_encode(account_id.to_s)}" : "refresh" + post(path) + end + + private + + RETRYABLE_ERRORS = [ + SocketError, + Net::OpenTimeout, + Net::ReadTimeout, + Errno::ECONNRESET, + Errno::ECONNREFUSED, + Errno::ETIMEDOUT, + EOFError + ].freeze + + MAX_RETRIES = 3 + INITIAL_RETRY_DELAY = 2 + + def fetch_all(path, start_date: nil, end_date: nil) + query = date_query(start_date: start_date, end_date: end_date) + cursor = nil + results = [] + + loop do + page_query = query.dup + page_query[:cursor] = cursor if cursor.present? + payload = get(path, query: page_query) + results.concat(Array(payload[:items])) + cursor = payload.dig(:cursor, :next) + break if cursor.blank? + end + + results + end + + def date_query(start_date:, end_date:) + query = {} + query[:start] = format_api_time(start_date) if start_date.present? + query[:end] = format_api_time(end_date) if end_date.present? + query + end + + def format_api_time(value) + return Time.utc(value.year, value.month, value.day).iso8601(3) if value.is_a?(Date) && !value.is_a?(DateTime) + + value.to_time.utc.iso8601(3) + end + + def get(path, query: {}) + with_retries("GET #{path}") do + response = self.class.get(endpoint_url(path), headers: auth_headers, query: query.presence) + handle_response(response) + end + end + + def post(path) + with_retries("POST #{path}") do + response = self.class.post(endpoint_url(path), headers: auth_headers) + handle_response(response) + end + end + + def endpoint_url(path) + "#{DEFAULT_BASE_URL}/#{path}" + end + + def auth_headers + { + "Authorization" => "Bearer #{user_token}", + "X-Akahu-Id" => app_token, + "Accept" => "application/json" + } + end + + 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( + "Akahu API: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \ + "#{e.class}: #{e.message}. Retrying in #{delay}s..." + ) + Kernel.sleep(delay) + retry + end + + Rails.logger.error("Akahu API: #{operation_name} failed after #{max_retries} retries: #{e.class}: #{e.message}") + raise AkahuError.new("Network error after #{max_retries} retries: #{e.message}", :network_error) + end + end + + 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 + + def handle_response(response) + case response.code + when 200, 201 + parse_response_body(response) + when 204 + {} + when 400 + raise AkahuError.new("Bad request to Akahu API (#{response_diagnostics(response)})", :bad_request) + when 401 + raise AkahuError.new("Invalid Akahu user token", :unauthorized) + when 403 + raise AkahuError.new("Akahu access forbidden - check app token and permissions", :access_forbidden) + when 404 + raise AkahuError.new("Akahu resource not found", :not_found) + when 429 + raise AkahuError.new("Akahu rate limit exceeded. Please try again later.", :rate_limited) + when 500..599 + raise AkahuError.new("Akahu server error (#{response.code}). Please try again later.", :server_error) + else + Rails.logger.error "Akahu API: Unexpected response status=#{response.code}" + raise AkahuError.new("Failed to fetch Akahu data", :fetch_failed) + end + end + + def response_diagnostics(response) + "status=#{response.code}" + end + + def parse_response_body(response) + return {} if response.body.blank? + + JSON.parse(response.body, symbolize_names: true) + rescue JSON::ParserError => e + Rails.logger.error "Akahu API: Failed to parse response: #{e.class}" + raise AkahuError.new("Failed to parse Akahu API response", :parse_error) + end + + class AkahuError < StandardError + attr_reader :error_type + + def initialize(message, error_type = :unknown) + super(message) + @error_type = error_type + end + end +end diff --git a/app/models/provider/akahu_adapter.rb b/app/models/provider/akahu_adapter.rb new file mode 100644 index 000000000..7855a51c6 --- /dev/null +++ b/app/models/provider/akahu_adapter.rb @@ -0,0 +1,105 @@ +class Provider::AkahuAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + Provider::Factory.register("AkahuAccount", self) + + def self.supported_account_types + %w[Depository CreditCard Loan Investment] + end + + def self.connection_configs(family:) + return [] unless family.can_connect_akahu? + + family.akahu_items.active.ordered.select(&:credentials_configured?).map do |akahu_item| + connection_config_for(akahu_item) + end + end + + def self.build_provider(family: nil, akahu_item_id: nil) + return nil unless family.present? + + akahu_item = resolve_akahu_item(family, akahu_item_id) + return nil unless akahu_item&.credentials_configured? + + Provider::Akahu.new( + app_token: akahu_item.app_token, + user_token: akahu_item.user_token + ) + end + + def self.connection_config_for(akahu_item) + path_params = ->(extra = {}) { extra.merge(akahu_item_id: akahu_item.id) } + + { + key: "akahu_#{akahu_item.id}", + name: akahu_item.name.presence || I18n.t("providers.akahu.name"), + description: I18n.t("providers.akahu.description"), + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_akahu_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_akahu_items_path( + path_params.call(account_id: account_id) + ) + } + } + end + private_class_method :connection_config_for + + def provider_name + "akahu" + end + + def sync_path + Rails.application.routes.url_helpers.sync_akahu_item_path(item) + end + + def item + provider_account.akahu_item + end + + def can_delete_holdings? + false + end + + def institution_domain + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["domain"] + end + + def institution_name + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["name"] || item&.institution_name + end + + def institution_url + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["url"] || item&.institution_url + end + + def institution_color + item&.institution_color + end + + def self.resolve_akahu_item(family, akahu_item_id) + if akahu_item_id.present? + item = family.akahu_items.active.find_by(id: akahu_item_id) + return item if item&.credentials_configured? + + return nil + end + + family.akahu_items.active.ordered.find(&:credentials_configured?) + end + private_class_method :resolve_akahu_item +end diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb index 4c8263b8a..805e84397 100644 --- a/app/models/provider/metadata.rb +++ b/app/models/provider/metadata.rb @@ -1,21 +1,22 @@ class Provider module Metadata REGISTRY = { - simplefin: { region: "US", kind: "Bank", maturity: :stable, logo_text: "SF", logo_bg: "bg-blue-600" }, - lunchflow: { region: "US", kind: "Bank", maturity: :stable, logo_text: "LF", logo_bg: "bg-orange-500" }, - enable_banking: { region: "EU", kind: "Bank", maturity: :beta, logo_text: "EB", logo_bg: "bg-purple-600" }, - coinstats: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CS", logo_bg: "bg-pink-600" }, - mercury: { region: "US", kind: "Bank", maturity: :beta, logo_text: "ME", logo_bg: "bg-cyan-600" }, - brex: { region: "US", kind: "Bank", maturity: :beta, logo_text: "BX", logo_bg: "bg-emerald-600" }, - coinbase: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CB", logo_bg: "bg-blue-500" }, - binance: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "BI", logo_bg: "bg-yellow-600" }, - kraken: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "KR", logo_bg: "bg-violet-600" }, - snaptrade: { region: "US / CA", kind: "Investment", maturity: :beta, logo_text: "ST", logo_bg: "bg-green-600" }, - ibkr: { region: "Global", kind: "Investment", maturity: :beta, logo_text: "IB", logo_bg: "bg-red-600" }, - indexa_capital: { region: "ES", kind: "Investment", maturity: :alpha, logo_text: "IC", logo_bg: "bg-red-600" }, - sophtron: { region: "US", kind: "Bank", maturity: :alpha, logo_text: "SO", logo_bg: "bg-teal-600" }, - plaid: { region: "US", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" }, - plaid_eu: { name: "Plaid EU", region: "EU", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" } + 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" }, + 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" }, + brex: { region: "US", kinds: %w[Bank], maturity: :beta, logo_text: "BX", logo_bg: "bg-emerald-600" }, + coinbase: { region: "Global", kinds: %w[Crypto], maturity: :beta, logo_text: "CB", logo_bg: "bg-blue-500" }, + binance: { region: "Global", kinds: %w[Crypto], maturity: :beta, logo_text: "BI", logo_bg: "bg-yellow-600" }, + kraken: { region: "Global", kinds: %w[Crypto], maturity: :beta, logo_text: "KR", logo_bg: "bg-violet-600" }, + snaptrade: { region: "US / CA", kinds: %w[Investment], maturity: :beta, logo_text: "ST", logo_bg: "bg-green-600" }, + ibkr: { region: "Global", kinds: %w[Investment], maturity: :beta, logo_text: "IB", logo_bg: "bg-red-600" }, + indexa_capital: { region: "ES", kinds: %w[Investment], maturity: :alpha, logo_text: "IC", logo_bg: "bg-red-600" }, + sophtron: { region: "US", kinds: %w[Bank Investment], maturity: :alpha, logo_text: "SO", logo_bg: "bg-teal-600" }, + plaid: { region: "US", kinds: %w[Bank], maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600", tier: "Paid" }, + plaid_eu: { region: "EU", kinds: %w[Bank], maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600", tier: "Paid", name: "Plaid EU" } }.freeze def self.for(provider_key) diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index 0563d6f77..0c3720323 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -2,6 +2,7 @@ class ProviderConnectionStatus PROVIDERS = [ + { key: "akahu", type: "AkahuItem", association: :akahu_items, accounts: :akahu_accounts }, { key: "plaid", type: "PlaidItem", association: :plaid_items, accounts: :plaid_accounts }, { key: "simplefin", type: "SimplefinItem", association: :simplefin_items, accounts: :simplefin_accounts }, { key: "lunchflow", type: "LunchflowItem", association: :lunchflow_items, accounts: :lunchflow_accounts }, diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index fbfba7735..261a1e660 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -1,5 +1,5 @@ class ProviderMerchant < Merchant - enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", 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", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", brex: "brex", indexa_capital: "indexa_capital", sophtron: "sophtron" } validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 0334298d1..e564e3ea1 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -94,7 +94,7 @@ class Transaction < ApplicationRecord INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].freeze # Providers that support pending transaction flags - PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking].freeze + PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking akahu].freeze # Pre-computed SQL fragment for subqueries that check if a transaction (aliased as "t") is pending. # Stored as a constant so static analysis can verify it contains no user input. diff --git a/app/models/user.rb b/app/models/user.rb index e585d9d56..cf1c257c1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,6 +56,7 @@ class User < ApplicationRecord normalizes :first_name, :last_name, with: ->(value) { value.strip.presence } enum :role, { guest: "guest", member: "member", admin: "admin", super_admin: "super_admin" }, validate: true + attribute :ui_layout, :string enum :ui_layout, { dashboard: "dashboard", intro: "intro" }, validate: true, prefix: true before_validation :apply_ui_layout_defaults diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index e8f4bee52..609d757eb 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -17,7 +17,7 @@ ) %> <% end %> -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @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? && @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 %>
@@ -33,6 +33,10 @@ <%= render @lunchflow_items.sort_by(&:created_at) %> <% end %> + <% if @akahu_items.any? %> + <%= render @akahu_items.sort_by(&:created_at) %> + <% end %> + <% if @enable_banking_items.any? %> <%= render @enable_banking_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/akahu_items/_akahu_item.html.erb b/app/views/akahu_items/_akahu_item.html.erb new file mode 100644 index 000000000..7f2c36d40 --- /dev/null +++ b/app/views/akahu_items/_akahu_item.html.erb @@ -0,0 +1,114 @@ +<%# locals: (akahu_item:) %> + +<%= tag.div id: dom_id(akahu_item) do %> + <%= render DS::Disclosure.new(variant: :card, open: true) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+ <%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> + +
+

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

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

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

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

<%= akahu_item.institution_summary %>

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

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

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

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

+

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

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

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

+

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

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

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

+ <% else %> +

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

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

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

+ <% else %> +

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

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

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

+

<%= @api_error %>

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

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

+

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

+
+ <% else %> +
+

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

+

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

+
+ + <% @akahu_accounts.each do |akahu_account| %> +
+
+

<%= akahu_account.name %>

+

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

+
+ + <%= label_tag "account_types[#{akahu_account.id}]", t(".account_type_label"), + class: "block text-sm font-medium text-primary mb-2" %> + <%= select_tag "account_types[#{akahu_account.id}]", + options_for_select(@account_type_options, @akahu_account_type_suggestions.fetch(akahu_account.id, "skip")), + class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full" %> +
+ <% end %> + <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".create_accounts"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + disabled: @api_error.present? || @akahu_accounts.empty? + ) %> + <%= render DS::Link.new( + text: t(".cancel"), + variant: "secondary", + href: accounts_path + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/settings/providers/_akahu_panel.html.erb b/app/views/settings/providers/_akahu_panel.html.erb new file mode 100644 index 000000000..216a31806 --- /dev/null +++ b/app/views/settings/providers/_akahu_panel.html.erb @@ -0,0 +1,135 @@ +
+ <% active_items = local_assigns[:akahu_items] || @akahu_items || Current.family.akahu_items.active.ordered %> + + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.akahu_panel.step_1_html", link: link_to(t("providers.akahu.name"), "https://my.akahu.nz/developers", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline")), + t("settings.providers.akahu_panel.step_2"), + t("settings.providers.akahu_panel.step_3") + ] %> + + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> + <%= render DS::Alert.new(message: error_msg, variant: :error) %> + <% end %> + + <% if active_items.any? %> +
+ <% active_items.each do |item| %> + <%= render DS::Disclosure.new(variant: :card) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+
+

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

+
+
+

<%= item.name %>

+

<%= item.sync_status_summary %>

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

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

+ <% end %> + + <%= styled_form_with model: akahu_item, + url: akahu_items_path, + scope: :akahu_item, + method: :post, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :name, + label: t("akahu_items.provider_panel.connection_name_label"), + placeholder: t("akahu_items.provider_panel.connection_name_placeholder") %> + + <%= form.text_field :app_token, + label: t("akahu_items.provider_panel.app_token_label"), + placeholder: t("akahu_items.provider_panel.app_token_placeholder"), + type: :password, + value: nil %> + + <%= form.text_field :user_token, + label: t("akahu_items.provider_panel.user_token_label"), + placeholder: t("akahu_items.provider_panel.user_token_placeholder"), + type: :password, + value: nil %> + +
+ <%= render DS::Button.new( + text: t("akahu_items.provider_panel.add_connection"), + type: "submit" + ) %> +
+ <% end %> +
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 9108eabcd..83ca18b71 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -12,15 +12,14 @@

<%= t("settings.providers.bank_sync.lede") %>

<% if @connected.any? || @needs_attention.any? %> <% sync_all_disabled = Current.family.last_sync_all_attempted_at.present? && Current.family.last_sync_all_attempted_at > 30.seconds.ago %> - <%= render DS::Link.new( + <%= render DS::Button.new( text: t("settings.providers.sync_all"), icon: "refresh-cw", variant: "outline", href: sync_all_settings_providers_path, method: :post, title: sync_all_disabled ? t("settings.providers.sync_all_recently") : nil, - aria: { disabled: sync_all_disabled.to_s }, - class: sync_all_disabled ? "opacity-50 pointer-events-none" : nil + disabled: sync_all_disabled ) %> <% end %>
@@ -76,7 +75,7 @@ name: entry[:title], tagline: t("settings.providers.taglines.#{entry[:provider_key]}", default: nil), region: meta[:region], - kind: meta[:kind], + kinds: meta[:kinds], tier: meta[:tier], maturity: meta[:maturity] || :stable, logo_bg: meta[:logo_bg], diff --git a/config/locales/views/akahu_items/en.yml b/config/locales/views/akahu_items/en.yml new file mode 100644 index 000000000..4872708ed --- /dev/null +++ b/config/locales/views/akahu_items/en.yml @@ -0,0 +1,127 @@ +--- +en: + providers: + akahu: + name: Akahu + description: Connect New Zealand bank accounts via Akahu + family: + akahu: + create_akahu_item: + default_name: Akahu Connection + akahu_account: + fallback: Akahu account + akahu_entry: + notes: + reference: Reference + particulars: Particulars + code: Code + other_account: Other account + akahu_item: + errors: + account_processing_failed: Could not sync Akahu account + account_sync_schedule_failed: Could not schedule Akahu account sync + pending_transactions_failed: Could not fetch Akahu pending transactions + transactions_failed: Could not fetch Akahu transactions + sync_failed: Could not sync Akahu 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" + akahu_items: + provider_panel: + default_connection_name: Akahu Connection + add_connection: Add Akahu connection + update_connection: Update connection + connection_name_label: Connection name + connection_name_placeholder: Main Akahu + app_token_label: App Token + app_token_placeholder: Paste your Akahu app token + keep_app_token_placeholder: Leave blank to keep the existing app token + user_token_label: User Token + user_token_placeholder: Paste your Akahu user token + keep_user_token_placeholder: Leave blank to keep the existing user token + setup_accounts: Setup accounts + syncing: Syncing... + sync: Sync + disconnect: Disconnect + disconnect_confirm: "Are you sure you want to disconnect %{name}?" + create: + success: Akahu connection saved. + update: + success: Akahu connection updated. + destroy: + success: Scheduled Akahu connection for deletion. + unlink_failed: Could not disconnect Akahu connection + select_accounts: + title: Link Akahu accounts + description: Choose the Akahu accounts to add. + no_accounts_found: No unlinked Akahu accounts were found. + no_credentials_configured: Configure Akahu in provider settings first. + cancel: Cancel + link_accounts: Link accounts + link_accounts: + success: + one: Linked 1 Akahu account. + other: "Linked %{count} Akahu accounts." + no_accounts_selected: Select at least one account. + no_credentials_configured: Configure Akahu in provider settings first. + unsupported_account_type: Akahu does not support that account type. + link_failed: No accounts were linked. + select_existing_account: + title: "Link Akahu account to %{account_name}" + description: Choose an unlinked Akahu account to connect to this account. + no_accounts_found: No unlinked Akahu accounts were found. + no_credentials_configured: Configure Akahu 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 Akahu account to %{account_name}." + account_already_linked: This account is already linked to a provider. + akahu_account_already_linked: This Akahu account is already linked. + setup_accounts: + title: Set Up Akahu Accounts + subtitle: Choose how each Akahu account should appear in Sure. + no_credentials: Configure Akahu credentials first. + api_error: Could not fetch Akahu accounts. + fetch_failed: Could not fetch accounts + no_accounts_to_setup: No accounts to set up + all_accounts_linked: All Akahu 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 + credit_card: Credit Card + investment: Investment + loan: Loan + create_accounts: Create accounts + cancel: Cancel + complete_account_setup: + success: + one: Created 1 Akahu account. + other: "Created %{count} Akahu accounts." + all_skipped: No Akahu accounts were created. + no_accounts: No Akahu accounts were selected. + creation_failed: Could not create Akahu accounts. + akahu_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 Akahu accounts and choose which ones to link. diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index f167d34c2..131994efa 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -315,6 +315,7 @@ en: sync_provider_in_progress: Sync started. recently_synced: Synced recently. Try again in a moment. taglines: + akahu: Sync New Zealand financial institutions via Akahu. simplefin: Connect US bank accounts via the open SimpleFIN protocol. lunchflow: Connect 20k+ banks from 40+ countries (UK, EU, USA and more!) enable_banking: Sync European bank accounts via PSD2 open banking. @@ -442,6 +443,10 @@ en: base_url_placeholder: "https://lunchflow.app/api/v1 (default)" save_and_connect: "Save and connect" update_connection: "Update connection" + akahu_panel: + 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." simplefin_panel: step_1_html: "Go to %{link} for a one-time setup token." step_2: "Paste the token below and connect." diff --git a/config/routes.rb b/config/routes.rb index 23886529c..fb4355deb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -623,6 +623,22 @@ Rails.application.routes.draw do end end + resources :akahu_items, only: %i[index new create show edit update destroy] do + collection do + get :preload_accounts + get :select_accounts + post :link_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + get :setup_accounts + post :complete_account_setup + end + end + resources :sophtron_items, only: %i[index new create show edit update destroy] do collection do get :preload_accounts diff --git a/db/migrate/20260522120000_create_akahu_items_and_accounts.rb b/db/migrate/20260522120000_create_akahu_items_and_accounts.rb new file mode 100644 index 000000000..622162444 --- /dev/null +++ b/db/migrate/20260522120000_create_akahu_items_and_accounts.rb @@ -0,0 +1,51 @@ +class CreateAkahuItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + create_table :akahu_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 :app_token + t.text :user_token + t.timestamps + end + + add_index :akahu_items, :status + + create_table :akahu_accounts, id: :uuid do |t| + t.references :akahu_item, null: false, foreign_key: true, type: :uuid + t.string :name + t.string :account_id + t.string :formatted_account + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + t.decimal :available_balance, precision: 19, scale: 4 + t.decimal :balance_limit, precision: 19, scale: 4 + t.string :account_status + t.string :account_type + t.string :provider + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + t.date :sync_start_date + + t.timestamps + end + + add_index :akahu_accounts, :account_id + add_index :akahu_accounts, + [ :akahu_item_id, :account_id ], + unique: true, + where: "account_id IS NOT NULL", + name: "index_akahu_accounts_on_item_and_account_id" + end +end diff --git a/db/migrate/20260522121000_remove_legacy_recurring_transactions_family_unique_index.rb b/db/migrate/20260522121000_remove_legacy_recurring_transactions_family_unique_index.rb new file mode 100644 index 000000000..cee9ba418 --- /dev/null +++ b/db/migrate/20260522121000_remove_legacy_recurring_transactions_family_unique_index.rb @@ -0,0 +1,7 @@ +class RemoveLegacyRecurringTransactionsFamilyUniqueIndex < ActiveRecord::Migration[7.2] + def change + remove_index :recurring_transactions, + name: "idx_recurring_txns_on_family_merchant_amount_currency", + if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 88fa3aae9..ddaa60b71 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -42,7 +42,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.index ["account_id"], name: "index_account_shares_on_account_id" t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances" t.index ["user_id"], name: "index_account_shares_on_user_id" - t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission" + t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying, 'read_write'::character varying, 'read_only'::character varying]::text[])", name: "chk_account_shares_permission" end create_table "account_statements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -91,9 +91,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.check_constraint "match_confidence IS NULL OR match_confidence >= 0::numeric AND match_confidence <= 1::numeric", name: "chk_account_statements_match_confidence" t.check_constraint "parser_confidence IS NULL OR parser_confidence >= 0::numeric AND parser_confidence <= 1::numeric", name: "chk_account_statements_parser_confidence" t.check_constraint "period_start_on IS NULL OR period_end_on IS NULL OR period_start_on <= period_end_on", name: "chk_account_statements_period_order" - t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying::text, 'linked'::character varying::text, 'rejected'::character varying::text])", name: "chk_account_statements_review_status" + t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying, 'linked'::character varying, 'rejected'::character varying]::text[])", name: "chk_account_statements_review_status" t.check_constraint "source::text = 'manual_upload'::text", name: "chk_account_statements_source" - t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying::text, 'failed'::character varying::text])", name: "chk_account_statements_upload_status" + t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying, 'failed'::character varying]::text[])", name: "chk_account_statements_upload_status" end create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -106,7 +106,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.uuid "accountable_id" t.decimal "balance", precision: 19, scale: 4 t.string "currency" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" t.uuid "plaid_account_id" t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" @@ -175,6 +175,51 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable" end + create_table "akahu_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "akahu_item_id", null: false + t.string "name" + t.string "account_id" + t.string "formatted_account" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.decimal "available_balance", precision: 19, scale: 4 + t.decimal "balance_limit", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "provider" + 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_akahu_accounts_on_account_id" + t.index ["akahu_item_id", "account_id"], name: "index_akahu_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" + t.index ["akahu_item_id"], name: "index_akahu_accounts_on_akahu_item_id" + end + + create_table "akahu_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 "app_token" + t.text "user_token" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_akahu_items_on_family_id" + t.index ["status"], name: "index_akahu_items_on_status" + end + create_table "api_keys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name" t.uuid "user_id", null: false @@ -342,8 +387,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "parent_id" - t.string "lucide_icon", default: "shapes", null: false t.string "classification_unused", default: "expense", null: false + t.string "lucide_icon", default: "shapes", null: false t.index ["family_id"], name: "index_categories_on_family_id" end @@ -500,7 +545,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.index ["provider_key"], name: "index_debug_log_entries_on_provider_key" t.index ["source"], name: "index_debug_log_entries_on_source" t.index ["user_id"], name: "index_debug_log_entries_on_user_id" - t.check_constraint "level::text = ANY (ARRAY['debug'::character varying::text, 'info'::character varying::text, 'warn'::character varying::text, 'error'::character varying::text])", name: "chk_debug_log_entries_level" + t.check_constraint "level::text = ANY (ARRAY['debug'::character varying, 'info'::character varying, 'warn'::character varying, 'error'::character varying]::text[])", name: "chk_debug_log_entries_level" end create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -714,13 +759,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } t.boolean "recurring_transactions_disabled", default: false, null: false t.integer "month_start_day", default: 1, null: false - t.string "vector_store_id" t.string "moniker", default: "Family", null: false + t.string "vector_store_id" t.string "assistant_type", default: "builtin", null: false t.string "default_account_sharing", default: "shared", null: false t.string "enabled_currencies", array: true t.datetime "last_sync_all_attempted_at" - t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" + t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, 'private'::character varying]::text[])", name: "chk_families_default_account_sharing" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end @@ -920,11 +965,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.text "notes" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "exchange_operating_mic" t.string "category_parent" t.string "category_color" t.string "category_classification" t.string "category_icon" + t.string "exchange_operating_mic" t.string "resource_type" t.boolean "active" t.string "effective_date" @@ -966,15 +1011,15 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.string "exchange_operating_mic_col_label" t.string "amount_type_strategy", default: "signed_amount" t.string "amount_type_inflow_value" - t.integer "rows_to_skip", default: 0, null: false t.integer "rows_count", default: 0, null: false t.string "amount_type_identifier_value" + t.integer "rows_to_skip", default: 0, null: false t.text "ai_summary" t.string "document_type" t.jsonb "extracted_data" + t.uuid "account_statement_id" t.jsonb "expected_record_counts", default: {}, null: false t.jsonb "readback_verification", default: {}, null: false - t.uuid "account_statement_id" t.index ["account_statement_id"], name: "index_imports_on_account_statement_id" t.index ["family_id"], name: "index_imports_on_family_id" end @@ -1510,7 +1555,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.index ["kind"], name: "index_securities_on_kind" t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason" t.index ["price_provider"], name: "index_securities_on_price_provider" - t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying::text, 'cash'::character varying::text])", name: "chk_securities_kind" + t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind" end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -1615,9 +1660,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.jsonb "raw_activities_payload", default: [] t.datetime "last_holdings_sync" t.datetime "last_activities_sync" + t.boolean "activities_fetch_pending", default: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.boolean "activities_fetch_pending", default: false t.date "sync_start_date" t.jsonb "raw_balances_payload", default: [] t.index ["snaptrade_item_id", "snaptrade_account_id"], name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", unique: true, where: "(snaptrade_account_id IS NOT NULL)" @@ -1665,9 +1710,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.jsonb "raw_transactions_payload" t.string "customer_id" t.string "member_id" - t.string "account_number_mask" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "account_number_mask" t.boolean "manual_sync", default: false, null: false t.index ["account_id"], name: "index_sophtron_accounts_on_account_id" t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true @@ -1691,6 +1736,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.string "user_id", null: false t.string "access_key", null: false t.string "base_url" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "customer_id" t.string "customer_name" t.jsonb "raw_customer_payload" @@ -1699,8 +1746,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.string "job_status" t.jsonb "raw_job_payload" t.text "last_connection_error" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.boolean "manual_sync", default: false, null: false t.uuid "current_job_sophtron_account_id" t.index ["current_job_sophtron_account_id"], name: "index_sophtron_items_on_current_job_sophtron_account_id" @@ -1885,9 +1930,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.datetime "set_onboarding_preferences_at" t.datetime "set_onboarding_goals_at" t.string "default_account_order", default: "name_asc" + t.string "ui_layout" t.jsonb "preferences", default: {}, null: false t.string "locale" - t.string "ui_layout" t.uuid "default_account_id" t.string "webauthn_id" t.index ["default_account_id"], name: "index_users_on_default_account_id" @@ -1947,6 +1992,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do add_foreign_key "accounts", "users", column: "owner_id", on_delete: :nullify add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "akahu_accounts", "akahu_items" + add_foreign_key "akahu_items", "families" add_foreign_key "api_keys", "users" add_foreign_key "balances", "accounts", on_delete: :cascade add_foreign_key "binance_accounts", "binance_items" diff --git a/test/components/settings/provider_card_test.rb b/test/components/settings/provider_card_test.rb new file mode 100644 index 000000000..33df478a9 --- /dev/null +++ b/test/components/settings/provider_card_test.rb @@ -0,0 +1,36 @@ +require "test_helper" + +class Settings::ProviderCardTest < ActiveSupport::TestCase + test "metadata line displays multiple kinds" do + card = Settings::ProviderCard.new( + provider_key: "example", + name: "Example", + region: "US", + kinds: %w[Bank Investment], + tier: "Paid" + ) + + assert_equal "US · Bank / Investment · Paid", card.meta_line + end + + test "filter data includes all kinds as searchable tokens" do + card = Settings::ProviderCard.new( + provider_key: "example", + name: "Example", + kinds: %w[Bank Investment] + ) + + assert_equal "bank investment", card.filter_data[:provider_kind] + end + + test "metadata line displays a single kind" do + card = Settings::ProviderCard.new( + provider_key: "example", + name: "Example", + kinds: %w[Crypto] + ) + + assert_equal "Crypto", card.meta_line + assert_equal "crypto", card.filter_data[:provider_kind] + end +end diff --git a/test/controllers/akahu_items_controller_test.rb b/test/controllers/akahu_items_controller_test.rb new file mode 100644 index 000000000..cd10280a8 --- /dev/null +++ b/test/controllers/akahu_items_controller_test.rb @@ -0,0 +1,290 @@ +require "test_helper" + +class AkahuItemsControllerTest < ActionDispatch::IntegrationTest + setup do + ensure_tailwind_build + sign_in users(:family_admin) + SyncJob.stubs(:perform_later) + + @family = families(:dylan_family) + @akahu_item = AkahuItem.create!( + family: @family, + name: "Main Akahu", + app_token: "akahu-app-credential", + user_token: "akahu-user-credential" + ) + @akahu_account = @akahu_item.akahu_accounts.create!( + name: "Akahu Checking", + account_id: "acc_123", + currency: "NZD" + ) + @account = accounts(:depository) + end + + test "setup_accounts preselects mapped account type for each account" do + AkahuItemsController.any_instance.stubs(:fetch_akahu_accounts_from_api).returns(nil) + + @akahu_account.update!(account_type: "SAVINGS") + get setup_accounts_akahu_item_url(@akahu_item) + assert_response :success + + selected_option = css_select("select[name='account_types[#{@akahu_account.id}]'] option[selected='selected']").first + assert_equal "Depository", selected_option["value"] + + @akahu_account.update!(account_type: "FOREIGN") + get setup_accounts_akahu_item_url(@akahu_item) + assert_response :success + selected_option = css_select("select[name='account_types[#{@akahu_account.id}]'] option[selected='selected']").first + assert_equal "skip", selected_option["value"] + end + + test "complete_account_setup uses Akahu account type suggestion subtype for investment accounts" do + @akahu_account.update!(account_type: "KIWISAVER") + + assert_difference "Account.count", 1 do + assert_difference "AccountProvider.count", 1 do + post complete_account_setup_akahu_item_url(@akahu_item), params: { + account_types: { @akahu_account.id.to_s => "Investment" } + } + end + end + + assert_redirected_to accounts_path + @akahu_account.reload + created_account = @akahu_account.current_account + assert_not_nil created_account + assert_equal "Investment", created_account.accountable_type + assert_equal "retirement", created_account.accountable.subtype + end + + test "select accounts rejects unsafe return paths" do + AkahuItemsController.any_instance.stubs(:fetch_akahu_accounts_from_api).returns(nil) + + unsafe_return_paths.each do |return_to| + get select_accounts_akahu_items_url, params: { + akahu_item_id: @akahu_item.id, + accountable_type: "Depository", + return_to: return_to + } + + assert_response :success + assert_select %(input[name="return_to"]) do |fields| + assert fields.first["value"].blank? + end + end + end + + test "select existing account rejects unsafe return paths" do + AkahuItemsController.any_instance.stubs(:fetch_akahu_accounts_from_api).returns(nil) + + unsafe_return_paths.each do |return_to| + get select_existing_account_akahu_items_url, params: { + account_id: @account.id, + akahu_item_id: @akahu_item.id, + return_to: return_to + } + + assert_response :success + assert_select %(input[name="return_to"]) do |fields| + assert fields.first["value"].blank? + end + end + end + + test "select existing account preserves safe local return path" do + return_to = "/accounts?tab=manual" + AkahuItemsController.any_instance.stubs(:fetch_akahu_accounts_from_api).returns(nil) + + get select_existing_account_akahu_items_url, params: { + account_id: @account.id, + akahu_item_id: @akahu_item.id, + return_to: return_to + } + + assert_response :success + assert_select %(input[name="return_to"][value="#{return_to}"]) + end + + test "link accounts rejects unsafe return path on no selection redirect" do + post link_accounts_akahu_items_url, params: { + akahu_item_id: @akahu_item.id, + accountable_type: "Depository", + return_to: "https://evil.example/accounts" + } + + assert_redirected_to select_accounts_akahu_items_path( + akahu_item_id: @akahu_item.id, + accountable_type: "Depository", + return_to: nil + ) + end + + test "link accounts rejects unsafe return path after linking" do + @akahu_account.update!(current_balance: 10) + + assert_difference "AccountProvider.count", 1 do + post link_accounts_akahu_items_url, params: { + akahu_item_id: @akahu_item.id, + account_ids: [ @akahu_account.id ], + accountable_type: "Depository", + return_to: "https://evil.example/accounts" + } + end + + assert_redirected_to accounts_path + end + + test "link existing account rejects unsafe return paths" do + unsafe_return_paths.each_with_index do |return_to, index| + account = @family.accounts.create!( + name: "Manual Checking #{index}", + balance: 0, + currency: "NZD", + accountable: Depository.new + ) + akahu_account = @akahu_item.akahu_accounts.create!( + name: "Akahu Checking #{index}", + account_id: "acc_unsafe_#{index}", + currency: "NZD" + ) + + assert_difference "AccountProvider.count", 1 do + post link_existing_account_akahu_items_url, params: { + account_id: account.id, + akahu_item_id: @akahu_item.id, + akahu_account_id: akahu_account.id, + return_to: return_to + } + end + + assert_redirected_to accounts_path + end + end + + test "preload accounts uses requested Akahu connection" do + second_item = AkahuItem.create!( + family: @family, + name: "Secondary Akahu", + app_token: "akahu-app-credential-2", + user_token: "akahu-user-credential-2" + ) + + AkahuItemsController.any_instance + .expects(:fetch_akahu_accounts_from_api) + .with(second_item) + .returns(nil) + + get preload_accounts_akahu_items_url, params: { akahu_item_id: second_item.id }, as: :json + + assert_response :success + assert_equal false, JSON.parse(response.body)["has_accounts"] + end + + test "select accounts uses requested Akahu connection" do + second_item = AkahuItem.create!( + family: @family, + name: "Secondary Akahu", + app_token: "akahu-app-credential-2", + user_token: "akahu-user-credential-2" + ) + second_item.akahu_accounts.create!( + name: "Secondary Checking", + account_id: "acc_secondary", + currency: "NZD" + ) + AkahuItemsController.any_instance.stubs(:fetch_akahu_accounts_from_api).returns(nil) + + get select_accounts_akahu_items_url, params: { akahu_item_id: second_item.id, accountable_type: "Depository" } + + assert_response :success + assert_includes response.body, "Secondary Checking" + refute_includes response.body, "Akahu Checking" + end + + test "link accounts uses requested Akahu connection" do + second_item = AkahuItem.create!( + family: @family, + name: "Secondary Akahu", + app_token: "akahu-app-credential-2", + user_token: "akahu-user-credential-2" + ) + second_account = second_item.akahu_accounts.create!( + name: "Secondary Checking", + account_id: "acc_secondary", + currency: "NZD", + current_balance: 42 + ) + + assert_difference "Account.count", 1 do + assert_difference "AccountProvider.count", 1 do + post link_accounts_akahu_items_url, params: { + akahu_item_id: second_item.id, + account_ids: [ second_account.id ], + accountable_type: "Depository" + } + end + end + + assert_redirected_to accounts_path + assert_equal second_account.id, AccountProvider.order(:created_at).last.provider_id + assert_nil @akahu_account.reload.current_account + end + + test "select existing account uses requested Akahu connection" do + second_item = AkahuItem.create!( + family: @family, + name: "Secondary Akahu", + app_token: "akahu-app-credential-2", + user_token: "akahu-user-credential-2" + ) + second_item.akahu_accounts.create!( + name: "Secondary Checking", + account_id: "acc_secondary", + currency: "NZD" + ) + AkahuItemsController.any_instance.stubs(:fetch_akahu_accounts_from_api).returns(nil) + + get select_existing_account_akahu_items_url, params: { + account_id: @account.id, + akahu_item_id: second_item.id + } + + assert_response :success + assert_includes response.body, "Secondary Checking" + refute_includes response.body, "Akahu Checking" + end + + test "complete account setup hides raw creation errors from users" do + raw_message = "raw provider failure with akahu-user-credential" + AkahuItemsController.any_instance + .stubs(:create_account_from_akahu) + .raises(ActiveRecord::RecordNotSaved.new(raw_message)) + + post complete_account_setup_akahu_item_url(@akahu_item), params: { + account_types: { @akahu_account.id.to_s => "Depository" } + } + + assert_redirected_to accounts_path + assert_equal I18n.t("akahu_items.complete_account_setup.creation_failed"), flash[:alert] + refute_includes flash[:alert], raw_message + end + + private + + def unsafe_return_paths + [ + "https://evil.example/accounts", + "http://evil.example/accounts", + "//evil.example/accounts", + "\\evil.example/accounts", + "/\\evil.example/accounts", + "/%2fevil.example/accounts", + "/%2Fevil.example/accounts", + "/%5cevil.example/accounts", + "/%5Cevil.example/accounts", + "/\naccounts", + "/ accounts", + " " + ] + end +end diff --git a/test/controllers/settings/providers_controller_test.rb b/test/controllers/settings/providers_controller_test.rb index 0f9bce42e..5137b3b57 100644 --- a/test/controllers/settings/providers_controller_test.rb +++ b/test/controllers/settings/providers_controller_test.rb @@ -4,6 +4,7 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest include ActiveJob::TestHelper setup do + ensure_tailwind_build sign_in users(:family_admin) # Ensure provider adapters are loaded for all tests @@ -53,6 +54,20 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest refute_includes response.body, "Test Brex Connection" end + test "sync all control submits with POST" do + SimplefinItem.create!( + family: families(:dylan_family), + name: "Test SimpleFIN Sync All Control", + access_url: "https://bridge.simplefin.org/simplefin/access" + ) + + with_self_hosting do + get settings_providers_url + assert_response :success + assert_select "form[action=?][method=?]", sync_all_settings_providers_path, "post" + end + end + test "correctly identifies declared vs dynamic fields" do # All current provider fields are dynamic, but the logic should correctly # distinguish between declared and dynamic fields diff --git a/test/models/akahu_account/processor_test.rb b/test/models/akahu_account/processor_test.rb new file mode 100644 index 000000000..456ef3578 --- /dev/null +++ b/test/models/akahu_account/processor_test.rb @@ -0,0 +1,138 @@ +require "test_helper" + +class AkahuAccount::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @akahu_item = AkahuItem.create!( + family: @family, + name: "Test Akahu", + app_token: "akahu-app-credential", + user_token: "akahu-user-credential" + ) + @akahu_account = AkahuAccount.create!( + akahu_item: @akahu_item, + name: "Test Invest - Portfolio", + account_id: "investment_123", + currency: "NZD", + current_balance: 12_345.67 + ) + @account = Account.create!( + family: @family, + name: "Portfolio", + accountable: Investment.new, + balance: 0, + cash_balance: 999, + currency: "NZD" + ) + + AccountProvider.create!(account: @account, provider: @akahu_account) + end + + test "updates investment account balance without treating portfolio value as cash" do + AkahuAccount::Processor.new(@akahu_account).process + + @account.reload + assert_equal BigDecimal("12345.67"), @account.balance + assert_equal BigDecimal("0"), @account.cash_balance + assert_equal "NZD", @account.currency + end + + test "logs account processing failures without raw exception message" do + sensitive_message = "provider returned account holder details" + error = RuntimeError.new(sensitive_message) + + @akahu_account.stubs(:current_account).returns(@account) + @account.stubs(:update!).raises(error) + scope = RecordingSentryScope.new + Sentry.expects(:capture_exception).with do |captured_error| + captured_error.is_a?(AkahuAccount::Processor::SanitizedProcessingError) && + !captured_error.equal?(error) && + captured_error.cause.nil? && + captured_error.message == "Akahu account processing failed" && + !captured_error.message.include?(sensitive_message) + end.yields(scope).once + Rails.logger.expects(:error).with do |message| + message.include?("akahu_account_id=#{@akahu_account.id}") && + message.include?("error_class=RuntimeError") && + !message.include?(sensitive_message) + end.once + + assert_raises(RuntimeError) do + AkahuAccount::Processor.new(@akahu_account).process + end + + assert_equal( + { + akahu_account_id: @akahu_account.id, + context: "account", + error_class: "RuntimeError" + }, + scope.tags + ) + assert_equal( + { + akahu_account_id: @akahu_account.id, + context: "account", + error_class: "RuntimeError" + }, + scope.contexts["akahu_account_processor"] + ) + end + + test "logs transaction processing failures without raw exception message" do + sensitive_message = "provider returned account number 12-3456" + error = RuntimeError.new(sensitive_message) + + AkahuAccount::Transactions::Processor.any_instance.stubs(:process).raises(error) + scope = RecordingSentryScope.new + Sentry.expects(:capture_exception).with do |captured_error| + captured_error.is_a?(AkahuAccount::Processor::SanitizedProcessingError) && + !captured_error.equal?(error) && + captured_error.cause.nil? && + captured_error.message == "Akahu account processing failed" && + !captured_error.message.include?(sensitive_message) + end.yields(scope).once + Rails.logger.expects(:error).with do |message| + message.include?("akahu_account_id=#{@akahu_account.id}") && + message.include?("error_class=RuntimeError") && + !message.include?(sensitive_message) + end.once + + result = AkahuAccount::Processor.new(@akahu_account).process + + assert_equal false, result[:success] + assert_equal( + { + akahu_account_id: @akahu_account.id, + context: "transactions", + error_class: "RuntimeError" + }, + scope.tags + ) + assert_equal( + { + akahu_account_id: @akahu_account.id, + context: "transactions", + error_class: "RuntimeError" + }, + scope.contexts["akahu_account_processor"] + ) + end + + class RecordingSentryScope + attr_reader :tags, :contexts + + def initialize + @tags = {} + @contexts = {} + end + + def set_tags(tags) + @tags.merge!(tags) + end + + def set_context(name, context) + @contexts[name] = context + end + end +end diff --git a/test/models/akahu_account_test.rb b/test/models/akahu_account_test.rb new file mode 100644 index 000000000..267842f91 --- /dev/null +++ b/test/models/akahu_account_test.rb @@ -0,0 +1,100 @@ +require "test_helper" + +class AkahuAccountTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = AkahuItem.create!( + family: @family, + name: "Test Akahu", + app_token: "akahu-app-credential", + user_token: "akahu-user-credential" + ) + @account = AkahuAccount.create!( + akahu_item: @item, + name: "Test Account", + account_id: "acc_123", + currency: "NZD" + ) + end + + test "maps common Akahu account types to Sure accountable types" do + @account.update!(account_type: "CHECKING") + assert_equal "Depository", @account.suggested_account_type + assert_equal "checking", @account.suggested_subtype + + @account.update!(account_type: "SAVINGS") + assert_equal "Depository", @account.suggested_account_type + assert_equal "savings", @account.suggested_subtype + + @account.update!(account_type: "TERMDEPOSIT") + assert_equal "Depository", @account.suggested_account_type + assert_equal "cd", @account.suggested_subtype + + @account.update!(account_type: "CREDITCARD") + assert_equal "CreditCard", @account.suggested_account_type + assert_equal "credit_card", @account.suggested_subtype + end + + test "maps KIWISAVER and INVESTMENT to Investment" do + @account.update!(account_type: "KIWISAVER") + assert_equal "Investment", @account.suggested_account_type + assert_equal "retirement", @account.suggested_subtype + + @account.update!(account_type: "INVESTMENT") + assert_equal "Investment", @account.suggested_account_type + assert_nil @account.suggested_subtype + end + + test "returns skip when Akahu account type is unmapped" do + @account.update!(account_type: "WALLET") + assert_nil @account.suggested_account_type + assert_nil @account.suggested_subtype + end + + test "is case insensitive for mapping" do + @account.update!(account_type: "savings") + assert_equal "Depository", @account.suggested_account_type + assert_equal "savings", @account.suggested_subtype + end + + test "transaction processor hides raw exception messages from result errors" do + raw_message = "raw provider payload with sensitive-value" + AkahuAccount::Transactions::Processor.any_instance + .stubs(:process) + .raises(StandardError.new(raw_message)) + + AccountProvider.create!(account: accounts(:investment), provider: @account) + result = @item.process_accounts.first + + assert_equal false, result[:success] + assert_equal I18n.t("akahu_item.errors.account_processing_failed"), result[:error] + refute_includes result.inspect, raw_message + end + + test "process accounts sanitizes failed processor result payload" do + raw_message = "raw provider failure with account number 12-3456" + AkahuAccount::Processor.any_instance.stubs(:process).returns( + success: false, + error: raw_message, + result: { + success: false, + total: 1, + imported: 0, + failed: 1, + pruned_pending: 0, + errors: [] + }, + errors: [ { error: raw_message } ] + ) + + AccountProvider.create!(account: accounts(:investment), provider: @account) + result = @item.process_accounts.first + + assert_equal @account.id, result[:akahu_account_id] + assert_equal false, result[:success] + assert_equal I18n.t("akahu_item.errors.account_processing_failed"), result[:error] + refute result.key?(:result) + refute result.key?(:errors) + refute_includes result.inspect, raw_message + end +end diff --git a/test/models/akahu_entry/processor_test.rb b/test/models/akahu_entry/processor_test.rb new file mode 100644 index 000000000..488c7d841 --- /dev/null +++ b/test/models/akahu_entry/processor_test.rb @@ -0,0 +1,99 @@ +require "test_helper" + +class AkahuEntry::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @akahu_item = AkahuItem.create!( + family: @family, + name: "Test Akahu", + app_token: "akahu-app-credential", + user_token: "akahu-user-credential" + ) + @akahu_account = AkahuAccount.create!( + akahu_item: @akahu_item, + name: "Test Bank - Everyday", + account_id: "acc_123", + currency: "NZD" + ) + @account = Account.create!( + family: @family, + name: "Everyday", + accountable: Depository.new(subtype: "checking"), + balance: 1000, + currency: "NZD" + ) + + AccountProvider.create!(account: @account, provider: @akahu_account) + end + + test "imports Akahu transaction with sign conversion and provider metadata" do + transaction_data = { + _id: "tx_123", + _account: "acc_123", + date: "2026-01-15T00:00:00.000Z", + description: "COFFEE SHOP", + amount: -12.50, + type: "EFTPOS", + merchant: { _id: "merchant_1", name: "Coffee Shop", website: "https://coffee.example" }, + category: { + _id: "cat_1", + name: "Food & Drink", + groups: { personal_finance: { name: "Eating Out" } } + }, + meta: { reference: "REF", particulars: "PART", code: "CODE", other_account: "12-3456-0000000-00" } + } + + entry = AkahuEntry::Processor.new(transaction_data, akahu_account: @akahu_account).process + + assert_equal "akahu_tx_123", entry.external_id + assert_equal "akahu", entry.source + assert_equal BigDecimal("12.5"), entry.amount + assert_equal "NZD", entry.currency + assert_equal Date.new(2026, 1, 15), entry.date + assert_equal "Coffee Shop", entry.name + assert_equal "COFFEE SHOP | Reference: REF | Particulars: PART | Code: CODE | Other account: 12-3456-0000000-00", entry.notes + + transaction = entry.entryable + assert_equal false, transaction.pending? + assert_equal false, transaction.extra.dig("akahu", "pending") + assert_equal "Food & Drink", transaction.extra.dig("akahu", "category") + assert_equal "Eating Out", transaction.extra.dig("akahu", "category_group") + assert_equal "REF", transaction.extra.dig("akahu", "reference") + assert_equal "Coffee Shop", transaction.merchant.name + end + + test "marks pending transactions from importer metadata" do + entry = AkahuEntry::Processor.new( + { + _id: "pending_tx_1", + _account: "acc_123", + date: "2026-01-15", + description: "Pending card auth", + amount: -8.00, + _pending: true + }, + akahu_account: @akahu_account + ).process + + assert entry.entryable.pending? + assert_equal true, entry.entryable.extra.dig("akahu", "pending") + end + + test "uses stable temporary external id for id-less pending transactions" do + transaction_data = { + _account: "acc_123", + date: "2026-01-15", + description: "Pending card auth", + amount: -8.00, + merchant: { name: "Cafe" }, + _pending: true + } + + first_entry = AkahuEntry::Processor.new(transaction_data, akahu_account: @akahu_account).process + second_entry = AkahuEntry::Processor.new(transaction_data, akahu_account: @akahu_account).process + + assert_match(/^akahu_pending_[a-f0-9]{32}$/, first_entry.external_id) + assert_equal first_entry.id, second_entry.id + assert_equal 1, @account.entries.where(source: "akahu").count + end +end diff --git a/test/models/akahu_item/importer_test.rb b/test/models/akahu_item/importer_test.rb new file mode 100644 index 000000000..4f751a384 --- /dev/null +++ b/test/models/akahu_item/importer_test.rb @@ -0,0 +1,218 @@ +require "test_helper" + +class AkahuItem::ImporterTest < ActiveSupport::TestCase + class FakeAkahuProvider + attr_reader :transaction_calls + + def initialize(posted_transactions: nil, pending_transactions: nil, pending_error: nil) + @transaction_calls = [] + @posted_transactions = posted_transactions + @pending_transactions = pending_transactions + @pending_error = pending_error + end + + def get_accounts + [ + { + _id: "acc_123", + name: "Everyday", + type: "CHECKING", + status: "ACTIVE", + connection: { _id: "conn_1", name: "Test Bank", logo: "https://example.com/logo.png" }, + balance: { currency: "NZD", current: 123.45, available: 100.00 }, + formatted_account: "12-3456-0000000-00", + meta: { holder: "Test Person" } + } + ] + end + + def get_pending_transactions + raise Provider::Akahu::AkahuError.new(@pending_error, :server_error) if @pending_error + + @pending_transactions || [ + { + _id: "pending_1", + _account: "acc_123", + date: "2026-01-20", + description: "Pending transaction", + amount: -10.00 + } + ] + end + + def get_account_transactions(account_id:, start_date: nil, end_date: nil) + @transaction_calls << { account_id: account_id, start_date: start_date, end_date: end_date } + @posted_transactions || [ + { + _id: "tx_1", + _account: account_id, + date: "2026-01-19", + description: "Posted transaction", + amount: -20.00 + } + ] + end + end + + setup do + @family = families(:empty) + @akahu_item = AkahuItem.create!( + family: @family, + name: "Test Akahu", + app_token: "akahu-app-credential", + user_token: "akahu-user-credential" + ) + @akahu_account = AkahuAccount.create!( + akahu_item: @akahu_item, + name: "Old name", + account_id: "acc_123", + currency: "NZD" + ) + @account = Account.create!( + family: @family, + name: "Everyday", + accountable: Depository.new(subtype: "checking"), + balance: 0, + currency: "NZD" + ) + AccountProvider.create!(account: @account, provider: @akahu_account) + end + + test "imports account snapshots and stores posted plus pending transactions" do + provider = FakeAkahuProvider.new + + result = AkahuItem::Importer.new(@akahu_item, akahu_provider: provider).import + + assert result[:success] + assert_equal 1, result[:accounts_updated] + assert_equal 2, result[:transactions_imported] + + @akahu_account.reload + assert_equal "Test Bank - Everyday", @akahu_account.name + assert_equal BigDecimal("123.45"), @akahu_account.current_balance + assert_equal "12-3456-0000000-00", @akahu_account.formatted_account + assert_equal "Test Bank", @akahu_account.institution_metadata["name"] + + transactions = @akahu_account.raw_transactions_payload + assert_equal [ "tx_1", "pending_1" ], transactions.map { |tx| tx["_id"] } + assert_equal true, transactions.second["_pending"] + assert_equal "acc_123", provider.transaction_calls.first[:account_id] + end + + test "removes pending transactions that disappear from latest pending response" do + pending = [ pending_transaction(description: "Pending card auth", amount: -8.00) ] + import_with(pending_transactions: pending, posted_transactions: []) + process_transactions + + assert_equal 1, pending_entries.count + + import_with(pending_transactions: [], posted_transactions: []) + process_transactions + + @akahu_account.reload + assert_empty pending_entries + assert_empty @akahu_account.raw_transactions_payload.select { |tx| tx["_pending"] } + end + + test "keeps unchanged pending transactions without creating duplicates" do + pending = [ pending_transaction(description: "Pending card auth", amount: -8.00) ] + import_with(pending_transactions: pending, posted_transactions: []) + process_transactions + + original_entry = pending_entries.first + + import_with(pending_transactions: pending, posted_transactions: []) + process_transactions + + assert_equal 1, pending_entries.count + assert_equal original_entry.id, pending_entries.first.id + end + + test "replaces changed pending transactions" do + import_with(pending_transactions: [ pending_transaction(description: "Pending card auth", amount: -8.00) ], posted_transactions: []) + process_transactions + + original_entry = pending_entries.first + + import_with(pending_transactions: [ pending_transaction(description: "Pending card auth", amount: -10.00) ], posted_transactions: []) + process_transactions + + assert_equal 1, pending_entries.count + replacement_entry = pending_entries.first + assert_not_equal original_entry.id, replacement_entry.id + assert_equal BigDecimal("10.0"), replacement_entry.amount + end + + test "preserves existing pending transactions when pending fetch fails" do + pending = [ pending_transaction(description: "Pending card auth", amount: -8.00) ] + import_with(pending_transactions: pending, posted_transactions: []) + process_transactions + + original_entry = pending_entries.first + + import_with(posted_transactions: [], pending_error: "Akahu pending unavailable") + process_transactions + + assert_equal 1, pending_entries.count + assert_equal original_entry.id, pending_entries.first.id + assert_equal 1, @akahu_account.reload.raw_transactions_payload.count { |tx| tx["_pending"] } + end + + test "posted transaction can claim matching pending before stale pending pruning" do + pending = [ pending_transaction(description: "Pending card auth", amount: -8.00, date: "2026-01-15") ] + import_with(pending_transactions: pending, posted_transactions: []) + process_transactions + + original_entry = pending_entries.first + import_with( + pending_transactions: [], + posted_transactions: [ + { + _id: "posted_1", + _account: "acc_123", + date: "2026-01-16", + description: "Posted card auth", + amount: -8.00 + } + ] + ) + process_transactions + + original_entry.reload + assert_empty pending_entries + assert_equal "akahu_posted_1", original_entry.external_id + assert_equal false, original_entry.entryable.pending? + end + + private + + def import_with(posted_transactions: [], pending_transactions: [], pending_error: nil) + provider = FakeAkahuProvider.new( + posted_transactions: posted_transactions, + pending_transactions: pending_transactions, + pending_error: pending_error + ) + AkahuItem::Importer.new(@akahu_item, akahu_provider: provider).import + end + + def process_transactions + AkahuAccount::Transactions::Processor.new(@akahu_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: "akahu") + .where("(transactions.extra -> 'akahu' ->> 'pending')::boolean = true") + end + + def pending_transaction(description:, amount:, date: "2026-01-15") + { + _account: "acc_123", + date: date, + description: description, + amount: amount, + type: "DEBIT" + } + end +end diff --git a/test/models/akahu_item/syncer_test.rb b/test/models/akahu_item/syncer_test.rb new file mode 100644 index 000000000..be8526586 --- /dev/null +++ b/test/models/akahu_item/syncer_test.rb @@ -0,0 +1,80 @@ +require "test_helper" + +class AkahuItem::SyncerTest < ActiveSupport::TestCase + setup do + @akahu_item = AkahuItem.create!( + family: families(:dylan_family), + name: "Main Akahu", + app_token: "akahu-app-credential", + user_token: "akahu-user-credential" + ) + + AkahuItem.any_instance.stubs(:perform_post_sync) + AkahuItem.any_instance.stubs(:broadcast_sync_complete) + end + + test "failed import result marks sync failed and records health error" do + AkahuItem.any_instance.stubs(:import_latest_akahu_data).returns( + success: false, + error: "Failed to fetch accounts data" + ) + + sync = @akahu_item.syncs.create! + + sync.perform + + sync.reload + assert_predicate sync, :failed? + assert_equal "Akahu import: Failed to fetch accounts data", sync.error + assert_equal 1, sync.sync_stats["total_errors"] + assert_equal "Akahu import: Failed to fetch accounts data", sync.sync_stats.dig("errors", 0, "message") + assert_equal "sync_error", sync.sync_stats.dig("errors", 0, "category") + end + + test "unexpected sync error log excludes raw exception message" do + sensitive_message = "provider payload included account details" + error = RuntimeError.new(sensitive_message) + + AkahuItem.any_instance.stubs(:import_latest_akahu_data).raises(error) + Rails.logger.expects(:error).with do |message| + message == "AkahuItem::Syncer - Unexpected sync error: RuntimeError" + end.once + + sync = @akahu_item.syncs.create! + scope = RecordingSentryScope.new + Sentry.expects(:capture_exception).with do |captured_error| + captured_error.is_a?(AkahuItem::Syncer::SafeSyncError) && + !captured_error.equal?(error) && + captured_error.cause.nil? && + captured_error.message == I18n.t("akahu_item.errors.sync_failed") && + !captured_error.message.include?(sensitive_message) + end.yields(scope).once + + sync.perform + + sync.reload + assert_predicate sync, :failed? + assert_equal I18n.t("akahu_item.errors.sync_failed"), sync.error + assert_equal I18n.t("akahu_item.errors.sync_failed"), sync.sync_stats.dig("errors", 0, "message") + assert_not_includes sync.sync_stats.dig("errors", 0, "message"), sensitive_message + assert_equal({ sync_id: sync.id }, scope.tags) + assert_empty scope.contexts + end + + class RecordingSentryScope + attr_reader :tags, :contexts + + def initialize + @tags = {} + @contexts = {} + end + + def set_tags(tags) + @tags.merge!(tags) + end + + def set_context(name, context) + @contexts[name] = context + end + end +end diff --git a/test/models/akahu_item_importer_error_messages_test.rb b/test/models/akahu_item_importer_error_messages_test.rb new file mode 100644 index 000000000..3c0f54ed8 --- /dev/null +++ b/test/models/akahu_item_importer_error_messages_test.rb @@ -0,0 +1,47 @@ +require "test_helper" + +class AkahuItemImporterErrorMessagesTest < ActiveSupport::TestCase + setup do + @item = AkahuItem.create!( + family: families(:dylan_family), + name: "Test Akahu", + app_token: "akahu-app-credential", + user_token: "akahu-user-credential" + ) + @akahu_account = @item.akahu_accounts.create!( + name: "Test Akahu Account", + account_id: "akahu-account-1", + currency: "NZD" + ) + end + + test "pending transaction fetch hides raw exception messages from result errors" do + raw_message = "raw pending payload with sensitive-value" + provider = mock + provider.stubs(:get_pending_transactions).raises(StandardError.new(raw_message)) + + result = AkahuItem::Importer + .new(@item, akahu_provider: provider) + .send(:fetch_pending_transactions_by_account) + + assert_equal false, result[:success] + assert_equal I18n.t("akahu_item.errors.pending_transactions_failed"), result[:error] + refute_includes result.inspect, raw_message + end + + test "posted transaction fetch hides raw Akahu error messages from result errors" do + raw_message = "raw posted payload with sensitive-value" + provider = mock + provider + .stubs(:get_account_transactions) + .raises(Provider::Akahu::AkahuError.new(raw_message, :fetch_failed)) + + result = AkahuItem::Importer + .new(@item, akahu_provider: provider) + .send(:fetch_and_store_transactions, @akahu_account, [], pending_refresh_succeeded: true) + + assert_equal false, result[:success] + assert_equal I18n.t("akahu_item.errors.transactions_failed"), result[:error] + refute_includes result.inspect, raw_message + end +end diff --git a/test/models/akahu_item_unlinking_test.rb b/test/models/akahu_item_unlinking_test.rb new file mode 100644 index 000000000..a3ad38b37 --- /dev/null +++ b/test/models/akahu_item_unlinking_test.rb @@ -0,0 +1,81 @@ +require "test_helper" + +class AkahuItemUnlinkingTest < ActiveSupport::TestCase + test "unlink all only detaches holdings for the current family account provider links" do + current_family = families(:dylan_family) + other_family = families(:empty) + security = securities(:aapl) + + current_account = Account.create!( + family: current_family, + owner: users(:family_admin), + name: "Akahu Current Family Investment", + balance: 100, + cash_balance: 0, + currency: "USD", + accountable: Investment.create!(subtype: "brokerage") + ) + other_account = Account.create!( + family: other_family, + owner: users(:empty), + name: "Akahu Other Family Investment", + balance: 100, + cash_balance: 0, + currency: "USD", + accountable: Investment.create!(subtype: "brokerage") + ) + + current_item = AkahuItem.create!( + family: current_family, + name: "Current Akahu", + app_token: "current-akahu-app-credential", + user_token: "current-akahu-user-credential" + ) + other_item = AkahuItem.create!( + family: other_family, + name: "Other Akahu", + app_token: "other-akahu-app-credential", + user_token: "other-akahu-user-credential" + ) + + current_akahu_account = current_item.akahu_accounts.create!( + name: "Current Akahu Account", + account_id: "akahu-current-account", + currency: "USD" + ) + other_akahu_account = other_item.akahu_accounts.create!( + name: "Other Akahu Account", + account_id: "akahu-other-account", + currency: "USD" + ) + + current_link = AccountProvider.create!(account: current_account, provider: current_akahu_account) + other_link = AccountProvider.create!(account: other_account, provider: other_akahu_account) + + current_holding = current_account.holdings.create!( + security: security, + qty: 1, + price: 100, + amount: 100, + currency: "USD", + date: Date.current, + account_provider: current_link + ) + other_holding = other_account.holdings.create!( + security: security, + qty: 1, + price: 100, + amount: 100, + currency: "USD", + date: Date.current, + account_provider: other_link + ) + + current_item.unlink_all!(dry_run: false) + + assert_nil current_holding.reload.account_provider_id + assert_not AccountProvider.exists?(current_link.id) + assert_equal other_link.id, other_holding.reload.account_provider_id + assert AccountProvider.exists?(other_link.id) + end +end diff --git a/test/models/family/syncer_test.rb b/test/models/family/syncer_test.rb index be9624109..beb8c7645 100644 --- a/test/models/family/syncer_test.rb +++ b/test/models/family/syncer_test.rb @@ -7,10 +7,16 @@ class Family::SyncerTest < ActiveSupport::TestCase test "syncs provider items and manual accounts" do family_sync = syncs(:family) + @family.akahu_items.create!( + name: "Test Akahu", + app_token: "app_token", + user_token: "user_token" + ) manual_accounts_count = @family.accounts.manual.count plaid_items_count = @family.plaid_items.syncable.count brex_items_count = @family.brex_items.syncable.count + akahu_items_count = @family.akahu_items.syncable.count binance_items_count = @family.binance_items.syncable.count syncer = Family::Syncer.new(@family) @@ -30,6 +36,11 @@ class Family::SyncerTest < ActiveSupport::TestCase .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) .times(brex_items_count) + AkahuItem.any_instance + .expects(:sync_later) + .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) + .times(akahu_items_count) + BinanceItem.any_instance .expects(:sync_later) .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) @@ -74,6 +85,7 @@ class Family::SyncerTest < ActiveSupport::TestCase EnableBankingItem.any_instance.stubs(:sync_later) SophtronItem.any_instance.stubs(:sync_later) BrexItem.any_instance.stubs(:sync_later) + AkahuItem.any_instance.stubs(:sync_later) BinanceItem.any_instance.stubs(:sync_later) syncer.perform_sync(family_sync) diff --git a/test/models/provider/akahu_adapter_test.rb b/test/models/provider/akahu_adapter_test.rb new file mode 100644 index 000000000..cc0126543 --- /dev/null +++ b/test/models/provider/akahu_adapter_test.rb @@ -0,0 +1,37 @@ +require "test_helper" +require "uri" + +class Provider::AkahuAdapterTest < ActiveSupport::TestCase + test "supports Investment accounts" do + assert_includes Provider::AkahuAdapter.supported_account_types, "Investment" + end + + test "returns one connection config per credentialed Akahu item" do + family = families(:dylan_family) + first_item = AkahuItem.create!( + family: family, + name: "Main Akahu", + app_token: "akahu-app-credential", + user_token: "akahu-user-credential" + ) + second_item = AkahuItem.create!( + family: family, + name: "Secondary Akahu", + app_token: "second-akahu-app-credential", + user_token: "second-akahu-user-credential" + ) + + configs = Provider::AkahuAdapter.connection_configs(family: family) + + assert_equal [ "akahu_#{second_item.id}", "akahu_#{first_item.id}" ], configs.map { |config| config[:key] } + assert_equal [ second_item.name, first_item.name ], configs.map { |config| config[:name] } + + new_account_uri = URI.parse(configs.first[:new_account_path].call("Depository", "/accounts")) + assert_equal "/akahu_items/select_accounts", new_account_uri.path + assert_includes new_account_uri.query, "akahu_item_id=#{second_item.id}" + + existing_account_uri = URI.parse(configs.first[:existing_account_path].call(accounts(:depository).id)) + assert_equal "/akahu_items/select_existing_account", existing_account_uri.path + assert_includes existing_account_uri.query, "akahu_item_id=#{second_item.id}" + end +end diff --git a/test/models/provider/akahu_test.rb b/test/models/provider/akahu_test.rb new file mode 100644 index 000000000..7998ed19a --- /dev/null +++ b/test/models/provider/akahu_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class Provider::AkahuTest < ActiveSupport::TestCase + FakeResponse = Struct.new(:code, :body, :message, keyword_init: true) + + test "fetches paginated account transactions with Akahu auth headers" do + responses = [ + FakeResponse.new( + code: 200, + message: "OK", + body: { items: [ { _id: "tx_1" } ], cursor: { next: "next-cursor" } }.to_json + ), + FakeResponse.new( + code: 200, + message: "OK", + body: { items: [ { _id: "tx_2" } ] }.to_json + ) + ] + requests = [] + + Provider::Akahu.stub(:get, ->(url, headers:, query: nil) { + requests << { url: url, headers: headers, query: query } + responses.shift + }) do + client = Provider::Akahu.new(app_token: "akahu-app-credential", user_token: "akahu-user-credential") + + transactions = client.get_account_transactions( + account_id: "acc_123", + start_date: Date.new(2026, 1, 1) + ) + + assert_equal [ "tx_1", "tx_2" ], transactions.map { |tx| tx[:_id] } + end + + assert_equal 2, requests.size + assert_match "/accounts/acc_123/transactions", requests.first[:url] + assert_equal "Bearer akahu-user-credential", requests.first[:headers]["Authorization"] + assert_equal "akahu-app-credential", requests.first[:headers]["X-Akahu-Id"] + assert_match "2026-01-01", requests.first[:query][:start] + assert_equal "next-cursor", requests.second[:query][:cursor] + end + + test "raises typed errors for unauthorized responses" do + response = FakeResponse.new(code: 401, message: "Unauthorized", body: "{}") + + Provider::Akahu.stub(:get, ->(_url, headers:, query: nil) { response }) do + error = assert_raises Provider::Akahu::AkahuError do + Provider::Akahu.new(app_token: "akahu-app-credential", user_token: "invalid-credential").get_accounts + end + + assert_equal :unauthorized, error.error_type + end + end +end diff --git a/test/models/provider/metadata_test.rb b/test/models/provider/metadata_test.rb new file mode 100644 index 000000000..84ca5ded9 --- /dev/null +++ b/test/models/provider/metadata_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class Provider::MetadataTest < ActiveSupport::TestCase + test "provider metadata can define multiple kinds" do + assert_equal %w[Bank Investment], Provider::Metadata.for(:akahu)[:kinds] + end + + test "akahu supports multiple kinds" do + providers_with_multiple_kinds = Provider::Metadata::REGISTRY.select { |_provider_key, metadata| metadata[:kinds].size > 1 } + + assert_includes providers_with_multiple_kinds.keys, :akahu + end + + test "registered provider metadata only uses kinds" do + Provider::Metadata::REGISTRY.each_value do |metadata| + assert metadata.key?(:kinds) + refute metadata.key?(:kind) + end + end +end