mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 16:44:17 +00:00
* 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>
344 lines
11 KiB
Ruby
344 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class WiseItemsController < ApplicationController
|
|
before_action :set_wise_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup, :generate_sca_keypair ]
|
|
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
|
|
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
|
|
@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]
|
|
@encrypted_pending_token = session[:wise_pending_encrypted_token]
|
|
|
|
if @pending_profiles.blank? || @encrypted_pending_token.blank?
|
|
redirect_to settings_providers_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(session[:wise_pending_encrypted_token]) # pipelock:ignore
|
|
profiles = session[:wise_pending_profiles]
|
|
|
|
if token.blank? || profiles.blank?
|
|
redirect_to settings_providers_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
|
|
|
|
import_all_history = session[:wise_pending_import_all_history] || false
|
|
|
|
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,
|
|
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")
|
|
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
|
|
|
|
# Generates a fresh SCA keypair for this item. The private key is stored
|
|
# (encrypted); the public key is derived from it on every render so the user
|
|
# can register it with Wise. Regenerating invalidates the previous keypair.
|
|
def generate_sca_keypair
|
|
@wise_item.generate_sca_keypair!
|
|
render_provider_panel_success(t(".success"))
|
|
rescue => e
|
|
Rails.logger.error "WiseItemsController#generate_sca_keypair - #{e.class}: #{e.message}"
|
|
@wise_item.errors.add(:base, t(".failed"))
|
|
render_provider_panel_error
|
|
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, :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
|
|
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
|