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

144 lines
9.5 KiB
Markdown

# 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:
```bash
# 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](https://stripe.com/docs/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:
```bash
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. |