Files
sure/app/models/provider_merchant.rb
Oscar c6a240a183 feat(redbark): add australian bank sync (redbark) (#2794)
* add redbark provider integration

- per family api key provider, built like the lunchflow integration
- syncs accounts, balances and transactions from api.redbark.com
- account setup flow, settings panel, locales and routes
- tests and fixtures

* harden redbark integration based on prior provider pr feedback

- use DebugLogEntry.capture for sync/import/unlink failures
- retry 429s and 5xxs with backoff, raise on page cap instead of truncating
- keep raw response bodies out of logs and errors
- not null constraints on account columns, migration base 7.2
- persist ignored flag for skipped accounts so they stop nagging setup
- validate api key on every save, re-arm status on key rotation
- destroy aborts if unlink fails, atomic account create and link
- require_admin on mutating actions, see_other on error redirects
- single grouped query for item account counts
- i18n default connection name, blank password field value
- controller and provider tests

* fix issues found in second review sweep

- add missing syncable scope, without it every family sync raises
- kick off a sync on connection create and on key rotation
- setup dialog fetches accounts inline for fresh connections and shows api errors
- skip balance write when no balance has been fetched yet, never anchor a false zero
- exclude stale and non banking accounts from the batched balances call, per account fallback if the batch is rejected
- detect the server row ceiling and empty pages instead of silently truncating history
- user sync start date only governs the initial backfill, incremental after that
- fetch connections before the per account loop so auth errors propagate once
- drop untemplated index/show/new/edit routes and dead preload/link_accounts actions
- stable dom id on the settings panel so repeat turbo replaces keep working

* skip brokerage connections, found in live testing

- the transactions endpoint 400s for brokerage connections, they belong to /v1/trades
- only import accounts from banking and documents connections
- guard transaction fetches for any legacy linked non banking account

* address review feedback

- treat the truncation header as a pagination signal: split the date window and refetch instead of failing the account
- prune stale pending rows from the snapshot so settled pendings cant come back as duplicates
- block linking a sure account that already has another provider feed
- count setup failures separately from skips and surface an error instead of "all skipped"
- add not nulls on redbark_items name and api key
- enqueue the destroy job after the flag commits, not inside the transaction
- swap bg-gray-400 for bg-surface-inset, drop amounts from info logs, remove i18n default fallbacks
- tests for window splitting, pending pruning and encrypted payload round trip

* fix issues from convention review

- benign skips (unlinked account, blank id, unparseable rows) no longer count as failures, tracked separately so a clean batch reports success
- currency parsing goes through extract_currency so hash shaped payloads resolve instead of falling to the default
- merchant ids use truncated sha256 instead of md5
- debug log entries for import failures and account sync scheduling failures

* bound the raw transactions snapshot to the fetch window

- trim raw_transactions_payload to the current fetch window on merge, same as brex
- keep rows without a parseable date, drop settled pendings as before
- surface skipped rows in the aggregate debug log entry with imported/skipped counts
2026-07-26 07:40:25 +02:00

68 lines
2.8 KiB
Ruby

class ProviderMerchant < Merchant
enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", akahu: "akahu", up: "up", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", brex: "brex", indexa_capital: "indexa_capital", sophtron: "sophtron", questrade: "questrade", redbark: "redbark" }
validates :name, uniqueness: { scope: [ :source ] }
validates :source, presence: true
# Convert this ProviderMerchant to a FamilyMerchant for a specific family.
# Only affects transactions belonging to that family.
# Returns the newly created FamilyMerchant.
def convert_to_family_merchant_for(family, attributes = {})
transaction do
family_merchant = family.merchants.create!(
name: attributes[:name].presence || name,
color: attributes[:color].presence || FamilyMerchant::COLORS.sample,
website_url: attributes[:website_url].presence || website_url
)
scope = family.transactions.where(merchant_id: id)
# Protect the manual reassignment from being reverted on the next
# provider sync (issue #1977). Must run before the merchant_id update.
Entry.mark_user_modified_for_transactions!(scope)
# Update only this family's transactions to point to new merchant
scope.update_all(merchant_id: family_merchant.id)
family_merchant
end
end
# Generate logo URL from website_url using BrandFetch, if configured.
def generate_logo_url_from_website!
if website_url.present? && Setting.brand_fetch_client_id.present?
domain = extract_domain(website_url)
size = Setting.brand_fetch_logo_size
update!(logo_url: "https://cdn.brandfetch.io/#{domain}/icon/fallback/lettermark/w/#{size}/h/#{size}?c=#{Setting.brand_fetch_client_id}")
elsif website_url.blank?
update!(logo_url: nil)
end
end
# Unlink from family's transactions (set merchant_id to null).
# Does NOT delete the ProviderMerchant since it may be used by other families.
# Tracks the unlink in FamilyMerchantAssociation so it shows as "recently unlinked".
def unlink_from_family(family)
scope = family.transactions.where(merchant_id: id)
# Protect the manual unlink from being reverted on the next provider sync
# (issue #1977). Must run before the merchant_id is nulled.
Entry.mark_user_modified_for_transactions!(scope)
scope.update_all(merchant_id: nil)
# Track that this merchant was unlinked from this family
association = FamilyMerchantAssociation.find_or_initialize_by(family: family, merchant: self)
association.update!(unlinked_at: Time.current)
end
private
def extract_domain(url)
normalized_url = url.start_with?("http://", "https://") ? url : "https://#{url}"
URI.parse(normalized_url).host&.sub(/\Awww\./, "")
rescue URI::InvalidURIError
url.sub(/\Awww\./, "")
end
end