mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 21:29:38 +02:00
Initial pass at Plaid EU (#1555)
* 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 <josh@joshpigford.com>
This commit is contained in:
parent
41873de11d
commit
4bf72506d5
15 changed files with 81 additions and 21 deletions
|
@ -119,3 +119,5 @@ STRIPE_WEBHOOK_SECRET=
|
||||||
PLAID_CLIENT_ID=
|
PLAID_CLIENT_ID=
|
||||||
PLAID_SECRET=
|
PLAID_SECRET=
|
||||||
PLAID_ENV=
|
PLAID_ENV=
|
||||||
|
PLAID_EU_CLIENT_ID=
|
||||||
|
PLAID_EU_SECRET=
|
||||||
|
|
|
@ -55,7 +55,8 @@ module AccountableResource
|
||||||
@link_token = Current.family.get_link_token(
|
@link_token = Current.family.get_link_token(
|
||||||
webhooks_url: webhooks_url,
|
webhooks_url: webhooks_url,
|
||||||
redirect_url: accounts_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
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ class PlaidItemsController < ApplicationController
|
||||||
Current.family.plaid_items.create_from_public_token(
|
Current.family.plaid_items.create_from_public_token(
|
||||||
plaid_item_params[:public_token],
|
plaid_item_params[:public_token],
|
||||||
item_name: item_name,
|
item_name: item_name,
|
||||||
|
region: plaid_item_params[:region]
|
||||||
)
|
)
|
||||||
|
|
||||||
redirect_to accounts_path, notice: t(".success")
|
redirect_to accounts_path, notice: t(".success")
|
||||||
|
@ -29,7 +30,7 @@ class PlaidItemsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def plaid_item_params
|
def plaid_item_params
|
||||||
params.require(:plaid_item).permit(:public_token, metadata: {})
|
params.require(:plaid_item).permit(:public_token, :region, metadata: {})
|
||||||
end
|
end
|
||||||
|
|
||||||
def item_name
|
def item_name
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Controller } from "@hotwired/stimulus";
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static values = {
|
static values = {
|
||||||
linkToken: String,
|
linkToken: String,
|
||||||
|
region: { type: String, default: "us" }
|
||||||
};
|
};
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
|
@ -31,6 +32,7 @@ export default class extends Controller {
|
||||||
plaid_item: {
|
plaid_item: {
|
||||||
public_token: public_token,
|
public_token: public_token,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
region: this.regionValue
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
|
|
|
@ -5,10 +5,19 @@ module Plaidable
|
||||||
def plaid_provider
|
def plaid_provider
|
||||||
Provider::Plaid.new if Rails.application.config.plaid
|
Provider::Plaid.new if Rails.application.config.plaid
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def plaid_provider
|
def plaid_provider_for(plaid_item)
|
||||||
self.class.plaid_provider
|
self.class.plaid_provider_for(plaid_item)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# rubocop:disable Layout/ElseAlignment, Layout/IndentationWidth
|
||||||
class Family < ApplicationRecord
|
class Family < ApplicationRecord
|
||||||
include Plaidable, Syncable
|
include Plaidable, Syncable
|
||||||
|
|
||||||
|
@ -47,14 +48,22 @@ class Family < ApplicationRecord
|
||||||
super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?)
|
super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil)
|
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us)
|
||||||
return nil unless plaid_provider
|
provider = case region
|
||||||
|
when :eu
|
||||||
|
self.class.plaid_eu_provider
|
||||||
|
else
|
||||||
|
self.class.plaid_provider
|
||||||
|
end
|
||||||
|
|
||||||
plaid_provider.get_link_token(
|
return nil unless provider
|
||||||
|
|
||||||
|
provider.get_link_token(
|
||||||
user_id: id,
|
user_id: id,
|
||||||
webhooks_url: webhooks_url,
|
webhooks_url: webhooks_url,
|
||||||
redirect_url: redirect_url,
|
redirect_url: redirect_url,
|
||||||
accountable_type: accountable_type
|
accountable_type: accountable_type,
|
||||||
|
eu: region == :eu
|
||||||
).link_token
|
).link_token
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -229,3 +238,4 @@ class Family < ApplicationRecord
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
# rubocop:enable Layout/ElseAlignment, Layout/IndentationWidth
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
class PlaidItem < ApplicationRecord
|
class PlaidItem < ApplicationRecord
|
||||||
include Plaidable, Syncable
|
include Plaidable, Syncable
|
||||||
|
|
||||||
|
enum :plaid_region, { us: "us", eu: "eu" }
|
||||||
|
|
||||||
if Rails.application.credentials.active_record_encryption.present?
|
if Rails.application.credentials.active_record_encryption.present?
|
||||||
encrypts :access_token, deterministic: true
|
encrypts :access_token, deterministic: true
|
||||||
end
|
end
|
||||||
|
@ -19,13 +21,14 @@ class PlaidItem < ApplicationRecord
|
||||||
scope :ordered, -> { order(created_at: :desc) }
|
scope :ordered, -> { order(created_at: :desc) }
|
||||||
|
|
||||||
class << self
|
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)
|
response = plaid_provider.exchange_public_token(token)
|
||||||
|
|
||||||
new_plaid_item = create!(
|
new_plaid_item = create!(
|
||||||
name: item_name,
|
name: item_name,
|
||||||
plaid_id: response.item_id,
|
plaid_id: response.item_id,
|
||||||
access_token: response.access_token,
|
access_token: response.access_token,
|
||||||
|
plaid_region: region
|
||||||
)
|
)
|
||||||
|
|
||||||
new_plaid_item.sync_later
|
new_plaid_item.sync_later
|
||||||
|
@ -56,10 +59,11 @@ class PlaidItem < ApplicationRecord
|
||||||
private
|
private
|
||||||
def fetch_and_load_plaid_data
|
def fetch_and_load_plaid_data
|
||||||
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)
|
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 || []
|
data[:accounts] = fetched_accounts || []
|
||||||
|
|
||||||
internal_plaid_accounts = fetched_accounts.map do |account|
|
internal_plaid_accounts = fetched_accounts.map do |account|
|
||||||
|
|
|
@ -68,13 +68,13 @@ class Provider::Plaid
|
||||||
@client = self.class.client
|
@client = self.class.client
|
||||||
end
|
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({
|
request = Plaid::LinkTokenCreateRequest.new({
|
||||||
user: { client_user_id: user_id },
|
user: { client_user_id: user_id },
|
||||||
client_name: "Maybe Finance",
|
client_name: "Maybe Finance",
|
||||||
products: [ get_primary_product(accountable_type) ],
|
products: [ get_primary_product(accountable_type) ],
|
||||||
additional_consented_products: get_additional_consented_products(accountable_type),
|
additional_consented_products: get_additional_consented_products(accountable_type),
|
||||||
country_codes: [ "US", "CA" ],
|
country_codes: get_country_codes(eu),
|
||||||
language: "en",
|
language: "en",
|
||||||
webhook: webhooks_url,
|
webhook: webhooks_url,
|
||||||
redirect_uri: redirect_url,
|
redirect_uri: redirect_url,
|
||||||
|
@ -198,4 +198,12 @@ class Provider::Plaid
|
||||||
def get_additional_consented_products(accountable_type)
|
def get_additional_consented_products(accountable_type)
|
||||||
MAYBE_SUPPORTED_PLAID_PRODUCTS - [ get_primary_product(accountable_type) ]
|
MAYBE_SUPPORTED_PLAID_PRODUCTS - [ get_primary_product(accountable_type) ]
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -10,12 +10,23 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if link_token.present? %>
|
<% 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">
|
<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)]">
|
<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") %>
|
<%= lucide_icon("link-2", class: "text-gray-500 w-5 h-5") %>
|
||||||
</span>
|
</span>
|
||||||
<%= t("accounts.new.method_selector.connected_entry") %>
|
<%= t(".connected_entry") %>
|
||||||
</button>
|
</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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
Rails.application.configure do
|
Rails.application.configure do
|
||||||
config.plaid = nil
|
config.plaid = nil
|
||||||
|
config.plaid_eu = nil
|
||||||
|
|
||||||
if ENV["PLAID_CLIENT_ID"].present? && ENV["PLAID_SECRET"].present?
|
if ENV["PLAID_CLIENT_ID"].present? && ENV["PLAID_SECRET"].present?
|
||||||
config.plaid = Plaid::Configuration.new
|
config.plaid = Plaid::Configuration.new
|
||||||
|
@ -7,4 +8,11 @@ Rails.application.configure do
|
||||||
config.plaid.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_CLIENT_ID"]
|
config.plaid.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_CLIENT_ID"]
|
||||||
config.plaid.api_key["PLAID-SECRET"] = ENV["PLAID_SECRET"]
|
config.plaid.api_key["PLAID-SECRET"] = ENV["PLAID_SECRET"]
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -30,6 +30,7 @@ en:
|
||||||
import_accounts: Import accounts
|
import_accounts: Import accounts
|
||||||
method_selector:
|
method_selector:
|
||||||
connected_entry: Link account
|
connected_entry: Link account
|
||||||
|
connected_entry_eu: Link EU account
|
||||||
manual_entry: Enter account balance
|
manual_entry: Enter account balance
|
||||||
title: How would you like to add it?
|
title: How would you like to add it?
|
||||||
title: What would you like to add?
|
title: What would you like to add?
|
||||||
|
|
5
db/migrate/20241219151540_add_region_to_plaid_item.rb
Normal file
5
db/migrate/20241219151540_add_region_to_plaid_item.rb
Normal file
|
@ -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
db/schema.rb
generated
1
db/schema.rb
generated
|
@ -516,6 +516,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_31_171943) do
|
||||||
t.string "available_products", default: [], array: true
|
t.string "available_products", default: [], array: true
|
||||||
t.string "billed_products", default: [], array: true
|
t.string "billed_products", default: [], array: true
|
||||||
t.datetime "last_synced_at"
|
t.datetime "last_synced_at"
|
||||||
|
t.string "plaid_region", default: "us", null: false
|
||||||
t.index ["family_id"], name: "index_plaid_items_on_family_id"
|
t.index ["family_id"], name: "index_plaid_items_on_family_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest
|
||||||
post plaid_items_url, params: {
|
post plaid_items_url, params: {
|
||||||
plaid_item: {
|
plaid_item: {
|
||||||
public_token: public_token,
|
public_token: public_token,
|
||||||
|
region: "us",
|
||||||
metadata: { institution: { name: "Plaid Item Name" } }
|
metadata: { institution: { name: "Plaid Item Name" } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,9 +9,7 @@ class PlaidItemTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
test "removes plaid item when destroyed" do
|
test "removes plaid item when destroyed" do
|
||||||
@plaid_provider = mock
|
@plaid_provider = mock
|
||||||
|
@plaid_item.stubs(:plaid_provider).returns(@plaid_provider)
|
||||||
PlaidItem.stubs(:plaid_provider).returns(@plaid_provider)
|
|
||||||
|
|
||||||
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).once
|
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).once
|
||||||
|
|
||||||
assert_difference "PlaidItem.count", -1 do
|
assert_difference "PlaidItem.count", -1 do
|
||||||
|
@ -21,9 +19,7 @@ class PlaidItemTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
test "if plaid item not found, silently continues with deletion" do
|
test "if plaid item not found, silently continues with deletion" do
|
||||||
@plaid_provider = mock
|
@plaid_provider = mock
|
||||||
|
@plaid_item.stubs(:plaid_provider).returns(@plaid_provider)
|
||||||
PlaidItem.stubs(:plaid_provider).returns(@plaid_provider)
|
|
||||||
|
|
||||||
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).raises(Plaid::ApiError.new("Item not found"))
|
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).raises(Plaid::ApiError.new("Item not found"))
|
||||||
|
|
||||||
assert_difference "PlaidItem.count", -1 do
|
assert_difference "PlaidItem.count", -1 do
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue