2024-11-15 13:49:37 -05:00
|
|
|
class Provider::Plaid
|
2025-01-31 17:04:26 -05:00
|
|
|
attr_reader :client, :region
|
2024-11-15 13:49:37 -05:00
|
|
|
|
2024-11-15 17:33:18 -05:00
|
|
|
MAYBE_SUPPORTED_PLAID_PRODUCTS = %w[transactions investments liabilities].freeze
|
2024-11-15 13:49:37 -05:00
|
|
|
MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730
|
|
|
|
|
2025-02-07 10:35:42 -05:00
|
|
|
def initialize(config, region: :us)
|
|
|
|
@client = Plaid::PlaidApi.new(
|
|
|
|
Plaid::ApiClient.new(config)
|
|
|
|
)
|
|
|
|
@region = region
|
|
|
|
end
|
2024-11-15 13:49:37 -05:00
|
|
|
|
2025-02-07 10:35:42 -05:00
|
|
|
def process_webhook(webhook_body)
|
|
|
|
parsed = JSON.parse(webhook_body)
|
2024-11-15 13:49:37 -05:00
|
|
|
|
2025-02-07 10:35:42 -05:00
|
|
|
type = parsed["webhook_type"]
|
|
|
|
code = parsed["webhook_code"]
|
2024-11-15 13:49:37 -05:00
|
|
|
|
2025-02-07 10:35:42 -05:00
|
|
|
item = PlaidItem.find_by(plaid_id: parsed["item_id"])
|
2024-11-15 13:49:37 -05:00
|
|
|
|
2025-02-07 10:35:42 -05:00
|
|
|
case [ type, code ]
|
|
|
|
when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ]
|
|
|
|
item.sync_later
|
|
|
|
when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ]
|
|
|
|
item.sync_later
|
|
|
|
when [ "HOLDINGS", "DEFAULT_UPDATE" ]
|
|
|
|
item.sync_later
|
|
|
|
else
|
|
|
|
Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}")
|
|
|
|
end
|
|
|
|
rescue => error
|
|
|
|
# Processing errors shouldn't return a 400 to Plaid since they are internal, so capture silently
|
|
|
|
Sentry.capture_exception(error)
|
|
|
|
end
|
2024-11-15 13:49:37 -05:00
|
|
|
|
2025-02-07 10:35:42 -05:00
|
|
|
def validate_webhook!(verification_header, raw_body)
|
|
|
|
jwks_loader = ->(options) do
|
|
|
|
key_id = options[:kid]
|
2024-11-15 13:49:37 -05:00
|
|
|
|
2025-02-07 10:35:42 -05:00
|
|
|
jwk_response = client.webhook_verification_key_get(
|
|
|
|
Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id)
|
2024-11-15 13:49:37 -05:00
|
|
|
)
|
|
|
|
|
2025-02-07 10:35:42 -05:00
|
|
|
jwks = JWT::JWK::Set.new([ jwk_response.key.to_hash ])
|
2024-11-15 13:49:37 -05:00
|
|
|
|
2025-02-07 10:35:42 -05:00
|
|
|
jwks.filter! { |key| key[:use] == "sig" }
|
|
|
|
jwks
|
2024-11-15 13:49:37 -05:00
|
|
|
end
|
|
|
|
|
2025-02-07 10:35:42 -05:00
|
|
|
payload, _header = JWT.decode(
|
|
|
|
verification_header, nil, true,
|
|
|
|
{
|
|
|
|
algorithms: [ "ES256" ],
|
|
|
|
jwks: jwks_loader,
|
|
|
|
verify_expiration: false
|
|
|
|
}
|
2025-01-31 17:04:26 -05:00
|
|
|
)
|
2025-02-07 10:35:42 -05:00
|
|
|
|
|
|
|
issued_at = Time.at(payload["iat"])
|
|
|
|
raise JWT::VerificationError, "Webhook is too old" if Time.now - issued_at > 5.minutes
|
|
|
|
|
|
|
|
expected_hash = payload["request_body_sha256"]
|
|
|
|
actual_hash = Digest::SHA256.hexdigest(raw_body)
|
|
|
|
raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash)
|
2024-11-15 13:49:37 -05:00
|
|
|
end
|
|
|
|
|
2025-02-12 12:59:35 -06:00
|
|
|
def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil, access_token: nil)
|
|
|
|
request_params = {
|
2024-11-15 13:49:37 -05:00
|
|
|
user: { client_user_id: user_id },
|
|
|
|
client_name: "Maybe Finance",
|
2025-01-31 17:04:26 -05:00
|
|
|
country_codes: country_codes,
|
2024-11-25 09:48:21 -05:00
|
|
|
language: "en",
|
2024-11-15 13:49:37 -05:00
|
|
|
webhook: webhooks_url,
|
|
|
|
redirect_uri: redirect_url,
|
|
|
|
transactions: { days_requested: MAX_HISTORY_DAYS }
|
2025-02-12 12:59:35 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
if access_token.present?
|
|
|
|
request_params[:access_token] = access_token
|
|
|
|
else
|
|
|
|
request_params[:products] = [ get_primary_product(accountable_type) ]
|
|
|
|
request_params[:additional_consented_products] = get_additional_consented_products(accountable_type)
|
|
|
|
end
|
|
|
|
|
|
|
|
request = Plaid::LinkTokenCreateRequest.new(request_params)
|
2024-11-15 13:49:37 -05:00
|
|
|
|
|
|
|
client.link_token_create(request)
|
|
|
|
end
|
|
|
|
|
|
|
|
def exchange_public_token(token)
|
|
|
|
request = Plaid::ItemPublicTokenExchangeRequest.new(
|
|
|
|
public_token: token
|
|
|
|
)
|
|
|
|
|
|
|
|
client.item_public_token_exchange(request)
|
|
|
|
end
|
|
|
|
|
|
|
|
def get_item(access_token)
|
|
|
|
request = Plaid::ItemGetRequest.new(access_token: access_token)
|
|
|
|
client.item_get(request)
|
|
|
|
end
|
|
|
|
|
|
|
|
def remove_item(access_token)
|
|
|
|
request = Plaid::ItemRemoveRequest.new(access_token: access_token)
|
|
|
|
client.item_remove(request)
|
|
|
|
end
|
|
|
|
|
|
|
|
def get_item_accounts(item)
|
|
|
|
request = Plaid::AccountsGetRequest.new(access_token: item.access_token)
|
|
|
|
client.accounts_get(request)
|
|
|
|
end
|
|
|
|
|
|
|
|
def get_item_transactions(item)
|
|
|
|
cursor = item.next_cursor
|
|
|
|
added = []
|
|
|
|
modified = []
|
|
|
|
removed = []
|
|
|
|
has_more = true
|
|
|
|
|
|
|
|
while has_more
|
|
|
|
request = Plaid::TransactionsSyncRequest.new(
|
|
|
|
access_token: item.access_token,
|
|
|
|
cursor: cursor
|
|
|
|
)
|
|
|
|
|
|
|
|
response = client.transactions_sync(request)
|
|
|
|
|
|
|
|
added += response.added
|
|
|
|
modified += response.modified
|
|
|
|
removed += response.removed
|
|
|
|
has_more = response.has_more
|
|
|
|
cursor = response.next_cursor
|
|
|
|
end
|
|
|
|
|
|
|
|
TransactionSyncResponse.new(added:, modified:, removed:, cursor:)
|
|
|
|
end
|
|
|
|
|
|
|
|
def get_item_investments(item, start_date: nil, end_date: Date.current)
|
|
|
|
start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date
|
2024-12-12 08:56:52 -05:00
|
|
|
holdings, holding_securities = get_item_holdings(item)
|
|
|
|
transactions, transaction_securities = get_item_investment_transactions(item, start_date:, end_date:)
|
2024-11-15 13:49:37 -05:00
|
|
|
|
2024-12-12 08:56:52 -05:00
|
|
|
merged_securities = ((holding_securities || []) + (transaction_securities || [])).uniq { |s| s.security_id }
|
|
|
|
|
|
|
|
InvestmentsResponse.new(holdings:, transactions:, securities: merged_securities)
|
2024-11-15 13:49:37 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def get_item_liabilities(item)
|
|
|
|
request = Plaid::LiabilitiesGetRequest.new({ access_token: item.access_token })
|
|
|
|
response = client.liabilities_get(request)
|
|
|
|
response.liabilities
|
|
|
|
end
|
|
|
|
|
2025-02-06 08:57:24 -06:00
|
|
|
def get_institution(institution_id)
|
|
|
|
request = Plaid::InstitutionsGetByIdRequest.new({
|
|
|
|
institution_id: institution_id,
|
|
|
|
country_codes: country_codes,
|
|
|
|
options: {
|
|
|
|
include_optional_metadata: true
|
|
|
|
}
|
|
|
|
})
|
|
|
|
client.institutions_get_by_id(request)
|
|
|
|
end
|
|
|
|
|
2024-11-15 13:49:37 -05:00
|
|
|
private
|
|
|
|
TransactionSyncResponse = Struct.new :added, :modified, :removed, :cursor, keyword_init: true
|
|
|
|
InvestmentsResponse = Struct.new :holdings, :transactions, :securities, keyword_init: true
|
|
|
|
|
|
|
|
def get_item_holdings(item)
|
|
|
|
request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: item.access_token })
|
|
|
|
response = client.investments_holdings_get(request)
|
|
|
|
|
2024-12-12 08:56:52 -05:00
|
|
|
[ response.holdings, response.securities ]
|
2024-11-15 13:49:37 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def get_item_investment_transactions(item, start_date:, end_date:)
|
|
|
|
transactions = []
|
|
|
|
securities = []
|
|
|
|
offset = 0
|
|
|
|
|
|
|
|
loop do
|
|
|
|
request = Plaid::InvestmentsTransactionsGetRequest.new(
|
|
|
|
access_token: item.access_token,
|
|
|
|
start_date: start_date.to_s,
|
|
|
|
end_date: end_date.to_s,
|
|
|
|
options: { offset: offset }
|
|
|
|
)
|
|
|
|
|
|
|
|
response = client.investments_transactions_get(request)
|
|
|
|
|
2024-12-12 08:56:52 -05:00
|
|
|
transactions += response.investment_transactions
|
2024-11-15 13:49:37 -05:00
|
|
|
securities += response.securities
|
|
|
|
|
|
|
|
break if transactions.length >= response.total_investment_transactions
|
|
|
|
offset = transactions.length
|
|
|
|
end
|
|
|
|
|
|
|
|
[ transactions, securities ]
|
|
|
|
end
|
|
|
|
|
2024-11-15 17:33:18 -05:00
|
|
|
def get_primary_product(accountable_type)
|
2025-02-03 20:39:08 -05:00
|
|
|
return "transactions" if eu?
|
|
|
|
|
2024-11-15 13:49:37 -05:00
|
|
|
case accountable_type
|
|
|
|
when "Investment"
|
2024-11-15 17:33:18 -05:00
|
|
|
"investments"
|
2024-11-15 13:49:37 -05:00
|
|
|
when "CreditCard", "Loan"
|
2024-11-15 17:33:18 -05:00
|
|
|
"liabilities"
|
2024-11-15 13:49:37 -05:00
|
|
|
else
|
2024-11-15 17:33:18 -05:00
|
|
|
"transactions"
|
2024-11-15 13:49:37 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-11-15 17:33:18 -05:00
|
|
|
def get_additional_consented_products(accountable_type)
|
2025-02-03 20:39:08 -05:00
|
|
|
return [] if eu?
|
|
|
|
|
2024-11-15 17:33:18 -05:00
|
|
|
MAYBE_SUPPORTED_PLAID_PRODUCTS - [ get_primary_product(accountable_type) ]
|
|
|
|
end
|
2025-01-31 12:13:58 -06:00
|
|
|
|
2025-02-03 20:39:08 -05:00
|
|
|
def eu?
|
|
|
|
region.to_sym == :eu
|
|
|
|
end
|
|
|
|
|
2025-01-31 17:04:26 -05:00
|
|
|
def country_codes
|
2025-02-03 20:39:08 -05:00
|
|
|
if eu?
|
2025-01-31 12:13:58 -06:00
|
|
|
[ "ES", "NL", "FR", "IE", "DE", "IT", "PL", "DK", "NO", "SE", "EE", "LT", "LV", "PT", "BE" ] # EU supported countries
|
|
|
|
else
|
|
|
|
[ "US", "CA" ] # US + CA only
|
|
|
|
end
|
|
|
|
end
|
2024-11-15 13:49:37 -05:00
|
|
|
end
|