diff --git a/.planning/phases/18-payment-gateway/18-RESEARCH.md b/.planning/phases/18-payment-gateway/18-RESEARCH.md
new file mode 100644
index 0000000..f6777bb
--- /dev/null
+++ b/.planning/phases/18-payment-gateway/18-RESEARCH.md
@@ -0,0 +1,962 @@
+# Phase 18: Payment Gateway - Research
+
+**Researched:** 2026-10-12
+**Domain:** Port of `Golem15.PaymentGateway` v2.6.0 (`4b9ecf9`) and `Golem15.PGStripe` (`8b47457`) to two SummerCMS core plugins, plus a cabana `money` field, a customer order API and a Nuxt layer
+**Confidence:** HIGH for the PHP contract, framework capabilities and stripe-go API (read or executed this session); MEDIUM for plan sizing and the admin Quotes screen; LOW only where marked `[ASSUMED]`
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+#### Split, source and acceptance proof
+- **D-01:** **Subscriptions move to a new Phase 18.1.** This covers `Golem15.PaymentGatewaySubscriptions` (Subscription, SubscriptionPlan, the `SubscriptionGateway` contract, the `golem15.pgsub.*` events) together with pgstripe's `StripeSubscriptionGateway`, `ProcessStripeSubscriptionWebhook` job and `POST /webhook/stripe/subscriptions`. Phase 18 ports pgstripe without them. Insert 18.1 into the roadmap after this discussion (`/msd-phase`). — **Reversibility:** reversible — a roadmap split.
+- **D-02:** **The frozen port contract is Horoskopia's checkout:** `paymentgateway` at `4b9ecf9` (2026-06-10, equal to `origin/master`) and `pgstripe` at `8b47457` (`v1.0.1`), read from `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/{paymentgateway,pgstripe}`. Planner and researcher must not silently pick a newer `master` (the same rule as P15 D-01/D-02). Binding from that tree: tables and migrations, models, YAML forms/lists/filters, permission codes, event names, config keys, mail template codes, console command names and the notification route. Its `tests/` (unit, functional, security, reliability) are a behavioral spec: map each one to a Go case, and cover them in the last plan. — **Reversibility:** costly — every table, key and event name is copied from this tree.
+- **D-03:** **The proof is a test-only host inside the plugin repo**, not an app repo. It mounts `sm-paymentgateway-plugin`, `sm-pgstripe-plugin` and `sm-user-plugin` with a fake consumer plugin, runs against Postgres via testcontainers, and talks to Stripe through an `httptest` fake behind stripe-go's backend override. There is no `sm-horoskopia-app` in this phase.
+- **D-04:** **The proof exercises both pay paths:**
+ - (a) **Operator path:** `OrderFactory.Make(cart, details, user)` → Stripe operator (PaymentIntent, and Checkout mode per D-16) → signed notification on `_paymentgateway/stripe/...` → Payment `Accepted` → Order `Paid` → `OrderPaid` event and its mail.
+ - (b) **Consumer-driven path:** the app creates its own Checkout Session, receives its own webhook, and moves the order to `Paid` through the plugin's public Order/Payment API. This is what Horoskopia's `BookApiController::pay` and `BookFulfilmentService` do today. Path (b) proves the API surface Horoskopia needs: the state constants (`STATE_PLACED`, `STATE_AWAITINGPAYMENT`, the paid states), the transition methods, `hash_id` lookup and the `OrderPaid` event.
+
+#### Feature scope
+- **D-05:** **Port all of paymentgateway 1:1**, including Quotes/Quoteables, Shippings and Currencies with the NBP fields and the `pg:update-currencies` command. It's a shared core plugin with 7 real users, so one schema and migration history serves every app. — **Reversibility:** costly — the tables and migration IDs become the shared contract.
+- **D-06:** **The Winter `Orders` component's successor is a headless customer order API plus a Nuxt layer.**
+ - **API:** a JSON API under `/_paymentgateway/api/v1/` (following the `/_journal/api/v1`, `/_menu` and `/_faq` conventions) mirrors the component's handlers: show order, select payment method, select shipping, process (start payment), cancel, request refund, add/remove piece and remove item.
+ - **Layer:** `vue-paymentgateway-layer`, a new repo under `git.golem15.com/golem15/`, distributed like `vue-journal-layer` (P16 D-02 to D-04: a pnpm git dependency pinned to a tag, a neutral CSS-token design, components the app can override). It ships a typed client and order/checkout/status components covering the PHP state partials.
+ - **Reversibility:** one-way — a published API that apps and the layer consume.
+- **D-07:** **MoneyWidget becomes a generic cabana `money` field type**, built framework first in `summercms.go` (the P15 D-11 / P17 D-06 pattern). It holds an amount in minor units plus a currency, is formatted by locale, and fails loud on schema errors. The module README, `docs/`, admin OpenAPI, generated TS types and committed `dist/` update in the same change. **TaxWidget gets no new type**: it becomes a dropdown or number field over the tax rates. — **Reversibility:** costly — a new field type in the typed admin schema.
+- **D-08:** **Mail templates become postcard Markdown under the PHP template codes** (`golem15.paymentgateway::mail.order.purchased`, `...mail.payment.accepted`, and so on, about 26 of them), with the same variables, in EN and PL only (as P15 D-06). **MailerSettings** ports as a settings page that controls which customer and employee mails are sent. **The SetUp wizard** (which redirects until the base currency is configured) becomes a settings default or a required setting, not a redirecting controller.
+
+#### Money
+- **D-09:** **Money is `int64` minor units plus an ISO currency code**, a small value type that mirrors moneyphp (`Money::PLN(3984)`). It needs no new dependency. Arithmetic, allocation and rounding follow moneyphp's behavior, and the currency `ratio` (decimal 8,4) and NBP conversion use exact integer or `math/big` arithmetic, never floats. The researcher decides whether the type lives in the plugin or in `wire` (it goes in `wire` only if it's generic). — **Reversibility:** costly — every model, the cart and the consumer API take this type.
+- **D-10:** **The `decimal(12,4)` / `decimal(10,4)` `*_amount_stored` columns are major units.** The Money type implements an exact `sql.Scanner`/`driver.Valuer` that parses and formats the decimal string with no float (PITFALLS.md #5). Values with more precision than the currency's minor unit are preserved or explicitly rejected, never silently rounded; the researcher checks what PHP actually writes. Unit tests cover the column ceiling and the edge cases.
+- **D-11:** **`cart` and `details` (`mediumText`, JMS-serializer JSON) are read in the legacy shape and written in a versioned Go shape.** The guardrails:
+ 1. New rows carry a format version marker (for example `"v": 2`).
+ 2. A permanent legacy JMS reader decodes unversioned rows. It's tested against golden fixtures taken from real PHP rows (Horoskopia plus at least one shop-type app with shipping and quotes). Legacy rows are never rewritten in bulk.
+ 3. A typed extension registry replaces JMS's `setCartClass`/`setDetailsClass` (subclass-allowlisted, PG-007) and `registerMetadataDirectory`. A plugin registers a named extension type for Item/Details extra data, with explicit JSON tags and no class injection.
+ 4. Rollback to PHP is one-way: once Go writes v2 rows, PHP can't read them. Each app cutover treats this as a point of no return and takes a DB backup first.
+ - **Reversibility:** one-way — stored rows in every app's `orders` table.
+
+#### Stripe
+- **D-12:** **The Stripe client is the official `github.com/stripe/stripe-go`** (current major, with a pinned API version). This decision names it as an allowed dependency for `sm-pgstripe-plugin`, and 18.1 reuses it. Tests use the SDK's backend override pointed at an `httptest` fake; no test calls the network. — **Reversibility:** reversible — it's isolated in pgstripe.
+- **D-13:** **Stripe keys and secrets live in encrypted settings with a config fallback**, as in PHP `StripeSettings`. The secret key, publishable key and webhook signing secrets are admin settings stored as `lagoon.Encrypted`, with a test/live mode switch and a host-config/`SUMMER_*` env fallback when a setting is empty. The admin form never returns secrets (write-only, like password fields). App cutovers import PHP values with `lagoon.DecryptLaravelPayload`.
+- **D-14:** **Webhook verification keeps PHP's behavior:** `Stripe-Signature` is checked against the mode's signing secret, a missing header or secret is refused with a log entry, and the notification route stays CSRF-free. Behavior of `_paymentgateway/{operator}/{mode?}/{result?}` (any method, `mode`/`modeResult` merged into the input, operator code de-urlized) is preserved, and only registered operators resolve.
+- **D-15:** **Operators register at build time through a Go interface**, the successor of `core/Operator` and the `golem15.paymentgateway.operators` config push in `paymentgateway.booted`. The approach is a `pact`-style capability (or a registry in the paymentgateway plugin) that pgstripe implements. Cash, manual transfer, disabled and Stripe operators ship.
+- **D-16:** **New beyond PHP: a Stripe Checkout mode.** The Stripe operator can create a Checkout Session (hosted page) as well as PaymentIntents, and its notification handling accepts `checkout.session.completed` (and `checkout.session.async_payment_succeeded`) to accept the payment. This is the plugin-level version of what Horoskopia hand-rolls, so its later port can drop that code. Selecting the mode is a setting or a per-order operator option (planner's call). — **Reversibility:** costly — a new operator mode and settings that apps rely on.
+- **D-17:** **The client receives payment data in the process response; there's no separate keys endpoint.** The customer API's process call returns `{redirect_url}` for Checkout mode, and `{client_secret, publishable_key}` (for the active test/live mode) for PaymentIntent mode. `vue-paymentgateway-layer` defaults to Checkout and simply redirects, as `vue-horoskopia-app` does today (`window.location.href = checkout_url`). The Winter `StripeKeys` component is not ported as an endpoint.
+
+### Claude's Discretion
+- Go package, type and identifier names for the operator interface, Money type, extension registry and state-machine types, provided D-09 to D-15 hold.
+- Exact JSON field names and authorization of the customer order API (order hash for guests vs the logged-in user), following the PHP component's checks. The security review covers this.
+- Whether the Money type and the `money` field live in `wire`/cabana or in the plugin, as long as D-07 keeps `money` a cabana core type.
+- How the five daily commands (`pg:update-currencies`, `pg:cancel-created-payments`, `pg:cancel-placed-orders`, `pg:cancel-sent-payments`, `pg:finish-orders`, plus `UpdateOrders` and `UpdateAbstractItem`) are scheduled through conga, keeping the PHP command names.
+- Typed festival event shapes for the 19 Order and 5 Payment events, keeping the PHP event names as their identifiers.
+- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". This phase is large (two plugins, a framework field, a customer API and a Nuxt layer), so expect the framework `money` field first and the layer near the end.
+- Run the security-review agent: the phase handles payments, secrets, a public webhook route and a public customer API.
+
+### Deferred Ideas (OUT OF SCOPE)
+- **Phase 18.1, Payment Gateway Subscriptions:** `Golem15.PaymentGatewaySubscriptions` plus pgstripe's subscription gateway, webhook and job (D-01). Insert it into the roadmap.
+- **Plugin-registered, value-bearing cabana field types** (the successor to Winter `registerFormWidgets`): cabana has a closed field-type set, and `type: widget` is valueless. Captured as `.planning/todos/pending/cabana-plugin-registered-field-types.md` (major, framework). It's needed before porting plugins that ship their own fields.
+
+#### Reviewed Todos (not folded)
+- `cabana-richeditor-field-type.md`: already folded into Phase 17 (P17 D-10).
+- `nest-framework-packages-under-modules.md`, `journal-author-profile-fields.md`, `2026-10-01-benchmark-...`, `2026-10-07-translate-messages-as-nuxt-i18n-json-editor.md`, `bonfire-duplicate-command-names.md`, `faq-plugin-port.md`: these matched on keywords only and are unrelated to payments.
+
+
+
+## Phase Requirements
+
+No requirement IDs are mapped (ROADMAP says `Requirements: TBD`). The success criteria (SC) and CONTEXT decisions are the contract.
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| SC-1 | Payment (6 states) and Order (12 states, all refund paths) state machines port with the PHP transitions, using exact decimal money | §"State machines" lists every state, transition, from/to set, guard and after-callback verbatim; §"Money" gives the exact MoneyRight arithmetic with PHP-generated vectors |
+| SC-2 | Operators register at build time through a Go interface; cash, manual transfer and Stripe ship; the `_paymentgateway/{operator}/{mode?}/{result?}` route keeps PHP behavior | §"Operator registry (D-15)", §"Notification route", §"Stripe operator"; the base64 route segments are computed below |
+| SC-3 | Order/Payment events, mail templates, admin screens and scheduled commands ported; tables, event names and config keys stay non-breaking | §"Inventory", §"Events and mail", §"Admin screens", §"Scheduled commands", §"Settings" |
+| SC-4 | A host test proves order-create, Stripe-pay and order-paid as Horoskopia relies on them | §"Test host (D-03/D-04)", plus a stripe-go httptest probe executed this session |
+| SC-5 | New code has unit tests in the last plan | §"Validation Architecture", §"PHP test → Go case map" (74 PHP tests) |
+
+
+## Project Constraints (from CLAUDE.md)
+
+These directives carry the same weight as the locked decisions.
+
+- One Go module per repo. Framework packages are `git.golem15.com/golem15/summercms/modules/`, and a new framework module gets a beach name, a README in the standard structure and a row in the root `README.md` table.
+- Plugins compile in through `pact` capability interfaces. There is no runtime plugin loading without a decision note.
+- Data: GORM on Postgres only, per-plugin gormigrate migrations. Never use `float64` for money (PITFALLS #5).
+- Parse YAML with `goccy/go-yaml`, never `gopkg.in/yaml.v3`. stripe-go pulls `gopkg.in/yaml.v3` in only as an indirect test dependency of `testify`; application code must not import it.
+- Dependencies: standard library first. A new dependency needs a research doc or a decision note naming it. D-12 names stripe-go. This research also names `github.com/speps/go-hashids/v2` (see the Standard Stack), and that needs a decision note or the user's confirmation.
+- Framework code never names a consuming application. READMEs, docs and examples in `summercms.go`, `sm-paymentgateway-plugin`, `sm-pgstripe-plugin` and `vue-paymentgateway-layer` must not say "Horoskopia". The test host uses a neutral fake consumer (for example `acme.books`).
+- Documentation: a change to a module's exported API, config keys, CLI or dependencies updates its README and the affected `docs/` pages in the same commit. Every identifier in a README or docs page must exist (the docs checker enforces this).
+- Admin: a change to `admin/` needs a rebuilt, committed `modules/boardwalk/dist`, `scripts/check-admin-dist.sh`, and a refreshed `scripts/check-admin-openapi.sh`.
+- Tests: `go vet ./...` and `go test ./...` stay green at every commit. Docker tests skip under `-short`; a `TestMain` must call `flag.Parse()` and check `testing.Short()`.
+- MSD rules: lean planning (few, large plans, with the count confirmed before PLAN.md files are written), unit tests in the last plan, STATE.md updated through `msd_run query state.*`, g15office sync.
+- Commits: no co-author tags, one logical change per commit, planning docs and code in separate commits, files staged by explicit path, no push, tag or deploy without the user's go-ahead.
+- Core plugins (user, blog, pages, payment) serve many projects. Avoid breaking changes unless the user asks.
+- Submodule workflow: commit and push the plugin repo first, then bump its pointer in the app in a separate commit.
+
+## Summary
+
+The PHP contract is now fully read: 6 tables, 9 migration files in `version.yaml` (5 creates, 2 alters, 2 data/settings), 24 events, 26 mail template files, 7 permissions, 7 console commands, 8 admin controllers, a 1,174-line `Orders` component and 74 PHPUnit tests (63 in paymentgateway, 11 in pgstripe). About 18.7k lines of PHP sit outside the tests. The framework already covers most of what the port needs: `pact.HasSchedule` with `Daily`/`Every`, `festival` typed events, `postcard` Markdown mail, `lagoon.Encrypted` and `DecryptLaravelPayload`, `lagoon.AfterCommit`, admin record actions with `Applies` (a natural fit for FSM transition buttons), `party.Activated` for build-time operator discovery, and lagoon's `sql.Scanner` fill path. Phase 16.2 has also since shipped plugin-registered field types, so the CONTEXT's deferred todo is already done.
+
+Five findings change how the decisions should be implemented. The planner should raise them with the user:
+
+1. **The PHP money library is Keios MoneyRight, not moneyphp.** It stores every amount as a 4-decimal BCMath string, treats every currency as 2-decimal, and reads `Money::PLN(3984)` as 39.84. Currency conversion and admin input write sub-cent values: `EUR 19.99 → PLN` gives `85.4193`. An `int64`-in-minor-units type cannot hold the stored rows without silent rounding, which D-10 forbids. **Recommendation:** an `int64` scaled by 10⁴ (MoneyRight's "GAAP" precision) with a `FromMinor(3984, "PLN")` constructor and a `Minor()` accessor. This keeps D-09's intent (int64, exact, no new dependency) and satisfies D-10.
+2. **The PHP Stripe operator never receives a real Stripe webhook on `_paymentgateway`.** Its notification URL is a browser redirect (`?pgUuid=`) that carries no `Stripe-Signature`. Since PG-003 that redirect gets a 403, and acceptance happens synchronously inside `purchase()`. D-14's signed path therefore has no working PHP precedent. The Go operator must handle real signed events and treat the browser return as an unsigned, state-neutral redirect.
+3. **stripe-go v87 pins API version `2026-09-30.endive`, and `webhook.ConstructEvent` rejects events from any other release train** (verified by running it this session). Existing endpoints were created under `acacia`/`basil`. Verify the signature with `webhook.ValidatePayload` and decode only the few stable fields needed, or each app must repoint its endpoint API version at cutover.
+4. **Cabana settings pages refuse `type: password` and `type: richeditor`.** D-13 needs write-only encrypted secrets on a settings form, and the manual-transfer bank details (PG-008) are rich text. Both are framework gaps for the first plan.
+5. **Cabana settings are a typed one-row table per plugin**, not PHP's `system_settings` JSON. pgstripe cannot add columns to the paymentgateway settings table the way it injects `stripe.*` fields in PHP, so pgstripe owns its own settings page and table. A `paymentgateway:import-winter` / `pgstripe:import-winter` command maps the flat dotted PHP keys. The real key list was read from a Horoskopia dump.
+
+**Primary recommendation:** Plan the phase as six plans. (1) Framework first: the cabana `money` field plus write-only/encrypted and richeditor support on settings forms. (2) paymentgateway domain core: money, FSMs, operator registry, legacy cart reader, route, commands. (3) paymentgateway admin, settings and mail. (4) pgstripe with the D-03/D-04 host test. (5) Customer API and `vue-paymentgateway-layer`. (6) Unit tests and the phase gate. Use stripe-go `v87.0.0` with signature-only verification plus minimal event decoding.
+
+## Decision-Impact Findings (read before planning)
+
+| # | Finding | Evidence | Affects | Recommendation |
+|---|---------|----------|---------|----------------|
+| F-1 | PHP Money is `Keios\MoneyRight\Money` (BCMath, 4-decimal string amount); int input is cents for **every** currency | `composer.json` requires `"keios/moneyright": "~1.0.9"` (lock: `v1.0.10`); `vendor/keios/moneyright/src/Money.php:34` `const GAAP_PRECISION = 4;`, `:39` `const BASIC_PRECISION = 2;`, `:86-88` `if (is_int($amount)) { $amount = $amount / 100; }`; PHP run: `"JPY(500) amount": "5.0000"` [VERIFIED: vendor/keios/moneyright/src/Money.php:34-88 + php execution] | D-09, D-10, cabana `money` | Scale-4 int64 (see §Money). Mirror MoneyRight, not moneyphp. |
+| F-2 | Sub-cent values are written to `*_amount_stored` | `CurrencyPair::convert` rounds to `Money::GAAP_PRECISION`; PHP run: `"EUR 19.99 -> PLN @4.2731": "85.4193"`, `"PLN 23.00 -> EUR @inv": "5.3820"`; MoneyWidget saves `new Money($value, ...)` from `` [VERIFIED: vendor/keios/moneyright/src/CurrencyPair.php convert(); formwidgets/MoneyWidget.php; php execution] | D-10 | **Preserve** 4 decimals; reject >4 decimals explicitly (never round on input). |
+| F-3 | The PHP Stripe operator's `_paymentgateway` hit is a browser redirect with `?pgUuid=`, but `verifyNotification()` demands `Stripe-Signature` | `pgstripe/operators/Stripe.php:169` `'_paymentgateway/' . OperatorUrlizer::urlize($this) . '?pgUuid=' . base64_encode($this->uuid)`; `:218` `$sigHeader = \Request::header('Stripe-Signature');`; `paymentgateway/core/PaymentManager.php:172-181` find-then-verify, 403 on failure [VERIFIED: files read] | D-04(a), D-14 | Go: signed POST → event handling; unsigned GET with `pgUuid` → server-side re-check via Stripe API (never trust input) then redirect to `return_url`. |
+| F-4 | stripe-go `v87.0.0` pins `2026-09-30.endive`; `ConstructEvent` rejects other trains | Probe output: `api_version=2025-02-24.acacia ConstructEvent err=received event with API version 2025-02-24.acacia, but stripe-go 87.0.0 expects API version 2026-09-30.endive ... | ValidatePayload err=` [VERIFIED: go test run this session] | D-12, D-14, cutover | Use `webhook.ValidatePayload` + decode a minimal local struct. |
+| F-5 | Settings forms refuse `password` and `richeditor` | `modules/cabana/settings.go:86` `if field.Type == "widget" \|\| field.Type == "partial" \|\| field.Type == "fileupload" \|\| field.Type == "password" \|\| field.Type == permissionFieldType {`; `modules/cabana/field_dependent.go:123-124` `if field.Type == richEditorType \|\| field.Type == repeaterType { return fmt.Errorf("%s is supported in controller forms only", field.Type)` [VERIFIED: files read] | D-13, PG-008 | Framework plan: allow write-only `password` backed by `lagoon.Encrypted` and `richeditor` (seaglass-sanitized) on settings forms. |
+| F-6 | Cabana settings = typed singleton table with column-backed fields | `modules/cabana/settings.go:104-109` field must be a model column and fillable; precedent `sm-journal-plugin/models/settings.go:22` `golem15_journal_settings`, `sm-sitemap-plugin` `sitemap:import-winter` | D-13, settings | `golem15_paymentgateway_settings` + `golem15_pgstripe_settings` tables + import-winter commands. |
+| F-7 | Plugin field types already exist (Phase 16.2) | `modules/pact/capabilities.go` `type FormFieldType struct`, `HasFormFieldTypes`; commit `1e20ace 2026-10-10 feat(16.2-01): add the pact plugin field type API` [VERIFIED: git log + file] | Deferred todo, D-07 | `money` stays a core cabana type (D-07); the Quotes cart editor can be a plugin field type or `type: widget` + `AdminAction`. |
+| F-8 | Real legacy rows locally: one Horoskopia `Placed` order, zero payments; no shop-type dump | `storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql` (`AUTO_INCREMENT=3`, one tuple); `g15live.sql` empty; no sklep/getaround/quotify dumps found [VERIFIED: dump scan] | D-11 golden fixtures | Generate synthetic fixtures with PHP (8.5 available) + request a production row export from a shop app (checkpoint). |
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Money arithmetic, cart totals, currency conversion | API / Backend (paymentgateway `money` pkg) | Database (numeric(12,4)/(10,4)) | Exactness lives in Go; DB stores the decimal string verbatim |
+| Order/Payment state machines and events | API / Backend (paymentgateway) | — | Transitions, guards, events and the payment→order bridge are server-only |
+| Operator registration and dispatch | API / Backend (paymentgateway registry, pgstripe implements) | — | Build-time interface discovery via `party.Activated` |
+| Stripe API calls, webhook signature verification | API / Backend (pgstripe) | — | Secret key and signing secrets never leave the server |
+| Notification route `_paymentgateway/...` | API / Backend (surf route in paymentgateway) | — | Public, CSRF-free, any method |
+| Customer order API `/_paymentgateway/api/v1` | API / Backend | Browser (layer client) | Authorization (owner/guest_code/guest_token) is server-side |
+| Checkout redirect / Stripe.js confirm | Browser (`vue-paymentgateway-layer`) | Frontend Server (Nuxt SSR renders order pages) | Default flow is `window.location.href = redirect_url` |
+| Admin lists/forms/settings, transition buttons | API / Backend (cabana compiled YAML + record actions) | Browser (admin SPA, boardwalk dist) | cabana owns routes/CSRF/permissions; plugin supplies YAML + `Run` funcs |
+| `money` admin field rendering | Browser (admin SPA `MoneyField.vue`) | API / Backend (cabana schema + fill) | Locale formatting in `Intl.NumberFormat`; exact value travels as decimal string |
+| Mail rendering and delivery | API / Backend (postcard) | — | Sent after commit (`lagoon.AfterCommit`) |
+| Scheduled cancel/finish/currency jobs | API / Backend (conga `HasSchedule`) | Database | Leader-elected periodic jobs |
+| Persistence of cart/details | Database (`mediumtext`→`text`) | API / Backend (legacy reader + v2 writer) | Format versioning in the payload |
+
+## Inventory (port size)
+
+All counts come from the frozen trees. paymentgateway at `4b9ecf9` is the checkout HEAD. pgstripe's checkout HEAD is `d54dcde`, three commits past `8b47457`, and `git diff --stat 8b47457 HEAD` touches only `classes/StripeSubscriptionGateway.php`. That file is 18.1 scope, so reading the working tree for Phase 18 files is equivalent to `8b47457`. [VERIFIED: git log/diff]
+
+| Area | Count | Detail |
+|------|-------|--------|
+| Tables | 6 | `golem15_paymentgateway_{orders,payments,currencies,shippings,quoteables,qs_orders}` (schema below) |
+| PHP migrations | 9 files in `version.yaml` | create payments/orders/shippings/currencies/quoteables(+qs_orders), `flush_settings`, `update_existing_created_orders`, `add_order_guest_code`, `add_nbp_fields_to_currencies` (seed file commented out) |
+| Models | 6 | Order, Payment, Currency, Shipping, Quoteable, Settings |
+| Value objects | 12 | AbstractItem, Item, AdjustableItem, AbstractAdjustment, PercentAdjustment, ValueAdjustment, Cart, Details, ShippingAddress, ShippingMethod, CreditCard, PaymentResponse |
+| JMS metadata files | 11 | `metadata/*.yml` (+ `generic/AbstractItem.yml` template) |
+| Events | 24 | 19 Order (17 transition events + CustomerCreatedOrder + EmployeeCreatedOrder; OrderRejectedReceivedPayment fired by the bridge) and 5 Payment (Sent, Accepted, Rejected, Canceled, Refunded) |
+| Mail template files | 26 | 25 registered in `registerMailTemplates` + `views/mail/order/placed.htm` (used by `CustomerOrderMailer::onOrderPlaced`, not registered) |
+| Permissions | 7 | `golem15.paymentgateway.{access_payments,access_settings,access_orders,access_shippings,manage_orders,manage_shippings,manage_payments}` |
+| Console commands | 7 | `pg:cancel-placed-orders`, `pg:cancel-created-payments`, `pg:cancel-sent-payments`, `pg:finish-orders`, `pg:update-currencies` (`--force`), `pg:update-metadata`, `pg:update-orders` (abstract, "unregistered command that you should extend") |
+| Scheduled | 5 | 4 `->daily()` + `pg:update-currencies` `->everyTenMinutes()` (`Plugin.php:363-370`) |
+| Admin controllers | 8 | Orders (manage page, `onAction`, `onCreateCashPayment`, `onCreateBankPayment`), Payments (`onAccept`, `onRefund`), Quotes (6 `manage_on*` handlers), Quoteables, Shippings, Settings, MailerSettings, SetUp |
+| Form widgets | 2 | MoneyWidget (`money`), TaxWidget (`tax`) |
+| Operators | 3 + 1 | Cash, ManualTransfer, Disabled (pg) + Stripe (pgstripe) |
+| Component | 1 | `Orders` (`orderManager`), 1,174 LOC, 8 AJAX handlers, 17 state partials |
+| Lang | ~500 keys | `lang/en/lang.php` 496 `=>`, `lang/pl/lang.php` 508; pgstripe 26 lines each |
+| PHP LOC (non-test) | 18,726 | paymentgateway; pgstripe ≈2.5k minus subscription parts |
+| PHPUnit tests | 74 | pg: unit 19, functional 22, security 22; pgstripe: security 9, reliability 2 (reliability = 18.1 job) |
+
+### Schema (MySQL as deployed; port to Postgres types)
+
+Read from `CREATE TABLE` in `horoskopia.eu/storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql` [VERIFIED: dump lines 5197892-5198085]:
+
+```
+golem15_paymentgateway_orders: id int unsigned AI; hash_id varchar(255) NULL idx; guest_code varchar(255) NULL;
+ user_id int unsigned NULL idx; shipping_id int unsigned NULL idx; cart mediumtext NOT NULL; details mediumtext NOT NULL;
+ state varchar(255) NOT NULL; was_shipped tinyint(1) NOT NULL DEFAULT 0; amount_stored decimal(12,4) NOT NULL;
+ currency varchar(3) NOT NULL; native_amount_stored decimal(12,4) NOT NULL; native_currency varchar(3) NOT NULL;
+ successful_payment_id int unsigned NULL idx; created_at/updated_at timestamp NULL
+golem15_paymentgateway_payments: id; order_id NULL idx; user_id NULL idx; data text NULL; state varchar(255) NOT NULL;
+ operator varchar(255) NOT NULL idx; description varchar(255) NOT NULL; uuid varchar(255) NOT NULL UNIQUE;
+ currency varchar(3) idx; amount_stored decimal(10,4); native_currency varchar(3) idx; native_amount_stored decimal(10,4); timestamps
+golem15_paymentgateway_currencies: id; iso varchar(3) UNIQUE; ratio decimal(8,4) NOT NULL; nbp_table_number varchar(30) NULL;
+ nbp_effective_date date NULL; rate_source varchar(20) DEFAULT 'fixer'; timestamps
+golem15_paymentgateway_shippings: id; name varchar(255); cost_stored decimal(10,4); currency varchar(255); tax int;
+ is_payable_with_cash tinyint(1) DEFAULT 0; timestamps (+ logotype attachOne via system_files)
+golem15_paymentgateway_quoteables: id; name varchar(255); cost_stored decimal(10,4); currency varchar(255);
+ default_tax int; custom_fields text NOT NULL; timestamps
+golem15_paymentgateway_qs_orders: order_id int unsigned; quoteable_id int unsigned; PK(order_id, quoteable_id)
+```
+
+Postgres mapping: `int unsigned AI` → `SERIAL`/`BIGSERIAL` (match the journal precedent), `decimal(p,4)` → `NUMERIC(p,4)`, `tinyint(1)` → `BOOLEAN`, `mediumtext`/`text` → `TEXT`, `timestamp NULL` → `TIMESTAMP(0) WITHOUT TIME ZONE NULL` (check the lagoon convention used by journal migrations).
+
+## Standard Stack
+
+### Core
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `github.com/stripe/stripe-go/v87` | v87.0.0 (2026-10-01) | Stripe API client (PaymentIntents, Checkout Sessions, Refunds), webhook signature verification | Official SDK, named by D-12; `APIVersion = "2026-09-30.endive"` [VERIFIED: go module proxy + `api_version.go:10`] |
+| `github.com/speps/go-hashids/v2` | v2.0.1 (2021-05-04) | `hash_id` (8) and `guest_code` (32) generation and decoding, compatible with existing PHP IDs | Byte-identical to PHP `hashids/hashids` 5.0.2 on 24/24 vectors (custom alphabet, legacy and configured salts, lengths 8 and 32) [VERIFIED: probe run this session] |
+| Framework (in-repo) | `summercms` v0.1.23 + Phase 18 framework plan | cabana, lagoon, conga, festival, postcard, surf, bouncer, party, pact, wire | Project stack |
+| `math/big`, `strconv`, `database/sql/driver` | stdlib | Exact decimal parse/format, ratio multiply (product can exceed int64) | D-09 "no new dependency" |
+
+### Supporting
+
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `github.com/shopspring/decimal` | v1.4.0 | Transitive dependency of stripe-go v87 (its `go.mod` requires it) | Do **not** use it for paymentgateway money (MoneyRight semantics are hand-ported and covered by tests); it appears in `go.sum` regardless |
+| `@stripe/stripe-js` | 9.17.0 (pin) | Optional PaymentIntent confirm (3DS) component in the layer | Only if the layer ships a PaymentIntent component; the default is a Checkout redirect with no SDK. `[WARNING: 10.0.0 flagged as suspicious ("too-new") — verify before using.]` |
+| Nuxt layer stack | same as `vue-journal-layer` v0.3.0: `nuxt 4.4.8`, `vue 3.5.35`, `@nuxtjs/i18n 10.4.0`, `typescript 5.9.3` | `vue-paymentgateway-layer` | Copy the journal layer's toolchain; tests run with `node --test` |
+
+### Alternatives Considered
+
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Hand-ported MoneyRight on int64×10⁴ | `shopspring/decimal` (already transitive) | Arbitrary precision, but the default rounding and division semantics differ from MoneyRight's truncate-then-half-up. A port must match PHP byte for byte, and a 300-line value type with vectors is safer than adapting a general decimal. D-09 also says "no new dependency" |
+| `speps/go-hashids/v2` | Hand-rolled Hashids, or random hash IDs | Hashids is an algorithm with many edge cases (alphabet shuffles, separators, guards); hand-rolling it risks mismatches with existing IDs. Random IDs break `parseKey` decoding, which the Orders component and Horoskopia (`BookApiController.php:615`) still use |
+| `webhook.ValidatePayload` + minimal decode | `webhook.ConstructEvent` | `ConstructEvent` rejects events whose API version comes from another release train (F-4). Pinning dashboards to `endive` at every app cutover is a runtime-state task the port should not force |
+| pgstripe's own settings table | Columns for Stripe in the paymentgateway settings table | paymentgateway must not know Stripe. Separate tables keep "payments-only without Stripe" builds clean, and 18.1 adds its subscription secret columns to pgstripe's table |
+
+**Installation:**
+```bash
+# sm-pgstripe-plugin
+go get github.com/stripe/stripe-go/v87@v87.0.0
+# sm-paymentgateway-plugin
+go get github.com/speps/go-hashids/v2@v2.0.1
+# vue-paymentgateway-layer (only if a PaymentIntent component ships)
+pnpm add @stripe/stripe-js@9.17.0
+```
+
+**Version verification:** `go list -m -versions` against the Go module proxy showed v88+ do not exist and `v87.0.0` is the newest stable release (`"Time": "2026-10-01T00:45:47Z"`); `v86.4.2` was released 2026-09-09. `speps/go-hashids/v2@latest` is `v2.0.1`. `npm view @stripe/stripe-js` reports 10.0.0 published 2026-10-01 and 9.17.0 published 2026-09-21. [VERIFIED: registry queries this session]
+
+## Package Legitimacy Audit
+
+The `package-legitimacy` seam supports only npm, PyPI and crates. Go modules were checked against the Go module proxy and the GitHub API instead.
+
+| Package | Registry | Age | Downloads / adoption | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| `github.com/stripe/stripe-go/v87` | Go proxy | major released 2026-10-01; repo active (pushed 2026-10-12) | 2,641 stars, MIT, official Stripe | github.com/stripe/stripe-go | OK (manual: official vendor, named in D-12) | Approved |
+| `github.com/speps/go-hashids/v2` | Go proxy | v2.0.1 2021-05-04; last push 2022-06-24 (stable, algorithm frozen) | 1,376 stars, MIT, not archived | github.com/speps/go-hashids | OK (manual) | Approved, **needs a decision note** (new dependency per CLAUDE.md) `[ASSUMED: discovered from training knowledge; correctness verified by vectors]` |
+| `github.com/shopspring/decimal` | Go proxy | v1.4.0 | 7,490 stars | github.com/shopspring/decimal | OK (transitive only) | Approved as indirect |
+| `@stripe/stripe-js` | npm | 10.0.0 is 11 days old | 14.4M/week | github.com/stripe/stripe-js | SUS (`too-new`), postinstall: none | Flagged. Pin 9.17.0 or skip; the planner adds `checkpoint:human-verify` before install |
+
+**Packages removed due to [SLOP] verdict:** none.
+**Packages flagged as suspicious [SUS]:** `@stripe/stripe-js` (reason: recency of 10.0.0 only; official package).
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+ ┌──────────────────────── consumer plugin (e.g. acme.books) ───────────────────────┐
+ │ cart := pg.NewCart("PLN"); cart.Add(pg.NewItem(desc,1,money.FromMinor(3984,"PLN"),23,data))│
+ │ order, err := pg.Orders.Make(ctx, tx, cart, details, &user) ── hash_id, guest_code │
+ └───────────┬─────────────────────────────────────────────┬─────────────────────────────┘
+ │ path (a) operator │ path (b) consumer-driven
+ browser/Nuxt layer ──► POST /_paymentgateway/api/v1/orders/{hash}/process │ app creates own Checkout Session
+ (owner JWT | guest_code | guest_token) │ app's own webhook ─► pg.Payments.RecordExternal
+ │ │ (order, operator code, data) → Send → Accept
+ ▼ ▼
+ ┌──────────────────────────┐ build-time scan of party.Activated
+ │ PaymentManager.Create │◄── paymentgateway.HasOperators (cash, manual, disabled, stripe)
+ │ Operator.Purchase(ctx) │
+ └───────┬───────────────────┘
+ │ PaymentIntent / Checkout Session via stripe-go client (backend URL overridable)
+ ▼
+ Stripe ──(signed POST event)──► ANY /_paymentgateway/{base64(code)}/{mode?}/{result?}
+ │ │ 1. resolve registered+enabled operator
+ │ (browser return, unsigned GET ──┘ 2. operator.VerifyNotification(r, rawBody)
+ │ ?pgUuid=…: re-check via API, 3. operator.HandleNotification → Payment FSM
+ │ redirect to return_url)
+ ▼
+ Payment FSM: Created ─Send─► Sent ─Accept─► Accepted (after-callbacks fire festival events)
+ │ │
+ PaymentSent ──► bridge: Order Purchase (Placed→AwaitingPayment)
+ PaymentAccepted ─► bridge: set successful_payment, Order Pay (AwaitingPayment→Paid) ─► OrderPaid
+ │
+ lagoon.AfterCommit ─► postcard mail (if MailerSettings switch on) ─► customer/admin
+ conga schedule (daily) ─► pg:cancel-* / pg:finish-orders / pg:update-currencies (NBP/Fixer)
+```
+
+### Recommended Project Structure
+
+```
+sm-paymentgateway-plugin/ # module git.golem15.com/golem15/sm-paymentgateway-plugin, package paymentgateway
+├── plugin.go # ID golem15.paymentgateway, Requires golem15.user, capabilities
+├── money/ # Money (int64 ×10⁴ + currency), Amount column type, MoneyRight ops, vectors
+├── fsm/ # tiny table-driven FSM (states, transitions, guards, after-callbacks)
+├── orders/ payments/ # Order + Payment services (Make, Wrap, Find, PerformTransition, Purchase, Refund, Cancel, RecordExternal)
+├── operators/ # Operator interface, HasOperators, registry, cash/manual/disabled
+├── cart/ # Cart/Item/AdjustableItem/Details/ShippingMethod/ShippingAddress, legacy JMS reader, v2 writer, extension registry
+├── currency/ # converter, NBP + Fixer providers (httptest-able), ratio math
+├── events/ # 24 typed festival events + bridge listeners + mailers
+├── models/ # GORM models with TableName()/Fillable()/Rules()
+├── updates/ # gormigrate migrations (timestamp IDs), settings singleton
+├── controllers/ + models/*/*.yaml # cabana admin controllers, YAML copied from PHP and adapted
+├── console/ # pg:* commands + paymentgateway:import-winter
+├── api/ # /_paymentgateway/api/v1 handlers
+├── views/mail/… # postcard templates (PL default + -en), layouts
+├── lang/{en,pl}/lang.yaml
+└── integration_test.go # testcontainers host for PG-only flows (cash/manual)
+
+sm-pgstripe-plugin/ # module …/sm-pgstripe-plugin, package pgstripe, Requires golem15.paymentgateway
+├── plugin.go operator.go client.go (stripe.NewClient factory with backend override)
+├── settings (model+fields.yaml+migration), console/import_winter.go
+├── webhook.go (signature, minimal event decode, mode secrets)
+└── hosttest/ or integration_test.go # D-03 proof host: user + pg + pgstripe + fake consumer + httptest Stripe
+```
+
+### Pattern 1: Money as int64 × 10⁴ (MoneyRight semantics)
+
+**What:** `type Money struct { units int64; currency string }` where `units` = amount × 10⁴. `Add`/`Sub` are exact. `Mul(operand)`/`Div(operand)` compute the product at 5 decimals (truncated, as `bcmul(..., 5)` does) and round half away from zero to 2 decimals by default, or to 4 with a `GAAP` option. `Allocate` follows MoneyRight's remainder loop. `Minor()` = `getAmount()` (half-up to cents). `AmountString()` = `"%d.%04d"`.
+
+**Why scale 4:** MoneyRight stores and compares 4 decimals (`Money.php:280` `bccomp($this->amount, $other->getAmountString(), self::GAAP_PRECISION)`), and conversions write 4 decimals (F-2).
+
+**PHP vectors (generate the Go table test from these):** [VERIFIED: php execution against `vendor/keios/moneyright` v1.0.10]
+```
+PLN(3984) → "39.8400" PLN('3984') → "3984.0000"
+PLN('12.34565') → "12.3457" PLN('-12.34565') → "-12.3457" PLN('1,5') → "1.5000"
+2439 × (23/100) → "560.9700" 10.01 × 0.23 → "2.3000" (gaap) → "2.3023"
+0.05 × 0.5 → "0.0300" (half_even) → "0.0200" -0.05 × 0.5 → "-0.0300"
+10 / 3 → "3.3300"
+12.345 getAmount → 1235 12.345 getAmountBasic → "12.35" 12.3449 getAmount → 1234
+allocate 100 [1,1,1] → 33.3400, 33.3300, 33.3300 (gaap) → 33.3334, 33.3333, 33.3333
+allocate 0.05 [1,1] → 0.0300, 0.0200
+EUR 10.00 → PLN @4.2731 → "42.7310" EUR 19.99 → PLN @4.2731 → "85.4193"
+1/4.2731 (bcdiv scale 4, truncates) → "0.2340" PLN 23.00 → EUR @0.2340 → "5.3820"
+JPY(500) → "5.0000", getAmount 500 (string)(7/100) → "0.07"
+```
+
+**Rounding mechanics** (`Math.php` read): `bcRoundHalfUp` adds a signed `0.0…05` at precision+1 and then truncates, which is **half away from zero**. `ROUND_HALF_EVEN`/`HALF_ODD` look only at the first digit after the precision. Constructor input is normalized with `Math::bcround(…, 4)` (half up), which is why `"12.34565"` becomes `"12.3457"`. For Go **input** (cabana, API, scanner), D-10 forbids silent rounding, so **reject** more than 4 decimals. Keep a separate internal `roundTo4` only for arithmetic results.
+
+**Tax operand:** PHP uses `$this->tax / 100` (a float cast to a string such as `"0.23"`). In Go, represent the operand as the exact rational `tax/100` (`big.Rat` or scaled int); no float is ever involved.
+
+### Pattern 2: Scanner/Valuer for `*_amount_stored`
+
+**What:** A column type, for example `money.Amount4` (int64 ×10⁴), implements `Scan` (accepting a Postgres `numeric` as `[]byte`/`string`, parsing digits, rejecting more than 4 fraction digits, rejecting exponents and NaN) and `Value` (returning the 4-decimal string `"2999.9700"`). The model keeps separate `Currency string` columns, matching the PHP `Financial` trait's `balance`/`currency` pairs (`Order.php` `$financial`). A model method `Amount() money.Money` combines the two. Ceiling: `numeric(12,4)` holds at most `99999999.9999`, and `numeric(10,4)` (payments, shippings, quoteables) at most `999999.9999`. **Payments use (10,4) while orders use (12,4)**, so an order above 999,999.9999 cannot store its payment. Validate before insert and fail loudly. [VERIFIED: dump schema]
+
+**Why not reuse fonoteka's `MoneyString`:** it pads and rounds through `big.Rat.FloatString(4)` (`fonoteka.go/plugins/golem15/fonoteka/models/money_string.go:61-66`), which silently rounds. D-10 forbids that for payments.
+
+### Pattern 3: Table-driven FSM
+
+PHP uses `yohang/finite` (Keios fork) through `traits/FiniteStateMachine.php`. The semantics to port:
+- `can(t)` = current state ∈ `from(t)` AND guard(t) is true.
+- `apply(t)` sets the state and then runs the **after** callbacks. The callbacks fire **before** `saveWithState()` persists the row (`Order::performTransition` default branch: `$this->apply($action); $this->saveWithState();`).
+- Orders have two initial states. The first listed, `Placed`, is the default; `Quote` is used when `createAsQuote` is set.
+
+**Go recommendation:** persist inside the transaction first, then dispatch the typed events synchronously within the same transaction (so listeners can read the new state), and defer side effects such as mail through `lagoon.AfterCommit`. Document that listeners see the persisted state, which is stricter than PHP and safer.
+
+**Payment FSM** [VERIFIED: `paymentgateway/core/Operator.php:55-96` constants, `:706-770` config]:
+```
+states: Created(initial) Sent(normal) Accepted(final) Rejected(final) Canceled(final) Refunded(final)
+Send: [Created] → Sent guard enabled
+Reject: [Sent] → Rejected guard enabled
+Cancel: [Created, Sent] → Canceled (no guard: "we should be able to cancel payments even if operator is disabled")
+Accept: [Created, Sent] → Accepted guard enabled
+Refund: [Accepted] → Refunded guard enabled
+after: Send→PaymentSent, Accept→PaymentAccepted, Refund→PaymentRefunded, Reject→PaymentRejected, Cancel→PaymentCanceled
+```
+`purchase()` outcomes (`Operator.php` `purchase`): an additional step (3DS) → Send; success and complete → Send+Accept; success → Send; error → Send+Reject plus a logged error.
+
+**Order FSM** [VERIFIED: `paymentgateway/core/Order.php:49-79` constants, `:406-594` config]:
+```
+states: Placed(initial) Quote(initial) AwaitingPayment AwaitingReturn Paid Underway Shipped RequestedRefund
+ Refunded(final) Canceled(final) Rejected(final) Finished(final)
+Place: [Quote] → Placed
+Purchase: [Placed, AwaitingPayment] → AwaitingPayment
+RejectEarly: [Placed] → Rejected
+Reject: [AwaitingPayment] → Rejected
+Cancel: [Placed, AwaitingPayment] → Canceled
+CancelNotActive: [AwaitingPayment] → Canceled
+Pay: [AwaitingPayment] → Paid
+Execute: [Paid] → Underway
+Ship: [Paid, Underway] → Shipped guard isShippingEnabled (Underway ⇒ true, else settings.shippingsEnabled)
+Finish: [Shipped, Underway] → Finished
+FinishNoShipping: [Paid] → Finished
+RequestEarlyRefund: [Paid] → RequestedRefund
+RequestRefund: [Underway, Shipped] → RequestedRefund
+DirectRefund: [Paid, Finished] → Refunded
+GrantRefund: [RequestedRefund] → Refunded
+WaitForReturn: [RequestedRefund] → AwaitingReturn
+RefundAfterReturn: [AwaitingReturn] → Refunded
+DeferRefund: [Underway, Shipped] → AwaitingReturn
+GuaranteeRefund: [Finished] → AwaitingReturn
+after: Place→OrderPlaced, Purchase→OrderPurchased, RejectEarly/Reject→OrderRejected, Cancel→OrderCanceled,
+ CancelNotActive→OrderCanceledNotActive, Pay→OrderPaid, Execute→OrderUnderway, Ship→OrderShipped,
+ Finish/FinishNoShipping→OrderFinished, RequestEarlyRefund/RequestRefund→OrderRequestedRefund,
+ DirectRefund→OrderDirectRefund, GrantRefund→OrderGrantedRefund, WaitForReturn→OrderWaitingForReturn,
+ RefundAfterReturn→OrderRefundedAfterReturn, DeferRefund→OrderDeferredRefund, GuaranteeRefund→OrderGuaranteeRefund
+finalStates: Refunded, Canceled, Rejected, Finished refundableStates: Shipped, Paid
+```
+That is 19 transitions, 7 of them refund paths. `performTransition` special cases: `Ship` sets `was_shipped=true`. `GrantRefund`/`DirectRefund`/`RefundAfterReturn` call `PaymentManager::refund(successfulPayment)` unless the payment is already `Refunded`, and the **payment refund event then moves the order**. `Reject`/`RejectEarly` first cancel all `manual`/`cash` payments. `requestRefund()` tries `RequestEarlyRefund`, then `RequestRefund`.
+
+**Payment→Order bridge** (`core/OrderPaymentEventSubscriber.php`, active only when `ordersEnabled`):
+- PaymentSent: if `can(Purchase)`, run Purchase.
+- PaymentAccepted: if the order is `Rejected`, fire `OrderRejectedReceivedPayment` and refund the payment. Otherwise set `successful_payment`, and if `can(Pay)` run Pay, else log an error.
+- PaymentCanceled: Cancel, else CancelNotActive, else no-op when already Canceled, else log.
+- PaymentRefunded: the first applicable of GuaranteeRefund (only if `shippingsEnabled`), DeferRefund, DirectRefund, GrantRefund, RefundAfterReturn, then save.
+- PaymentRejected: no-op.
+
+### Pattern 4: Operator registry (D-15) without a framework change
+
+`party.Activate` publishes `party.Activated` **before** any plugin's `Register` (`modules/party/registry.go:113` `if err := app.Publish(Activated(ordered)); err != nil {`), and every `Register` runs before any `Boot`. [VERIFIED: file read]
+
+**Recommendation:** define the capability in the paymentgateway package itself, not in `pact`, because the framework must stay payment-agnostic:
+
+```go
+// package paymentgateway (sm-paymentgateway-plugin)
+type HasOperators interface {
+ PaymentOperators() []Operator
+}
+
+// Operator is the successor of core/Operator. Code is the PHP $operatorCode
+// (a translation key, stored in payments.operator and used in settings and
+// the notification route), e.g. "golem15.pgstripe::lang.operators.stripe".
+type Operator interface {
+ Code() string
+ Mode() string // "api" | "manual" | "cash" | "disabled"
+ LogoPath() string
+ AllowedCurrencies() []string // ["*"] = any
+ RequiredFields() []string
+ Enabled(ctx context.Context) (bool, error) // replaces Settings::get($operatorCode)
+ Purchase(ctx context.Context, p *PaymentContext) (Response, error)
+ Refund(ctx context.Context, p *PaymentContext) (Response, error)
+ VerifyNotification(ctx context.Context, r *http.Request, body []byte) bool // default false (PG-003)
+ ExtractUUID(ctx context.Context, r *http.Request, body []byte, input map[string]any) (string, error)
+ HandleNotification(ctx context.Context, p *PaymentContext, r *http.Request, body []byte, input map[string]any) (http.Handler, error)
+}
+```
+
+In Boot, paymentgateway reads `app.Lookup[party.Activated]()`, collects every plugin implementing `HasOperators` (itself first, for cash, manual and disabled) and fails boot on a duplicate `Code()`. `DisabledPayment` is not registered; it wraps payments whose operator is unknown or disabled (`PaymentFactory::wrap`). 18.1 and getaround's StripeConnect follow the same path.
+
+**Operator codes are contracts** (stored in `payments.operator`, used as settings enable keys, base64-encoded in the route) [VERIFIED]:
+- `paymentgateway/operators/CashPayment.php:19` `'golem15.paymentgateway::lang.channels.cash'`
+- `paymentgateway/operators/ManualTransferPayment.php:15` `'golem15.paymentgateway::lang.channels.manualTransfer'`
+- `paymentgateway/operators/DisabledPayment.php:15` `'golem15.paymentgateway::lang.channels.disabled'`
+- `pgstripe/operators/Stripe.php:33` `'golem15.pgstripe::lang.operators.stripe'`
+
+Route segments computed with `printf '%s' CODE | base64`:
+```
+stripe Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl
+cash Z29sZW0xNS5wYXltZW50Z2F0ZXdheTo6bGFuZy5jaGFubmVscy5jYXNo
+manualTransfer Z29sZW0xNS5wYXltZW50Z2F0ZXdheTo6bGFuZy5jaGFubmVscy5tYW51YWxUcmFuc2Zlcg==
+disabled Z29sZW0xNS5wYXltZW50Z2F0ZXdheTo6bGFuZy5jaGFubmVscy5kaXNhYmxlZA==
+```
+D-04 says "`_paymentgateway/stripe/...`", but the real PHP path segment is base64 (`support/OperatorUrlizer.php` `base64_encode($operator::$operatorCode)`). Keep base64 for compatibility. Accepting a literal short alias such as `stripe` as well would be a new behavior and needs a decision.
+
+`payments.data` holds the decorator's non-column attributes, snake_cased (`traits/ModelDecorator.php:55-64` `$field = Str::snake($field); … $this->dataAttributes[$field] = $value;`): `email`, `return_url`, `is_paid_in_stripe`, `charge_id`, `payment_intent_id`, the Details parameters, and (from Horoskopia) `stripe_checkout_session_id` and `stripe_payment_intent`. The Go payment data map must keep these keys.
+
+### Pattern 5: Notification route
+
+PHP (`paymentgateway/routes.php:5-16`) [VERIFIED]:
+```php
+Route::any(
+ '_paymentgateway/{paymentOperator}/{mode?}/{modeResult?}',
+ static function ($urlizedOperatorCode, $mode = null, $modeResult = null) {
+ $data = Input::all();
+ if($mode && $modeResult){
+ $data[$mode] = $modeResult;
+ }
+ return PaymentManager::processNotification(
+ OperatorUrlizer::deUrlize($urlizedOperatorCode),
+```
+The route is CSRF-whitelisted in PHP (`'kernel.middleware.csrf'` → `whitelist('_paymentgateway/*')`). `pact.Router` has only Get/Post/Put/Patch/Delete and ServeMux has no optional segments. **No framework change is needed:** register three patterns (`/_paymentgateway/{op}`, `/{op}/{mode}`, `/{op}/{mode}/{result}`) × the five methods in a loop (ServeMux `GET` also serves `HEAD`). Plugin routes carry no CSRF middleware (CSRF is cabana-admin only), so the route is CSRF-free by default.
+
+Go `processNotification` order:
+1. De-urlize with `base64.StdEncoding` (PHP `base64_decode` is lenient; accept padded and unpadded input).
+2. Resolve the operator. Only registered **and enabled** operators resolve; anything else returns 404 (PHP throws `OperatorClassNotFoundException`, a 500).
+3. **Verify the notification before any database lookup**, using a raw body read with a cap. PHP looks the payment up first and then verifies.
+4. Extract the UUID and find the payment (404 `Payment not found`).
+5. Handle.
+
+`log_requests` logging must go through a redactor (`support/SensitiveLogRedactor.php`, PG-006).
+
+### Pattern 6: Stripe operator (PaymentIntent + Checkout, D-16/D-17)
+
+- **Client factory:** `stripe.NewClient(key, stripe.WithBackends(stripe.NewBackendsWithConfig(&stripe.BackendConfig{URL: …, HTTPClient: …})))`. Production leaves `URL` nil; tests point it at `httptest`. The key is the mode's secret (`stripe.sapiKey`/`stripe.stestApiKey` equivalents).
+- **PaymentIntent mode:** create with `Amount = cart.TotalGross().Minor()` (exact; do **not** port `getAmountBasic() * 100`, which in PHP gives `3984.0000000000005` and is only masked by `http_build_query`), `Currency = lower(iso)`, `Metadata{"payment_uuid": uuid, "email": …}`, and the payment method or `confirm` as in PHP. Map statuses: `requires_action` → additional step (Send; response carries `client_secret`, `payment_intent_id`, `payment_uuid` and the publishable key per D-17); `succeeded` → Send+Accept; `processing` → Send; `requires_payment_method`/`canceled` → error (Send+Reject).
+- **Checkout mode (new):** create a session with `Mode=payment`, line items from the cart (or one aggregated line at `TotalGross().Minor()`, the safer choice for tax rounding parity), `SuccessURL` = `details.return_url`, `ClientReferenceID` = payment UUID, `Metadata{"payment_uuid": uuid}` (and `PaymentIntentData.Metadata` too, so `payment_intent.*` events correlate). Store `stripe_checkout_session_id` in `payments.data` and apply Send. The response is `{redirect_url: session.URL}`.
+- **Webhook events to accept:** `checkout.session.completed` (accept when `payment_status == "paid"`; when `unpaid`, an async method is in progress, so wait), `checkout.session.async_payment_succeeded`, and `payment_intent.succeeded` (for PaymentIntent `processing` flows). Optional: `checkout.session.async_payment_failed` / `payment_intent.payment_failed` → Reject. The UUID comes from `data.object.metadata.payment_uuid`. Idempotency: an event replay must be a no-op (`can(Accept)` false ⇒ 200).
+- **Browser return (`?pgUuid=`, unsigned):** never accept on unsigned input. Optionally retrieve the PaymentIntent or Session server-side and accept if Stripe says it is paid. Always redirect to `return_url`. This is what the PHP code intended (`processNotification` → `Redirect::to($this->returnUrl)`).
+- **Mode selection (planner's call, D-16):** a pgstripe setting `checkout_mode` (`payment_intent` | `checkout`, default `checkout`, matching the layer default), overridable per order through a Details parameter. A setting alone is enough for MVP.
+
+### Pattern 7: Legacy cart/details reader + v2 writer (D-11)
+
+The legacy JMS shape comes from `metadata/*.yml` (all read) and the real row:
+```json
+// cart (Horoskopia row, strings redacted)
+{"i":{"":{"dn":"","c":1,"p":["2439.0000","PLN"],"da":{"natal_chart_id":1,"saved_chart_id":1,"product_type":""},
+ "t":23,"i":"","type":"generic"}},
+ "d":"PLN"}
+// details
+{"p":[],"o":"stripe","d":"","r":"","e":""}
+```
+Key map [VERIFIED: `metadata/*.yml` read]:
+- Cart: `i` = ordered map id→item, `s` = ShippingMethod, `d` = defaultCurrency.
+- Item and AbstractItem: `dn` description, `c` count (int), `p` Money `[amountString, iso]`, `da` data (array), `t` tax (int), `i` id, `type` discriminator: `generic`→Item, `adjustable`→AdjustableItem, plus app-registered names such as `InvoiceItem`.
+- AdjustableItem adds `ad` = map id→Adjustment, with discriminator `type`: `value`→ValueAdjustment {`dn`,`i`,`v` Money}, `percent`→PercentAdjustment {`dn`,`i`,`p` int}.
+- ShippingMethod: `n` name, `c` Money, `t` tax (**float** type).
+- Details: `o` operator, `d` description, `r` returnUrl, `e` email, `s` ShippingAddress, `p` parameters (secureParameters excluded).
+- ShippingAddress: `rfn`,`rln`,`s`,`bn`,`fn`,`z`,`cty`,`c`.
+
+Reader rules:
+1. An empty PHP array serializes as `[]`, not `{}` (see `"p":[]`). `p`, `da`, `i` and `ad` must accept `[]` as an empty map.
+2. Preserve object key order of `i` and `ad` with a `json.Decoder` token walk. Cart totals use the first item's currency, and `reset($this->items)` is order-sensitive.
+3. JMS omits null properties, so tolerate missing keys.
+4. A `t` float in ShippingMethod may arrive as `23` or `23.0`.
+5. An unknown `type` (an app type not registered) must fail loudly, or round-trip as opaque raw JSON. Never drop it.
+6. The detection rule: a top-level `"v"` number means versioned; no `v` means legacy.
+
+**v2 writer:** `{"v":2,"currency":"PLN","items":[{"id","type","description","count","net_price":{"amount":"24.3900","currency":"PLN"},"tax":23,"data":{…},"adjustments":[…]}],"shipping":{…}}`. Items are an **array**, so order is explicit. `details` is `{"v":2,"operator","description","return_url","email","shipping_address","parameters"}`.
+
+**Extension registry (D-11 pt 3):** `cart.RegisterItemType(name string, legacyNames []string, newData func() any)` registers typed extra data for `data`/`da`, and `cart.RegisterDetailsParam(...)` does the same for Details parameters. Names are allowlisted (no class names, PG-007). Known real users: invoicer's `InvoiceItem` (same fields as Item; `portal.golem15.com/plugins/golem15/invoicer/Plugin.php:88-106`) and the shop plugin (uses Item/Cart/Details/ShippingAddress directly). The `ItemFormatter` type map (`golem15.paymentgateway.items.types`: `GENERIC`, `ADJUSTABLE`, plus app entries) moves into the same registry. `pg:update-metadata` and `pg:update-orders` become **no-ops or are dropped**, since they regenerate JMS YAML and rename a discriminator in stored rows. A future rename should be done by the registry's `legacyNames` alias, not a bulk rewrite (D-11 pt 2).
+
+### Pattern 8: Settings (D-08, D-13, F-5, F-6)
+
+The PHP keys (flat, literal dots; item `golem15::paymentgateway.settings`, `models/Settings.php:32`) read from the Horoskopia dump, values redacted [VERIFIED: dump parse]:
+```
+isSetUp defaultCurrency ordersEnabled shippingsEnabled backendPreferLocal frontendPreferLocal currencyAutoCreate
+useQuotes useUnderway orderPlacedToCanceledTimeout paymentSentToCanceledTimeout paymentCreatedToCanceledTimeout
+orderShippedToFinishedTimeout enableGuest exchangeRateProvider
+stripe.apiKey stripe.sapiKey(ENCRYPTED-LARAVEL) stripe.testApiKey stripe.stestApiKey(ENCRYPTED-LARAVEL)
+stripe.webhookSecret stripe.testWebhookSecret(ENCRYPTED-LARAVEL) stripe.testMode stripe.3dsecure(legacy) stripe.stestPublishableKey(legacy)
+golem15.paymentgateway::lang.channels.cash golem15.paymentgateway::lang.channels.manualTransfer golem15.pgstripe::lang.operators.stripe
+golem15.paymentgateway::lang.channels.manualTransfer.{name,receiverAddress,bankDetails,swift,bankAccNo}
+mailer.credentials.email mailer.credentials.name mailgroup mailer.{order,payment,admin_order,admin_payment}.
+```
+Switch values are stored as strings `"1"`/`"0"`. `isSetUp` and `testMode` are booleans.
+
+**Go design:**
+- `golem15_paymentgateway_settings` (singleton row) with snake_case columns: default_currency (required; replaces the SetUp wizard), orders_enabled, shippings_enabled, use_quotes, use_underway, enable_guest, currency_auto_create, exchange_rate_provider (`fixer`|`nbp`), the 4 timeouts (number 1–30, replacing `knob`), backend/frontend_prefer_local, cash_enabled, manual_transfer_enabled, manual_transfer_{name,receiver_address,bank_details,swift,bank_acc_no}, log_requests.
+- A second settings page, **Mailer** (D-08): mailer_from_email, mailer_from_name, admin_email (the `dual` widget's left side), admin_group_id (right side, a dropdown of backend groups), and one boolean per mail switch. The PHP `mailgroup` is stored as a JSON pair.
+- `golem15_pgstripe_settings`: enabled, test_mode, checkout_mode, publishable_key (plain), test_publishable_key (plain), secret_key/test_secret_key/webhook_secret/test_webhook_secret as `lagoon.Encrypted`, write-only. Env fallback: `SUMMER_PGSTRIPE_*` for each secret when the column is empty; PHP falls back only for the webhook secret (`config('golem15.pgstripe::webhook_secret')` = `STRIPE_WEBHOOK_SECRET`). 18.1 adds subscription secret columns here.
+- Import commands: `paymentgateway:import-winter` and `pgstripe:import-winter` read `system_settings` where `item = 'golem15::paymentgateway.settings'`, the same pattern as `fonoteka.go/plugins/golem15/golem/console/import_settings.go`. Laravel payloads (`eyJpdi…`, from `Crypt::encryptString`, so no PHP serialization wrapper) go through `lagoon.DecryptLaravelPayload(payload, appKey)` and plaintext values pass through, matching PHP's lazy-decrypt tolerance (`StripeSettings::readEncryptedSetting` catches `DecryptException` and returns the raw value).
+
+### Pattern 9: Events and mail
+
+- Use typed festival events, one struct per PHP class, with a doc comment naming the PHP class (precedent: `sm-user-plugin/classes/events.go` `RegisterEvent` "Winter's golem15.user.register"). Add an exported `const …EventName = "Golem15\\PaymentGateway\\Events\\OrderPaid"` for traceability. Event payloads carry `Order` (model + state) or `Payment`, plus `User`.
+- Mail: postcard templates `golem15.paymentgateway::mail..` (PL default) and `…-en`, following the user-plugin convention (`fonoteka.go/plugins/golem15/user/plugin.go:184-193`: `"golem15.user::mail.activate"`, `"golem15.user::mail.activate-en"`). Postcard does no locale selection (`modules/postcard/README.md:31` "No locale selection: callers pass the full template name, including any locale suffix."). Template variables: `order` (hashId, state, totals…), `user` (first_name, last_name, email), `posName`, `posEmail`, `orderUrl`. `orderUrl` came from the CMS page hosting the component (`OrderComponentResolver`); in Go it needs a setting such as `order_url_template` (`https://shop.example/orders/{hash}`).
+- Mailer gating table (event → setting key → template), read from `core/*Mailer.php`. Customer order mailers: 17 events, each `mailer.order.` → `mail.order.`. Employee order: placed, paid, requestedRefund, customerCreatedOrder → `mail.admin_order.*`. Customer payment (only when `ordersEnabled` is off): accepted, canceled, refunded, rejected → `mail.payment.*`. Employee payment: accepted → `mail.admin_payment.accepted`. Subscribers switch on `ordersEnabled` (`Plugin.php:572-579`).
+- **PHP bugs to decide, not copy:** `onOrderUnderway` uses setting `'mailer.order.placed'` with template `mail.order.underway`, which has **no file**. `mail.order.placed` exists but is unregistered, and `mailer.order.placed` has no switch in `mailer_fields.yaml`. Recommendation: register `order.placed`, add an `order.underway` template plus a `mailer.order.underway` switch, and record this in the README as a fix.
+- Recipient: the user's email unless the address contains `guest@`; else `details.email` unless it is the literal `'golem15.paymentgateway::lang.labels.anonymous'`. Admin recipients: the `mailgroup` email or the members of the backend group.
+
+### Pattern 10: Scheduled commands
+
+`pact.HasSchedule` returns `[]pact.ScheduledCommand{{Command:"pg:cancel-sent-payments", Cadence: pact.Daily()}, …, {Command:"pg:update-currencies", Cadence: pact.Every(10*time.Minute)}}` (`modules/pact/capabilities.go:124` `func Daily() Cadence { return DailyAt(0, 0) }`, `:134` `func Every(d time.Duration) Cadence {`). Conga rejects an interval that does not divide 24h; 10 minutes divides it. `pg:update-currencies` updates only rows with `updated_at < today` unless `--force`, so ten-minute runs are cheap. Command semantics:
+- cancel-sent-payments: `Sent` older than `paymentSentToCanceledTimeout` days → Cancel.
+- cancel-created-payments: the same with `paymentCreatedToCanceledTimeout`.
+- cancel-placed-orders: `Placed` older than `orderPlacedToCanceledTimeout`.
+- finish-orders: `Shipped`→Finish if shippings are enabled, else `Paid`→FinishNoShipping, after `orderShippedToFinishedTimeout` (by `updated_at`).
+
+### Pattern 11: Customer order API (D-06) and authorization
+
+The `Orders` component's access check (`components/Orders.php` `canActOnOrder`) [VERIFIED]: (1) the logged-in user owns the order; (2) the session's current order id (no headless equivalent; drop it); (3) if `enableGuest`, a valid `guest_token` (HMAC-SHA256 over `timestamp|orderId` with `app.key`, 24h TTL, `support/GuestTokenGenerator.php`); (4) the `guest_code` query value equals `orders.guest_code`. Recommended endpoints:
+```
+GET /_paymentgateway/api/v1/orders (jwt.auth; the user's orders, paginated, like prepareOrderList)
+GET /_paymentgateway/api/v1/orders/{hash} (owner | ?guest_code= | ?guest_token=)
+GET /_paymentgateway/api/v1/payment-methods (enabled operators: code, name, logo, mode, currency support)
+GET /_paymentgateway/api/v1/shippings (when shippings are enabled)
+POST /_paymentgateway/api/v1/orders/{hash}/payment-method {payment_method} state Placed only; must be an enabled operator code
+POST /_paymentgateway/api/v1/orders/{hash}/shipping {shipping_id} state Placed only
+POST /_paymentgateway/api/v1/orders/{hash}/process → {redirect_url} | {requires_action, client_secret, payment_intent_id, payment_uuid, publishable_key} | {errors}
+POST /_paymentgateway/api/v1/orders/{hash}/cancel state Placed | AwaitingPayment
+POST /_paymentgateway/api/v1/orders/{hash}/refund-request canBeRefunded
+POST /_paymentgateway/api/v1/orders/{hash}/items/{itemId}/pieces (add) / DELETE (subtract) / DELETE …/items/{itemId}
+```
+Order lookup goes by `hash_id` through `HashIdsHelper::parseKey`, which decodes with the configured salt and falls back to the legacy salt, then takes the first id. Use go-hashids with `PG_HASHIDS_SALT` (`summer` config key `golem15.paymentgateway.hashids_salt` with `SUMMER_*` env) and the legacy salt `'KEIOS.PAYMENTGATEWAY'` as decode-only (`support/HashIdsHelper.php:21` `ALPHABET = 'abcdefghijklmnopqrstuvwyzABCDEFGHIJKLMNOPQRSTUVWYZ1234567890'`, `:27` `LEGACY_SALT = 'KEIOS.PAYMENTGATEWAY'`). Prefer a lookup by the `hash_id` column (indexed) plus the hashids decode as a cross-check, so a row with a re-salted `hash_id` still resolves.
+
+Optional authentication: `bouncer` has no optional-JWT middleware and sm-user-plugin registers only `jwt.auth` (`plugin.go:219`). Implement a plugin-local wrapper: when `Authorization: Bearer` is absent, continue anonymously; otherwise delegate to `reg.Middleware("jwt")` (a 401 on an invalid token), and read the user with `bouncer.User(ctx)`. Rate-limit with `surf.Use("throttle:paymentgateway-api")` (the journal pattern).
+
+Money in JSON: `{"amount":"2999.9700","amount_minor":299997,"currency":"PLN"}`. The exact string is the contract and the minor value is a convenience.
+
+### Pattern 12: Admin screens
+
+- Lists and forms: copy the PHP YAML (`models/{order,payment,shipping,quoteable,currency}/*.yaml`, `controllers/*/config_*.yaml`) and adapt it. MoneyWidget fields become `type: money`, TaxWidget becomes `type: number` (0–100 with a `%` hint), `knob` becomes `number`, and `dual` becomes two fields. The `_*_field.htm` list/form partials become `AdminPartialData` view models.
+- Order "manage" becomes the form preview plus **record actions**: one `pact.AdminRecordAction` per transition with `Applies: fsm.Can(t)` and `Permissions: manage_orders`, plus `createCashPayment`/`createBankPayment`. Payment `accept`/`refund` actions need `manage_payments`. Side-panel and cart partials use `AdminPartialData` (no GORM models in view models).
+- The navigation is conditional on settings (`ordersEnabled`, `useQuotes`, `shippingsEnabled`). Check whether `HasNavigation` can hide items at runtime; if not, show the items and let the controllers answer "disabled" (PHP `sendToPayments()`).
+- Quotes `manage` (add quoteable, change item price/count/tax, remove item, process quote) is the largest UI piece. Build it as a `type: widget` custom element plus `AdminAction`s with `Payload`, or as a plugin field type (Phase 16.2). **Sizing risk.** Alternative: ship Quotes CRUD plus record actions in Phase 18 and the cart editor widget in the same plan if time allows.
+- Stats headers (this/last month income, sales) become `headerPartial` with `AdminPartialData`.
+
+### Pattern 13: Test host (D-03/D-04)
+
+Lives in `sm-pgstripe-plugin`, which can import both paymentgateway and pgstripe without a module cycle. `go.work` uses `../summercms.go`, `../sm-paymentgateway-plugin`, and a sm-user-plugin checkout at remote HEAD (`../sm-summercmsio-app/plugins/golem15/user` is at `24b16b1` = `v0.1.1` = remote `master`; journal's go.work points at the grzybyfunkcjonalne copy `0fe5b91`). Use the journal precedent `integration_test.go` / `newIntegrationEnv` with testcontainers Postgres.
+- A fake consumer plugin `acme.books` builds Cart, Item and Details and calls `Orders.Make`.
+- The Stripe fake is an `httptest.Server` answering `/v1/payment_intents`, `/v1/checkout/sessions`, `/v1/refunds`, and `/v1/checkout/sessions/{id}` / `/v1/payment_intents/{id}` retrieve.
+- Signed events come from `webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{Payload, Secret, Timestamp})`, POSTed to `/_paymentgateway/`.
+- Assert: payment `Accepted`, order `Paid`, `successful_payment_id` set, an `OrderPaid` listener called once, the mail in `postcard.MemoryDriver`, and a replayed event that changes nothing.
+- Path (b): the consumer's own handler calls `Payments.RecordExternal(ctx, tx, order, stripeCode, data)` then `Send` and `Accept`, and gets the same assertions.
+
+### Anti-Patterns to Avoid
+
+- **`float64` anywhere in money, tax, ratio or NBP parsing.** Decode NBP/Fixer JSON with `json.Decoder.UseNumber()` and parse the `json.Number` string exactly. NBP Table A sometimes quotes 6 decimals for weak currencies `[ASSUMED]`; the `numeric(8,4)` column rounds them, so round half away from zero to 4 places explicitly before storing, to mirror MySQL `decimal(8,4)`.
+- **Trusting `?pgUuid=` or any unsigned input to change payment state.**
+- **`map[string]Item` for the cart.** Go map order is random, so totals and JSON break.
+- **Copying PHP's find-then-verify order** in the notification handler. Verify first.
+- **Re-encoding the Stripe event JSON before verification.** Hash the raw bytes and cap the body size.
+- **Writing v2 cart rows during import or a migration.** Legacy rows are never rewritten (D-11).
+- **Putting a payment-specific capability in `pact`.** Keep it in paymentgateway (the framework stays domain-neutral).
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Stripe HTTP calls, retries, form encoding | Raw `net/http` to api.stripe.com | stripe-go v87 `stripe.NewClient` | Idempotency keys, error types, API version header |
+| Webhook HMAC check | Own HMAC/timestamp parser | `webhook.ValidatePayload` (5-minute tolerance, multiple v1 signatures) | Replay window and multi-signature handling are subtle |
+| Hashids | Own encoder | `speps/go-hashids/v2` | Must match 30+ existing hash IDs per app exactly (verified) |
+| Laravel decrypt at cutover | Own AES-CBC+HMAC | `lagoon.DecryptLaravelPayload` | Exists, MAC-checked |
+| Column encryption | Own AES | `lagoon.Encrypted` | Key rotation, redaction in JSON and `%v` |
+| Scheduling | Own cron goroutines | `pact.HasSchedule` + conga | Leader election, one execution per period |
+| HTML sanitizing (bank details) | Regex strip | seaglass (cabana richeditor) | PG-008 expects allowed tags kept |
+| Mail rendering | `html/template` by hand | postcard | Layouts, plain text part, header injection checks |
+| JSON response encoding | `json.NewEncoder` | `wire.WriteJSON` | Unescaped HTML, no trailing newline |
+
+**Key insight:** the only domain code worth hand-writing is MoneyRight's arithmetic, because its exact quirks (truncate at scale 5 then half away from zero, int input meaning cents, 2-decimal default rounding) are the compatibility contract, and the PHP vectors above pin them down.
+
+## Runtime State Inventory
+
+This phase ports a live plugin, so the cutover state matters even though no app cuts over here. The planner should record these items in the plugin READMEs ("Cutover" section); they are not tasks for this phase.
+
+| Category | Items Found | Action Required |
+|----------|-------------|------------------|
+| Stored data | `golem15_paymentgateway_*` rows in **MySQL** in every PHP app (6 tables). `orders.cart`/`details` in the JMS shape. `payments.data` snake_case JSON. `system_settings` item `golem15::paymentgateway.settings` with flat dotted keys and Laravel-encrypted Stripe secrets | Cutover (not this phase): MySQL→Postgres row copy; `paymentgateway:import-winter` + `pgstripe:import-winter` (this phase builds them); the legacy reader (this phase) reads rows in place; no bulk rewrite |
+| Live service config | Stripe dashboard webhook endpoints per app and mode, their **API version** (`acacia`/`basil`, not `endive`), and their URL (Horoskopia uses its own controller URL; `_paymentgateway/` had no working webhook in PHP) | Document in the pgstripe README: create or point an endpoint to `/_paymentgateway/Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl`, subscribe `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `payment_intent.succeeded`; signature-only verification makes the API version irrelevant |
+| OS-registered state | `cron-entries.txt` lists 5 `artisan pg:*` daily crontab lines on PHP hosts | At cutover remove the PHP crontab; conga's schedule takes over (or `schedule:run --once` from system cron) |
+| Secrets / env vars | `PG_HASHIDS_SALT` (`config('paymentgateway.hashids_salt', env('PG_HASHIDS_SALT'))`), `STRIPE_WEBHOOK_SECRET`, `APP_KEY` (Laravel decrypt **and** `GuestTokenGenerator` HMAC key) | The Go config must accept the same salt (else new hash IDs diverge from the sequence; old ones still decode via fallback). Guest tokens issued by PHP verify in Go only if Go uses the PHP `app.key` bytes as the HMAC key (24h TTL, so acceptable to drop at cutover) |
+| Build artifacts | `storage/temp/protected/paymentgateway` JMS cache, `metadata/AbstractItem.yml` regenerated by `pg:update-metadata` | None in Go (no JMS); verified by reading `UpdateAbstractItem.php` |
+
+## Common Pitfalls
+
+### Pitfall 1: Treating Money as minor units
+**What goes wrong:** native amounts such as `85.4193` round to `85.42` on read, and totals drift from PHP.
+**Why it happens:** D-09 says "minor units" and moneyphp is the assumed model.
+**How to avoid:** scale 4 internally, `Minor()` only at Stripe and the API boundary, and the PHP vectors as table tests.
+**Warning signs:** an `int64` field named `cents`, or `numeric` scanned into `float64` or `big.Float`.
+
+### Pitfall 2: stripe-go webhook API-version rejection
+**What goes wrong:** every webhook from an existing endpoint fails with "received event with API version …".
+**How to avoid:** `webhook.ValidatePayload` + a minimal local struct `{id,type,api_version,data:{object:{id,object,metadata,payment_status,payment_intent,status}}}`.
+**Warning signs:** tests pass with `GenerateTestSignedPayload` using `stripe.APIVersion` but fail with an older `api_version`. Add that case as a test (the probe output above).
+
+### Pitfall 3: Raw body consumed or modified before verification
+**What goes wrong:** the signature fails because `r.ParseForm()` or middleware consumed the body, or the input-merge step re-encoded it.
+**How to avoid:** read the body once with `http.MaxBytesReader`, pass the bytes to verification, and parse form/JSON from the same bytes for `input`.
+
+### Pitfall 4: `[]` vs `{}` in legacy JSON
+**What goes wrong:** `json.Unmarshal` into `map[string]any` fails on `"p":[]`.
+**How to avoid:** custom `UnmarshalJSON` that accepts both. Golden fixture: the Horoskopia details row.
+
+### Pitfall 5: Cart order and currency
+**What goes wrong:** totals take the currency of a random item, and JSON order changes on every save.
+**How to avoid:** an ordered slice plus an index map, and legacy decoding by token stream.
+
+### Pitfall 6: Event timing vs transaction
+**What goes wrong:** an `OrderPaid` listener reads the DB and sees `AwaitingPayment` (PHP behavior), or mail goes out for a rolled-back payment.
+**How to avoid:** save, then fire events in the transaction, and send mail and external side effects in `lagoon.AfterCommit`. Check the after-commit callback-order todo (`.planning/todos/pending/lagoon-readme-after-commit-callback-order.md`) when registering GORM callbacks.
+
+### Pitfall 7: Payment `numeric(10,4)` vs order `numeric(12,4)`
+**What goes wrong:** an order total of at least 1,000,000 cannot create its payment row.
+**How to avoid:** validate at `Payments.Create` with a typed error. Keep the PHP column sizes (non-breaking contract).
+
+### Pitfall 8: Operator code as settings key
+**What goes wrong:** the PHP flag key `golem15.pgstripe::lang.operators.stripe` cannot be a cabana column.
+**How to avoid:** `Operator.Enabled(ctx)` reads the operator plugin's own setting, and the import command maps the PHP key.
+
+### Pitfall 9: Duplicate events from Stripe and the browser return racing
+**What goes wrong:** a double Accept, or a double `OrderPaid`.
+**How to avoid:** `SELECT … FOR UPDATE` on the payment row inside the transaction before `can(Accept)`. Replays and races become no-ops.
+
+### Pitfall 10: Guest access leaks
+**What goes wrong:** `guest_code` compared with `==`, `guest_token` promoted to a user identity, or enumeration through sequential hash IDs.
+**How to avoid:** `subtle.ConstantTimeCompare`. Tokens are bound to one order id (PG-002). Return 404 for both missing and forbidden.
+
+### Pitfall 11: Settings defaults and the SetUp wizard
+**What goes wrong:** PHP `initSettingsData` sets `defaultCurrency = 'USD'` and `isSetUp = false`, and boot skips subscribers until `isSetUp`.
+**How to avoid:** make `default_currency` required (validation) and drop `isSetUp` gating, or map it to "default_currency set". Test that a fresh install refuses to create orders until the currency is set, with a clear error.
+
+## Code Examples
+
+### stripe-go v87: fake backend, PaymentIntent, Checkout Session, webhook (executed this session, PASS)
+
+```go
+// Source: probe run against github.com/stripe/stripe-go/v87@v87.0.0 (stripe_client.go:332,536; stripe.go:211,1724; webhook/client.go:64,127,266)
+srv := httptest.NewServer(fakeStripe) // answers /v1/payment_intents, /v1/checkout/sessions
+backends := stripe.NewBackendsWithConfig(&stripe.BackendConfig{
+ URL: stripe.String(srv.URL), HTTPClient: srv.Client(), MaxNetworkRetries: stripe.Int64(0),
+})
+sc := stripe.NewClient("sk_test_fake", stripe.WithBackends(backends))
+
+pi, err := sc.V1PaymentIntents.Create(ctx, &stripe.PaymentIntentCreateParams{
+ Amount: stripe.Int64(3984), Currency: stripe.String("pln"),
+ Metadata: map[string]string{"payment_uuid": uuid},
+})
+// pi.Status == stripe.PaymentIntentStatusRequiresAction, pi.ClientSecret
+
+cs, err := sc.V1CheckoutSessions.Create(ctx, &stripe.CheckoutSessionCreateParams{
+ Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
+ SuccessURL: stripe.String(returnURL),
+ LineItems: []*stripe.CheckoutSessionCreateLineItemParams{{
+ Quantity: stripe.Int64(1),
+ PriceData: &stripe.CheckoutSessionCreateLineItemPriceDataParams{
+ Currency: stripe.String("pln"), UnitAmount: stripe.Int64(3984),
+ ProductData: &stripe.CheckoutSessionCreateLineItemPriceDataProductDataParams{Name: stripe.String("Item")},
+ },
+ }},
+ Metadata: map[string]string{"payment_uuid": uuid},
+})
+// cs.URL → {redirect_url}
+
+// Webhook: signature only, then minimal decode (works for any API version)
+sp := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{Payload: payload, Secret: "whsec_test", Timestamp: time.Now()})
+err = webhook.ValidatePayload(sp.Payload, sp.Header, "whsec_test") // nil for acacia AND endive events
+// webhook.ConstructEvent(...) → error for "2025-02-24.acacia" (see F-4)
+// missing header → "webhook has no Stripe-Signature header"; wrong secret → "webhook had no valid signature"
+```
+Requests observed by the fake: `POST /v1/payment_intents v=2026-09-30.endive amount=3984 … meta=u-1` and `POST /v1/checkout/sessions v=2026-09-30.endive … mode=payment meta=u-1`.
+
+### Hashids parity (executed, 24/24 match with PHP hashids 5.0.2)
+
+```go
+// Source: probe; github.com/speps/go-hashids/v2@v2.0.1
+hd := hashids.NewData()
+hd.Salt = salt // PG_HASHIDS_SALT; legacy decode-only "KEIOS.PAYMENTGATEWAY"
+hd.MinLength = 8 // 32 for guest_code = encode(id*118)
+hd.Alphabet = "abcdefghijklmnopqrstuvwyzABCDEFGHIJKLMNOPQRSTUVWYZ1234567890"
+h, _ := hashids.NewWithData(hd)
+code, _ := h.EncodeInt64([]int64{id}) // legacy salt: 1→"GWbRDV4M", 42→"wLVK9Pbz", 118→"Md5jwjVY"
+ids, _ := h.DecodeInt64WithError(code)
+```
+
+### Exact decimal scan (sketch; values from the D-10 analysis)
+
+```go
+// Amount4 is a numeric(p,4) column in units of 1e-4. Scan rejects >4 fraction
+// digits, exponents and non-digits; it never rounds (D-10).
+func parseAmount4(s string) (int64, error) {
+ neg := strings.HasPrefix(s, "-")
+ s = strings.TrimPrefix(strings.TrimPrefix(s, "-"), "+")
+ whole, frac, _ := strings.Cut(s, ".")
+ if whole == "" || len(frac) > 4 || !allDigits(whole) || !allDigits(frac) {
+ return 0, fmt.Errorf("money: %q is not a decimal with at most 4 places", s)
+ }
+ frac += strings.Repeat("0", 4-len(frac))
+ v, err := strconv.ParseInt(whole+frac, 10, 64) // overflow → error, never wraps
+ if neg { v = -v }
+ return v, err
+}
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| stripe-go package-level `paymentintent.New(params)` with the global `stripe.Key` | `stripe.NewClient(key, …).V1PaymentIntents.Create(ctx, params)` | stripe-go v82+ (client API); v87 current `[ASSUMED: version of introduction]` | Per-mode keys without global state; test backends per client |
+| `webhook.ConstructEvent` lenient on API version | Rejects other release trains | present in v87 `webhook/client.go:177-194` | See F-4 |
+| PHP operator: synchronous accept + unsigned redirect | Signed webhook-driven accept + Checkout mode | This phase (D-14, D-16) | The browser return becomes informational |
+| JMS-serialized cart with discriminator classes | Versioned JSON v2 + typed extension registry | This phase (D-11) | One-way for each app once Go writes |
+
+**Deprecated/outdated:**
+- `pg:update-metadata`, `pg:update-orders`: JMS-era maintenance with no Go equivalent; replaced by the registry's `legacyNames`.
+- `Yahoo` currency exchange (`support/YahooCurrencyExchange.php`): not selectable in settings (`fixer`|`nbp` only); do not port.
+- `CreditCard` value object, `CreditCardValidator` (`card_number`, `card_expiry_date` rules): only for raw-card operators (none ship; Stripe uses `SECURE_CARD`). Port only if the planner wants parity; recommendation is to skip and note it.
+
+## PHP test → Go case map (D-02 behavioral spec, last plan)
+
+| PHP test (file) | Behavior | Go case (package) |
+|---|---|---|
+| unit/CartTest (5): fill, add/subtract pieces, get items, ArrayAccess, missing item | Cart ops, `subtractPiece` never below 1, missing item → error | `cart` table tests |
+| unit/ItemTest (6): valid data, count change, formatable, invalid description/count/tax | Constructor validation (string description, int count, int tax) | `cart.NewItem` errors |
+| unit/DetailsTest (3): getters, serialization, secure serialization | secureParameters never persisted | `cart` v2 + legacy round trip |
+| unit/ExtendedStateMachineTest (2) | graph-scoped events, null graph | `fsm` dispatch tests (map to "after callbacks fire once per apply") |
+| unit/FactoryConfigurationTest (3): register valid, missing class, invalid class | Registry rejects duplicates and nil; only `Operator` implementers | `operators` registry tests |
+| functional/CartFunctionalTest (3), ItemFunctionalTest (2) | (JSON) serialization, totals | legacy reader golden fixtures + v2 round trip + totals vectors |
+| functional/PaymentFiniteStateMachineTest (6) | Allowed transitions from each payment state | `fsm` payment matrix (6 states × 5 transitions) |
+| functional/PaymentModuleFunctionalTest (6) | create, cancel, refund, API responses accept/cancel/refund | `payments` service with a fake operator |
+| functional/PaymentTest (3), ShippingTest (2) | Financial trait: Money attribute ↔ `*_stored`/currency; protected fields; extra data storage | `money.Amount4` Scan/Value + model mass-assignment (Fillable excludes `*_stored`, `state`, `user_id`) |
+| security/AccessControlTest (5) | PG-002 no `?login=` impersonation; guest token functional and order-scoped; PG-003 forged notification refused; PG-004 controllers require permissions | API authz tests; notification route tests; cabana controller `RequiredPermissions` |
+| security/DataHandlingTest (13) | PG-001 mass assignment (amount, financial fields, user_id); PG-005 salt required and legacy decode fallback; PG-006 log redaction; PG-007 serializer allowlist; PG-008 bank details sanitized and allowed tags kept | `lagoon.Fill` allowlist tests; hashids config tests; redactor tests; registry allowlist; settings richeditor sanitize |
+| security/StateTest (4) | PG-001 state not fillable; out-of-order transition rejected; PG-003 no signature → 403; verify before process | FSM + route ordering tests |
+| pgstripe security/AuthenticationTest (4) | STRIPE-002 signature required; valid accepted, invalid rejected | webhook tests with `GenerateTestSignedPayload` (positive + negative + missing secret) |
+| pgstripe security/DataHandlingTest (5) | STRIPE-001 secrets encrypted at rest, round trip, lazy decrypt of plaintext, SDK pinned | `lagoon.Encrypted` columns, import of plaintext and encrypted values, `go.mod` pin check |
+| pgstripe reliability/ReliabilityTest (2) | STRIPE-006 idempotency marker for the **subscription job** | **18.1** (not this phase); Phase 18 adds its own replay-idempotency test for payment events |
+
+## Suggested Plan Split (lean: 6 plans)
+
+1. **Framework: cabana `money` field and settings-form gaps (summercms.go).**
+ - `type: money` with options `currencyFrom` (sibling column, read-only) or `currency` (literal ISO), and `scale` (2–4, default 4). Fail loud: exactly one currency source, the column must exist, and the model field must be string or `sql.Scanner`+`driver.Valuer`. The value is a decimal string; more than `scale` places is a validation error. A list column renders a formatted amount.
+ - `MoneyField.vue` (`Intl.NumberFormat` by admin locale, decimal input, currency suffix), registry, preview.
+ - OpenAPI annotation, `schema.d.ts`, `docs/backend/forms.md`, cabana README, root README if needed, `dist` rebuild.
+ - Settings forms: allow `password` (write-only, backed by `lagoon.Encrypted`; the GET projection never includes it; empty input keeps the stored value) and `richeditor` (seaglass sanitize on settings Put, notices).
+ - Smoke tests.
+ - Tag a framework release only with the user's go-ahead; plugins use `replace` meanwhile.
+2. **sm-paymentgateway-plugin domain core.**
+ - Repo bootstrap (go.mod, go.work, README, plugin ID `golem15.paymentgateway`, Requires `golem15.user`), migrations for the 6 tables and the settings table, models.
+ - `money` package, FSM, Order and Payment services (Make, Wrap, Find, PerformTransition, Purchase, Refund, Cancel, RecordExternal), operator interface, registry and cash/manual/disabled.
+ - Cart value objects, legacy reader, v2 writer, extension registry; hashids; currency converter, NBP and Fixer; 24 events plus the payment→order bridge.
+ - Notification route, 5 scheduled `pg:*` commands, `paymentgateway:import-winter`.
+ - Smoke tests including golden fixtures (the Horoskopia row plus PHP-generated synthetic rows).
+3. **sm-paymentgateway-plugin admin, settings and mail.**
+ - 8 controllers' YAML; record actions for 19 transitions plus payment accept/refund and create cash/bank payment; partial view models; the Quotes cart editor (widget or plugin field type); permissions and navigation; PG and Mailer settings pages.
+ - 26 (+1 underway) postcard templates in PL and EN, mailers with settings gating, phrasebook EN/PL (~500 keys).
+4. **sm-pgstripe-plugin and the proof host.**
+ - Repo bootstrap; stripe-go client factory with backend override; settings table and page (encrypted, write-only, env fallback); `pgstripe:import-winter`.
+ - Stripe operator (PaymentIntent and Checkout), signature-only webhook handling and minimal decoding, refund, the browser-return handler.
+ - The D-03/D-04 host test (both paths, replay, wrong secret, missing header, older API version).
+5. **Customer order API and `vue-paymentgateway-layer`.**
+ - `/_paymentgateway/api/v1` endpoints with the authorization above, throttle, CORS.
+ - New layer repo: typed client, order/checkout/status components for the 13 state partials, a Checkout redirect default, i18n EN/PL, `node --test` tests, tag `v0.1.0` (with go-ahead).
+6. **Unit tests and the phase gate.**
+ - The full PHP test map (74 → Go cases); money vectors; FSM matrices; legacy fixtures; authz; webhook; settings import; the cabana `money` field tests (Go and vitest).
+ - `scripts/check-phase18.sh` modelled on `check-phase17.sh` (stages go, plugins, admin, layers, docs, postgres).
+
+Plans 2–3 are the largest; split plan 3 into 3a (admin) and 3b (mail and settings) only if the plan checker flags size.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | The user accepts **scale-4 int64** instead of "minor units" for D-09 (evidence: F-1/F-2) | Pattern 1, F-1 | HIGH: every model, cart and API take this type; needs explicit confirmation before plan 2 |
+| A2 | Reject >4 decimals on input (rather than >2) is the right D-10 reading | Pattern 2 | MEDIUM: admin input of `12.34567` errors instead of rounding |
+| A3 | `speps/go-hashids/v2` is acceptable as a new dependency (needs a decision note per CLAUDE.md) | Standard Stack | LOW: correctness verified; only governance |
+| A4 | Signature-only verification (`ValidatePayload`) plus minimal decode satisfies D-14 "keeps PHP's behavior" | Pattern 6, F-4 | MEDIUM: if the user prefers `ConstructEvent`, every app must repoint its endpoint API version |
+| A5 | A literal `_paymentgateway/stripe` alias is NOT added; base64 segments only | Pattern 4 | LOW: D-04 wording says `stripe/...`; an alias is easy to add later |
+| A6 | Checkout mode is selected by a pgstripe setting (default `checkout`) with an optional per-order Details override | Pattern 6 | LOW: planner's call per D-16 |
+| A7 | The test host lives in sm-pgstripe-plugin | Pattern 13 | LOW |
+| A8 | NBP Table A can quote more than 4 decimals for some currencies | Anti-Patterns | LOW: rounding code handles both |
+| A9 | `stripe.NewClient` service API appeared in stripe-go v82 | State of the Art | None (v87 API verified directly) |
+| A10 | Fixing the PHP `order.underway`/`order.placed` mail bugs is acceptable (adds one template and one switch) | Pattern 9 | LOW: or keep PHP behavior (no underway mail) |
+| A11 | The PHP `isSetUp` gating is replaced by a required `default_currency` | Pitfall 11, D-08 | LOW |
+| A12 | Unknown legacy item `type` fails loudly (vs opaque round trip) | Pattern 7 | MEDIUM: an app with an unregistered custom item type cannot load its orders until it registers the type |
+
+## Open Questions
+
+1. **Golden fixtures from a shop-type app (D-11 pt 2).**
+ - What we know: only one real Horoskopia row exists locally; no sklep.figs, getaround or quotify dumps were found under `/media/nvme/dev/golem15`.
+ - What's unclear: a production export with shipping, quotes, adjustable items and the invoicer `InvoiceItem`.
+ - Recommendation: plan a `checkpoint:human-action` asking for an anonymized `SELECT cart, details FROM golem15_paymentgateway_orders LIMIT 50` from sklep.figs.org.pl and portal.golem15.com. Meanwhile generate synthetic fixtures by running the PHP SerializationService in the Horoskopia checkout (PHP 8.5.10 and the vendor tree are available; booting Winter needs its DB). Strip PII from every committed fixture.
+2. **Quotes admin cart editor scope.**
+ - What we know: 6 AJAX handlers and 610 LOC.
+ - Recommendation: build it as a `type: widget` + `AdminAction` payload element in plan 3; if it slips, keep record actions and defer the inline editor with a todo (the D-05 port-all-1:1 rule would then need the user's OK).
+3. **Admin navigation conditional on settings.** Check whether cabana navigation supports runtime visibility; if not, add a small framework hook in plan 1 or accept always-visible items.
+4. **Which `sm-user-plugin` revision is canonical?** Remote `master` is `24b16b1` (v0.1.1); the grzybyfunkcjonalne copy is at `0fe5b91`. Use the remote HEAD for the host's `go.work`.
+5. **Repos do not exist yet.** `git ls-remote` returned `Cannot find repository` for `sm-paymentgateway-plugin`, `sm-pgstripe-plugin` and `vue-paymentgateway-layer`. Creating them is a user action, so add a checkpoint before the first push.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Go | all Go repos | ✓ | go1.27.0 | — |
+| Docker | testcontainers Postgres (host test, migrations) | ✓ | 29.7.2 (user in `docker` group) | `-short` skips; run via `sudo -u golem` if the socket is denied |
+| Node | admin SPA, layer | ✓ | v22.23.2 (layer engines `>=22.18`) | — |
+| pnpm / npm | layer / admin | ✓ | 11.3.0 / 12.0.2 | — |
+| PHP CLI | golden fixtures and vectors from the PHP tree | ✓ | 8.5.10 | Hand-built fixtures from metadata |
+| Stripe CLI | optional manual check of real webhooks | ✓ | `/usr/bin/stripe` | httptest fake (the default; no network in tests) |
+| Go module proxy | stripe-go, go-hashids | ✓ | reachable | — |
+| Git remotes for new repos | push | ✗ | — | User creates them (checkpoint) |
+
+**Missing dependencies with no fallback:** git remotes for the three new repos (user action before push).
+**Missing dependencies with fallback:** none.
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | Go `testing` (+ testcontainers-go v0.44.0 Postgres), vitest for the admin SPA, `node --test` (TAP) for the layer |
+| Config file | none for Go; `admin/vitest` config exists; layer `package.json` `"test": "node --test --test-reporter=tap tests/*.test.ts"` |
+| Quick run command | `go test -short ./...` in each Go repo (summercms.go, sm-paymentgateway-plugin, sm-pgstripe-plugin) |
+| Full suite command | `bash scripts/check-phase18.sh --all` (to be written; modelled on `scripts/check-phase17.sh`) |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| SC-1 | Payment FSM 6×5 matrix; Order FSM 12×19 matrix; refund bridge | unit | `go test -short ./fsm/... ./orders/... ./payments/... -run 'TestPaymentMatrix\|TestOrderMatrix\|TestRefundBridge'` | ❌ Wave 0 |
+| SC-1 | MoneyRight vectors (above), Amount4 Scan/Value, ceilings (10,4)/(12,4) | unit | `go test -short ./money/...` | ❌ Wave 0 |
+| SC-2 | Operator registry discovery via `party.Activated`; duplicate code fails boot | unit | `go test -short ./operators/... -run TestRegistry` | ❌ Wave 0 |
+| SC-2 | Notification route: 3 paths × 5 methods, mode merge, base64 de-urlize, unregistered → 404, verify-before-lookup | integration (httptest, no DB) | `go test -short . -run TestNotificationRoute` | ❌ Wave 0 |
+| SC-2 | Stripe webhook: valid, invalid, missing header, missing secret, older API version accepted, replay no-op | integration | `go test -short ./... -run TestStripeWebhook` (pgstripe) | ❌ Wave 0 |
+| SC-3 | Events fired once per transition; mail gated by MailerSettings; templates render EN/PL | unit | `go test -short ./events/... -run 'TestEvents\|TestMailers'` | ❌ Wave 0 |
+| SC-3 | Migrations up/down on Postgres; schema equals the PHP v2.6.0 column set | integration (Docker) | `go test ./updates/... -run TestMigrations` | ❌ Wave 0 |
+| SC-3 | Scheduled commands select the right rows by timeout | integration (Docker) | `go test ./console/... -run TestPG` | ❌ Wave 0 |
+| SC-3 | Settings import-winter (flat dotted keys, Laravel-encrypted and plaintext secrets) | integration (Docker) | `go test ./console/... -run TestImportWinter` | ❌ Wave 0 |
+| SC-4 | Host: Make → Stripe PI/Checkout → signed event → Accepted → Paid → OrderPaid + mail; path (b) RecordExternal | integration (Docker + httptest) | `go test ./... -run TestHostStripeFlow` (pgstripe) | ❌ Wave 0 |
+| D-07 | cabana `money` field schema validation, fill via Scanner, projection, list value | unit | `go test -short ./modules/cabana -run TestMoneyField` | ❌ Wave 0 |
+| D-07 | `MoneyField.vue` render/format/validation | unit (vitest) | `npm --prefix admin run test -- MoneyField` | ❌ Wave 0 |
+| D-11 | Legacy reader golden fixtures (`[]` vs `{}`, key order, unknown type), v2 round trip | unit | `go test -short ./cart/... -run 'TestLegacy\|TestV2'` | ❌ Wave 0 |
+| D-06 | Customer API authz (owner, guest_code, guest_token, foreign user → 404), state guards | integration (Docker) | `go test ./api/... -run TestCustomerAPI` | ❌ Wave 0 |
+| D-06 | Layer client and components | unit (node --test) | `pnpm --dir ../vue-paymentgateway-layer test` | ❌ Wave 0 |
+| Docs | docs checker, OpenAPI, dist | gate | `go test ./cmd/summer -run TestDocsTree && summer docs:build --check && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh` | ✅ |
+
+### Sampling Rate
+- **Per task commit:** `go vet ./... && go test -short ./...` in the touched repo.
+- **Per wave merge:** the full `go test ./...` (Docker) in the touched repos, plus `npm --prefix admin run test` when admin changed.
+- **Phase gate:** `bash scripts/check-phase18.sh --all` green (postgres stage on a Docker host) before `/msd-verify-work`.
+
+### Wave 0 Gaps
+- [ ] New repos `sm-paymentgateway-plugin`, `sm-pgstripe-plugin` with `go.mod`/`go.work`, `integration_test.go` harness copied from `sm-journal-plugin` (`newIntegrationEnv`, a `TestMain` with `flag.Parse()` + `testing.Short()`).
+- [ ] `testdata/legacy/` golden fixtures (the Horoskopia row with PII stripped + PHP-generated synthetic rows).
+- [ ] `money/vectors_test.go` seeded from the PHP vectors in this document.
+- [ ] Stripe `httptest` fake helper shared by pgstripe tests.
+- [ ] `vue-paymentgateway-layer` scaffold copied from `vue-journal-layer` (`tests/*.test.ts`, `.playground`).
+- [ ] `scripts/check-phase18.sh`.
+
+## Security Domain
+
+`security_enforcement` is not set in `.planning/config.json`, so it is treated as enabled. Run the security-review agent (CONTEXT discretion).
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes | `bouncer` JWT (`jwt.auth`) for customer endpoints; plugin-local optional-auth wrapper; no `?login=` impersonation (PG-002) |
+| V3 Session Management | partial | Headless: no session "current order"; the guest token is HMAC with a TTL (24h), bound to one order id |
+| V4 Access Control | yes | Owner / `guest_code` (constant time) / `guest_token` checks; cabana `RequiredPermissions` per controller (PG-004) plus action-level `manage_*` permissions; 404 for foreign orders |
+| V5 Input Validation | yes | Exact decimal parser (≤4 places), enabled-operator allowlist for `payment_method`, `shipping_id` existence, state guards, body size caps, `lagoon.Fill` allowlists (no `state`, `*_stored`, `user_id`, `hash_id` mass assignment, PG-001) |
+| V6 Cryptography | yes | `lagoon.Encrypted` (AES-256-GCM) for secrets; stripe-go webhook HMAC; `crypto/hmac` for guest tokens; hashids is **not** a security control (an obfuscation salt only) |
+| V7 Error/Logging | yes | Redact card, secret, token and email fields in notification logs (PG-006 `SensitiveLogRedactor`); never log raw webhook bodies with PII; settings JSON never returns secrets |
+| V13 API | yes | Throttle on `/_paymentgateway/api/v1` and on the notification route; CORS per config; `wire.WriteJSON` |
+
+### Known Threat Patterns for this stack
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Forged payment notification (fake "paid") | Spoofing/Tampering | Signature verification **before** lookup; unsigned browser returns never change state; only registered and enabled operators resolve |
+| Webhook replay / race (double accept) | Tampering/Repudiation | 300s tolerance (stripe-go default), row lock + `can(Accept)` idempotency, event id logged |
+| Amount tampering (client sends a price) | Tampering | Amount is always computed server-side from the stored cart; the API never accepts amounts; Stripe amount = `Order.Total.Minor()` |
+| IDOR on orders via hash_id enumeration | Information disclosure | Hashids is not authorization; require owner/guest_code/guest_token; 404 for both unknown and forbidden |
+| Guest token theft / reuse | Spoofing | HMAC binding to order id + TTL; not promoted to a user identity (PG-002) |
+| Secret disclosure via admin API / logs | Information disclosure | Write-only password fields; `lagoon.Encrypted` redaction; env fallback never echoed |
+| Class/type injection through cart JSON | Elevation | Typed extension registry with allowlisted names; unknown types rejected (PG-007 successor) |
+| Stored XSS via bank details / mail templates | Tampering | seaglass sanitizing on richeditor settings (PG-008); postcard rejects scripts and event handlers in rendered HTML |
+| Mass assignment of state / amounts | Tampering | `Fillable()` excludes FSM and money columns; transitions only through FSM methods (PG-001) |
+| Denial of service via large webhook bodies or cart JSON | DoS | `http.MaxBytesReader` on the notification route; size limits on cart operations |
+| PII in committed golden fixtures | Information disclosure | Anonymize names, emails and addresses before committing; review in the plan's checkpoint |
+
+## Sources
+
+### Primary (HIGH confidence)
+- Frozen PHP tree `horoskopia.eu/plugins/golem15/paymentgateway@4b9ecf9`: `Plugin.php`, `routes.php`, `core/{Operator,Order,OrderFactory,OrderManager,PaymentManager,PaymentFactory,FactoryConfiguration,SerializationService,OrderPaymentEventSubscriber,Event,*Mailer}.php`, `operators/*`, `models/*` (+ YAML), `valueobjects/{AbstractItem,Cart,PaymentResponse}.php`, `metadata/*.yml`, `support/{HashIdsHelper,GuestTokenGenerator,CurrencyConverter,NbpCurrencyExchange,OperatorUrlizer}.php`, `traits/{FiniteStateMachine,ModelDecorator,CustomerEventMailer}.php`, `formwidgets/*`, `console/*`, `updates/*`, `tests/*`, `components/Orders.php` (excerpts)
+- `horoskopia.eu/plugins/golem15/pgstripe` (working tree = `8b47457` for Phase 18 files): `operators/Stripe.php`, `classes/StripeSettings.php`, `Plugin.php`, `config/config.php`, `tests/*`
+- `horoskopia.eu/vendor/keios/moneyright` v1.0.10: `Money.php`, `Math.php`, `CurrencyPair.php`, `Currency.php` + PHP execution vectors
+- `horoskopia.eu/vendor/keios/oc-trait-financial/src/Financial.php`
+- Horoskopia dump `storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql` (schema, one order row and the settings key set, values redacted)
+- stripe-go `v87.0.0` source in the module cache (`api_version.go`, `stripe.go`, `stripe_client.go`, `webhook/client.go`) + executed probe
+- go-hashids `v2.0.1` executed against PHP hashids 5.0.2 vectors
+- summercms.go modules read: `pact/capabilities.go`, `party/registry.go`, `cabana/{form_schema,settings,field_dependent,model_fields,crud}.go`, `lagoon/{fill,encrypted,laravel_decrypt,transaction}.go`, `surf/router.go`, `bouncer/{registry,jwt,context}.go`, READMEs of wire/festival/postcard/conga
+- Sibling precedents: `sm-journal-plugin` (routes, settings, migrations, integration test, go.work), `sm-sitemap-plugin` (settings migration, import-winter), `sm-user-plugin` (middleware, events, mail naming), `fonoteka.go` (`MoneyString`, `golem:import-settings`), `vue-journal-layer` v0.3.0
+
+### Secondary (MEDIUM confidence)
+- Horoskopia acceptance consumer: `BookApiController.php`, `BookFulfilmentService.php`, `HoroscopeStripeWebhookController.php`, `vue-horoskopia-app/app/stores/book.ts` (read-only reference)
+- Other apps' extension usage: `portal.golem15.com/plugins/golem15/invoicer/Plugin.php`, `getaround.world/plugins/golem15/tourism/Plugin.php`, `sklep.figs.org.pl/plugins/golem15/shop` imports
+
+### Tertiary (LOW confidence)
+- NBP decimal places for weak currencies; the stripe-go version that introduced the client API (training knowledge, marked `[ASSUMED]`)
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH. Versions come from the module proxy and npm; stripe-go and go-hashids behavior was executed.
+- Architecture: HIGH for the domain contract (every FSM, key, code and route read verbatim); MEDIUM for the admin Quotes editor and navigation visibility.
+- Pitfalls: HIGH. F-1 to F-5 are backed by files read or code run this session.
+- Plan sizing: MEDIUM.
+
+**Research date:** 2026-10-12
+**Valid until:** 2026-11-11 for the PHP contract (frozen) and the framework; re-check stripe-go minor releases before plan 4 (Stripe ships often, but the v87 API version stays pinned).