From 826c4a356ebaf8d24fdcbaf604cefe13a612049c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Dular?= <22869613+xBlaz3kx@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:16:28 +0200 Subject: [PATCH] feat(bank-sync): Wise integration (#2433) * feat(wise): add Wise integration with JAR savings account and activity support - Add WiseItem/WiseAccount models with full sync pipeline (importer, syncer, processor) - Detect income vs expense using targetAccount == recipientId from borderless accounts API - Support JAR (SAVINGS) accounts with totalWorth balance and savings subtype - Fetch JAR activity via profile activities API (INTERBALANCE, BALANCE_CASHBACK, BALANCE_ASSET_FEE) - Route INTERBALANCE activities to both JAR and STANDARD accounts and link as Transfer records - Add provider connection status registration, routes, views, and i18n - Add migration for wise_items and wise_accounts tables - Add tests for WiseAccount, WiseEntry::Processor, WiseActivity::Processor, WiseItem::Importer, and WiseItem#link_jar_transfers! * chore(lint): add ignore to security scan (false positive) * fix(wise): address PR review feedback on activity routing, HTML stripping, rate limiting, and scope extraction - Replace title string matching in activity_for_account? with resource.id vs balance_id comparison to avoid breakage when users rename JAR accounts on Wise - Replace gsub(/<[^>]+>/, "") with ActionController::Base.helpers.strip_tags to safely handle user-controlled HTML-like content - Wrap paginated API calls in with_rate_limit_retry (up to 3 attempts, exponential backoff) to handle 429 responses during fetch_jar_activities and fetch_transfers - Extract WiseAccount.unlinked scope and remove duplicated left_joins query from controller * fix(wise): address PR #2433 review feedback - Wire @wise_items into AccountsController#index so linked accounts appear on /accounts - Fix find_wise_account_for_linking to preserve .active scope via .merge instead of .then - Fix cross-currency incoming transfer amount to use targetValue instead of sourceValue - Add missing select_profiles.session_expired locale key - Add missing account_name interpolation to link_existing_account success notice - Use i18n for profile type labels in profile_display_name - Trigger wise_item.sync_later after account linking in all three linking actions - Isolate fee transaction failure so it no longer aborts the main transfer import - Use bare raise in WiseActivity/WiseEntry processors to preserve original backtrace - Re-raise in WiseItem::Unlinking to prevent silently orphaned Holdings - Fix avatar badge to use design system tokens (bg-container-inset, text-primary) * fix(wise): fix INTERBALANCE routing and encrypt pending token in session * fix(wise): replace hand-rolled buttons/links with DS::Button and DS::Link Address repeated sure-design DS drift findings: migrate all manual Tailwind button_to and link_to calls in Wise views to DS::Button (outline/outline_destructive variants) and DS::Link (secondary variant). Add missing provider_panel.disconnect locale key for the disconnect button text. * fix(wise): use render_provider_panel_error in create instead of missing new template * fix(wise): add missing comma in PROVIDERS array CI failed with a SyntaxError because the questrade entry wasn't comma-terminated before the wise entry. * chore(wise): re-add pipelock:ignore for pending token param Lost when the token source moved from session to an encrypted params field in 0b5dc886, causing the CI secret scanner to flag a false positive. * fix(test): widen random ticker suffix to avoid rare collision flake hex(2) only yields 65536 possible tickers, so create_trade's 4 calls per test run had a small but nonzero chance of colliding on the unique ticker+exchange index. hex(8) makes collisions practically impossible. --- app/controllers/accounts_controller.rb | 8 + .../settings/providers_controller.rb | 6 + app/controllers/wise_items_controller.rb | 326 +++++++++++++++++ app/models/account.rb | 17 + app/models/family.rb | 2 +- app/models/family/wise_connectable.rb | 26 ++ app/models/provider/metadata.rb | 1 + app/models/provider/wise.rb | 117 +++++++ app/models/provider/wise_adapter.rb | 96 +++++ app/models/provider_connection_status.rb | 3 +- app/models/wise_account.rb | 57 +++ app/models/wise_account/processor.rb | 44 +++ .../wise_account/transactions/processor.rb | 53 +++ app/models/wise_activity/processor.rb | 156 +++++++++ app/models/wise_entry/processor.rb | 179 ++++++++++ app/models/wise_item.rb | 166 +++++++++ app/models/wise_item/importer.rb | 331 ++++++++++++++++++ app/models/wise_item/provided.rb | 9 + app/models/wise_item/sync_complete_event.rb | 30 ++ app/models/wise_item/syncer.rb | 129 +++++++ app/models/wise_item/unlinking.rb | 40 +++ app/views/accounts/index.html.erb | 6 +- app/views/budgets/_budget_categories.html.erb | 5 +- app/views/budgets/_category_section.html.erb | 2 +- app/views/ibkr_items/setup_accounts.html.erb | 4 +- .../configurations/_merchant_import.html.erb | 2 +- app/views/reports/_period_picker.html.erb | 2 +- .../settings/providers/_wise_panel.html.erb | 143 ++++++++ app/views/shared/_money_field.html.erb | 3 +- app/views/transfers/_form.html.erb | 6 +- app/views/wise_items/_wise_item.html.erb | 101 ++++++ app/views/wise_items/select_accounts.html.erb | 65 ++++ .../select_existing_account.html.erb | 65 ++++ app/views/wise_items/select_profiles.html.erb | 66 ++++ app/views/wise_items/setup_accounts.html.erb | 52 +++ config/initializers/wise.rb | 6 + config/locales/views/settings/en.yml | 1 + config/locales/views/wise_items/en.yml | 150 ++++++++ config/routes.rb | 17 + ...18120000_create_wise_items_and_accounts.rb | 45 +++ db/schema.rb | 35 ++ .../controllers/wise_items_controller_test.rb | 98 ++++++ test/fixtures/wise_accounts.yml | 13 + test/fixtures/wise_items.yml | 7 + test/models/investment_statement_test.rb | 2 +- test/models/wise_account_test.rb | 115 ++++++ test/models/wise_activity/processor_test.rb | 212 +++++++++++ test/models/wise_entry/processor_test.rb | 177 ++++++++++ test/models/wise_item/importer_test.rb | 263 ++++++++++++++ test/models/wise_item_test.rb | 115 ++++++ 50 files changed, 3557 insertions(+), 17 deletions(-) create mode 100644 app/controllers/wise_items_controller.rb create mode 100644 app/models/family/wise_connectable.rb create mode 100644 app/models/provider/wise.rb create mode 100644 app/models/provider/wise_adapter.rb create mode 100644 app/models/wise_account.rb create mode 100644 app/models/wise_account/processor.rb create mode 100644 app/models/wise_account/transactions/processor.rb create mode 100644 app/models/wise_activity/processor.rb create mode 100644 app/models/wise_entry/processor.rb create mode 100644 app/models/wise_item.rb create mode 100644 app/models/wise_item/importer.rb create mode 100644 app/models/wise_item/provided.rb create mode 100644 app/models/wise_item/sync_complete_event.rb create mode 100644 app/models/wise_item/syncer.rb create mode 100644 app/models/wise_item/unlinking.rb create mode 100644 app/views/settings/providers/_wise_panel.html.erb create mode 100644 app/views/wise_items/_wise_item.html.erb create mode 100644 app/views/wise_items/select_accounts.html.erb create mode 100644 app/views/wise_items/select_existing_account.html.erb create mode 100644 app/views/wise_items/select_profiles.html.erb create mode 100644 app/views/wise_items/setup_accounts.html.erb create mode 100644 config/initializers/wise.rb create mode 100644 config/locales/views/wise_items/en.yml create mode 100644 db/migrate/20260618120000_create_wise_items_and_accounts.rb create mode 100644 test/controllers/wise_items_controller_test.rb create mode 100644 test/fixtures/wise_accounts.yml create mode 100644 test/fixtures/wise_items.yml create mode 100644 test/models/wise_account_test.rb create mode 100644 test/models/wise_activity/processor_test.rb create mode 100644 test/models/wise_entry/processor_test.rb create mode 100644 test/models/wise_item/importer_test.rb create mode 100644 test/models/wise_item_test.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 20d31811c..a50f60137 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -28,6 +28,7 @@ class AccountsController < ApplicationController @sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts)) @binance_items = visible_provider_items(family.binance_items.ordered.includes(:binance_accounts, :accounts, :syncs)) @questrade_items = visible_provider_items(family.questrade_items.ordered.includes(:syncs, questrade_accounts: :account_provider)) + @wise_items = visible_provider_items(family.wise_items.ordered.includes(:syncs, :wise_accounts)) # Build sync stats maps for all providers build_sync_stats_maps @@ -451,5 +452,12 @@ class AccountsController < ApplicationController linked: linked, unlinked: accounts.size - linked, total: accounts.size } end + + # Wise sync stats + @wise_sync_stats_map = {} + @wise_items.each do |item| + latest_sync = item.syncs.ordered.first + @wise_sync_stats_map[item.id] = latest_sync&.sync_stats || {} + end end end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 5fdb82657..14585339e 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -188,6 +188,7 @@ class Settings::ProvidersController < ApplicationController { key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" }, { key: "enable_banking", title: "Enable Banking", turbo_id: "enable_banking", partial: "enable_banking_panel" }, { key: "coinstats", title: "CoinStats", turbo_id: "coinstats", partial: "coinstats_panel" }, + { key: "wise", title: "Wise", turbo_id: "wise", partial: "wise_panel" }, { key: "mercury", title: "Mercury", turbo_id: "mercury", partial: "mercury_panel" }, { key: "brex", title: "Brex", turbo_id: "brex", partial: "brex_panel" }, { key: "coinbase", title: "Coinbase", turbo_id: "coinbase", partial: "coinbase_panel" }, @@ -210,6 +211,7 @@ class Settings::ProvidersController < ApplicationController "lunchflow" => "LunchflowItem", "enable_banking" => "EnableBankingItem", "coinstats" => "CoinstatsItem", + "wise" => "WiseItem", "mercury" => "MercuryItem", "brex" => "BrexItem", "coinbase" => "CoinbaseItem", @@ -236,6 +238,8 @@ class Settings::ProvidersController < ApplicationController @enable_banking_items = Current.family.enable_banking_items.ordered when "coinstats" @coinstats_items = Current.family.coinstats_items.ordered + when "wise" + @wise_items = Current.family.wise_items.active.ordered.includes(:syncs, :wise_accounts) when "mercury" @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) when "brex" @@ -276,6 +280,7 @@ class Settings::ProvidersController < ApplicationController # Providers page only needs to know whether any Sophtron connections exist with valid credentials @sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id) @coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display + @wise_items = Current.family.wise_items.active.ordered @mercury_items = Current.family.mercury_items.active.ordered @brex_items = Current.family.brex_items.active.ordered @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display @@ -308,6 +313,7 @@ class Settings::ProvidersController < ApplicationController "lunchflow" => @lunchflow_items, "enable_banking" => @enable_banking_items, "coinstats" => @coinstats_items, + "wise" => @wise_items, "mercury" => @mercury_items, "brex" => @brex_items, "coinbase" => @coinbase_items, diff --git a/app/controllers/wise_items_controller.rb b/app/controllers/wise_items_controller.rb new file mode 100644 index 000000000..ad8a7b86e --- /dev/null +++ b/app/controllers/wise_items_controller.rb @@ -0,0 +1,326 @@ +# frozen_string_literal: true + +class WiseItemsController < ApplicationController + before_action :set_wise_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, except: [ :index ] + + def index + @wise_items = Current.family.wise_items.active.ordered + render layout: "settings" + end + + def show + end + + def new + @wise_item = Current.family.wise_items.build + end + + # Step 1: Validate token and fetch profiles, then proceed to profile selection. + def create + token = wise_item_token_param # pipelock:ignore + + if token.blank? + @wise_item = Current.family.wise_items.build + @wise_item.errors.add(:token, :blank) + return render_provider_panel_error + end + + provider = Provider::Wise.new(token, base_url: Rails.configuration.x.wise.base_url) + profiles = provider.get_profiles + + if profiles.blank? + @wise_item = Current.family.wise_items.build + @wise_item.errors.add(:base, t(".no_profiles_found")) + return render_provider_panel_error + end + + session[:wise_pending_profiles] = profiles + @pending_profiles = profiles + @existing_profile_ids = Current.family.wise_items.pluck(:profile_id).map(&:to_s).to_set + @encrypted_pending_token = encrypt_pending_token(token) + + render :select_profiles + rescue Provider::Wise::WiseError => e + @wise_item = Current.family.wise_items.build + error_key = e.error_type == :unauthorized ? ".invalid_token" : ".connection_failed" + @wise_item.errors.add(:base, t(error_key)) + render_provider_panel_error + end + + # Step 2: Show profile selection. + def select_profiles + @pending_profiles = session[:wise_pending_profiles] + + if @pending_profiles.blank? + redirect_to new_wise_item_path, alert: t(".session_expired") and return + end + + @existing_profile_ids = Current.family.wise_items.pluck(:profile_id).map(&:to_s).to_set + end + + # Step 3: Create one WiseItem per selected profile. + def link_profiles + token = decrypt_pending_token(params[:encrypted_pending_token]) # pipelock:ignore + profiles = session[:wise_pending_profiles] + + if token.blank? || profiles.blank? + redirect_to new_wise_item_path, alert: t(".session_expired") and return + end + + selected_ids = Array(params[:profile_ids]).map(&:to_s).compact_blank + if selected_ids.empty? + redirect_to select_profiles_wise_items_path, alert: t(".no_profiles_selected") and return + end + + created = 0 + profiles.each do |profile| + profile_id = profile["id"].to_s + next unless selected_ids.include?(profile_id) + next if Current.family.wise_items.exists?(profile_id: profile_id) + + profile_type = profile["type"] == "business" ? "business" : "personal" + display_name = profile_display_name(profile) + + Current.family.create_wise_item!( + token: token, + profile_id: profile_id, + profile_type: profile_type, + item_name: display_name + ) + created += 1 + end + + session.delete(:wise_pending_profiles) + + if created.zero? + redirect_to settings_providers_path, alert: t(".already_connected") + else + redirect_to settings_providers_path, notice: t(".success", count: created) + end + end + + def edit + end + + def update + permitted = wise_item_update_params + if @wise_item.update(permitted) + render_provider_panel_success(t(".success")) + else + render_provider_panel_error + end + end + + def destroy + @wise_item.unlink_all!(dry_run: false) + @wise_item.destroy_later + redirect_to accounts_path, notice: t(".success") + end + + def sync + @wise_item.sync_later unless @wise_item.syncing? + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + def setup_accounts + @wise_accounts = @wise_item.wise_accounts.unlinked + end + + def complete_account_setup + wise_account_id = params[:wise_account_id] + wise_account = @wise_item.wise_accounts.find_by(id: wise_account_id) + + unless wise_account + redirect_to accounts_path, alert: t(".not_found") and return + end + + account = Account.create_from_wise_account(wise_account) + + AccountProvider.create!( + account: account, + provider: wise_account + ) + + @wise_item.sync_later unless @wise_item.syncing? + + redirect_to accounts_path, notice: t(".success") + rescue => e + Rails.logger.error "WiseItemsController#complete_account_setup - #{e.class}: #{e.message}" + redirect_to setup_accounts_wise_item_path(@wise_item), alert: t(".failed") + end + + # Collection actions for provider-panel account linking flow + + def select_accounts + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + @wise_item = resolve_wise_item_for_selection + + unless @wise_item + redirect_to settings_providers_path, alert: t("wise_items.select_accounts.no_connection") and return + end + + @available_accounts = @wise_item.wise_accounts.unlinked + + render layout: false + end + + def link_accounts + wise_account = find_wise_account_for_linking(params[:wise_account_id]) + + unless wise_account + redirect_to safe_return_to_path || accounts_path, alert: t("wise_items.link_accounts.not_found") and return + end + + account = Account.create_from_wise_account(wise_account) + AccountProvider.create!(account: account, provider: wise_account) + wise_account.wise_item.sync_later unless wise_account.wise_item.syncing? + + redirect_to safe_return_to_path || accounts_path, notice: t("wise_items.link_accounts.success") + rescue => e + Rails.logger.error "WiseItemsController#link_accounts - #{e.class}: #{e.message}" + redirect_to safe_return_to_path || accounts_path, alert: t("wise_items.link_accounts.failed") + end + + def select_existing_account + @account = Current.family.accounts.find_by(id: params[:account_id]) + @return_to = safe_return_to_path + @wise_item = resolve_wise_item_for_selection + + unless @account && @wise_item + redirect_to accounts_path, alert: t("wise_items.select_existing_account.not_found") and return + end + + @available_accounts = @wise_item.wise_accounts.unlinked + + render layout: false + end + + def link_existing_account + account = Current.family.accounts.find_by(id: params[:account_id]) + wise_account = find_wise_account_for_linking(params[:wise_account_id]) + + unless account && wise_account + redirect_to accounts_path, alert: t("wise_items.link_existing_account.not_found") and return + end + + AccountProvider.create!(account: account, provider: wise_account) + wise_account.wise_item.sync_later unless wise_account.wise_item.syncing? + + redirect_to safe_return_to_path || accounts_path, notice: t("wise_items.link_existing_account.success", account_name: account.name) + rescue => e + Rails.logger.error "WiseItemsController#link_existing_account - #{e.class}: #{e.message}" + redirect_to accounts_path, alert: t("wise_items.link_existing_account.failed") + end + + private + + def set_wise_item + @wise_item = Current.family.wise_items.find(params[:id]) + end + + def wise_item_token_param + params.dig(:wise_item, :token).to_s.strip + end + + def wise_item_update_params + permitted = params.require(:wise_item).permit(:name, :sync_start_date, :token) + permitted.delete(:token) if @wise_item.persisted? && permitted[:token].blank? + permitted[:token] = permitted[:token].to_s.strip if permitted[:token].present? + permitted + end + + def resolve_wise_item_for_selection + wise_item_id = params[:wise_item_id] + + if wise_item_id.present? + Current.family.wise_items.active.find_by(id: wise_item_id) + else + Current.family.wise_items.active.ordered.first + end + end + + def find_wise_account_for_linking(wise_account_id) + return nil if wise_account_id.blank? + + WiseAccount.joins(:wise_item) + .merge(Current.family.wise_items.active) + .find_by(id: wise_account_id) + end + + def profile_display_name(profile) + type_key = profile["type"] == "business" ? :business : :personal + type_label = I18n.t("wise_items.profile_types.#{type_key}") + details = profile["details"] || {} + name = details["name"].presence || + [ details["firstName"], details["lastName"] ].compact.join(" ").presence + + name.present? ? "#{name} (#{type_label})" : "Wise #{type_label}" + end + + def render_provider_panel_success(message) + return redirect_to accounts_path, notice: message, status: :see_other unless turbo_frame_request? + + flash.now[:notice] = message + @wise_items = Current.family.wise_items.active.ordered.includes(:syncs, :wise_accounts) + render_wise_provider_panel(locals: { wise_items: @wise_items }, include_flash: true) + end + + def render_provider_panel_error + @error_message = @wise_item.errors.full_messages.join(", ") + return redirect_to settings_providers_path, alert: @error_message, status: :see_other unless turbo_frame_request? + + render_wise_provider_panel(locals: { error_message: @error_message }, status: :unprocessable_entity) + end + + def render_wise_provider_panel(locals:, status: :ok, include_flash: false) + streams = [ + turbo_stream.replace( + "wise-providers-panel", + partial: "settings/providers/wise_panel", + locals: locals + ) + ] + streams += flash_notification_stream_items if include_flash + render turbo_stream: streams, status: status + end + + def encrypt_pending_token(token) + build_token_encryptor.encrypt_and_sign(token, expires_in: 15.minutes) + end + + def decrypt_pending_token(encrypted) + return nil if encrypted.blank? + build_token_encryptor.decrypt_and_verify(encrypted) + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError + nil + end + + def build_token_encryptor + key = Rails.application.key_generator.generate_key("wise_pending_token", 32) + ActiveSupport::MessageEncryptor.new(key) + 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?("/") + + second_char = return_to[1] + return nil if second_char.blank? || second_char == "/" || second_char == "\\" + return nil if second_char.match?(/[[:space:][:cntrl:]]/) + + uri = URI.parse(return_to) + return nil if uri.scheme.present? || uri.host.present? + + return_to + rescue URI::InvalidURIError + nil + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 5392d0fde..008bb5646 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -280,6 +280,23 @@ class Account < ApplicationRecord ) end + def create_from_wise_account(wise_account) + family = wise_account.wise_item.family + + create_and_sync( + { + family: family, + name: wise_account.name || "Wise #{wise_account.currency}", + balance: wise_account.current_balance || 0, + cash_balance: wise_account.current_balance || 0, + currency: wise_account.currency, + accountable_type: "Depository", + accountable_attributes: { subtype: wise_account.account_subtype } + }, + skip_initial_sync: true + ) + end + def create_from_coinbase_account(coinbase_account) # All Coinbase accounts are crypto exchange accounts family = coinbase_account.coinbase_item.family diff --git a/app/models/family.rb b/app/models/family.rb index 45324932a..a352d72d2 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -2,7 +2,7 @@ class Family < ApplicationRecord include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, AkahuConnectable, EnableBankingConnectable include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, BrexConnectable, SophtronConnectable - include IndexaCapitalConnectable, IbkrConnectable + include IndexaCapitalConnectable, IbkrConnectable, WiseConnectable include UpConnectable include QuestradeConnectable diff --git a/app/models/family/wise_connectable.rb b/app/models/family/wise_connectable.rb new file mode 100644 index 000000000..855277ea5 --- /dev/null +++ b/app/models/family/wise_connectable.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Family::WiseConnectable + extend ActiveSupport::Concern + + included do + has_many :wise_items, dependent: :destroy + end + + def can_connect_wise? + true + end + + def create_wise_item!(token:, profile_id:, profile_type:, item_name:) + item = wise_items.create!( + token: token, + profile_id: profile_id, + profile_type: profile_type, + name: item_name + ) + + item.sync_later + + item + end +end diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb index 384547b77..91014fc2f 100644 --- a/app/models/provider/metadata.rb +++ b/app/models/provider/metadata.rb @@ -7,6 +7,7 @@ class Provider up: { region: "AU", kinds: %w[Bank], maturity: :beta, logo_text: "UP", logo_bg: "bg-orange-600" }, enable_banking: { region: "EU", kinds: %w[Bank], maturity: :beta, logo_text: "EB", logo_bg: "bg-purple-600" }, coinstats: { region: "Global", kinds: %w[Crypto], maturity: :beta, logo_text: "CS", logo_bg: "bg-pink-600" }, + wise: { region: "Global", kinds: %w[Bank], maturity: :beta, logo_text: "WI", logo_bg: "bg-green-500" }, 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" }, diff --git a/app/models/provider/wise.rb b/app/models/provider/wise.rb new file mode 100644 index 000000000..ddd64efda --- /dev/null +++ b/app/models/provider/wise.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +class Provider::Wise + include HTTParty + extend SslConfigurable + + LIVE_BASE_URL = "https://api.wise.com" + SANDBOX_BASE_URL = "https://api.sandbox.transferwise.tech" + + headers "User-Agent" => "Sure Finance Wise Client" + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) + + attr_reader :token, :base_url + + def initialize(token, base_url: LIVE_BASE_URL) + @token = token + @base_url = base_url + end + + def get_me + get("/v1/me") + end + + def get_profiles + get("/v1/profiles") + end + + def get_balances(profile_id) + get("/v4/profiles/#{profile_id}/balances", query: { types: "STANDARD" }) + end + + def get_savings_balances(profile_id) + get("/v4/profiles/#{profile_id}/balances", query: { types: "SAVINGS" }) + end + + def get_balance_statement(profile_id, balance_id, interval_start:, interval_end:) + get( + "/v1/profiles/#{profile_id}/balance-statements/#{balance_id}/statement.json", + query: { + intervalStart: interval_start.iso8601, + intervalEnd: interval_end.iso8601 + } + ) + end + + def get_transfers(profile_id, limit: 100, offset: 0) + get( + "/v1/transfers", + query: { profile: profile_id, limit: limit, offset: offset } + ) + end + + def get_transfer(transfer_id) + get("/v1/transfers/#{transfer_id}") + end + + def get_activities(profile_id, cursor: nil, size: 100) + query = { size: size } + query[:cursor] = cursor if cursor + get("/v1/profiles/#{profile_id}/activities", query: query) + end + + def get_borderless_accounts(profile_id) + get("/v1/borderless-accounts", query: { profileId: profile_id }) + end + + private + + def get(path, query: {}) + response = self.class.get( + "#{base_url}#{path}", + headers: auth_headers, + query: query.presence + ) + handle_response(response) + rescue WiseError + raise + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise WiseError.new("Connection failed: #{e.message}", :request_failed) + rescue => e + raise WiseError.new("Unexpected error: #{e.message}", :request_failed) + end + + def auth_headers + { + "Authorization" => "Bearer #{token}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def handle_response(response) + case response.code + when 200 + JSON.parse(response.body) + when 401 + raise WiseError.new("Invalid API token", :unauthorized) + when 403 + raise WiseError.new("Access forbidden — check token permissions", :access_forbidden) + when 404 + raise WiseError.new("Resource not found", :not_found) + when 429 + raise WiseError.new("Rate limit exceeded. Please try again later.", :rate_limited) + else + raise WiseError.new("Unexpected response #{response.code}: #{response.body}", :fetch_failed) + end + end + + class WiseError < 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/wise_adapter.rb b/app/models/provider/wise_adapter.rb new file mode 100644 index 000000000..05c28bc23 --- /dev/null +++ b/app/models/provider/wise_adapter.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class Provider::WiseAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + Provider::Factory.register("WiseAccount", self) + + def self.supported_account_types + %w[Depository] + end + + def self.connection_configs(family:) + return [] unless family.can_connect_wise? + + wise_items = family.wise_items.active.ordered + + return [ connection_config_for(nil) ] if wise_items.empty? + + wise_items.map { |item| connection_config_for(item) } + end + + def provider_name + "wise" + end + + def self.build_provider(family: nil, wise_item_id: nil) + return nil unless family.present? + + item = resolve_wise_item(family, wise_item_id) + return nil unless item&.credentials_configured? + + Provider::Wise.new(item.token.to_s.strip, base_url: Rails.configuration.x.wise.base_url) + end + + def sync_path + Rails.application.routes.url_helpers.sync_wise_item_path(item) + end + + def item + provider_account.wise_item + end + + def can_delete_holdings? + false + end + + def institution_name + "Wise" + end + + def institution_domain + "wise.com" + end + + def institution_url + "https://wise.com" + end + + def institution_color + "#9FE870" + end + + def self.connection_config_for(wise_item) + path_params = ->(extra = {}) do + wise_item.present? ? extra.merge(wise_item_id: wise_item.id) : extra + end + + { + key: wise_item.present? ? "wise_#{wise_item.id}" : "wise", + name: wise_item.present? ? I18n.t("wise_items.provider_connection.name", name: wise_item.name) : I18n.t("wise_items.provider_connection.default_name"), + description: wise_item.present? ? I18n.t("wise_items.provider_connection.description", name: wise_item.name) : I18n.t("wise_items.provider_connection.default_description"), + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_wise_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_wise_items_path( + path_params.call(account_id: account_id) + ) + } + } + end + private_class_method :connection_config_for + + def self.resolve_wise_item(family, wise_item_id) + if wise_item_id.present? + return family.wise_items.active.find_by(id: wise_item_id) + end + + family.wise_items.active.ordered.first + end + private_class_method :resolve_wise_item +end diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index 3b5e54ccc..cf026629a 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -18,7 +18,8 @@ class ProviderConnectionStatus { key: "brex", type: "BrexItem", association: :brex_items, accounts: :brex_accounts }, { key: "sophtron", type: "SophtronItem", association: :sophtron_items, accounts: :sophtron_accounts }, { key: "indexa_capital", type: "IndexaCapitalItem", association: :indexa_capital_items, accounts: :indexa_capital_accounts }, - { key: "questrade", type: "QuestradeItem", association: :questrade_items, accounts: :questrade_accounts } + { key: "questrade", type: "QuestradeItem", association: :questrade_items, accounts: :questrade_accounts }, + { key: "wise", type: "WiseItem", association: :wise_items, accounts: :wise_accounts } ].freeze class << self diff --git a/app/models/wise_account.rb b/app/models/wise_account.rb new file mode 100644 index 000000000..7869b867a --- /dev/null +++ b/app/models/wise_account.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class WiseAccount < ApplicationRecord + include Encryptable + + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end + + belongs_to :wise_item + + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider + + validates :balance_id, :currency, presence: true + validates :balance_id, uniqueness: { scope: :wise_item_id } + + scope :unlinked, -> { left_joins(:account_provider).where(account_providers: { id: nil }) } + + def current_account + account + end + + def jar? + raw_payload&.dig("type") == "SAVINGS" + end + + def account_subtype + jar? ? "savings" : Depository::DEFAULT_SUBTYPE + end + + def upsert_wise_snapshot!(balance_data, borderless_account_id: nil, recipient_id: nil) + data = balance_data.with_indifferent_access + payload = balance_data.is_a?(Hash) ? balance_data.dup : balance_data + payload = payload.merge("borderless_account_id" => borderless_account_id) if borderless_account_id + payload = payload.merge("recipient_id" => recipient_id) if recipient_id + + currency_code = data.dig(:amount, :currency).presence || data[:currency].presence || currency + savings = data[:type] == "SAVINGS" + balance_value = savings ? data.dig(:totalWorth, :value).to_d : data.dig(:amount, :value).to_d + api_name = data[:name].presence + default_name = savings ? "Wise JAR #{currency_code}" : "Wise #{currency_code}" + + update!( + current_balance: balance_value, + reserved_balance: data.dig(:reservedAmount, :value).to_d, + currency: currency_code, + name: name.presence || api_name || default_name, + raw_payload: payload + ) + end + + def upsert_wise_transactions_snapshot!(transactions) + update!(raw_transactions_payload: transactions) + end +end diff --git a/app/models/wise_account/processor.rb b/app/models/wise_account/processor.rb new file mode 100644 index 000000000..82105a0de --- /dev/null +++ b/app/models/wise_account/processor.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class WiseAccount::Processor + attr_reader :wise_account + + def initialize(wise_account) + @wise_account = wise_account + end + + def process + unless wise_account.current_account.present? + Rails.logger.info "WiseAccount::Processor - No linked account for wise_account #{wise_account.id}, skipping" + return + end + + process_account! + process_transactions + rescue StandardError => e + Rails.logger.error "WiseAccount::Processor - Failed to process account #{wise_account.id}: #{e.message}" + Sentry.capture_exception(e) { |s| s.set_tags(wise_account_id: wise_account.id) } + raise + end + + private + + def process_account! + account = wise_account.current_account + balance = wise_account.current_balance || 0 + + account.update!( + balance: balance, + cash_balance: balance, + currency: wise_account.currency + ) + end + + def process_transactions + WiseAccount::Transactions::Processor.new(wise_account).process + rescue StandardError => e + Rails.logger.error "WiseAccount::Processor - Failed to process transactions for wise_account #{wise_account.id}: #{e.message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + Sentry.capture_exception(e) { |s| s.set_tags(wise_account_id: wise_account.id) } + end +end diff --git a/app/models/wise_account/transactions/processor.rb b/app/models/wise_account/transactions/processor.rb new file mode 100644 index 000000000..d8bd176d2 --- /dev/null +++ b/app/models/wise_account/transactions/processor.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class WiseAccount::Transactions::Processor + attr_reader :wise_account + + def initialize(wise_account) + @wise_account = wise_account + end + + def process + unless wise_account.raw_transactions_payload.present? + Rails.logger.info "WiseAccount::Transactions::Processor - No transactions for wise_account #{wise_account.id}" + return { success: true, total: 0, imported: 0, skipped: 0, failed: 0, errors: [] } + end + + total = wise_account.raw_transactions_payload.count + Rails.logger.info "WiseAccount::Transactions::Processor - Processing #{total} transactions for wise_account #{wise_account.id}" + + imported = 0 + failed = 0 + skipped = 0 + errors = [] + + wise_account.raw_transactions_payload.each_with_index do |tx_data, index| + # Activities (from the Wise activities API) carry a "type" field; transfers do not. + processor_class = tx_data["type"].present? ? WiseActivity::Processor : WiseEntry::Processor + result = processor_class.new(tx_data, wise_account: wise_account).process + + case result + when :skipped + skipped += 1 + when nil + failed += 1 + errors << { index: index, error: "No transaction imported" } + else + imported += 1 + end + rescue ArgumentError => e + failed += 1 + Rails.logger.error "WiseAccount::Transactions::Processor - Validation error at index #{index}: #{e.message}" + errors << { index: index, error: e.message } + rescue => e + failed += 1 + Rails.logger.error "WiseAccount::Transactions::Processor - Error at index #{index}: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + errors << { index: index, error: "#{e.class}: #{e.message}" } + end + + Rails.logger.info "WiseAccount::Transactions::Processor - Done: #{imported} imported, #{skipped} skipped, #{failed} failed" + + { success: failed == 0, total: total, imported: imported, skipped: skipped, failed: failed, errors: errors } + end +end diff --git a/app/models/wise_activity/processor.rb b/app/models/wise_activity/processor.rb new file mode 100644 index 000000000..e58d5c9df --- /dev/null +++ b/app/models/wise_activity/processor.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +class WiseActivity::Processor + JAR_ACTIVITY_TYPES = %w[INTERBALANCE BALANCE_CASHBACK BALANCE_ASSET_FEE].freeze + + def initialize(activity, wise_account:) + @activity = activity.with_indifferent_access + @wise_account = wise_account + end + + def process + unless account.present? + Rails.logger.warn "WiseActivity::Processor - No linked account for wise_account #{wise_account.id}, skipping #{safe_id}" + return :skipped + end + + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "wise", + extra: extra + ) + rescue ArgumentError => e + Rails.logger.error "WiseActivity::Processor - Validation error for activity #{safe_id}: #{e.message}" + raise + rescue => e + Rails.logger.error "WiseActivity::Processor - Error for activity #{safe_id}: #{e.class} - #{e.message}" + raise + end + + private + + attr_reader :activity, :wise_account + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + @account ||= wise_account.current_account + end + + # INTERBALANCE uses resource_id so both sides can be matched for Transfer linking. + # Other activity types use the opaque activity id. + def external_id + if activity_type == "INTERBALANCE" + side = wise_account.jar? ? "inflow" : "outflow" + "wise_interbalance_#{resource_id}_#{side}" + else + "wise_activity_#{activity[:id]}" + end + end + + def safe_id + activity[:id].presence || "unknown" + end + + def activity_type + activity[:type].to_s + end + + def resource_id + activity.dig(:resource, :id).to_s + end + + # Expenses (outflow) → positive in Sure. + # Incomes (inflow / interest) → negative in Sure. + def amount + raw = parse_amount + case activity_type + when "BALANCE_ASSET_FEE" + raw.abs # fee = outflow = positive (expense) + when "INTERBALANCE" + if wise_account.jar? + # JAR perspective: "To Jar" = deposit (income, negative) + deposit_direction? ? -raw.abs : raw.abs + else + # STANDARD perspective: "To Jar" = outflow (expense, positive) + deposit_direction? ? raw.abs : -raw.abs + end + else + -raw.abs # BALANCE_CASHBACK / interest → income (negative) + end + end + + # True when the activity title indicates money flowing INTO the JAR. + def deposit_direction? + title = strip_html(activity[:title]).downcase + title.start_with?("to") || title.include?("received") || title.include?("added") + end + + def currency + parse_currency || wise_account.currency + end + + def name + jar_name = wise_account.jar? ? (wise_account.raw_payload&.dig("name") || "Jar") : nil + + case activity_type + when "INTERBALANCE" + if wise_account.jar? + deposit_direction? ? I18n.t("wise_items.activities.jar_deposit") : I18n.t("wise_items.activities.jar_withdrawal") + else + deposit_direction? ? I18n.t("wise_items.activities.transfer_to_jar", jar: jar_name_for_standard) : I18n.t("wise_items.activities.transfer_from_jar", jar: jar_name_for_standard) + end + when "BALANCE_CASHBACK" + I18n.t("wise_items.activities.interest") + when "BALANCE_ASSET_FEE" + I18n.t("wise_items.activities.asset_fee") + else + strip_html(activity[:title]).strip.presence || + I18n.t("wise_items.activities.default_name") + end + end + + def jar_name_for_standard + activity[:title].to_s.scan(/([^<]+)<\/strong>/).flatten.last || "Jar" + end + + def date + raw = activity[:createdOn].presence + raise ArgumentError, "Activity missing createdOn" unless raw + DateTime.parse(raw).to_date + end + + def extra + { + wise: { + activity_id: activity[:id], + activity_type: activity_type, + resource_type: activity.dig(:resource, :type), + resource_id: resource_id.presence + }.compact + } + end + + # Parses the numeric amount from strings like: + # "1,000 EUR" → 1000.0 + # "+ 1.12 EUR" → 1.12 + # "0.83 EUR" → 0.83 + def parse_amount + stripped = strip_html(activity[:primaryAmount]).strip + stripped.scan(/[\d,]+\.?\d*/).first.to_s.delete(",").to_d + end + + def parse_currency + activity[:primaryAmount].to_s.scan(/\b[A-Z]{3}\b/).first + end + + def strip_html(str) + ActionController::Base.helpers.strip_tags(str.to_s) + end +end diff --git a/app/models/wise_entry/processor.rb b/app/models/wise_entry/processor.rb new file mode 100644 index 000000000..8bd42a92e --- /dev/null +++ b/app/models/wise_entry/processor.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +class WiseEntry::Processor + # Statuses Wise uses for outgoing transfers (money leaves the Wise balance). + OUTGOING_STATUSES = %w[ + processing funds_converted outgoing_payment_sent + bounced_back funds_refunded + ].freeze + + # Statuses Wise uses for incoming transfers (money arrives in the Wise balance). + INCOMING_STATUSES = %w[ + incoming_payment_waiting incoming_payment_received + funds_credited credited + ].freeze + + def initialize(wise_transaction, wise_account:) + @wise_transaction = wise_transaction + @wise_account = wise_account + end + + def process + unless account.present? + Rails.logger.warn "WiseEntry::Processor - No linked account for wise_account #{wise_account.id}, skipping #{safe_id}" + return :skipped + end + + result = import_main_transaction + + if fee > 0 + begin + import_fee_transaction + rescue StandardError => e + Rails.logger.warn "WiseEntry::Processor - Fee transaction failed for transfer #{safe_id}: #{e.message}" + end + end + + result + rescue ArgumentError => e + Rails.logger.error "WiseEntry::Processor - Validation error for transfer #{safe_id}: #{e.message}" + raise + rescue => e + Rails.logger.error "WiseEntry::Processor - Unexpected error for transfer #{safe_id}: #{e.class} - #{e.message}" + raise + end + + private + + attr_reader :wise_transaction, :wise_account + + def data + @data ||= wise_transaction.with_indifferent_access + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + @account ||= wise_account.current_account + end + + def import_main_transaction + import_adapter.import_transaction( + external_id: "wise_transfer_#{transfer_id}", + amount: main_amount, + currency: source_currency, + date: date, + name: name, + source: "wise", + extra: extra + ) + end + + def import_fee_transaction + import_adapter.import_transaction( + external_id: "wise_fee_#{transfer_id}", + amount: fee, + currency: source_currency, + date: date, + name: I18n.t("wise_items.entries.fee_name"), + source: "wise", + extra: { wise: { transfer_id: transfer_id, type: "FEE" } } + ) + end + + def transfer_id + data[:id].presence.tap { |id| raise ArgumentError, "Wise transfer missing id" unless id } + end + + def safe_id + data[:id].presence || "unknown" + end + + def name + ref = data.dig(:details, :reference).presence || data[:reference].presence + ref.present? ? ref : I18n.t("wise_items.entries.default_name") + end + + # Expenses (outgoing) → positive amount in Sure convention. + # Incomes (incoming) → negative amount in Sure convention. + # Incoming cross-currency transfers use targetValue (what actually arrived) not sourceValue. + def main_amount + if outgoing? + data[:sourceValue].to_d.abs + else + -(data[:targetValue].to_d.abs) + end + end + + # Fee is the difference between what was debited and what the recipient received, + # only applicable for same-currency transfers. + def fee + @fee ||= begin + return 0 unless source_currency == target_currency + + diff = (data[:sourceValue].to_d - data[:targetValue].to_d).round(4) + diff > 0 ? diff : 0 + end + end + + def source_currency + data[:sourceCurrency].presence || wise_account.currency + end + + def target_currency + data[:targetCurrency].presence || wise_account.currency + end + + # An expense if targetAccount does NOT match our Wise recipientId + # (i.e. money went TO an external account, not to us). + # An income if targetAccount == recipientId (money arrived at our Wise account). + # Falls back to status-based detection when no recipientId is stored. + def outgoing? + recipient_id = wise_account.raw_payload&.dig("recipient_id") + + if recipient_id.present? + return data[:targetAccount].to_s != recipient_id.to_s + end + + # Status-based fallback + status = data[:status].to_s.downcase + return false if INCOMING_STATUSES.any? { |s| status.include?(s) } + return true if OUTGOING_STATUSES.any? { |s| status.include?(s) } + + true + end + + def date + raw = data[:created].presence + raise ArgumentError, "Wise transfer missing created date" unless raw + + case raw + when Date then raw + when String then DateTime.parse(raw).to_date + else raise ArgumentError, "Invalid date format: #{raw.inspect}" + end + rescue ArgumentError + raise + rescue => e + raise ArgumentError, "Unable to parse date #{raw.inspect}: #{e.message}" + end + + def extra + { + wise: { + transfer_id: transfer_id, + status: data[:status], + direction: outgoing? ? "outgoing" : "incoming", + source_currency: source_currency, + source_value: data[:sourceValue], + target_currency: target_currency, + target_value: data[:targetValue], + rate: data[:rate], + fee: fee > 0 ? fee : nil, + reference: data.dig(:details, :reference).presence || data[:reference] + }.compact + } + end +end diff --git a/app/models/wise_item.rb b/app/models/wise_item.rb new file mode 100644 index 000000000..7684f1e6c --- /dev/null +++ b/app/models/wise_item.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +class WiseItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + enum :profile_type, { personal: "personal", business: "business" } + + if encryption_ready? + encrypts :token, deterministic: true + encrypts :raw_payload + end + + validates :name, :profile_id, :profile_type, presence: true + validates :token, presence: true, on: :create + validates :profile_id, uniqueness: { scope: :family_id } + + before_validation :normalize_token + + belongs_to :family + + has_many :wise_accounts, dependent: :destroy + has_many :accounts, through: :wise_accounts + + 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_wise_data(sync_start_date: nil) + provider = wise_provider + unless provider + Rails.logger.error "WiseItem #{id} - Cannot import: provider not configured" + raise Provider::Wise::WiseError.new("Wise provider is not configured", :not_configured) + end + + WiseItem::Importer.new(self, wise_provider: provider, sync_start_date: sync_start_date).import + rescue => e + Rails.logger.error "WiseItem #{id} - Failed to import data: #{e.message}" + raise + end + + def process_accounts + return [] if wise_accounts.empty? + + results = [] + wise_accounts.joins(:account).merge(Account.visible).each do |wise_account| + begin + result = WiseAccount::Processor.new(wise_account).process + results << { wise_account_id: wise_account.id, success: true, result: result } + rescue => e + Rails.logger.error "WiseItem #{id} - Failed to process account #{wise_account.id}: #{e.message}" + results << { wise_account_id: wise_account.id, success: false, error: e.message } + end + end + + results + end + + # Finds interbalance entry pairs (JAR inflow ↔ STANDARD outflow) and links them as Transfers. + def link_jar_transfers! + account_ids = accounts.pluck(:id) + return if account_ids.empty? + + inflow_entries = Entry.where(source: "wise", account_id: account_ids) + .where("external_id LIKE 'wise_interbalance_%_inflow'") + + inflow_entries.each do |inflow_entry| + resource_id = inflow_entry.external_id.sub("wise_interbalance_", "").sub("_inflow", "") + outflow_entry = Entry.where(source: "wise", account_id: account_ids, + external_id: "wise_interbalance_#{resource_id}_outflow").first + + next unless outflow_entry + next unless inflow_entry.entryable.is_a?(Transaction) && outflow_entry.entryable.is_a?(Transaction) + + inflow_txn = inflow_entry.entryable + outflow_txn = outflow_entry.entryable + + next if Transfer.exists?(inflow_transaction_id: inflow_txn.id) + next if Transfer.exists?(outflow_transaction_id: outflow_txn.id) + + transfer = Transfer.new(inflow_transaction: inflow_txn, outflow_transaction: outflow_txn, status: "confirmed") + unless transfer.save + Rails.logger.warn "WiseItem #{id} - Could not link interbalance #{resource_id}: #{transfer.errors.full_messages.join(", ")}" + end + rescue => e + Rails.logger.error "WiseItem #{id} - Error linking interbalance #{resource_id}: #{e.message}" + end + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + results = [] + accounts.visible.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + Rails.logger.error "WiseItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + end + end + + results + end + + def has_completed_initial_setup? + accounts.any? + end + + def credentials_configured? + token.to_s.strip.present? + end + + def sync_status_summary + total = total_accounts_count + linked = linked_accounts_count + unlinked = unlinked_accounts_count + + if total == 0 + I18n.t("wise_items.sync_status.no_accounts") + elsif unlinked == 0 + I18n.t("wise_items.sync_status.all_synced", count: linked) + else + I18n.t("wise_items.sync_status.partial_setup", synced: linked, pending: unlinked) + end + end + + def linked_accounts_count + wise_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + wise_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + wise_accounts.count + end + + def institution_display_name + "Wise" + end + + def wise_provider + return nil unless credentials_configured? + + Provider::Wise.new(token.to_s.strip, base_url: Rails.configuration.x.wise.base_url) + end + + private + + def normalize_token + self.token = token&.strip + end +end diff --git a/app/models/wise_item/importer.rb b/app/models/wise_item/importer.rb new file mode 100644 index 000000000..09ef7d954 --- /dev/null +++ b/app/models/wise_item/importer.rb @@ -0,0 +1,331 @@ +# frozen_string_literal: true + +class WiseItem::Importer + DEFAULT_HISTORY_DAYS = 90 + + attr_reader :wise_item, :wise_provider, :sync_start_date + + def initialize(wise_item, wise_provider:, sync_start_date: nil) + @wise_item = wise_item + @wise_provider = wise_provider + @sync_start_date = sync_start_date + end + + def import + Rails.logger.info "WiseItem::Importer - Starting import for item #{wise_item.id} (profile #{wise_item.profile_id})" + + balances = fetch_balances + return failed_result("Failed to fetch balances") if balances.nil? + + savings_balances = fetch_savings_balances + all_balances = Array(balances) + Array(savings_balances) + + borderless_accounts = fetch_borderless_accounts + account_result = import_balances(all_balances, borderless_accounts: borderless_accounts) + + transfers = fetch_transfers + activities = fetch_jar_activities + transaction_result = store_transfers_per_account(transfers, activities: activities) + @interbalance_activities = activities.select { |a| a["type"] == "INTERBALANCE" } + + wise_item.update!(status: :good) if account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero? + + { + success: account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero?, + **account_result, + **transaction_result + } + end + + private + + def fetch_balances + wise_provider.get_balances(wise_item.profile_id) + rescue Provider::Wise::WiseError => e + if e.error_type == :not_found + Rails.logger.info "WiseItem::Importer - No balances for profile #{wise_item.profile_id}" + return [] + end + mark_requires_update_if_credentials_error(e) + Rails.logger.error "WiseItem::Importer - Failed to fetch balances: #{e.message}" + nil + rescue => e + Rails.logger.error "WiseItem::Importer - Unexpected error fetching balances: #{e.class} - #{e.message}" + nil + end + + def fetch_savings_balances + wise_provider.get_savings_balances(wise_item.profile_id) + rescue Provider::Wise::WiseError => e + Rails.logger.info "WiseItem::Importer - No savings (JAR) balances for profile #{wise_item.profile_id}: #{e.message}" + [] + rescue => e + Rails.logger.warn "WiseItem::Importer - Unexpected error fetching savings balances: #{e.message}" + [] + end + + # Returns a map of { balance_id => { borderless_account_id:, recipient_id: } }. + # Used in WiseEntry::Processor to distinguish expenses (targetAccount != recipientId) + # from incomes (targetAccount == recipientId). + def fetch_borderless_accounts + result = wise_provider.get_borderless_accounts(wise_item.profile_id) + Array(result).each_with_object({}) do |ba, map| + borderless_id = ba["id"] + recipient_id = ba["recipientId"] + Array(ba["balances"]).each do |b| + map[b["id"].to_s] = { borderless_account_id: borderless_id, recipient_id: recipient_id } + end + end + rescue => e + Rails.logger.warn "WiseItem::Importer - Could not fetch borderless accounts (#{e.message})" + {} + end + + def import_balances(balances, borderless_accounts: {}) + accounts_created = 0 + accounts_updated = 0 + accounts_failed = 0 + + existing_ids = wise_item.wise_accounts.pluck(:balance_id).map(&:to_s).to_set + + Array(balances).each do |balance_data| + data = balance_data.with_indifferent_access + balance_id = data[:id].to_s + currency = data.dig(:amount, :currency).to_s + + next if balance_id.blank? || currency.blank? + + account_ids = borderless_accounts[balance_id] || {} + + is_savings = data[:type] == "SAVINGS" + api_name = data[:name].presence + + wise_account = wise_item.wise_accounts.find_or_initialize_by(balance_id: balance_id) + wise_account.currency ||= currency + wise_account.name ||= api_name || (is_savings ? "Wise JAR #{currency}" : "Wise #{currency}") + wise_account.upsert_wise_snapshot!( + data, + borderless_account_id: account_ids[:borderless_account_id], + recipient_id: account_ids[:recipient_id] + ) + + if existing_ids.include?(balance_id) + accounts_updated += 1 + else + accounts_created += 1 + existing_ids << balance_id + end + rescue => e + accounts_failed += 1 + Rails.logger.error "WiseItem::Importer - Failed to import balance #{balance_id.presence || 'unknown'}: #{e.message}" + end + + { accounts_created: accounts_created, accounts_updated: accounts_updated, accounts_failed: accounts_failed } + end + + # Fetches all profile activities and keeps only JAR-relevant types. + def fetch_jar_activities + cutoff = transfer_cutoff + activities = [] + cursor = nil + + loop do + result = with_rate_limit_retry { wise_provider.get_activities(wise_item.profile_id, cursor: cursor, size: 100) } + batch = Array(result["activities"]) + break if batch.empty? + + old_ones = batch.select { |a| parse_transfer_date(a["createdOn"]) < cutoff } + relevant = (batch - old_ones).select { |a| WiseActivity::Processor::JAR_ACTIVITY_TYPES.include?(a["type"]) } + activities.concat(relevant) + + break if old_ones.any? || batch.size < 100 + + cursor = result["cursor"] + break if cursor.nil? + end + + activities.uniq { |a| a["id"] } + rescue Provider::Wise::WiseError => e + Rails.logger.warn "WiseItem::Importer - Could not fetch activities (#{e.message})" + [] + rescue => e + Rails.logger.warn "WiseItem::Importer - Unexpected error fetching activities: #{e.message}" + [] + end + + # Fetches all transfers for the profile, filtered to the sync window. + def fetch_transfers + cutoff = transfer_cutoff + transfers = [] + offset = 0 + limit = 100 + + loop do + page = with_rate_limit_retry { wise_provider.get_transfers(wise_item.profile_id, limit: limit, offset: offset) } + batch = Array(page.is_a?(Hash) ? page["content"] : page) + break if batch.empty? + + # Wise returns transfers newest-first; stop once we're past the cutoff. + old_ones = batch.select { |t| parse_transfer_date(t["created"]) < cutoff } + transfers.concat(batch - old_ones) + break if old_ones.any? || batch.size < limit + + offset += limit + end + + transfers.uniq! { |t| t["id"] } + Rails.logger.info "WiseItem::Importer - Fetched #{transfers.size} transfers for profile #{wise_item.profile_id}" + transfers + rescue Provider::Wise::WiseError => e + Rails.logger.warn "WiseItem::Importer - Could not fetch transfers (#{e.message})" + [] + rescue => e + Rails.logger.warn "WiseItem::Importer - Unexpected error fetching transfers: #{e.message}" + [] + end + + # Partitions transfers by the currency relevant to each WiseAccount: + # - Expenses (outgoing): matched by sourceCurrency + # - Incomes (incoming): matched by targetCurrency + # Routes transfers to STANDARD accounts and activities to JAR accounts. + def store_transfers_per_account(transfers, activities: []) + transactions_imported = 0 + transactions_failed = 0 + + wise_item.wise_accounts.find_each do |wise_account| + if wise_account.jar? + jar_activities = activities.select { |a| activity_for_account?(a, wise_account) } + wise_account.upsert_wise_transactions_snapshot!(jar_activities) + transactions_imported += jar_activities.size + else + account_transfers = transfers.select do |t| + t["sourceCurrency"] == wise_account.currency || + t["targetCurrency"] == wise_account.currency + end + # Also include INTERBALANCE activities so the standard account shows outflows to the JAR. + interbalance = activities.select { |a| activity_for_account?(a, wise_account) } + wise_account.upsert_wise_transactions_snapshot!(account_transfers + interbalance) + transactions_imported += account_transfers.size + interbalance.size + end + rescue => e + transactions_failed += 1 + Rails.logger.error "WiseItem::Importer - Failed to store transactions for wise_account #{wise_account.id}: #{e.message}" + end + + { transactions_imported: transactions_imported, transactions_failed: transactions_failed } + end + + # Routes an activity to the given WiseAccount. + # JAR: receives INTERBALANCE where the activity title's tag matches the JAR name, + # plus BALANCE_CASHBACK and BALANCE_ASSET_FEE. + # STANDARD: receives all INTERBALANCE activities (outflow side of JAR transfers). + def activity_for_account?(activity, wise_account) + type = activity["type"] + + case type + when "INTERBALANCE" + if wise_account.jar? + jar_name_in_title = activity["title"].to_s.scan(/([^<]+)<\/strong>/).flatten.last.to_s.strip + jar_name_in_title.present? && jar_name_in_title.casecmp?(wise_account.name.to_s.strip) + else + true + end + when "BALANCE_ASSET_FEE", "BALANCE_CASHBACK" + wise_account.jar? + else + false + end + end + + # Called after entries have been created to link interbalance pairs as Sure Transfers. + def link_interbalance_transfers! + Array(@interbalance_activities).each do |activity| + resource_id = activity.dig("resource", "id").to_s + next if resource_id.blank? + + inflow_entry = entry_by_external_id("wise_interbalance_#{resource_id}_inflow") + outflow_entry = entry_by_external_id("wise_interbalance_#{resource_id}_outflow") + + next unless inflow_entry && outflow_entry + + inflow_txn = inflow_entry.entryable + outflow_txn = outflow_entry.entryable + + next unless inflow_txn.is_a?(Transaction) && outflow_txn.is_a?(Transaction) + next if Transfer.exists?(inflow_transaction_id: inflow_txn.id) + next if Transfer.exists?(outflow_transaction_id: outflow_txn.id) + + transfer = Transfer.new( + inflow_transaction: inflow_txn, + outflow_transaction: outflow_txn, + status: "confirmed" + ) + + unless transfer.save + Rails.logger.warn "WiseItem::Importer - Could not link interbalance #{resource_id}: #{transfer.errors.full_messages.join(", ")}" + end + rescue => e + Rails.logger.error "WiseItem::Importer - Error linking interbalance #{resource_id}: #{e.message}" + end + end + + def entry_by_external_id(external_id) + Entry.joins(account: :account_providers) + .where(external_id: external_id, source: "wise") + .where(account_providers: { provider_type: "WiseAccount" }) + .joins("INNER JOIN wise_accounts ON wise_accounts.id = account_providers.provider_id") + .where(wise_accounts: { wise_item_id: wise_item.id }) + .first + end + + def with_rate_limit_retry(max_retries: 3) + retries = 0 + begin + yield + rescue Provider::Wise::WiseError => e + raise unless e.error_type == :rate_limited && retries < max_retries + retries += 1 + sleep(2 ** retries) + retry + end + end + + def transfer_cutoff + # Use last_synced_at only if we actually have stored transfers — otherwise fall back to full history. + has_stored_transfers = wise_item.wise_accounts.any? { |wa| wa.raw_transactions_payload.present? } + + if has_stored_transfers && wise_item.last_synced_at.present? + wise_item.last_synced_at - 7.days + elsif sync_start_date.present? + sync_start_date.to_time + else + DEFAULT_HISTORY_DAYS.days.ago + end + end + + def parse_transfer_date(raw) + DateTime.parse(raw.to_s).to_time + rescue + Time.current + end + + def mark_requires_update_if_credentials_error(error) + return unless error.is_a?(Provider::Wise::WiseError) && error.error_type.in?([ :unauthorized, :access_forbidden ]) + + wise_item.update!(status: :requires_update) + rescue => e + Rails.logger.error "WiseItem::Importer - Failed to update item status: #{e.message}" + end + + def failed_result(error) + { + success: false, + error: error, + accounts_created: 0, + accounts_updated: 0, + accounts_failed: 0, + transactions_imported: 0, + transactions_failed: 0 + } + end +end diff --git a/app/models/wise_item/provided.rb b/app/models/wise_item/provided.rb new file mode 100644 index 000000000..db51cb89a --- /dev/null +++ b/app/models/wise_item/provided.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WiseItem::Provided + extend ActiveSupport::Concern + + def syncer + WiseItem::Syncer.new(self) + end +end diff --git a/app/models/wise_item/sync_complete_event.rb b/app/models/wise_item/sync_complete_event.rb new file mode 100644 index 000000000..49d24fa43 --- /dev/null +++ b/app/models/wise_item/sync_complete_event.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class WiseItem::SyncCompleteEvent + attr_reader :wise_item + + def initialize(wise_item) + @wise_item = wise_item + end + + def broadcast + wise_item.accounts.each do |account| + account.broadcast_sync_complete + end + + wise_item.broadcast_replace_to( + wise_item.family, + target: dom_id(wise_item), + partial: "wise_items/wise_item", + locals: { wise_item: wise_item } + ) + + wise_item.family.broadcast_sync_complete + end + + private + + def dom_id(record) + "#{record.class.name.underscore}_#{record.id}" + end +end diff --git a/app/models/wise_item/syncer.rb b/app/models/wise_item/syncer.rb new file mode 100644 index 000000000..d72f6a27c --- /dev/null +++ b/app/models/wise_item/syncer.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +class WiseItem::Syncer + include SyncStats::Collector + + SafeSyncError = Class.new(StandardError) + + attr_reader :wise_item + + def initialize(wise_item) + @wise_item = wise_item + end + + def perform_sync(sync) + sync_errors = [] + + # Phase 1: Import balances and transactions from Wise API + update_status(sync, :importing_accounts) + import_result = wise_item.import_latest_wise_data(sync_start_date: sync.window_start_date) + sync_errors.concat(import_result_errors(import_result)) + + # Phase 2: Collect setup statistics + update_status(sync, :checking_account_configuration) + + linked_count = wise_item.linked_accounts_count + unlinked_count = wise_item.unlinked_accounts_count + total_count = linked_count + unlinked_count + + collect_wise_setup_stats(sync, total_count: total_count, linked_count: linked_count, unlinked_count: unlinked_count) + + if unlinked_count.positive? + wise_item.update!(pending_account_setup: true) + update_status(sync, :accounts_need_setup, count: unlinked_count) + else + wise_item.update!(pending_account_setup: false) + end + + # Phase 3: Process transactions for linked accounts + if linked_count.positive? + update_status(sync, :processing_transactions) + mark_import_started(sync) + process_results = wise_item.process_accounts + sync_errors.concat(result_failure_errors(process_results, category: :account_processing_error, message_key: :account_processing_failed)) + + wise_item.link_jar_transfers! + + # Phase 4: Schedule balance calculations + update_status(sync, :calculating_balances) + schedule_results = wise_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + sync_errors.concat(result_failure_errors(schedule_results, category: :account_sync_error, message_key: :account_sync_failed)) + + # Phase 5: Collect transaction statistics + account_ids = wise_item.wise_accounts + .joins(:account_provider) + .includes(account_provider: :account) + .filter_map { |wa| wa.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "wise") + end + + collect_health_stats(sync, errors: sync_errors.presence) + rescue => e + safe_message = user_safe_error_message(e) + Rails.logger.error "WiseItem::Syncer - sync failed for item #{wise_item.id}: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + Sentry.capture_exception(e) { |s| s.set_tags(wise_item_id: wise_item.id) } + collect_health_stats(sync, errors: [ { message: safe_message, category: "sync_error" } ]) + raise SafeSyncError, safe_message + end + + def perform_post_sync + # no-op + end + + private + + def update_status(sync, key, **options) + return unless sync.respond_to?(:status_text) + + sync.update!(status_text: I18n.t("wise_items.syncer.#{key}", **options)) + end + + def collect_wise_setup_stats(sync, total_count:, linked_count:, unlinked_count:) + return unless sync.respond_to?(:sync_stats) + + merge_sync_stats(sync, { + "total_accounts" => total_count, + "linked_accounts" => linked_count, + "unlinked_accounts" => unlinked_count + }) + end + + def import_result_errors(result) + return [] if result.is_a?(Hash) && result[:success] + + return [ sync_error(:import_error, :import_failed) ] unless result.is_a?(Hash) + + errors = [] + errors << sync_error(:account_import_error, :accounts_failed, count: result[:accounts_failed]) if result[:accounts_failed].to_i.positive? + errors << sync_error(:transaction_import_error, :transactions_failed, count: result[:transactions_failed]) if result[:transactions_failed].to_i.positive? + errors << sync_error(:import_error, :import_failed) if errors.empty? + errors + end + + def result_failure_errors(results, category:, message_key:) + failed = Array(results).count { |r| r.is_a?(Hash) && r[:success] == false } + return [] unless failed.positive? + + [ sync_error(category, message_key, count: failed) ] + end + + def sync_error(category, message_key, **options) + { + message: I18n.t("wise_items.syncer.#{message_key}", **options), + category: category.to_s + } + end + + def user_safe_error_message(error) + if error.is_a?(Provider::Wise::WiseError) && error.error_type.in?([ :unauthorized, :access_forbidden ]) + I18n.t("wise_items.syncer.credentials_invalid") + else + I18n.t("wise_items.syncer.failed") + end + end +end diff --git a/app/models/wise_item/unlinking.rb b/app/models/wise_item/unlinking.rb new file mode 100644 index 000000000..1634b74c9 --- /dev/null +++ b/app/models/wise_item/unlinking.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module WiseItem::Unlinking + extend ActiveSupport::Concern + + def unlink_all!(dry_run: false) + results = [] + + wise_accounts.find_each do |provider_account| + links = AccountProvider.where(provider_type: "WiseAccount", provider_id: provider_account.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 + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + links.each(&:destroy!) + end + rescue StandardError => e + Rails.logger.warn( + "WiseItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + result[:error] = e.message + raise + end + end + + results + end +end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index fb3fe4395..4d7f161b8 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -17,7 +17,7 @@ ) %> <% end %> -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @akahu_items.empty? && @up_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? && @binance_items.empty? && @questrade_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @akahu_items.empty? && @up_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? && @binance_items.empty? && @questrade_items.empty? && @wise_items.empty? %> <%= render "empty" %> <% else %>
@@ -69,6 +69,10 @@ <%= render @binance_items.sort_by(&:created_at) %> <% end %> + <% if @wise_items.any? %> + <%= render @wise_items.sort_by(&:created_at) %> + <% end %> + <% if @snaptrade_items.any? %> <%= render @snaptrade_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/budgets/_budget_categories.html.erb b/app/views/budgets/_budget_categories.html.erb index addb349f3..edae68fad 100644 --- a/app/views/budgets/_budget_categories.html.erb +++ b/app/views/budgets/_budget_categories.html.erb @@ -28,7 +28,6 @@ groups: on_track_groups, uncategorized: uncategorized_budget_category, show_uncategorized: show_on_track_uncategorized, - over_budget_mode: false - %> + over_budget_mode: false %> -
\ No newline at end of file + diff --git a/app/views/budgets/_category_section.html.erb b/app/views/budgets/_category_section.html.erb index 52d4c4980..1f1c992b1 100644 --- a/app/views/budgets/_category_section.html.erb +++ b/app/views/budgets/_category_section.html.erb @@ -53,4 +53,4 @@ <% end %> - \ No newline at end of file + diff --git a/app/views/ibkr_items/setup_accounts.html.erb b/app/views/ibkr_items/setup_accounts.html.erb index 80fb4d1e3..5c85dc0ee 100644 --- a/app/views/ibkr_items/setup_accounts.html.erb +++ b/app/views/ibkr_items/setup_accounts.html.erb @@ -123,7 +123,7 @@

<%= ibkr_account.name %>

<%= link_form.select :account_id, - options_for_select(@linkable_accounts.map { |account| [t(".link_existing.manual_account_option", name: account.name, balance: number_to_currency(account.balance, unit: Money::Currency.new(account.currency || 'USD').symbol)), account.id] }), + options_for_select(@linkable_accounts.map { |account| [t(".link_existing.manual_account_option", name: account.name, balance: number_to_currency(account.balance, unit: Money::Currency.new(account.currency || "USD").symbol)), account.id] }), { prompt: t(".link_existing.select_prompt") }, class: "bg-container border border-primary rounded px-2 py-1 text-sm text-primary flex-1 min-w-0" %> <%= render DS::Button.new( @@ -140,7 +140,7 @@ <% end %> <% if @linked_accounts.any? %> -
+
">

<%= t(".linked_accounts.title") %>

<% @linked_accounts.each do |ibkr_account| %>
diff --git a/app/views/import/configurations/_merchant_import.html.erb b/app/views/import/configurations/_merchant_import.html.erb index 7f4efc1fa..b434e5461 100644 --- a/app/views/import/configurations/_merchant_import.html.erb +++ b/app/views/import/configurations/_merchant_import.html.erb @@ -11,4 +11,4 @@

<%= t("import.configurations.merchant_import.instructions") %>

<%= form.submit t("import.configurations.merchant_import.button_label"), disabled: import.complete? %> <% end %> -
\ No newline at end of file +
diff --git a/app/views/reports/_period_picker.html.erb b/app/views/reports/_period_picker.html.erb index 764c95616..f5937f8aa 100644 --- a/app/views/reports/_period_picker.html.erb +++ b/app/views/reports/_period_picker.html.erb @@ -147,4 +147,4 @@
<% end %>
-<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/settings/providers/_wise_panel.html.erb b/app/views/settings/providers/_wise_panel.html.erb new file mode 100644 index 000000000..a656675c0 --- /dev/null +++ b/app/views/settings/providers/_wise_panel.html.erb @@ -0,0 +1,143 @@ +
+ <% active_items = local_assigns[:wise_items] || @wise_items || Current.family.wise_items.active.ordered %> + +
+

<%= t("wise_items.provider_panel.setup_title") %>

+
    +
  1. <%= t("wise_items.provider_panel.instructions.sign_in_html", link: link_to("Wise", "https://wise.com", target: "_blank", rel: "noopener noreferrer", class: "link")).html_safe %>
  2. +
  3. <%= t("wise_items.provider_panel.instructions.open_tokens") %>
  4. +
  5. <%= t("wise_items.provider_panel.instructions.create_token") %>
  6. +
  7. <%= t("wise_items.provider_panel.instructions.copy_token_html").html_safe %>
  8. +
+
+ + <% unless WiseItem.encryption_ready? %> +
+
+ <%= icon "shield-alert", size: "sm", class: "mt-0.5 shrink-0" %> +
+

<%= t("wise_items.provider_panel.encryption_warning.title") %>

+

<%= t("wise_items.provider_panel.encryption_warning.message") %>

+
+
+
+ <% end %> + + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+

<%= error_msg %>

+
+ <% end %> + + <% if active_items.any? %> +
+ <% active_items.each do |item| %> + <%= render DS::Disclosure.new(variant: :card) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+
+

WI

+
+
+

<%= item.name %>

+

<%= item.sync_status_summary %>

+
+
+
+ <% end %> + +
+
+ <%= render DS::Button.new( + text: t("wise_items.provider_panel.sync"), + icon: "refresh-cw", + variant: :outline, + size: :sm, + href: sync_wise_item_path(item), + method: :post, + disabled: item.syncing? + ) %> + <%= render DS::Button.new( + text: t("wise_items.provider_panel.disconnect"), + icon: "trash-2", + variant: :outline_destructive, + size: :sm, + href: wise_item_path(item), + method: :delete, + aria: { label: t("wise_items.provider_panel.disconnect_label", name: item.name) }, + confirm: t("wise_items.provider_panel.disconnect_confirm", name: item.name) + ) %> +
+ + <%= styled_form_with model: item, + url: wise_item_path(item), + scope: :wise_item, + method: :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :name, + label: t("wise_items.provider_panel.connection_name_label"), + placeholder: t("wise_items.provider_panel.connection_name_placeholder") %> + + <%= form.text_field :token, + label: t("wise_items.provider_panel.token_label"), + placeholder: t("wise_items.provider_panel.keep_token_placeholder"), + type: :password, + value: nil %> + +
+ <%= render DS::Link.new( + text: t("wise_items.provider_panel.setup_accounts"), + icon: "settings", + variant: "secondary", + href: setup_accounts_wise_item_path(item), + frame: :modal + ) %> + <%= form.submit t("wise_items.provider_panel.update_connection") %> +
+ <% end %> +
+ <% end %> + <% end %> +
+ <% end %> + + <%= render DS::Disclosure.new(variant: :card, open: active_items.none?) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+ <%= icon "plus" %> + <%= t("wise_items.provider_panel.add_connection") %> +
+ <% end %> + + <%= styled_form_with url: wise_items_path, + scope: :wise_item, + method: :post, + data: { turbo: true }, + class: "space-y-3 mt-4" do |form| %> + <%= form.text_field :token, + label: t("wise_items.provider_panel.token_label"), + placeholder: t("wise_items.provider_panel.token_placeholder"), + type: :password, + value: nil %> + +

<%= t("wise_items.provider_panel.sandbox_note_html").html_safe %>

+ +
+ <%= form.submit t("wise_items.provider_panel.connect") %> +
+ <% end %> + <% end %> + +
+ <% if active_items.any? %> +
+

<%= t("wise_items.provider_panel.configured_html", accounts_link: link_to(t("wise_items.provider_panel.accounts_link"), accounts_path, class: "link")).html_safe %>

+ <% else %> +
+

<%= t("wise_items.provider_panel.not_configured") %>

+ <% end %> +
+
diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index 9e34fbc63..67e135c6c 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -75,8 +75,7 @@ }.compact) # Preserve any existing action and append money-field handler existing_action = currency_data.delete("action") - currency_data["action"] = ["change->money-field#handleCurrencyChange", existing_action].compact.join(" ") - %> + currency_data["action"] = ["change->money-field#handleCurrencyChange", existing_action].compact.join(" ") %> <%= form.select currency_method, currency_picker_options_for_family(extra: currency.iso_code), { inline: true, selected: currency.iso_code }, diff --git a/app/views/transfers/_form.html.erb b/app/views/transfers/_form.html.erb index 445e8c30a..69a727448 100644 --- a/app/views/transfers/_form.html.erb +++ b/app/views/transfers/_form.html.erb @@ -30,11 +30,11 @@
<%= f.collection_select :from_account_id, @accounts, :id, :name, { prompt: t(".select_account"), label: t(".from"), selected: @from_account_id, variant: :logo }, { required: true, data: { transfer_form_target: "fromAccount", action: "change->transfer-form#checkCurrencyDifference" } } %> <%= f.collection_select :to_account_id, @accounts, :id, :name, { prompt: t(".select_account"), label: t(".to"), variant: :logo }, { required: true, data: { transfer_form_target: "toAccount", action: "change->transfer-form#checkCurrencyDifference" } } %> - + <%= f.date_field :date, value: transfer.inflow_transaction&.entry&.date || Date.current, label: t(".date"), required: true, max: Date.current, data: { transfer_form_target: "date", action: "change->transfer-form#checkCurrencyDifference" } %> - + <%= f.number_field :amount, label: t(".source_amount"), required: true, min: 0, placeholder: "100", step: 0.00000001, data: { transfer_form_target: "amount", action: "input->transfer-form#onSourceAmountChange" } %> - + <% convert_input = capture do %> <%= f.number_field :exchange_rate, label: t("shared.exchange_rate_tabs.exchange_rate"), diff --git a/app/views/wise_items/_wise_item.html.erb b/app/views/wise_items/_wise_item.html.erb new file mode 100644 index 000000000..edd95f580 --- /dev/null +++ b/app/views/wise_items/_wise_item.html.erb @@ -0,0 +1,101 @@ +<%# locals: (wise_item:) %> + +<%= tag.div id: dom_id(wise_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" %> + +
+

WI

+
+ +
+
+ <%= tag.p wise_item.name, class: "font-medium text-primary" %> + + <%= t("wise_items.profile_types.#{wise_item.profile_type}") %> + + <% if wise_item.scheduled_for_deletion? %> + <%= tag.p t(".deletion_in_progress"), class: "text-destructive text-sm animate-pulse" %> + <% end %> +
+ <% if wise_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif wise_item.sync_error.present? %> +
+ <%= render DS::Tooltip.new(text: wise_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive", as: :span) %> + <%= tag.span t(".error"), class: "text-destructive" %> +
+ <% else %> +

+ <% if wise_item.last_synced_at %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(wise_item.last_synced_at), summary: wise_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: wise_item_path(wise_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(wise_item.name, high_severity: true) + ) %> + <% end %> +
+ <% end %> +
+ <% end %> + + <% unless wise_item.scheduled_for_deletion? %> +
+ <% if wise_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: wise_item.accounts %> + <% end %> + + <% unlinked = wise_item.unlinked_accounts_count %> + <% total = wise_item.total_accounts_count %> + <% linked = wise_item.linked_accounts_count %> + + <% if unlinked > 0 %> +
+

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

+

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

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

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

+

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

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_wise_item_path(wise_item), + frame: :modal + ) %> +
+ <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/wise_items/select_accounts.html.erb b/app/views/wise_items/select_accounts.html.erb new file mode 100644 index 000000000..2c1a25b0f --- /dev/null +++ b/app/views/wise_items/select_accounts.html.erb @@ -0,0 +1,65 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+

<%= t(".description", product_name: product_name) %>

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

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

+
+
+ <%= render DS::Link.new( + text: t(".cancel"), + variant: :secondary, + href: @return_to || new_account_path, + frame: "_top" + ) %> +
+ <% else %> + <%= form_with url: link_accounts_wise_items_path, + method: :post, + data: { turbo_frame: "_top" }, + class: "space-y-4" do %> + <%= hidden_field_tag :wise_item_id, @wise_item.id %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @available_accounts.each do |wise_account| %> + + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t(".cancel"), + variant: :secondary, + href: @return_to || new_account_path, + frame: "_top" + ) %> + <%= render DS::Button.new( + text: t(".link_account"), + variant: :primary, + type: :submit + ) %> +
+ <% end %> + <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/wise_items/select_existing_account.html.erb b/app/views/wise_items/select_existing_account.html.erb new file mode 100644 index 000000000..ac7d521ea --- /dev/null +++ b/app/views/wise_items/select_existing_account.html.erb @@ -0,0 +1,65 @@ +<%= 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 %> +
+

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

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

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

+
+
+ <%= render DS::Link.new( + text: t(".cancel"), + variant: :secondary, + href: @return_to || accounts_path, + frame: "_top" + ) %> +
+ <% else %> + <%= form_with url: link_existing_account_wise_items_path, + method: :post, + data: { turbo_frame: "_top" }, + class: "space-y-4" do %> + <%= hidden_field_tag :wise_item_id, @wise_item&.id %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @available_accounts.each do |wise_account| %> + + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t(".cancel"), + variant: :secondary, + href: @return_to || accounts_path, + frame: "_top" + ) %> + <%= render DS::Button.new( + text: t(".link_account"), + variant: :primary, + type: :submit + ) %> +
+ <% end %> + <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/wise_items/select_profiles.html.erb b/app/views/wise_items/select_profiles.html.erb new file mode 100644 index 000000000..440ea9d82 --- /dev/null +++ b/app/views/wise_items/select_profiles.html.erb @@ -0,0 +1,66 @@ +<% content_for :title, t(".title") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "globe", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> + <%= form_with url: link_profiles_wise_items_path, + method: :post, + local: true, + data: { turbo_frame: "_top" }, + class: "space-y-6" do |form| %> + + <%= hidden_field_tag :encrypted_pending_token, @encrypted_pending_token %> + +

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

+ +
+ <% @pending_profiles.each do |profile| %> + <% profile_id = profile["id"].to_s %> + <% already_connected = @existing_profile_ids.include?(profile_id) %> + <% profile_type = profile["type"] == "business" ? "business" : "personal" %> + <% details = profile["details"] || {} %> + <% display_name = details["name"].presence || [ details["firstName"], details["lastName"] ].compact.join(" ") %> + + + <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".connect"), + variant: "primary", + icon: "link", + type: "submit", + class: "flex-1" + ) %> + <%= render DS::Link.new( + text: t(".cancel"), + variant: "secondary", + href: settings_providers_path + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/wise_items/setup_accounts.html.erb b/app/views/wise_items/setup_accounts.html.erb new file mode 100644 index 000000000..9913c04ec --- /dev/null +++ b/app/views/wise_items/setup_accounts.html.erb @@ -0,0 +1,52 @@ +<% content_for :title, t(".title") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "wallet", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> +
+ <% if @wise_accounts.empty? %> +
+ <%= icon "check-circle", size: "lg", class: "text-success" %> +

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

+

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

+
+ <%= render DS::Link.new(text: t(".done"), variant: "primary", href: accounts_path) %> + <% else %> +

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

+ +
+ <% @wise_accounts.each do |wise_account| %> +
+
+

<%= wise_account.name %>

+

+ <%= format_money(Money.new(wise_account.current_balance || 0, wise_account.currency)) %> +  ·  + <%= wise_account.currency %> +

+
+ <%= render DS::Button.new( + text: t(".create_account"), + icon: "plus", + variant: :outline, + size: :sm, + href: complete_account_setup_wise_item_path(@wise_item), + method: :post, + params: { wise_account_id: wise_account.id }, + frame: "_top" + ) %> +
+ <% end %> +
+ + <%= render DS::Link.new(text: t(".done"), variant: "secondary", href: accounts_path) %> + <% end %> +
+ <% end %> +<% end %> diff --git a/config/initializers/wise.rb b/config/initializers/wise.rb new file mode 100644 index 000000000..91fafcf53 --- /dev/null +++ b/config/initializers/wise.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Rails.configuration.x.wise.tap do |wise| + wise.base_url = ENV.fetch("WISE_BASE_URL", "https://api.wise.com") + wise.include_pending = ENV.fetch("WISE_INCLUDE_PENDING", "true") == "true" +end diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 381e2af65..a3b41ad22 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -355,6 +355,7 @@ en: lunchflow: Connect 20k+ banks from 40+ countries (UK, EU, USA and more!) enable_banking: Sync European bank accounts via PSD2 open banking. coinstats: Track your entire crypto portfolio across wallets and exchanges. + wise: Sync your Wise multi-currency balances and international transfers automatically. mercury: Sync your Mercury business banking accounts automatically. brex: Sync Brex cash and corporate card activity with read-only access. coinbase: Import your Coinbase crypto holdings and track performance. diff --git a/config/locales/views/wise_items/en.yml b/config/locales/views/wise_items/en.yml new file mode 100644 index 000000000..2da7f2613 --- /dev/null +++ b/config/locales/views/wise_items/en.yml @@ -0,0 +1,150 @@ +--- +en: + wise_items: + profile_types: + personal: Personal + business: Business + entries: + default_name: Wise transaction + fee_name: Wise fee + activities: + jar_deposit: Transfer to Jar + jar_withdrawal: Transfer from Jar + transfer_to_jar: "Transfer to %{jar}" + transfer_from_jar: "Transfer from %{jar}" + interest: Wise interest + asset_fee: Wise Assets fee + default_name: Wise activity + sync_status: + no_accounts: No accounts found + all_synced: + one: "%{count} account synced" + other: "%{count} accounts synced" + partial_setup: "%{synced} synced, %{pending} need setup" + create: + no_profiles_found: No Wise profiles found. Please check your API token. + invalid_token: Invalid API token. Please check and try again. + connection_failed: Could not connect to Wise. Please try again later. + destroy: + success: Wise connection removed + update: + success: Wise connection updated + sync: + success: Sync started + syncer: + importing_accounts: Importing accounts from Wise... + checking_account_configuration: Checking account configuration... + accounts_need_setup: + one: "%{count} account needs setup..." + other: "%{count} accounts need setup..." + processing_transactions: Processing transactions... + calculating_balances: Calculating balances... + credentials_invalid: Invalid Wise API token or insufficient permissions + failed: Sync failed. Please try again or contact support. + import_failed: Wise import failed. + accounts_failed: + one: "%{count} balance failed to import." + other: "%{count} balances failed to import." + transactions_failed: + one: "%{count} balance had transaction import failures." + other: "%{count} balances had transaction import failures." + account_processing_failed: + one: "%{count} Wise account failed while processing." + other: "%{count} Wise accounts failed while processing." + account_sync_failed: + one: "%{count} Wise account sync could not be scheduled." + other: "%{count} Wise account syncs could not be scheduled." + provider_panel: + setup_title: "Setup instructions:" + add_connection: Add Wise connection + token_label: API token + token_placeholder: Paste your Wise personal API token + keep_token_placeholder: Leave blank to keep the current token + connection_name_label: Connection name + connection_name_placeholder: Wise Personal + connect: Connect Wise + update_connection: Update connection + setup_accounts: Set up accounts + sync: Sync + disconnect: Disconnect + disconnect_label: "Disconnect %{name}" + disconnect_confirm: "Are you sure you want to disconnect %{name}? This will remove all synced account data." + accounts_link: Accounts + configured_html: "Connected and syncing. Visit the %{accounts_link} tab to manage your accounts." + not_configured: Not configured + sandbox_note_html: "Use the sandbox base URL (https://api.sandbox.transferwise.tech) for testing. Set WISE_BASE_URL in your environment." + encryption_warning: + title: Database encryption is not configured + message: Configure Active Record encryption keys before adding Wise tokens in production. Without encryption, tokens are stored in plaintext. + instructions: + sign_in_html: "Visit %{link} and log in to your account" + open_tokens: "Go to Settings → API tokens" + create_token: "Create a new personal API token with read-only access" + copy_token_html: "Copy the token and paste it below. Sure uses it only to sync your balances and transactions." + provider_connection: + default_name: Wise + default_description: Connect your Wise multi-currency account + name: "Wise — %{name}" + description: "Connect using %{name}" + link_profiles: + session_expired: Session expired. Please try connecting again. + no_profiles_selected: Please select at least one profile. + already_connected: All selected profiles are already connected. + success: + one: "Successfully connected %{count} Wise profile" + other: "Successfully connected %{count} Wise profiles" + select_profiles: + session_expired: Session expired. Please try connecting again. + title: Select Wise Profiles + subtitle: Choose which profiles to connect + description: Your Wise account has multiple profiles. Select the ones you want to sync with Sure. + unnamed_profile: "(Unnamed profile)" + already_connected_label: Already connected + connect: Connect selected profiles + cancel: Cancel + wise_item: + deletion_in_progress: deletion in progress... + syncing: Syncing... + error: Sync error + status: "Synced %{timestamp} ago" + status_never: Never synced + status_with_summary: "Last synced %{timestamp} ago — %{summary}" + delete: Disconnect + setup_needed: New accounts ready to set up + setup_description: "%{linked} of %{total} accounts linked. Create Sure accounts for your Wise currency balances." + setup_action: Set Up Accounts + no_accounts_title: No accounts linked yet + no_accounts_description: Run a sync to discover your Wise balances, then link them to Sure accounts. + setup_accounts: + title: Set Up Wise Accounts + subtitle: Link your Wise currency balances + description: Create a Sure account for each Wise currency balance you want to track. Each currency becomes its own account. + no_accounts_to_setup: All accounts are set up + all_accounts_linked: All your Wise currency balances have been linked to Sure accounts. + create_account: Create Account + done: Done + select_accounts: + title: Select Wise Balance + description: Select the Wise currency balance you want to link to your %{product_name} account. + no_accounts_found: All Wise balances are already linked to Sure accounts. + no_connection: No Wise connection found. Please connect Wise in Provider Settings first. + link_account: Link balance + cancel: Cancel + select_existing_account: + title: "Link %{account_name} with Wise" + description: Select the Wise currency balance to link with this account. Transactions will be synced automatically. + no_accounts_found: No unlinked Wise balances found. + link_account: Link balance + cancel: Cancel + link_accounts: + not_found: Wise balance not found. + success: Successfully linked Wise balance to account. + failed: Failed to link Wise balance. Please try again. + link_existing_account: + not_found: Account or Wise balance not found. + success: "Successfully linked %{account_name} with Wise" + failed: Failed to link account. Please try again. + complete_account_setup: + not_found: Wise balance not found. + success: Account created and linked successfully. + failed: Failed to create account. Please try again. diff --git a/config/routes.rb b/config/routes.rb index 05fa44112..c46497896 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,6 +50,23 @@ Rails.application.routes.draw do end end + resources :wise_items, only: %i[index new create show edit update destroy] do + collection do + get :select_profiles + post :link_profiles + 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 :brex_items, only: %i[index new create show edit update destroy] do collection do get :preload_accounts, to: "brex_items/account_flows#preload_accounts" diff --git a/db/migrate/20260618120000_create_wise_items_and_accounts.rb b/db/migrate/20260618120000_create_wise_items_and_accounts.rb new file mode 100644 index 000000000..55206216c --- /dev/null +++ b/db/migrate/20260618120000_create_wise_items_and_accounts.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class CreateWiseItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + create_table :wise_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + + t.string :profile_id, null: false + t.string :profile_type, null: false + t.string :name, null: false + + t.string :status, null: false, default: "good" + t.boolean :scheduled_for_deletion, null: false, default: false + t.boolean :pending_account_setup, null: false, default: false + + t.datetime :sync_start_date + + t.text :token, null: false + t.jsonb :raw_payload + + t.timestamps + end + + add_index :wise_items, :status + add_index :wise_items, [ :family_id, :profile_id ], unique: true + + create_table :wise_accounts, id: :uuid do |t| + t.references :wise_item, null: false, foreign_key: { on_delete: :cascade }, type: :uuid + + t.string :balance_id, null: false + t.string :currency, null: false + t.string :name + + t.decimal :current_balance, precision: 19, scale: 4 + t.decimal :reserved_balance, precision: 19, scale: 4 + + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + + t.timestamps + end + + add_index :wise_accounts, [ :wise_item_id, :balance_id ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index e64defa39..94e46d191 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2176,6 +2176,39 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_13_090000) do t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative" end + create_table "wise_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "balance_id", null: false + t.datetime "created_at", null: false + t.string "currency", null: false + t.decimal "current_balance", precision: 19, scale: 4 + t.string "name" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.decimal "reserved_balance", precision: 19, scale: 4 + t.datetime "updated_at", null: false + t.uuid "wise_item_id", null: false + t.index ["wise_item_id", "balance_id"], name: "index_wise_accounts_on_wise_item_id_and_balance_id", unique: true + t.index ["wise_item_id"], name: "index_wise_accounts_on_wise_item_id" + end + + create_table "wise_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.uuid "family_id", null: false + t.string "name", null: false + t.boolean "pending_account_setup", default: false, null: false + t.string "profile_id", null: false + t.string "profile_type", null: false + t.jsonb "raw_payload" + t.boolean "scheduled_for_deletion", default: false, null: false + t.string "status", default: "good", null: false + t.datetime "sync_start_date" + t.text "token", null: false + t.datetime "updated_at", null: false + t.index ["family_id", "profile_id"], name: "index_wise_items_on_family_id_and_profile_id", unique: true + t.index ["family_id"], name: "index_wise_items_on_family_id" + t.index ["status"], name: "index_wise_items_on_status" + end + add_foreign_key "account_providers", "accounts", on_delete: :cascade add_foreign_key "account_shares", "accounts" add_foreign_key "account_shares", "users" @@ -2305,4 +2338,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_13_090000) do add_foreign_key "users", "chats", column: "last_viewed_chat_id" add_foreign_key "users", "families" add_foreign_key "webauthn_credentials", "users" + add_foreign_key "wise_accounts", "wise_items", on_delete: :cascade + add_foreign_key "wise_items", "families" end diff --git a/test/controllers/wise_items_controller_test.rb b/test/controllers/wise_items_controller_test.rb new file mode 100644 index 000000000..cfcec1b1b --- /dev/null +++ b/test/controllers/wise_items_controller_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "test_helper" + +class WiseItemsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:family_admin) + SyncJob.stubs(:perform_later) + @family = families(:dylan_family) + @wise_item = wise_items(:one) + + @valid_profiles = [ + { "id" => "99999999", "type" => "personal", "details" => { "firstName" => "Jane", "lastName" => "Doe" } } + ] + end + + # create renders select_profiles directly — token must NOT appear in the session + + test "create renders select_profiles and keeps token out of session" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + assert_response :success + assert_nil session[:wise_pending_token], "raw API token must not be stored in the session" + assert_select "input[name='encrypted_pending_token']" + end + + test "create stores an encrypted token that round-trips to the original value" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + encrypted = css_select("input[name='encrypted_pending_token']").first["value"] + assert encrypted.present?, "hidden encrypted_pending_token field must be present" + + key = Rails.application.key_generator.generate_key("wise_pending_token", 32) + decrypted = ActiveSupport::MessageEncryptor.new(key).decrypt_and_verify(encrypted) + assert_equal "live_token_abc", decrypted + end + + test "create redirects to providers on blank token" do + post wise_items_url, params: { wise_item: { token: "" } } + assert_redirected_to settings_providers_path + assert_nil session[:wise_pending_token] + end + + test "create redirects to providers when Wise API rejects the token" do + Provider::Wise.any_instance.stubs(:get_profiles).raises( + Provider::Wise::WiseError.new("unauthorized", :unauthorized) + ) + + post wise_items_url, params: { wise_item: { token: "bad_token" } } + assert_redirected_to settings_providers_path + assert_nil session[:wise_pending_token] + end + + # link_profiles uses the encrypted hidden field, not the session + + test "link_profiles creates WiseItems using the encrypted token" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + encrypted = css_select("input[name='encrypted_pending_token']").first["value"] + + assert_difference "WiseItem.count", 1 do + post link_profiles_wise_items_url, params: { + encrypted_pending_token: encrypted, + profile_ids: [ "99999999" ] + } + end + + assert_redirected_to settings_providers_path + assert_equal "live_token_abc", @family.wise_items.find_by!(profile_id: "99999999").token + assert_nil session[:wise_pending_profiles] + end + + test "link_profiles redirects to new when encrypted token is missing" do + post link_profiles_wise_items_url, params: { + encrypted_pending_token: "", + profile_ids: [ "99999999" ] + } + + assert_redirected_to new_wise_item_path + end + + test "link_profiles redirects to new when encrypted token is tampered" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + post wise_items_url, params: { wise_item: { token: "live_token_abc" } } + + post link_profiles_wise_items_url, params: { + encrypted_pending_token: "tampered_garbage_value", + profile_ids: [ "99999999" ] + } + + assert_redirected_to new_wise_item_path + end +end diff --git a/test/fixtures/wise_accounts.yml b/test/fixtures/wise_accounts.yml new file mode 100644 index 000000000..9e2a5be60 --- /dev/null +++ b/test/fixtures/wise_accounts.yml @@ -0,0 +1,13 @@ +checking: + wise_item: one + balance_id: "10000001" + name: "Wise EUR" + currency: EUR + current_balance: 1000.00 + +jar: + wise_item: one + balance_id: "10000002" + name: "Jar" + currency: EUR + current_balance: 5000.00 diff --git a/test/fixtures/wise_items.yml b/test/fixtures/wise_items.yml new file mode 100644 index 000000000..4743c13d0 --- /dev/null +++ b/test/fixtures/wise_items.yml @@ -0,0 +1,7 @@ +one: + family: dylan_family + name: "Test Wise Connection" + token: "test_wise_token_123" + profile_id: "11111111" + profile_type: business + status: good diff --git a/test/models/investment_statement_test.rb b/test/models/investment_statement_test.rb index d8260a421..18d4ef51f 100644 --- a/test/models/investment_statement_test.rb +++ b/test/models/investment_statement_test.rb @@ -286,7 +286,7 @@ class InvestmentStatementTest < ActiveSupport::TestCase date: date, currency: account.currency, entryable: Trade.new( - security: Security.create!(ticker: "T#{SecureRandom.hex(2)}", name: "Test Security"), + security: Security.create!(ticker: "T#{SecureRandom.hex(8)}", name: "Test Security"), qty: qty, price: amount.to_d.abs / qty.to_d.abs, currency: account.currency diff --git a/test/models/wise_account_test.rb b/test/models/wise_account_test.rb new file mode 100644 index 000000000..5d69a6769 --- /dev/null +++ b/test/models/wise_account_test.rb @@ -0,0 +1,115 @@ +require "test_helper" + +class WiseAccountTest < ActiveSupport::TestCase + setup do + @wise_item = wise_items(:one) + @checking = wise_accounts(:checking) + @jar = wise_accounts(:jar) + end + + # jar? + + test "jar? returns false for STANDARD balance" do + @checking.update!(raw_payload: { "type" => "STANDARD" }) + assert_not @checking.jar? + end + + test "jar? returns true for SAVINGS balance" do + @jar.update!(raw_payload: { "type" => "SAVINGS" }) + assert @jar.jar? + end + + test "jar? returns false when raw_payload is nil" do + @checking.update!(raw_payload: nil) + assert_not @checking.jar? + end + + # account_subtype + + test "account_subtype returns checking for STANDARD account" do + @checking.update!(raw_payload: { "type" => "STANDARD" }) + assert_equal "checking", @checking.account_subtype + end + + test "account_subtype returns savings for JAR account" do + @jar.update!(raw_payload: { "type" => "SAVINGS" }) + assert_equal "savings", @jar.account_subtype + end + + # upsert_wise_snapshot! + + test "upsert_wise_snapshot! updates balance from STANDARD response" do + balance_data = { + "id" => "10000001", + "amount" => { "value" => 2500.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 100.0, "currency" => "EUR" }, + "type" => "STANDARD" + } + + @checking.upsert_wise_snapshot!(balance_data) + + assert_equal BigDecimal("2500.0"), @checking.current_balance + assert_equal BigDecimal("100.0"), @checking.reserved_balance + assert_equal "EUR", @checking.currency + assert_equal "STANDARD", @checking.raw_payload["type"] + end + + test "upsert_wise_snapshot! uses totalWorth for SAVINGS balance" do + balance_data = { + "id" => "10000002", + "amount" => { "value" => 4000.0, "currency" => "EUR" }, + "totalWorth" => { "value" => 5000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "SAVINGS", + "name" => "Jar" + } + + @jar.update!(name: nil) + @jar.upsert_wise_snapshot!(balance_data) + + assert_equal BigDecimal("5000.0"), @jar.current_balance + assert_equal "Jar", @jar.name + end + + test "upsert_wise_snapshot! stores borderless_account_id in raw_payload" do + balance_data = { + "id" => "10000001", + "amount" => { "value" => 1000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" } + } + + @checking.upsert_wise_snapshot!(balance_data, borderless_account_id: 88888001, recipient_id: 99999001) + + assert_equal 88888001, @checking.raw_payload["borderless_account_id"] + assert_equal 99999001, @checking.raw_payload["recipient_id"] + end + + test "upsert_wise_snapshot! preserves existing name" do + @checking.update!(name: "My Wise EUR") + balance_data = { + "id" => "10000001", + "amount" => { "value" => 1000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "name" => "Jar" + } + + @checking.upsert_wise_snapshot!(balance_data) + + assert_equal "My Wise EUR", @checking.name + end + + test "upsert_wise_snapshot! defaults name to Wise JAR currency for new SAVINGS account" do + @jar.update!(name: nil) + balance_data = { + "id" => "10000002", + "amount" => { "value" => 1000.0, "currency" => "EUR" }, + "totalWorth" => { "value" => 1000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "SAVINGS" + } + + @jar.upsert_wise_snapshot!(balance_data) + + assert_equal "Wise JAR EUR", @jar.name + end +end diff --git a/test/models/wise_activity/processor_test.rb b/test/models/wise_activity/processor_test.rb new file mode 100644 index 000000000..2514146e2 --- /dev/null +++ b/test/models/wise_activity/processor_test.rb @@ -0,0 +1,212 @@ +require "test_helper" + +class WiseActivity::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @wise_item = WiseItem.create!( + family: @family, + name: "Test Wise", + token: "test_token", + profile_id: "123", + profile_type: :business + ) + + @jar_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000002", + name: "Jar", + currency: "EUR", + raw_payload: { "type" => "SAVINGS", "name" => "Jar" } + ) + @jar_sure_account = Account.create!( + family: @family, + name: "Jar", + accountable: Depository.new(subtype: "savings"), + balance: 0, + currency: "EUR" + ) + AccountProvider.create!(account: @jar_sure_account, provider: @jar_account) + + @standard_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000001", + name: "Wise EUR", + currency: "EUR", + raw_payload: { "type" => "STANDARD", "recipient_id" => 99999001 } + ) + @standard_sure_account = Account.create!( + family: @family, + name: "Wise EUR", + accountable: Depository.new(subtype: "checking"), + balance: 0, + currency: "EUR" + ) + AccountProvider.create!(account: @standard_sure_account, provider: @standard_account) + end + + # INTERBALANCE — JAR side + + test "INTERBALANCE 'To Jar' on JAR account is income (negative)" do + activity = build_interbalance("To Jar", amount: "1,000 EUR", resource_id: "5001") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("-1000.0"), entry.amount + assert_equal "wise_interbalance_5001_inflow", entry.external_id + assert_equal I18n.t("wise_items.activities.jar_deposit"), entry.name + end + + test "INTERBALANCE 'From Jar' on JAR account is expense (positive)" do + activity = build_interbalance("From EUR", amount: "500 EUR", resource_id: "5002") + # Simulates a withdrawal: title doesn't start with "To" + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("500.0"), entry.amount + assert_equal "wise_interbalance_5002_inflow", entry.external_id + assert_equal I18n.t("wise_items.activities.jar_withdrawal"), entry.name + end + + # INTERBALANCE — STANDARD side + + test "INTERBALANCE 'To Jar' on STANDARD account is expense (positive outflow)" do + activity = build_interbalance("To Jar", amount: "1,000 EUR", resource_id: "5003") + + entry = WiseActivity::Processor.new(activity, wise_account: @standard_account).process + + assert_equal BigDecimal("1000.0"), entry.amount + assert_equal "wise_interbalance_5003_outflow", entry.external_id + end + + # Amount parsing + + test "parses comma-formatted amount" do + activity = build_interbalance("To Jar", amount: "2,500 EUR", resource_id: "6001") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("-2500.0"), entry.amount + end + + test "parses HTML-wrapped positive amount" do + activity = build_cashback(amount: "+ 3.53 EUR", resource_id: "57001") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("-3.53"), entry.amount + end + + test "parses plain decimal amount" do + activity = build_asset_fee(amount: "0.83 EUR", resource_id: "18001") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("0.83"), entry.amount + end + + # BALANCE_CASHBACK (interest) + + test "BALANCE_CASHBACK is imported as income (negative)" do + activity = build_cashback(amount: "+ 1.12 EUR", resource_id: "57002") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("-1.12"), entry.amount + assert_equal I18n.t("wise_items.activities.interest"), entry.name + assert_equal "wise_activity_#{activity["id"]}", entry.external_id + end + + # BALANCE_ASSET_FEE + + test "BALANCE_ASSET_FEE is imported as expense (positive)" do + activity = build_asset_fee(amount: "0.06 EUR", resource_id: "18002") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal BigDecimal("0.06"), entry.amount + assert_equal I18n.t("wise_items.activities.asset_fee"), entry.name + assert_equal "wise_activity_#{activity["id"]}", entry.external_id + end + + # Deduplication + + test "re-processing same activity returns existing entry" do + activity = build_cashback(amount: "+ 0.09 EUR", resource_id: "57003") + + entry1 = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + entry2 = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + assert_equal entry1.id, entry2.id + assert_equal 1, @jar_sure_account.entries.where(source: "wise").count + end + + # Skips without linked account + + test "returns skipped when JAR has no linked Sure account" do + unlinked = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000099", + name: "Unlinked Jar", + currency: "EUR", + raw_payload: { "type" => "SAVINGS", "name" => "Jar" } + ) + + result = WiseActivity::Processor.new( + build_cashback(amount: "0.10 EUR", resource_id: "1"), + wise_account: unlinked + ).process + + assert_equal :skipped, result + end + + # Extra metadata + + test "stores wise activity metadata in entry extra" do + activity = build_cashback(amount: "1.00 EUR", resource_id: "57004") + + entry = WiseActivity::Processor.new(activity, wise_account: @jar_account).process + + extra = entry.entryable.extra + assert_equal "BALANCE_CASHBACK", extra.dig("wise", "activity_type") + assert_equal "BALANCE_CASHBACK", extra.dig("wise", "resource_type") + assert_equal "57004", extra.dig("wise", "resource_id") + end + + private + + def build_interbalance(title, amount:, resource_id:) + { + "id" => "interbalance_activity_#{resource_id}", + "type" => "INTERBALANCE", + "resource" => { "type" => "BALANCE_TRANSACTION", "id" => resource_id }, + "title" => title, + "primaryAmount" => amount, + "status" => "COMPLETED", + "createdOn" => "2026-05-01T06:12:11.597Z" + } + end + + def build_cashback(amount:, resource_id:) + { + "id" => "cashback_activity_#{resource_id}", + "type" => "BALANCE_CASHBACK", + "resource" => { "type" => "BALANCE_CASHBACK", "id" => resource_id }, + "title" => "Cashback", + "primaryAmount" => amount, + "status" => "COMPLETED", + "createdOn" => "2026-06-03T07:26:34.500Z" + } + end + + def build_asset_fee(amount:, resource_id:) + { + "id" => "fee_activity_#{resource_id}", + "type" => "BALANCE_ASSET_FEE", + "resource" => { "type" => "ACCRUAL_CHARGE", "id" => resource_id }, + "title" => "Wise Assets Europe fee", + "primaryAmount" => amount, + "status" => "COMPLETED", + "createdOn" => "2026-06-02T18:11:07.593Z" + } + end +end diff --git a/test/models/wise_entry/processor_test.rb b/test/models/wise_entry/processor_test.rb new file mode 100644 index 000000000..2fc3cb6e6 --- /dev/null +++ b/test/models/wise_entry/processor_test.rb @@ -0,0 +1,177 @@ +require "test_helper" + +class WiseEntry::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @wise_item = WiseItem.create!( + family: @family, + name: "Test Wise", + token: "test_token", + profile_id: "123", + profile_type: :business + ) + @wise_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000001", + name: "Wise EUR", + currency: "EUR", + raw_payload: { "type" => "STANDARD", "recipient_id" => 99999001 } + ) + @account = Account.create!( + family: @family, + name: "Wise EUR", + accountable: Depository.new(subtype: "checking"), + balance: 0, + currency: "EUR" + ) + AccountProvider.create!(account: @account, provider: @wise_account) + end + + # Income vs expense detection + + test "imports outgoing transfer (targetAccount != recipientId) as positive expense" do + transfer = build_transfer(id: 1001, target_account: 9999999, source_value: 500.0, target_value: 500.0) + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal BigDecimal("500.0"), entry.amount + assert_equal "outgoing", entry.entryable.extra.dig("wise", "direction") + end + + test "imports incoming transfer (targetAccount == recipientId) as negative income" do + transfer = build_transfer(id: 1002, target_account: 99999001, source_value: 1200.0, target_value: 1200.0) + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal BigDecimal("-1200.0"), entry.amount + assert_equal "incoming", entry.entryable.extra.dig("wise", "direction") + end + + test "uses status-based fallback when no recipient_id stored" do + @wise_account.update!(raw_payload: { "type" => "STANDARD" }) + + outgoing = build_transfer(id: 1003, target_account: 9999, source_value: 100.0, + status: "outgoing_payment_sent") + entry = WiseEntry::Processor.new(outgoing, wise_account: @wise_account).process + assert entry.amount.positive? + + incoming = build_transfer(id: 1004, target_account: 9999, source_value: 100.0, + status: "funds_credited") + entry = WiseEntry::Processor.new(incoming, wise_account: @wise_account).process + assert entry.amount.negative? + end + + # External ID and deduplication + + test "uses stable external_id based on transfer id" do + transfer = build_transfer(id: 2001, target_account: 9999) + + entry1 = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + entry2 = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal "wise_transfer_2001", entry1.external_id + assert_equal entry1.id, entry2.id + assert_equal 1, @account.entries.where(source: "wise").count + end + + # Reference as name + + test "uses details.reference as transaction name when present" do + transfer = build_transfer(id: 3001, target_account: 9999, reference: "Invoice #42") + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal "Invoice #42", entry.name + end + + test "falls back to default name when no reference" do + transfer = build_transfer(id: 3002, target_account: 9999, reference: nil) + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal I18n.t("wise_items.entries.default_name"), entry.name + end + + # Fee handling + + test "creates separate fee entry when sourceValue exceeds targetValue in same currency" do + transfer = build_transfer(id: 4001, target_account: 9999, source_value: 100.5, target_value: 100.0) + + WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + entries = @account.entries.where(source: "wise").order(:created_at) + assert_equal 2, entries.count + + fee_entry = entries.find { |e| e.external_id == "wise_fee_4001" } + assert_not_nil fee_entry + assert_equal BigDecimal("0.5"), fee_entry.amount + assert_equal I18n.t("wise_items.entries.fee_name"), fee_entry.name + end + + test "does not create fee entry when sourceValue equals targetValue" do + transfer = build_transfer(id: 4002, target_account: 9999, source_value: 200.0, target_value: 200.0) + + WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal 1, @account.entries.where(source: "wise").count + end + + test "does not create fee entry for cross-currency transfers" do + transfer = build_transfer(id: 4003, target_account: 9999, source_value: 100.0, target_value: 90.0, + source_currency: "EUR", target_currency: "GBP") + + WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal 1, @account.entries.where(source: "wise").count + end + + # Skips without linked account + + test "returns skipped when no account linked" do + unlinked_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000099", + name: "Unlinked", + currency: "GBP" + ) + + result = WiseEntry::Processor.new(build_transfer(id: 5001, target_account: 9999), + wise_account: unlinked_account).process + + assert_equal :skipped, result + end + + # Extra metadata + + test "stores wise metadata in entry extra" do + transfer = build_transfer(id: 6001, target_account: 9999, source_value: 50.0, reference: "test ref") + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + extra = entry.entryable.extra + assert_equal 6001, extra.dig("wise", "transfer_id") + assert_equal "outgoing_payment_sent", extra.dig("wise", "status") + assert_equal "EUR", extra.dig("wise", "source_currency") + assert_equal "test ref", extra.dig("wise", "reference") + end + + private + + def build_transfer(id:, target_account:, source_value: 100.0, target_value: nil, + source_currency: "EUR", target_currency: nil, status: "outgoing_payment_sent", + reference: nil) + { + "id" => id, + "targetAccount" => target_account, + "sourceAccount" => nil, + "sourceCurrency" => source_currency, + "sourceValue" => source_value, + "targetCurrency" => target_currency || source_currency, + "targetValue" => target_value || source_value, + "status" => status, + "rate" => 1.0, + "created" => "2026-01-15 10:00:00", + "details" => reference ? { "reference" => reference } : {} + } + end +end diff --git a/test/models/wise_item/importer_test.rb b/test/models/wise_item/importer_test.rb new file mode 100644 index 000000000..f9afd5a4d --- /dev/null +++ b/test/models/wise_item/importer_test.rb @@ -0,0 +1,263 @@ +require "test_helper" + +class WiseItem::ImporterTest < ActiveSupport::TestCase + class FakeWiseProvider + attr_reader :calls + + def initialize(balances: nil, savings_balances: nil, borderless_accounts: nil, + transfers: nil, activities: nil, raise_on: {}) + @balances = balances || [ standard_balance ] + @savings_balances = savings_balances || [] + @borderless_accounts = borderless_accounts || [ borderless_account ] + @transfers = transfers || [] + @activities = activities || [] + @raise_on = raise_on + @calls = [] + end + + def get_balances(profile_id) + @calls << :get_balances + raise_if(:get_balances) + @balances + end + + def get_savings_balances(profile_id) + @calls << :get_savings_balances + raise_if(:get_savings_balances) + @savings_balances + end + + def get_borderless_accounts(profile_id) + @calls << :get_borderless_accounts + raise_if(:get_borderless_accounts) + @borderless_accounts + end + + def get_transfers(profile_id, limit: 100, offset: 0) + @calls << :get_transfers + @transfers + end + + def get_activities(profile_id, cursor: nil, size: 100) + @calls << :get_activities + raise_if(:get_activities) + { "activities" => @activities, "cursor" => nil } + end + + private + + def raise_if(method) + error = @raise_on[method] + raise Provider::Wise::WiseError.new(error, :fetch_failed) if error + end + + def standard_balance + { + "id" => "10000001", + "amount" => { "value" => 1964.88, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "STANDARD" + } + end + + def borderless_account + { + "id" => 88888001, + "recipientId" => 99999001, + "balances" => [ { "id" => 10000001 } ] + } + end + end + + setup do + @family = families(:empty) + @wise_item = WiseItem.create!( + family: @family, + name: "Test Wise", + token: "test_token", + profile_id: "11111111", + profile_type: :business + ) + end + + # STANDARD balance import + + test "imports STANDARD balances and creates WiseAccount records" do + provider = FakeWiseProvider.new + + result = WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + assert result[:success] + assert_equal 1, result[:accounts_created] + assert_equal 0, result[:accounts_updated] + + account = @wise_item.wise_accounts.first + assert_equal "10000001", account.balance_id + assert_equal "EUR", account.currency + assert_not account.jar? + end + + test "stores borderless_account_id and recipient_id in STANDARD account raw_payload" do + provider = FakeWiseProvider.new + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + account = @wise_item.wise_accounts.find_by(balance_id: "10000001") + assert_equal 88888001, account.raw_payload["borderless_account_id"] + assert_equal 99999001, account.raw_payload["recipient_id"] + end + + # SAVINGS (JAR) balance import + + test "imports SAVINGS balances and marks them as JAR" do + savings = { + "id" => "10000002", + "amount" => { "value" => 11022.16, "currency" => "EUR" }, + "totalWorth" => { "value" => 11022.16, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "SAVINGS", + "name" => "Jar" + } + provider = FakeWiseProvider.new(savings_balances: [ savings ]) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + jar = @wise_item.wise_accounts.find_by(balance_id: "10000002") + assert_not_nil jar + assert jar.jar? + assert_equal BigDecimal("11022.16"), jar.current_balance + assert_equal "Jar", jar.name + end + + test "continues if savings balances fetch fails" do + provider = FakeWiseProvider.new(raise_on: { get_savings_balances: "forbidden" }) + + result = WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + assert result[:success] + assert_equal 1, @wise_item.wise_accounts.count + end + + # Transfer routing + + test "routes transfers to matching STANDARD account by source currency" do + transfers = [ + build_transfer(id: 1, source_currency: "EUR", target_currency: "EUR", target_account: 9999), + build_transfer(id: 2, source_currency: "USD", target_currency: "USD", target_account: 9999) + ] + provider = FakeWiseProvider.new(transfers: transfers) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + eur_account = @wise_item.wise_accounts.find_by(currency: "EUR") + assert_equal 1, eur_account.raw_transactions_payload.size + assert_equal 1, eur_account.raw_transactions_payload.first["id"] + end + + test "routes incoming transfers to account by target currency" do + transfers = [ + build_transfer(id: 3, source_currency: "USD", target_currency: "EUR", target_account: 99999001) + ] + provider = FakeWiseProvider.new(transfers: transfers) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + eur_account = @wise_item.wise_accounts.find_by(currency: "EUR") + assert_equal 1, eur_account.raw_transactions_payload.size + end + + # Activity routing + + test "routes INTERBALANCE activities to both JAR and STANDARD accounts" do + savings = { + "id" => "10000002", + "amount" => { "value" => 1000.0, "currency" => "EUR" }, + "totalWorth" => { "value" => 1000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "SAVINGS", + "name" => "Jar" + } + interbalance = build_interbalance("To Jar", resource_id: "5001") + provider = FakeWiseProvider.new(savings_balances: [ savings ], activities: [ interbalance ]) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + jar = @wise_item.wise_accounts.find_by(balance_id: "10000002") + standard = @wise_item.wise_accounts.find_by(balance_id: "10000001") + + assert_equal 1, jar.raw_transactions_payload.size + assert jar.raw_transactions_payload.any? { |a| a["type"] == "INTERBALANCE" } + assert standard.raw_transactions_payload.any? { |a| a["type"] == "INTERBALANCE" } + end + + test "routes BALANCE_CASHBACK only to JAR account" do + savings = { + "id" => "10000002", + "amount" => { "value" => 1000.0, "currency" => "EUR" }, + "totalWorth" => { "value" => 1000.0, "currency" => "EUR" }, + "reservedAmount" => { "value" => 0.0, "currency" => "EUR" }, + "type" => "SAVINGS", + "name" => "Jar" + } + cashback = build_cashback("57001") + provider = FakeWiseProvider.new(savings_balances: [ savings ], activities: [ cashback ]) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + jar = @wise_item.wise_accounts.find_by(balance_id: "10000002") + standard = @wise_item.wise_accounts.find_by(balance_id: "10000001") + + assert_equal 1, jar.raw_transactions_payload.size + assert_empty standard.raw_transactions_payload.select { |a| a["type"] == "BALANCE_CASHBACK" } + end + + # Returns failed result when balances fetch fails + + test "returns failed result when STANDARD balances fetch fails" do + provider = FakeWiseProvider.new(raise_on: { get_balances: "unauthorized" }) + + result = WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + assert_not result[:success] + assert_equal "Failed to fetch balances", result[:error] + end + + private + + def build_transfer(id:, source_currency: "EUR", target_currency: "EUR", target_account: 9999) + { + "id" => id, + "targetAccount" => target_account, + "sourceCurrency" => source_currency, + "targetCurrency" => target_currency, + "sourceValue" => 100.0, + "targetValue" => 100.0, + "status" => "outgoing_payment_sent", + "created" => 7.days.ago.strftime("%Y-%m-%d %H:%M:%S") + } + end + + def build_interbalance(title, resource_id:) + { + "id" => "interbalance_#{resource_id}", + "type" => "INTERBALANCE", + "resource" => { "type" => "BALANCE_TRANSACTION", "id" => resource_id }, + "title" => title, + "primaryAmount" => "1,000 EUR", + "status" => "COMPLETED", + "createdOn" => 7.days.ago.iso8601 + } + end + + def build_cashback(resource_id) + { + "id" => "cashback_#{resource_id}", + "type" => "BALANCE_CASHBACK", + "resource" => { "type" => "BALANCE_CASHBACK", "id" => resource_id }, + "title" => "Cashback", + "primaryAmount" => "+ 1.12 EUR", + "status" => "COMPLETED", + "createdOn" => 3.days.ago.iso8601 + } + end +end diff --git a/test/models/wise_item_test.rb b/test/models/wise_item_test.rb new file mode 100644 index 000000000..b913e5e70 --- /dev/null +++ b/test/models/wise_item_test.rb @@ -0,0 +1,115 @@ +require "test_helper" + +class WiseItemTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @wise_item = WiseItem.create!( + family: @family, + name: "Test Wise", + token: "test_token", + profile_id: "123", + profile_type: :business + ) + + @standard_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000001", + name: "Wise EUR", + currency: "EUR", + raw_payload: { "type" => "STANDARD", "recipient_id" => 99999001 } + ) + @standard_sure_account = Account.create!( + family: @family, + name: "Wise EUR", + accountable: Depository.new(subtype: "checking"), + balance: 0, + currency: "EUR" + ) + AccountProvider.create!(account: @standard_sure_account, provider: @standard_account) + + @jar_account = WiseAccount.create!( + wise_item: @wise_item, + balance_id: "10000002", + name: "Jar", + currency: "EUR", + raw_payload: { "type" => "SAVINGS", "name" => "Jar" } + ) + @jar_sure_account = Account.create!( + family: @family, + name: "Jar", + accountable: Depository.new(subtype: "savings"), + balance: 0, + currency: "EUR" + ) + AccountProvider.create!(account: @jar_sure_account, provider: @jar_account) + end + + # link_jar_transfers! + + test "links matching interbalance inflow and outflow entries as a Transfer" do + inflow_entry = create_interbalance_entry(@jar_sure_account, "5001", side: :inflow, amount: -1000.0) + outflow_entry = create_interbalance_entry(@standard_sure_account, "5001", side: :outflow, amount: 1000.0) + + assert_difference "Transfer.count", 1 do + @wise_item.link_jar_transfers! + end + + transfer = Transfer.find_by(inflow_transaction_id: inflow_entry.entryable_id) + assert_not_nil transfer + assert_equal outflow_entry.entryable_id, transfer.outflow_transaction_id + assert_equal "confirmed", transfer.status + end + + test "does not create duplicate Transfer for already-linked pair" do + inflow_entry = create_interbalance_entry(@jar_sure_account, "5002", side: :inflow, amount: -2000.0) + outflow_entry = create_interbalance_entry(@standard_sure_account, "5002", side: :outflow, amount: 2000.0) + + @wise_item.link_jar_transfers! + + assert_no_difference "Transfer.count" do + @wise_item.link_jar_transfers! + end + end + + test "skips unmatched inflow entries with no corresponding outflow" do + create_interbalance_entry(@jar_sure_account, "5003", side: :inflow, amount: -500.0) + + assert_no_difference "Transfer.count" do + @wise_item.link_jar_transfers! + end + end + + test "links multiple interbalance pairs in one call" do + create_interbalance_entry(@jar_sure_account, "6001", side: :inflow, amount: -1000.0) + create_interbalance_entry(@standard_sure_account, "6001", side: :outflow, amount: 1000.0) + create_interbalance_entry(@jar_sure_account, "6002", side: :inflow, amount: -3000.0) + create_interbalance_entry(@standard_sure_account, "6002", side: :outflow, amount: 3000.0) + + assert_difference "Transfer.count", 2 do + @wise_item.link_jar_transfers! + end + end + + test "does nothing when no interbalance entries exist" do + assert_no_difference "Transfer.count" do + @wise_item.link_jar_transfers! + end + end + + private + + def create_interbalance_entry(account, resource_id, side:, amount:) + external_id = "wise_interbalance_#{resource_id}_#{side}" + transaction = Transaction.create!(kind: "funds_movement") + entry = account.entries.create!( + external_id: external_id, + source: "wise", + amount: amount, + currency: "EUR", + date: Date.today, + name: "Transfer to Jar", + entryable: transaction + ) + entry + end +end