← 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,
};