docs(phase-18): research the payment gateway port
This commit is contained in:
962
.planning/phases/18-payment-gateway/18-RESEARCH.md
Normal file
962
.planning/phases/18-payment-gateway/18-RESEARCH.md
Normal file
@@ -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>
|
||||
## 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.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## 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) |
|
||||
</phase_requirements>
|
||||
|
||||
## 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/<name>`, 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 `<input type="number" step="any">` [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=<nil>` [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":{"<uuid>":{"dn":"<str>","c":1,"p":["2439.0000","PLN"],"da":{"natal_chart_id":1,"saved_chart_id":1,"product_type":"<str>"},
|
||||
"t":23,"i":"<uuid>","type":"generic"}},
|
||||
"d":"PLN"}
|
||||
// details
|
||||
{"p":[],"o":"stripe","d":"<str>","r":"<str>","e":"<str>"}
|
||||
```
|
||||
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}.<event>
|
||||
```
|
||||
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.<group>.<name>` (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.<x>` → `mail.order.<x>`. 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/<base64 stripe code>`.
|
||||
- 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/<base64>` 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).
|
||||
Reference in New Issue
Block a user