← back to AbramsOS

routes/plaid.js

182 lines

const express = require('express');
const db = require('../lib/db');
const audit = require('../lib/audit');
const { id } = require('../lib/ids');
const { encrypt, decrypt } = require('../lib/crypto');
const plaidLib = require('../lib/plaid-client');

const router = express.Router();
const DEV_USER_ID = 'user_steve';

// 0) Is Plaid wired up? Lets the UI show "Connect a bank" vs a setup hint instead of 500ing.
router.get('/api/plaid/status', async (_req, res) => {
  const configured = plaidLib.isConfigured();
  const banks = await db.query(
    `SELECT id, external_subject_id, last_sync_at, created_at
       FROM connector_account WHERE user_id = $1 AND provider = 'plaid' ORDER BY created_at DESC`,
    [DEV_USER_ID]
  );
  res.json({ configured, env: process.env.PLAID_ENV || 'sandbox', banks: banks.rows });
});

// 1) Browser asks for a Link token; we hand one back, they open Plaid Link in their browser.
router.post('/api/plaid/link/token', async (req, res) => {
  try {
    const c = plaidLib.client();
    const r = await c.linkTokenCreate({
      user: { client_user_id: req.userId || DEV_USER_ID },
      client_name: 'AbramsOS',
      products: plaidLib.PRODUCTS,
      country_codes: plaidLib.COUNTRIES,
      language: 'en',
    });
    res.json({ link_token: r.data.link_token, expiration: r.data.expiration });
  } catch (err) {
    console.error('[plaid link/token]', err.response?.data || err);
    res.status(500).json({ error: err.response?.data?.error_message || err.message });
  }
});

// 2) Plaid Link returns a public_token; we exchange it for an access_token and store encrypted.
router.post('/api/plaid/exchange', async (req, res) => {
  const { public_token, institution_name } = req.body || {};
  if (!public_token) return res.status(400).json({ error: 'public_token required' });
  try {
    const c = plaidLib.client();
    const r = await c.itemPublicTokenExchange({ public_token });
    const accessToken = r.data.access_token;
    const itemId = r.data.item_id;
    const enc = encrypt(accessToken);

    const consentId = id('consent');
    const connectorId = id('connector');

    await db.tx(async (client) => {
      await client.query(
        `INSERT INTO consent_grant (id, user_id, connector_type, scopes_json) VALUES ($1, $2, $3, $4)`,
        [consentId, DEV_USER_ID, 'plaid', JSON.stringify({ products: ['transactions'], institution: institution_name || null })]
      );
      await client.query(
        `INSERT INTO connector_account
           (id, user_id, consent_grant_id, provider, external_subject_id,
            refresh_token_encrypted, refresh_token_iv, refresh_token_tag)
         VALUES ($1, $2, $3, 'plaid', $4, $5, $6, $7)
         ON CONFLICT (provider, external_subject_id) DO UPDATE SET
           refresh_token_encrypted = EXCLUDED.refresh_token_encrypted,
           refresh_token_iv = EXCLUDED.refresh_token_iv,
           refresh_token_tag = EXCLUDED.refresh_token_tag,
           consent_grant_id = EXCLUDED.consent_grant_id`,
        [connectorId, DEV_USER_ID, consentId, itemId, enc.ciphertext, enc.iv, enc.tag]
      );
    });

    await audit.log({
      actorType: 'user',
      actorId: DEV_USER_ID,
      objectType: 'connector_account',
      objectId: connectorId,
      eventType: 'plaid_link_exchange',
      metadata: { institution: institution_name || null, item_id: itemId },
    });

    res.json({ ok: true, connector_id: connectorId });
  } catch (err) {
    console.error('[plaid exchange]', err.response?.data || err);
    res.status(500).json({ error: err.response?.data?.error_message || err.message });
  }
});

// 3) Sync transactions for a Plaid connector → upsert into purchase rows.
router.post('/api/connectors/plaid/:id/sync', async (req, res) => {
  const connectorId = req.params.id;
  const r = await db.query(
    `SELECT refresh_token_encrypted, refresh_token_iv, refresh_token_tag, sync_cursor
       FROM connector_account WHERE id = $1 AND user_id = $2 AND provider = 'plaid'`,
    [connectorId, DEV_USER_ID]
  );
  if (!r.rows.length) return res.status(404).json({ error: 'plaid connector not found' });
  const accessToken = decrypt(r.rows[0].refresh_token_encrypted, r.rows[0].refresh_token_iv, r.rows[0].refresh_token_tag);

  await audit.log({
    actorType: 'user',
    actorId: DEV_USER_ID,
    objectType: 'connector_account',
    objectId: connectorId,
    eventType: 'sync_started',
  });

  let purchases = 0;
  try {
    const c = plaidLib.client();
    // /transactions/sync — resume from the saved cursor so we only pull what's NEW.
    let cursor = r.rows[0].sync_cursor || null;
    let added = [];
    let hasMore = true;
    let calls = 0;
    while (hasMore && calls < 20) {  // safety cap; cursor is persisted so the next sync resumes
      const resp = await c.transactionsSync({ access_token: accessToken, cursor: cursor || undefined });
      added = added.concat(resp.data.added || []);
      cursor = resp.data.next_cursor;
      hasMore = resp.data.has_more;
      calls += 1;
    }

    for (const tx of added) {
      // Plaid amounts are positive for outflow (purchase), negative for inflow (refund/deposit).
      if (tx.amount == null || tx.amount <= 0) continue;
      const purchaseId = id('purchase');
      const ins = await db.query(
        `INSERT INTO purchase
           (id, user_id, source_message_id, merchant_name, merchant_domain, order_number, purchase_date, total_amount, currency, confidence, raw_extract)
         VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, $10)
         ON CONFLICT (user_id, order_number) WHERE order_number IS NOT NULL DO NOTHING
         RETURNING id`,
        [
          purchaseId,
          DEV_USER_ID,
          tx.merchant_name || tx.name || 'Unknown',
          tx.website || null,
          tx.transaction_id,                    // Plaid's stable id
          tx.date || tx.authorized_date || new Date(),
          tx.amount,
          tx.iso_currency_code || 'USD',
          0.95,                                  // bank-statement-derived purchases are high-confidence
          tx,
        ]
      );
      if (!ins.rows.length) continue;           // already had this transaction
      purchases += 1;
      await audit.log({
        actorType: 'system',
        objectType: 'purchase',
        objectId: purchaseId,
        eventType: 'purchase_extracted',
        metadata: { source: 'plaid', merchant: tx.merchant_name || tx.name, total: tx.amount, plaid_tx_id: tx.transaction_id },
      });
    }

    await db.query(`UPDATE connector_account SET last_sync_at = now(), sync_cursor = $2 WHERE id = $1`, [connectorId, cursor]);
    await audit.log({
      actorType: 'system',
      objectType: 'connector_account',
      objectId: connectorId,
      eventType: 'sync_completed',
      metadata: { source: 'plaid', added: added.length, purchases },
    });

    res.json({ ok: true, fetched: added.length, purchases });
  } catch (err) {
    console.error('[plaid sync]', err.response?.data || err);
    await audit.log({
      actorType: 'system',
      objectType: 'connector_account',
      objectId: connectorId,
      eventType: 'sync_failed',
      metadata: { source: 'plaid', error: err.message },
    });
    res.status(500).json({ error: err.response?.data?.error_message || err.message });
  }
});

module.exports = router;