mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 13:34:58 +00:00
* feat(sync): add Brex provider schema Adds Brex item and account tables with per-family credentials, scoped upstream account uniqueness, encrypted token storage, and sanitized provider payload columns. * feat(sync): add Brex provider core Adds Brex item/account models, provider client and adapter support, family connection helpers, and provider enum registration for read-only Brex cash and card data. * feat(sync): add Brex import pipeline Adds Brex account discovery, linked-account sync, cash/card balance processors, transaction import, sanitized metadata handling, and idempotent provider entry processing. * feat(sync): add Brex connection flows Adds Mercury-style Brex connection management, explicit item-scoped account selection and linking, settings provider UI, account index visibility, localized copy, and per-item cache handling. * test(sync): cover Brex provider workflows Adds targeted coverage for Brex provider requests, adapter config, item/account guards, importer behavior, entry processing, and Mercury-style controller flows. * fix(sync): align Brex API edge cases Tightens Brex account fetching against the official card-account response shape, sends transaction start filters as RFC3339 date-times, and keeps provider error bodies out of user-facing messages while expanding provider client guard coverage. * fix(sync): harden Brex provider integration Restrict Brex API base URLs to official hosts, tighten account-selection UI behavior, and add tests for invalid credentials, cache scoping, and provider setup edge cases. * test(sync): avoid Brex secret-shaped fixtures * refactor(sync): extract Brex account flows * fix(sync): address Brex provider review feedback * fix(sync): address Brex review follow-ups Move remaining Brex review cleanup into focused model behavior, tighten link/setup edge cases, localize summaries, and add regression coverage from CodeRabbit feedback. Also records the security-review pass as no-findings after diff-scoped inspection and Brakeman validation. * refactor(sync): split Brex account flow controllers Route Brex account selection and setup actions through small namespaced controllers while keeping existing URLs and helpers stable. Business flow remains in BrexItem::AccountFlow; the main Brex item controller now only handles connection CRUD, provider-panel rendering, destroy, and sync. * fix(sync): address Brex CodeRabbit review * fix(sync): address Brex follow-up review * fix(sync): address Brex review follow-ups * fix(sync): address Brex sync review findings * fix(sync): polish Brex review copy and errors * fix(sync): register Brex provider health * fix(sync): polish Brex bank sync presentation * fix(sync): address Brex review follow-ups * fix(sync): tighten Brex setup params * test(api): stabilize usage rate-limit window * fix(sync): polish Brex setup flow nits * fix(sync): harden Brex setup params * fix(sync): finalize Brex review cleanup --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
205 lines
6.6 KiB
Ruby
205 lines
6.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class BrexAccount < ApplicationRecord
|
|
include CurrencyNormalizable, Encryptable
|
|
|
|
CARD_PRIMARY_ACCOUNT_ID = "card_primary"
|
|
|
|
if encryption_ready?
|
|
encrypts :raw_payload
|
|
encrypts :raw_transactions_payload
|
|
end
|
|
|
|
belongs_to :brex_item
|
|
|
|
has_one :account_provider, as: :provider, dependent: :destroy
|
|
has_one :account, through: :account_provider, source: :account
|
|
|
|
validates :name, :currency, presence: true
|
|
validates :account_id, uniqueness: { scope: :brex_item_id }
|
|
validates :account_kind, inclusion: { in: %w[cash card] }
|
|
|
|
def self.card_account_id
|
|
CARD_PRIMARY_ACCOUNT_ID
|
|
end
|
|
|
|
def self.kind_for(account_data)
|
|
return account_data.account_kind if account_data.respond_to?(:account_kind)
|
|
|
|
data = account_data.with_indifferent_access
|
|
kind = data[:account_kind].presence || data[:kind].presence || "cash"
|
|
kind.to_s == "credit_card" ? "card" : kind.to_s
|
|
end
|
|
|
|
def self.name_for(account_data)
|
|
data = account_data.with_indifferent_access
|
|
kind = kind_for(data)
|
|
|
|
if kind == "card"
|
|
data[:name].presence || I18n.t("brex_items.default_card_name", default: "Brex Card")
|
|
else
|
|
data[:name].presence || data[:display_name].presence || I18n.t("brex_items.default_cash_name", id: data[:id], default: "Brex Cash #{data[:id]}")
|
|
end
|
|
end
|
|
|
|
def self.currency_for(account_data)
|
|
data = account_data.with_indifferent_access
|
|
currency_code_from_money(data[:current_balance] || data[:available_balance] || data[:account_limit])
|
|
end
|
|
|
|
def self.default_account_type_for(account_data)
|
|
kind_for(account_data) == "card" ? "CreditCard" : "Depository"
|
|
end
|
|
|
|
def self.default_accountable_attributes(accountable_type)
|
|
case accountable_type
|
|
when "CreditCard"
|
|
{ subtype: CreditCard::DEFAULT_SUBTYPE }
|
|
when "Depository"
|
|
{ subtype: Depository::DEFAULT_SUBTYPE }
|
|
else
|
|
{}
|
|
end
|
|
end
|
|
|
|
def self.money_to_decimal(money_payload)
|
|
return nil if money_payload.blank?
|
|
|
|
payload = money_payload.is_a?(Hash) ? money_payload.with_indifferent_access : { amount: money_payload, currency: "USD" }
|
|
amount = payload[:amount]
|
|
return nil if amount.nil?
|
|
|
|
currency = currency_code_from_money(payload)
|
|
divisor = Money::Currency.new(currency).minor_unit_conversion
|
|
BigDecimal(amount.to_s) / BigDecimal(divisor.to_s)
|
|
rescue Money::Currency::UnknownCurrencyError, ArgumentError
|
|
Rails.logger.warn("Invalid Brex money payload #{money_payload.inspect}, defaulting conversion to USD")
|
|
begin
|
|
safe_amount = BigDecimal(payload[:amount].to_s)
|
|
safe_amount / BigDecimal(Money::Currency.new("USD").minor_unit_conversion.to_s)
|
|
rescue ArgumentError, TypeError
|
|
BigDecimal("0")
|
|
end
|
|
end
|
|
|
|
def self.currency_code_from_money(money_payload)
|
|
payload = money_payload.is_a?(Hash) ? money_payload.with_indifferent_access : {}
|
|
currency = payload[:currency].presence || "USD"
|
|
Money::Currency.new(currency).iso_code
|
|
rescue Money::Currency::UnknownCurrencyError
|
|
"USD"
|
|
end
|
|
|
|
def self.sanitize_payload(payload)
|
|
case payload
|
|
when Array
|
|
payload.map { |value| sanitize_payload(value) }
|
|
when Hash
|
|
payload.each_with_object({}) do |(key, value), sanitized|
|
|
key_string = key.to_s
|
|
normalized_key = key_string.downcase
|
|
|
|
if sensitive_number_key?(normalized_key)
|
|
sanitized["#{key_string}_last4"] = last_four(value)
|
|
elsif normalized_key == "card_metadata"
|
|
sanitized[key_string] = sanitize_card_metadata(value)
|
|
elsif sensitive_secret_key?(normalized_key)
|
|
sanitized[key_string] = "[FILTERED]"
|
|
else
|
|
sanitized[key_string] = sanitize_payload(value)
|
|
end
|
|
end
|
|
else
|
|
payload
|
|
end
|
|
end
|
|
|
|
def self.last_four(value)
|
|
digits = value.to_s.gsub(/\D/, "")
|
|
digits.last(4) if digits.present?
|
|
end
|
|
|
|
def self.sanitize_card_metadata(value)
|
|
return nil unless value.is_a?(Hash)
|
|
|
|
metadata = value.with_indifferent_access
|
|
{
|
|
"card_id" => metadata[:card_id].presence || metadata[:id].presence,
|
|
"card_name" => metadata[:card_name].presence || metadata[:name].presence,
|
|
"card_type" => metadata[:card_type].presence || metadata[:type].presence,
|
|
"last_four" => last_four(metadata[:last_four].presence || metadata[:last4].presence || metadata[:card_last_four].presence)
|
|
}.compact
|
|
end
|
|
|
|
def current_account
|
|
account
|
|
end
|
|
|
|
def linked_account
|
|
account
|
|
end
|
|
|
|
def cash?
|
|
account_kind == "cash"
|
|
end
|
|
|
|
def card?
|
|
account_kind == "card"
|
|
end
|
|
|
|
def upsert_brex_snapshot!(account_snapshot)
|
|
snapshot = account_snapshot.with_indifferent_access
|
|
kind = snapshot[:account_kind].presence || snapshot[:kind].presence || "cash"
|
|
kind = "card" if kind.to_s == "credit_card"
|
|
|
|
update!(
|
|
current_balance: self.class.money_to_decimal(snapshot[:current_balance]),
|
|
available_balance: self.class.money_to_decimal(snapshot[:available_balance]),
|
|
account_limit: self.class.money_to_decimal(snapshot[:account_limit]),
|
|
currency: self.class.currency_code_from_money(snapshot[:current_balance] || snapshot[:available_balance] || snapshot[:account_limit]),
|
|
name: self.class.name_for(snapshot.merge(account_kind: kind)),
|
|
account_id: snapshot[:id]&.to_s,
|
|
account_kind: kind,
|
|
account_status: snapshot[:status],
|
|
account_type: snapshot[:type],
|
|
provider: "brex",
|
|
institution_metadata: build_institution_metadata(snapshot, kind),
|
|
raw_payload: self.class.sanitize_payload(account_snapshot)
|
|
)
|
|
end
|
|
|
|
def upsert_brex_transactions_snapshot!(transactions_snapshot)
|
|
update!(
|
|
raw_transactions_payload: self.class.sanitize_payload(transactions_snapshot)
|
|
)
|
|
end
|
|
|
|
private
|
|
|
|
def self.sensitive_number_key?(normalized_key)
|
|
normalized_key.in?(%w[account_number routing_number pan primary_account_number card_number])
|
|
end
|
|
|
|
def self.sensitive_secret_key?(normalized_key)
|
|
normalized_key.include?("token") ||
|
|
normalized_key.include?("secret") ||
|
|
normalized_key.in?(%w[api_key access_key authorization cvc cvv security_code])
|
|
end
|
|
private_class_method :sensitive_number_key?, :sensitive_secret_key?
|
|
|
|
def build_institution_metadata(snapshot, kind)
|
|
{
|
|
name: "Brex",
|
|
domain: "brex.com",
|
|
url: "https://brex.com",
|
|
account_kind: kind,
|
|
account_type: snapshot[:type],
|
|
primary: snapshot[:primary],
|
|
account_number_last4: self.class.last_four(snapshot[:account_number]),
|
|
routing_number_last4: self.class.last_four(snapshot[:routing_number]),
|
|
status: snapshot[:status],
|
|
current_statement_period: self.class.sanitize_payload(snapshot[:current_statement_period])
|
|
}.compact
|
|
end
|
|
end
|