diff --git a/app/components/settings/provider_card.rb b/app/components/settings/provider_card.rb index c57657a2ea..a7773d0e99 100644 --- a/app/components/settings/provider_card.rb +++ b/app/components/settings/provider_card.rb @@ -9,13 +9,13 @@ def self.maturity_label(maturity) 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 @@ def maturity_label end def meta_line - [ @region, @kind, @tier ].compact.join(" · ") + [ @region, @kinds.join(" / "), @tier ].compact_blank.join(" · ") end def connect_path @@ -41,7 +41,7 @@ def filter_data 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 be8f2adf20..6ce5e02c6c 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -15,6 +15,7 @@ def index @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 @@ def build_sync_stats_maps @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 0000000000..51d8edad95 --- /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 2d123f2ab3..1a3a03ca1b 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -182,6 +182,7 @@ def reload_provider_configs(updated_fields) # 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 @@ def reload_provider_configs(updated_fields) # 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 @@ def reload_provider_configs(updated_fields) 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 @@ def prepare_show_context 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 @@ def prepare_show_context # 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 d9eb42a0ec..4b89c3f8d5 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -57,6 +57,9 @@ def provider_summary(provider_key) 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 54004b2f63..a4d90e2a46 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 b4f8e17dcf..56677d5cad 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -49,11 +49,10 @@ def import_transaction(external_id:, amount:, currency:, date:, name:, source:, 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 === @@ -757,6 +756,7 @@ def find_pending_transaction(date:, amount:, currency:, source:, date_window: 8) 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 @@ -804,6 +804,7 @@ def find_pending_transaction_fuzzy(date:, amount:, currency:, source:, merchant_ 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 @@ -874,6 +875,7 @@ def find_pending_transaction_low_confidence(date:, amount:, currency:, source:, 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 0000000000..ec8fd54a3c --- /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 0000000000..0d22c427b7 --- /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 0000000000..48855193d3 --- /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 0000000000..a928fab96d --- /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 0000000000..5c550420db --- /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 0000000000..2c1f71204d --- /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 0000000000..8fa1f7d522 --- /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 0000000000..73fed67a18 --- /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 0000000000..26fe15eaf4 --- /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 0000000000..4823f88eb3 --- /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 d14b47eb5f..66c364660a 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 4313ab4a5c..7d2c458c92 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 0000000000..ef933f275e --- /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 7873c5ff0d..66721a1a26 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 0000000000..9d6dda11d2 --- /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 0000000000..7855a51c6e --- /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 4c8263b8a4..805e84397a 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 0563d6f77e..0c37203230 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 fbfba77353..261a1e6603 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 0334298d1d..e564e3ea18 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -94,7 +94,7 @@ def exchange_rate_must_be_valid 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 e585d9d569..cf1c257c1c 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 e8f4bee52d..609d757eb3 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 %>
<%= akahu_item.name.to_s.first.to_s.upcase %>
+<%= t(".deletion_in_progress") %>
+ <% end %> +<%= akahu_item.institution_summary %>
+ <% end %> + + <% if akahu_item.syncing? %> ++ <% 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 %> +<%= 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 + ) %> +<%= 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 + ) %> +<%= 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 %> + +<%= 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 %> + +<%= t(".fetch_failed") %>
+<%= @api_error %>
+<%= t(".no_accounts_to_setup") %>
+<%= t(".all_accounts_linked") %>
+<%= t(".choose_account_type") %>
+<%= t(".choose_account_type_description") %>
++ <%= [akahu_account.formatted_account, akahu_account.currency, akahu_account.account_type].compact.join(" · ") %> +
+<%= item.name.to_s.first.to_s.upcase %>
+<%= item.name %>
+<%= item.sync_status_summary %>
+<%= 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 %>