mirror of
https://github.com/we-promise/sure.git
synced 2026-09-08 08:04:15 +00:00
* Fix user-triggered Plaid transaction refresh Request a fresh Plaid institution update for explicit user syncs, then poll the saved cursor with bounded retries so private self-hosted instances do not depend on webhooks. Preserve the existing immediate sync and coalesce repeated refresh requests.\n\nCloses #3204 * Address Plaid refresh concurrency races * Preserve Plaid refresh handoff on retry exhaustion * Release Plaid refresh lease on enqueue errors Ensure adapter exceptions cannot leave the shared refresh cooldown occupied when no job was queued.
208 lines
6.8 KiB
Ruby
208 lines
6.8 KiB
Ruby
class PlaidItem < ApplicationRecord
|
|
include Syncable, Provided, Encryptable
|
|
|
|
enum :plaid_region, { us: "us", eu: "eu" }
|
|
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
|
|
|
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
|
|
if encryption_ready?
|
|
encrypts :access_token, deterministic: true
|
|
encrypts :raw_payload
|
|
encrypts :raw_institution_payload
|
|
end
|
|
|
|
validates :name, presence: true
|
|
validates :access_token, presence: true, on: :create
|
|
|
|
before_destroy :remove_plaid_item
|
|
|
|
belongs_to :family
|
|
has_one_attached :logo, dependent: :purge_later
|
|
|
|
has_many :plaid_accounts, dependent: :destroy
|
|
has_many :legacy_accounts, through: :plaid_accounts, source: :account
|
|
|
|
scope :active, -> { where(scheduled_for_deletion: false) }
|
|
scope :syncable, -> { active }
|
|
scope :ordered, -> { order(created_at: :desc) }
|
|
scope :needs_update, -> { where(status: :requires_update) }
|
|
|
|
TRANSACTIONS_REFRESH_COOLDOWN = 5.minutes
|
|
|
|
# Get accounts from both new and legacy systems
|
|
def accounts
|
|
@accounts ||= plaid_accounts
|
|
.includes(:account, account_provider: :account)
|
|
.map(&:current_account)
|
|
.compact
|
|
.uniq
|
|
end
|
|
|
|
def get_update_link_token(webhooks_url:, redirect_url:, account_selection_enabled: false)
|
|
family.get_link_token(
|
|
webhooks_url: webhooks_url,
|
|
redirect_url: redirect_url,
|
|
region: plaid_region,
|
|
access_token: access_token,
|
|
account_selection_enabled: account_selection_enabled
|
|
)
|
|
rescue Plaid::ApiError => e
|
|
error_body = begin
|
|
JSON.parse(e.response_body.to_s)
|
|
rescue JSON::ParserError
|
|
{}
|
|
end
|
|
|
|
if error_body["error_code"] == "ITEM_NOT_FOUND"
|
|
# Mark the connection as invalid but don't auto-delete. The caller
|
|
# gets nil so the calling controller can decide what to render.
|
|
update!(status: :requires_update)
|
|
Sentry.capture_exception(e) if defined?(Sentry)
|
|
nil
|
|
else
|
|
# Re-raise so the controller can surface a friendly alert to the user
|
|
# (issue #1792). Swallowing here previously left the Plaid modal frame
|
|
# blank with no actionable signal.
|
|
raise
|
|
end
|
|
end
|
|
|
|
# Queue a fresh import after an in-flight sync. Plaid Link can finish adding
|
|
# accounts after the active sync already fetched its account list.
|
|
def sync_later_with_follow_up
|
|
active_sync = syncs.visible.ordered.first
|
|
sync_later
|
|
|
|
return unless active_sync&.reload&.in_progress?
|
|
|
|
PlaidFollowUpSyncJob.set(wait: PlaidFollowUpSyncJob::RETRY_DELAY).perform_later(self, active_sync_id: active_sync.id)
|
|
end
|
|
|
|
def destroy_later
|
|
update!(scheduled_for_deletion: true)
|
|
DestroyJob.perform_later(self)
|
|
end
|
|
|
|
def request_transactions_refresh_later
|
|
return unless supports_product?("transactions")
|
|
return unless shared_transactions_refresh_cache?
|
|
|
|
refresh_requested = Rails.cache.write(
|
|
transactions_refresh_cache_key,
|
|
true,
|
|
expires_in: TRANSACTIONS_REFRESH_COOLDOWN,
|
|
unless_exist: true
|
|
)
|
|
|
|
return unless refresh_requested
|
|
|
|
enqueued_job = begin
|
|
PlaidTransactionsRefreshJob.perform_later(self)
|
|
rescue
|
|
Rails.cache.delete(transactions_refresh_cache_key)
|
|
raise
|
|
end
|
|
|
|
Rails.cache.delete(transactions_refresh_cache_key) unless enqueued_job
|
|
end
|
|
|
|
def sync_later_with_provider_refresh
|
|
request_transactions_refresh_later
|
|
sync_later_with_follow_up
|
|
end
|
|
|
|
def import_latest_plaid_data
|
|
PlaidItem::Importer.new(self, plaid_provider: plaid_provider).import
|
|
end
|
|
|
|
# Reads the fetched data and updates internal domain objects
|
|
# Generally, this should only be called within a "sync", but can be called
|
|
# manually to "re-sync" the already fetched data
|
|
def process_accounts
|
|
plaid_accounts.each do |plaid_account|
|
|
PlaidAccount::Processor.new(plaid_account).process
|
|
end
|
|
end
|
|
|
|
# Once all the data is fetched, we can schedule account syncs to calculate historical balances
|
|
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
|
accounts.each do |account|
|
|
account.sync_later(
|
|
parent_sync: parent_sync,
|
|
window_start_date: window_start_date,
|
|
window_end_date: window_end_date
|
|
)
|
|
end
|
|
end
|
|
|
|
# Saves the raw data fetched from Plaid API for this item
|
|
def upsert_plaid_snapshot!(item_snapshot)
|
|
assign_attributes(
|
|
available_products: item_snapshot.available_products,
|
|
billed_products: item_snapshot.billed_products,
|
|
raw_payload: item_snapshot,
|
|
)
|
|
|
|
save!
|
|
end
|
|
|
|
# Saves the raw data fetched from Plaid API for this item's institution
|
|
def upsert_plaid_institution_snapshot!(institution_snapshot)
|
|
assign_attributes(
|
|
institution_id: institution_snapshot.institution_id,
|
|
institution_url: institution_snapshot.url,
|
|
institution_color: institution_snapshot.primary_color,
|
|
raw_institution_payload: institution_snapshot
|
|
)
|
|
|
|
save!
|
|
end
|
|
|
|
def supports_product?(product)
|
|
supported_products.include?(product)
|
|
end
|
|
|
|
private
|
|
def transactions_refresh_cache_key
|
|
"plaid_item:#{id}:transactions_refresh_requested"
|
|
end
|
|
|
|
def shared_transactions_refresh_cache?
|
|
shared_cache = Rails.cache.is_a?(ActiveSupport::Cache::RedisCacheStore) ||
|
|
Rails.cache.is_a?(ActiveSupport::Cache::MemCacheStore) ||
|
|
Rails.cache.class.name == "SolidCache::Store"
|
|
|
|
Rails.logger.warn("Plaid transaction refresh requires a shared Rails cache store") unless shared_cache
|
|
shared_cache
|
|
end
|
|
|
|
def remove_plaid_item
|
|
return unless plaid_provider.present?
|
|
|
|
plaid_provider.remove_item(access_token)
|
|
rescue Plaid::ApiError => e
|
|
json_response = JSON.parse(e.response_body)
|
|
error_code = json_response["error_code"]
|
|
|
|
# Continue with deletion if:
|
|
# - ITEM_NOT_FOUND: Item was already deleted by the user on their Plaid portal OR by Plaid support
|
|
# - INVALID_API_KEYS: API credentials are invalid/missing, so we can't communicate with Plaid anyway
|
|
# - Other credential errors: We're deleting our record, so no need to fail if we can't reach Plaid
|
|
ignorable_errors = %w[ITEM_NOT_FOUND INVALID_API_KEYS INVALID_CLIENT_ID INVALID_SECRET]
|
|
|
|
unless ignorable_errors.include?(error_code)
|
|
# Log the error but don't prevent deletion - we're removing the item from our database
|
|
# If we can't tell Plaid, we'll at least stop using it on our end
|
|
Rails.logger.warn("Failed to remove Plaid item: #{error_code} - #{json_response['error_message']}")
|
|
Sentry.capture_exception(e) if defined?(Sentry)
|
|
end
|
|
end
|
|
|
|
# Plaid returns mutually exclusive arrays here. If the item has made a request for a product,
|
|
# it is put in the billed_products array. If it is supported, but not yet used, it goes in the
|
|
# available_products array.
|
|
def supported_products
|
|
available_products + billed_products
|
|
end
|
|
end
|