From babb039ad15e7543f71f3319933fbe6d92f2e9bb Mon Sep 17 00:00:00 2001 From: packetsnscripts Date: Tue, 11 Aug 2026 14:21:08 -0700 Subject: [PATCH] Fix wise imports only 90 days history on initial setup (#2998) * Wise connection get full transaction history and adjust for fees * undo devcontainers change * address comments in PR --- app/controllers/wise_items_controller.rb | 9 +- app/models/family/wise_connectable.rb | 5 +- app/models/provider/wise.rb | 61 +++++- .../wise_account/transactions/processor.rb | 9 +- app/models/wise_entry/processor.rb | 16 +- app/models/wise_item/importer.rb | 186 +++++++++++++++++- app/models/wise_statement/processor.rb | 148 ++++++++++++++ .../settings/providers/_wise_panel.html.erb | 14 ++ config/locales/views/wise_items/en.yml | 4 + config/locales/views/wise_items/fr.yml | 4 + config/locales/views/wise_items/pl.yml | 4 + config/locales/views/wise_items/tr.yml | 4 + ...00_add_import_all_history_to_wise_items.rb | 5 + db/schema.rb | 3 +- .../controllers/wise_items_controller_test.rb | 38 ++++ test/models/provider/wise_test.rb | 74 +++++++ test/models/wise_entry/processor_test.rb | 26 ++- test/models/wise_item/importer_test.rb | 142 ++++++++++++- test/models/wise_statement/processor_test.rb | 88 +++++++++ 19 files changed, 808 insertions(+), 32 deletions(-) create mode 100644 app/models/wise_statement/processor.rb create mode 100644 db/migrate/20260809000000_add_import_all_history_to_wise_items.rb create mode 100644 test/models/provider/wise_test.rb create mode 100644 test/models/wise_statement/processor_test.rb diff --git a/app/controllers/wise_items_controller.rb b/app/controllers/wise_items_controller.rb index f92daec0b..3026bb06e 100644 --- a/app/controllers/wise_items_controller.rb +++ b/app/controllers/wise_items_controller.rb @@ -37,6 +37,7 @@ class WiseItemsController < ApplicationController session[:wise_pending_profiles] = profiles session[:wise_pending_encrypted_token] = encrypt_pending_token(token) + session[:wise_pending_import_all_history] = params.dig(:wise_item, :import_all_history) == "1" redirect_to select_profiles_wise_items_path rescue Provider::Wise::WiseError => e @@ -72,6 +73,8 @@ class WiseItemsController < ApplicationController redirect_to select_profiles_wise_items_path, alert: t(".no_profiles_selected") and return end + import_all_history = session[:wise_pending_import_all_history] || false + created = 0 profiles.each do |profile| profile_id = profile["id"].to_s @@ -85,13 +88,15 @@ class WiseItemsController < ApplicationController token: token, profile_id: profile_id, profile_type: profile_type, - item_name: display_name + item_name: display_name, + import_all_history: import_all_history ) created += 1 end session.delete(:wise_pending_profiles) session.delete(:wise_pending_encrypted_token) + session.delete(:wise_pending_import_all_history) if created.zero? redirect_to settings_providers_path, alert: t(".already_connected") @@ -229,7 +234,7 @@ class WiseItemsController < ApplicationController end def wise_item_update_params - permitted = params.require(:wise_item).permit(:name, :sync_start_date, :token) + permitted = params.require(:wise_item).permit(:name, :sync_start_date, :import_all_history, :token) permitted.delete(:token) if @wise_item.persisted? && permitted[:token].blank? permitted[:token] = permitted[:token].to_s.strip if permitted[:token].present? permitted diff --git a/app/models/family/wise_connectable.rb b/app/models/family/wise_connectable.rb index 855277ea5..3113c7d26 100644 --- a/app/models/family/wise_connectable.rb +++ b/app/models/family/wise_connectable.rb @@ -11,12 +11,13 @@ module Family::WiseConnectable true end - def create_wise_item!(token:, profile_id:, profile_type:, item_name:) + def create_wise_item!(token:, profile_id:, profile_type:, item_name:, import_all_history: false) item = wise_items.create!( token: token, profile_id: profile_id, profile_type: profile_type, - name: item_name + name: item_name, + import_all_history: import_all_history ) item.sync_later diff --git a/app/models/provider/wise.rb b/app/models/provider/wise.rb index ddd64efda..6b3af322f 100644 --- a/app/models/provider/wise.rb +++ b/app/models/provider/wise.rb @@ -6,6 +6,9 @@ class Provider::Wise LIVE_BASE_URL = "https://api.wise.com" SANDBOX_BASE_URL = "https://api.sandbox.transferwise.tech" + # Wise caps a single balance-statement request at 469 days; chunk at 468 to + # stay safely within the limit while covering the full requested range. + MAX_STATEMENT_DAYS = 468 headers "User-Agent" => "Sure Finance Wise Client" default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) @@ -33,16 +36,40 @@ class Provider::Wise get("/v4/profiles/#{profile_id}/balances", query: { types: "SAVINGS" }) end - def get_balance_statement(profile_id, balance_id, interval_start:, interval_end:) + def get_balance_statement(profile_id, balance_id, interval_start:, interval_end:, currency: nil) get( "/v1/profiles/#{profile_id}/balance-statements/#{balance_id}/statement.json", query: { - intervalStart: interval_start.iso8601, - intervalEnd: interval_end.iso8601 + currency: currency, + intervalStart: interval_start.to_time.utc.iso8601, + intervalEnd: interval_end.to_time.utc.iso8601 } ) end + def get_balance_statements(profile_id, balance_id, currency:, start_date:, end_date: Date.current) + transactions = [] + window_start = start_date.to_date + end_date = end_date.to_date + + while window_start <= end_date + window_end = [ window_start + MAX_STATEMENT_DAYS - 1, end_date ].min + response = with_rate_limit_retry do + get_balance_statement( + profile_id, + balance_id, + currency: currency, + interval_start: window_start.beginning_of_day, + interval_end: window_end.end_of_day + ) + end + transactions.concat(Array(response["transactions"] || response[:transactions])) + window_start = window_end + 1.day + end + + transactions + end + def get_transfers(profile_id, limit: 100, offset: 0) get( "/v1/transfers", @@ -106,12 +133,28 @@ class Provider::Wise end end - class WiseError < StandardError - attr_reader :error_type + private - def initialize(message, error_type = :unknown) - super(message) - @error_type = error_type + # Retries only the failed window so a rate-limited request does not + # restart earlier windows in a multi-window statement fetch. + def with_rate_limit_retry(max_retries: 3) + retries = 0 + begin + yield + rescue WiseError => e + raise unless e.error_type == :rate_limited && retries < max_retries + retries += 1 + sleep(2 ** retries) + retry + end + end + + class WiseError < StandardError + attr_reader :error_type + + def initialize(message, error_type = :unknown) + super(message) + @error_type = error_type + end end - end end diff --git a/app/models/wise_account/transactions/processor.rb b/app/models/wise_account/transactions/processor.rb index d8bd176d2..fa31a0f56 100644 --- a/app/models/wise_account/transactions/processor.rb +++ b/app/models/wise_account/transactions/processor.rb @@ -22,8 +22,13 @@ class WiseAccount::Transactions::Processor 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 + processor_class = if WiseActivity::Processor::JAR_ACTIVITY_TYPES.include?(tx_data["type"]) + WiseActivity::Processor + elsif tx_data["wise_statement"].present? + WiseStatement::Processor + else + WiseEntry::Processor + end result = processor_class.new(tx_data, wise_account: wise_account).process case result diff --git a/app/models/wise_entry/processor.rb b/app/models/wise_entry/processor.rb index af4fe2c81..8f1bb86d6 100644 --- a/app/models/wise_entry/processor.rb +++ b/app/models/wise_entry/processor.rb @@ -169,6 +169,7 @@ class WiseEntry::Processor def extra { + exchange_rate: exchange_rate, wise: { transfer_id: transfer_id, status: data[:status], @@ -181,6 +182,19 @@ class WiseEntry::Processor fee: fee > 0 ? fee : nil, reference: data.dig(:details, :reference).presence || data[:reference] }.compact - } + }.compact + end + + # Wise reports the rate actually applied to the transfer (target units per + # source unit). Balance calculators read Transaction#exchange_rate + # (extra["exchange_rate"]) as a custom rate, so cross-currency transfers + # routed to an account in the target currency can be converted without a + # historical rate from the global exchange_rates table. + def exchange_rate + rate = data[:rate].presence + return nil if rate.blank? + + parsed = rate.to_d + parsed.positive? ? parsed : nil end end diff --git a/app/models/wise_item/importer.rb b/app/models/wise_item/importer.rb index 09ef7d954..221dbadbd 100644 --- a/app/models/wise_item/importer.rb +++ b/app/models/wise_item/importer.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true class WiseItem::Importer - DEFAULT_HISTORY_DAYS = 90 + DEFAULT_HISTORY_DAYS = 365 + # Wise has existed since 2011, so this safely predates any account creation. + FULL_HISTORY_START_DATE = Date.new(2000, 1, 1) attr_reader :wise_item, :wise_provider, :sync_start_date @@ -23,9 +25,18 @@ class WiseItem::Importer borderless_accounts = fetch_borderless_accounts account_result = import_balances(all_balances, borderless_accounts: borderless_accounts) - transfers = fetch_transfers + transfers = fetch_transfers if legacy_transfer_import_needed? activities = fetch_jar_activities - transaction_result = store_transfers_per_account(transfers, activities: activities) + statements = fetch_statements + statement_fallbacks = statement_fetches_failed_completely? + transfers ||= fetch_transfers if statement_fallbacks + transaction_result = store_transfers_per_account( + transfers || [], + activities: activities, + statements: statements, + statement_failures: Array(@statement_fetch_failed_accounts), + fallback_to_transfers: statement_fallbacks + ) @interbalance_activities = activities.select { |a| a["type"] == "INTERBALANCE" } wise_item.update!(status: :good) if account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero? @@ -184,15 +195,100 @@ class WiseItem::Importer [] end - # Partitions transfers by the currency relevant to each WiseAccount: + # Fetches statement rows for STANDARD balances. Legacy transfer snapshots + # are retained and only backfilled with statements older than their oldest + # transfer, avoiding duplicate imports during the migration. + def fetch_statements + @statement_fetch_attempted_accounts = [] + @statement_fetch_failed_accounts = [] + + wise_item.wise_accounts.each_with_object({}) do |wise_account, result| + next if wise_account.jar? + + existing = Array(wise_account.raw_transactions_payload) + legacy = existing.select do |transaction| + transaction["wise_statement"].blank? && !jar_activity?(transaction) + end + start_date = + if existing.any? { |transaction| transaction["wise_statement"].present? } && + sync_start_date.blank? && wise_item.sync_start_date.blank? && wise_item.last_synced_at.present? + # Incremental: once statements exist, only fetch the short overlap. + (wise_item.last_synced_at - 7.days).to_date + elsif full_history? + full_history_start_date(wise_account) + else + sync_start_date_value + end + end_date = Date.current + + if legacy.any? + oldest = legacy.filter_map { |transaction| parse_transaction_date(transaction) }.min + end_date = oldest - 1.day if oldest + end + + next if start_date > end_date + + @statement_fetch_attempted_accounts << wise_account.id + rows = wise_provider.get_balance_statements( + wise_item.profile_id, + wise_account.balance_id, + currency: wise_account.currency, + start_date: start_date, + end_date: end_date + ) + result[wise_account.id] = Array(rows).map { |row| row.merge("wise_statement" => true) } + rescue Provider::Wise::WiseError => e + @statement_fetch_failed_accounts << wise_account.id + capture_statement_error(wise_account, e, level: "warn") + Rails.logger.warn "WiseItem::Importer - Could not fetch statements for wise_account #{wise_account.id} (#{e.message})" + result[wise_account.id] = [] + rescue => e + @statement_fetch_failed_accounts << wise_account.id + capture_statement_error(wise_account, e, level: "warn") + Rails.logger.warn "WiseItem::Importer - Unexpected error fetching statements for wise_account #{wise_account.id}: #{e.message}" + result[wise_account.id] = [] + end + end + + # Statements are only "unavailable" when every attempted standard balance + # request failed (e.g. the token lacks statement access). Successful + # requests that return zero rows are normal — quiet incremental syncs must + # not re-enable the transfer fallback, which would duplicate movements that + # are already imported as statement rows. + def statement_fetches_failed_completely? + attempted = Array(@statement_fetch_attempted_accounts) + failed = Array(@statement_fetch_failed_accounts) + attempted.any? && (attempted - failed).empty? + end + + # The transfer endpoint remains a compatibility fallback for tokens that + # cannot access statements and for accounts imported before statement rows + # were supported. + def legacy_transfer_import_needed? + wise_item.wise_accounts.any? do |wise_account| + !wise_account.jar? && Array(wise_account.raw_transactions_payload).any? do |transaction| + transaction["wise_statement"].blank? && !jar_activity?(transaction) + end + end + end + + # Partitions transactions 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: []) + def store_transfers_per_account(transfers, activities: [], statements: {}, statement_failures: [], fallback_to_transfers: false) transactions_imported = 0 transactions_failed = 0 wise_item.wise_accounts.find_each do |wise_account| + # A failed statement request for this balance (without a transfer + # fallback) leaves the account incomplete: keep its existing payload + # and surface the failure so the item is not marked good. + if statement_failures.include?(wise_account.id) && !fallback_to_transfers + transactions_failed += 1 + next + end + if wise_account.jar? jar_activities = activities.select { |a| activity_for_account?(a, wise_account) } wise_account.upsert_wise_transactions_snapshot!(jar_activities) @@ -202,10 +298,19 @@ class WiseItem::Importer t["sourceCurrency"] == wise_account.currency || t["targetCurrency"] == wise_account.currency end + account_statements = statements[wise_account.id] || [] + existing_legacy = Array(wise_account.raw_transactions_payload).reject { |transaction| transaction["wise_statement"].present? } + existing_statements = Array(wise_account.raw_transactions_payload).select { |transaction| transaction["wise_statement"].present? } # 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 + payload = merge_transaction_payloads( + existing_legacy + account_transfers + existing_statements + account_statements + interbalance + ) + # A statement request can legitimately return no rows for a token + # lacking statement permission; preserve transfer fallback behavior. + payload = merge_transaction_payloads(account_transfers + interbalance) if payload.empty? + wise_account.upsert_wise_transactions_snapshot!(payload) + transactions_imported += payload.size end rescue => e transactions_failed += 1 @@ -215,6 +320,38 @@ class WiseItem::Importer { transactions_imported: transactions_imported, transactions_failed: transactions_failed } end + def merge_transaction_payloads(transactions) + transactions.each_with_object({}) do |transaction, merged| + key = if transaction["wise_statement"].present? + [ "statement", transaction["referenceNumber"].presence || transaction["id"] || transaction.to_json ] + elsif transaction["type"].present? + [ "activity", transaction["id"] || transaction.to_json ] + else + [ "transfer", transaction["id"] || transaction.to_json ] + end + merged[key] = transaction + end.values + end + + def jar_activity?(transaction) + WiseActivity::Processor::JAR_ACTIVITY_TYPES.include?(transaction["type"]) + end + + def capture_statement_error(wise_account, error, level:) + DebugLogEntry.capture( + category: "provider_sync_error", + level: level, + message: "WiseItem::Importer - Failed to fetch statement for wise_account #{wise_account.id}: #{error.message}", + source: self.class.name, + provider_key: "wise", + family: wise_item.family, + account_provider: wise_account.account_provider, + metadata: { wise_account_id: wise_account.id, error_class: error.class.name } + ) + rescue => capture_error + Rails.logger.warn "WiseItem::Importer - Failed to capture statement error: #{capture_error.message}" + 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. @@ -291,18 +428,49 @@ class WiseItem::Importer end def transfer_cutoff + return sync_start_date_value.to_time if sync_start_date.present? || wise_item.sync_start_date.present? + # 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 + elsif full_history? + FULL_HISTORY_START_DATE.beginning_of_day else DEFAULT_HISTORY_DAYS.days.ago end end + def sync_start_date_value + (sync_start_date.presence || wise_item.sync_start_date.presence || DEFAULT_HISTORY_DAYS.days.ago).to_date + end + + def full_history? + wise_item.import_all_history? + end + + # Full-history imports start from the balance's creation time when Wise + # reports one, falling back to a far-past date so every historical row is + # fetched regardless. + def full_history_start_date(wise_account) + raw = wise_account.raw_payload&.dig("creationTime") + return FULL_HISTORY_START_DATE if raw.blank? + + Time.parse(raw.to_s).to_date + rescue ArgumentError, TypeError + FULL_HISTORY_START_DATE + end + + def parse_transaction_date(transaction) + raw = transaction["created"] || transaction["createdOn"] || transaction["date"] + return nil if raw.blank? + + Time.parse(raw.to_s).to_date + rescue ArgumentError, TypeError + nil + end + def parse_transfer_date(raw) DateTime.parse(raw.to_s).to_time rescue diff --git a/app/models/wise_statement/processor.rb b/app/models/wise_statement/processor.rb new file mode 100644 index 000000000..7f8da746a --- /dev/null +++ b/app/models/wise_statement/processor.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +class WiseStatement::Processor + def initialize(statement, wise_account:) + @statement = statement.with_indifferent_access + @wise_account = wise_account + end + + def process + unless account.present? + Rails.logger.warn "WiseStatement::Processor - No linked account for wise_account #{wise_account.id}, skipping #{safe_id}" + return :skipped + end + + result = import_main_transaction + + if fee.positive? + begin + import_fee_transaction + rescue => e + Rails.logger.warn "WiseStatement::Processor - Fee transaction failed for statement #{safe_id}: #{e.message}" + end + end + + result + rescue ArgumentError => e + Rails.logger.error "WiseStatement::Processor - Validation error for statement #{safe_id}: #{e.message}" + raise + rescue => e + Rails.logger.error "WiseStatement::Processor - Error for statement #{safe_id}: #{e.class}: #{e.message}" + raise + end + + private + + attr_reader :statement, :wise_account + + def account + @account ||= wise_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def import_main_transaction + import_adapter.import_transaction( + external_id: "wise_statement_#{transaction_id}", + amount: amount, + currency: currency, + date: date, + name: name, + source: "wise", + extra: extra + ) + end + + # The statement amount excludes fees, so the fee is imported as its own + # entry (mirroring WiseEntry::Processor) to preserve the balance impact. + def import_fee_transaction + import_adapter.import_transaction( + external_id: "wise_statement_#{transaction_id}_fee", + amount: fee, + currency: currency, + date: date, + name: I18n.t("wise_items.entries.fee_name"), + source: "wise", + extra: { wise: { statement_id: transaction_id, type: "FEE", fee: fee } } + ) + end + + def transaction_id + statement[:referenceNumber].presence || statement[:id].presence || digest + end + + def safe_id + statement[:referenceNumber].presence || statement[:id].presence || "unknown" + end + + # Wise statements use a signed amount: credits are positive and debits are + # negative. Sure uses the opposite sign convention for imported entries. + # The statement amount is the full balance impact and includes fees, so the + # reported totalFees are excluded to match the transfer importer's net amount. + def amount + signed = statement.dig(:amount, :value).to_d + net = signed.abs - fee + net = 0 if net.negative? + + signed.negative? ? net : -net + end + + def currency + statement.dig(:amount, :currency).presence || wise_account.currency + end + + def date + raw = statement[:date].presence + raise ArgumentError, "Wise statement missing date" unless raw + + Time.parse(raw.to_s).in_time_zone(account.family.timezone).to_date + rescue ArgumentError + raise + rescue => e + raise ArgumentError, "Unable to parse Wise statement date #{raw.inspect}: #{e.message}" + end + + def name + details = statement[:details] + base = details[:description].presence if details.is_a?(Hash) && details[:description].present? + base ||= details[:reference].presence if details.is_a?(Hash) && details[:reference].present? + base ||= statement[:referenceNumber].presence || I18n.t("wise_items.entries.default_name") + + payment_reference.present? ? "#{base} #{payment_reference}" : base + end + + def extra + { + wise: { + statement_id: transaction_id, + statement_type: statement[:type], + reference: statement[:referenceNumber], + payment_reference: payment_reference, + fee: fee.positive? ? fee : nil + }.compact + } + end + + # Fee reported by Wise in totalFees. Only subtracted when denominated in the + # transaction's currency so values are never mixed. + def fee + total_fees = statement[:totalFees] + return 0 unless total_fees.is_a?(Hash) + + amount_currency = statement.dig(:amount, :currency) + fee_currency = total_fees[:currency].presence + return 0 if amount_currency.present? && fee_currency.present? && amount_currency != fee_currency + + total_fees[:value].to_d + end + + def payment_reference + statement.dig(:details, :paymentReference).presence + end + + def digest + Digest::SHA256.hexdigest(statement.to_json)[0, 24] + end +end diff --git a/app/views/settings/providers/_wise_panel.html.erb b/app/views/settings/providers/_wise_panel.html.erb index a656675c0..596095fa3 100644 --- a/app/views/settings/providers/_wise_panel.html.erb +++ b/app/views/settings/providers/_wise_panel.html.erb @@ -81,6 +81,12 @@ label: t("wise_items.provider_panel.connection_name_label"), placeholder: t("wise_items.provider_panel.connection_name_placeholder") %> + <%= form.date_field :sync_start_date, + label: t("wise_items.provider_panel.sync_start_date_label"), + value: item.sync_start_date || 365.days.ago.to_date, + max: Date.current, + help_text: t("wise_items.provider_panel.sync_start_date_help") %> + <%= form.text_field :token, label: t("wise_items.provider_panel.token_label"), placeholder: t("wise_items.provider_panel.keep_token_placeholder"), @@ -125,6 +131,14 @@

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

+
+
+

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

+

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

+
+ <%= form.toggle :import_all_history %> +
+
<%= form.submit t("wise_items.provider_panel.connect") %>
diff --git a/config/locales/views/wise_items/en.yml b/config/locales/views/wise_items/en.yml index 14e1007e0..15acafb65 100644 --- a/config/locales/views/wise_items/en.yml +++ b/config/locales/views/wise_items/en.yml @@ -62,6 +62,10 @@ en: keep_token_placeholder: Leave blank to keep the current token connection_name_label: Connection name connection_name_placeholder: Wise Personal + sync_start_date_label: Import transactions from + sync_start_date_help: Wise statements are imported from this date onward. Leave the default for up to one year of history. + import_all_history_label: Import full transaction history + import_all_history_help: Fetch all historical Wise statements instead of only the last year. connect: Connect Wise update_connection: Update connection setup_accounts: Set up accounts diff --git a/config/locales/views/wise_items/fr.yml b/config/locales/views/wise_items/fr.yml index 358f882af..36a32706e 100644 --- a/config/locales/views/wise_items/fr.yml +++ b/config/locales/views/wise_items/fr.yml @@ -55,6 +55,10 @@ fr: connect: Connecter Wise connection_name_label: Nom de la connexion connection_name_placeholder: Wise Personnel + sync_start_date_label: Importer les transactions à partir de + sync_start_date_help: Les relevés Wise sont importés à partir de cette date. Conservez la valeur par défaut pour importer jusqu'à un an d'historique. + import_all_history_label: Importer tout l'historique des transactions + import_all_history_help: Récupère tous les relevés Wise historiques au lieu de seulement la dernière année. disconnect: Déconnecter disconnect_confirm: Voulez-vous vraiment déconnecter %{name} ? Cela supprimera toutes les données de compte synchronisées. diff --git a/config/locales/views/wise_items/pl.yml b/config/locales/views/wise_items/pl.yml index bdc527f11..abdb28a8e 100644 --- a/config/locales/views/wise_items/pl.yml +++ b/config/locales/views/wise_items/pl.yml @@ -74,6 +74,10 @@ pl: keep_token_placeholder: Pozostaw puste, aby zachować bieżący token connection_name_label: Nazwa połączenia connection_name_placeholder: Wise Osobiste + sync_start_date_label: Importuj transakcje od + sync_start_date_help: Wyciągi Wise są importowane od tej daty. Pozostaw wartość domyślną, aby zaimportować do roku historii. + import_all_history_label: Importuj pełną historię transakcji + import_all_history_help: Pobiera wszystkie historyczne wyciągi Wise zamiast tylko ostatniego roku. connect: Połącz Wise update_connection: Aktualizuj połączenie setup_accounts: Konfiguruj konta diff --git a/config/locales/views/wise_items/tr.yml b/config/locales/views/wise_items/tr.yml index d96aa0144..2c5eb0881 100644 --- a/config/locales/views/wise_items/tr.yml +++ b/config/locales/views/wise_items/tr.yml @@ -53,6 +53,10 @@ tr: connect: Wise'ı Bağla connection_name_label: Bağlantı adı connection_name_placeholder: Wise Bireysel + sync_start_date_label: İşlemleri şu tarihten itibaren içe aktar + sync_start_date_help: Wise hesap özetleri bu tarihten itibaren içe aktarılır. Varsayılanı bırakırsanız bir yıla kadar geçmiş yüklenir. + import_all_history_label: Tüm işlem geçmişini içe aktar + import_all_history_help: Yalnızca son bir yıl yerine tüm geçmiş Wise hesap özetlerini getirir. disconnect: Bağlantıyı kes disconnect_confirm: "%{name} bağlantısını kesmek istediğinizden emin misiniz? Bu, tüm eşitlenmiş hesap verilerini kaldıracaktır." diff --git a/db/migrate/20260809000000_add_import_all_history_to_wise_items.rb b/db/migrate/20260809000000_add_import_all_history_to_wise_items.rb new file mode 100644 index 000000000..2bd4476fd --- /dev/null +++ b/db/migrate/20260809000000_add_import_all_history_to_wise_items.rb @@ -0,0 +1,5 @@ +class AddImportAllHistoryToWiseItems < ActiveRecord::Migration[7.2] + def change + add_column :wise_items, :import_all_history, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index c4346da7f..23a6efe06 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_27_111051) do +ActiveRecord::Schema[7.2].define(version: 2026_08_09_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -2313,6 +2313,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_27_111051) do t.datetime "sync_start_date" t.text "token", null: false t.datetime "updated_at", null: false + t.boolean "import_all_history", default: false, 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" diff --git a/test/controllers/wise_items_controller_test.rb b/test/controllers/wise_items_controller_test.rb index b2073b607..eef886bce 100644 --- a/test/controllers/wise_items_controller_test.rb +++ b/test/controllers/wise_items_controller_test.rb @@ -77,6 +77,44 @@ class WiseItemsControllerTest < ActionDispatch::IntegrationTest assert_nil session[:wise_pending_encrypted_token] end + test "link_profiles applies the pending import_all_history setting to created items" do + Provider::Wise.any_instance.stubs(:get_profiles).returns(@valid_profiles) + post wise_items_url, params: { wise_item: { token: "live_token_abc", import_all_history: "1" } } + + assert_difference "WiseItem.count", 1 do + post link_profiles_wise_items_url, params: { profile_ids: [ "99999999" ] } + end + + assert @family.wise_items.find_by!(profile_id: "99999999").import_all_history? + assert_nil session[:wise_pending_import_all_history] + end + + test "link_profiles defaults import_all_history to false when not requested" 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: { profile_ids: [ "99999999" ] } + + assert_not @family.wise_items.find_by!(profile_id: "99999999").import_all_history? + end + + test "link_profiles applies import_all_history to every created profile" do + profiles = [ + { "id" => "99999999", "type" => "personal", "details" => { "firstName" => "Jane", "lastName" => "Doe" } }, + { "id" => "88888888", "type" => "business", "details" => { "name" => "Acme" } } + ] + Provider::Wise.any_instance.stubs(:get_profiles).returns(profiles) + post wise_items_url, params: { wise_item: { token: "live_token_abc", import_all_history: "1" } } + + assert_difference "WiseItem.count", 2 do + post link_profiles_wise_items_url, params: { profile_ids: [ "99999999", "88888888" ] } + end + + assert @family.wise_items.find_by!(profile_id: "99999999").import_all_history? + assert @family.wise_items.find_by!(profile_id: "88888888").import_all_history? + assert_nil session[:wise_pending_import_all_history] + end + test "link_profiles redirects to providers when there is no pending session" do post link_profiles_wise_items_url, params: { profile_ids: [ "99999999" ] } diff --git a/test/models/provider/wise_test.rb b/test/models/provider/wise_test.rb new file mode 100644 index 000000000..53ad8e269 --- /dev/null +++ b/test/models/provider/wise_test.rb @@ -0,0 +1,74 @@ +require "test_helper" + +class Provider::WiseTest < ActiveSupport::TestCase + setup do + @provider = Provider::Wise.new("test_token", base_url: "https://api.wise.com") + end + + test "chunks balance statement requests into windows under the 469-day limit" do + start_date = Date.new(2015, 4, 12) + end_date = Date.new(2018, 4, 30) + + expected_windows = [] + window_start = start_date + while window_start <= end_date + window_end = [ window_start + (Provider::Wise::MAX_STATEMENT_DAYS - 1), end_date ].min + expected_windows << { + interval_start: window_start.beginning_of_day, + interval_end: window_end.end_of_day + } + window_start = window_end + 1.day + end + assert_operator expected_windows.size, :>, 1 + + expected_windows.each do |window| + @provider.expects(:get_balance_statement) + .with("111", "222", currency: "EUR", + interval_start: window[:interval_start], + interval_end: window[:interval_end]) + .returns({ "transactions" => [] }) + .once + end + + result = @provider.get_balance_statements("111", "222", currency: "EUR", start_date: start_date, end_date: end_date) + + assert_equal [], result + end + + test "uses a single request when the range fits within the limit" do + @provider.stubs(:get_balance_statement) + .with("111", "222", currency: "EUR", + interval_start: Date.new(2018, 1, 1).beginning_of_day, + interval_end: Date.new(2018, 4, 30).end_of_day) + .returns({ "transactions" => [] }) + + result = @provider.get_balance_statements( + "111", + "222", + currency: "EUR", + start_date: Date.new(2018, 1, 1), + end_date: Date.new(2018, 4, 30) + ) + + assert_equal [], result + end + + test "retries a rate-limited window without restarting earlier windows" do + error = Provider::Wise::WiseError.new("rate limited", :rate_limited) + @provider.stubs(:sleep) + @provider.stubs(:get_balance_statement) + .raises(error).then + .returns({ "transactions" => [] }).then + .returns({ "transactions" => [] }) + + result = @provider.get_balance_statements( + "111", + "222", + currency: "EUR", + start_date: Date.new(2018, 1, 1), + end_date: Date.new(2018, 4, 30) + ) + + assert_equal [], result + end +end diff --git a/test/models/wise_entry/processor_test.rb b/test/models/wise_entry/processor_test.rb index 2fc3cb6e6..f2d1292bb 100644 --- a/test/models/wise_entry/processor_test.rb +++ b/test/models/wise_entry/processor_test.rb @@ -125,6 +125,28 @@ class WiseEntry::ProcessorTest < ActiveSupport::TestCase assert_equal 1, @account.entries.where(source: "wise").count end + # Exchange rate for balance conversion + + test "stores the Wise exchange rate for cross-currency transfers" do + transfer = build_transfer(id: 6001, target_account: 9999, source_value: 1828.25, + target_value: 1372.47, source_currency: "CAD", + target_currency: "USD", rate: 0.7507) + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_equal BigDecimal("0.7507"), entry.entryable.extra.dig("exchange_rate").to_d + assert_equal BigDecimal("0.7507"), entry.entryable.extra.dig("wise", "rate").to_d + end + + test "omits exchange_rate when Wise does not report a rate" do + transfer = build_transfer(id: 6002, target_account: 9999) + transfer["rate"] = nil + + entry = WiseEntry::Processor.new(transfer, wise_account: @wise_account).process + + assert_nil entry.entryable.extra.dig("exchange_rate") + end + # Skips without linked account test "returns skipped when no account linked" do @@ -159,7 +181,7 @@ class WiseEntry::ProcessorTest < ActiveSupport::TestCase 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) + reference: nil, rate: 1.0) { "id" => id, "targetAccount" => target_account, @@ -169,7 +191,7 @@ class WiseEntry::ProcessorTest < ActiveSupport::TestCase "targetCurrency" => target_currency || source_currency, "targetValue" => target_value || source_value, "status" => status, - "rate" => 1.0, + "rate" => rate, "created" => "2026-01-15 10:00:00", "details" => reference ? { "reference" => reference } : {} } diff --git a/test/models/wise_item/importer_test.rb b/test/models/wise_item/importer_test.rb index f9afd5a4d..ee15acac6 100644 --- a/test/models/wise_item/importer_test.rb +++ b/test/models/wise_item/importer_test.rb @@ -2,17 +2,21 @@ require "test_helper" class WiseItem::ImporterTest < ActiveSupport::TestCase class FakeWiseProvider - attr_reader :calls + attr_reader :calls, :statement_requests def initialize(balances: nil, savings_balances: nil, borderless_accounts: nil, - transfers: nil, activities: nil, raise_on: {}) + transfers: nil, activities: nil, statements: nil, raise_on: {}, + statement_fail_balance_ids: []) @balances = balances || [ standard_balance ] @savings_balances = savings_balances || [] @borderless_accounts = borderless_accounts || [ borderless_account ] @transfers = transfers || [] @activities = activities || [] + @statements = statements || [] @raise_on = raise_on + @statement_fail_balance_ids = statement_fail_balance_ids @calls = [] + @statement_requests = [] end def get_balances(profile_id) @@ -38,6 +42,15 @@ class WiseItem::ImporterTest < ActiveSupport::TestCase @transfers end + def get_balance_statements(profile_id, balance_id, currency:, start_date:, end_date:) + @calls << :get_balance_statements + @statement_requests << { profile_id: profile_id, balance_id: balance_id, currency: currency, + start_date: start_date, end_date: end_date } + raise_if(:get_balance_statements) + raise Provider::Wise::WiseError.new("forbidden", :fetch_failed) if @statement_fail_balance_ids.include?(balance_id) + @statements + end + def get_activities(profile_id, cursor: nil, size: 100) @calls << :get_activities raise_if(:get_activities) @@ -145,7 +158,7 @@ class WiseItem::ImporterTest < ActiveSupport::TestCase 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) + provider = FakeWiseProvider.new(transfers: transfers, raise_on: { get_balance_statements: "forbidden" }) WiseItem::Importer.new(@wise_item, wise_provider: provider).import @@ -158,7 +171,7 @@ class WiseItem::ImporterTest < ActiveSupport::TestCase transfers = [ build_transfer(id: 3, source_currency: "USD", target_currency: "EUR", target_account: 99999001) ] - provider = FakeWiseProvider.new(transfers: transfers) + provider = FakeWiseProvider.new(transfers: transfers, raise_on: { get_balance_statements: "forbidden" }) WiseItem::Importer.new(@wise_item, wise_provider: provider).import @@ -166,6 +179,127 @@ class WiseItem::ImporterTest < ActiveSupport::TestCase assert_equal 1, eur_account.raw_transactions_payload.size end + test "imports standard account balance statements and honors configured history start" do + @wise_item.update!(sync_start_date: Date.new(2024, 1, 1)) + statements = [ + { + "type" => "CREDIT", + "date" => "2024-01-02T10:00:00Z", + "amount" => { "value" => "25.00", "currency" => "EUR" }, + "details" => { "description" => "Salary" }, + "referenceNumber" => "statement-1" + } + ] + provider = FakeWiseProvider.new(statements: statements) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + account = @wise_item.wise_accounts.find_by(currency: "EUR") + assert_equal statements.first.merge("wise_statement" => true), account.raw_transactions_payload.first + assert_equal Date.new(2024, 1, 1), provider.statement_requests.first[:start_date] + assert_equal Date.current, provider.statement_requests.first[:end_date] + end + + test "fetches full statement history when import_all_history is enabled" do + @wise_item.update!(import_all_history: true) + provider = FakeWiseProvider.new(statements: []) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + assert_equal WiseItem::Importer::FULL_HISTORY_START_DATE, provider.statement_requests.first[:start_date] + assert_equal Date.current, provider.statement_requests.first[:end_date] + end + + test "starts full history from balance creation time when available" do + @wise_item.update!(import_all_history: true) + balance = { + "id" => "10000001", + "amount" => { "value" => 100.0, "currency" => "EUR" }, + "type" => "STANDARD", + "creationTime" => "2015-04-12T10:00:00Z" + } + provider = FakeWiseProvider.new(balances: [ balance ], statements: []) + + WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + assert_equal Date.new(2015, 4, 12), provider.statement_requests.first[:start_date] + end + + test "keeps incremental sync window after statements already exist" do + @wise_item.update!(import_all_history: true) + statements = [ + { + "type" => "CREDIT", + "date" => "2024-01-02T10:00:00Z", + "amount" => { "value" => "25.00", "currency" => "EUR" }, + "details" => { "description" => "Salary" }, + "referenceNumber" => "statement-1" + } + ] + WiseItem::Importer.new(@wise_item, wise_provider: FakeWiseProvider.new(statements: statements)).import + + sync = @wise_item.syncs.create!(status: :completed, completed_at: Time.current) + + second_provider = FakeWiseProvider.new(statements: []) + WiseItem::Importer.new(@wise_item, wise_provider: second_provider).import + + assert_equal (sync.completed_at - 7.days).to_date, second_provider.statement_requests.first[:start_date] + assert_equal Date.current, second_provider.statement_requests.first[:end_date] + refute_includes second_provider.calls, :get_transfers + end + + test "falls back to transfers when every statement request fails" do + transfers = [ + build_transfer(id: 1, source_currency: "EUR", target_currency: "EUR", target_account: 9999) + ] + provider = FakeWiseProvider.new(transfers: transfers, raise_on: { get_balance_statements: "forbidden" }) + + result = WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + assert result[:success] + assert_includes provider.calls, :get_transfers + account = @wise_item.wise_accounts.find_by(currency: "EUR") + assert_equal 1, account.raw_transactions_payload.size + end + + test "surfaces partial statement fetch failures without the transfer fallback" do + balances = [ + { + "id" => "10000001", + "amount" => { "value" => 1964.88, "currency" => "EUR" }, + "type" => "STANDARD" + }, + { + "id" => "10000002", + "amount" => { "value" => 500.0, "currency" => "USD" }, + "type" => "STANDARD" + } + ] + provider = FakeWiseProvider.new(balances: balances, statement_fail_balance_ids: [ "10000002" ]) + + result = WiseItem::Importer.new(@wise_item, wise_provider: provider).import + + assert_not result[:success] + assert_equal 1, result[:transactions_failed] + refute_includes provider.calls, :get_transfers + end + + test "keeps transfer cutoff incremental after the first sync when full history is enabled" do + @wise_item.update!(import_all_history: true) + @wise_item.wise_accounts.create!( + balance_id: "10000001", + name: "Wise EUR", + currency: "EUR", + raw_payload: { "type" => "STANDARD" }, + raw_transactions_payload: [ { "id" => 1 } ] + ) + sync = @wise_item.syncs.create!(status: :completed, completed_at: Time.current) + + importer = WiseItem::Importer.new(@wise_item, wise_provider: FakeWiseProvider.new) + + assert_equal sync.completed_at - 7.days, importer.send(:transfer_cutoff) + end + # Activity routing test "routes INTERBALANCE activities to both JAR and STANDARD accounts" do diff --git a/test/models/wise_statement/processor_test.rb b/test/models/wise_statement/processor_test.rb new file mode 100644 index 000000000..67150047e --- /dev/null +++ b/test/models/wise_statement/processor_test.rb @@ -0,0 +1,88 @@ +require "test_helper" + +class WiseStatement::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" } + ) + @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 + + test "imports signed balance statement amounts and stable reference id" do + statement = { + "type" => "CREDIT", + "date" => "2026-01-15T10:00:00Z", + "amount" => { "value" => "1200.00", "currency" => "EUR" }, + "details" => { "description" => "Salary" }, + "referenceNumber" => "statement-123" + } + + entry = WiseStatement::Processor.new(statement, wise_account: @wise_account).process + + assert_equal BigDecimal("-1200.00"), entry.amount + assert_equal "wise_statement_statement-123", entry.external_id + assert_equal "Salary", entry.name + assert_equal "statement-123", entry.entryable.extra.dig("wise", "statement_id") + end + + test "appends payment reference to the name and excludes fees from the amount" do + statement = { + "type" => "DEBIT", + "date" => "2026-01-15T10:00:00Z", + "amount" => { "value" => "-7.76", "currency" => "EUR" }, + "totalFees" => { "value" => "0.04", "currency" => "EUR" }, + "details" => { + "description" => "Sent money to Questrade, Inc.", + "paymentReference" => "INV-1234" + }, + "referenceNumber" => "statement-456" + } + + entry = WiseStatement::Processor.new(statement, wise_account: @wise_account).process + + assert_equal BigDecimal("7.72"), entry.amount + assert_equal "Sent money to Questrade, Inc. INV-1234", entry.name + assert_equal "INV-1234", entry.entryable.extra.dig("wise", "payment_reference") + assert_equal "0.04", entry.entryable.extra.dig("wise", "fee") + + fee_entry = @account.entries.find_by(external_id: "wise_statement_statement-456_fee") + assert_not_nil fee_entry + assert_equal BigDecimal("0.04"), fee_entry.amount + assert_equal I18n.t("wise_items.entries.fee_name"), fee_entry.name + end + + test "ignores fees denominated in a different currency" do + statement = { + "type" => "DEBIT", + "date" => "2026-01-15T10:00:00Z", + "amount" => { "value" => "-10.00", "currency" => "EUR" }, + "totalFees" => { "value" => "1.50", "currency" => "GBP" }, + "details" => { "description" => "Card payment" }, + "referenceNumber" => "statement-789" + } + + entry = WiseStatement::Processor.new(statement, wise_account: @wise_account).process + + assert_equal BigDecimal("10.00"), entry.amount + assert_equal 1, @account.entries.where(source: "wise").count + end +end