diff --git a/.env.example b/.env.example
index ef90f4a374d..5253039cd5c 100644
--- a/.env.example
+++ b/.env.example
@@ -118,4 +118,6 @@ STRIPE_WEBHOOK_SECRET=
#
PLAID_CLIENT_ID=
PLAID_SECRET=
-PLAID_ENV=
\ No newline at end of file
+PLAID_ENV=
+PLAID_EU_CLIENT_ID=
+PLAID_EU_SECRET=
diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb
index 8f6a3244abd..5a160917225 100644
--- a/app/controllers/concerns/accountable_resource.rb
+++ b/app/controllers/concerns/accountable_resource.rb
@@ -47,7 +47,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
diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb
index c0ac89ad744..64537896a0c 100644
--- a/app/controllers/plaid_items_controller.rb
+++ b/app/controllers/plaid_items_controller.rb
@@ -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")
@@ -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
diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js
index 14b0c182ea1..5f4f1d29248 100644
--- a/app/javascript/controllers/plaid_controller.js
+++ b/app/javascript/controllers/plaid_controller.js
@@ -4,6 +4,7 @@ import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = {
linkToken: String,
+ region: { type: String, default: "us" }
};
open() {
@@ -31,6 +32,7 @@ export default class extends Controller {
plaid_item: {
public_token: public_token,
metadata: metadata,
+ region: this.regionValue
},
}),
}).then((response) => {
diff --git a/app/models/concerns/plaidable.rb b/app/models/concerns/plaidable.rb
index ddecd89325b..838b4837e4b 100644
--- a/app/models/concerns/plaidable.rb
+++ b/app/models/concerns/plaidable.rb
@@ -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
diff --git a/app/models/family.rb b/app/models/family.rb
index 69ac5eb7ca4..75e8d1c515d 100644
--- a/app/models/family.rb
+++ b/app/models/family.rb
@@ -1,3 +1,4 @@
+# rubocop:disable Layout/ElseAlignment, Layout/IndentationWidth
class Family < ApplicationRecord
include Plaidable, Syncable
@@ -45,14 +46,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
@@ -174,3 +183,4 @@ def primary_user
users.order(:created_at).first
end
end
+# rubocop:enable Layout/ElseAlignment, Layout/IndentationWidth
diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb
index 79087900c3c..1230613b713 100644
--- a/app/models/plaid_item.rb
+++ b/app/models/plaid_item.rb
@@ -1,6 +1,9 @@
class PlaidItem < ApplicationRecord
include Plaidable, Syncable
+ enum :plaid_region, { us: "us", eu: "eu" }
+ validates :plaid_region, inclusion: { in: plaid_regions.keys }
+
if Rails.application.credentials.active_record_encryption.present?
encrypts :access_token, deterministic: true
end
@@ -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|
diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb
index e41a0a46dab..924e10c9459 100644
--- a/app/models/provider/plaid.rb
+++ b/app/models/provider/plaid.rb
@@ -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" ],
+ country_codes: get_country_codes(eu),
language: "en",
webhook: webhooks_url,
redirect_uri: redirect_url,
@@ -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" ] # US only
+ end
+ end
end
diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb
index 4c32ede4af3..a4e2afa98a1 100644
--- a/app/views/accounts/new/_method_selector.html.erb
+++ b/app/views/accounts/new/_method_selector.html.erb
@@ -6,16 +6,27 @@
<%= lucide_icon("keyboard", class: "text-gray-500 w-5 h-5") %>
- <%= t("accounts.new.method_selector.manual_entry") %>
+ <%= t(".manual_entry") %>
<% end %>
<% if link_token.present? %>
+ <%# Default US-only Link %>
+
+ <%# EU Link %>
+ <% unless Current.family.country == "US" %>
+
+ <% end %>
<% end %>
<% end %>
diff --git a/config/initializers/plaid.rb b/config/initializers/plaid.rb
index b0631110fd9..1925158a01f 100644
--- a/config/initializers/plaid.rb
+++ b/config/initializers/plaid.rb
@@ -1,5 +1,6 @@
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
@@ -7,4 +8,11 @@
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
diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml
index 3180b37ec15..d986cedee25 100644
--- a/config/locales/views/accounts/en.yml
+++ b/config/locales/views/accounts/en.yml
@@ -30,6 +30,8 @@ en:
import_accounts: Import accounts
method_selector:
connected_entry: Link account
+ connected_entry_us: Link US 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?
diff --git a/db/migrate/20241219151540_add_region_to_plaid_item.rb b/db/migrate/20241219151540_add_region_to_plaid_item.rb
new file mode 100644
index 00000000000..55e9607505f
--- /dev/null
+++ b/db/migrate/20241219151540_add_region_to_plaid_item.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 7beb5097e92..4e786c063bc 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2024_12_18_132503) do
+ActiveRecord::Schema[7.2].define(version: 2024_12_19_151540) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -178,6 +178,15 @@
t.index ["family_id"], name: "index_categories_on_family_id"
end
+ create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "user_id", null: false
+ t.string "title"
+ t.text "summary"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["user_id"], name: "index_chats_on_user_id"
+ end
+
create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -460,6 +469,33 @@
t.index ["family_id"], name: "index_merchants_on_family_id"
end
+ create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "chat_id", null: false
+ t.uuid "user_id"
+ t.text "content"
+ t.text "log"
+ t.string "role"
+ t.string "status", default: "pending"
+ t.boolean "hidden", default: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["chat_id"], name: "index_messages_on_chat_id"
+ t.index ["user_id"], name: "index_messages_on_user_id"
+ end
+
+ create_table "metrics", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "family_id", null: false
+ t.uuid "account_id"
+ t.string "kind", null: false
+ t.string "subkind"
+ t.date "date", null: false
+ t.decimal "value", precision: 10, scale: 2, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_metrics_on_account_id"
+ t.index ["family_id"], name: "index_metrics_on_family_id"
+ end
+
create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -497,6 +533,7 @@
t.string "available_products", default: [], array: true
t.string "billed_products", default: [], array: true
t.datetime "last_synced_at"
+ t.string "plaid_region", default: "us", null: false
t.index ["family_id"], name: "index_plaid_items_on_family_id"
end
@@ -647,6 +684,7 @@
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "categories", "families"
+ add_foreign_key "chats", "users"
add_foreign_key "impersonation_session_logs", "impersonation_sessions"
add_foreign_key "impersonation_sessions", "users", column: "impersonated_id"
add_foreign_key "impersonation_sessions", "users", column: "impersonator_id"
@@ -655,6 +693,10 @@
add_foreign_key "invitations", "families"
add_foreign_key "invitations", "users", column: "inviter_id"
add_foreign_key "merchants", "families"
+ add_foreign_key "messages", "chats"
+ add_foreign_key "messages", "users"
+ add_foreign_key "metrics", "accounts"
+ add_foreign_key "metrics", "families"
add_foreign_key "plaid_accounts", "plaid_items"
add_foreign_key "plaid_items", "families"
add_foreign_key "security_prices", "securities"