{"slug":"plaid-bank-account-sync","meta":{"title":"Plaid — Bank Account Linking \u0026 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 \u0026 Phone Verification","url":"/cookbook/twilio-sms-and-phone-verification","summary":"Sibling integration recipe — same .env-key + service-module pattern."},{"title":"Plaid API docs — Transactions \u0026 Link","url":"https://plaid.com/docs/","summary":"Official reference for link/token/create, transactions/sync, and webhook codes used below."}]},"body":"# Plaid — Bank Account Linking \u0026 Transaction Sync\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nYour app needs real bank data — transactions for a P\u0026L, balances for a dashboard, a\nbookkeeping tool. Plaid is the bridge: the user clicks \"Link account\", authenticates\nwith their bank inside Plaid's hosted **Link** widget, and your app gets an\n`access_token` it can use to pull accounts and transactions forever after.\n\nThis recipe is a **complete, production-proven integration with zero gems**. The\nofficial `plaid` gem is not in the base image and you cannot add gems on a Leo instance\n— so the service module below talks to Plaid's JSON API directly with `Net::HTTP` from\nthe standard library. It is the whole integration: Link token creation, the token\nexchange, cursor-based incremental transaction sync in a background job, webhook-driven\nupdates, historical backfill by date range, and a connection-health state machine that\nhandles the day the user's bank login expires.\n\n\u003e **When to use:** pulling a user's real bank transactions and balances into your app —\n\u003e bookkeeping, P\u0026L reports, spend dashboards, budgeting tools.\n\u003e **When not to:** taking payments (that's Stripe), or when the user only has a handful\n\u003e of transactions to track (a manual entry form is a day less work and zero per-item\n\u003e fees — Plaid bills per connected Item).\n\n---\n\n## The 80/20 in one breath\n\n1. Get a **client_id** and **secret** from the Plaid dashboard\n   (`dashboard.plaid.com`). Start in **sandbox**; request production access later.\n2. Add `PLAID_CLIENT_ID`, `PLAID_SECRET`, `PLAID_URL`, and `BASE_URL` to `.env`, then\n   **recreate the web container** (a plain restart does not reload `.env`).\n3. Run the two migrations below (`plaid_institutions`, `plaid_accounts`) plus the Plaid\n   columns on your `transactions` table.\n4. Copy `PlaidService` into `app/services/plaid_service.rb`.\n5. Wire the controller + routes: `GET /plaid` renders the Link page,\n   `POST /plaid/exchange_public_token` stores the access token and accounts,\n   `POST /plaid/webhook` reacts to Plaid's pushes.\n6. Copy the sync job. Link a sandbox bank (`user_good` / `pass_good`) and watch\n   transactions appear.\n\n---\n\n## Layer 0 — Environment keys (.env)\n\n```bash\n# .env  (project root, next to docker-compose.yml — compose passes this file\n# into the web container via `env_file: .env`)\n\n# REQUIRED — from dashboard.plaid.com → Team Settings → Keys\nPLAID_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxx\nPLAID_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx        # sandbox and production secrets DIFFER\n\n# REQUIRED — which Plaid environment the secret belongs to.\n# Sandbox:    https://sandbox.plaid.com/\n# Production: https://production.plaid.com/\nPLAID_URL=https://sandbox.plaid.com/\n\n# REQUIRED for webhooks — your app's public https URL.\n# The webhook URL registered with Plaid becomes \"#{BASE_URL}/plaid/webhook\".\nBASE_URL=https://yourapp.example.com\n```\n\nAfter editing `.env`: `docker compose down llamapress \u0026\u0026 docker compose up -d llamapress`.\n\n---\n\n## Layer 1 — Model \u0026 SQL\n\nThree tables carry the integration. A `PlaidInstitution` is one Plaid **Item** — one\nlogin at one bank (it owns the `access_token`, the sync cursor, and the health state).\nA `PlaidAccount` is one account inside that Item (checking, savings, a credit card).\nTransactions belong to both.\n\n```ruby\n# db/migrate/XXXXXXXXXXXXXX_create_plaid_institutions.rb\nclass CreatePlaidInstitutions \u003c ActiveRecord::Migration[8.0]\n  def change\n    create_table :plaid_institutions, id: :uuid do |t|\n      t.references :user, type: :uuid, null: false, foreign_key: true\n      t.text :access_token                        # the long-lived Plaid credential\n      t.text :item_id                             # Plaid's ID for this login (Item)\n      t.text :institution_id_plaid, null: false   # the BANK's ID (e.g. ins_120013) — NOT unique per Item!\n      t.text :institution_name, null: false\n\n      # Incremental sync state\n      t.text :transactions_cursor                 # cursor for transactions/sync\n      t.boolean :initial_sync_complete, default: false\n      t.boolean :historical_sync_complete, default: false\n      t.datetime :last_sync_at\n\n      # Connection-health state machine\n      t.text :status, default: \"active\"           # active | needs_reauth | pending_expiration | pending_disconnect | error\n      t.boolean :needs_update, default: false\n      t.text :last_error_code\n      t.text :last_error_message\n      t.datetime :last_successful_sync_at\n      t.datetime :last_failed_sync_at\n      t.datetime :consent_expiration_time\n\n      t.timestamps\n    end\n  end\nend\n```\n\n```ruby\n# db/migrate/XXXXXXXXXXXXXX_create_plaid_accounts.rb\nclass CreatePlaidAccounts \u003c ActiveRecord::Migration[8.0]\n  def change\n    create_table :plaid_accounts, id: :uuid do |t|\n      t.references :user, type: :uuid, foreign_key: true\n      t.references :plaid_institution, type: :uuid, foreign_key: true\n      t.text :account_id_plaid, null: false       # Plaid's account ID (changes on re-link!)\n      t.text :item_id, null: false\n      t.text :name, null: false                   # \"Plaid Checking\"\n      t.text :account_type, null: false           # depository | credit | loan | investment\n      t.text :subtype                             # checking | savings | credit card ...\n      t.text :mask                                # last 4 digits — the STABLE identifier\n      t.decimal :balance_available\n      t.decimal :balance_current\n      t.decimal :balance_limit\n      t.text :iso_currency_code, default: \"USD\"\n      t.timestamps\n    end\n\n    # One Plaid account ID per institution; mask+name is the human-stable identity\n    add_index :plaid_accounts, [:plaid_institution_id, :account_id_plaid], unique: true\n    add_index :plaid_accounts, [:user_id, :mask, :name], unique: true\n  end\nend\n```\n\n```ruby\n# db/migrate/XXXXXXXXXXXXXX_add_plaid_columns_to_transactions.rb\n# Assumes you already have a transactions table (user_id, amount, date, description).\nclass AddPlaidColumnsToTransactions \u003c ActiveRecord::Migration[8.0]\n  def change\n    add_reference :transactions, :plaid_account, type: :uuid, foreign_key: true\n    add_reference :transactions, :plaid_institution, type: :uuid, foreign_key: true\n    add_column :transactions, :transaction_id_plaid, :text     # dedup key from Plaid\n    add_column :transactions, :account_id_plaid, :text\n    add_column :transactions, :name_plaid, :text\n    add_column :transactions, :merchant_name, :text\n    add_column :transactions, :category_list_plaid, :text, array: true, default: []\n    add_column :transactions, :category_id_plaid, :text\n    add_column :transactions, :pending, :boolean, default: false\n    add_column :transactions, :transaction_type, :text\n    add_column :transactions, :iso_currency_code, :text, default: \"USD\"\n    add_index  :transactions, :transaction_id_plaid\n  end\nend\n```\n\nThe institution model carries the health state machine — this is what makes the\nintegration survive month two, when bank logins start expiring:\n\n```ruby\n# app/models/plaid_institution.rb\nclass PlaidInstitution \u003c ApplicationRecord\n  belongs_to :user\n  has_many :plaid_accounts, dependent: :destroy\n  has_many :transactions, dependent: :nullify\n\n  enum :status, {\n    active: \"active\",\n    needs_reauth: \"needs_reauth\",\n    pending_expiration: \"pending_expiration\",\n    pending_disconnect: \"pending_disconnect\",\n    error: \"error\"\n  }, validate: true\n\n  scope :healthy, -\u003e { where(status: :active, needs_update: false) }\n\n  def record_sync_success!\n    update!(\n      last_sync_at: Time.current, last_successful_sync_at: Time.current,\n      last_error_code: nil, last_error_message: nil, last_failed_sync_at: nil,\n      needs_update: false, status: :active\n    )\n  end\n\n  def record_sync_failure!(error_code:, error_message:)\n    update!(\n      last_error_code: error_code, last_error_message: error_message,\n      last_failed_sync_at: Time.current, needs_update: true,\n      status: error_code == \"ITEM_LOGIN_REQUIRED\" ? :needs_reauth : :error\n    )\n  end\nend\n```\n\n```ruby\n# app/models/plaid_account.rb\nclass PlaidAccount \u003c ApplicationRecord\n  belongs_to :user\n  belongs_to :plaid_institution\n  has_many :transactions, dependent: :nullify\nend\n```\n\n---\n\n## Layer 2 — The service module (plain Net::HTTP, no gem)\n\nEvery Plaid endpoint is a JSON POST with `client_id` + `secret` in the body. That's the\nwhole API surface — one private `post_request` helper covers everything.\n\n```ruby\n# app/services/plaid_service.rb\nrequire \"net/http\"\nrequire \"uri\"\nrequire \"json\"\n\nmodule PlaidService\n  extend self\n\n  # Create a Link token. Two modes:\n  #  - New connection: omit access_token → requests the 'transactions' product.\n  #  - RE-AUTH (update mode): pass the institution's stored access_token →\n  #    Link opens against the existing Item so the user can refresh their login.\n  def create_link_token(client_id:, secret:, user_id:, webhook_url: nil, access_token: nil)\n    body = {\n      client_id: client_id,\n      secret: secret,\n      user: { client_user_id: user_id.to_s },   # must be unique per user\n      client_name: \"My Finance App\",            # shown inside the Link UI\n      country_codes: [\"US\"],\n      language: \"en\"\n    }\n\n    if access_token.present?\n      body[:access_token] = access_token        # update mode — no products key\n    else\n      body[:products] = [\"transactions\"]\n      # Plaid only backfills 90 days by default. Ask for 2 years UP FRONT —\n      # this cannot be changed after the Item is created.\n      body[:transactions] = { days_requested: 730 }\n    end\n\n    body[:webhook] = webhook_url if webhook_url.present?\n\n    post_request(\"link/token/create\", body)[\"link_token\"]\n  end\n\n  # Trade the short-lived public_token from Link for the permanent access_token.\n  def exchange_public_token(client_id:, secret:, public_token:)\n    result = post_request(\"item/public_token/exchange\",\n      client_id: client_id, secret: secret, public_token: public_token)\n    { access_token: result[\"access_token\"], item_id: result[\"item_id\"] }\n  end\n\n  # Current accounts + balances for an Item.\n  def get_accounts(access_token:, client_id:, secret:)\n    post_request(\"accounts/balance/get\",\n      client_id: client_id, secret: secret, access_token: access_token)[\"accounts\"]\n  end\n\n  # Incremental transaction sync. Returns {'added', 'modified', 'removed',\n  # 'next_cursor', 'has_more'}. Pass cursor: nil on the very first call.\n  def get_transactions(access_token:, client_id:, secret:, cursor: nil)\n    post_request(\"transactions/sync\",\n      client_id: client_id, secret: secret, access_token: access_token, cursor: cursor)\n  end\n\n  # One-off historical backfill for a date range (transactions/get, offset-paginated).\n  def get_transactions_by_date_range(access_token:, client_id:, secret:,\n                                     start_date:, end_date:, offset: 0, count: 500)\n    post_request(\"transactions/get\",\n      client_id: client_id, secret: secret, access_token: access_token,\n      start_date: start_date, end_date: end_date,\n      options: { offset: offset, count: count })\n  end\n\n  # \"Plaid error CODE: MESSAGE - ...\" → [code, message]; feeds the health state machine.\n  def parse_plaid_error(error)\n    match = error.message.match(/Plaid error (\\w+): (.*?) - /)\n    match ? [match[1], match[2]] : [\"UNKNOWN\", error.message.truncate(500)]\n  end\n\n  private\n\n  def base_uri\n    URI(ENV[\"PLAID_URL\"] || \"https://sandbox.plaid.com/\")\n  end\n\n  def post_request(endpoint, body)\n    url = base_uri.merge(endpoint)\n    http = Net::HTTP.new(url.host, url.port)\n    http.use_ssl = true\n\n    request = Net::HTTP::Post.new(url.path, \"Content-Type\" =\u003e \"application/json\")\n    request.body = body.to_json\n    response = http.request(request)\n    result = JSON.parse(response.body)\n\n    return result if response.is_a?(Net::HTTPSuccess)\n\n    # Keep this exact format — parse_plaid_error depends on it.\n    raise \"Plaid error #{result['error_code']}: #{result['error_message']} - #{result.fetch('display_message', 'No display message')}\"\n  end\nend\n```\n\n---\n\n## Layer 3 — Controller \u0026 routes\n\n```ruby\n# config/routes.rb  (inside the authenticated scope, except the webhook)\nget  \"plaid\" =\u003e \"plaid#new\"\npost \"plaid/exchange_public_token\" =\u003e \"plaid#exchange_public_token\"\npost \"plaid/sync_all\" =\u003e \"plaid#sync_all\", as: :plaid_sync_all\npost \"plaid/institutions/:id/resync\" =\u003e \"plaid#resync\", as: :plaid_institution_resync\npost \"plaid/webhook\" =\u003e \"plaid#webhook\"     # must be reachable WITHOUT auth\n```\n\n```ruby\n# app/controllers/plaid_controller.rb\nclass PlaidController \u003c ApplicationController\n  # Plaid's servers call the webhook — no session, no CSRF token.\n  skip_before_action :verify_authenticity_token, only: [:webhook]\n  skip_before_action :authenticate_user!, only: [:webhook], raise: false\n\n  # GET /plaid  — renders the Link page.\n  # With ?institution_id=\u003cuuid\u003e it becomes a RE-AUTH page for that institution.\n  def new\n    @plaid_institutions = current_user.plaid_institutions.order(created_at: :desc)\n    webhook_url = ENV[\"BASE_URL\"].present? ? \"#{ENV['BASE_URL']}/plaid/webhook\" : nil\n\n    access_token = nil\n    if params[:institution_id].present?\n      @reauth_institution = current_user.plaid_institutions.find(params[:institution_id])\n      access_token = @reauth_institution.access_token\n    end\n\n    @link_token = PlaidService.create_link_token(\n      client_id: ENV[\"PLAID_CLIENT_ID\"], secret: ENV[\"PLAID_SECRET\"],\n      user_id: current_user.id, webhook_url: webhook_url, access_token: access_token\n    )\n  end\n\n  # POST /plaid/exchange_public_token — called by the Link onSuccess JS.\n  def exchange_public_token\n    client_id  = ENV[\"PLAID_CLIENT_ID\"]\n    secret     = ENV[\"PLAID_SECRET\"]\n    bank_id    = params[:institution][:institution_id]   # Plaid's BANK id, e.g. ins_120013\n    bank_name  = params[:institution][:name]\n\n    if params[:plaid_institution_id].present?\n      # ── RE-AUTH: the frontend told us EXACTLY which of OUR institutions this is.\n      # In update mode the access_token does NOT change (and public_token may be\n      # null) — reuse the stored token, skip the exchange entirely.\n      plaid_institution = current_user.plaid_institutions.find(params[:plaid_institution_id])\n      access_token = plaid_institution.access_token\n      item_id      = plaid_institution.item_id\n    else\n      # ── NEW CONNECTION: exchange the public_token for a permanent access_token.\n      tokens = PlaidService.exchange_public_token(\n        client_id: client_id, secret: secret, public_token: params[:public_token])\n      access_token = tokens[:access_token]\n      item_id      = tokens[:item_id]\n\n      # Match against an existing institution before creating one:\n      # A. exact item_id match (same Item re-linked)\n      plaid_institution = current_user.plaid_institutions.find_by(item_id: item_id)\n      # B. else match by account mask+name — but ONLY within the same bank_id.\n      #    Two logins at the same bank (personal + business) share bank_id and can\n      #    even share account names; without this scoping they overwrite each other.\n      if plaid_institution.nil?\n        accounts = PlaidService.get_accounts(access_token: access_token,\n                                             client_id: client_id, secret: secret)\n        accounts.each do |acc|\n          existing = current_user.plaid_accounts.joins(:plaid_institution)\n            .where(plaid_institutions: { institution_id_plaid: bank_id })\n            .find_by(mask: acc[\"mask\"], name: acc[\"name\"])\n          if existing\n            plaid_institution = existing.plaid_institution\n            break\n          end\n        end\n      end\n    end\n\n    accounts ||= PlaidService.get_accounts(access_token: access_token,\n                                           client_id: client_id, secret: secret)\n\n    if plaid_institution\n      plaid_institution.update!(\n        item_id: item_id, access_token: access_token,\n        needs_update: false, status: :active,\n        last_error_code: nil, last_error_message: nil, consent_expiration_time: nil\n      )\n    else\n      plaid_institution = PlaidInstitution.create!(\n        user: current_user, item_id: item_id, access_token: access_token,\n        institution_id_plaid: bank_id, institution_name: bank_name\n      )\n    end\n\n    # Upsert accounts. Match by mask+name FIRST (stable across re-links),\n    # then by account_id_plaid (changes when the user re-links!).\n    accounts.each do |a|\n      plaid_account =\n        plaid_institution.plaid_accounts.find_by(mask: a[\"mask\"], name: a[\"name\"]) ||\n        plaid_institution.plaid_accounts.find_by(account_id_plaid: a[\"account_id\"])\n\n      attrs = {\n        account_id_plaid: a[\"account_id\"], item_id: item_id,\n        name: a[\"name\"], account_type: a[\"type\"], subtype: a[\"subtype\"],\n        mask: a[\"mask\"],\n        balance_available: a[\"balances\"][\"available\"],\n        balance_current: a[\"balances\"][\"current\"],\n        balance_limit: a[\"balances\"][\"limit\"],\n        iso_currency_code: a[\"balances\"][\"iso_currency_code\"] || \"USD\"\n      }\n      if plaid_account\n        plaid_account.update!(attrs)\n      else\n        plaid_institution.plaid_accounts.create!(attrs.merge(user: current_user))\n      end\n    end\n\n    PlaidTransactionSyncJob.perform_later(plaid_institution.id)\n    redirect_to root_path, notice: \"Successfully linked your bank account!\"\n  end\n\n  # POST /plaid/institutions/:id/resync — manual \"pull now\" button.\n  # If the connection is unhealthy, send the user to re-auth instead of syncing.\n  def resync\n    plaid_institution = current_user.plaid_institutions.find(params[:id])\n\n    if plaid_institution.needs_update? || !plaid_institution.active?\n      redirect_to plaid_path(institution_id: plaid_institution.id),\n        alert: \"Your bank login for #{plaid_institution.institution_name} needs re-authentication.\"\n      return\n    end\n\n    PlaidTransactionSyncJob.perform_later(plaid_institution.id)\n    redirect_back fallback_location: root_path,\n      notice: \"Syncing #{plaid_institution.institution_name}...\"\n  end\n\n  def sync_all\n    current_user.plaid_institutions.each { |i| PlaidTransactionSyncJob.perform_later(i.id) }\n    redirect_back fallback_location: root_path, notice: \"Syncing all accounts...\"\n  end\n\n  # POST /plaid/webhook — Plaid pushes state changes here. ALWAYS return 200.\n  def webhook\n    webhook_code = params[:webhook_code]\n    plaid_institution = PlaidInstitution.find_by(item_id: params[:item_id])\n    Rails.logger.info \"Plaid webhook: #{webhook_code} for item #{params[:item_id]}\"\n\n    case webhook_code\n    when \"INITIAL_UPDATE\", \"HISTORICAL_UPDATE\", \"DEFAULT_UPDATE\", \"SYNC_UPDATES_AVAILABLE\"\n      PlaidTransactionSyncJob.perform_later(plaid_institution.id) if plaid_institution\n\n    when \"TRANSACTIONS_REMOVED\"\n      (params[:removed_transactions] || []).each do |tx_id|\n        Transaction.find_by(transaction_id_plaid: tx_id)\u0026.destroy\n      end\n\n    when \"ITEM_LOGIN_REQUIRED\"\n      error = params[:error] || {}\n      plaid_institution\u0026.update!(\n        status: :needs_reauth, needs_update: true,\n        last_error_code: error[\"error_code\"] || \"ITEM_LOGIN_REQUIRED\",\n        last_error_message: error[\"error_message\"] || \"User login required\",\n        last_failed_sync_at: Time.current\n      )\n\n    when \"PENDING_EXPIRATION\"\n      updates = { status: :pending_expiration, needs_update: true }\n      updates[:consent_expiration_time] = params[:consent_expiration_time] if params[:consent_expiration_time].present?\n      plaid_institution\u0026.update!(updates)\n\n    when \"PENDING_DISCONNECT\"\n      plaid_institution\u0026.update!(status: :pending_disconnect, needs_update: true)\n\n    when \"LOGIN_REPAIRED\"\n      plaid_institution\u0026.update!(\n        status: :active, needs_update: false,\n        last_error_code: nil, last_error_message: nil, last_failed_sync_at: nil\n      )\n\n    when \"ERROR\"\n      error = params[:error] || {}\n      plaid_institution\u0026.update!(\n        status: :error, needs_update: true,\n        last_error_code: error[\"error_code\"] || \"WEBHOOK_ERROR\",\n        last_error_message: error[\"error_message\"] || \"Webhook reported an error\",\n        last_failed_sync_at: Time.current\n      )\n    end\n\n    head :ok   # anything else = Plaid retries + may eventually disable the webhook\n  end\nend\n```\n\n---\n\n## Layer 4 — The sync job (cursor-based, idempotent)\n\n```ruby\n# app/jobs/plaid_transaction_sync_job.rb\nclass PlaidTransactionSyncJob \u003c ApplicationJob\n  queue_as :default\n\n  def perform(plaid_institution_id)\n    plaid_institution = PlaidInstitution.find(plaid_institution_id)\n    user = plaid_institution.user\n    client_id = ENV[\"PLAID_CLIENT_ID\"]\n    secret    = ENV[\"PLAID_SECRET\"]\n\n    has_more = true\n    cursor = plaid_institution.transactions_cursor   # nil on the very first sync\n\n    while has_more\n      response = PlaidService.get_transactions(\n        access_token: plaid_institution.access_token,\n        client_id: client_id, secret: secret, cursor: cursor)\n\n      response[\"added\"].each do |tx|\n        next if Transaction.exists?(transaction_id_plaid: tx[\"transaction_id\"])\n\n        # Scope the account lookup to THIS institution — never assign a\n        # transaction to a same-named account on a sibling institution.\n        plaid_account = plaid_institution.plaid_accounts\n                          .find_by(account_id_plaid: tx[\"account_id\"])\n        next unless plaid_account\n\n        Transaction.create!(\n          user: user,\n          plaid_account: plaid_account,\n          plaid_institution: plaid_institution,\n          account_id_plaid: tx[\"account_id\"],\n          transaction_id_plaid: tx[\"transaction_id\"],\n          amount: tx[\"amount\"],                 # Plaid: positive = money OUT\n          description: tx[\"name\"],\n          name_plaid: tx[\"name\"],\n          merchant_name: tx[\"merchant_name\"],\n          category_list_plaid: tx[\"category\"],\n          category_id_plaid: tx[\"category_id\"],\n          date: tx[\"date\"],\n          pending: tx[\"pending\"],\n          transaction_type: tx[\"transaction_type\"],\n          iso_currency_code: tx[\"iso_currency_code\"]\n        )\n      end\n\n      (response[\"modified\"] || []).each do |tx|\n        Transaction.find_by(transaction_id_plaid: tx[\"transaction_id\"])\u0026.update!(\n          amount: tx[\"amount\"], description: tx[\"name\"], name_plaid: tx[\"name\"],\n          merchant_name: tx[\"merchant_name\"], date: tx[\"date\"], pending: tx[\"pending\"])\n      end\n\n      (response[\"removed\"] || []).each do |tx|\n        Transaction.find_by(transaction_id_plaid: tx[\"transaction_id\"])\u0026.destroy\n      end\n\n      cursor = response[\"next_cursor\"]\n      has_more = response[\"has_more\"]\n\n      # Persist the cursor EVERY page — a crash mid-sync resumes, not restarts.\n      plaid_institution.update!(transactions_cursor: cursor, last_sync_at: Time.current)\n    end\n\n    plaid_institution.record_sync_success!\n  rescue =\u003e e\n    error_code, error_message = PlaidService.parse_plaid_error(e)\n    plaid_institution.record_sync_failure!(error_code: error_code, error_message: error_message)\n    raise e\n  end\nend\n```\n\n---\n\n## Layer 5 — The Link page (frontend)\n\n```erb\n\u003c%# app/views/plaid/new.html.erb %\u003e\n\u003cdiv class=\"max-w-lg mx-auto p-8\"\u003e\n  \u003ch1 class=\"text-2xl font-bold mb-4\"\u003eLink a bank account\u003c/h1\u003e\n  \u003cbutton id=\"link-button\"\n          class=\"px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700\"\u003e\n    \u003c%= @reauth_institution ? \"Re-authenticate #{@reauth_institution.institution_name}\" : \"Link account\" %\u003e\n  \u003c/button\u003e\n\u003c/div\u003e\n\n\u003cscript src=\"https://cdn.plaid.com/link/v2/stable/link-initialize.js\"\u003e\u003c/script\u003e\n\u003cscript\u003e\n  function initializePlaidLink() {\n    // In re-auth mode, pass OUR institution UUID through to the exchange endpoint\n    // so the backend updates exactly the right record — item_id can change and\n    // two institutions at the same bank can have identical account names/masks.\n    const reauthInstitutionId = '\u003c%= @reauth_institution\u0026.id %\u003e';\n\n    const handler = Plaid.create({\n      token: '\u003c%= @link_token %\u003e',\n      onSuccess: (public_token, metadata) =\u003e {\n        const body = { public_token: public_token, institution: metadata.institution };\n        if (reauthInstitutionId) body.plaid_institution_id = reauthInstitutionId;\n\n        fetch('/plaid/exchange_public_token', {\n          method: 'POST',\n          headers: {\n            'Content-Type': 'application/json',\n            'X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]').content\n          },\n          body: JSON.stringify(body)\n        }).then(response =\u003e {\n          if (response.ok) { window.location.href = '/'; }\n          else { alert('Linking failed. Please try again.'); }\n        });\n      },\n      onExit: (err, metadata) =\u003e { if (err) console.error('Plaid Link exit:', err); }\n    });\n\n    document.getElementById('link-button')\n            .addEventListener('click', () =\u003e handler.open());\n\n    // Auto-open in re-auth mode — the user already clicked \"fix connection\".\n    if (reauthInstitutionId) handler.open();\n  }\n\n  // Cover both Turbo and non-Turbo page loads.\n  document.addEventListener('turbo:load', initializePlaidLink, { once: true });\n  if (document.readyState !== 'loading') initializePlaidLink();\n\u003c/script\u003e\n```\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **No gem needed — and none available.** The `plaid` gem is not in the base image and\n  Leo instances can't add gems. The `Net::HTTP` module above is the whole client;\n  Plaid's API is uniform enough (JSON POST, keys in the body) that one `post_request`\n  helper covers every endpoint.\n- **The \"two accounts at the same bank\" trap (this one caused real data crossing).**\n  Plaid's `institution_id` (e.g. `ins_120013`) identifies the **bank**, not the login.\n  A user with personal + business accounts at the same bank has two Items with the\n  *same* bank ID — and possibly identical account names and masks. Defenses, in order:\n  (1) during re-auth, pass **your own institution UUID** from the frontend and match by\n  it directly; (2) when fuzzy-matching by mask+name, always scope the query to the same\n  `institution_id_plaid`; (3) in the sync job, look accounts up **through the\n  institution association**, never globally.\n- **Re-auth (update mode) is a different flow, not a re-link.** Pass the stored\n  `access_token` to `link/token/create` (and omit `products`). On success the\n  `public_token` **may be null** and the `access_token` **does not change** — skip the\n  exchange and reuse the stored token. Treating re-auth as a fresh link is how you end\n  up with duplicate institutions.\n- **`account_id` is not stable; `mask` + `name` is.** When a user re-links, Plaid\n  issues brand-new `account_id`s (and a new `item_id`). Match accounts by mask+name\n  first, then update the stored `account_id_plaid`. This is also why the unique index\n  is on `(user_id, mask, name)`.\n- **Ask for history up front: `days_requested: 730`.** Plaid defaults to 90 days of\n  backfill and the setting is fixed at Item creation — you cannot raise it later\n  without re-linking. For bookkeeping apps, 90 days is never enough.\n- **Persist the `transactions/sync` cursor after every page.** The `while has_more`\n  loop can crash mid-run (Plaid 500, deploy, OOM). Saving `next_cursor` each iteration\n  means the next run resumes where it left off instead of re-processing everything.\n  The `Transaction.exists?(transaction_id_plaid:)` guard makes replays harmless anyway.\n- **Webhooks: skip CSRF *and* auth, and always `head :ok`.** Plaid's servers have no\n  session and no CSRF token. A non-200 response makes Plaid retry and can eventually\n  get the webhook disabled. Also: webhooks need a public `BASE_URL` — they will never\n  arrive on localhost, so keep a manual \"Sync now\" button as the fallback path.\n- **Handle `modified` and `removed`, not just `added`.** Pending transactions post a\n  few days later as *modified* (amount and name can change), and cancelled ones arrive\n  as *removed*. Only processing `added` leaves phantom pending charges in the ledger.\n- **Amount sign convention:** Plaid's `amount` is **positive for money leaving** the\n  account (a purchase) and negative for money coming in (a paycheck) — backwards from\n  what most people expect. Decide once, at ingest, how your app stores it.\n- **Bank logins WILL expire — build the health state machine on day one.** OAuth\n  consents lapse, passwords change, banks force re-login. The `status` enum +\n  `record_sync_failure!` (which maps `ITEM_LOGIN_REQUIRED` → `needs_reauth`) +\n  the webhook handlers (`PENDING_EXPIRATION`, `LOGIN_REPAIRED`, `ERROR`) let the UI\n  show \"Reconnect your bank\" instead of silently serving stale numbers.\n- **Keep the error-string format stable.** `post_request` raises\n  `\"Plaid error CODE: MESSAGE - DISPLAY\"` and `parse_plaid_error` regexes it back\n  apart to feed the state machine. If you reword one, reword both.\n- **Sandbox → production is a triple change:** `PLAID_URL`, the `PLAID_SECRET` (each\n  environment has its own), and production access approval in the Plaid dashboard.\n  Sandbox test login is `user_good` / `pass_good`. After editing `.env`, recreate the\n  container — a plain `restart` does not reload env vars.\n- **Backfilling a gap:** `transactions/sync` only moves forward from its cursor. To\n  re-pull a specific historical window (e.g. \"March is missing\"), use\n  `get_transactions_by_date_range` (`transactions/get`, offset-paginated, 500 per\n  page) in a separate job, and dedup by `transaction_id_plaid` on insert.\n\n---\n\n## Files this pattern touches\n\n```\n.env                                              # PLAID_CLIENT_ID / PLAID_SECRET / PLAID_URL / BASE_URL\ndb/migrate/*_create_plaid_institutions.rb\ndb/migrate/*_create_plaid_accounts.rb\ndb/migrate/*_add_plaid_columns_to_transactions.rb\napp/models/plaid_institution.rb\napp/models/plaid_account.rb\napp/services/plaid_service.rb\napp/controllers/plaid_controller.rb\napp/jobs/plaid_transaction_sync_job.rb\napp/views/plaid/new.html.erb\nconfig/routes.rb\n```\n\n## How to adapt to your schema\n\n1. **Already have a transactions/ledger table?** Keep it — just add the Plaid columns\n   from the third migration. `transaction_id_plaid` (dedup key) and the two foreign\n   keys are the only ones the sync depends on; the category/merchant columns are\n   nice-to-have.\n2. **Only need balances, not transactions?** Drop the job, the cursor column, and the\n   transaction columns. Call `get_accounts` on demand (or on a schedule) and store\n   snapshots in a small `plaid_account_balances` table (`plaid_account_id`,\n   `balance_date`, `balance_current`).\n3. **Single-user internal tool?** The `current_user` scoping still costs nothing —\n   keep it. It's what makes the mask+name unique index safe.\n4. **Different country:** change `country_codes` in `create_link_token` and drop the\n   `USD` defaults.\n5. **No background job runner?** The sync can run inline in `exchange_public_token`\n   for small accounts, but the initial 730-day backfill of a busy account takes many\n   pages — keep it in a job if at all possible.\n"}