← back to Whatsmystyle

scripts/stripe-fc.js

83 lines

/**
 * Stripe Financial Connections — read-only bank-receipt scaffold.
 *
 * Three routes, all gated behind STRIPE_FC_SECRET_KEY:
 *
 *   POST /api/stripe-fc/session   create a Link session, returns client_secret
 *                                 → frontend opens Stripe's hosted Link flow
 *   GET  /api/stripe-fc/return    Stripe redirects here after the user finishes
 *                                 → exchange + persist the financial-connection account ids
 *   POST /api/stripe-fc/webhook   Stripe POSTs transaction.created / .updated events
 *                                 → match clothing-merchant rows against our brand list,
 *                                   insert into closet with acquired_via='bank'
 *
 * NEVER persist Stripe secret keys in this file. They live in .env only.
 * NEVER move money. NEVER read balance. Permissions = ['transactions'] only.
 */
const fetch = require('node-fetch');

const KEY = process.env.STRIPE_FC_SECRET_KEY;
const BASE = 'https://api.stripe.com/v1';

const PERMISSIONS = ['transactions']; // intentionally narrow — never 'balances', never 'payment_method'

function available() { return !!KEY; }

async function stripePost(path, formBody) {
  const r = await fetch(`${BASE}${path}`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${KEY}`,
      'content-type': 'application/x-www-form-urlencoded',
    },
    body: typeof formBody === 'string' ? formBody : new URLSearchParams(formBody).toString(),
  });
  if (!r.ok) throw new Error(`Stripe ${path} ${r.status}: ${await r.text()}`);
  return r.json();
}

async function createLinkSession({ accountHolderName, returnUrl }) {
  if (!KEY) throw new Error('STRIPE_FC_SECRET_KEY not set');
  // Stripe Financial Connections session — narrow permissions
  const body = new URLSearchParams();
  body.append('account_holder[type]', 'individual');
  body.append('account_holder[customer]', '');  // TODO: link to a Stripe customer once created
  PERMISSIONS.forEach(p => body.append('permissions[]', p));
  if (returnUrl) body.append('return_url', returnUrl);
  return stripePost('/financial_connections/sessions', body);
}

async function listTransactionsForAccount(accountId, { limit = 100, cursor } = {}) {
  if (!KEY) throw new Error('STRIPE_FC_SECRET_KEY not set');
  const qs = new URLSearchParams();
  qs.append('account', accountId);
  qs.append('limit', String(limit));
  if (cursor) qs.append('starting_after', cursor);
  const r = await fetch(`${BASE}/financial_connections/transactions?${qs}`, {
    headers: { Authorization: `Bearer ${KEY}` },
  });
  if (!r.ok) throw new Error(`Stripe transactions list ${r.status}: ${await r.text()}`);
  return r.json();
}

// Clothing-merchant fingerprinting — used by the webhook to decide which
// transactions become closet rows. Domain-based; expand as we learn vendors.
const CLOTHING_DOMAINS = [
  'nordstrom', 'net-a-porter', 'matchesfashion', 'mrporter', 'farfetch',
  'ssense', 'shopbop', 'revolve', 'saks', 'neimanmarcus', 'bergdorf',
  'therealreal', 'poshmark', 'vestiairecollective', 'ebay', 'depop',
  'cos.com', 'arket', 'uniqlo', 'zara', 'hm.com', 'madewell', 'jcrew',
  'everlane', 'reformation', 'patagonia', 'levis', 'agolde',
  'totokaelo', 'shop.bottegaveneta', 'gucci', 'prada', 'fendi',
];

function isClothingTransaction(tx) {
  const desc = (tx.description || '').toLowerCase();
  return CLOTHING_DOMAINS.some(d => desc.includes(d));
}

module.exports = {
  available, createLinkSession, listTransactionsForAccount,
  isClothingTransaction, PERMISSIONS,
};