Files
rootnode-academy/docs/PHASE9_1_STRIPE_TEST_VALIDATION.md
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

21 KiB

Phase 9.1 — Real Stripe TEST-Mode Integration Validation

Status: VALIDATED. A real Stripe TEST Checkout Session, a real Stripe TEST payment ($399.00 — the founder-approved Winter Break Founding Cohort tuition), a genuine Stripe-signed webhook, and the existing registration→enrollment conversion path were all proven end to end against Stripe's real TEST-mode API — no live money, no live-mode credential, nothing simulated. This document also records the first, credential-less attempt at this phase (which correctly stopped rather than fake anything) and a real defect the live traffic exposed and fixed.

1. Baseline verification

  • First attempt (credential-less): branch main, clean tree, HEAD = origin/main = 80a5f6e4576cb9b83a60c9e9b57e4ebc52b62f07 — the Phase 9 milestone.
  • Resume attempt (this run): git fetch origin confirmed HEAD = origin/main = 77fd1869dbe694a7ff323a6fac07382e1f55b225 — the prior, credential-less Phase 9.1 commit.
  • No docs/ROOTNODE_PRICING_AND_OFFERS_V1.md or any "Phase 9.5" commit exists anywhere in this repository's history, branches, or tags (verified via git log --all) — the pricing decision below came directly from Chase in this session, not from a document that turned out not to have reached this canonical repo yet (it was apparently done on a different machine and not yet pushed). Used directly; no invented business decision was needed to proceed.
  • Phase 9 payment code and documentation re-inspected: payment-provider.js, commerce.js, webhook-routes.js, offerings.js's conversion path, admin-offerings-routes.js's payment routes, integration-routes.js's checkout/agreement/status routes, platform/test/ e2e-phase9.js, infra/scripts/drills/test-phase9-winter-camp-commerce.js.

2. Stripe TEST credential status

First attempt: none found anywhere reachable (process env, every .env file, this repo's Infisical wiring, session memory) — correctly stopped and documented rather than faked (see git history for that version of this document).

This run: Chase provided a real sk_test_... secret key directly. Format-verified as TEST mode (never printed in full anywhere in this document, any commit, or any persisted log — only an 8-character prefix sk_test_ ever appears). Stored in platform/.env (gitignored, never committed — confirmed via git status throughout this phase). A push to Infisical for durable storage was attempted (per Chase's request) but the existing machine identity credential at ~/.config/infisical/identity.env returned a consistent 401 Invalid credentials from app.infisical.com across three attempts — a pre-existing, unrelated credential-store problem, not something fixable from this session. The Stripe key remains safely local in the meantime.

The webhook signing secret was obtained via stripe listen --api-key ... --forward-to http://localhost:4200/api/webhooks/payment --print-secret (the Stripe CLI, installed to ~/.local/bin/stripe this session for exactly this purpose — no Dashboard endpoint needed for local TEST-mode validation).

3. Stripe adapter architecture — confirmed, unchanged

StripeProvider.createCheckoutSession() uses dynamic Checkout line-item price_data, never pre-created Stripe Price/Product IDs. RootNode's offerings table remains the sole source of truth for identity, price, and currency. Confirmed still true after this phase's fix (below) — the fix only changes how a LATER webhook event is correlated back to a transaction, not how the Checkout Session itself is created.

4. Disposable TEST offering used

A clearly-labeled, disposable offering — [TEST] Stripe Integration Check <run-id> — delivery_mode: cohort, price 39900 (cents) / usd = $399.00, the founder-approved Winter Break "Founding Cohort" launch tuition Chase gave directly in this session (standard/reference retail $449, paid in full, no deposit, no payment plan — none of that billing structure was built, only the flat price was used for this validation). Not the real Winter Break offering — a separate, disposable fixture, transitioned to cancelled at the end of the run (confirmed).

5. Registration result

A real guardian + minor registration (the more complex path, preferred per the mission spec since the Winter Break audience may include minors) via POST /api/integrations/registrations — guardian display_name/email/phone real-shaped test data, registrant is_minor: true, no real child PII. Registration created, seat reserved, payment status tracked separately from registration status, no enrollment existed before payment — all as designed.

6. Agreement gate result

Checkout attempted before agreement acceptance → 409 AGREEMENTS_INCOMPLETE. Agreement then accepted via POST /api/integrations/registrations/:id/agreements → 201, accepted_version: 1 persisted. Checkout attempted again → succeeded. The real, live enforcement path, not bypassed.

7. Real Stripe TEST Checkout Session result

POST /api/integrations/registrations/:id/checkout returned a real https://checkout.stripe.com/... URL. Independently retrieved via Stripe's own checkout.sessions.retrieve API (never trusting RootNode's own echo):

Stripe session cs_test_a1VBoHHATcDr3DRTnZixQX8E0E5Ulk3H1UAKwTYVEOYVboOcaKCuZEMuff:
  amount_total = 39900, currency = usd, mode = payment, livemode = false

8. Server-authoritative amount verification

The checkout request was sent with a tampered body (amount: 1, currency: "eur" — neither field exists on the real contract, sent anyway to prove they're inert). Stripe's own recorded session still shows amount_total: 39900, currency: usd — the real, configured offering price, not the caller-supplied value. The caller cannot choose the price or currency.

9. Real TEST payment result

Chase completed the real Stripe-hosted TEST Checkout in a browser with Stripe's official test card (4242 4242 4242 4242). Independently confirmed via Stripe's own API (not just the redirect):

GET /v1/checkout/sessions/cs_test_a1VB... → status: complete, payment_status: paid,
  payment_intent: pi_3U9QuqPl0OwZKsjY0lprOPWi

10. Real signed webhook result

stripe listen relayed a genuine Stripe-signed checkout.session.completed event (evt_1U9QutPl0OwZKsjYSkW8Le84) to POST /api/webhooks/payment, which accepted it (200, {"received":true,"result":"processed"}). This is Stripe's own real signature over Stripe's own real event body — not a locally-fabricated one.

11. Invalid-signature result

A garbage Stripe-Signature header against a fabricated body → 400 invalid_signature. The registration's status was re-read before and after and confirmed unchanged — zero domain side effect from a rejected signature.

12. Payment transaction result

Exactly one payment_transactions row for this registration, status: paid, provider_reference matching the real Checkout Session id. No card_number/cvv/card_last4/ payment_method_details field exists anywhere on the record (none is ever stored, by design).

13. Registration confirmation result

offering_registrations.status = enrollment_activated, server-derived entirely from the real payment webhook — never a client action.

14. Enrollment conversion result

The existing, unmodified convertRegistrationToEnrollment() path ran — no second implementation was created. Verified directly against the database: exactly one row in enrollments for this student/program.

15. Cohort/capacity result

Exactly one row in cohort_enrollments for this student/cohort — the offering's cohort_id correctly produced real classroom membership on conversion, unchanged from Phase 8/9's design.

16. Guardian preservation result

Exactly one row in student_guardians for this student (the Phase 9 guardian-linking fix from the original Phase 9 phase held under real Stripe traffic too). students.email for the minor remains the empty string — the guardian's email was never written into the student's own identity record.

17. Activation/notification result

The real activation notification was queued and delivered through the existing dev-preview mechanism (the same one every prior phase's testing has used — EMAIL_PROVIDER was left at its dev default this run; Google Workspace live delivery was not exercised, since nothing about Stripe validation requires it and the mission explicitly said not to make GWS delivery a Stripe blocker). No second, Stripe-specific notification path exists — this reuses the exact notifyForEnrollment flow every other enrollment trigger already uses.

18. Student access result

The real one-time activation token worked: POST /api/student/activate → 200, and the newly activated student's GET /api/student/programs includes the enrolled program. One access proof, as instructed — no full curriculum rehearsal was repeated (Phase 8/9's own rehearsals already cover that in depth).

19. Duplicate-event/idempotency result

The real Stripe event was fetched fresh via stripe.events.retrieve(eventId) and re-signed locally with the real webhook secret (stripe.webhooks.generateTestHeaderString) — never a fabricated "payment succeeded" event for an unpaid session, but a genuine replay of the exact event Stripe itself recorded, mirroring what stripe events resend does server-side (not usable here directly — an ephemeral stripe listen session has no persistent dashboard endpoint id to target). Result: 200, {"result":"duplicate"}. Confirmed via direct query: still exactly one enrollment, still exactly one activation/confirmation notification — no double conversion, no double seat consumption, no double email.

20. Declined-payment result

Not exercised via an actual real declined Stripe TEST payment this session — doing so would require a second full browser-driven Checkout completion (Stripe Checkout Sessions have no supported pure-API "decline" trigger; PaymentIntents-direct-confirm would mean not using Checkout Sessions at all, which this phase was told not to redesign toward). Phase 9's deterministic suite already proves "failed payment retains the seat" directly, and this phase's own new regression test (§28) proves the exact real-Stripe reference-correlation shape a genuine payment_intent.payment_failed event would use. Available on request if Chase wants one more manual decline-card checkout to close this specific gap with fully real traffic.

21. Abandoned-checkout result

Fully automated, no browser needed: a second, separate registration's Checkout Session was created normally, then expired via Stripe's own sessions.expire() API (a real, supported, pure-API operation — no fabricated event). Stripe itself then fired a genuine checkout.session.expired webhook, relayed by stripe listen and accepted (200). Confirmed: the registration's status became cancelled and its seat was released exactly once, within the polling window.

22. Refund result

stripe.refunds.create({ payment_intent }) against the real PaymentIntent → succeeded (re_3U9QuqPl0OwZKsjY0IYvfOsM, 39900 usd). RootNode's own refund state was confirmed (status: refunded). The non-cascade rule was reconfirmed directly against the database: the registration stayed enrollment_activated and the enrollment stayed active — a refund does not automatically revoke registration or academic access. Whether it should, for this specific offering/policy, remains an explicit business-policy question this phase does not decide in code (per Phase 9's own design and this phase's own instruction).

23. Stripe ↔ RootNode reconciliation

RootNode registration 04772e72-4a09-4afd-8032-d6ef4d132375
RootNode enrollment 10e2724a-43bb-4d35-8bea-98f600cbd8e9
RootNode student student-001
Stripe Checkout Session cs_test_a1VBoHHATcDr3DRTnZixQX8E0E5Ulk3H1UAKwTYVEOYVboOcaKCuZEMuff
Stripe PaymentIntent pi_3U9QuqPl0OwZKsjY0lprOPWi
Stripe webhook event evt_1U9QutPl0OwZKsjYSkW8Le84
Stripe refund re_3U9QuqPl0OwZKsjY0IYvfOsM
Amount / currency 39900 / usd ($399.00)

No secret key, webhook secret, card number, or CVV appears above or anywhere in this document.

24. Stripe Dashboard reconciliation

For a real transaction, Chase can locate it in the Stripe Dashboard under Test mode → Payments (search by the PaymentIntent id pi_... or the Checkout Session id cs_test_... above) or Developers → Events (search by the event id evt_...). The RootNode admin UI's own Payments card (on the registration detail view) shows the matching provider_reference side by side with RootNode's own status/timestamps — reconciliation is possible without either system exposing credentials or card data to the other.

25. Security revalidation (with real Stripe enabled)

Check Result
Stripe secret key never reaches browser Server-only env var; not referenced anywhere in frontend/.
Webhook signing secret never reaches browser Same.
Caller cannot choose price Proven directly (§8) against Stripe's own recorded amount.
Caller cannot choose currency Proven directly (§8), same test.
Caller cannot bypass required agreements Proven directly (§6).
Caller cannot bypass capacity Unchanged architecture; existing e2e-phase9.js last-seat race regression rerun clean (§27).
Success redirect cannot forge payment The script never trusted the redirect — every "paid" claim in this document is independently verified via Stripe's own API or the real webhook, not the URL.
Invalid webhook signature rejected Proven directly (§11).
Duplicate webhook safe Proven directly with a REAL re-signed event (§19).
No card number enters RootNode DB Proven directly (§12) — no such field exists.
No card number enters RootNode logs Confirmed — grepped this session's own logs/scratch files for the real secret values and any card-shaped pattern; none found.
No Stripe secret enters audit log Existing e2e-phase9.js hygiene test rerun clean (§27).
No Stripe secret enters metrics Same.
Stripe provider IDs not high-cardinality metric labels Unchanged architecture — metrics still use only fixed-enum labels.
One student cannot inspect another's payment Structural — payment routes are admin/owner-only; students have no route to any payment data at all.
Integration shared secret remains server-to-server only Unchanged — never read from any browser-facing code.

26. Bugs found

One real defect, found only because real Stripe traffic was exercised: payment_intent. payment_failed and charge.refunded webhook events reference Stripe's PaymentIntent/Charge object, never the Checkout Session — but payment_transactions.provider_reference only ever stored the Session id. A real Stripe-issued failure or refund event would have silently failed to match any transaction (result: 'error', reason: 'UNKNOWN_TRANSACTION'). Phase 9's deterministic TestProvider never exposed this because its fabricated events always reuse whatever reference the calling test explicitly supplies, never distinguishing real Stripe object id shapes.

27. Bugs fixed

Smallest correct fix, matching Stripe's own documented pattern for this exact problem:

  • payment-provider.js: StripeProvider.createCheckoutSession() now also sets payment_intent_data: { metadata } at session creation, so the SAME metadata (which already carries our own transaction_id) propagates onto the PaymentIntent Stripe creates — not just the Session.
  • normalizeEvent() (now async, one extra API call only on the rare refund path): for payment_intent.payment_failed, reads obj.metadata.transaction_id directly (the event object IS the PaymentIntent). For charge.refunded, retrieves the PaymentIntent (stripe.paymentIntents.retrieve) since Charges do not inherit PaymentIntent metadata automatically, and reads transaction_id from there.
  • commerce.js: the transaction lookup now matches provider_reference = $1 OR transaction_id::text = $1 — a single additional OR clause, no schema/migration change.
  • webhook-routes.js: added the now-required await on verifyAndParseWebhook (the deterministic TestProvider's synchronous return is unaffected — await on a non-promise value resolves immediately).

Verified fixed: the real Stripe refund in this session (§22) correctly resolved via this new path (the refund's charge.refunded webhook references the PaymentIntent, exactly the previously-broken case), and a new permanent deterministic regression test (§28) proves the mechanism directly.

28. Permanent regression coverage added

platform/test/e2e-phase9.js gained one new test: "a webhook event referencing OUR OWN transaction_id (not provider_reference) — the real Stripe payment_intent/charge shape — is still matched" — builds a test event whose providerReference is the transaction's own UUID (exactly what the fixed normalizeEvent() now produces for real Stripe failure/refund events) and confirms it's found and processed. This runs on every future deterministic e2e-phase9.js pass, with no Stripe credentials required.

platform/test/stripe-integration-test.js (the opt-in real-Stripe check from the first Phase 9.1 attempt) was substantially extended: configurable price/currency (STRIPE_TEST_PRICE_CENTS/STRIPE_TEST_CURRENCY, defaults stay cheap/generic), a STRIPE_TEST_MINOR=true guardian+minor path, and — once a real payment lands — fully automated deep verification (payment record, enrollment/cohort/guardian reconciliation, the real re-signed-event duplicate proof, student access, a real refund) plus a fully automated, browser-free abandoned/expired-checkout proof via sessions.expire(). Still opt-in, still a guaranteed no-op by default, still refuses live-mode keys.

29. Regression results

  • platform/test/e2e-phase9.js: 20/20 (fresh stack, PAYMENT_PROVIDER=test) — includes the one new regression test from §28.
  • infra/scripts/drills/test-phase9-winter-camp-commerce.js: 13/13 (fresh stack) — unaffected by the fix, rerun to confirm.
  • No registration/agreement/enrollment/notification-template code changed beyond the webhook correlation fix itself, so Phase 8/5B/5C were not rerun (per the mission's own conditional rule) — the fix is entirely inside commerce.js/payment-provider.js/webhook-routes.js, already covered by the Phase 9 suite.
  • No frontend code changed — no frontend build was required.

30. Documentation

Rewrote PHASE9_1_STRIPE_TEST_VALIDATION.md (this document) from BLOCKED to the real, proven results. Updated docs/STRIPE_INTEGRATION_RUNBOOK.md (real-Stripe local testing is no longer "not yet exercised") and docs/WINTER_BREAK_LAUNCH_CHECKLIST.md (the Stripe TEST technical validation item is now checked; live-mode items remain unchecked, correctly — this phase never touched live mode). docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md needed no change — nothing about the website-facing contract itself changed this phase, only the server-side webhook correlation internals.

31. Remaining Stripe TEST gaps

An actual real declined-payment checkout was not exercised (§20) — available on request, needs one more manual browser step. Everything else the mission asked for was proven with real Stripe traffic.

32. Remaining LIVE Stripe gaps

Unchanged from Phase 9 — see docs/STRIPE_INTEGRATION_RUNBOOK.md's "Going live" section and docs/WINTER_BREAK_LAUNCH_CHECKLIST.md: business/account verification, a live secret key, a permanent live webhook endpoint, payout/bank configuration, a real HTTPS production URL, and production deployment itself. None of this phase's work performed or authorized any live-mode action.

33. Remaining Winter Break business decisions

Real dates, venue, and final capacity remain undecided (price is now decided — $399 launch / $449 standard retail, per Chase directly this session; not yet reflected in a committed ROOTNODE_PRICING_AND_OFFERS_V1.md in this canonical repo — see §1). Refund/cancellation policy (as opposed to the mechanical non-cascade behavior proven in §22) remains an open business decision.

Unchanged from every prior phase: the actual agreement/waiver legal text, jurisdiction-specific minor-consent review, data-retention/deletion policy — see docs/STUDENT_GUARDIAN_DATA_MODEL.md.

Final status

PHASE 9.1 COMPLETE / STRIPE TEST-MODE VALIDATED

A real Stripe TEST Checkout Session, a real Stripe TEST payment, a genuine Stripe-signed webhook, and the existing registration→enrollment conversion were all proven end to end, plus a real duplicate-delivery proof, a real automated abandoned-checkout proof, and a real refund with the non-cascade rule reconfirmed. One genuine defect (webhook-event correlation for PaymentIntent/Charge-referencing events) was found only by this real traffic, fixed with the smallest correct change, and covered by a new permanent deterministic regression test.