Skip to content

Commit

Permalink
Initial pass at Plaid EU (#1555)
Browse files Browse the repository at this point in the history
* Initial pass at Plaid EU

* Add EU support to Plaid Items

* Lint

* Temp fix for rubocop isseus

* Merge cleanup

* Pass in region and get tests passing

* Use absolute path for translation

---------

Signed-off-by: Josh Pigford <[email protected]>
  • Loading branch information
Shpigford authored Jan 31, 2025
1 parent 41873de commit 4bf7250
Show file tree
Hide file tree
Showing 15 changed files with 81 additions and 21 deletions.
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,6 @@ STRIPE_WEBHOOK_SECRET=
#
PLAID_CLIENT_ID=
PLAID_SECRET=
PLAID_ENV=
PLAID_ENV=
PLAID_EU_CLIENT_ID=
PLAID_EU_SECRET=
3 changes: 2 additions & 1 deletion app/controllers/concerns/accountable_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ def set_link_token
@link_token = Current.family.get_link_token(
webhooks_url: webhooks_url,
redirect_url: accounts_url,
accountable_type: accountable_type.name
accountable_type: accountable_type.name,
region: Current.family.country.to_s.downcase == "us" ? :us : :eu
)
end

Expand Down
3 changes: 2 additions & 1 deletion app/controllers/plaid_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ def create
Current.family.plaid_items.create_from_public_token(
plaid_item_params[:public_token],
item_name: item_name,
region: plaid_item_params[:region]
)

redirect_to accounts_path, notice: t(".success")
Expand All @@ -29,7 +30,7 @@ def set_plaid_item
end

def plaid_item_params
params.require(:plaid_item).permit(:public_token, metadata: {})
params.require(:plaid_item).permit(:public_token, :region, metadata: {})
end

def item_name
Expand Down
2 changes: 2 additions & 0 deletions app/javascript/controllers/plaid_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = {
linkToken: String,
region: { type: String, default: "us" }
};

open() {
Expand Down Expand Up @@ -31,6 +32,7 @@ export default class extends Controller {
plaid_item: {
public_token: public_token,
metadata: metadata,
region: this.regionValue
},
}),
}).then((response) => {
Expand Down
13 changes: 11 additions & 2 deletions app/models/concerns/plaidable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@ module Plaidable
def plaid_provider
Provider::Plaid.new if Rails.application.config.plaid
end

def plaid_eu_provider
Provider::Plaid.new if Rails.application.config.plaid_eu
end

def plaid_provider_for(plaid_item)
return nil unless plaid_item
plaid_item.eu? ? plaid_eu_provider : plaid_provider
end
end

private
def plaid_provider
self.class.plaid_provider
def plaid_provider_for(plaid_item)
self.class.plaid_provider_for(plaid_item)
end
end
18 changes: 14 additions & 4 deletions app/models/family.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# rubocop:disable Layout/ElseAlignment, Layout/IndentationWidth
class Family < ApplicationRecord
include Plaidable, Syncable

Expand Down Expand Up @@ -47,14 +48,22 @@ def syncing?
super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?)
end

def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil)
return nil unless plaid_provider
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us)
provider = case region
when :eu
self.class.plaid_eu_provider
else
self.class.plaid_provider
end

return nil unless provider

plaid_provider.get_link_token(
provider.get_link_token(
user_id: id,
webhooks_url: webhooks_url,
redirect_url: redirect_url,
accountable_type: accountable_type
accountable_type: accountable_type,
eu: region == :eu
).link_token
end

Expand Down Expand Up @@ -229,3 +238,4 @@ def categories_with_stats(classification:, date: Date.current)
)
end
end
# rubocop:enable Layout/ElseAlignment, Layout/IndentationWidth
10 changes: 7 additions & 3 deletions app/models/plaid_item.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
class PlaidItem < ApplicationRecord
include Plaidable, Syncable

enum :plaid_region, { us: "us", eu: "eu" }

if Rails.application.credentials.active_record_encryption.present?
encrypts :access_token, deterministic: true
end
Expand All @@ -19,13 +21,14 @@ class PlaidItem < ApplicationRecord
scope :ordered, -> { order(created_at: :desc) }

class << self
def create_from_public_token(token, item_name:)
def create_from_public_token(token, item_name:, region: "us")
response = plaid_provider.exchange_public_token(token)

new_plaid_item = create!(
name: item_name,
plaid_id: response.item_id,
access_token: response.access_token,
plaid_region: region
)

new_plaid_item.sync_later
Expand Down Expand Up @@ -56,10 +59,11 @@ def destroy_later
private
def fetch_and_load_plaid_data
data = {}
item = plaid_provider.get_item(access_token).item
provider = plaid_provider_for(self)
item = provider.get_item(access_token).item
update!(available_products: item.available_products, billed_products: item.billed_products)

fetched_accounts = plaid_provider.get_item_accounts(self).accounts
fetched_accounts = provider.get_item_accounts(self).accounts
data[:accounts] = fetched_accounts || []

internal_plaid_accounts = fetched_accounts.map do |account|
Expand Down
12 changes: 10 additions & 2 deletions app/models/provider/plaid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,13 @@ def initialize
@client = self.class.client
end

def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil)
def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil, eu: false)
request = Plaid::LinkTokenCreateRequest.new({
user: { client_user_id: user_id },
client_name: "Maybe Finance",
products: [ get_primary_product(accountable_type) ],
additional_consented_products: get_additional_consented_products(accountable_type),
country_codes: [ "US", "CA" ],
country_codes: get_country_codes(eu),
language: "en",
webhook: webhooks_url,
redirect_uri: redirect_url,
Expand Down Expand Up @@ -198,4 +198,12 @@ def get_primary_product(accountable_type)
def get_additional_consented_products(accountable_type)
MAYBE_SUPPORTED_PLAID_PRODUCTS - [ get_primary_product(accountable_type) ]
end

def get_country_codes(eu)
if eu
[ "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
end
13 changes: 12 additions & 1 deletion app/views/accounts/new/_method_selector.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,23 @@
<% end %>

<% if link_token.present? %>
<%# Default US-only Link %>
<button data-controller="plaid" data-action="plaid#open modal#close" data-plaid-link-token-value="<%= @link_token %>" class="flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon("link-2", class: "text-gray-500 w-5 h-5") %>
</span>
<%= t("accounts.new.method_selector.connected_entry") %>
<%= t(".connected_entry") %>
</button>

<%# EU Link %>
<% unless Current.family.country == "US" %>
<button data-controller="plaid" data-action="plaid#open modal#close" data-plaid-link-token-value="<%= Current.family.get_link_token(webhooks_url: webhooks_plaid_url, redirect_url: accounts_url, accountable_type: accountable_type.name, region: :eu) %>" class="flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon("link-2", class: "text-gray-500 w-5 h-5") %>
</span>
<%= t(".connected_entry_eu") %>
</button>
<% end %>
<% end %>
</div>
<% end %>
8 changes: 8 additions & 0 deletions config/initializers/plaid.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
Rails.application.configure do
config.plaid = nil
config.plaid_eu = nil

if ENV["PLAID_CLIENT_ID"].present? && ENV["PLAID_SECRET"].present?
config.plaid = Plaid::Configuration.new
config.plaid.server_index = Plaid::Configuration::Environment[ENV["PLAID_ENV"] || "sandbox"]
config.plaid.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_CLIENT_ID"]
config.plaid.api_key["PLAID-SECRET"] = ENV["PLAID_SECRET"]
end

if ENV["PLAID_EU_CLIENT_ID"].present? && ENV["PLAID_EU_SECRET"].present?
config.plaid_eu = Plaid::Configuration.new
config.plaid_eu.server_index = Plaid::Configuration::Environment[ENV["PLAID_ENV"] || "sandbox"]
config.plaid_eu.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_EU_CLIENT_ID"]
config.plaid_eu.api_key["PLAID-SECRET"] = ENV["PLAID_EU_SECRET"]
end
end
1 change: 1 addition & 0 deletions config/locales/views/accounts/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ en:
import_accounts: Import accounts
method_selector:
connected_entry: Link account
connected_entry_eu: Link EU account
manual_entry: Enter account balance
title: How would you like to add it?
title: What would you like to add?
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20241219151540_add_region_to_plaid_item.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddRegionToPlaidItem < ActiveRecord::Migration[7.2]
def change
add_column :plaid_items, :plaid_region, :string, null: false, default: "us"
end
end
1 change: 1 addition & 0 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/controllers/plaid_items_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest
post plaid_items_url, params: {
plaid_item: {
public_token: public_token,
region: "us",
metadata: { institution: { name: "Plaid Item Name" } }
}
}
Expand Down
8 changes: 2 additions & 6 deletions test/models/plaid_item_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ class PlaidItemTest < ActiveSupport::TestCase

test "removes plaid item when destroyed" do
@plaid_provider = mock

PlaidItem.stubs(:plaid_provider).returns(@plaid_provider)

@plaid_item.stubs(:plaid_provider).returns(@plaid_provider)
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).once

assert_difference "PlaidItem.count", -1 do
Expand All @@ -21,9 +19,7 @@ class PlaidItemTest < ActiveSupport::TestCase

test "if plaid item not found, silently continues with deletion" do
@plaid_provider = mock

PlaidItem.stubs(:plaid_provider).returns(@plaid_provider)

@plaid_item.stubs(:plaid_provider).returns(@plaid_provider)
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).raises(Plaid::ApiError.new("Item not found"))

assert_difference "PlaidItem.count", -1 do
Expand Down

0 comments on commit 4bf7250

Please sign in to comment.