|
1 | 1 | require "set" |
2 | 2 | class SimplefinItemsController < ApplicationController |
3 | 3 | include SimplefinItems::RelinkHelpers |
4 | | - before_action :set_simplefin_item, only: [ :show, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup, :errors, :relink, :apply_relink ] |
| 4 | + before_action :set_simplefin_item, only: [ :show, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup, :errors, :relink, :manual_relink, :apply_relink ] |
5 | 5 |
|
6 | 6 | def index |
7 | 7 | @simplefin_items = Current.family.simplefin_items.active.ordered |
@@ -355,107 +355,6 @@ def apply_relink |
355 | 355 |
|
356 | 356 |
|
357 | 357 |
|
358 | | - def normalize_name(str) |
359 | | - s = str.to_s.downcase.strip |
360 | | - return s if s.empty? |
361 | | - s.gsub(NAME_NORM_RE, " ") |
362 | | - end |
363 | | - |
364 | | - def compute_relink_candidates |
365 | | - # Best-effort dedup before building candidates |
366 | | - @simplefin_item.dedup_simplefin_accounts! rescue nil |
367 | | - |
368 | | - family = @simplefin_item.family |
369 | | - manuals = family.accounts.left_joins(:account_providers).where(account_providers: { id: nil }).to_a |
370 | | - |
371 | | - # Evaluate only one SimpleFin account per upstream account_id (prefer linked, else newest) |
372 | | - grouped = @simplefin_item.simplefin_accounts.group_by(&:account_id) |
373 | | - sfas = grouped.values.map { |list| list.find { |s| s.current_account.present? } || list.max_by(&:updated_at) } |
374 | | - |
375 | | - Rails.logger.info("SimpleFin compute_relink_candidates: manuals=#{manuals.size} sfas=#{sfas.size} (item_id=#{@simplefin_item.id})") |
376 | | - |
377 | | - used_manual_ids = Set.new |
378 | | - pairs = [] |
379 | | - |
380 | | - sfas.each do |sfa| |
381 | | - next if sfa.name.blank? |
382 | | - # Heuristics (with ambiguity guards): last4 > balance ±0.01 > name |
383 | | - raw = (sfa.raw_payload || {}).with_indifferent_access |
384 | | - sfa_last4 = raw[:mask] || raw[:last4] || raw[:"last-4"] || raw[:"account_number_last4"] |
385 | | - sfa_last4 = sfa_last4.to_s.strip.presence |
386 | | - sfa_balance = (sfa.current_balance || sfa.available_balance).to_d rescue 0.to_d |
387 | | - |
388 | | - chosen = nil |
389 | | - reason = nil |
390 | | - |
391 | | - # 1) last4 match: compute all candidates not yet used |
392 | | - if sfa_last4.present? |
393 | | - last4_matches = manuals.reject { |a| used_manual_ids.include?(a.id) }.select do |a| |
394 | | - a_last4 = nil |
395 | | - %i[mask last4 number_last4 account_number_last4].each do |k| |
396 | | - if a.respond_to?(k) |
397 | | - val = a.public_send(k) |
398 | | - a_last4 = val.to_s.strip.presence if val.present? |
399 | | - break if a_last4 |
400 | | - end |
401 | | - end |
402 | | - a_last4.present? && a_last4 == sfa_last4 |
403 | | - end |
404 | | - # Ambiguity guard: skip if multiple matches |
405 | | - if last4_matches.size == 1 |
406 | | - cand = last4_matches.first |
407 | | - # Conflict guard: if both have balances and differ wildly, skip |
408 | | - begin |
409 | | - ab = (cand.balance || cand.cash_balance || 0).to_d |
410 | | - if sfa_balance.nonzero? && ab.nonzero? && (ab - sfa_balance).abs > BigDecimal("1.00") |
411 | | - cand = nil |
412 | | - end |
413 | | - rescue |
414 | | - # ignore balance parsing errors |
415 | | - end |
416 | | - if cand |
417 | | - chosen = cand |
418 | | - reason = "last4" |
419 | | - end |
420 | | - end |
421 | | - end |
422 | | - |
423 | | - # 2) balance proximity |
424 | | - if chosen.nil? && sfa_balance.nonzero? |
425 | | - balance_matches = manuals.reject { |a| used_manual_ids.include?(a.id) }.select do |a| |
426 | | - begin |
427 | | - ab = (a.balance || a.cash_balance || 0).to_d |
428 | | - (ab - sfa_balance).abs <= BigDecimal("0.01") |
429 | | - rescue |
430 | | - false |
431 | | - end |
432 | | - end |
433 | | - if balance_matches.size == 1 |
434 | | - chosen = balance_matches.first |
435 | | - reason = "balance" |
436 | | - end |
437 | | - end |
438 | | - |
439 | | - # 3) exact normalized name |
440 | | - if chosen.nil? |
441 | | - name_matches = manuals.reject { |a| used_manual_ids.include?(a.id) }.select { |a| normalize_name(a.name) == normalize_name(sfa.name) } |
442 | | - if name_matches.size == 1 |
443 | | - chosen = name_matches.first |
444 | | - reason = "name" |
445 | | - end |
446 | | - end |
447 | | - |
448 | | - if chosen |
449 | | - used_manual_ids << chosen.id |
450 | | - pairs << { sfa_id: sfa.id, sfa_name: sfa.name, manual_id: chosen.id, manual_name: chosen.name, reason: reason } |
451 | | - end |
452 | | - end |
453 | | - |
454 | | - Rails.logger.info("SimpleFin compute_relink_candidates: built #{pairs.size} pairs (item_id=#{@simplefin_item.id})") |
455 | | - |
456 | | - # Return without the reason field to the view |
457 | | - pairs.map { |p| p.slice(:sfa_id, :sfa_name, :manual_id, :manual_name) } |
458 | | - end |
459 | 358 |
|
460 | 359 | def set_simplefin_item |
461 | 360 | if defined?(Current) && Current.respond_to?(:family) && Current.family.present? |
|
0 commit comments