diff --git a/.planning/phases/18-payment-gateway/18-CONTEXT.md b/.planning/phases/18-payment-gateway/18-CONTEXT.md new file mode 100644 index 0000000..b316fb0 --- /dev/null +++ b/.planning/phases/18-payment-gateway/18-CONTEXT.md @@ -0,0 +1,157 @@ +# Phase 18: Payment Gateway - Context + +**Gathered:** 2026-10-09 +**Status:** Ready for planning + + +## 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. + + + + +## 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. + + + + +## 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--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 + + + + +## 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`. + + + + +## 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). + + + + +## 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. + + + +--- + +*Phase: 18-payment-gateway* +*Context gathered: 2026-10-09* diff --git a/.planning/phases/18-payment-gateway/18-DISCUSSION-LOG.md b/.planning/phases/18-payment-gateway/18-DISCUSSION-LOG.md new file mode 100644 index 0000000..41942ce --- /dev/null +++ b/.planning/phases/18-payment-gateway/18-DISCUSSION-LOG.md @@ -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).