---
title: Plaid — Bank Account Linking & Transaction Sync
slug: plaid-bank-account-sync
category: Integrations
summary: Full Plaid integration with zero gems — plain Net::HTTP service module, Plaid Link frontend, cursor-based transactions/sync job, webhook-driven updates, a connection-health state machine for re-auth, and the hard-won fixes for the "two accounts at the same bank" data-crossing trap.
tags: [plaid, banking, transactions, finance, api, webhook, sync, env, service]
status: stable
visibility: public
source_project: lp-finances.leo.llamapress.ai
layers: [model, sql, controller, view]
related:
  - title: Twilio — SMS Sending & Phone Verification
    url: /cookbook/twilio-sms-and-phone-verification
    summary: Sibling integration recipe — same .env-key + service-module pattern.
  - title: Plaid API docs — Transactions & Link
    url: https://plaid.com/docs/
    summary: Official reference for link/token/create, transactions/sync, and webhook codes used below.
---

# Plaid — Bank Account Linking & Transaction Sync

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below
> is an **example snippet**, **not part of the llamapress.ai codebase**, and **not
> running on this server**. This is a reference recipe for a **Leo instance (an AI coding
> agent) to implement in its own app** — read it to understand the pattern, then recreate
> it there.

Your app needs real bank data — transactions for a P&L, balances for a dashboard, a
bookkeeping tool. Plaid is the bridge: the user clicks "Link account", authenticates
with their bank inside Plaid's hosted **Link** widget, and your app gets an
`access_token` it can use to pull accounts and transactions forever after.

This recipe is a **complete, production-proven integration with zero gems**. The
official `plaid` gem is not in the base image and you cannot add gems on a Leo instance
— so the service module below talks to Plaid's JSON API directly with `Net::HTTP` from
the standard library. It is the whole integration: Link token creation, the token
exchange, cursor-based incremental transaction sync in a background job, webhook-driven
updates, historical backfill by date range, and a connection-health state machine that
handles the day the user's bank login expires.

> **When to use:** pulling a user's real bank transactions and balances into your app —
> bookkeeping, P&L reports, spend dashboards, budgeting tools.
> **When not to:** taking payments (that's Stripe), or when the user only has a handful
> of transactions to track (a manual entry form is a day less work and zero per-item
> fees — Plaid bills per connected Item).

---

## The 80/20 in one breath

1. Get a **client_id** and **secret** from the Plaid dashboard
   (`dashboard.plaid.com`). Start in **sandbox**; request production access later.
2. Add `PLAID_CLIENT_ID`, `PLAID_SECRET`, `PLAID_URL`, and `BASE_URL` to `.env`, then
   **recreate the web container** (a plain restart does not reload `.env`).
3. Run the two migrations below (`plaid_institutions`, `plaid_accounts`) plus the Plaid
   columns on your `transactions` table.
4. Copy `PlaidService` into `app/services/plaid_service.rb`.
5. Wire the controller + routes: `GET /plaid` renders the Link page,
   `POST /plaid/exchange_public_token` stores the access token and accounts,
   `POST /plaid/webhook` reacts to Plaid's pushes.
6. Copy the sync job. Link a sandbox bank (`user_good` / `pass_good`) and watch
   transactions appear.

---

## Layer 0 — Environment keys (.env)

```bash
# .env  (project root, next to docker-compose.yml — compose passes this file
# into the web container via `env_file: .env`)

# REQUIRED — from dashboard.plaid.com → Team Settings → Keys
PLAID_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxx
PLAID_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx        # sandbox and production secrets DIFFER

# REQUIRED — which Plaid environment the secret belongs to.
# Sandbox:    https://sandbox.plaid.com/
# Production: https://production.plaid.com/
PLAID_URL=https://sandbox.plaid.com/

# REQUIRED for webhooks — your app's public https URL.
# The webhook URL registered with Plaid becomes "#{BASE_URL}/plaid/webhook".
BASE_URL=https://yourapp.example.com
```

After editing `.env`: `docker compose down llamapress && docker compose up -d llamapress`.

---

## Layer 1 — Model & SQL

Three tables carry the integration. A `PlaidInstitution` is one Plaid **Item** — one
login at one bank (it owns the `access_token`, the sync cursor, and the health state).
A `PlaidAccount` is one account inside that Item (checking, savings, a credit card).
Transactions belong to both.

```ruby
# db/migrate/XXXXXXXXXXXXXX_create_plaid_institutions.rb
class CreatePlaidInstitutions < ActiveRecord::Migration[8.0]
  def change
    create_table :plaid_institutions, id: :uuid do |t|
      t.references :user, type: :uuid, null: false, foreign_key: true
      t.text :access_token                        # the long-lived Plaid credential
      t.text :item_id                             # Plaid's ID for this login (Item)
      t.text :institution_id_plaid, null: false   # the BANK's ID (e.g. ins_120013) — NOT unique per Item!
      t.text :institution_name, null: false

      # Incremental sync state
      t.text :transactions_cursor                 # cursor for transactions/sync
      t.boolean :initial_sync_complete, default: false
      t.boolean :historical_sync_complete, default: false
      t.datetime :last_sync_at

      # Connection-health state machine
      t.text :status, default: "active"           # active | needs_reauth | pending_expiration | pending_disconnect | error
      t.boolean :needs_update, default: false
      t.text :last_error_code
      t.text :last_error_message
      t.datetime :last_successful_sync_at
      t.datetime :last_failed_sync_at
      t.datetime :consent_expiration_time

      t.timestamps
    end
  end
end
```

```ruby
# db/migrate/XXXXXXXXXXXXXX_create_plaid_accounts.rb
class CreatePlaidAccounts < ActiveRecord::Migration[8.0]
  def change
    create_table :plaid_accounts, id: :uuid do |t|
      t.references :user, type: :uuid, foreign_key: true
      t.references :plaid_institution, type: :uuid, foreign_key: true
      t.text :account_id_plaid, null: false       # Plaid's account ID (changes on re-link!)
      t.text :item_id, null: false
      t.text :name, null: false                   # "Plaid Checking"
      t.text :account_type, null: false           # depository | credit | loan | investment
      t.text :subtype                             # checking | savings | credit card ...
      t.text :mask                                # last 4 digits — the STABLE identifier
      t.decimal :balance_available
      t.decimal :balance_current
      t.decimal :balance_limit
      t.text :iso_currency_code, default: "USD"
      t.timestamps
    end

    # One Plaid account ID per institution; mask+name is the human-stable identity
    add_index :plaid_accounts, [:plaid_institution_id, :account_id_plaid], unique: true
    add_index :plaid_accounts, [:user_id, :mask, :name], unique: true
  end
end
```

```ruby
# db/migrate/XXXXXXXXXXXXXX_add_plaid_columns_to_transactions.rb
# Assumes you already have a transactions table (user_id, amount, date, description).
class AddPlaidColumnsToTransactions < ActiveRecord::Migration[8.0]
  def change
    add_reference :transactions, :plaid_account, type: :uuid, foreign_key: true
    add_reference :transactions, :plaid_institution, type: :uuid, foreign_key: true
    add_column :transactions, :transaction_id_plaid, :text     # dedup key from Plaid
    add_column :transactions, :account_id_plaid, :text
    add_column :transactions, :name_plaid, :text
    add_column :transactions, :merchant_name, :text
    add_column :transactions, :category_list_plaid, :text, array: true, default: []
    add_column :transactions, :category_id_plaid, :text
    add_column :transactions, :pending, :boolean, default: false
    add_column :transactions, :transaction_type, :text
    add_column :transactions, :iso_currency_code, :text, default: "USD"
    add_index  :transactions, :transaction_id_plaid
  end
end
```

The institution model carries the health state machine — this is what makes the
integration survive month two, when bank logins start expiring:

```ruby
# app/models/plaid_institution.rb
class PlaidInstitution < ApplicationRecord
  belongs_to :user
  has_many :plaid_accounts, dependent: :destroy
  has_many :transactions, dependent: :nullify

  enum :status, {
    active: "active",
    needs_reauth: "needs_reauth",
    pending_expiration: "pending_expiration",
    pending_disconnect: "pending_disconnect",
    error: "error"
  }, validate: true

  scope :healthy, -> { where(status: :active, needs_update: false) }

  def record_sync_success!
    update!(
      last_sync_at: Time.current, last_successful_sync_at: Time.current,
      last_error_code: nil, last_error_message: nil, last_failed_sync_at: nil,
      needs_update: false, status: :active
    )
  end

  def record_sync_failure!(error_code:, error_message:)
    update!(
      last_error_code: error_code, last_error_message: error_message,
      last_failed_sync_at: Time.current, needs_update: true,
      status: error_code == "ITEM_LOGIN_REQUIRED" ? :needs_reauth : :error
    )
  end
end
```

```ruby
# app/models/plaid_account.rb
class PlaidAccount < ApplicationRecord
  belongs_to :user
  belongs_to :plaid_institution
  has_many :transactions, dependent: :nullify
end
```

---

## Layer 2 — The service module (plain Net::HTTP, no gem)

Every Plaid endpoint is a JSON POST with `client_id` + `secret` in the body. That's the
whole API surface — one private `post_request` helper covers everything.

```ruby
# app/services/plaid_service.rb
require "net/http"
require "uri"
require "json"

module PlaidService
  extend self

  # Create a Link token. Two modes:
  #  - New connection: omit access_token → requests the 'transactions' product.
  #  - RE-AUTH (update mode): pass the institution's stored access_token →
  #    Link opens against the existing Item so the user can refresh their login.
  def create_link_token(client_id:, secret:, user_id:, webhook_url: nil, access_token: nil)
    body = {
      client_id: client_id,
      secret: secret,
      user: { client_user_id: user_id.to_s },   # must be unique per user
      client_name: "My Finance App",            # shown inside the Link UI
      country_codes: ["US"],
      language: "en"
    }

    if access_token.present?
      body[:access_token] = access_token        # update mode — no products key
    else
      body[:products] = ["transactions"]
      # Plaid only backfills 90 days by default. Ask for 2 years UP FRONT —
      # this cannot be changed after the Item is created.
      body[:transactions] = { days_requested: 730 }
    end

    body[:webhook] = webhook_url if webhook_url.present?

    post_request("link/token/create", body)["link_token"]
  end

  # Trade the short-lived public_token from Link for the permanent access_token.
  def exchange_public_token(client_id:, secret:, public_token:)
    result = post_request("item/public_token/exchange",
      client_id: client_id, secret: secret, public_token: public_token)
    { access_token: result["access_token"], item_id: result["item_id"] }
  end

  # Current accounts + balances for an Item.
  def get_accounts(access_token:, client_id:, secret:)
    post_request("accounts/balance/get",
      client_id: client_id, secret: secret, access_token: access_token)["accounts"]
  end

  # Incremental transaction sync. Returns {'added', 'modified', 'removed',
  # 'next_cursor', 'has_more'}. Pass cursor: nil on the very first call.
  def get_transactions(access_token:, client_id:, secret:, cursor: nil)
    post_request("transactions/sync",
      client_id: client_id, secret: secret, access_token: access_token, cursor: cursor)
  end

  # One-off historical backfill for a date range (transactions/get, offset-paginated).
  def get_transactions_by_date_range(access_token:, client_id:, secret:,
                                     start_date:, end_date:, offset: 0, count: 500)
    post_request("transactions/get",
      client_id: client_id, secret: secret, access_token: access_token,
      start_date: start_date, end_date: end_date,
      options: { offset: offset, count: count })
  end

  # "Plaid error CODE: MESSAGE - ..." → [code, message]; feeds the health state machine.
  def parse_plaid_error(error)
    match = error.message.match(/Plaid error (\w+): (.*?) - /)
    match ? [match[1], match[2]] : ["UNKNOWN", error.message.truncate(500)]
  end

  private

  def base_uri
    URI(ENV["PLAID_URL"] || "https://sandbox.plaid.com/")
  end

  def post_request(endpoint, body)
    url = base_uri.merge(endpoint)
    http = Net::HTTP.new(url.host, url.port)
    http.use_ssl = true

    request = Net::HTTP::Post.new(url.path, "Content-Type" => "application/json")
    request.body = body.to_json
    response = http.request(request)
    result = JSON.parse(response.body)

    return result if response.is_a?(Net::HTTPSuccess)

    # Keep this exact format — parse_plaid_error depends on it.
    raise "Plaid error #{result['error_code']}: #{result['error_message']} - #{result.fetch('display_message', 'No display message')}"
  end
end
```

---

## Layer 3 — Controller & routes

```ruby
# config/routes.rb  (inside the authenticated scope, except the webhook)
get  "plaid" => "plaid#new"
post "plaid/exchange_public_token" => "plaid#exchange_public_token"
post "plaid/sync_all" => "plaid#sync_all", as: :plaid_sync_all
post "plaid/institutions/:id/resync" => "plaid#resync", as: :plaid_institution_resync
post "plaid/webhook" => "plaid#webhook"     # must be reachable WITHOUT auth
```

```ruby
# app/controllers/plaid_controller.rb
class PlaidController < ApplicationController
  # Plaid's servers call the webhook — no session, no CSRF token.
  skip_before_action :verify_authenticity_token, only: [:webhook]
  skip_before_action :authenticate_user!, only: [:webhook], raise: false

  # GET /plaid  — renders the Link page.
  # With ?institution_id=<uuid> it becomes a RE-AUTH page for that institution.
  def new
    @plaid_institutions = current_user.plaid_institutions.order(created_at: :desc)
    webhook_url = ENV["BASE_URL"].present? ? "#{ENV['BASE_URL']}/plaid/webhook" : nil

    access_token = nil
    if params[:institution_id].present?
      @reauth_institution = current_user.plaid_institutions.find(params[:institution_id])
      access_token = @reauth_institution.access_token
    end

    @link_token = PlaidService.create_link_token(
      client_id: ENV["PLAID_CLIENT_ID"], secret: ENV["PLAID_SECRET"],
      user_id: current_user.id, webhook_url: webhook_url, access_token: access_token
    )
  end

  # POST /plaid/exchange_public_token — called by the Link onSuccess JS.
  def exchange_public_token
    client_id  = ENV["PLAID_CLIENT_ID"]
    secret     = ENV["PLAID_SECRET"]
    bank_id    = params[:institution][:institution_id]   # Plaid's BANK id, e.g. ins_120013
    bank_name  = params[:institution][:name]

    if params[:plaid_institution_id].present?
      # ── RE-AUTH: the frontend told us EXACTLY which of OUR institutions this is.
      # In update mode the access_token does NOT change (and public_token may be
      # null) — reuse the stored token, skip the exchange entirely.
      plaid_institution = current_user.plaid_institutions.find(params[:plaid_institution_id])
      access_token = plaid_institution.access_token
      item_id      = plaid_institution.item_id
    else
      # ── NEW CONNECTION: exchange the public_token for a permanent access_token.
      tokens = PlaidService.exchange_public_token(
        client_id: client_id, secret: secret, public_token: params[:public_token])
      access_token = tokens[:access_token]
      item_id      = tokens[:item_id]

      # Match against an existing institution before creating one:
      # A. exact item_id match (same Item re-linked)
      plaid_institution = current_user.plaid_institutions.find_by(item_id: item_id)
      # B. else match by account mask+name — but ONLY within the same bank_id.
      #    Two logins at the same bank (personal + business) share bank_id and can
      #    even share account names; without this scoping they overwrite each other.
      if plaid_institution.nil?
        accounts = PlaidService.get_accounts(access_token: access_token,
                                             client_id: client_id, secret: secret)
        accounts.each do |acc|
          existing = current_user.plaid_accounts.joins(:plaid_institution)
            .where(plaid_institutions: { institution_id_plaid: bank_id })
            .find_by(mask: acc["mask"], name: acc["name"])
          if existing
            plaid_institution = existing.plaid_institution
            break
          end
        end
      end
    end

    accounts ||= PlaidService.get_accounts(access_token: access_token,
                                           client_id: client_id, secret: secret)

    if plaid_institution
      plaid_institution.update!(
        item_id: item_id, access_token: access_token,
        needs_update: false, status: :active,
        last_error_code: nil, last_error_message: nil, consent_expiration_time: nil
      )
    else
      plaid_institution = PlaidInstitution.create!(
        user: current_user, item_id: item_id, access_token: access_token,
        institution_id_plaid: bank_id, institution_name: bank_name
      )
    end

    # Upsert accounts. Match by mask+name FIRST (stable across re-links),
    # then by account_id_plaid (changes when the user re-links!).
    accounts.each do |a|
      plaid_account =
        plaid_institution.plaid_accounts.find_by(mask: a["mask"], name: a["name"]) ||
        plaid_institution.plaid_accounts.find_by(account_id_plaid: a["account_id"])

      attrs = {
        account_id_plaid: a["account_id"], item_id: item_id,
        name: a["name"], account_type: a["type"], subtype: a["subtype"],
        mask: a["mask"],
        balance_available: a["balances"]["available"],
        balance_current: a["balances"]["current"],
        balance_limit: a["balances"]["limit"],
        iso_currency_code: a["balances"]["iso_currency_code"] || "USD"
      }
      if plaid_account
        plaid_account.update!(attrs)
      else
        plaid_institution.plaid_accounts.create!(attrs.merge(user: current_user))
      end
    end

    PlaidTransactionSyncJob.perform_later(plaid_institution.id)
    redirect_to root_path, notice: "Successfully linked your bank account!"
  end

  # POST /plaid/institutions/:id/resync — manual "pull now" button.
  # If the connection is unhealthy, send the user to re-auth instead of syncing.
  def resync
    plaid_institution = current_user.plaid_institutions.find(params[:id])

    if plaid_institution.needs_update? || !plaid_institution.active?
      redirect_to plaid_path(institution_id: plaid_institution.id),
        alert: "Your bank login for #{plaid_institution.institution_name} needs re-authentication."
      return
    end

    PlaidTransactionSyncJob.perform_later(plaid_institution.id)
    redirect_back fallback_location: root_path,
      notice: "Syncing #{plaid_institution.institution_name}..."
  end

  def sync_all
    current_user.plaid_institutions.each { |i| PlaidTransactionSyncJob.perform_later(i.id) }
    redirect_back fallback_location: root_path, notice: "Syncing all accounts..."
  end

  # POST /plaid/webhook — Plaid pushes state changes here. ALWAYS return 200.
  def webhook
    webhook_code = params[:webhook_code]
    plaid_institution = PlaidInstitution.find_by(item_id: params[:item_id])
    Rails.logger.info "Plaid webhook: #{webhook_code} for item #{params[:item_id]}"

    case webhook_code
    when "INITIAL_UPDATE", "HISTORICAL_UPDATE", "DEFAULT_UPDATE", "SYNC_UPDATES_AVAILABLE"
      PlaidTransactionSyncJob.perform_later(plaid_institution.id) if plaid_institution

    when "TRANSACTIONS_REMOVED"
      (params[:removed_transactions] || []).each do |tx_id|
        Transaction.find_by(transaction_id_plaid: tx_id)&.destroy
      end

    when "ITEM_LOGIN_REQUIRED"
      error = params[:error] || {}
      plaid_institution&.update!(
        status: :needs_reauth, needs_update: true,
        last_error_code: error["error_code"] || "ITEM_LOGIN_REQUIRED",
        last_error_message: error["error_message"] || "User login required",
        last_failed_sync_at: Time.current
      )

    when "PENDING_EXPIRATION"
      updates = { status: :pending_expiration, needs_update: true }
      updates[:consent_expiration_time] = params[:consent_expiration_time] if params[:consent_expiration_time].present?
      plaid_institution&.update!(updates)

    when "PENDING_DISCONNECT"
      plaid_institution&.update!(status: :pending_disconnect, needs_update: true)

    when "LOGIN_REPAIRED"
      plaid_institution&.update!(
        status: :active, needs_update: false,
        last_error_code: nil, last_error_message: nil, last_failed_sync_at: nil
      )

    when "ERROR"
      error = params[:error] || {}
      plaid_institution&.update!(
        status: :error, needs_update: true,
        last_error_code: error["error_code"] || "WEBHOOK_ERROR",
        last_error_message: error["error_message"] || "Webhook reported an error",
        last_failed_sync_at: Time.current
      )
    end

    head :ok   # anything else = Plaid retries + may eventually disable the webhook
  end
end
```

---

## Layer 4 — The sync job (cursor-based, idempotent)

```ruby
# app/jobs/plaid_transaction_sync_job.rb
class PlaidTransactionSyncJob < ApplicationJob
  queue_as :default

  def perform(plaid_institution_id)
    plaid_institution = PlaidInstitution.find(plaid_institution_id)
    user = plaid_institution.user
    client_id = ENV["PLAID_CLIENT_ID"]
    secret    = ENV["PLAID_SECRET"]

    has_more = true
    cursor = plaid_institution.transactions_cursor   # nil on the very first sync

    while has_more
      response = PlaidService.get_transactions(
        access_token: plaid_institution.access_token,
        client_id: client_id, secret: secret, cursor: cursor)

      response["added"].each do |tx|
        next if Transaction.exists?(transaction_id_plaid: tx["transaction_id"])

        # Scope the account lookup to THIS institution — never assign a
        # transaction to a same-named account on a sibling institution.
        plaid_account = plaid_institution.plaid_accounts
                          .find_by(account_id_plaid: tx["account_id"])
        next unless plaid_account

        Transaction.create!(
          user: user,
          plaid_account: plaid_account,
          plaid_institution: plaid_institution,
          account_id_plaid: tx["account_id"],
          transaction_id_plaid: tx["transaction_id"],
          amount: tx["amount"],                 # Plaid: positive = money OUT
          description: tx["name"],
          name_plaid: tx["name"],
          merchant_name: tx["merchant_name"],
          category_list_plaid: tx["category"],
          category_id_plaid: tx["category_id"],
          date: tx["date"],
          pending: tx["pending"],
          transaction_type: tx["transaction_type"],
          iso_currency_code: tx["iso_currency_code"]
        )
      end

      (response["modified"] || []).each do |tx|
        Transaction.find_by(transaction_id_plaid: tx["transaction_id"])&.update!(
          amount: tx["amount"], description: tx["name"], name_plaid: tx["name"],
          merchant_name: tx["merchant_name"], date: tx["date"], pending: tx["pending"])
      end

      (response["removed"] || []).each do |tx|
        Transaction.find_by(transaction_id_plaid: tx["transaction_id"])&.destroy
      end

      cursor = response["next_cursor"]
      has_more = response["has_more"]

      # Persist the cursor EVERY page — a crash mid-sync resumes, not restarts.
      plaid_institution.update!(transactions_cursor: cursor, last_sync_at: Time.current)
    end

    plaid_institution.record_sync_success!
  rescue => e
    error_code, error_message = PlaidService.parse_plaid_error(e)
    plaid_institution.record_sync_failure!(error_code: error_code, error_message: error_message)
    raise e
  end
end
```

---

## Layer 5 — The Link page (frontend)

```erb
<%# app/views/plaid/new.html.erb %>
<div class="max-w-lg mx-auto p-8">
  <h1 class="text-2xl font-bold mb-4">Link a bank account</h1>
  <button id="link-button"
          class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
    <%= @reauth_institution ? "Re-authenticate #{@reauth_institution.institution_name}" : "Link account" %>
  </button>
</div>

<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
<script>
  function initializePlaidLink() {
    // In re-auth mode, pass OUR institution UUID through to the exchange endpoint
    // so the backend updates exactly the right record — item_id can change and
    // two institutions at the same bank can have identical account names/masks.
    const reauthInstitutionId = '<%= @reauth_institution&.id %>';

    const handler = Plaid.create({
      token: '<%= @link_token %>',
      onSuccess: (public_token, metadata) => {
        const body = { public_token: public_token, institution: metadata.institution };
        if (reauthInstitutionId) body.plaid_institution_id = reauthInstitutionId;

        fetch('/plaid/exchange_public_token', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
          },
          body: JSON.stringify(body)
        }).then(response => {
          if (response.ok) { window.location.href = '/'; }
          else { alert('Linking failed. Please try again.'); }
        });
      },
      onExit: (err, metadata) => { if (err) console.error('Plaid Link exit:', err); }
    });

    document.getElementById('link-button')
            .addEventListener('click', () => handler.open());

    // Auto-open in re-auth mode — the user already clicked "fix connection".
    if (reauthInstitutionId) handler.open();
  }

  // Cover both Turbo and non-Turbo page loads.
  document.addEventListener('turbo:load', initializePlaidLink, { once: true });
  if (document.readyState !== 'loading') initializePlaidLink();
</script>
```

---

## Gotchas (the hard-won stuff)

- **No gem needed — and none available.** The `plaid` gem is not in the base image and
  Leo instances can't add gems. The `Net::HTTP` module above is the whole client;
  Plaid's API is uniform enough (JSON POST, keys in the body) that one `post_request`
  helper covers every endpoint.
- **The "two accounts at the same bank" trap (this one caused real data crossing).**
  Plaid's `institution_id` (e.g. `ins_120013`) identifies the **bank**, not the login.
  A user with personal + business accounts at the same bank has two Items with the
  *same* bank ID — and possibly identical account names and masks. Defenses, in order:
  (1) during re-auth, pass **your own institution UUID** from the frontend and match by
  it directly; (2) when fuzzy-matching by mask+name, always scope the query to the same
  `institution_id_plaid`; (3) in the sync job, look accounts up **through the
  institution association**, never globally.
- **Re-auth (update mode) is a different flow, not a re-link.** Pass the stored
  `access_token` to `link/token/create` (and omit `products`). On success the
  `public_token` **may be null** and the `access_token` **does not change** — skip the
  exchange and reuse the stored token. Treating re-auth as a fresh link is how you end
  up with duplicate institutions.
- **`account_id` is not stable; `mask` + `name` is.** When a user re-links, Plaid
  issues brand-new `account_id`s (and a new `item_id`). Match accounts by mask+name
  first, then update the stored `account_id_plaid`. This is also why the unique index
  is on `(user_id, mask, name)`.
- **Ask for history up front: `days_requested: 730`.** Plaid defaults to 90 days of
  backfill and the setting is fixed at Item creation — you cannot raise it later
  without re-linking. For bookkeeping apps, 90 days is never enough.
- **Persist the `transactions/sync` cursor after every page.** The `while has_more`
  loop can crash mid-run (Plaid 500, deploy, OOM). Saving `next_cursor` each iteration
  means the next run resumes where it left off instead of re-processing everything.
  The `Transaction.exists?(transaction_id_plaid:)` guard makes replays harmless anyway.
- **Webhooks: skip CSRF *and* auth, and always `head :ok`.** Plaid's servers have no
  session and no CSRF token. A non-200 response makes Plaid retry and can eventually
  get the webhook disabled. Also: webhooks need a public `BASE_URL` — they will never
  arrive on localhost, so keep a manual "Sync now" button as the fallback path.
- **Handle `modified` and `removed`, not just `added`.** Pending transactions post a
  few days later as *modified* (amount and name can change), and cancelled ones arrive
  as *removed*. Only processing `added` leaves phantom pending charges in the ledger.
- **Amount sign convention:** Plaid's `amount` is **positive for money leaving** the
  account (a purchase) and negative for money coming in (a paycheck) — backwards from
  what most people expect. Decide once, at ingest, how your app stores it.
- **Bank logins WILL expire — build the health state machine on day one.** OAuth
  consents lapse, passwords change, banks force re-login. The `status` enum +
  `record_sync_failure!` (which maps `ITEM_LOGIN_REQUIRED` → `needs_reauth`) +
  the webhook handlers (`PENDING_EXPIRATION`, `LOGIN_REPAIRED`, `ERROR`) let the UI
  show "Reconnect your bank" instead of silently serving stale numbers.
- **Keep the error-string format stable.** `post_request` raises
  `"Plaid error CODE: MESSAGE - DISPLAY"` and `parse_plaid_error` regexes it back
  apart to feed the state machine. If you reword one, reword both.
- **Sandbox → production is a triple change:** `PLAID_URL`, the `PLAID_SECRET` (each
  environment has its own), and production access approval in the Plaid dashboard.
  Sandbox test login is `user_good` / `pass_good`. After editing `.env`, recreate the
  container — a plain `restart` does not reload env vars.
- **Backfilling a gap:** `transactions/sync` only moves forward from its cursor. To
  re-pull a specific historical window (e.g. "March is missing"), use
  `get_transactions_by_date_range` (`transactions/get`, offset-paginated, 500 per
  page) in a separate job, and dedup by `transaction_id_plaid` on insert.

---

## Files this pattern touches

```
.env                                              # PLAID_CLIENT_ID / PLAID_SECRET / PLAID_URL / BASE_URL
db/migrate/*_create_plaid_institutions.rb
db/migrate/*_create_plaid_accounts.rb
db/migrate/*_add_plaid_columns_to_transactions.rb
app/models/plaid_institution.rb
app/models/plaid_account.rb
app/services/plaid_service.rb
app/controllers/plaid_controller.rb
app/jobs/plaid_transaction_sync_job.rb
app/views/plaid/new.html.erb
config/routes.rb
```

## How to adapt to your schema

1. **Already have a transactions/ledger table?** Keep it — just add the Plaid columns
   from the third migration. `transaction_id_plaid` (dedup key) and the two foreign
   keys are the only ones the sync depends on; the category/merchant columns are
   nice-to-have.
2. **Only need balances, not transactions?** Drop the job, the cursor column, and the
   transaction columns. Call `get_accounts` on demand (or on a schedule) and store
   snapshots in a small `plaid_account_balances` table (`plaid_account_id`,
   `balance_date`, `balance_current`).
3. **Single-user internal tool?** The `current_user` scoping still costs nothing —
   keep it. It's what makes the mask+name unique index safe.
4. **Different country:** change `country_codes` in `create_link_token` and drop the
   `USD` defaults.
5. **No background job runner?** The sync can run inline in `exchange_public_token`
   for small accounts, but the initial 730-day backfill of a busy account takes many
   pages — keep it in a job if at all possible.
