docs(18-05): complete Stripe operator plugin plan

This commit is contained in:
2026-10-13 14:58:24 +02:00
parent 7252cfd3cd
commit 623876a166

View File

@@ -0,0 +1,324 @@
---
phase: 18-payment-gateway
plan: 05
subsystem: payments
tags: [pgstripe, stripe, stripe-go, checkout, paymentintent, webhook, encrypted-settings, import-winter, testcontainers, proof-host]
requires:
- phase: 18-01
provides: cabana write-only password fields on lagoon.Encrypted settings columns with SecretFallbacks, settings tabs
- phase: 18-02
provides: operators.Operator and HasOperators, payments and orders services, the notification route, RecordExternal, money.Minor
- phase: 18-04
provides: method-backed dropdowns on settings models, the OrderPaid customer mail and events.MailSender
provides:
- new repository ../sm-pgstripe-plugin (module git.golem15.com/golem15/sm-pgstripe-plugin, package pgstripe, plugin ID golem15.pgstripe, branch main, no remote)
- the Stripe operator (code golem15.pgstripe::lang.operators.stripe) on stripe-go v87.0.0 with Checkout (default) and PaymentIntent modes, per-order override, refunds
- signature-only webhook verification (webhook.ValidatePayload) with a minimal local event decode and the five-event table
- the browser return (GET ?pgUuid=) that re-checks the payment at Stripe and always redirects 303 to the stored return URL
- golem15_pgstripe_settings with four encrypted write-only secrets, SecretFallbacks to golem15.pgstripe.* config keys, the P10 settings page
- pgstripe:import-winter with Laravel decrypt and scripts/winter-export-settings.sql
- the D-03 proof host TestHostStripeFlow (14 subtests) with an httptest Stripe fake and the acme.books fixture plugin
- paymentgateway operators.ErrNotificationIgnored (an authentic notification for no payment of this application answers 200)
affects: [18-06 site integration and customer API, 18-07 unit tests and phase gate, 18.1 subscriptions]
actuals:
tokens: 46400
tasks: 3
commits: 0
plan_head_before: 7252cfd5f1b1fcd02f6b6b8cfc60bd13b5f9ea4d
plan_head_after: 7252cfd5f1b1fcd02f6b6b8cfc60bd13b5f9ea4d
tech-stack:
added:
- "github.com/stripe/stripe-go/v87 v87.0.0 (decision D-12; shopspring/decimal v1.4.0 transitive and unused)"
patterns:
- "Stripe client per call (stripe.NewClient with the active mode's secret); tests publish *pgstripe.BackendOverride before activation so every client talks to an httptest fake"
- "Signature-only webhook verification plus a minimal local event struct: the endpoint's API version is irrelevant"
- "Unsigned browser return never trusts input: retrieve the stored Checkout Session or PaymentIntent server-side, accept only on Stripe's word, redirect to the stored return URL"
- "Authentic notifications for no payment of this application answer 200 ignored (operators.ErrNotificationIgnored) so Stripe does not retry"
- "Proof host inside the operator plugin's repository with a test-only consumer plugin and a production-source guard"
key-files:
created:
- ../sm-pgstripe-plugin/plugin.go
- ../sm-pgstripe-plugin/admin.go
- ../sm-pgstripe-plugin/client.go
- ../sm-pgstripe-plugin/operator.go
- ../sm-pgstripe-plugin/webhook.go
- ../sm-pgstripe-plugin/browser_return.go
- ../sm-pgstripe-plugin/models/settings.go
- ../sm-pgstripe-plugin/models/settings/fields.yaml
- ../sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go
- ../sm-pgstripe-plugin/console/import_winter.go
- ../sm-pgstripe-plugin/scripts/winter-export-settings.sql
- ../sm-pgstripe-plugin/config/config.yaml
- ../sm-pgstripe-plugin/lang/en/lang.yaml
- ../sm-pgstripe-plugin/lang/pl/lang.yaml
- ../sm-pgstripe-plugin/README.md
- ../sm-pgstripe-plugin/host_test.go
- ../sm-pgstripe-plugin/stripefake_test.go
- ../sm-pgstripe-plugin/fixture_plugin_test.go
- ../sm-pgstripe-plugin/webhook_test.go
- ../sm-pgstripe-plugin/settings_smoke_test.go
- ../sm-pgstripe-plugin/testmain_test.go
modified:
- ../sm-paymentgateway-plugin/operators/operator.go
- ../sm-paymentgateway-plugin/payments/notification.go
- ../sm-paymentgateway-plugin/notification_route_test.go
- ../sm-paymentgateway-plugin/README.md
key-decisions:
- "Mode selection: checkout_mode setting (default checkout) with the per-order Details parameter stripe_checkout_mode; an unknown override falls back to the setting"
- "Checkout success_url is the operator's browser return (re-checks at Stripe); cancel_url is the order's return URL"
- "PaymentIntent created without a payment method answers the requires_action bundle also for requires_payment_method (the site confirms with Stripe.js); only a confirmed payment method that ends in requires_payment_method is a decline (Send plus Reject)"
- "Stripe operator RequiredFields is empty, as PHP: the payment service reads RequiredFields as Details parameters and the e-mail is a Details field"
- "Stripe amounts: Minor() for two-decimal currencies, whole units for Stripe's zero-decimal currencies and thousandths for the three-decimal ones (MoneyRight treats every currency as two-decimal)"
- "Verified events without a payment uuid, or naming no Stripe payment of this application, answer 200 ignored through the new paymentgateway sentinel operators.ErrNotificationIgnored"
- "Env fallback for all four secrets (golem15.pgstripe.<column>); pgstripe:import-winter keeps stored secrets unless --force"
patterns-established:
- "Operator plugin repository layout: plugin.go (HasOperators), operator.go, webhook.go, client.go with BackendOverride, models/settings.go with SecretFallbacks, console/import_winter.go"
- "Proof host: bootHost publishes BackendOverride and an events.MailSender recorder before party.Activate, activates user + paymentgateway + operator + fixture"
requirements-completed: [SC-2, SC-4, D-02, D-03, D-04, D-12, D-13, D-14, D-16, D-17]
coverage:
- id: D1
description: "Checkout operator path (D-04 a, D-16): Make, Purchase to a Checkout Session for the server-side total, signed checkout.session.completed, payment Accepted, order Paid with successful_payment_id, one OrderPaid, one paid mail, replay changes nothing"
requirement: "SC-4"
verification:
- kind: integration
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/checkout_operator_path (Docker, run and passed)"
status: pass
human_judgment: false
- id: D2
description: "PaymentIntent mode (D-17): requires_action bundle with client secret, intent id, test publishable key and payment uuid; payment_intent.succeeded pays the order; confirmed card completes at once; decline rejects; processing stays Sent; per-order override both ways"
requirement: "D-17"
verification:
- kind: integration
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/payment_intent_operator_path, /payment_intent_confirmed_and_declined, /per_order_override (Docker, run and passed)"
status: pass
human_judgment: false
- id: D3
description: "Consumer-driven path (D-04 b): the fixture's own Checkout Session and own signed webhook record the payment through RecordExternal, Send and Accept in one transaction; order Paid, one OrderPaid, one mail; replay no-op; the shop's event on the operator route is ignored with 200"
requirement: "D-04"
verification:
- kind: integration
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/consumer_driven_path_b (Docker, run and passed)"
status: pass
human_judgment: false
- id: D4
description: "Webhook refusals and robustness (D-14, F-3, F-4): wrong secret, live secret in test mode, missing header (logged), missing secret (logged) and env fallback, older api_version accepted, eight concurrent deliveries give one Accept, full event table, ignored events"
requirement: "D-14"
verification:
- kind: unit
ref: "../sm-pgstripe-plugin/webhook_test.go#TestStripeWebhook (11 subtests)"
status: pass
- kind: integration
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/wrong_secret, /missing_header, /missing_secret, /older_api_version, /replay_and_race, /event_table (Docker, run and passed)"
status: pass
human_judgment: false
- id: D5
description: "Browser return never trusts input: unpaid at Stripe leaves the payment Sent, paid at Stripe accepts it, always 303 to the stored return URL (request return_url ignored), unknown or undecodable pgUuid 404; refund by the payment intent ends Refunded and the order follows"
requirement: "D-14"
verification:
- kind: integration
ref: "../sm-pgstripe-plugin/host_test.go#TestHostStripeFlow/browser_return, /refund, /no_secret_key (Docker, run and passed)"
status: pass
human_judgment: false
- id: D6
description: "Stripe settings (D-13, UI-SPEC P10): three tabs, four password fields, secrets never in a response, ciphertext column, keep on empty string, clear on null, env state, 422 on an unknown checkout mode, 403 without access_settings"
requirement: "D-13"
verification:
- kind: integration
ref: "../sm-pgstripe-plugin/settings_smoke_test.go#TestStripeSettingsSmoke (Docker, run and passed)"
status: pass
human_judgment: false
- id: D7
description: "pgstripe:import-winter: Laravel-encrypted and plaintext secrets land decryptable through ModeKeys, test mode and the operator switch mapped, dry run writes nothing and masks secrets, missing --app-key refused naming the key, stored secrets kept unless --force"
requirement: "D-13"
verification:
- kind: integration
ref: "../sm-pgstripe-plugin/settings_smoke_test.go#TestStripeImportWinterSmoke (Docker, run and passed)"
status: pass
human_judgment: false
- id: D8
description: "Stripe settings page rendering in the admin SPA (tab chips, the F2 status lines, the wrapped webhook comment)"
requirement: "D-13"
verification: []
human_judgment: true
rationale: "The page renders through the existing SettingsFormView; no browser test covers this plugin's form, so the visual check of P10 needs a human"
duration: 20min
completed: 2026-10-13
status: complete
---
# Phase 18 Plan 05: Stripe operator plugin Summary
**New `sm-pgstripe-plugin`: the Stripe operator on stripe-go v87.0.0 with hosted Checkout (default) and PaymentIntent modes, signature-only webhooks that accept any API release train, a browser return that re-checks at Stripe, refunds, encrypted write-only settings with env fallback, `pgstripe:import-winter` with Laravel decrypt, and the proof host that pays a consumer plugin's order through both the operator and the consumer-driven paths on Postgres with a fake Stripe.**
## Performance
- **Duration:** about 20 minutes (started 2026-10-13T12:37:17Z, last task commit about 12:56Z)
- **Tasks:** 3 of 3 (Task 1 tracer, gate auto-continued: interactive run, `human_verify_mode` end-of-phase, automated-only verify re-run green)
- **Files:** 27 tracked files in the new plugin repository (go.sum included); 4 files in `sm-paymentgateway-plugin`
## Commits
Plugin repository `../sm-pgstripe-plugin` (new, branch `main`, no remote, nothing pushed); measured `git rev-list --count` from the root = 3:
| Task | Commit | Subject |
|------|--------|---------|
| 1 | ed4745f | feat(18-05): Stripe operator tracer: hosted Checkout payment accepted from a signed event on the proof host (task 1) |
| 2 | 5b268d4 | feat(18-05): PaymentIntent mode, the full event table, refunds, the safe browser return and the complete proof host (task 2) |
| 3 | a8bc0b5 | feat(18-05): pgstripe:import-winter with Laravel decrypt, the settings smoke and the README with the dashboard steps (task 3) |
Repository `../sm-paymentgateway-plugin` (branch `main`, no remote, nothing pushed); measured `git rev-list --count 4b4b7c7..e4fbd3b` = 1:
| Task | Commit | Subject |
|------|--------|---------|
| 2 | e4fbd3b | feat(18-05): ErrNotificationIgnored: an authentic notification for no payment of this application answers 200 without a change |
Framework repository `summercms.go`: no code change (`commits: 0` measured from `7252cfd`); this SUMMARY and the state updates only.
## Accomplishments
- **Task 1 (tracer):** repository, `go.mod` (stripe-go v87.0.0, summercms v0.1.24 with replace, the gateway v0.0.0 placeholder with replace; sm-user-plugin v0.1.1 resolves as indirect for the tests), `go.work`, the settings singleton and migration, the P10 `fields.yaml` (four `type: password`), en/pl lang, `config/config.yaml` with the four fallback keys, `ModeKeys`, `ClientFactory`/`NewClient`/`BackendOverride`, the operator in Checkout mode, `VerifySignature`, `DecodeEvent`/`MinimalEvent`, the plugin wiring, the testcontainers TestMain, the Stripe fake, the `acme.books` fixture with `TestFixtureAbsentFromProduction`, and `TestHostStripeFlow/checkout_operator_path`.
- **Task 2:** PaymentIntent mode and its status map, the per-order override, refunds, the full event table, the browser return (`browser_return.go`), ignored events, `TestStripeWebhook` and thirteen more `TestHostStripeFlow` subtests including the consumer-driven path (b) through a fixture route with its own signing secret; the gateway's `operators.ErrNotificationIgnored`.
- **Task 3:** `pgstripe:import-winter` (`console/import_winter.go`), `scripts/winter-export-settings.sql`, the README (Overview, Features with the response bundles, Usage with the dashboard steps and test cards, API reference, Configuration, CLI commands, Cutover, Dependencies, Testing), `TestStripeSettingsSmoke` and `TestStripeImportWinterSmoke`.
## TestHostStripeFlow subtests (all RUN and PASS on this host, Docker available)
| Subtest | Result |
|---------|--------|
| checkout_operator_path | PASS |
| payment_intent_operator_path | PASS |
| payment_intent_confirmed_and_declined | PASS |
| per_order_override | PASS |
| consumer_driven_path_b | PASS |
| wrong_secret | PASS |
| missing_header | PASS |
| missing_secret | PASS |
| older_api_version | PASS |
| replay_and_race | PASS |
| browser_return | PASS |
| refund | PASS |
| event_table | PASS |
| no_secret_key | PASS |
Also PASS: `TestStripeWebhook` (11 subtests, no Docker), `TestFixtureAbsentFromProduction`, `TestStripeSettingsSmoke`, `TestStripeImportWinterSmoke`; full `go test -count=1 ./...` in the plugin green; full `go test -count=1 ./...` in `sm-paymentgateway-plugin` green after the route change. None recorded NOT RUN.
## Process response shapes as implemented (for plan 06)
`payments.Service.Purchase` returns the operator's `operators.Response` (JSON tags from the gateway):
- **Checkout** (default): `{"status":"pending","redirect_url":"<session.url>"}`; payment Sent, order AwaitingPayment; `payments.data.stripe_checkout_session_id` (and `payment_intent_id` when Stripe reports one).
- **PaymentIntent, customer step** (no `payment_method_id`, or Stripe `requires_action`): `{"status":"requires_action","client_secret":"pi_..._secret_...","payment_intent_id":"pi_...","publishable_key":"<active mode's publishable key>","data":{"payment_uuid":"<uuid>"}}`; payment Sent; the client secret is not stored.
- **PaymentIntent confirmed and succeeded:** `{"status":"complete"}`; payment Sent then Accepted, order Paid; `is_paid_in_stripe` true and `charge_id` stored.
- **PaymentIntent processing:** `{"status":"pending"}`; payment Sent.
- **Decline or Stripe error:** `{"status":"error","message":"<Stripe message>"}`; payment Sent then Rejected. A 401 from Stripe answers "Stripe rejected the API key" (Stripe's own message names part of the key).
- **No secret key for the active mode:** `Purchase` returns `pgstripe.ErrNoSecretKey` (the caller's transaction rolls back; nothing is created).
The customer API (plan 06) passes `redirect_url` or the `requires_action` bundle through; the Details parameters `stripe_checkout_mode` and `payment_method_id` are the per-order inputs.
## Files Created/Modified
See `key-files`. Production sources of the plugin: `plugin.go`, `admin.go`, `client.go`, `operator.go`, `webhook.go`, `browser_return.go`, `models/{registry,settings}.go`, `models/settings/fields.yaml`, `updates/{registry,202610130001_...}.go`, `console/import_winter.go`, `config/config.yaml`, `lang/{en,pl}/lang.yaml`, `scripts/winter-export-settings.sql`, `README.md`. Tests: `testmain_test.go`, `host_test.go`, `stripefake_test.go`, `fixture_plugin_test.go`, `webhook_test.go`, `settings_smoke_test.go`.
## Decisions Made
See `key-decisions`. In short: Checkout is the default with a per-order override; the browser return is the Checkout `success_url` and the PaymentIntent `return_url`; ignored events answer 200 so a shared Stripe account does not trigger retries; the four secrets all fall back to `golem15.pgstripe.<column>`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Spec arithmetic] The asserted Stripe amount is 4900, not 3984**
- **Found during:** Task 1
- **Issue:** The plan's fixture is one item at `FromMinor(3984, "PLN")` net with 23% VAT and requires `amount = TotalGross().Minor()`, but asserts `unit_amount=3984`. The gross total is 49.00 PLN (39.84 + 9.16, as the gateway tracer pins), so `Minor()` is 4900.
- **Fix:** The fixture and the formula are as planned; the tests assert `unit_amount=4900` and `amount=4900` (the server-side gross total in minor units). T-18-31's intent (the amount comes from the stored cart, never the client) holds.
- **Files modified:** host_test.go
- **Commit:** ed4745f, 5b268d4
**2. [Rule 1 - Bug] `RequiredFields` is empty, not `["email"]`**
- **Found during:** Task 1 (the first host run failed with `requires the details parameter "email"`)
- **Issue:** The gateway's `Payments.Create` checks `RequiredFields` as Details parameters; the e-mail is a Details field, so `["email"]` refused every order. PHP `Stripe::$requiredFields` inherits `[]`.
- **Fix:** `RequiredFields()` returns nil; `Purchase` sends the e-mail in the metadata when the order has one.
- **Files modified:** operator.go
- **Commit:** ed4745f
**3. [Rule 1 - Correctness] PaymentIntent without a payment method: `requires_payment_method` is the customer's step, not a decline**
- **Found during:** Task 2
- **Issue:** The plan maps `requires_payment_method` to Send plus Reject (PHP always sent a payment method). A PaymentIntent created for the site's own card form (D-17) has no payment method yet, and real Stripe answers exactly `requires_payment_method`; rejecting it would fail every PaymentIntent-mode payment.
- **Fix:** Without `payment_method_id`, `requires_payment_method` and `requires_confirmation` answer the `requires_action` bundle; with a confirmed payment method, `requires_payment_method` is a decline (StatusError with Stripe's `last_payment_error` message). `requires_capture` is pending.
- **Files modified:** operator.go, stripefake_test.go, host_test.go
- **Commit:** 5b268d4
**4. [Rule 2 - Correctness] Events that concern no payment of this application answer 200 (paymentgateway `operators.ErrNotificationIgnored`)**
- **Found during:** Task 2
- **Issue:** A verified Stripe event without a payment uuid (the consumer's own sessions, other integrations) or naming another site's payment on a shared Stripe account would have answered 404 from the gateway route, and Stripe retries 4xx deliveries for days and then disables the endpoint.
- **Fix:** The gateway gained the sentinel `operators.ErrNotificationIgnored`: when `ExtractUUID` returns it, the route answers 200 `{"status":"ignored"}` before any lookup (covered by a new `TestNotificationRoute` subtest, README updated). The Stripe operator returns it for an event without a uuid or whose uuid is not a Stripe payment in this database. A `pgUuid` browser return with an unknown uuid is still 404, as planned.
- **Files modified:** ../sm-paymentgateway-plugin/operators/operator.go, payments/notification.go, notification_route_test.go, README.md; operator.go
- **Commit:** e4fbd3b (paymentgateway), 5b268d4
**5. [Rule 2 - Correctness] Zero- and three-decimal currencies**
- **Found during:** Task 1
- **Issue:** MoneyRight treats every currency as two-decimal, so `Minor()` of JPY 500 is 50000; Stripe charges JPY in whole units, which would have charged a hundred times the price (the PHP `* 100` had the same flaw).
- **Fix:** `stripeAmount` sends `Minor()` for two-decimal currencies, whole units (rounded half up from cents) for Stripe's zero-decimal list and thousandths for BHD, JOD, KWD, OMR, TND. PLN and the tests are unaffected.
- **Files modified:** operator.go
- **Commit:** ed4745f
**6. [Rule 2 - Safety] An event or a return for another operator's payment changes nothing; a retrieved Stripe object whose metadata names another payment is not trusted**
- **Found during:** Tasks 1 and 2
- **Fix:** `handleEvent` and `browserReturn` act only on payments whose operator is the Stripe code; `paidAtStripe` refuses a session or intent whose `payment_uuid` metadata differs from the payment's uuid.
- **Commit:** ed4745f, 5b268d4
**7. [Rule 2 - Copy] Extra phrase keys**
- `settings.checkout_mode_checkout` and `settings.checkout_mode_payment_intent` (the dropdown option labels from the UI-SPEC copy), plus the PHP `labels.pluginDesc`, `settings.tab`, `settings.general`, `settings.apiKey` and `info.header` kept with corrected values.
- **Commit:** ed4745f
### Not deviations
- General has no explicit tab key: the untabbed fields form the default tab (F4), so the schema tabs are the default tab, Live keys and Test keys.
- Path (b)'s webhook is a route of the test fixture (`POST /_books/stripe/webhook` with its own signing secret), so the consumer "receives its own webhook" over HTTP rather than through a direct call.
- The paid mail is observed through an `events.MailSender` recorder that renders into a postcard memory driver (the plan 04 mechanism), since the app's own memory driver is unreachable from a test.
---
**Total deviations:** 7 auto-fixed (2 bugs or arithmetic, 4 correctness or safety, 1 copy). **Impact:** contracts named in the plan (operator code, route segment, settings columns and config keys, data keys, event table, response shapes) are as planned; one additive change landed in `sm-paymentgateway-plugin` with its test and README.
## Issues Encountered
- `gofmt -l` in `sm-paymentgateway-plugin` lists `mail_smoke_test.go` (from plan 04); out of scope, left untouched.
- The browser return and the refund call Stripe inside the transaction that holds the payment row lock (as the route design requires); the HTTP client carries a 30 s timeout and up to two retries in production (T-18-34).
## Known Stubs
None.
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: new-route-test-only | ../sm-pgstripe-plugin/fixture_plugin_test.go | The fixture's `POST /_books/stripe/webhook` exists only in the test binary (guarded by TestFixtureAbsentFromProduction); no production surface |
The register's mitigations T-18-28 to T-18-36 are implemented and tested: signature before lookup with refusal logs (TestHostStripeFlow wrong_secret, missing_header, missing_secret; TestStripeWebhook), row lock plus Can for replays and races (replay steps, replay_and_race), the browser return re-checking at Stripe (browser_return), server-side amounts (4900 asserted on both create calls), secrets never in responses or logs (TestStripeSettingsSmoke greps every body; the operator logs event ids and Stripe error codes only; dry-run output masked), only public ids in `payments.data` (no client secret, asserted), bounded HTTP client, pinned SDK with go.sum, the fixture guard.
## User Setup Required
None for this plan (no application cuts over). The README's Stripe Dashboard and Cutover sections list what an application does at cutover.
## Next Phase Readiness
- Plan 06 (customer API and layer) can pass the process response shapes above through; `stripe_checkout_mode` and `payment_method_id` are the per-order Details inputs.
- Plan 07 (unit tests) should add pure tests for `stripeAmount` (zero- and three-decimal currencies), `decodeReturnUUID`, `IsLaravelPayload`, `ParseAppKey`, `maskSecret` and `ModeKeys`, and include `TestHostStripeFlow`, `TestStripeWebhook`, `TestStripeSettingsSmoke` and `TestStripeImportWinterSmoke` in the phase gate script.
- The plugin has no remote yet; create `git.golem15.com/golem15/sm-pgstripe-plugin` before the first push (phase gate).
## Self-Check: PASSED
- Files exist: every path in `key-files.created` under `../sm-pgstripe-plugin` and the four modified gateway files.
- Commits exist: pgstripe ed4745f, 5b268d4, a8bc0b5; paymentgateway e4fbd3b.
- Acceptance re-run: branch `main` and no remote; operator code and `webhook.ValidatePayload` present; no `ConstructEvent` in production; exactly four `lagoon.Encrypted` lines and one `SecretFallbacks` method; `Minor()` present and no `* 100`; the fixture name only in test files; the four event names in webhook.go; `StatusSeeOther` and both Retrieve calls in browser_return.go; `RecordExternal` in the fixture; 14 `t.Run(` in host_test.go; `DecryptLaravelPayload` and `stripe.3dsecure` in the import; the three README sections; no consuming application named.
- Plan verification re-run: `go vet ./...`, `go test -count=1 -short ./...`, the SDK pin, no `gopkg.in/yaml.v3`, four password fields, `--- PASS: TestStripeWebhook`, 14 `TestHostStripeFlow` passes, 2 smoke passes.