Files
rootnode-academy/docs/STRIPE_INTEGRATION_RUNBOOK.md
T
ced4568 a4c0058f8c docs(stripe): document real Stripe TEST-mode validation results
PHASE9_1_STRIPE_TEST_VALIDATION.md rewritten from BLOCKED to the real, proven results: a real
Stripe TEST Checkout, a real TEST payment at the founder-approved $399 Founding Cohort price, a
genuine signed webhook, the existing registration->enrollment conversion, a real
duplicate-delivery proof, a real automated abandoned-checkout proof, a real refund with the
non-cascade rule reconfirmed, full Stripe<->RootNode reconciliation IDs, and the one real defect
found and fixed. STRIPE_INTEGRATION_RUNBOOK.md updated (real-Stripe local testing is no longer
'not yet exercised'; notes the no-sudo Stripe CLI install path and --api-key/--print-secret
usage). WINTER_BREAK_LAUNCH_CHECKLIST.md: Stripe TEST validation checked, price checked ($399
launch / $449 retail per Chase directly), live-mode items correctly still unchecked.
PHASE9_COMMERCE_AND_LAUNCH.md's Phase 9.1 addendum updated to reflect completion.
2026-08-28 09:47:11 -05:00

9.5 KiB

Stripe Integration Runbook

Operational reference for RootNode's payment-provider abstraction (platform/api/src/ payment-provider.js) — configuration, local testing against both the deterministic test provider and real Stripe test mode, webhook setup, and what's left before a real, live-money launch. See docs/PHASE9_COMMERCE_AND_LAUNCH.md for the design/architecture story and docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md for the website's own contract.

The two providers

PAYMENT_PROVIDER=test (default) PAYMENT_PROVIDER=stripe
Network calls None — fully in-process Real calls to Stripe's API
Needs credentials No STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
Checkout URL A fake URL pointing back at your own success_url A real Stripe-hosted Checkout page
Webhook signature A static shared secret (TEST_PAYMENT_WEBHOOK_SECRET, header X-Test-Payment-Signature) Real Stripe HMAC signature (header Stripe-Signature), verified via Stripe's own SDK
Real money Never Real in live mode; fake (test cards) in Stripe's own test mode
Used by Every automated test/rehearsal in this repo Nothing yet — pending real credentials

commerce.js and every route above it call the SAME methods regardless of which provider is active (createCheckoutSession, verifyAndParseWebhook) — switching providers is an environment variable, never a code change.

Environment variables

Variable Default Purpose
PAYMENT_PROVIDER test test or stripe.
STRIPE_SECRET_KEY unset Required for stripe. Server-side only — never exposed to any browser-served code.
STRIPE_WEBHOOK_SECRET unset Required for stripe. The signing secret Stripe gives you for a specific webhook endpoint (dashboard or stripe listen, see below) — NOT your API secret key.
TEST_PAYMENT_WEBHOOK_SECRET test-payment-secret-not-for-production Only used by the test provider. Change if you need to isolate parallel test runs; otherwise the default is fine.
REGISTRATION_RESERVATION_TIMEOUT_MINUTES 30 How long a paid offering's seat stays reserved before the reaper releases it if checkout is never completed.
RESERVATION_REAPER_POLL_MS 60000 How often the reservation reaper checks for expired, unpaid reservations.

None of these belong in frontend/ or anywhere a browser can read them — the Stripe secret key and webhook secret are server-only, exactly like INTEGRATION_SHARED_SECRET/ PROVISIONER_SHARED_SECRET already are in this codebase.

Local testing against the test provider (no Stripe account needed)

This is what every e2e suite and rehearsal in this repo actually runs against. Leave PAYMENT_PROVIDER unset (defaults to test) and drive the full cycle directly:

# 1. Create a checkout session (server-to-server, same as the website would)
curl -s -X POST http://localhost:4200/api/integrations/registrations/<id>/checkout \
  -H 'Content-Type: application/json' \
  -H 'X-RootNode-Integration-Key: dev-integration-secret-not-for-production' \
  -d '{"success_url":"https://example.com/s","cancel_url":"https://example.com/c"}'

# 2. Look up the provider_reference the checkout just created
docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform \
  -c "SELECT provider_reference FROM payment_transactions WHERE registration_id = '<id>' ORDER BY created_at DESC LIMIT 1;"

# 3. Build and POST a fake "payment succeeded" webhook event (Node, using the same provider
#    module the real server uses — see platform/test/e2e-phase9.js for the full pattern)
node -e "
const { getPaymentProvider } = require('./platform/api/src/payment-provider');
const p = getPaymentProvider();
const event = p.buildTestEvent({ providerReference: '<provider_reference from step 2>', eventType: 'checkout.session.completed' });
console.log(JSON.stringify(event));
"
curl -s -X POST http://localhost:4200/api/webhooks/payment \
  -H 'Content-Type: application/json' \
  -H "X-Test-Payment-Signature: <event.signature from above>" \
  -d '<event.body from above>'

To simulate a failed payment or abandoned checkout, use eventType: 'payment_intent.payment_failed' or 'checkout.session.expired' in step 3 instead — see platform/test/e2e-phase9.js for both, and for the reservation-reaper path (never send a webhook at all; wait past REGISTRATION_RESERVATION_TIMEOUT_MINUTES, or lower it for a faster local test).

Local testing against real Stripe TEST mode

Exercised for real in Phase 9.1 — a real Stripe TEST Checkout, a real TEST payment, a real signed webhook, and the full registration→enrollment conversion were all proven this way; see docs/PHASE9_1_STRIPE_TEST_VALIDATION.md for the full result and the one real defect it exposed and fixed. platform/test/stripe-integration-test.js automates most of the steps below — set RUN_STRIPE_INTEGRATION_TEST=true plus STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET/ OWNER_EMAIL/OWNER_PASSWORD and run it; it refuses to run against a live-mode key and makes zero Stripe network calls unless explicitly enabled. Manually, or to understand what that script does:

  1. Create (or use an existing) Stripe account, switch to Test mode in the dashboard.
  2. Copy the test-mode Secret key (sk_test_...) into STRIPE_SECRET_KEY.
  3. Install the Stripe CLI (a prebuilt binary from https://github.com/stripe/stripe-cli/releases works with no apt/sudo needed — this is how Phase 9.1 installed it, to ~/.local/bin/stripe) and run:
    stripe listen --api-key sk_test_... --forward-to localhost:4200/api/webhooks/payment
    
    No stripe login needed when passing --api-key directly (useful in a headless/agent session with no browser to complete an OAuth flow). The CLI prints a webhook signing secret (whsec_...) — set that as STRIPE_WEBHOOK_SECRET for the duration of the local session (it's ephemeral per stripe listen run; a real deployment instead creates a permanent webhook endpoint in the Stripe dashboard and uses ITS signing secret). Add --print-secret to retrieve just the secret without leaving the listener running.
  4. Set PAYMENT_PROVIDER=stripe and restart the API.
  5. Drive the checkout flow through steps 2-4 of docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md — you now get back a REAL checkout_url. Open it in a browser and pay with a Stripe test card (4242 4242 4242 4242, any future expiry, any CVC, any ZIP).
  6. stripe listen forwards Stripe's real webhook event to your local server; watch the server log and payment_webhook_events table for processed.
  7. To simulate a failure without a real failing card, use Stripe's documented test card numbers for declines (e.g. 4000 0000 0000 0002), or stripe trigger payment_intent.payment_failed.

This is the recommended path to real validation before going live — it exercises the actual Stripe SDK, actual signature verification, and actual Stripe event delivery, with zero real money at risk.

Going live (real money)

  1. Switch the Stripe dashboard to Live mode, generate a live Secret key (sk_live_...), and create a permanent webhook endpoint pointed at the production API's /api/webhooks/payment URL — copy ITS signing secret (whsec_..., different from test mode's).
  2. Set STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET (live values) and PAYMENT_PROVIDER=stripe in production configuration only — never commit these to git, never put them in a .env file that gets version-controlled (matching this repo's existing secret-handling convention for PROVISIONER_SHARED_SECRET/INTEGRATION_SHARED_SECRET).
  3. Set a real price on the live offering (PATCH /api/admin/offerings/:id) and confirm GET /api/admin/offerings/:id/readiness shows payment_provider: ready.
  4. Run one real, small, genuinely-refundable transaction against a real card before opening registration publicly, and confirm the full pipeline (checkout → webhook → confirmation → enrollment → activation email) end to end, then refund it via the admin UI or the Stripe dashboard directly.
  5. Monitor rootnode_payment_webhooks_total/rootnode_payments_total (Prometheus metrics, see docs/PHASE9_COMMERCE_AND_LAUNCH.md) after opening registration.

This phase does not perform steps 1-4 above — no real Stripe credentials were provided, and inventing/obtaining them is a business decision reserved for Chase, not something this phase invents. Everything up to this runbook is built and tested against the test provider; live validation is the one explicitly pending item.

Troubleshooting

Symptom Likely cause
503 PROVIDER_NOT_CONFIGURED on checkout PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY unset, or the stripe npm package failed to load.
400 invalid_signature on every real Stripe webhook STRIPE_WEBHOOK_SECRET doesn't match the endpoint Stripe actually delivered to (test-mode secret used against a live endpoint, or vice versa — each stripe listen/dashboard endpoint has its OWN secret).
Webhook received but nothing changes Check payment_webhook_events.result for that event id — duplicate means it was already processed (expected on a retry); check server logs for an exception if result is missing entirely.
Seat never releases after an abandoned checkout Confirm the reservation reaper is running (RESERVATION_REAPER_POLL_MS log line on server startup) and that REGISTRATION_RESERVATION_TIMEOUT_MINUTES hasn't been set unreasonably high.