mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +00:00
* fix(sync): don't persist zero balances when a provider balance fetch fails A nil current_balance means the sync's balance fetch did not succeed (the snapshot upsert clears it; only a successful fetch repopulates it). The Lunchflow and Enable Banking processors coerced nil to 0 and write it (plus a currency fallback) onto the linked account, so any transient provider failure persists wrong data with no user-visible signal. Instead, treat nil as no-data: skip the account update. Also stop the Lunchflow snapshot upsert resetting an established account's currency to USD when the accounts endpoint omits currency. * fix(lunchflow): normalize the preserved currency in snapshot upsert Addresses the review comment on #2617: the preserved fallback reused the record's raw in-memory currency, so a blank value would fail the presence validation and break import, and an invalid code would persist instead of falling back to USD. Run it through parse_currency like the payload value. Regression test added. * fix(lunchflow,eb): review round 2 — failure visibility and currency parity Addresses the three review findings on #2617: 1. Capture the Lunch Flow balance-fetch failure via DebugLogEntry (the sync otherwise reports success with no mention of the skip). This is Lunch Flow only: Enable Banking's importer already surfaces the failure through transactions_failed/@sync_error. 2. Apply the currency-preservation fix to Enable Banking's snapshot upsert (same shape as the Lunch Flow fix: parity/safety). Regression test added. 3. Label the Enable Banking processor currency assertion as a parity check — it also passes on main, since the reset defect was Lunch Flow-specific.
177 lines
6.3 KiB
Ruby
177 lines
6.3 KiB
Ruby
class EnableBankingAccount < ApplicationRecord
|
|
include CurrencyNormalizable, Encryptable
|
|
|
|
# Encrypt raw payloads if ActiveRecord encryption is configured
|
|
if encryption_ready?
|
|
encrypts :raw_payload
|
|
encrypts :raw_transactions_payload
|
|
end
|
|
|
|
belongs_to :enable_banking_item
|
|
|
|
# New association through account_providers
|
|
has_one :account_provider, as: :provider, dependent: :destroy
|
|
has_one :account, through: :account_provider, source: :account
|
|
has_one :linked_account, through: :account_provider, source: :account
|
|
|
|
validates :name, :currency, presence: true
|
|
validates :uid, presence: true, uniqueness: { scope: :enable_banking_item_id }
|
|
# account_id is not uniquely scoped: uid already enforces one-account-per-identifier per item
|
|
|
|
# Helper to get account using account_providers system
|
|
def current_account
|
|
account
|
|
end
|
|
|
|
# Returns the API account ID (UUID) for Enable Banking API calls
|
|
# The Enable Banking API requires a valid UUID for balance/transaction endpoints
|
|
# Falls back to raw_payload["uid"] for existing accounts that have the wrong account_id stored
|
|
def api_account_id
|
|
# Check if account_id looks like a valid UUID (not an identification_hash)
|
|
if account_id.present? && account_id.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
|
|
account_id
|
|
else
|
|
# Fall back to raw_payload for existing accounts with incorrect account_id
|
|
raw_payload&.dig("uid") || account_id || uid
|
|
end
|
|
end
|
|
|
|
# Map PSD2 cash_account_type codes to user-friendly names
|
|
# Based on ISO 20022 External Cash Account Type codes
|
|
def account_type_display
|
|
return nil unless account_type.present?
|
|
|
|
type_mappings = {
|
|
"CACC" => "Current/Checking Account",
|
|
"SVGS" => "Savings Account",
|
|
"CARD" => "Card Account",
|
|
"CRCD" => "Credit Card",
|
|
"LOAN" => "Loan Account",
|
|
"MORT" => "Mortgage Account",
|
|
"ODFT" => "Overdraft Account",
|
|
"CASH" => "Cash Account",
|
|
"TRAN" => "Transacting Account",
|
|
"SALA" => "Salary Account",
|
|
"MOMA" => "Money Market Account",
|
|
"NREX" => "Non-Resident External Account",
|
|
"TAXE" => "Tax Account",
|
|
"TRAS" => "Cash Trading Account",
|
|
"ONDP" => "Overnight Deposit"
|
|
}
|
|
|
|
type_mappings[account_type.upcase] || account_type.titleize
|
|
end
|
|
|
|
CASH_ACCOUNT_TYPE_MAP = {
|
|
"CACC" => { type: "Depository", subtype: "checking" },
|
|
"SVGS" => { type: "Depository", subtype: "savings" },
|
|
"CARD" => { type: "CreditCard", subtype: "credit_card" },
|
|
"CRCD" => { type: "CreditCard", subtype: "credit_card" },
|
|
"LOAN" => { type: "Loan", subtype: nil },
|
|
"MORT" => { type: "Loan", subtype: "mortgage" },
|
|
"ODFT" => { type: "Depository", subtype: "checking" },
|
|
"TRAN" => { type: "Depository", subtype: "checking" },
|
|
"SALA" => { type: "Depository", subtype: "checking" },
|
|
"MOMA" => { type: "Depository", subtype: "savings" },
|
|
"NREX" => { type: "Depository", subtype: "checking" },
|
|
"TAXE" => { type: "Depository", subtype: "checking" },
|
|
"TRAS" => { type: "Depository", subtype: "checking" },
|
|
"ONDP" => { type: "Depository", subtype: "savings" },
|
|
"CASH" => { type: "Depository", subtype: "checking" },
|
|
"OTHR" => nil
|
|
}.freeze
|
|
|
|
def suggested_account_type
|
|
CASH_ACCOUNT_TYPE_MAP[account_type&.upcase]&.dig(:type)
|
|
end
|
|
|
|
def suggested_subtype
|
|
CASH_ACCOUNT_TYPE_MAP[account_type&.upcase]&.dig(:subtype)
|
|
end
|
|
|
|
def upsert_enable_banking_snapshot!(account_snapshot)
|
|
snapshot = account_snapshot.with_indifferent_access
|
|
|
|
raw_account_id = snapshot[:account_id]
|
|
account_id_data = if raw_account_id.is_a?(Hash)
|
|
raw_account_id
|
|
elsif raw_account_id.is_a?(Array) && raw_account_id.first.is_a?(Hash)
|
|
raw_account_id.find { |item| item[:iban].present? } || {}
|
|
else
|
|
{}
|
|
end
|
|
|
|
credit_limit_amount = snapshot.dig(:credit_limit, :amount)
|
|
|
|
update!(
|
|
current_balance: nil,
|
|
# Preserve an established currency when the snapshot omits or mangles it
|
|
# (normalized, so blank/invalid stored values still fall through) —
|
|
# EUR only for records that never had a valid currency. Mirrors the
|
|
# equivalent Lunch Flow fix; PSD2 payloads usually carry currency, so
|
|
# this is parity/safety rather than an observed failure.
|
|
currency: parse_currency(snapshot[:currency]) || parse_currency(currency) || "EUR",
|
|
name: build_account_name(snapshot),
|
|
account_id: snapshot[:uid],
|
|
uid: snapshot[:identification_hash] || snapshot[:uid],
|
|
iban: account_id_data[:iban] || snapshot[:iban],
|
|
account_type: snapshot[:cash_account_type] || snapshot[:account_type],
|
|
account_status: "active",
|
|
provider: "enable_banking",
|
|
product: snapshot[:product],
|
|
credit_limit: parse_decimal_safe(credit_limit_amount),
|
|
identification_hashes: snapshot[:identification_hashes] || [],
|
|
institution_metadata: {
|
|
name: enable_banking_item&.aspsp_name,
|
|
aspsp_name: enable_banking_item&.aspsp_name,
|
|
bic: snapshot.dig(:account_servicer, :bic_fi),
|
|
servicer_name: snapshot.dig(:account_servicer, :name)
|
|
}.compact,
|
|
raw_payload: account_snapshot
|
|
)
|
|
end
|
|
|
|
def upsert_enable_banking_transactions_snapshot!(transactions_snapshot)
|
|
assign_attributes(
|
|
raw_transactions_payload: transactions_snapshot
|
|
)
|
|
|
|
save!
|
|
end
|
|
|
|
private
|
|
|
|
def build_account_name(snapshot)
|
|
# Try to build a meaningful name from the account data
|
|
raw_account_id = snapshot[:account_id]
|
|
account_id_data = if raw_account_id.is_a?(Hash)
|
|
raw_account_id
|
|
elsif raw_account_id.is_a?(Array) && raw_account_id.first.is_a?(Hash)
|
|
raw_account_id.find { |item| item[:iban].present? } || {}
|
|
else
|
|
{}
|
|
end
|
|
iban = account_id_data[:iban] || snapshot[:iban]
|
|
|
|
if snapshot[:name].present?
|
|
snapshot[:name]
|
|
elsif iban.present?
|
|
# Use last 4 digits of IBAN for privacy
|
|
"Account ...#{iban[-4..]}"
|
|
else
|
|
"Enable Banking Account"
|
|
end
|
|
end
|
|
|
|
def log_invalid_currency(currency_value)
|
|
Rails.logger.warn("Invalid currency code '#{currency_value}' for EnableBanking account #{id}, defaulting to EUR")
|
|
end
|
|
|
|
def parse_decimal_safe(value)
|
|
return nil if value.blank?
|
|
BigDecimal(value.to_s)
|
|
rescue ArgumentError, TypeError
|
|
nil
|
|
end
|
|
end
|