← back to NationalPaperHangers
lib/stripe.js
293 lines
// Stripe subscription helpers.
// Pricing tiers map to STRIPE_PRICE_* env vars (set via secrets-manager).
// Until real keys are loaded, all functions return mocked responses so the
// rest of the app can be developed end-to-end.
const Stripe = require('stripe');
const TIERS = {
pro: { name: 'Pro', month: 'STRIPE_PRICE_PRO_MONTH', year: 'STRIPE_PRICE_PRO_YEAR' },
signature: { name: 'Signature', month: 'STRIPE_PRICE_SIGNATURE_MONTH', year: 'STRIPE_PRICE_SIGNATURE_YEAR' },
enterprise: { name: 'Enterprise', month: 'STRIPE_PRICE_ENTERPRISE_MONTH', year: null }
};
const PRICE_TABLE = [
{ tier: 'pro', cadence: 'month', amount: 39, label: '$39 / month' },
{ tier: 'pro', cadence: 'year', amount: 399, label: '$399 / year' },
{ tier: 'signature', cadence: 'month', amount: 149, label: '$149 / month' },
{ tier: 'signature', cadence: 'year', amount: 1500, label: '$1,500 / year' },
{ tier: 'enterprise', cadence: 'month', amount: 399, label: '$399 / month and up' }
];
function client() {
const key = process.env.STRIPE_SECRET_KEY;
if (!key || !key.startsWith('sk_')) return null;
return new Stripe(key, { apiVersion: '2024-10-28.acacia' });
}
function isLive() { return !!client(); }
function priceIdFor(tier, cadence) {
const t = TIERS[tier];
if (!t) return null;
const envKey = cadence === 'year' ? t.year : t.month;
if (!envKey) return null;
return process.env[envKey] || null;
}
// Reverse lookup: given a Stripe price.id, derive { tier, cadence }. Webhook
// events for `customer.subscription.updated` don't carry our metadata.tier, so
// the price ID is the only safe source of truth for the tier transition.
function tierFromPriceId(priceId) {
if (!priceId) return null;
for (const [tier, cfg] of Object.entries(TIERS)) {
for (const cadence of ['month', 'year']) {
const envKey = cfg[cadence];
if (envKey && process.env[envKey] && process.env[envKey] === priceId) {
return { tier, cadence };
}
}
}
return null;
}
async function ensureCustomer(installer) {
const c = client();
if (!c) {
return { mocked: true, id: 'cus_mock_' + installer.id };
}
if (installer.stripe_customer_id) return { id: installer.stripe_customer_id };
const created = await c.customers.create({
email: installer.email,
name: installer.business_name,
metadata: { installer_id: String(installer.id) }
});
return { id: created.id };
}
async function createCheckoutSession({ installer, tier, cadence, successUrl, cancelUrl }) {
const c = client();
const priceId = priceIdFor(tier, cadence);
if (!c) {
return {
mocked: true,
url: `${successUrl}?mock=1&tier=${tier}&cadence=${cadence}`,
tier, cadence
};
}
if (!priceId) throw new Error('price_not_configured');
const cust = await ensureCustomer(installer);
const session = await c.checkout.sessions.create({
mode: 'subscription',
customer: cust.id,
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
metadata: { installer_id: String(installer.id), tier, cadence },
allow_promotion_codes: true
});
return { url: session.url, id: session.id, customer_id: cust.id };
}
async function createPortalSession({ installer, returnUrl }) {
const c = client();
if (!c) return { mocked: true, url: returnUrl };
if (!installer.stripe_customer_id) throw new Error('no_customer');
const session = await c.billingPortal.sessions.create({
customer: installer.stripe_customer_id,
return_url: returnUrl
});
return { url: session.url };
}
function constructWebhookEvent(rawBody, signature) {
const c = client();
// Each project has its own webhook secret — secrets-manager fans them under
// distinct env names so a multi-project server doesn't cross-validate. Prefer
// the NPH-specific name; fall back to generic for older deployments.
const secret = process.env.STRIPE_NPH_WEBHOOK_SECRET || process.env.STRIPE_WEBHOOK_SECRET;
if (!c || !secret) return null;
return c.webhooks.constructEvent(rawBody, signature, secret);
}
// =======================================================================
// MARKETPLACE — booking deposits + Stripe Connect Express
// =======================================================================
// Every booking carries a deposit. NPH retains `application_fee_amount` from
// each charge as the platform cut; the rest transfers to the installer's
// Connect account. When the installer has no account yet (unclaimed or
// onboarding incomplete), the deposit holds in NPH's platform balance and is
// flagged for manual transfer at claim-time via metadata.
const DEFAULT_DEPOSIT_CENTS = parseInt(process.env.NPH_DEFAULT_DEPOSIT_CENTS || '9900', 10);
// 2500 bps = 25%. Set 2026-05-06 per Steve. Was 1000 (10%); the 25% rate
// positions NPH as a referral/lead service rather than a marketplace cut —
// see the lead-coordination legal framing in the v0.7 conversation.
const DEFAULT_PLATFORM_FEE_BPS = parseInt(process.env.NPH_PLATFORM_FEE_BPS || '2500', 10);
function platformFeeCents(amountCents, feeBps) {
return Math.floor((amountCents * feeBps) / 10000);
}
async function createBookingDepositIntent({ booking, installer, amountCents, platformFeeBps }) {
const amount = amountCents || DEFAULT_DEPOSIT_CENTS;
const feeBps = platformFeeBps || DEFAULT_PLATFORM_FEE_BPS;
const fee = platformFeeCents(amount, feeBps);
const c = client();
if (!c) {
return {
mocked: true,
client_secret: `pi_mock_${booking.uuid}_secret_mock`,
intent_id: `pi_mock_${booking.uuid}`,
amount_cents: amount,
application_fee_cents: fee,
destination: installer.stripe_account_id || null,
mode: installer.stripe_account_id && installer.stripe_account_charges_enabled
? 'destination' : 'platform_hold'
};
}
const useConnect = !!(installer.stripe_account_id && installer.stripe_account_charges_enabled);
const params = {
amount,
currency: 'usd',
capture_method: 'automatic',
description: `NPH booking ${booking.uuid} · ${installer.business_name}`,
receipt_email: booking.customer_email,
metadata: {
booking_uuid: booking.uuid,
booking_id: String(booking.id),
installer_id: String(installer.id),
installer_slug: installer.slug,
platform_fee_bps: String(feeBps),
payout_mode: useConnect ? 'destination' : 'platform_hold'
}
};
if (useConnect) {
params.application_fee_amount = fee;
params.transfer_data = { destination: installer.stripe_account_id };
} else {
params.metadata.pending_payout_to_installer_id = String(installer.id);
}
const intent = await c.paymentIntents.create(params);
return {
client_secret: intent.client_secret,
intent_id: intent.id,
amount_cents: amount,
application_fee_cents: useConnect ? fee : 0,
destination: installer.stripe_account_id || null,
mode: useConnect ? 'destination' : 'platform_hold'
};
}
async function createConnectAccount({ installer }) {
const c = client();
if (!c) {
return {
mocked: true,
account_id: `acct_mock_${installer.id}`,
charges_enabled: false,
payouts_enabled: false
};
}
// Stripe rejects placeholder/test URLs (example.com, localhost) with
// url_invalid. Only pass `url` when it looks like a real public site.
const validBusinessUrl = (() => {
const u = (installer.website || '').trim();
if (!u) return undefined;
try {
const parsed = new URL(u);
if (!/^https?:$/.test(parsed.protocol)) return undefined;
const host = parsed.hostname.toLowerCase();
if (/^(localhost|127\.|0\.0\.0\.0)/.test(host)) return undefined;
if (/(^|\.)example\.(com|org|net)$/.test(host)) return undefined;
if (/(^|\.)test$/.test(host)) return undefined;
return parsed.toString();
} catch { return undefined; }
})();
const acct = await c.accounts.create({
type: 'express',
country: 'US',
email: installer.email,
capabilities: {
transfers: { requested: true },
card_payments: { requested: true }
},
business_type: 'company',
business_profile: {
name: installer.business_name,
url: validBusinessUrl,
mcc: '1771'
},
metadata: {
installer_id: String(installer.id),
installer_slug: installer.slug
}
});
return {
account_id: acct.id,
charges_enabled: !!acct.charges_enabled,
payouts_enabled: !!acct.payouts_enabled
};
}
async function createConnectAccountLink({ accountId, returnUrl, refreshUrl }) {
const c = client();
if (!c) {
return {
mocked: true,
url: `${returnUrl || '/admin'}?mock=connect&acct=${encodeURIComponent(accountId)}`
};
}
const link = await c.accountLinks.create({
account: accountId,
refresh_url: refreshUrl,
return_url: returnUrl,
type: 'account_onboarding'
});
return { url: link.url };
}
async function getConnectAccount({ accountId }) {
const c = client();
if (!c) {
return {
mocked: true,
account_id: accountId,
charges_enabled: false,
payouts_enabled: false,
details_submitted: false
};
}
const acct = await c.accounts.retrieve(accountId);
return {
account_id: acct.id,
charges_enabled: !!acct.charges_enabled,
payouts_enabled: !!acct.payouts_enabled,
details_submitted: !!acct.details_submitted
};
}
module.exports = {
TIERS,
PRICE_TABLE,
isLive,
priceIdFor,
tierFromPriceId,
ensureCustomer,
createCheckoutSession,
createPortalSession,
constructWebhookEvent,
// marketplace
DEFAULT_DEPOSIT_CENTS,
DEFAULT_PLATFORM_FEE_BPS,
platformFeeCents,
createBookingDepositIntent,
createConnectAccount,
createConnectAccountLink,
getConnectAccount
};