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.
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:
- Create (or use an existing) Stripe account, switch to Test mode in the dashboard.
- Copy the test-mode Secret key (
sk_test_...) intoSTRIPE_SECRET_KEY. - 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:Nostripe listen --api-key sk_test_... --forward-to localhost:4200/api/webhooks/paymentstripe loginneeded when passing--api-keydirectly (useful in a headless/agent session with no browser to complete an OAuth flow). The CLI prints a webhook signing secret (whsec_...) — set that asSTRIPE_WEBHOOK_SECRETfor the duration of the local session (it's ephemeral perstripe listenrun; a real deployment instead creates a permanent webhook endpoint in the Stripe dashboard and uses ITS signing secret). Add--print-secretto retrieve just the secret without leaving the listener running. - Set
PAYMENT_PROVIDER=stripeand restart the API. - Drive the checkout flow through steps 2-4 of
docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md— you now get back a REALcheckout_url. Open it in a browser and pay with a Stripe test card (4242 4242 4242 4242, any future expiry, any CVC, any ZIP). stripe listenforwards Stripe's real webhook event to your local server; watch the server log andpayment_webhook_eventstable forprocessed.- To simulate a failure without a real failing card, use Stripe's documented test card numbers
for declines (e.g.
4000 0000 0000 0002), orstripe 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)
- 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/paymentURL — copy ITS signing secret (whsec_..., different from test mode's). - Set
STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET(live values) andPAYMENT_PROVIDER=stripein production configuration only — never commit these to git, never put them in a.envfile that gets version-controlled (matching this repo's existing secret-handling convention forPROVISIONER_SHARED_SECRET/INTEGRATION_SHARED_SECRET). - Set a real price on the live offering (
PATCH /api/admin/offerings/:id) and confirmGET /api/admin/offerings/:id/readinessshowspayment_provider: ready. - 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.
- Monitor
rootnode_payment_webhooks_total/rootnode_payments_total(Prometheus metrics, seedocs/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. |