diff --git a/.planning/phases/18-payment-gateway/18-05-SUMMARY.md b/.planning/phases/18-payment-gateway/18-05-SUMMARY.md new file mode 100644 index 0000000..98ed11b --- /dev/null +++ b/.planning/phases/18-payment-gateway/18-05-SUMMARY.md @@ -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.); 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":""}`; 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":"","data":{"payment_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":""}`; 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.`. + +## 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.