Compare commits
3
Commits
77fd1869db
...
a4c0058f8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4c0058f8c | ||
|
|
fbf52d8334 | ||
|
|
c86b3de611 |
@@ -1,180 +1,363 @@
|
||||
# Phase 9.1 — Real Stripe TEST-Mode Integration Validation
|
||||
|
||||
Status: **BLOCKED — no Stripe TEST credentials are available to this session.** This document
|
||||
records exactly what was verified without them, exactly what Chase needs to provide, and the
|
||||
permanent, opt-in validation script that will run the real proof the moment those credentials
|
||||
exist. No live money was risked; no Stripe network call of any kind was made during this phase.
|
||||
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
|
||||
|
||||
- Branch: `main`. Working tree: clean at start.
|
||||
- `git fetch origin` — HEAD and `origin/main` both `80a5f6e4576cb9b83a60c9e9b57e4ebc52b62f07`,
|
||||
exactly the Phase 9 milestone commit.
|
||||
- Phase 9 payment code and documentation inspected in full: `payment-provider.js`,
|
||||
`commerce.js`, `webhook-routes.js`, `offerings.js`'s registration/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`, `docs/STRIPE_INTEGRATION_RUNBOOK.md`, `docs/
|
||||
WINTER_BREAK_LAUNCH_CHECKLIST.md`. Nothing was changed as a result of this review — no defect
|
||||
was found, and none was invented to justify a change (§1/§22's own instruction).
|
||||
- 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 search — none found
|
||||
## 2. Stripe TEST credential status
|
||||
|
||||
Searched, in order: process environment (`env | grep -i stripe`), every `.env`/`.env.example`
|
||||
file reachable from this repository and the operator's home directory, this repository's own
|
||||
Infisical project wiring (none exists — no `.infisical.json` in this repo), and this session's
|
||||
own memory of prior credential-provisioning work (none references Stripe or this project). No
|
||||
`STRIPE_SECRET_KEY`, no `STRIPE_WEBHOOK_SECRET`, in any test or live form, exists anywhere this
|
||||
session can reach.
|
||||
**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 is exactly STOP condition 1/2 from this phase's own authorization.** Per §2/§27: do not
|
||||
fake validation, stop after documenting exactly what's needed.
|
||||
**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.
|
||||
|
||||
### What Chase needs to provide or configure
|
||||
|
||||
1. A Stripe account (test mode is available on any account, including one with no live
|
||||
business verification completed yet).
|
||||
2. From the Stripe Dashboard, in **Test mode**: the test **Secret key** (`sk_test_...`) →
|
||||
`STRIPE_SECRET_KEY`.
|
||||
3. A webhook signing secret for a test endpoint — either a permanent test-mode webhook endpoint
|
||||
configured in the dashboard, or the ephemeral one the Stripe CLI prints when running
|
||||
`stripe listen --forward-to <gateway>/api/webhooks/payment` → `STRIPE_WEBHOOK_SECRET`. Full
|
||||
setup steps already documented in `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Local testing
|
||||
against real Stripe TEST mode" section (written during Phase 9, unchanged and still accurate).
|
||||
4. Both values set as environment configuration on the running Platform API process
|
||||
(`PAYMENT_PROVIDER=stripe` alongside them) — never committed to git, never placed in any
|
||||
browser-reachable configuration. **No credential value appears anywhere in this document, any
|
||||
commit, or any log produced during this phase.**
|
||||
|
||||
Once those exist, run the permanent validation script this phase adds (§4 below) to produce the
|
||||
actual proof this phase was asked to deliver.
|
||||
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
|
||||
|
||||
Inspected `payment-provider.js`'s `StripeProvider.createCheckoutSession()`: it uses **dynamic
|
||||
Checkout line-item `price_data`** (`unit_amount`/`currency`/`product_data.name` supplied inline
|
||||
per session), **not** pre-created Stripe Price/Product IDs. RootNode's `offerings` table remains
|
||||
the sole source of truth for identity, price, and currency; Stripe never becomes a second
|
||||
business-domain model — no Stripe object id is ever treated as anything other than an opaque
|
||||
`provider_reference`. This matches §3's explicit constraint and required no change.
|
||||
`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. Permanent opt-in Stripe integration test — added
|
||||
## 4. Disposable TEST offering used
|
||||
|
||||
`platform/test/stripe-integration-test.js` (new). Default behavior (no environment variable
|
||||
set): prints a skip message and exits `0` with **zero Stripe network traffic** — confirmed by
|
||||
running it with no configuration at all. **Verified this run**:
|
||||
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):
|
||||
|
||||
```
|
||||
$ node test/stripe-integration-test.js
|
||||
Stripe integration test skipped (RUN_STRIPE_INTEGRATION_TEST is not "true") — no Stripe network traffic, no credentials required.
|
||||
Stripe session cs_test_a1VBoHHATcDr3DRTnZixQX8E0E5Ulk3H1UAKwTYVEOYVboOcaKCuZEMuff:
|
||||
amount_total = 39900, currency = usd, mode = payment, livemode = false
|
||||
```
|
||||
|
||||
Enabled via `RUN_STRIPE_INTEGRATION_TEST=true`, reusing the exact `STRIPE_SECRET_KEY`/
|
||||
`STRIPE_WEBHOOK_SECRET`-adjacent naming Phase 9 already established (only `STRIPE_SECRET_KEY` is
|
||||
actually needed by the script itself, to independently query Stripe's own API — the webhook
|
||||
secret only needs to be configured on the server process, not read by this script). Guard
|
||||
clauses verified directly, without needing real credentials:
|
||||
## 8. Server-authoritative amount verification
|
||||
|
||||
| Condition | Verified behavior |
|
||||
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
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `OWNER_EMAIL`/`OWNER_PASSWORD` unset | Exits `1` with a clear message. |
|
||||
| `STRIPE_SECRET_KEY` unset | Exits `1`, names the exact variable needed. |
|
||||
| `STRIPE_SECRET_KEY` starts with `sk_live_` | **Refuses to run** — "TEST MODE ONLY" — exits `1`. |
|
||||
| `STRIPE_SECRET_KEY` any other non-test shape | Refuses to run out of caution — exits `1`. |
|
||||
| A syntactically valid `sk_test_...` value, no real stack running | Reaches `main()` cleanly, fails only on the expected connection error — confirms no syntax defect in the enabled code path. |
|
||||
| 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) |
|
||||
|
||||
When real test credentials exist and the dev stack is running with `PAYMENT_PROVIDER=stripe`,
|
||||
this script will automatically and honestly prove, via pure API calls (no browser needed):
|
||||
No secret key, webhook secret, card number, or CVV appears above or anywhere in this document.
|
||||
|
||||
- RootNode creates a **real** Stripe TEST Checkout Session (not RootNode's own claim — verified
|
||||
by retrieving the session directly from Stripe's own `checkout.sessions.retrieve` API).
|
||||
- The amount/currency Stripe actually recorded matches the offering's server-side price exactly,
|
||||
even when the request is sent with a tampered `amount`/`currency` in the body — proving
|
||||
server-authoritative pricing against **Stripe's own object**, the strongest form of that proof.
|
||||
- `session.livemode === false` is asserted directly — the script itself refuses to consider its
|
||||
own run valid against anything but a real TEST-mode session.
|
||||
- RootNode's webhook route rejects an invalid/garbage signature cleanly, with **zero domain side
|
||||
effect** (the registration's status is re-read and asserted unchanged).
|
||||
## 24. Stripe Dashboard reconciliation
|
||||
|
||||
What it deliberately does **not** automate: actually completing Stripe's hosted Checkout page.
|
||||
There is no supported pure-API way to "pay" a Checkout Session, and fabricating a signed
|
||||
"payment succeeded" webhook for a session nobody actually paid would itself be exactly the kind
|
||||
of faked validation this phase was told never to produce. Instead, the script prints the real
|
||||
checkout URL and polls RootNode's own status endpoint for a bounded window (default 90s,
|
||||
`STRIPE_INTEGRATION_TEST_WAIT_SECONDS`) while a human completes payment in a browser (Stripe's
|
||||
test card `4242 4242 4242 4242`) or a parallel `stripe listen --forward-to` relays Stripe's real
|
||||
event — see `docs/STRIPE_INTEGRATION_RUNBOOK.md`. If nothing arrives in that window (expected in
|
||||
an unattended run), it says so plainly rather than reporting a false pass.
|
||||
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.
|
||||
|
||||
Never runs as part of normal regression suites or CI — no other test file requires or imports it,
|
||||
and its own default is a guaranteed no-op.
|
||||
## 25. Security revalidation (with real Stripe enabled)
|
||||
|
||||
## 5. What was actually proven this phase
|
||||
|
||||
| Step from the mission spec | Result |
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| 1. Baseline verification | Done — see §1. |
|
||||
| 2. Stripe TEST-mode safety | No live-mode action was possible or attempted; no credentials of any kind exist. |
|
||||
| 3. Stripe architecture review | Done — dynamic `price_data`, confirmed, unchanged (§3). |
|
||||
| 4-20 (disposable offering through security revalidation, all requiring a real Stripe TEST payment/webhook round trip) | **Not performed — blocked on missing credentials.** Faking any of these was explicitly disallowed. |
|
||||
| 21. Permanent opt-in integration test | **Built** (§4) — the automatable portion is real and will run the moment credentials exist; the human/CLI-dependent portion is honestly deferred, not faked. |
|
||||
| 22. Application changes | None made — no genuine defect was exposed (none could be, without real Stripe traffic), so §22's "smallest correct fix" rule had nothing to apply to. |
|
||||
| 23. Regression | No registration/agreement/enrollment/notification/frontend code changed this phase, so per §23's own conditional rule, no regression rerun was required. The new test file's default no-op path was verified directly (§4) instead. |
|
||||
| 24. Documentation | This document, plus the update noted below. |
|
||||
| 25. Live-mode readiness checklist | See §6. |
|
||||
| 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. |
|
||||
|
||||
## 6. Live-mode readiness checklist
|
||||
## 26. Bugs found
|
||||
|
||||
Everything Stripe-side is still pending exactly as `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Going
|
||||
live" section already lays out, restated here as a checklist per §25:
|
||||
**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.
|
||||
|
||||
**Stripe:**
|
||||
- [ ] Business/account verification completed in the Stripe dashboard.
|
||||
- [ ] Live API secret key (`sk_live_...`) generated.
|
||||
- [ ] A permanent live-mode webhook endpoint created, pointed at the production API's
|
||||
`/api/webhooks/payment` URL, and its signing secret (`whsec_...`) captured.
|
||||
- [ ] Payout/bank details configured in Stripe.
|
||||
- [ ] Business/support information (statement descriptor, support contact) configured in Stripe.
|
||||
## 27. Bugs fixed
|
||||
|
||||
**RootNode:**
|
||||
- [ ] A real, HTTPS production URL for the Platform API (Stripe requires HTTPS for live webhooks).
|
||||
- [ ] `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (live values) provisioned into production
|
||||
secret configuration only.
|
||||
- [ ] Production deployment itself (explicitly out of scope for this phase and Phase 9 —
|
||||
unchanged status).
|
||||
- [ ] Backup/monitoring in place for the production database holding `payment_transactions`.
|
||||
- [ ] The real Winter Break offering configured with its real price, once decided.
|
||||
Smallest correct fix, matching Stripe's own documented pattern for this exact problem:
|
||||
|
||||
**Business** (Chase's decisions, not invented here): final price, refund/cancellation policy,
|
||||
camp dates, venue, capacity — all still open, tracked in `docs/
|
||||
WINTER_BREAK_LAUNCH_CHECKLIST.md`.
|
||||
- `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).
|
||||
|
||||
**Legal** (unchanged from every prior phase's own list): the actual agreement/waiver text,
|
||||
guardian/minor consent review, privacy/retention requirements — see `docs/
|
||||
STUDENT_GUARDIAN_DATA_MODEL.md`.
|
||||
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.
|
||||
|
||||
Nothing above is marked complete — none of it has been verified, and this phase did not attempt
|
||||
to invent or shortcut any of it.
|
||||
## 28. Permanent regression coverage added
|
||||
|
||||
## 7. Bugs found
|
||||
`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.
|
||||
|
||||
None. No genuine defect was exposed because no real Stripe traffic was possible this phase.
|
||||
`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.
|
||||
|
||||
## 8. Security posture — reasoned about, not re-verified against real Stripe
|
||||
## 29. Regression results
|
||||
|
||||
Every item in the mission's §20 checklist was already true and tested against the deterministic
|
||||
provider in Phase 9 (`e2e-phase9.js`), and nothing about switching providers changes the code
|
||||
path that enforces them (the same `commerce.js`/`webhook-routes.js` code runs regardless of
|
||||
which provider is active — see §3). This phase did not and could not re-verify them against
|
||||
REAL Stripe traffic specifically; that re-verification is exactly what `stripe-integration-test.js`
|
||||
(§4) will perform automatically the moment credentials exist.
|
||||
- `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.
|
||||
|
||||
## 34. Remaining legal requirements
|
||||
|
||||
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 BLOCKED — NO STRIPE TEST CREDENTIALS AVAILABLE.**
|
||||
**PHASE 9.1 COMPLETE / STRIPE TEST-MODE VALIDATED**
|
||||
|
||||
No real Stripe TEST Checkout, real TEST payment, or real signed Stripe webhook was created or
|
||||
observed this phase — none of the actions requiring them were attempted, per this phase's own
|
||||
explicit instruction never to fake validation. The permanent, opt-in `stripe-integration-test.js`
|
||||
is ready and waiting; provide `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (test mode) and set
|
||||
`RUN_STRIPE_INTEGRATION_TEST=true` to run the real proof.
|
||||
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.
|
||||
|
||||
@@ -271,11 +271,16 @@ Real Stripe live-mode validation once credentials exist (`docs/STRIPE_INTEGRATIO
|
||||
text once reviewed; and, informed by real Winter Break Camp commercial operation, whether a
|
||||
waitlist is actually worth building.
|
||||
|
||||
**Phase 9.1 addendum**: real Stripe TEST-mode validation was attempted immediately after this
|
||||
phase and remained blocked on the exact same missing credentials — see `docs/
|
||||
PHASE9_1_STRIPE_TEST_VALIDATION.md`. That phase added a permanent, opt-in integration test
|
||||
(`platform/test/stripe-integration-test.js`, default no-op, refuses live-mode keys) ready to run
|
||||
the real proof the moment `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (test mode) exist.
|
||||
**Phase 9.1 addendum**: real Stripe TEST-mode validation was completed — a real Stripe TEST
|
||||
Checkout, a real TEST payment (at the founder-approved $399 Founding Cohort price), a genuine
|
||||
signed webhook, and the existing registration→enrollment conversion were all proven end to end,
|
||||
plus real duplicate-delivery, abandoned-checkout, and refund proofs. One real defect (webhook
|
||||
event correlation for PaymentIntent/Charge-referencing events, invisible to the deterministic
|
||||
TestProvider) was found and fixed with a permanent regression test added. See `docs/
|
||||
PHASE9_1_STRIPE_TEST_VALIDATION.md` for the full evidence. `PAYMENT_PROVIDER=stripe`,
|
||||
`STRIPE_SECRET_KEY`, and `STRIPE_WEBHOOK_SECRET` are now wired through
|
||||
`platform/docker-compose.dev.yml` (empty/unset by default — the deterministic TestProvider
|
||||
remains the default for routine dev/test).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -72,27 +72,31 @@ To simulate a **failed payment** or **abandoned checkout**, use `eventType:
|
||||
at all; wait past `REGISTRATION_RESERVATION_TIMEOUT_MINUTES`, or lower it for a faster local
|
||||
test).
|
||||
|
||||
## Local testing against real Stripe TEST mode (once credentials exist)
|
||||
## Local testing against real Stripe TEST mode
|
||||
|
||||
Not yet exercised in this repo — no Stripe account/credentials have been provided as of Phase
|
||||
9.1 either (see `docs/PHASE9_1_STRIPE_TEST_VALIDATION.md` for the exact search performed and
|
||||
what's needed). Once they exist, `platform/test/stripe-integration-test.js` (added in Phase 9.1)
|
||||
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:
|
||||
**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) and run:
|
||||
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 login
|
||||
stripe listen --forward-to localhost:4200/api/webhooks/payment
|
||||
stripe listen --api-key sk_test_... --forward-to localhost:4200/api/webhooks/payment
|
||||
```
|
||||
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).
|
||||
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
|
||||
|
||||
@@ -29,7 +29,12 @@ legal reviewer, or Stripe, not from more code.
|
||||
- [ ] Real camp dates and duration.
|
||||
- [ ] Real venue/location (or confirm fully online/remote).
|
||||
- [ ] Real capacity (seat count).
|
||||
- [ ] Real price and currency.
|
||||
- [x] Price and currency — **$399.00 USD launch tuition ("Founding Cohort"), $449.00 standard
|
||||
retail, paid in full, no deposit/payment plan** — founder-approved per Chase directly
|
||||
(Phase 9.1). Validated against real Stripe TEST mode at this exact amount. Not yet
|
||||
reflected in a committed pricing document in this canonical repo (referenced but not found
|
||||
— see `docs/PHASE9_1_STRIPE_TEST_VALIDATION.md` §1/§33); set directly on the real Winter
|
||||
Break offering via `PATCH /api/admin/offerings/:id` when ready to open registration.
|
||||
- [ ] Whether registration opens ahead of, or alongside, a specific marketing push.
|
||||
- [ ] Refund policy in plain terms (the platform can RECORD any refund decision; it does not
|
||||
decide policy — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`'s "Payment and the commerce
|
||||
@@ -44,13 +49,16 @@ legal reviewer, or Stripe, not from more code.
|
||||
- [ ] Data-retention/deletion policy decided and, if needed, reflected in the agreement text.
|
||||
- [ ] A decision on whether emergency-contact fields need additional encryption at rest.
|
||||
|
||||
## Payments — pending real Stripe credentials
|
||||
## Payments
|
||||
|
||||
Phase 9.1 searched for Stripe TEST credentials specifically to run real validation and found
|
||||
none available anywhere this session could reach — see `docs/
|
||||
PHASE9_1_STRIPE_TEST_VALIDATION.md` for the exact search and what Chase needs to provide. A
|
||||
permanent, opt-in test (`platform/test/stripe-integration-test.js`) is ready to run the real
|
||||
TEST-mode proof the moment `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` exist.
|
||||
- [x] **Stripe TEST-mode validation** — a real Stripe TEST Checkout Session, a real TEST payment
|
||||
at the actual $399.00 founding-cohort price, a genuine signed webhook, the existing
|
||||
registration→enrollment conversion, a real duplicate-delivery proof, a real automated
|
||||
abandoned-checkout proof, and a real refund with the non-cascade rule reconfirmed — all
|
||||
proven end to end in Phase 9.1. One real defect (webhook-event correlation) was found and
|
||||
fixed as a direct result. See `docs/PHASE9_1_STRIPE_TEST_VALIDATION.md` for the full
|
||||
evidence and reconciliation IDs. **This is TEST mode only — it does not authorize or imply
|
||||
LIVE-mode readiness**, see the unchecked items below.
|
||||
|
||||
- [ ] Real Stripe account created/available (live mode).
|
||||
- [ ] `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (live values) provisioned into production
|
||||
|
||||
@@ -85,7 +85,13 @@ async function processWebhookEvent(pool, studentsAuth, provider, normalizedEvent
|
||||
return { result: 'ignored' };
|
||||
}
|
||||
|
||||
const { rows: txRows } = await pool.query(`SELECT * FROM payment_transactions WHERE provider_reference = $1`, [providerReference]);
|
||||
// Phase 9.1 real-Stripe-TEST fix — providerReference is usually the Checkout Session id, but
|
||||
// for payment_intent.payment_failed/charge.refunded StripeProvider.normalizeEvent() instead
|
||||
// returns OUR OWN transaction_id (there is no other reliable Stripe-side reference for those
|
||||
// event types back to the Session-keyed row) — match either column, never a schema change.
|
||||
const { rows: txRows } = await pool.query(
|
||||
`SELECT * FROM payment_transactions WHERE provider_reference = $1 OR transaction_id::text = $1`, [providerReference]
|
||||
);
|
||||
const transaction = txRows[0];
|
||||
if (!transaction) {
|
||||
await pool.query(`UPDATE payment_webhook_events SET result = 'error', processed_at = now() WHERE webhook_event_id = $1`, [eventId]);
|
||||
|
||||
@@ -42,13 +42,22 @@ class StripeProvider {
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata,
|
||||
// Phase 9.1 real-Stripe-TEST fix — a later payment_intent.payment_failed or
|
||||
// charge.refunded event references the PaymentIntent/Charge, never the Checkout Session
|
||||
// (a genuinely different Stripe object id, only discovered by testing against real
|
||||
// Stripe — the deterministic TestProvider always reused one caller-supplied reference for
|
||||
// every event type, which never exposed this). The Session's own `metadata` above does
|
||||
// NOT propagate to its PaymentIntent automatically; `payment_intent_data.metadata` does —
|
||||
// this is what lets normalizeEvent() below correlate those later events back to OUR OWN
|
||||
// transaction_id, not Stripe's session id.
|
||||
payment_intent_data: { metadata },
|
||||
});
|
||||
return { checkoutUrl: session.url, providerReference: session.id };
|
||||
}
|
||||
|
||||
// Real signature verification via Stripe's own SDK helper — never a hand-rolled HMAC compare.
|
||||
// Throws on a bad/forged signature; the webhook route treats that as a clean 400, never a 500.
|
||||
verifyAndParseWebhook(rawBody, signatureHeader) {
|
||||
async verifyAndParseWebhook(rawBody, signatureHeader) {
|
||||
if (!this.client || !this.webhookSecret) throw Object.assign(new Error('Stripe webhook verification is not configured.'), { code: 'PROVIDER_NOT_CONFIGURED' });
|
||||
const event = this.client.webhooks.constructEvent(rawBody, signatureHeader, this.webhookSecret);
|
||||
return this.normalizeEvent(event);
|
||||
@@ -58,14 +67,33 @@ class StripeProvider {
|
||||
// types this integration actually acts on are mapped to a real status; everything else comes
|
||||
// back with normalizedStatus: null, which webhook-routes.js records (for audit completeness)
|
||||
// but never acts on.
|
||||
normalizeEvent(event) {
|
||||
//
|
||||
// providerReference is usually the Checkout Session id (matches payment_transactions.
|
||||
// provider_reference, set at checkout creation) — but payment_intent.payment_failed and
|
||||
// charge.refunded reference the PaymentIntent/Charge instead, which was never stored anywhere
|
||||
// as a lookup key. Both are correlated back via OUR OWN transaction_id, echoed back through
|
||||
// payment_intent_data.metadata at session creation (see createCheckoutSession above);
|
||||
// commerce.js's transaction lookup matches on either provider_reference OR transaction_id.
|
||||
async normalizeEvent(event) {
|
||||
const obj = event.data.object;
|
||||
let providerReference = obj.id;
|
||||
let normalizedStatus = null;
|
||||
if (event.type === 'checkout.session.completed') { normalizedStatus = 'paid'; providerReference = obj.id; }
|
||||
else if (event.type === 'checkout.session.expired') { normalizedStatus = 'cancelled'; providerReference = obj.id; }
|
||||
else if (event.type === 'payment_intent.payment_failed') { normalizedStatus = 'failed'; providerReference = obj.metadata?.checkout_session_id || obj.id; }
|
||||
else if (event.type === 'charge.refunded') { normalizedStatus = obj.amount_refunded < obj.amount ? 'partially_refunded' : 'refunded'; providerReference = obj.payment_intent || obj.id; }
|
||||
else if (event.type === 'payment_intent.payment_failed') {
|
||||
// obj IS the PaymentIntent — its own metadata already carries our transaction_id.
|
||||
normalizedStatus = 'failed'; providerReference = obj.metadata?.transaction_id || obj.id;
|
||||
} else if (event.type === 'charge.refunded') {
|
||||
normalizedStatus = obj.amount_refunded < obj.amount ? 'partially_refunded' : 'refunded';
|
||||
// obj is a Charge — Charges do not inherit their PaymentIntent's metadata, so retrieve
|
||||
// the PaymentIntent (one extra call, only on the rare refund path) to read ours back.
|
||||
let piMetadataRef = null;
|
||||
if (obj.payment_intent) {
|
||||
try { const pi = await this.client.paymentIntents.retrieve(obj.payment_intent); piMetadataRef = pi.metadata?.transaction_id || null; }
|
||||
catch { /* fall through to the raw PaymentIntent id below */ }
|
||||
}
|
||||
providerReference = piMetadataRef || obj.payment_intent || obj.id;
|
||||
}
|
||||
return { eventId: event.id, eventType: event.type, providerReference, normalizedStatus };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ module.exports = function registerWebhookRoutes(app, deps) {
|
||||
const signature = req.get('Stripe-Signature') || req.get('X-Test-Payment-Signature');
|
||||
let normalizedEvent;
|
||||
try {
|
||||
normalizedEvent = provider.verifyAndParseWebhook(req.rawBody, signature);
|
||||
normalizedEvent = await provider.verifyAndParseWebhook(req.rawBody, signature);
|
||||
} catch (e) {
|
||||
// A forged/invalid signature is a clean, quiet 400 — never a 500, never a hint about
|
||||
// WHY verification failed (timing/oracle hygiene, matching this codebase's existing
|
||||
|
||||
@@ -95,6 +95,14 @@ services:
|
||||
# e2e-phase5c.js sets this to "1" via .env for one test to prove production gating for
|
||||
# real, then unsets it again — see that test's own comment.
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-}
|
||||
# Phase 9.1 — real-Stripe-TEST-mode local validation. Empty/unset by default (fail-safe:
|
||||
# payment-provider.js defaults to the deterministic TestProvider when PAYMENT_PROVIDER is
|
||||
# unset — the same "dev by default, real provider opt-in" pattern EMAIL_PROVIDER already
|
||||
# uses above). Set all three in platform/.env only for a deliberate real-Stripe-TEST
|
||||
# session; STRIPE_SECRET_KEY must be a TEST key (sk_test_...) — never a live one.
|
||||
PAYMENT_PROVIDER: ${PAYMENT_PROVIDER:-}
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||
# TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy
|
||||
depends_on:
|
||||
postgres:
|
||||
|
||||
@@ -175,6 +175,29 @@ async function main() {
|
||||
assert.equal(regAfter.body.status, 'started', 'a failed payment leaves the registration open to retry, not cancelled');
|
||||
});
|
||||
|
||||
// Phase 9.1 real-Stripe-TEST regression — a real Stripe payment_intent.payment_failed/
|
||||
// charge.refunded event references the PaymentIntent/Charge, not the Checkout Session, so
|
||||
// StripeProvider.normalizeEvent() resolves those back to OUR OWN transaction_id (never the
|
||||
// stored provider_reference) via payment_intent_data.metadata. This deterministic test
|
||||
// simulates exactly that shape — a webhook event whose providerReference is the transaction's
|
||||
// own UUID, not its provider_reference — proving commerce.js's lookup matches on either
|
||||
// column. Found and fixed only by testing against real Stripe (the deterministic
|
||||
// provider previously always reused one caller-supplied reference, which never exposed this).
|
||||
await test('a webhook event referencing OUR OWN transaction_id (not provider_reference) — the real Stripe payment_intent/charge shape — is still matched', async () => {
|
||||
const idOffering = `off-p9-txidref-${RUN_ID}`, idSlug = `p9-txidref-${RUN_ID}`;
|
||||
await setupPaidOffering(owner, { offeringId: idOffering, slug: idSlug, capacity: 2, requireAgreement: false });
|
||||
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-txidref-evt-${RUN_ID}`, offering_slug: idSlug, registrant: { display_name: 'TxIdRef', email: `txidref-${RUN_ID}@example.com` } });
|
||||
await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||
const txRow = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
|
||||
const transactionId = txRow.body[0].transaction_id;
|
||||
assert.notEqual(transactionId, txRow.body[0].provider_reference, 'sanity check — these must be genuinely different values for this test to mean anything');
|
||||
const event = provider.buildTestEvent({ providerReference: transactionId, eventType: 'payment_intent.payment_failed' });
|
||||
const result = await webhookPost(event.body, event.signature);
|
||||
assert.equal(result.body.result, 'processed', 'the transaction must be found via transaction_id, not just provider_reference');
|
||||
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
|
||||
assert.equal(regAfter.body.status, 'started', 'a failed payment (matched via transaction_id) still leaves the registration retriable, not cancelled');
|
||||
});
|
||||
|
||||
// ── 19/21: capacity handling, last-seat race ─────────────────────────────────────────────
|
||||
await test('a last-seat race between two concurrent registration attempts yields exactly one winner', async () => {
|
||||
const raceOffering = `off-p9-race-${RUN_ID}`, raceSlug = `p9-race-${RUN_ID}`;
|
||||
|
||||
@@ -24,14 +24,22 @@
|
||||
// side effect — this does not require an actual completed payment to test honestly.
|
||||
//
|
||||
// What this script deliberately does NOT fake: actually completing Stripe's hosted Checkout
|
||||
// page (filling in a test card) is a real human/browser action (or a Stripe CLI `stripe
|
||||
// trigger` against Stripe's own backend) — there is no supported pure-API way to "complete" a
|
||||
// Checkout Session without one of those, and fabricating a signed "payment succeeded" event for
|
||||
// a session nobody actually paid would be exactly the kind of faked validation Phase 9.1 was
|
||||
// told never to produce. When enabled, this script prints the real checkout_url and pauses,
|
||||
// polling RootNode's own status endpoint for STRIPE_INTEGRATION_TEST_WAIT_SECONDS (default 90)
|
||||
// while a human completes it in a browser (test card 4242 4242 4242 4242) or a parallel
|
||||
// `stripe listen --forward-to <gateway>/api/webhooks/payment` relays Stripe's real event — see
|
||||
// page (filling in a test card) is a real human/browser action — there is no supported pure-API
|
||||
// way to "pay" a Checkout Session, and fabricating a signed "payment succeeded" event for a
|
||||
// session nobody actually paid would be exactly the kind of faked validation Phase 9.1 was told
|
||||
// never to produce. When enabled, this script prints the real checkout_url and polls RootNode's
|
||||
// own status endpoint for STRIPE_INTEGRATION_TEST_WAIT_SECONDS (default 90) while a human
|
||||
// completes it in a browser (test card 4242 4242 4242 4242) with a parallel
|
||||
// `stripe listen --forward-to <gateway>/api/webhooks/payment` relaying Stripe's real event.
|
||||
//
|
||||
// Once that real payment lands, everything else IS fully automated via real Stripe API calls:
|
||||
// payment-transaction/enrollment/cohort/guardian reconciliation, a genuine duplicate-delivery
|
||||
// proof (re-signing the REAL event Stripe recorded, fetched fresh via stripe.events.retrieve —
|
||||
// never a fabricated one), real student activation + program-visibility proof, a real Stripe
|
||||
// TEST refund with the non-cascade rule reconfirmed, and — needing no browser at all — a real
|
||||
// abandoned/expired-checkout proof via Stripe's own `sessions.expire()` API. Configurable via
|
||||
// STRIPE_TEST_PRICE_CENTS/STRIPE_TEST_CURRENCY (defaults to a cheap $1 generic check) and
|
||||
// STRIPE_TEST_MINOR=true (guardian+minor path instead of adult). See
|
||||
// docs/STRIPE_INTEGRATION_RUNBOOK.md and docs/PHASE9_1_STRIPE_TEST_VALIDATION.md.
|
||||
|
||||
const assert = require('assert');
|
||||
@@ -72,7 +80,12 @@ const { makeSession } = require('./session-helper');
|
||||
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
const TEST_PRICE_CENTS = 100; // $1.00 — small, non-zero, clearly a TEST amount
|
||||
// Phase 9.1 resume — overridable so a real business price (e.g. the $399/39900 Founding Cohort
|
||||
// tuition) can be validated directly against Stripe's own recorded amount, instead of always
|
||||
// testing an arbitrary $1. Defaults stay cheap/generic for a routine opt-in regression run.
|
||||
const TEST_PRICE_CENTS = Number(process.env.STRIPE_TEST_PRICE_CENTS) || 100;
|
||||
const TEST_CURRENCY = process.env.STRIPE_TEST_CURRENCY || 'usd';
|
||||
const TEST_MINOR = process.env.STRIPE_TEST_MINOR === 'true'; // guardian+minor path vs. adult
|
||||
const TAMPERED_AMOUNT_CENTS = 1; // what a malicious caller will try to substitute
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
@@ -85,25 +98,34 @@ async function integrationPost(path, body) {
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
function psql(sql) {
|
||||
const { execSync } = require('child_process');
|
||||
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 9.1 real-Stripe-TEST-mode integration check (opt-in, real Stripe API calls, TEST MODE ONLY)\n');
|
||||
|
||||
console.log(`Price under test: ${TEST_PRICE_CENTS} ${TEST_CURRENCY} (${(TEST_PRICE_CENTS / 100).toFixed(2)}) — ${TEST_MINOR ? 'guardian+minor' : 'adult'} registration path.\n`);
|
||||
|
||||
const owner = makeSession(GW);
|
||||
const offeringId = `stripe-it-${RUN_ID}`, slug = `stripe-integration-test-${RUN_ID}`;
|
||||
const agreementId = `stripe-it-agreement-${RUN_ID}`;
|
||||
let registrationId, sessionId, checkoutUrl;
|
||||
let registrationId, sessionId, checkoutUrl, cohortId;
|
||||
|
||||
await test('owner logs in', async () => {
|
||||
const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
});
|
||||
|
||||
await test('a disposable TEST offering is created with a real, small, non-zero TEST price', async () => {
|
||||
await test('a disposable TEST offering is created with the real TEST price and a cohort', async () => {
|
||||
cohortId = `stripe-it-cohort-${RUN_ID}`;
|
||||
const cohort = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: `[TEST] Stripe Integration Cohort ${RUN_ID}`, tier: 'root' });
|
||||
assert.equal(cohort.status, 201, JSON.stringify(cohort.body));
|
||||
const create = await owner.request('POST', '/api/admin/offerings', {
|
||||
offeringId, slug, name: `[TEST] Stripe Integration Check ${RUN_ID}`,
|
||||
programId: PROGRAM, deliveryMode: 'self_paced',
|
||||
priceAmount: TEST_PRICE_CENTS, priceCurrency: 'usd',
|
||||
offeringId, slug, name: `[TEST] Stripe Integration Check ${RUN_ID} — NOT the real Winter Break offering`,
|
||||
programId: PROGRAM, deliveryMode: 'cohort', cohortId,
|
||||
priceAmount: TEST_PRICE_CENTS, priceCurrency: TEST_CURRENCY,
|
||||
});
|
||||
assert.equal(create.status, 201, JSON.stringify(create.body));
|
||||
const agreement = await owner.request('POST', '/api/admin/agreements', { agreementId, title: '[TEST] Stripe Integration Waiver', bodySummary: 'Disposable test agreement — Phase 9.1.' });
|
||||
@@ -115,11 +137,14 @@ async function main() {
|
||||
assert.equal(open.status, 200);
|
||||
});
|
||||
|
||||
await test('a real registration is created through the public integration contract', async () => {
|
||||
const reg = await integrationPost('/api/integrations/registrations', {
|
||||
event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Stripe Integration Tester', email: `stripe-it-${RUN_ID}@example.com` },
|
||||
});
|
||||
await test(`a real ${TEST_MINOR ? 'guardian+minor' : 'adult'} registration is created through the public integration contract`, async () => {
|
||||
const body = TEST_MINOR
|
||||
? { event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Stripe IT Test Camper', is_minor: true },
|
||||
guardian: { display_name: 'Stripe IT Test Guardian', email: `stripe-it-guardian-${RUN_ID}@example.com`, phone: '555-0199' } }
|
||||
: { event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Stripe Integration Tester', email: `stripe-it-${RUN_ID}@example.com` } };
|
||||
const reg = await integrationPost('/api/integrations/registrations', body);
|
||||
assert.equal(reg.status, 201, JSON.stringify(reg.body));
|
||||
registrationId = reg.body.registration_id;
|
||||
});
|
||||
@@ -154,8 +179,9 @@ async function main() {
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
assert.equal(session.mode, 'payment');
|
||||
assert.equal(session.amount_total, TEST_PRICE_CENTS, 'Stripe must have recorded the OFFERING price, never the tampered caller amount');
|
||||
assert.equal(session.currency, 'usd', 'Stripe must have recorded the OFFERING currency, never the tampered caller currency');
|
||||
assert.equal(session.currency, TEST_CURRENCY, 'Stripe must have recorded the OFFERING currency, never the tampered caller currency');
|
||||
assert.equal(session.livemode, false, 'this must be a TEST-mode session — refusing to proceed otherwise');
|
||||
console.log(` Stripe session ${sessionId}: amount_total=${session.amount_total} ${session.currency}, livemode=${session.livemode}`);
|
||||
});
|
||||
|
||||
await test('an invalid webhook signature is rejected with no domain side effect', async () => {
|
||||
@@ -188,13 +214,139 @@ async function main() {
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
let realEventId = null, realPaymentIntentId = null, enrollmentId = null, studentId = null;
|
||||
|
||||
if (finalStatus) {
|
||||
console.log(` ok — a REAL Stripe TEST payment was completed and confirmed by a REAL signed webhook: ${JSON.stringify(finalStatus)}`);
|
||||
passed++;
|
||||
|
||||
await test('exactly one payment_transactions record, paid, reconciled against Stripe — no card data anywhere', async () => {
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||
assert.equal(tx.body.length, 1, `expected exactly one transaction, got ${tx.body.length}`);
|
||||
const t = tx.body[0];
|
||||
assert.equal(t.status, 'paid');
|
||||
assert.equal(t.provider_reference, sessionId);
|
||||
for (const forbidden of ['card_number', 'cvv', 'card_last4', 'payment_method_details']) assert.ok(!(forbidden in t));
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
realPaymentIntentId = session.payment_intent;
|
||||
assert.ok(realPaymentIntentId, 'a paid session must have a real PaymentIntent');
|
||||
realEventId = psql(`SELECT webhook_event_id FROM payment_webhook_events WHERE provider = 'stripe' AND transaction_id = (SELECT transaction_id FROM payment_transactions WHERE registration_id = '${registrationId}') ORDER BY received_at DESC LIMIT 1`);
|
||||
assert.ok(realEventId, 'the real Stripe event id must be recorded');
|
||||
});
|
||||
|
||||
await test('registration confirmed, enrollment converted exactly once, cohort membership created, guardian preserved', async () => {
|
||||
const reg = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||
assert.equal(reg.body.status, 'enrollment_activated');
|
||||
enrollmentId = reg.body.enrollment_id;
|
||||
studentId = reg.body.student_id;
|
||||
const enrollmentCount = psql(`SELECT count(*) FROM enrollments WHERE student_id = '${studentId}' AND program_id = '${PROGRAM}'`);
|
||||
assert.equal(enrollmentCount.trim(), '1', 'exactly one enrollment must exist for this student/program');
|
||||
const cohortCount = psql(`SELECT count(*) FROM cohort_enrollments WHERE cohort_id = '${cohortId}' AND student_id = '${studentId}'`);
|
||||
assert.equal(cohortCount.trim(), '1', 'exactly one cohort membership must exist');
|
||||
if (TEST_MINOR) {
|
||||
const guardianLink = psql(`SELECT count(*) FROM student_guardians WHERE student_id = '${studentId}'`);
|
||||
assert.equal(guardianLink.trim(), '1', 'the minor student must have exactly one real guardian relationship');
|
||||
const studentEmail = psql(`SELECT email FROM students WHERE student_id = '${studentId}'`);
|
||||
assert.equal(studentEmail.trim(), '', 'the guardian email must never be written to the minor student\'s own identity record');
|
||||
}
|
||||
});
|
||||
|
||||
await test('a REAL, Stripe-issued duplicate delivery of the same event is idempotent — no double conversion', async () => {
|
||||
// Fetches the REAL event Stripe recorded (not fabricated) and re-signs it with the REAL
|
||||
// webhook secret to prove a genuine Stripe retry-delivery scenario is handled correctly —
|
||||
// never inventing a "payment succeeded" event for a session nobody paid (that would be
|
||||
// exactly the faked validation this phase was told never to produce). This mirrors what
|
||||
// `stripe events resend` does server-side; done locally here because a `stripe listen`
|
||||
// ephemeral forwarding session has no persistent dashboard endpoint id to target.
|
||||
const realEvent = await stripe.events.retrieve(realEventId);
|
||||
const payload = JSON.stringify(realEvent);
|
||||
const header = stripe.webhooks.generateTestHeaderString({ payload, secret: process.env.STRIPE_WEBHOOK_SECRET });
|
||||
const replay = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Stripe-Signature': header }, body: payload });
|
||||
const replayBody = await replay.json();
|
||||
assert.equal(replay.status, 200);
|
||||
assert.equal(replayBody.result, 'duplicate');
|
||||
const enrollmentCount = psql(`SELECT count(*) FROM enrollments WHERE student_id = '${studentId}' AND program_id = '${PROGRAM}'`);
|
||||
assert.equal(enrollmentCount.trim(), '1', 'a duplicate delivery must never create a second enrollment');
|
||||
const notifCount = psql(`SELECT count(*) FROM notification_events WHERE enrollment_id = '${enrollmentId}' AND notification_type IN ('activation','enrollment_confirmation')`);
|
||||
assert.equal(notifCount.trim(), '1', 'a duplicate delivery must never queue a second activation/confirmation email');
|
||||
});
|
||||
|
||||
await test('student access: the real activation token works and the enrolled program is visible', async () => {
|
||||
require('child_process').execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE notification_events SET next_attempt_at = now() WHERE status = 'queued'"`, { cwd: `${__dirname}/..`, stdio: 'pipe' });
|
||||
await sleep(3000);
|
||||
const notifs = await owner.request('GET', `/api/admin/registrations/${registrationId}/notifications`);
|
||||
const activationNotif = notifs.body.find((n) => n.notification_type === 'activation' || n.notification_type === 'enrollment_confirmation');
|
||||
assert.ok(activationNotif, `expected a real activation/confirmation notification, got: ${JSON.stringify(notifs.body)}`);
|
||||
if (activationNotif.notification_type === 'activation') {
|
||||
const preview = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`);
|
||||
const token = preview.body.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
|
||||
const s = makeSession(GW);
|
||||
const activate = await s.request('POST', '/api/student/activate', { token, password: 'StripeIT!2026Test' });
|
||||
assert.equal(activate.status, 200, JSON.stringify(activate.body));
|
||||
const programs = await s.request('GET', '/api/student/programs');
|
||||
assert.ok(programs.body.some((p) => p.program_id === PROGRAM), 'the enrolled program must be visible to the newly-activated student');
|
||||
}
|
||||
});
|
||||
|
||||
await test('a Stripe TEST refund is recorded without cascading into registration/enrollment status', async () => {
|
||||
const refund = await stripe.refunds.create({ payment_intent: realPaymentIntentId });
|
||||
assert.equal(refund.status, 'succeeded');
|
||||
// Give the (not-yet-forwarded) refund webhook a moment if stripe listen also relays
|
||||
// charge.refunded; either way, prove RootNode's OWN non-cascade rule holds regardless —
|
||||
// record it via the admin route directly if the webhook hasn't landed yet.
|
||||
await sleep(2000);
|
||||
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
|
||||
const t = tx.body[0];
|
||||
if (t.status !== 'refunded') {
|
||||
const transactionId = psql(`SELECT transaction_id FROM payment_transactions WHERE registration_id = '${registrationId}'`).trim();
|
||||
await owner.request('POST', `/api/admin/payments/${transactionId}/refund`, {});
|
||||
}
|
||||
const regAfter = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
|
||||
assert.equal(regAfter.body.status, 'enrollment_activated', 'a refund must never auto-cancel the registration');
|
||||
const enrollmentAfter = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${enrollmentId}'`);
|
||||
assert.equal(enrollmentAfter.trim(), 'active', 'a refund must never auto-revoke the enrollment — this is a business-policy question, not something to decide in code');
|
||||
console.log(` Stripe refund ${refund.id}: status=${refund.status}, amount=${refund.amount} ${refund.currency}`);
|
||||
});
|
||||
|
||||
console.log(`\n${'─'.repeat(78)}`);
|
||||
console.log('Stripe <-> RootNode reconciliation for this run:');
|
||||
console.log(` RootNode registration: ${registrationId}`);
|
||||
console.log(` RootNode enrollment: ${enrollmentId}`);
|
||||
console.log(` RootNode student: ${studentId}`);
|
||||
console.log(` Stripe Checkout Session: ${sessionId}`);
|
||||
console.log(` Stripe PaymentIntent: ${realPaymentIntentId}`);
|
||||
console.log(` Stripe event (webhook): ${realEventId}`);
|
||||
console.log(` Amount / currency: ${TEST_PRICE_CENTS} ${TEST_CURRENCY}`);
|
||||
} else {
|
||||
console.log(' (not completed within the wait window — this is expected in an unattended/CI run; rerun interactively to complete the manual step, or increase STRIPE_INTEGRATION_TEST_WAIT_SECONDS)');
|
||||
console.log(' (not completed within the wait window — go complete the checkout in a browser and rerun, or increase STRIPE_INTEGRATION_TEST_WAIT_SECONDS)');
|
||||
}
|
||||
|
||||
await test('abandoned/expired checkout (pure API, no browser needed): the seat is released exactly once', async () => {
|
||||
const abReg = await integrationPost('/api/integrations/registrations', {
|
||||
event_id: `stripe-it-abandon-${RUN_ID}`, offering_slug: slug,
|
||||
registrant: { display_name: 'Stripe IT Abandon Tester', email: `stripe-it-abandon-${RUN_ID}@example.com` },
|
||||
});
|
||||
assert.equal(abReg.status, 201, JSON.stringify(abReg.body));
|
||||
const abAccept = await integrationPost(`/api/integrations/registrations/${abReg.body.registration_id}/agreements`, { agreement_id: agreementId });
|
||||
assert.equal(abAccept.status, 201);
|
||||
const abCheckout = await integrationPost(`/api/integrations/registrations/${abReg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
|
||||
assert.equal(abCheckout.status, 201, JSON.stringify(abCheckout.body));
|
||||
const abTx = await owner.request('GET', `/api/admin/registrations/${abReg.body.registration_id}/payments`);
|
||||
const abSessionId = abTx.body[0].provider_reference;
|
||||
// A REAL Stripe API call — no browser needed — asking Stripe to expire an open session,
|
||||
// which makes Stripe itself fire a genuine checkout.session.expired webhook.
|
||||
await stripe.checkout.sessions.expire(abSessionId);
|
||||
const deadline2 = Date.now() + 30000;
|
||||
let released = false;
|
||||
while (Date.now() < deadline2) {
|
||||
const r = await fetch(`${GW}/api/integrations/registrations/${abReg.body.registration_id}/status`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } });
|
||||
const s = await r.json();
|
||||
if (s.status === 'cancelled') { released = true; break; }
|
||||
await sleep(2000);
|
||||
}
|
||||
assert.ok(released, 'the abandoned/expired checkout must release the seat within 30s of the real Stripe webhook');
|
||||
});
|
||||
|
||||
// Best-effort cleanup — never fatal.
|
||||
try { await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'cancelled' }); } catch { /* ponytail: best-effort cleanup only */ }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user