Stripe payment automation strategy¶
How to automate Raklet's Stripe card surfaces in browse-tests and QA. Scope: Stripe only (Iyzico is out of scope here). Companion to payment-sandbox-fixtures.md, which covers the env-var fixture contract.
TL;DR¶
- Raklet has three Stripe card surfaces, not two. Embedded Checkout (a cross-origin iframe) is now the default for donations, event tickets, and application/signup card-setup; membership invoice pay uses Hosted Checkout (full-page redirect); the legacy self-hosted plain-input card form is a fallback that only renders when Embedded card-setup is unavailable.
- The gstack
browseCLI already drives cross-origin iframes via the shippedframecommand (v1.58+). Embedded Checkout is automatable today — it was never a hard wall. The driver lives inbrowse-tests/helpers/stripe-embedded-checkout.ps1. - Split automation by purpose: state-setup (get a member subscribed for gated-page QA), UI coverage (drive the actual card surface in the browser), and lifecycle (renewals/dunning/balance) — the last via Stripe test-mode triggers and test clocks, with no browser at all.
The surface model¶
Stripe card entry in Raklet renders one of three surfaces. Which one you get is decided server-side and matters for how (and whether) you can automate it.
1. Stripe Embedded Checkout — cross-origin iframe¶
stripe.initEmbeddedCheckout({ clientSecret }).mount('#embedded-checkout') injects
an iframe served from js.stripe.com → checkout.stripe.com. The card fields
live inside that cross-origin iframe.
Used by (each behind a Web.config feature flag, read by
EmbeddedCheckoutService):
| Flow | Flag (AppSettings) |
View |
|---|---|---|
| Public donation | UseEmbeddedCheckoutForDonationPayments |
Checkout/Embedded.cshtml |
| Event ticket purchase | UseEmbeddedCheckoutForEventPayments |
Checkout/Embedded.cshtml |
| Application / signup card setup | UseEmbeddedCheckoutForApplicationCardSetup |
Apply/CreditCardForm.cshtml (useEmbeddedCardSetup branch) |
Card-setup additionally requires the org's processor to be
StripeWithDirectCharge and a SubMerchantId to be present
(EmbeddedCheckoutService.ResolveApplicationCardSetupContextAsync). All three
flags are true in this worktree's Application/Web.config, so on the local
gercek.raklet.org org these surfaces all render Embedded Checkout.
On completion Stripe redirects the top page (redirectOnCompletion) to the
flow's return URL — e.g. /checkout/embedded-status?...&session_id=... for
donations/events, or the apply/signup success URL for card setup.
2. Stripe Hosted Checkout — full-page redirect¶
The membership invoice-pay path
(CheckoutController.PayStripe)
builds a checkout session and Redirects the whole browser to
checkout.stripe.com. There is no iframe — you are on Stripe's page directly.
(CheckoutController.Embedded explicitly disables embedded membership checkout:
"Embedded membership checkout is not enabled yet.")
Importantly, Hosted Checkout and Embedded Checkout render the same checkout.stripe.com app — identical field selectors. The only difference is whether you must step into an iframe first. That is why the driver shares one field-fill routine across both.
3. Legacy self-hosted plain inputs¶
Apply/CreditCardForm.cshtml with useEmbeddedCardSetup == false renders plain
inputs on Raklet's own page: #CardNumber, #Name, #ExpireMonth,
#ExpireYear, #SecurityCode. This is the fallback when the card-setup flag is
off, the processor is a destination-charge mode (Standard/Custom Destination),
or no SubMerchantId exists. Drivable with ordinary browse fill (no frame).
Stripe account topology (don't verify against the wrong account)¶
Two distinct Stripe accounts are in play — using the wrong one is why a member
payment can look "missing". Both have keys in Application/Web.config:
| Account | Web.config keys | Account id | Scope |
|---|---|---|---|
| Raklet Connect (platform) | Stripe-Key (secret), Stripe-Connect-Client-Id |
StripeConnectAccountId = acct_16bClIDYzEqtsMpt |
Customer-facing money. Each org is a connected account under it (e.g. gercek → acct_1THkz6Rg9NqcFqyX). Member payments, donations, event tickets, application card-setup all live here. |
| Raklet Subscriptions | Subscription-Stripe-Secret-Key, Subscription-Stripe-Public-Key |
StripeSubscriptionAccountId = acct_189HZ3FQKuf4yICm |
Bills customers for their Raklet plan. Does not see members' payments to a customer org. |
To verify a member/org payment, query the Raklet Connect platform key with the org's connected-account header — not Subscriptions, and not the platform key alone:
# read the org's connected-account checkout session (TEST keys; values from Web.config)
curl -s "https://api.stripe.com/v1/checkout/sessions/$SID?expand[]=setup_intent" \
-u "$STRIPE_KEY:" -H "Stripe-Account: acct_1THkz6Rg9NqcFqyX"
The platform key alone (no header) returns No such checkout.session — the
session lives on the connected account, reachable only with the Stripe-Account
header. The Subscriptions key has no access to Connect connected accounts at all.
The workspace Stripe MCP is logged into Subscriptions, so it cannot read these
sessions — use the REST call above (or the Raklet Connect dashboard).
Verified 2026-06-13: the browse-frame-driven application card-setup on
gercekreturnedstatus=complete, mode=setup, setup_intent.status=succeededonacct_1THkz6…— confirming the Embedded Checkout driver completes the flow end-to-end even though the daemon can't observe the in-browser redirect.
The three automation levers¶
(a) Self-hosted form — drivable now, no special tooling¶
Plain browse fill on #CardNumber etc. This is the original
Fill-ApplicationFormStripeCard path. Still correct wherever Embedded
card-setup is unavailable.
(b) Embedded Checkout — browse frame (shipped) drives the iframe¶
The earlier assumption that "browse has no frame command, so the iframe is
unreachable" is out of date. browse (v1.58+) ships a frame command
(SKILL.md references it as
frame <sel|@ref|--name n|--url pattern|main>), and fill/click/js/forms
operate on the active frame via getActiveFrameOrPage(). Recipe:
# 1. Land on the page that mounts Embedded Checkout (donation/event/card-setup).
# 2. Step into the Stripe iframe (page.frame({url}) finds it at any depth):
browse frame --url checkout.stripe.com # fallback: --url js.stripe.com
# 3. Fill — these now run INSIDE the iframe:
browse fill input#cardNumber 4242424242424242
browse fill input#cardExpiry "12 / 34"
browse fill input#cardCvc 123
browse fill input#billingName "Raklet Test Member"
# 4. Submit, then return to the top document to read the result URL:
browse click "button[data-testid='hosted-payment-submit-button']"
browse frame main
browse url # expect /checkout/embedded-status?...&session_id=... (or the flow return url)
Validated live on gercek.raklet.org (2026-06-13). The application
card-setup flow mounts an iframe https://js.stripe.com/v3/embedded-checkout-inner-…;
frame --url embedded-checkout-inner enters it and the card fields are plain
inputs there — #cardNumber, #cardExpiry, #cardCvc, #billingName,
#billingAddressLine1, #billingLocality, #billingPostalCode, a
select#billingCountry, and a submit button[data-testid="hosted-payment-submit-button"].
Filling and submitting through the frame works end-to-end (values read back
correctly; the real Stripe test session cs_test_… on connected account
acct_1THkz6… accepts the submit).
Two gotchas the live run surfaced: 1. Target the inner frame, not
js.stripe.com. A live mount creates severaljs.stripe.comframes (m-outer metrics, controller, hcaptcha, express-checkout).page.frame({url:/js.stripe.com/})returns the first (empty m-outer) frame. Useembedded-checkout-inner. 2. The completion redirect is not observable in-session. On success Stripe driveswindow.top.location = returnUrlfrom inside the iframe; the browse daemon loses the page and reports a sustainedabout:blankinstead of following it. Verify completion server-side (admin applications/members, or the connected Stripe account) or re-navigate to the flow's status/gated page with a freshgoto(which recovers the daemon). The card data does submit — this is a daemon limitation on iframe-initiated top navigation, not a payment failure. Also fill the billing address (line1/city/postal): Stripe Checkout makes them required for many accounts, and a card-only fill leaves the Save button inert.
This is wrapped, with waits / selector fallbacks / a discovery probe, in
stripe-embedded-checkout.ps1:
Complete-EmbeddedCheckoutPayment— wait for mount → enter frame → fill → submit → wait for the return URL. Pass-DumpInventoryto print the live field inventory (handy when Stripe revises its DOM).Complete-HostedCheckoutPayment— same fill/submit, no frame step.Get-StripeCheckoutFrameInventory— dumps inputs/buttons in the active frame for selector confirmation.
Fill-ApplicationFormStripeCard now auto-detects legacy vs embedded
card-setup (presence of #CardNumber) and routes accordingly, so the
application-form payment tests survive the
UseEmbeddedCheckoutForApplicationCardSetup flip on the StripeWithDirectCharge
orgs.
Note for gstack: the only enhancement that would still help is a
browse framescommand that dumpspage.frames()(url + name) so deeply-nested field iframes can be discovered without guessing a URL pattern.frame --urlcovers Stripe Checkout today (the card fields are plain inputs in the checkout.stripe.com frame, not per-field sub-iframes), so this is a nice-to-have, not a blocker.
3DS / SCA¶
The published test card 4242 4242 4242 4242 does not trigger 3DS in test
mode, so there is no nested authentication iframe to drive. Cards that force SCA
(e.g. 4000 0025 0000 3155) open an additional challenge frame — out of scope
for the happy-path regression suite; cover those via the lifecycle lever below.
(c) Payment lifecycle — Stripe test mode, no browser¶
Renewals, dunning/retries, refunds, disputes, and balance changes are driven by Stripe server-side events, not the card form. Automate them without a browser:
stripe trigger <event>(Stripe CLI) fires representative test events (e.g.invoice.payment_failed,invoice.paid,customer.subscription.deleted) at the webhook endpoint, exercising Raklet's webhook handlers directly.- Test clocks (
stripe test_helpers test_clocks ...) advance time for a test customer so a subscription renews / a dunning cycle progresses on demand instead of waiting real days. - Combined with the Stripe MCP / API (
stripe_api_*tools) to read back the resulting subscription/invoice state.
Forward webhooks to the local app with stripe listen --forward-to
https://gercek.raklet.org/<webhook-path> during a session.
Recommended split by test purpose¶
| Purpose | Tool | What it covers | What it must NOT try to do |
|---|---|---|---|
| State setup (get to a subscribed member / paid invoice so gated UI can be QA'd) | Ensure-SubscribedMember (subscribed-member.ps1) — drives the real checkout once |
Unblocks App2 gated-page QA (Fees & Payments, Members, Events, Membership dashboard) | Re-assert payment-engine correctness on every run |
| UI coverage (the card surface itself renders & accepts a card) | browse-tests payment-regression/ via the embedded/hosted/legacy drivers |
The surface a customer actually sees, end-to-end through Stripe test mode | Renewals/dunning/time-based behaviour (slow, flaky in a browser) |
| Lifecycle (renewals, dunning, refunds, balance) | Stripe CLI trigger + test clocks + API/MCP read-back |
Webhook handlers and subscription state transitions | Be driven through the browser |
Rule of thumb: drive the browser only for what a human sees in the browser. Everything time- or event-based belongs to the lifecycle lever.
Current payment-regression status (Stripe)¶
- Fixtures: Stripe sandbox orgs (
stripe-standard-destination/-custom-destination/-standard-direct) live on.raklet.net; run withBROWSE_TEST_DOMAIN=.raklet.netand theraklet-netprofile's Stripe vars (see payment-sandbox-fixtures.md).00-fixture-check.ps1reports configured vs skipped at suite start. - Known block — apply-form validation regression (RAK-349 / Linear ENG-29):
the public apply form renders raw
ModelStateerrors instead of inline field highlights. The test-side drift fix (PR #13842) merged on 2026-05-21, but the product fix is still in PR Ready and not on master as of this writing. Tests that depend on apply-form validation behaviour (auto-approve16-…, and the dup-email path of15-…) stay blocked until ENG-29's product fix merges — documented, not forced. - Chronic flakes are quarantined (run-once, non-gating) in
browse-tests/known-failures.jsonunder ENG-4 (14-contact-donations-crud,16-application-form-auto-approve-quick-register,19-application-form-credit-card-toggle). - The StripeWithDirectCharge application-form tests (
08/11/14-…-standard-direct) hit Embedded card-setup whereverUseEmbeddedCheckoutForApplicationCardSetupis on; they are now driven through the frame by the updatedFill-ApplicationFormStripeCard.
The portal-regression suite (local, real surfaces, Connect-verified)¶
browse-tests/payment-regression/ runs against the shared .raklet.net Stripe
sandbox sub-orgs and drives the legacy in-modal/in-page card forms. It does
not exercise what production actually ships (Embedded / Hosted Checkout), and
the existing donation/event helpers historically drove the dead legacy path
(Fill-DonationCard typed into the old Stripe Elements iframe; the event helper
only ever bought free/cash/at-the-door tickets — it never entered a card).
browse-tests/portal-regression/ covers the real surfaces on a local org
(gercek.raklet.org, where all three UseEmbeddedCheckout* flags are on) and
verifies each checkout server-side on Stripe Connect (the in-browser completion
redirect is not observable — the daemon goes to about:blank):
| Test | Surface | What it asserts |
|---|---|---|
01-portal-donation-embedded-stripe |
Embedded (iframe) | Member logs in → donation modal → /checkout/embedded → iframe filled/submitted → session complete + paid on Connect |
02-portal-event-ticket-embedded-stripe |
Embedded (iframe) | Member logs in → paid ticket → /checkout/embedded?flowType=Event → iframe → session complete + paid on Connect |
03-portal-self-subscribe-stripe |
Hosted (full-page) | Member logs in → subscribes (Start Membership → Proceed to Payment → checkout.stripe.com) → gated App2 page renders → session complete/paid on Connect |
Validated live on
gercek2026-06-14 — all three PASS, each verified server-side on Stripe Connect (acct_1THkz6…). Donation and event registration on this org require a logged-in member (guest/anonymous flows show a join wall and depend on the org-level "public donations" setting), so all three tests authenticate as the member. The donation amount is a Kendo NumericTextBox and the membership subscribe is a two-step confirm (Start Membership → Proceed to Payment) — both handled by the helpers.
Run it: ./browse-tests/run.ps1 -Suite portal-regression -TestDomain .raklet.org
(or via scripts/dev/pr-ready-check.ps1, which now includes the suite).
Helpers added/changed for this¶
Fill-DonationCard(fundraising-donation.ps1) now auto-detects the surface (legacy card-token select present ⇒ legacy; absent ⇒ embedded) and returns"legacy"/"embedded", mirroringFill-ApplicationFormStripeCard. The embedded path is completed by the newComplete-DonationEmbeddedCheckout(clicks submit → follows/checkout/embedded→ scrapes the session id → fills + submits the iframe), returning thecs_…session id for verification.Complete-EventTicketPaymentForm(events.ps1) now auto-detects the/checkout/embeddedredirect after#paymentBtnand drives the iframe for paid tickets; the session id is exposed viaGet-LastEventCheckoutSessionId.Invoke-PublicEventTicketPurchasedrives a public event URL end-to-end.Invoke-MemberSubscribeCheckout(subscribed-member.ps1) now captures the session id (from thecheckout.stripe.comURL for Hosted, or the page for Embedded), exposed viaGet-LastSubscribeCheckoutSessionId.Get-EmbeddedCheckoutSessionId(stripe-embedded-checkout.ps1) scrapes thecs_…session id from the mounted embedded page before submit.
Server-side verification — stripe-connect-verify.ps1¶
Because completion isn't observable in-session, the suite asserts success by querying Stripe directly:
Get-StripePlatformKey— reads the Connect platform secret (Stripe-Key) fromApplication/Web.config, orBROWSE_TEST_STRIPE_PLATFORM_KEY(so CI can inject it as a secret without shipping Web.config).Get-StripeConnectedAccount— the org's connected-account id fromBROWSE_TEST_STRIPE_CONNECTED_ACCOUNT(defaults to gercek'sacct_1THkz6…).Assert-StripeCheckoutCompleted -SessionId <cs_…>(or-CreatedAfterUnixto pick the most recent session this run) —GET /v1/checkout/sessions/...with theStripe-Accountheader; throws unlessstatus == completeand the payment ispaid/no_payment_requiredor it's a succeeded setup.Test-StripeConnectVerifyAvailable— the capability check the suite's skip-gate (Use-LocalPortalStripeFixture,portal-fixtures.ps1) uses, so the tests SKIP cleanly on any runner without Connect access instead of failing.
Fixtures / what CI needs¶
Per-engineer fixtures live in browse-tests/local.settings.json (see
local.settings.example.json → the _optional_portal_regression block):
memberPassword, stripeConnectedAccount, portalDonationCampaignId,
portalEventId, portalSubscribeUrl, portalGatedUrl. The platform key is read
from Web.config automatically.
To run this in CI (e.g. on the self-hosted ci-vm-1 against .raklet.org, which
deploys this branch's Web.config with the embedded flags on), the runner needs the
Stripe Connect platform secret as a BROWSE_TEST_STRIPE_PLATFORM_KEY GitHub secret
and the org's connected-account id + the per-test fixture ids seeded on that
org. Until those are wired the suite SKIPs, so it can be added to a CI workflow
safely ahead of provisioning. CI wiring is a deliberate follow-up (decide:
.raklet.net with Connect keys vs the local .raklet.org pr-ci job).