docs(18): capture phase context

This commit is contained in:
2026-10-09 18:45:14 +02:00
parent e381f2cc0f
commit 6ea3652303
2 changed files with 286 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
# Phase 18: Payment Gateway - Context
**Gathered:** 2026-10-09
**Status:** Ready for planning
<domain>
## Phase Boundary
Port `Golem15.PaymentGateway` (about 21k LOC of PHP) and `Golem15.PGStripe` (about 2.5k LOC, minus its subscription parts) to SummerCMS as two shared core plugins, `sm-paymentgateway-plugin` and `sm-pgstripe-plugin`. The port covers:
- the Payment and Order state machines (exact money)
- build-time operators (cash, manual transfer, disabled, and Stripe with PaymentIntent and Checkout modes)
- the `_paymentgateway/{operator}/{mode?}/{result?}` notification route
- the Order and Payment events and their mails
- the admin screens and the scheduled commands
- Quotes, Shippings and Currencies
- a headless customer order API with a `vue-paymentgateway-layer` Nuxt layer
The phase is proven by a test host inside the plugin that replays the order-create, Stripe-pay and order-paid flow Horoskopia relies on.
Not in this phase: `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook (they move to Phase 18.1), and the Horoskopia app port itself.
</domain>
<decisions>
## Implementation 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.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Roadmap and decisions
- `.planning/ROADMAP.md` § Phase 18: goal, repos, port sources, splits, success criteria
- `.planning/notes/core-plugin-port-inventory.md` § "PaymentGateway facts that matter for Phase 18": users, operator registration, webhooks, state machines, events
- `.planning/notes/core-plugins-own-repos.md`: the `sm-<name>-plugin` repo, module path, submodule and `go.work` layout, and the submodule commit workflow
- `.planning/research/PITFALLS.md` #5: fixed-decimal money, never `float64`
- `.planning/phases/15-journal-plugin/15-CONTEXT.md`: D-01/D-02 (the frozen-source rule), D-05 (PHPUnit as spec), D-06 (EN/PL only), D-11 (framework-first field type), D-14 (`/_x/api/v1` convention)
- `.planning/phases/17-blog-companions/17-CONTEXT.md`: D-06 (framework-first cabana work), D-16/D-17 (public API shape), D-18 (Nuxt layer distribution)
- `.planning/seeds/SEED-261007-j2b-horoskopia-port.md`: the later Horoskopia app port that consumes these plugins
### PHP port sources (frozen per D-02)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/`: `Plugin.php` (permissions, settings, components, mail templates, form widgets, schedule), `routes.php`, `core/` (Operator, Order, OrderFactory, OrderManager, PaymentManager, SerializationService, the mailers and event subscribers), `models/`, `valueobjects/`, `operators/`, `console/`, `updates/` (schema), `metadata/` (JMS mappings for D-11), `components/Orders.php` and `components/orders/*.htm` (D-06), `formwidgets/` (D-07), `tests/`, `README.md`, `UPGRADE.md`, `docs/`, `cron-entries.txt`
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe/`: `operators/Stripe.php`, `classes/StripeSettings.php`, `classes/StripeChargeMaker.php`, `classes/StripeCreditCardProvider.php`, `components/StripeKeys.php`, `config/config.php`, `tests/`. Skip `StripeSubscriptionGateway`, `jobs/ProcessStripeSubscriptionWebhook.php` and `http/routes.php` (18.1 per D-01).
### Acceptance consumer (read-only reference)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/controllers/BookApiController.php`: `purchase` (Item/Cart/Details/OrderFactory) and `pay` (its own Checkout Session)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/classes/services/BookFulfilmentService.php`: the transition to Paid and the paid states
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/controllers/HoroscopeStripeWebhookController.php`: signature handling, two signing secrets, idempotency
- `/media/nvme/dev/golem15/horoskopia.eu/vue-horoskopia-app/app/stores/book.ts`: the client redirect flow (D-17)
### Framework
- `modules/cabana/README.md`, `modules/cabana/form_schema.go`, `modules/cabana/field_markdown.go`: the field-type set and the precedent for adding `money` (D-07)
- `modules/lagoon/encrypted.go`, `modules/lagoon/laravel_decrypt.go`: encrypted settings and the cutover import (D-13)
- `modules/postcard/README.md`: Markdown mail templates (D-08)
- `modules/conga/README.md`: scheduled commands
- `modules/festival/README.md`: typed events
- `modules/pact/`: the capability-interface pattern for operator registration (D-15)
- `modules/wire/README.md`: JSON value types for the customer API
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `lagoon.Encrypted` (AES-256-GCM at rest, redacted in JSON) and `lagoon.DecryptLaravelPayload` (cutover import): the Stripe secrets (D-13).
- `postcard`: plugin-owned Markdown mail templates and layouts with memory, log and SMTP drivers (D-08).
- `conga`: River jobs plus `ScheduledCommandArgs` / the `scheduled` queue for the daily `pg:*` commands.
- `festival.Bus` (`Fire`, `Collect`, `UntilHandled`): the Order and Payment events.
- cabana `HasSettings` settings pages, the `dropdown`/`number` fields and `type: password`-style write-only fields: the PG settings, MailerSettings and Stripe settings.
- `sm-journal-plugin/routes.go`: the `/_x/api/v1` route, limiter and CORS pattern for the customer order API.
- `vue-journal-layer`: the layer structure for `vue-paymentgateway-layer`.
- `sm-user-plugin` (mounted in `fonoteka.go/plugins/golem15/user`): the order `user_id` owner and the authenticated customer.
### Established Patterns
- Core plugins keep PHP table names, columns, migration semantics, permission codes, event names and config keys (non-breaking across the 7 apps), except where D-11 versions the stored cart/details JSON.
- Framework first: a new cabana field type lands in `summercms.go` with README, `docs/`, OpenAPI, TS types and `dist/` in one change.
- Framework and plugin READMEs never name a consuming application.
### Integration Points
- New sibling checkouts `/media/nvme/dev/golem15/summercms.io/summercms/sm-paymentgateway-plugin/` and `.../sm-pgstripe-plugin/`, with module paths `git.golem15.com/golem15/sm-paymentgateway-plugin` and `git.golem15.com/golem15/sm-pgstripe-plugin`, packages `paymentgateway` and `pgstripe`, and plugin IDs `golem15.paymentgateway` and `golem15.pgstripe`. Confirm the remotes exist before the first push.
- `summercms.go`: the cabana `money` field (D-07), plus anything else missing in the framework that the researcher finds.
- `vue-paymentgateway-layer`: a new repo, sibling of `vue-journal-layer`.
</code_context>
<specifics>
## Specific Ideas
- Horoskopia's real checkout flow: it builds `Item(name, 1, Money::PLN(net), vatRate, meta)` → `Cart('PLN')` → `Details([], 'stripe')` with a description, email and return URL → `OrderFactory->make($cart, $details, $user)` → `order hash_id`. Paying creates a hosted Checkout Session, and the frontend redirects to `checkout_url`. The Go API must make this sequence equally short.
- JMS was chosen for an explicitly mapped value object that each app can extend. That need carries over (D-11, point 3), but the JMS wire format does not.
- Horoskopia's webhook keeps a separate signing secret per endpoint and per test/live mode. The Stripe settings must allow distinct secrets per mode (D-13).
</specifics>
<deferred>
## Deferred Ideas
- **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.
</deferred>
---
*Phase: 18-payment-gateway*
*Context gathered: 2026-10-09*

View File

@@ -0,0 +1,129 @@
# Phase 18: Payment Gateway - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-10-09
**Phase:** 18-payment-gateway
**Areas discussed:** Subscriptions & acceptance, Feature scope of the port, Money representation, Stripe integration shape
---
## Subscriptions & acceptance
| Option | Description | Selected |
|--------|-------------|----------|
| Separate 18.1 | P18 ports paymentgateway and pgstripe without subscriptions; 18.1 ports subscriptions | ✓ |
| All in Phase 18 | Port all three plugins in one phase | |
| In 18, as the last wave | Same phase, subscription plans last | |
| Option | Description | Selected |
|--------|-------------|----------|
| Test host in the plugin | Test-only host with a fake consumer, testcontainers Postgres, Stripe httptest fake | ✓ |
| Skeleton sm-horoskopia-app | Start the Horoskopia Go app now | |
| Existing app (summercms.io) | Mount in sm-summercmsio-app | |
| Option | Description | Selected |
|--------|-------------|----------|
| Both paths | Operator PaymentIntent path plus the consumer-driven Checkout path | ✓ |
| Operator path only | | |
| Consumer path only | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Horoskopia's checkout | pg 4b9ecf9, pgstripe 8b47457 v1.0.1 | ✓ |
| Latest origin/master at plan time | | |
| Starter 'upgrade' branch | 96b6aa6 (older) | |
**Notes:** We found that Horoskopia creates Stripe Checkout Sessions itself and handles its own webhook, beside the PaymentIntent-based operator.
---
## Feature scope of the port
| Option | Description | Selected |
|--------|-------------|----------|
| Port all 1:1 | Quotes, Shippings, Currencies with NBP included | ✓ |
| Schema 1:1, features later | | |
| Horoskopia subset only | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Headless customer order API | /_paymentgateway/api/v1 mirroring the Orders component handlers | |
| Defer, Go API only | | |
| API plus a Nuxt layer | API plus vue-paymentgateway-layer | ✓ |
| Option | Description | Selected |
|--------|-------------|----------|
| Generic cabana `money` field | Framework first; TaxWidget becomes a dropdown/number | ✓ |
| Both as cabana field types | | |
| Plugin-local SPA widgets | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Postcard Markdown, same keys | EN+PL; MailerSettings as a settings page; SetUp wizard as a settings default | ✓ |
| Keep HTML bodies verbatim | | |
| You decide | | |
**Notes:** The user asked whether cabana lets plugins register their own field types, as WinterCMS does. It doesn't: the type set is closed, and `type: widget` is valueless. This was captured as the major todo `cabana-plugin-registered-field-types.md`, since plugins to be ported soon ship their own fields.
---
## Money representation
| Option | Description | Selected |
|--------|-------------|----------|
| int64 minor units + currency | Mirrors moneyphp, no new dependency | ✓ |
| shopspring/decimal | New dependency | |
| You decide | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Byte-compatible JMS shape | Rollback to PHP stays possible | |
| Read PHP shape, write a Go shape | Tolerant legacy reader, new Go shape | ✓ (with guardrails) |
| Migrate on import | | |
**User's choice:** "There was a reason we picked JMS serializer. I'd go with 2, if it's safe and correct for the future of this payment plugin." After the JMS rationale was explained (app-swappable Cart/Details subclasses, metadata dirs), the user confirmed option 2 with four guardrails: a versioned v2 shape, a permanent legacy reader with golden fixtures, a typed extension registry replacing setCartClass and metadata, and one-way rollback documented. A rewrite migration and the byte-compatible fallback were offered and not chosen.
| Option | Description | Selected |
|--------|-------------|----------|
| Exact scanner/valuer | decimal(12,4) major units ↔ int64 minor, no float | ✓ |
| You decide | | |
---
## Stripe integration shape
| Option | Description | Selected |
|--------|-------------|----------|
| stripe-go official SDK | Named as the decided dependency | ✓ |
| Hand-rolled net/http client | | |
| Option | Description | Selected |
|--------|-------------|----------|
| No, port the operator 1:1 | Checkout mode deferred | |
| Yes, add a Checkout mode | New behavior beyond PHP | ✓ |
| Option | Description | Selected |
|--------|-------------|----------|
| Encrypted settings + config fallback | lagoon.Encrypted, test/live, write-only, Laravel decrypt on import | ✓ |
| Config/env only | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Process response, Checkout first | redirect_url or client_secret+publishable_key | ✓ |
| Checkout only in the layer | | |
| Separate keys endpoint | | |
**Notes:** The user asked how Horoskopia handled keys without Winter components. It never needed the publishable key: the backend returns `checkout_url` and the Nuxt app redirects (`vue-horoskopia-app/app/stores/book.ts`).
---
## Claude's Discretion
- Identifier names, the customer API's JSON field names and authorization details, where the Money type lives, the conga scheduling of the `pg:*` commands, typed event shapes, plan count and split, and whether to run the security review (yes).
## Deferred Ideas
- Phase 18.1: Payment Gateway Subscriptions.
- Plugin-registered value-bearing cabana field types (todo created).