Files
sure/app/models/wise_item.rb
T
Igor OliveiraandJuan José Mata 05a787330d Fix Wise incoming transfers by implementing Strong Customer Authentication (#3391)
* Support Wise Strong Customer Authentication for balance statements

The balance-statement endpoint always 403s because it requires a signed
one-time-token challenge (SCA) that Sure never implemented, so every sync
silently fell back to /v1/transfers — an outgoing-only endpoint — meaning
incoming payments into a Wise balance never synced.

Adds a per-item RSA keypair (private key encrypted at rest) that signs the
SCA challenge and retries the statement request once, plus a settings UI
to generate the keypair and register its public key with Wise.

Fixes #3384

* Backfill incoming statements past legacy transfers; fix review nits

Backfill: once statements start succeeding for an account that already has
legacy /v1/transfers rows, the fetch window was clamped to end the day
before the oldest legacy transfer, so the window where incoming payments
were actually missing (the recent window transfers already "covered" with
outgoing-only data) was never re-fetched. Statement rows in that overlap
are now kept when they're incoming and dropped when outgoing, since the
legacy transfer rows already account for the outgoing side.

Also: replace the inline onclick handler on the SCA public key display with
the existing clipboard Stimulus controller (copy button, matching the API
key reveal pattern), and correct the regenerate-keypair confirmation text,
which implied local regeneration revokes the key with Wise -- it doesn't;
the old public key stays valid there until removed manually.

* Avoid double-booking internal cross-currency conversions on statement backfill

The backfilled statement fetch's outgoing/incoming filter only looked at
sign: a positive (credit) statement row was always kept in the legacy
overlap window. But a legacy transfer row can itself be incoming for this
account when it's the target side of a conversion between two of the
profile's own balances -- Wise already fully captures both legs of those
via /v1/transfers, unlike genuine external payments.

Now an incoming statement row in the overlap window is dropped only when
it matches a known incoming legacy transfer's date and amount, so internal
conversions aren't duplicated while external incoming payments (no legacy
counterpart) still backfill correctly.

* Never drop an incoming statement row on a date/amount heuristic

The previous fix dropped an incoming statement row in the legacy-overlap
window when it matched a known incoming legacy transfer's date and amount,
to avoid double-booking internal cross-currency conversions. But nothing
short of an endpoint-proven correlation id can tell that apart from a
genuine external payment that happens to share the same date and amount --
and silently losing a real transaction is worse than an occasional visible,
user-correctable duplicate. Incoming rows are kept unconditionally again.

Instead, bound the exposure at the source: the /v1/transfers fallback now
stops running for an account as soon as it has a successful statement row,
since statements alone cover both directions from then on. This leaves only
a narrow, one-time window (the initial backfill of historical internal
conversions) where a duplicate can occur, rather than an indefinite one.

* Gate the transfer fallback per-account, not per-item

legacy_transfer_import_needed? decides whether to fetch /v1/transfers at
all, but that decision is profile-wide -- true as soon as any one account
still needs the fallback. store_transfers_per_account then merged those
transfers into every currency-matching account by currency alone, with no
check for whether that specific account had already migrated to
statements. A still-legacy account in one currency was enough to make an
already-migrated account in the same currency re-absorb a movement its own
statements already had, double-booked under a different key.

account_transfers is now cleared for any account that already has
statement rows, regardless of why the profile-wide fetch ran.

* Handle SCA controller errors, corrupted keys, and adapter test coverage

- generate_sca_keypair now rescues like every other mutating action in
  this controller, logging and re-rendering the panel with an error
  instead of a raw 500 if the update ever raises.
- sca_configured? now depends on sca_public_key actually parsing, not just
  sca_private_key being present, so a corrupted/unparsable stored key
  (encryption misconfig, manual DB edit) falls back to the "generate a
  keypair" UI state instead of rendering a public key box around nothing.
- Added test/models/provider/wise_adapter_test.rb, which had no coverage
  at all, to cover build_provider's family/wise_item_id resolution and
  that sca_private_key actually reaches the constructed Provider::Wise.

* Add logging to Wise sync

---------

Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-09-05 08:37:32 +02:00

194 lines
6.2 KiB
Ruby

# 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
encrypts :sca_private_key
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
# True only when there's a private key AND it actually parses -- a stored
# key that's corrupted or unparsable (e.g. an encryption misconfig or a
# manual DB edit) should fall back to the "not configured" UI state rather
# than rendering a blank public key box.
def sca_configured?
sca_public_key.present?
end
# Generates a new RSA keypair for Wise's Strong Customer Authentication (SCA)
# flow, used to sign the one-time-token challenge on the balance-statement
# endpoint. The private key stays here (encrypted at rest); the public key
# must be registered with Wise by the user (Settings > API tokens > Public keys).
def generate_sca_keypair!
key = OpenSSL::PKey::RSA.generate(2048)
update!(sca_private_key: key.to_pem)
sca_public_key
end
def sca_public_key
return nil unless sca_private_key.present?
OpenSSL::PKey::RSA.new(sca_private_key).public_key.to_pem
rescue OpenSSL::PKey::RSAError
nil
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, sca_private_key: sca_private_key)
end
private
def normalize_token
self.token = token&.strip
end
end