← back to NationalPaperHangers
scripts/stripe-bootstrap-v05.js
177 lines
#!/usr/bin/env node
// One-shot bootstrap for NPH v0.5 #28 (webhook endpoint) + #29 (3 products + 5 prices).
// Runs against the live Stripe key from .env. Idempotent: skips products that already
// exist by metadata.nph_tier, skips webhook endpoints that already point at our URL.
//
// Output: writes a JSON summary to /tmp/nph-stripe-bootstrap.json with the IDs to
// fan out via secrets-manager. NEVER echoes the secret key.
const fs = require('node:fs');
const path = require('node:path');
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
const Stripe = require('stripe');
const SK = process.env.STRIPE_SECRET_KEY;
if (!SK || !SK.startsWith('sk_live_')) {
console.error('refusing to run: STRIPE_SECRET_KEY missing or not a live key');
process.exit(2);
}
const stripe = new Stripe(SK, { apiVersion: '2024-10-28.acacia' });
// Stripe rejects localhost / non-public URLs. Use NPH_PUBLIC_URL env override or
// the canonical prod URL — never PUBLIC_URL (that's the dev-mode local URL on Mac2).
const PUBLIC_URL = process.env.NPH_PUBLIC_URL || 'https://nationalpaperhangers.com';
const WEBHOOK_URL = `${PUBLIC_URL.replace(/\/+$/, '')}/webhooks/stripe`;
// 3 products × 1-2 prices = 5 prices total. Mirrors lib/stripe.js TIERS + PRICE_TABLE.
const PLAN = [
{
tier: 'pro',
product_name: 'NPH Pro',
description: 'Active listing on nationalpaperhangers.com — verified studio, brand-experience tags, lead routing.',
prices: [
{ env: 'STRIPE_PRICE_PRO_MONTH', cents: 3900, interval: 'month' },
{ env: 'STRIPE_PRICE_PRO_YEAR', cents: 39900, interval: 'year' }
]
},
{
tier: 'signature',
product_name: 'NPH Signature',
description: 'Featured-tier placement, full portfolio gallery, brand-trained credential review, concierge lead routing.',
prices: [
{ env: 'STRIPE_PRICE_SIGNATURE_MONTH', cents: 14900, interval: 'month' },
{ env: 'STRIPE_PRICE_SIGNATURE_YEAR', cents: 150000, interval: 'year' }
]
},
{
tier: 'enterprise',
product_name: 'NPH Enterprise',
description: 'Multi-installer team accounts, dedicated relationship manager, custom integrations.',
prices: [
{ env: 'STRIPE_PRICE_ENTERPRISE_MONTH', cents: 39900, interval: 'month' }
]
}
];
// Webhook events NPH actually consumes (routes/webhooks.js). Pulled from the
// switch in handleStripeEvent — keep this list in sync if more handlers are added.
const WEBHOOK_EVENTS = [
'payment_intent.succeeded',
'payment_intent.payment_failed',
'payment_intent.canceled',
'checkout.session.completed',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'invoice.payment_succeeded',
'invoice.payment_failed',
'account.updated'
// Note: account.application.deauthorized requires connect:true on the
// endpoint, which we don't need for our Express-only Connect flow.
];
(async () => {
const out = { products: [], prices: {}, webhook: null };
// 1. Find or create products by metadata.nph_tier
const existingProducts = await stripe.products.list({ active: true, limit: 100 });
const byTier = {};
for (const p of existingProducts.data) {
if (p.metadata && p.metadata.nph_tier) byTier[p.metadata.nph_tier] = p;
}
for (const tierPlan of PLAN) {
let product = byTier[tierPlan.tier];
if (product) {
console.log(`[products] reusing ${product.id} for tier=${tierPlan.tier}`);
} else {
product = await stripe.products.create({
name: tierPlan.product_name,
description: tierPlan.description,
metadata: { nph_tier: tierPlan.tier, project: 'national-paper-hangers' }
});
console.log(`[products] created ${product.id} for tier=${tierPlan.tier}`);
}
out.products.push({ tier: tierPlan.tier, id: product.id, name: product.name });
// 2. Find or create prices on each product
const existingPrices = await stripe.prices.list({ product: product.id, active: true, limit: 100 });
for (const priceSpec of tierPlan.prices) {
const match = existingPrices.data.find(pr =>
pr.unit_amount === priceSpec.cents &&
pr.recurring?.interval === priceSpec.interval &&
pr.currency === 'usd'
);
let price;
if (match) {
price = match;
console.log(` [prices] reusing ${price.id} (${priceSpec.cents}¢/${priceSpec.interval}) for ${priceSpec.env}`);
} else {
price = await stripe.prices.create({
product: product.id,
currency: 'usd',
unit_amount: priceSpec.cents,
recurring: { interval: priceSpec.interval },
metadata: { nph_tier: tierPlan.tier, env_var: priceSpec.env }
});
console.log(` [prices] created ${price.id} (${priceSpec.cents}¢/${priceSpec.interval}) for ${priceSpec.env}`);
}
out.prices[priceSpec.env] = price.id;
}
}
// 3. Find or create webhook endpoint pointing at our URL.
const endpoints = await stripe.webhookEndpoints.list({ limit: 100 });
const existing = endpoints.data.find(e => e.url === WEBHOOK_URL);
let endpoint, secret;
if (existing) {
endpoint = existing;
console.log(`[webhook] reusing endpoint ${endpoint.id} → ${WEBHOOK_URL}`);
// Check enabled events; if missing any of our list, update.
const missing = WEBHOOK_EVENTS.filter(e => !endpoint.enabled_events.includes(e));
if (missing.length) {
const merged = Array.from(new Set([...endpoint.enabled_events, ...WEBHOOK_EVENTS]));
endpoint = await stripe.webhookEndpoints.update(endpoint.id, { enabled_events: merged });
console.log(` [webhook] added ${missing.length} missing events; total now ${endpoint.enabled_events.length}`);
}
// Stripe only returns the secret on creation. Existing endpoint = secret already saved (or lost).
secret = null;
} else {
const payload = {
url: WEBHOOK_URL,
enabled_events: WEBHOOK_EVENTS,
metadata: { project: 'national-paper-hangers' }
};
console.log('[webhook] create payload:', JSON.stringify(payload, null, 2));
endpoint = await stripe.webhookEndpoints.create(payload);
secret = endpoint.secret; // ONE-TIME — never returned again. Save immediately.
console.log(`[webhook] created endpoint ${endpoint.id} → ${WEBHOOK_URL}`);
}
out.webhook = {
id: endpoint.id,
url: endpoint.url,
events: endpoint.enabled_events.length,
secret_revealed: !!secret // boolean only — never write the actual value to disk JSON
};
// 4. Write summary JSON (no secret value) for the runner to pick up.
const summary = path.resolve('/tmp/nph-stripe-bootstrap.json');
fs.writeFileSync(summary, JSON.stringify(out, null, 2));
console.log(`\n[summary] wrote ${summary}`);
// 5. If we just created the webhook, write the secret to a private one-shot
// file readable only by the user. Caller is responsible for routing it via
// /secrets and then deleting this file.
if (secret) {
const secretPath = '/tmp/.nph-stripe-webhook-secret';
fs.writeFileSync(secretPath, secret, { mode: 0o600 });
fs.chmodSync(secretPath, 0o600);
console.log(`[summary] webhook secret written to ${secretPath} (chmod 600) — route via /secrets and rm`);
} else {
console.log('[summary] webhook secret NOT revealed (endpoint already existed). If lost, rotate via stripe.webhookEndpoints.update + new secret.');
}
})().catch(err => {
console.error('FATAL:', err.message);
process.exit(1);
});