docs(18): create phase plan
This commit is contained in:
@@ -1113,10 +1113,29 @@ Plans:
|
||||
4. A host test proves the order-create, Stripe-pay and order-paid flow that Horoskopia relies on.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 7 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 18 to break down)
|
||||
**Wave 1**
|
||||
- [ ] 18-01-PLAN.md — Framework (summercms.go): cabana `money` field and list column (D-07, scale-4 decimal strings), write-only `lagoon.Encrypted` secrets, richeditor and tabs on settings forms (D-13, F2-F4), danger record actions, preview Edit rule and the table kit (F5-F7), docs, OpenAPI, dist; framework tag with go-ahead
|
||||
|
||||
**Wave 2** *(blocked on 18-01)*
|
||||
- [ ] 18-02-PLAN.md — sm-paymentgateway-plugin domain core: v2 row-shape gate, exact money, migrations, models, FSMs, Order/Payment services, operators (cash, manual, disabled) and registry, cart with legacy reader and extension registry, hashids, events and bridge, notification route, currencies (NBP, Fixer), `pg:*` commands, `paymentgateway:import-winter`
|
||||
|
||||
**Wave 3** *(blocked on 18-02)*
|
||||
- [ ] 18-03-PLAN.md — sm-paymentgateway-plugin admin: Orders, Payments, Quotes, Quote items and Shippings in cabana YAML with partials, record and admin actions, the quote cart element, permissions, navigation and the full EN/PL phrasebook
|
||||
|
||||
**Wave 4** *(blocked on 18-03)*
|
||||
- [ ] 18-04-PLAN.md — sm-paymentgateway-plugin settings and mail: PG and Mailer settings pages (D-08), 27 postcard templates PL/EN under the PHP codes, the four mailer groups after commit, mailer keys in the Winter import
|
||||
|
||||
**Wave 5** *(blocked on 18-04)*
|
||||
- [ ] 18-05-PLAN.md — sm-pgstripe-plugin and the proof host: stripe-go v87 operator (Checkout and PaymentIntent, D-16/D-17), signature-only webhooks (D-14), browser return, refunds, encrypted settings with env fallback and `pgstripe:import-winter` (D-13), the D-03/D-04 host test with both pay paths
|
||||
|
||||
**Wave 6** *(blocked on 18-05)*
|
||||
- [ ] 18-06-PLAN.md — Customer order API `/_paymentgateway/api/v1` (D-06 contract gate, authz, twelve endpoints) and the new vue-paymentgateway-layer (typed client, Pg* components, i18n, tests); repository creation, fixture request, pushes and layer tag v0.1.0 with go-ahead
|
||||
|
||||
**Wave 7** *(blocked on 18-01..18-06)*
|
||||
- [ ] 18-07-PLAN.md — Unit tests last: the 74-case PHP map, money and FSM matrices, legacy fixtures, security evidence, framework and layer tests, `scripts/check-phase18.sh`, validated 18-VALIDATION.md, final push approval
|
||||
|
||||
### Phase 18.1: Payment Gateway Subscriptions (INSERTED)
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
---
|
||||
msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: 17
|
||||
current_phase_name: Blog companions
|
||||
status: verifying
|
||||
stopped_at: Completed 17-06-PLAN.md
|
||||
last_updated: "2026-10-12T19:06:59.056Z"
|
||||
current_phase: 18
|
||||
current_phase_name: Payment Gateway
|
||||
status: executing
|
||||
stopped_at: Phase 18 UI-SPEC approved
|
||||
last_updated: "2026-10-13T03:17:07.125Z"
|
||||
last_activity: 2026-10-12
|
||||
last_activity_desc: "Completed quick task 261012-s3g: Phase 10 security blockers (DOMPurify, check-phase10 hygiene)"
|
||||
state_head: 708b3ec581287756baed7962f7a29b3f95265602
|
||||
state_head: 79b1f855646abf44e470a6f9ce1a1a1ae6376d04
|
||||
progress:
|
||||
total_phases: 40
|
||||
completed_phases: 14
|
||||
total_plans: 168
|
||||
total_plans: 175
|
||||
completed_plans: 168
|
||||
gsd_state_version: "1.0"
|
||||
milestone_name: milestone
|
||||
@@ -29,9 +29,9 @@ See: .planning/PROJECT.md (updated 2026-10-12)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 17 (Blog companions) — EXECUTING
|
||||
Phase: 18 (Payment Gateway) — READY TO EXECUTE
|
||||
Plan: 6 of 6
|
||||
Status: Phase complete — ready for verification
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-10-12 — Completed quick task 261012-s3g: Phase 10 security blockers (DOMPurify, check-phase10 hygiene)
|
||||
|
||||
Progress: [████░░░░░░] 44%
|
||||
@@ -809,6 +809,7 @@ Recent decisions affecting current work:
|
||||
| 261011-kzi | Fix Phase 09 gaps: fonoteka phase09AdminRoutes inventory + per-guard cabana last_used_at throttle | 2026-10-11 | b45af8e | [261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-](./quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/) |
|
||||
| 261011-mag | fix check-phase10.sh hygiene gaps from 10-VERIFICATION.md | 2026-10-11 | 1541cd4 | [261011-mag-fix-check-phase10-sh-hygiene-gaps-from-1](./quick/261011-mag-fix-check-phase10-sh-hygiene-gaps-from-1/) |
|
||||
| 261012-s3g | Fix Phase 10 security blockers: DOMPurify richeditor display sanitizer (T-10-16), check-phase10 hygiene green (T-10-24/25), CSP form-action/frame-src; --all still red on fonoteka.go TestPhase09SecurityRoutes | 2026-10-12 | 708b3ec | [261012-s3g-fix-phase-10-security-blockers-t-10-16-d](./quick/261012-s3g-fix-phase-10-security-blockers-t-10-16-d/) |
|
||||
| 261013-0wa | Close T-17-15: validate sitemap frontend_url and escape it in Rewrite | 2026-10-12 | 6e5a8e7 | [261013-0wa-close-t-17-15-validate-sitemap-frontend-](./quick/261013-0wa-close-t-17-15-validate-sitemap-frontend-/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -820,6 +821,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-10-12T17:53:56.658Z
|
||||
Stopped at: Completed 17-06-PLAN.md
|
||||
Resume file: None
|
||||
Last session: 2026-10-12T20:45:53.030Z
|
||||
Stopped at: Phase 18 UI-SPEC approved
|
||||
Resume file: .planning/phases/18-payment-gateway/18-UI-SPEC.md
|
||||
|
||||
399
.planning/phases/18-payment-gateway/18-01-PLAN.md
Normal file
399
.planning/phases/18-payment-gateway/18-01-PLAN.md
Normal file
@@ -0,0 +1,399 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/field_money.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/settings_secret.go
|
||||
- modules/cabana/field_dependent.go
|
||||
- modules/cabana/field_richeditor.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/testdata/payments/controllers/invoices/config_list.yaml
|
||||
- modules/cabana/testdata/payments/controllers/invoices/config_form.yaml
|
||||
- modules/cabana/testdata/payments/models/invoice/fields.yaml
|
||||
- modules/cabana/testdata/payments/models/invoice/columns.yaml
|
||||
- modules/cabana/testdata/payments/models/vault/fields.yaml
|
||||
- modules/cabana/payments_smoke_test.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/token_routes_test.go
|
||||
- modules/cabana/phase10_coverage_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/app/money.ts
|
||||
- admin/src/components/form/fields/MoneyField.vue
|
||||
- admin/src/components/form/fields/PasswordField.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/RecordActions.vue
|
||||
- admin/src/components/list/CellValue.vue
|
||||
- admin/src/components/list/DataTable.vue
|
||||
- admin/src/views/PreviewView.vue
|
||||
- admin/src/views/SettingsFormView.vue
|
||||
- admin/src/styles/main.css
|
||||
- admin/tests/form/MoneyField.test.ts
|
||||
- admin/tests/form/PasswordField.test.ts
|
||||
- admin/tests/form/RecordActions.test.ts
|
||||
- admin/tests/form/PreviewView.test.ts
|
||||
- admin/tests/views/SettingsFormView.test.ts
|
||||
- admin/tests/list/CellValue.test.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- docs/backend/settings.md
|
||||
- docs/backend/admin-controllers.md
|
||||
autonomous: false
|
||||
requirements: [SC-3, D-07, D-08, D-09, D-10, D-13]
|
||||
estimate:
|
||||
tokens: 230000
|
||||
raw_tokens: 230000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-07 (amended D-09): cabana has a core `money` form field whose value is a decimal string with a dot and at most `scale` (2 to 4, default 4) fraction digits, never a float and never rounded; a field declares exactly one of `currency: <ISO>` or `currencyFrom: <sibling column>` and may declare `max: <decimal string>`; two sources, none, an unknown sibling, a model column that is neither a string nor a sql.Scanner plus driver.Valuer, or a scale outside 2..4 fail boot with an error naming the field."
|
||||
- "D-07 and D-10: a submitted money value with more than `scale` fraction digits is a 422 on the field with `backend::lang.money.too_precise`, a non-decimal is 422 `money.invalid`, a value above `max` is 422 `money.too_large`; the server never rounds or truncates a money value, and a stored `85.4193` is re-saved byte-identical when the administrator did not edit it."
|
||||
- "D-07: a list column `type: money` (same `currency` / `currencyFrom` keys) renders a locale-formatted amount right-aligned in tabular numerals; each list row carries the `currencyFrom` column's value under that column key so the SPA never guesses a currency; `scripts/check-admin-openapi.sh --check` is clean and the field options appear in `admin/src/api/schema.d.ts`."
|
||||
- "D-13 (F2): a settings form accepts `type: password` on a `lagoon.Encrypted` column; the settings GET never includes the value and reports `meta.secrets.<field>` as `stored`, `env` (empty column and the model's `SecretFallbacks()` config key resolves to a non-empty value) or `empty`; on PUT an absent key or an empty string keeps the stored value and JSON null clears it; the PUT response carries the new states; the value never appears in a toast, an error or a log."
|
||||
- "F3: a settings form accepts `type: richeditor`; the settings PUT sanitizes the value through seaglass exactly like a controller form and returns removals as `meta.notices`; settings and relation forms still refuse `repeater` and `dependsOn` at boot."
|
||||
- "F4: settings fields may declare `tab`; SettingsFormView renders FormTabs like FormView, puts fields without a tab on the default tab first, wraps the card in the tab panel, and after a 422 activates the first tab with an error and shows its error count."
|
||||
- "F5: `pact.AdminRecordAction` gains `Danger bool` serialized as `danger`; RecordActions.vue passes it to the confirm dialog so the confirm button takes the danger variant while the footer button stays outline."
|
||||
- "F6: PreviewView renders its Edit button only when the form has at least one registered value-holding field whose context allows update; a preview with only partial fields shows no Edit and no primary fill."
|
||||
- "F7: main.css gains `.summer-table-wrap`, `.summer-table`, `.summer-table th`, `.summer-table td`, `.summer-table__num`, `.summer-table__sub`, `.summer-table tfoot td` and `.summer-stat__note` with the UI-SPEC values, reading only `--c-*` variables; the cabana README kit section lists them."
|
||||
- "UI considerations (money field, covered): an empty input emits null and read-only and preview show a muted em dash; blur validation shows `money.invalid` or `money.too_precise` with the danger border and the server 422 (same keys plus `money.too_large`) is the gate; without a currency yet the suffix is omitted and the amount still edits; `99999999.9999` fits the 44px input with `pr-16` and read-only and list values are `whitespace-nowrap tabular-nums`; the list cell shows the em dash when empty, right-aligned values with the ISO code, no wrapping, and the amount without a code when the row has no currency."
|
||||
- "UI considerations (secret field, covered): the input is always empty on load with the stored, env or empty status line; a 422 uses the existing error line and the value never appears in toasts or errors; the pending-clear state shows the danger line with Undo, disables the input and sends null on Save; a long key scrolls inside the input and the status line wraps."
|
||||
- "UI considerations (settings tabs, danger actions, preview Edit rule, kit): tabs render after the schema load and the first tab with errors is activated with its chip after a 422; five Mailer tabs with long Polish labels wrap or scroll as on record forms; danger record actions keep the existing 409, 404, 403 and failure handling and keep both dialog buttons disabled while the POST runs; `.summer-table-wrap` scrolls horizontally inside the card and cells use `overflow-wrap: anywhere` while amounts never wrap."
|
||||
- statement: "Money untouched value and parsing: a form loaded with the string 85.4193 and saved without an edit submits 85.4193; the typed text 1 234,56 emits 1234.56; 12.34567 at scale 4 shows money.too_precise and is neither rounded nor truncated (admin/tests/form/MoneyField.test.ts)."
|
||||
verification: backstop
|
||||
- statement: "Money formatting: Intl.NumberFormat receives the decimal string, so 99999999.9999 PLN formats with all four decimals in en and pl, and 39.8400 shows two (admin/tests/form/MoneyField.test.ts)."
|
||||
verification: backstop
|
||||
- statement: "Secret write-only contract: a Go test asserts the settings GET never contains a secret value and reports its state; a vitest asserts an untouched password field is not sent, Remove stored value sends null, and Undo restores the stored line (modules/cabana/payments_smoke_test.go, admin/tests/form/PasswordField.test.ts)."
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "modules/cabana/field_money.go"
|
||||
provides: "money field compile (currency, currencyFrom, scale, max), model type check, decimal validation, list column compile"
|
||||
contains: "money"
|
||||
- path: "modules/cabana/settings_secret.go"
|
||||
provides: "write-only password on settings: projection omits the value, meta.secrets states, keep/clear semantics, SecretFallbacks interface"
|
||||
contains: "SecretFallbacks"
|
||||
- path: "admin/src/components/form/fields/MoneyField.vue"
|
||||
provides: "decimal text input with currency suffix, blur validation, read-only box"
|
||||
contains: "inputmode=\"decimal\""
|
||||
- path: "admin/src/app/money.ts"
|
||||
provides: "parseMoneyInput, formatMoney, trimMoneyDisplay"
|
||||
contains: "Intl.NumberFormat"
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "AdminRecordAction.Danger"
|
||||
contains: "Danger"
|
||||
- path: "admin/src/styles/main.css"
|
||||
provides: "partial style kit classes summer-table and summer-stat__note"
|
||||
contains: "summer-table"
|
||||
key_links:
|
||||
- from: "modules/cabana/form_schema.go"
|
||||
to: "modules/cabana/field_money.go"
|
||||
via: "formFieldTypes gains money; compile calls compileMoneyKeys"
|
||||
pattern: "compileMoneyKeys"
|
||||
- from: "modules/cabana/settings.go"
|
||||
to: "modules/cabana/settings_secret.go"
|
||||
via: "settings compile accepts password on lagoon.Encrypted columns; Get and Put call the secret projection"
|
||||
pattern: "secret"
|
||||
- from: "admin/src/components/form/registry.ts"
|
||||
to: "admin/src/components/form/fields/MoneyField.vue"
|
||||
via: "renderers map entry money"
|
||||
pattern: "\\['money', MoneyField\\]"
|
||||
- from: "admin/src/components/form/RecordActions.vue"
|
||||
to: "modules/cabana/schema_types.go"
|
||||
via: "danger flag on the RecordAction projection (schema_types.go :223) that crud.go offeredRecordActions builds into meta.actions"
|
||||
pattern: "danger"
|
||||
prohibitions:
|
||||
- statement: "Framework code (cabana, pact, phrasebook, the admin SPA, docs) never names a consuming application and never carries a payment-specific type, key or route; the money field is generic (any ISO code, any model)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No money value is ever parsed into, computed with or formatted through a floating-point type on the server or in the SPA; the exact decimal string is the value from the wire to the column."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "A write-only settings secret is never returned by the settings GET or PUT projection, never logged, and never echoed in an error or a toast."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged without the human's reply at the release checkpoint; existing cabana field types, routes and payloads keep their behaviour byte for byte (only additive schema keys and the danger flag change the admin schema)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; a story would come from `/msd mvp-phase 18`): `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 1 of 7, framework, wave 1): the cabana `money` field and list column (D-07, amended D-09 scale-4 strings), the settings-form gaps the payment settings pages need (D-13 write-only secrets, richeditor bank details, tabs) and the three small generic admin additions the UI-SPEC lists as F5 to F7. The UI-SPEC asked the planner to place F4 to F7 in plan 1 next to F1 to F3; they are placed here. Plugins consume the result through `go.work` `replace`; the framework tag is a human decision at the end (Task 4).
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
No SPEC and no REQUIREMENTS IDs for this phase: the orchestrator recorded "spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)". No probe predicates were generated. `must_haves` are derived from the ROADMAP success criteria, CONTEXT decisions, RESEARCH § Validation Architecture and § Security Domain, and the UI-SPEC `## UI Considerations` rows this plan owns (the money field, money list column, secret field, settings tabs, danger record actions, preview Edit rule and the `.summer-table` kit; backstop rows are structured markers above; the dismissed rows "money field loading", "secret field loading", "preview Edit rule loading, error, long-text" and "money list column loading, error, zero-one-many" are recorded here as dismissed with the UI-SPEC's reasons and not lifted). Prohibitions were recalled in-prompt and are descriptor-less (flagged-unverified). Canon security items (XSS, IDOR, injection) are in the threat model, not minted as prohibitions.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- The money value on the wire is the exact decimal string (`"39.8400"`, `"85.4193"`), not minor units: D-09 was amended to scale-4 `int64` on 2026-10-13 and D-10 forbids rounding; cabana stores the string as the model column's own type (string or a `sql.Scanner`/`driver.Valuer`, such as the plugin's `money.Amount4`). The optional `max` key gives the server the column ceiling for `money.too_large` (the ceiling differs per table: `numeric(12,4)` and `numeric(10,4)`).
|
||||
- The env-fallback state of a secret comes from the settings model: it may implement `cabana.SecretFallbacks` (`SecretFallbacks() map[string]string`, field name to compass config key); cabana reports `env` when the column is empty and `app.Config.String(key)` is non-empty. Plugins keep their own key names (`golem15.pgstripe.secret_key` and so on) and `SUMMER_*` variables map onto them as everywhere else.
|
||||
- Settings keep refusing `repeater` and `dependsOn` (those checks live in the controller save); only `richeditor` and `password` are let through, each with its own save gate.
|
||||
- Danger is a plain field on `pact.AdminRecordAction`; no new checkpoint type, no new dialog component.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `modules/pact`: `AdminRecordAction.Danger bool` (json `danger`).
|
||||
- `modules/cabana`: field type `money` with keys `currency`, `currencyFrom`, `scale`, `max`; list column type `money` with `currency`, `currencyFrom`; `compileMoneyKeys`, `checkMoneyType`, `validateMoneyValue` (unexported); `SecretFallbacks` interface; `SecretState` constants `stored`, `env`, `empty`; `SettingsResult.Meta.Secrets`; settings `tab` support; richeditor sanitizing on the settings Put; phrase keys `money.invalid`, `money.too_precise`, `money.too_large`, `money.currency`, `secret.stored`, `secret.env`, `secret.empty`, `secret.clear`, `secret.clear_pending`, `secret.undo`, `secret.placeholder_stored`.
|
||||
- Admin SPA: `admin/src/app/money.ts` (`parseMoneyInput`, `trimMoneyDisplay`, `formatMoney`), `MoneyField.vue`, CellValue kind `money`, DataTable money alignment, PasswordField secret status line, SettingsFormView tabs, RecordActions danger pass-through, PreviewView Edit rule, `.summer-table*` and `.summer-stat__note` in `main.css`; rebuilt `modules/boardwalk/dist`.
|
||||
- Docs: sections in `docs/backend/forms.md` (Money, Settings secrets), `docs/backend/lists-and-filters.md` (money column), `docs/backend/settings.md` (password, richeditor, tabs, SecretFallbacks), `docs/backend/admin-controllers.md` (Danger); cabana and pact READMEs.
|
||||
- Smoke tests: `TestMoneyFieldSmoke`, `TestMoneyListSmoke`, `TestSettingsSecretSmoke`, `TestSettingsRichEditorSmoke`, `TestRecordActionDangerSmoke`; vitest `MoneyField.test.ts`, `PasswordField.test.ts` (secret states), `SettingsFormView.test.ts` (tabs), `RecordActions.test.ts` (danger), `PreviewView.test.ts` (Edit rule), `CellValue.test.ts` (money). Full unit coverage is plan 07.
|
||||
|
||||
<objective>
|
||||
Give cabana the generic admin pieces the payment plugins' screens need, framework first (D-07, D-13, UI-SPEC F1 to F7): an exact decimal `money` field and column, write-only encrypted secrets, richeditor and tabs on settings forms, danger record actions, the preview Edit rule and the table kit, with README, docs, OpenAPI, generated TS types and the committed dist in the same change.
|
||||
|
||||
Purpose: SC-3's admin screens (orders, payments, settings) and D-13's Stripe settings cannot be expressed in YAML without these; building them in the framework keeps the plugins free of admin hacks and reusable by every app (per D-07 and the P15 D-11 / P17 D-06 pattern).
|
||||
Output: cabana and pact changes, the admin SPA components, rebuilt dist, docs, smoke tests, and a human release decision.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-UI-SPEC.md
|
||||
|
||||
Concurrency: another session may hold uncommitted edits in `modules/cabana`, `modules/lagoon` or `admin/`. Before Task 1 run `git status --porcelain -- modules admin docs scripts` in summercms.go; if it lists files this plan touches, stop and report (never stash or overwrite another session's work). Stage files by explicit path; planning docs and code in separate commits; no co-author tags. Every task that changes `admin/` ends with `npm --prefix admin run build`, the committed `modules/boardwalk/dist` and `bash scripts/check-admin-dist.sh` (the 2026-10-07 release gate). Docker-backed cabana smoke tests skip under `-short`; run them on a Docker host (if the socket is denied although the user is in the docker group, run through `sudo -u golem`) and record NOT RUN in the SUMMARY when no daemon answers.
|
||||
|
||||
<interfaces>
|
||||
New or changed contracts (names fixed by this plan; doc comment on every exported identifier):
|
||||
|
||||
```go
|
||||
// modules/pact/capabilities.go
|
||||
type AdminRecordAction struct {
|
||||
Name, Label, Confirm string
|
||||
Permissions []string
|
||||
Danger bool // UI-SPEC F5: the confirm dialog uses the danger variant
|
||||
Applies func(ctx context.Context, record any) (bool, error) `json:"-"`
|
||||
Run func(ctx context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) `json:"-"`
|
||||
}
|
||||
|
||||
// modules/cabana/settings_secret.go
|
||||
// SecretFallbacks is implemented by a settings model whose write-only
|
||||
// password fields fall back to host configuration when the column is empty.
|
||||
// The map is field name to compass config key (SUMMER_* variables map onto it).
|
||||
type SecretFallbacks interface{ SecretFallbacks() map[string]string }
|
||||
const (SecretStored = "stored"; SecretEnv = "env"; SecretEmpty = "empty")
|
||||
// SettingsResult.Meta gains Secrets map[string]string (field -> state), omitted when the form has no password field.
|
||||
```
|
||||
|
||||
Money field schema keys (fields.yaml): `type: money`, `currency: PLN` or `currencyFrom: currency`, `scale: 4` (2..4, default 4), `max: "999999.9999"` (optional); columns.yaml: `type: money` with `currency` or `currencyFrom`. The JSON schema exposes `currency`, `currencyFrom`, `scale` and `max` on the field; the list schema exposes `currency` and `currencyFrom` on the column.
|
||||
|
||||
Settings secret wire contract: GET `/settings/{code}` omits every password field from `data` and answers `meta.secrets: {"secret_key": "stored"}`; PUT with the key absent or `""` keeps the value, `null` clears it; the PUT response carries `meta.secrets`.
|
||||
|
||||
Existing seams (read, do not change their behaviour): `field_date.go` (compileDatepickerKeys :42, checkDateType :253), `settings.go` compile :86-121 and `requireSettingInputs` :287, `field_dependent.go` controllerFormOnly :123, `field_richeditor.go` (seaglass gate and notices), `crud.go` scalarFormField :1270, `list_schema.go` column compile, `admin_openapi.go` swag blocks, `admin/src/components/form/control.ts` (`controlClass`, `controlAttributes`), `admin/src/components/form/fields/PasswordField.vue`, `admin/src/views/SettingsFormView.vue`, `admin/src/components/form/FormTabs.vue`, `admin/src/components/ui/ConfirmDialog.vue` and `useConfirm`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a `money` field travels as an exact decimal string from fields.yaml through the schema, the SPA input and the save back to the column, with OpenAPI, docs and dist</name>
|
||||
<reversibility rating="costly">D-07 (amended D-09): a new value-bearing field type in the typed admin schema; every payment screen, the generated TS types and later plugins build on its keys and its decimal-string contract.</reversibility>
|
||||
<files>modules/cabana/field_money.go, modules/cabana/form_schema.go, modules/cabana/crud.go, modules/cabana/settings.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/payments/controllers/invoices/config_list.yaml, modules/cabana/testdata/payments/controllers/invoices/config_form.yaml, modules/cabana/testdata/payments/models/invoice/fields.yaml, modules/cabana/testdata/payments/models/invoice/columns.yaml, modules/cabana/payments_smoke_test.go, modules/cabana/openapi_conformance_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/app/money.ts, admin/src/components/form/fields/MoneyField.vue, admin/src/components/form/registry.ts, admin/src/components/form/PreviewField.vue, admin/tests/form/MoneyField.test.ts, modules/boardwalk/dist, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/field_date.go (compileDatepickerKeys :42-65, checkDateType :253-268, the gate and type-check shapes to copy), modules/cabana/form_schema.go (formFieldTypes :24-30, formFieldKeys :38-52, compileFieldNode), modules/cabana/crud.go (scalarFormField :1270, create and update decode paths, the 422 error map shape), modules/cabana/settings.go (:104-117, the datepicker type check call site), modules/cabana/field_type.go (decodeOne :530, localizeNotices :781, the 422 field error helpers), modules/cabana/companions_smoke_test.go and modules/cabana/testdata/companions (fixture plugin and Postgres env to copy), modules/cabana/admin_openapi.go (:394-410 markdown block; the form schema and list schema swag types), modules/cabana/openapi_conformance_test.go (TestPhase10ConformanceInventory :134), modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (top-level blocks; add `money:`), admin/src/components/form/fields/NumberField.vue (geometry: h-input tabular-nums, controlClass), admin/src/components/form/fields/MarkdownField.vue (:1-26 control contract), admin/src/components/form/registry.ts (:72-93 renderers, selfLabelled, valueless sets), admin/src/components/form/PreviewField.vue (:24 ownControl), admin/src/components/form/control.ts, admin/src/app/i18n.ts (currentLocale, t), admin/tests/form/MarkdownField.test.ts and admin/tests/helpers.ts (mount helpers), docs/backend/forms.md (:97 type table row, :171 Date pickers section shape, :370 Markdown section), .planning/phases/18-payment-gateway/18-UI-SPEC.md (F1 contract, Money formatting row, error copy money.*), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 2 Amount4, Decision-Impact F-1 and F-2), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh</read_first>
|
||||
<action>Per D-07 with the 2026-10-13 amendment of D-09 (scale-4 decimal, never minor units) and D-10 (never round), wire one money field end to end.
|
||||
|
||||
cabana compile (field_money.go): `money` joins formFieldTypes and `currency`, `currencyFrom`, `scale`, `max` join formFieldKeys. compileMoneyKeys mirrors compileDatepickerKeys: the four keys are refused on any other type ("currency is only valid on type: money"); `options`, `emptyOption`, `nameFrom`, `dependsOn` and `preset` are refused on money; exactly one of currency (a three-letter upper-case ISO code) or currencyFrom (a sibling field or column name) is required ("field X: money needs exactly one of currency or currencyFrom"); scale defaults to 4 and must be 2, 3 or 4; max, when present, must itself parse as a decimal with at most scale places. checkMoneyType (called where settings.go and the controller compile call checkDateType) accepts a model column of kind string (or pointer to string) or a struct type implementing both sql.Scanner and driver.Valuer, and requires currencyFrom to name a string column of the model; anything else fails boot naming the field. The compiled FormField carries Currency, CurrencyFrom, Scale and Max to the SPA.
|
||||
|
||||
Save (crud.go and settings.go paths): money is a scalar form field. validateMoneyValue accepts null (stored as NULL or the zero Scanner) or a string: optional leading minus, digits, at most one dot, at most scale fraction digits, no exponent, no thousands separators; a non-matching string is a 422 on the field with backend::lang.money.invalid; more fraction digits than scale is money.too_precise with the placeholder :scale; a value whose absolute magnitude exceeds max (compared as scaled integers, never as floats) is money.too_large with :max. The accepted string is filled unchanged (lagoon.Fill passes it to the column's Scanner or string). A value the client did not change is therefore re-saved byte-identical (the SPA sends the loaded string back).
|
||||
|
||||
Phrase keys (en and pl, UI-SPEC copy): money.invalid, money.too_precise, money.too_large, money.currency ("Currency: :code" / "Waluta: :code").
|
||||
|
||||
OpenAPI: the form schema field type gains the four properties and the enum value money (swag on the schema types in admin_openapi.go); run scripts/check-admin-openapi.sh to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts; add the conformance inventory entry if the inventory lists field types; npm --prefix admin run typecheck.
|
||||
|
||||
SPA: admin/src/app/money.ts exports parseMoneyInput(text, scale) returning {value: canonical dot string | null, error: 'invalid' | 'too_precise' | null} (strip spaces and U+00A0, accept one `,` or `.` separator, optional leading minus, digits only otherwise; empty text is null; never Number()), trimMoneyDisplay(stored) (trailing zeros trimmed down to two decimals: "39.8400" to "39.84", "85.4193" unchanged, "5.0000" to "5.00") and formatMoney(value, currency, locale, scale) using Intl.NumberFormat with style currency, currencyDisplay code, minimumFractionDigits 2, maximumFractionDigits scale, or style decimal when the currency is empty, passing the string (never Number) to format. MoneyField.vue per UI-SPEC F1: a relative wrapper; input type text, inputmode decimal, autocomplete off, spellcheck false, controlClass(invalid) plus h-input pr-16 text-right tabular-nums (pr-3.5 when no currency); the currency suffix box with the UI-SPEC classes and aria-hidden plus the visually hidden money.currency span appended to aria-describedby; with currencyFrom the code follows the sibling's current form value; the loaded value is shown through trimMoneyDisplay with the locale's decimal separator and no grouping; typing emits the canonical dot string on every valid input and null on empty; validation runs on blur (and while typing once an error showed) and shows the FormField error line with money.invalid or money.too_precise; read-only renders the box with formatMoney and a muted em dash when empty; aria-invalid on error. registry.ts adds ['money', MoneyField] (not selfLabelled, not valueless). PreviewField renders money through formatMoney in the dd with tabular-nums (add money to ownControl). MoneyField.test.ts (vitest): loaded "85.4193" untouched submits "85.4193"; "1 234,56" emits "1234.56"; "12.34567" at scale 4 shows money.too_precise and emits nothing new; formatting of "99999999.9999" PLN keeps four decimals in en and pl and "39.8400" shows two; currencyFrom follows the sibling; empty emits null. Rebuild the admin (npm --prefix admin run build), commit modules/boardwalk/dist and run scripts/check-admin-dist.sh.
|
||||
|
||||
Fixture and Go smoke: modules/cabana/testdata/payments with controller invoices (config_list, config_form) and model invoice fields.yaml holding currency (text), total (money, currencyFrom currency, scale 4, max "999999.9999") and fee (money, currency EUR, scale 2) with a matching columns.yaml; payments_smoke_test.go with a fixture plugin whose Invoice model has Currency string and Total/Fee of a test Scanner/Valuer type that stores the raw string. TestMoneyFieldSmoke (Postgres, conform-style env): creating with total "85.4193" stores exactly "85.4193" and the GET returns it; "12.34567" answers 422 with money.too_precise on total; "1,5" answers money.invalid; "1000000.0000" answers money.too_large; fee "1.234" (scale 2) answers money.too_precise; a boot with both currency and currencyFrom fails naming the field (a compile-only subtest without Docker).
|
||||
|
||||
Docs: docs/backend/forms.md type table row for money and a "## Money" section (keys, the decimal-string contract, the three 422 keys, the never-round rule, an acme example); cabana README Features bullet "Money fields" and the API reference rows, in the same commit as the code.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short -run '^TestMoneyField' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestMoneyField' && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/MoneyField.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestMoneyField" line (the compile-only subtests accepted two currency sources or a bad scale); fewer than three inventory passes; admin.json or schema.d.ts drift from the swag annotations; the SPA does not typecheck; a MoneyField vitest fails (an untouched value was rewritten, a comma input was refused, or a too-precise value was rounded); the committed dist differs from a fresh build; the docs checker reports an unknown identifier or a missing section.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^TestMoneyFieldSmoke$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestMoneyFieldSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the stored value is not byte-identical to "85.4193", or a too-precise, invalid or too-large value was not answered 422 with the named key.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"money"' modules/cabana/form_schema.go` prints at least one line and `grep -n "func compileMoneyKeys\|func checkMoneyType\|func validateMoneyValue" modules/cabana/field_money.go` prints three lines.
|
||||
- `grep -c "too_precise" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
|
||||
- `grep -n "\['money', MoneyField\]" admin/src/components/form/registry.ts` prints one line and `grep -n 'inputmode="decimal"' admin/src/components/form/fields/MoneyField.vue` prints one line.
|
||||
- `! grep -n "Number(" admin/src/app/money.ts` exits 0 (no numeric coercion in the money helpers).
|
||||
- `! grep -rn "float64\|ParseFloat" modules/cabana/field_money.go` exits 0.
|
||||
- `grep -n "^## Money" docs/backend/forms.md` prints one line and `bash scripts/check-admin-openapi.sh --check` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator can edit an exact decimal amount with a currency suffix, the server validates it without rounding, and the generated schema, docs and dist carry the new type.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Money list column and preview, danger record actions (F5), the preview Edit rule (F6) and the table kit (F7)</name>
|
||||
<reversibility rating="costly">F5 and the list column keys are additive schema surface the plugins' YAML and the generated TS types depend on; changing them later touches every payment list and action.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/field_money.go, modules/cabana/list_schema.go, modules/cabana/schema_types.go, modules/cabana/crud.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/payments/models/invoice/columns.yaml, modules/cabana/testdata/payments/controllers/invoices/config_list.yaml, modules/cabana/payments_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/components/list/CellValue.vue, admin/src/components/list/DataTable.vue, admin/src/components/form/RecordActions.vue, admin/src/views/PreviewView.vue, admin/src/styles/main.css, admin/tests/list/CellValue.test.ts, admin/tests/form/RecordActions.test.ts, admin/tests/form/PreviewView.test.ts, modules/boardwalk/dist, docs/backend/lists-and-filters.md, docs/backend/admin-controllers.md</files>
|
||||
<read_first>modules/cabana/list_schema.go (column types and renderers, how relation and datetime columns compile, toolbarButtons :315), modules/cabana/crud.go (list row projection: which columns are selected and how a column value is emitted; :465-480 offeredRecordActions, which builds the record-response action list), modules/cabana/schema_types.go (:223-227 RecordAction, the projection of pact.AdminRecordAction that gains the danger key), modules/pact/capabilities.go (:331-360 AdminRecordAction, AdminRecordActionInput, AdminRecordActionResult), modules/pact/README.md, admin/src/components/list/CellValue.vue (kinds, the no-per-value-pills rule), admin/src/components/list/DataTable.vue (th and td classes), admin/src/components/form/RecordActions.vue (useConfirm call, the busy and error handling to keep), admin/src/components/ui/ConfirmDialog.vue (the danger prop), admin/src/views/PreviewView.vue (Edit button, isRegisteredField usage), admin/src/components/form/registry.ts (isRegisteredField, valueless), admin/src/styles/main.css (the .summer-stats, .summer-stat*, .summer-callout* kit and its --c-* variables; the --spacing-row-head and --spacing-row tokens), modules/cabana/README.md (the partial style kit section), admin/tests/list and admin/tests/form (existing specs for CellValue, RecordActions, PreviewView), .planning/phases/18-payment-gateway/18-UI-SPEC.md (F1 list column, F5, F6, F7 tables)</read_first>
|
||||
<action>Per D-07 (list column) and UI-SPEC F5 to F7.
|
||||
|
||||
List column (list_schema.go and field_money.go): columns.yaml accepts type money with currency or currencyFrom (exactly one, same gate as the field; sortable defaults to false); the compiled list column exposes type money, currency and currencyFrom; the list projection adds the currencyFrom column's value to every row under its own key even when it is not a declared column (so the SPA has the ISO code per row); money columns are never searchable. Swag: the list column schema gains the two properties; regenerate admin.json and schema.d.ts. CellValue.vue gains kind money: formatMoney(value, row[currencyFrom] or the literal currency, currentLocale, 4) in tabular-nums whitespace-nowrap, a muted em dash when the value is null or empty, and the amount alone (style decimal) when the row has no currency; DataTable.vue adds text-right to the th and td of a money column. CellValue.test.ts: en and pl formatting, the em dash, the no-currency row, right alignment.
|
||||
|
||||
Danger (F5): pact.AdminRecordAction gains Danger bool with a doc comment; cabana's RecordAction projection (schema_types.go) gains Danger bool with json tag danger, filled by offeredRecordActions in crud.go (zero value false keeps today's payload for existing actions); RecordActions.vue passes danger: action.danger into the existing useConfirm call and keeps the footer button variant outline. Add Danger to one fixture action in payments_smoke_test.go and assert the record response carries danger true for it and false for another (TestRecordActionDangerSmoke, Postgres). RecordActions.test.ts: the dialog's confirm button has the danger variant for a danger action and the default variant otherwise; the busy and 409 handling specs still pass unchanged.
|
||||
|
||||
Preview Edit rule (F6): PreviewView.vue computes hasEditableField as at least one field that isRegisteredField, is not valueless, is not type partial and whose context allows update (the same context rule FormView uses); the Edit button renders only then. PreviewView.test.ts: a form of only partial fields shows no Edit; a form with a text field keeps it.
|
||||
|
||||
Kit (F7): main.css gains .summer-table-wrap, .summer-table, .summer-table th, .summer-table td, the first-child and last-child padding rules, .summer-table__num, .summer-table__sub, .summer-table tfoot td and .summer-stat__note with the exact values from the UI-SPEC F7 table (44px header row from --spacing-row-head, 12px/600 muted headers, 12px 14px cell padding, overflow-wrap anywhere, tabular-nums on __num, 13px muted __sub, 16px tfoot top padding, the note 13px/400 muted with 4px top margin placed inside .summer-stat__value), reading only --c-* variables. The cabana README kit section lists the new classes and the rule that plugin CSS reads only --c-* variables.
|
||||
|
||||
Docs: docs/backend/lists-and-filters.md gains the money column (keys, per-row currency, right alignment, not sortable by default); docs/backend/admin-controllers.md documents Danger on record actions; pact README API row. Rebuild the admin, commit the dist, run scripts/check-admin-dist.sh and scripts/check-admin-openapi.sh.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana ./modules/pact && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/list/CellValue.test.ts tests/form/RecordActions.test.ts tests/form/PreviewView.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free cabana or pact test regressed (an existing record-action payload changed shape); admin.json or schema.d.ts drift; the SPA does not typecheck; a CellValue, RecordActions or PreviewView spec fails (the money cell went through Number, the confirm button lacks the danger variant, or Edit still renders for a partial-only form); the dist differs from a fresh build; the docs checker fails.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestMoneyListSmoke|TestRecordActionDangerSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: a list row lacks the currencyFrom value, a money column became searchable or sortable by default, or the record response lacks the danger flag.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Danger" modules/pact/capabilities.go` prints at least one line inside the AdminRecordAction struct and `grep -n "danger: action.danger" admin/src/components/form/RecordActions.vue` prints one line.
|
||||
- `grep -c "summer-table" admin/src/styles/main.css` is at least 6 and `grep -n "summer-stat__note" admin/src/styles/main.css modules/cabana/README.md` prints at least one line per file.
|
||||
- `grep -n "hasEditableField" admin/src/views/PreviewView.vue` prints at least one line.
|
||||
- `grep -n "money" docs/backend/lists-and-filters.md` prints at least one line and `grep -n "Danger" docs/backend/admin-controllers.md` prints at least one line.
|
||||
- `! grep -n "#[0-9a-fA-F]\{3,6\}" admin/src/styles/main.css | grep -i "summer-table\|summer-stat__note"` exits 0 (the new rules carry no hex colours).
|
||||
</acceptance_criteria>
|
||||
<done>Amounts align on their last digit in lists and previews, dangerous record actions confirm in red, read-only previews show no Edit, and plugin partials have a native table style.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Settings forms: write-only encrypted secrets with status states (F2), richeditor (F3) and tabs (F4), with docs and dist</name>
|
||||
<reversibility rating="costly">D-13: the settings secret wire contract (`meta.secrets`, keep-on-empty, clear-on-null) and the SecretFallbacks seam are what the Stripe settings page and every later secret-bearing settings page rely on.</reversibility>
|
||||
<files>modules/cabana/settings.go, modules/cabana/settings_secret.go, modules/cabana/field_dependent.go, modules/cabana/field_richeditor.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/payments/models/vault/fields.yaml, modules/cabana/payments_smoke_test.go, modules/cabana/openapi_conformance_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/components/form/fields/PasswordField.vue, admin/src/views/SettingsFormView.vue, admin/tests/form/PasswordField.test.ts, admin/tests/views/SettingsFormView.test.ts, modules/boardwalk/dist, docs/backend/settings.md, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/settings.go (compile :86-121: the refusal list, controllerFormOnly call, writable projection; Get :122 onward; Put and requireSettingInputs :287; the SettingsResult and meta shape), modules/cabana/field_dependent.go (controllerFormOnly :120-128), modules/cabana/field_richeditor.go (the seaglass gate, notices and how the controller save calls it), modules/cabana/field_type.go (localizeNotices :781), modules/lagoon/encrypted.go (:31-102 Encrypted, NewEncrypted, Scan/Value, JSON redaction), modules/cabana/form_schema.go (the tab key on FormField and how FormView gets tabs), admin/src/views/SettingsFormView.vue (load, save, error handling, the skeleton), admin/src/views/FormView.vue (how FormTabs is mounted and how errors pick the tab), admin/src/components/form/FormTabs.vue, admin/src/components/form/fields/PasswordField.vue (input, Eye/EyeOff toggle, autocomplete new-password, the clear-after-save rule), admin/src/components/ui/Button.vue (variant ghost, size sm, icon prop), admin/tests/form/PasswordField.test.ts and admin/tests/views (existing specs), modules/phrasebook/backend/lang/en/lang.yaml (settings: and form: blocks; add secret:), .planning/phases/18-payment-gateway/18-UI-SPEC.md (F2 server contract and status line table, F3, F4, secret.* copy), .planning/phases/18-payment-gateway/18-RESEARCH.md (F-5, Pattern 8 env fallback)</read_first>
|
||||
<action>Per D-13 and UI-SPEC F2 to F4.
|
||||
|
||||
Secrets (settings_secret.go, settings.go): remove password from the settings refusal list; at compile a password field must map to a model column of type lagoon.Encrypted (or a pointer to it), else boot fails "field X: password on a settings form needs a lagoon.Encrypted column". Define SecretFallbacks (interface, doc comment) and the three state constants. Get omits every password field from data and sets meta.secrets[field] to stored when the column holds a non-empty ciphertext, env when it is empty and the model implements SecretFallbacks with a config key whose app.Config.String value is non-empty, else empty. Put: an absent key or an empty string leaves the column untouched; JSON null writes an empty Encrypted (clears); any other non-string value is a 422 on the field; a string is stored through lagoon.NewEncrypted; the response carries the recomputed meta.secrets; the Put never logs the value and the error map never echoes it. Swag: the settings result meta gains secrets (map of string to string); regenerate admin.json and schema.d.ts and add a conformance case for a settings GET with meta.secrets.
|
||||
|
||||
Richeditor (field_dependent.go, field_richeditor.go, settings.go): controllerFormOnly keeps refusing repeater and dependsOn but lets richeditor through on a settings form; the settings Put runs the same seaglass sanitize-and-notice path the controller save uses for every richeditor field (string or null only; notices under the field in meta.notices).
|
||||
|
||||
Tabs (settings.go): the settings compile keeps each field's tab (if decodeFields drops or refuses tab on settings, allow it) and the settings schema exposes tabs like the controller form schema. SettingsFormView.vue: when any field has a tab, render FormTabs in the header row exactly as FormView does (v-model on the active tab), fields without a tab on the default tab (form.tab_default) first, the card inside the tab panel, and after a 422 activate the first tab with an error and show the error count chip (form.tab_errors); with no tab the view is unchanged.
|
||||
|
||||
PasswordField.vue (F2): when the field carries a secret state (from meta.secrets; FormView-driven password fields carry none and are unchanged), render the status line directly under the input and before the comment with the classes, icons, buttons and copy of the UI-SPEC F2 table (stored with Lock and the Remove stored value ghost button; env with Info; empty plain; pending clear in text-danger with CircleAlert, role status, the Undo ghost button, the input emptied and disabled); the input placeholder is secret.placeholder_stored while stored; an untouched field is not included in the PUT body; Remove stored value sends null; Undo restores stored; after a successful save the input is cleared and the line shows the state from the response. Phrase keys secret.stored, secret.env, secret.empty, secret.clear, secret.clear_pending, secret.undo, secret.placeholder_stored in en and pl (UI-SPEC copy). PasswordField.test.ts: the three states render their lines; an untouched field is not sent; Remove stored value sends null and disables the input; Undo restores stored; the value never appears in the DOM after save. SettingsFormView.test.ts: tabs render for a tabbed schema, the default tab is first, a 422 on a field of the second tab activates it with the chip, an untabbed schema renders no strip.
|
||||
|
||||
Fixture and Go smoke: modules/cabana/testdata/payments/models/vault/fields.yaml with api_key (password, tab Live keys), test_api_key (password, tab Test keys), notes (richeditor, default tab) and a settings model Vault{APIKey, TestAPIKey lagoon.Encrypted; Notes string} implementing SecretFallbacks{"api_key": "acme.vault.api_key"} registered through pact.HasSettings in the fixture plugin. TestSettingsSecretSmoke (Postgres, SUMMER_APP__KEY set): GET answers no api_key in data and meta.secrets api_key empty; PUT api_key "sk_1" then GET answers stored and the ciphertext column is non-empty and not the plaintext; PUT with api_key "" keeps it; PUT with null clears it to empty; with the config key acme.vault.api_key set through the env and the column empty the state is env; the response bodies never contain "sk_1". TestSettingsRichEditorSmoke: PUT notes with a script element stores the clean value and answers meta.notices; clean HTML stores byte-identical. A compile-only subtest: password on a plain string column fails boot naming the field.
|
||||
|
||||
Docs: docs/backend/settings.md sections for write-only secrets (the wire contract, SecretFallbacks, the env states), richeditor on settings and tabs; docs/backend/forms.md notes the settings behaviour in the password and rich text sections; cabana README Features. Rebuild the admin, commit the dist, run scripts/check-admin-dist.sh.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/PasswordField.test.ts tests/views/SettingsFormView.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free cabana test regressed (the compile-only subtest accepted password on a string column, or a relation form now accepts richeditor); admin.json or schema.d.ts drift; the SPA does not typecheck; a PasswordField or SettingsFormView spec fails (an untouched secret was sent, Undo did not restore the line, or the tab with the error was not activated); the dist differs from a fresh build; the docs checker fails.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestSettingsSecretSmoke|TestSettingsRichEditorSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: a secret value appeared in a GET or PUT body, an empty string overwrote a stored secret, null did not clear it, the env state was not reported, or a settings richeditor value was stored unsanitized.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type SecretFallbacks interface" modules/cabana/settings_secret.go` prints one line and `grep -n '"password"' modules/cabana/settings.go` no longer appears inside the refusal condition (the `grep -n 'field.Type == "password"' modules/cabana/settings.go` output is empty).
|
||||
- `grep -c "clear_pending" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
|
||||
- `grep -n "data-secret-state" admin/src/components/form/fields/PasswordField.vue` prints at least one line and `grep -n "FormTabs" admin/src/views/SettingsFormView.vue` prints at least one line.
|
||||
- `grep -n "SecretFallbacks" docs/backend/settings.md modules/cabana/README.md` prints at least one line per file.
|
||||
- `grep -n "richEditorType" modules/cabana/field_dependent.go` shows richeditor no longer refused for settings while `grep -n "repeaterType" modules/cabana/field_dependent.go` still prints a refusal line.
|
||||
</acceptance_criteria>
|
||||
<done>A settings page can hold write-only encrypted keys with a clear stored, env or empty status, rich text that is sanitized on save, and tabs, without the SPA ever seeing a secret.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Release gate: local checks green, tag chosen, one-way surfaces listed, a human approves the framework tag and the push</name>
|
||||
<read_first>.planning/phases/17-blog-companions/17-02-SUMMARY.md (how the Phase 17 framework release ran, RELEASE_TAG v0.1.23; the highest tag at planning time is v0.1.24, so the proposal below starts from whatever `git tag -l 'v0.1.*'` reports at execution), .planning/notes/2026-10-07-embedded-admin-release-gate.md, this plan's Task 1 to 3 SUMMARY notes (commits, Docker smoke tests run or NOT RUN), 18-REVIEW.md and 18-SECURITY.md when present</read_first>
|
||||
<action>Do everything automatable, print a release sheet, then stop.
|
||||
|
||||
Concurrency: in summercms.go `git status --porcelain -- modules admin docs scripts cmd go.mod go.sum` prints nothing, `git fetch origin --tags`, and `git log --oneline HEAD..origin/master` prints nothing; if origin has commits this checkout lacks, stop and report (never merge or rebase on your own).
|
||||
|
||||
Tag: propose the highest of `git tag -l 'v0.1.*' --sort=-v:refname` plus one and confirm with `git ls-remote --tags origin` that it does not exist.
|
||||
|
||||
Run the gate (verify). Release sheet: the proposed tag; the one-way surfaces it publishes (cabana field type money with keys currency, currencyFrom, scale, max; list column money; settings password on lagoon.Encrypted with meta.secrets and the keep/clear semantics; cabana.SecretFallbacks; richeditor and tab on settings forms; pact.AdminRecordAction.Danger; the phrase keys money.* and secret.*; the kit classes); the commits to push; the Docker smoke tests recorded NOT RUN in Tasks 1 to 3; open high findings from 18-REVIEW.md or 18-SECURITY.md if they exist (an open high finding allows only docker-first or hold); that plans 02 to 06 build against this checkout through go.work and need no tag, so "hold" costs nothing but a later bump. Stop for the human.</action>
|
||||
<decision>Push summercms master and the proposed annotated tag now?</decision>
|
||||
<context>A pushed tag is read by plugin go.mod files and the Go module proxy and publishes the money field, the settings secret contract and the danger flag; it cannot be moved, only superseded. The plugins in this phase use go.work replace and do not need the tag; the unit-test plan (07) runs after and its tests ship with the next tag.</context>
|
||||
<options>
|
||||
<option id="tag-now">
|
||||
<name>Tag and push now</name>
|
||||
<pros>The framework surface is frozen before the plugin plans build on it; the Go proxy serves it to every app.</pros>
|
||||
<cons>A defect found in plans 02 to 06 needs a second tag.</cons>
|
||||
</option>
|
||||
<option id="docker-first">
|
||||
<name>Run the Docker smoke tests on a Docker host first, then tag</name>
|
||||
<pros>No NOT RUN suite behind the tag.</pros>
|
||||
<cons>Needs a Docker host now.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the tag until the phase gate (plan 07)</name>
|
||||
<pros>One tag for the whole phase, tests included.</pros>
|
||||
<cons>No published framework version during the phase; plugins keep replace lines until then.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "tag-now", "docker-first" or "hold"; the executor pushes only after "tag-now" (or after the Docker run under "docker-first") and records the tag and the reply in the SUMMARY.</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp)$' ./cmd/summer && go run ./cmd/summer docs:build --check && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: a framework suite, inventory, docs check, typecheck, vitest run, dist check or OpenAPI check is red; the executor must fix it before presenting the sheet, never present a red gate.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed release-gate-ok and the SUMMARY records the proposed tag, the human's reply verbatim, and (after "tag-now") `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` non-empty with `git rev-parse master` equal to `git rev-parse origin/master`.
|
||||
- After "hold", `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing and no push happened (`git log --oneline origin/master..master` lists this plan's commits).
|
||||
</acceptance_criteria>
|
||||
<done>The human has decided whether the framework surface of this plan is published, and the record says what was pushed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser → cabana API | authenticated but untrusted input (decimal strings, secrets, HTML, tab and action names) |
|
||||
| cabana → settings column | encrypted at rest; the projection decides what leaves the server |
|
||||
| host config → SecretFallbacks | operator-controlled fallback keys; never echoed |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-01 | Information Disclosure | settings GET/PUT projection of password fields | high | mitigate | settings_secret.go omits every password field from data, reports only a state word, never logs the value; TestSettingsSecretSmoke asserts the bodies never contain the plaintext |
|
||||
| T-18-02 | Tampering | money value parsing (server) | high | mitigate | validateMoneyValue accepts only the decimal grammar, compares magnitude as scaled integers, never rounds; 422 money.invalid / too_precise / too_large; TestMoneyFieldSmoke |
|
||||
| T-18-03 | Tampering (stored XSS) | richeditor on settings | high | mitigate | the settings Put runs the same seaglass gate as the controller save; TestSettingsRichEditorSmoke |
|
||||
| T-18-04 | Tampering | secret clear semantics | medium | mitigate | only JSON null clears; absent or empty keeps; a non-string is 422; so a stray empty input cannot wipe a key |
|
||||
| T-18-05 | Information Disclosure | SPA money and secret components | medium | mitigate | the value never appears in toasts or errors; after save the password input is cleared and hidden; vitest asserts the DOM after save |
|
||||
| T-18-06 | Elevation of Privilege | danger record actions | low | accept | Danger is a display flag only; permissions and Applies are unchanged server-side |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no npm package is installed in this plan (MoneyField uses Intl; the status line reuses existing components); no Go dependency changes |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: compile-only money subtests, route inventories, OpenAPI drift, typecheck, MoneyField vitest, dist, docs; the money save smoke on a Docker host.
|
||||
- Task 2: Docker-free cabana and pact suites, OpenAPI drift, typecheck, CellValue/RecordActions/PreviewView specs, dist, docs; the list and danger smokes on a Docker host.
|
||||
- Task 3: Docker-free cabana suite, OpenAPI drift, typecheck, PasswordField and SettingsFormView specs, dist, docs; the secret and settings richeditor smokes on a Docker host.
|
||||
- Task 4: the full release gate (whole-repo -short, inventories, docs, typecheck, all vitest, dist, OpenAPI) before the human decision.
|
||||
- Feedback sampling (18-VALIDATION.md): the after-commit sample for Tasks 1 to 3 is the quick command `go vet ./... && go test -count=1 -short ./modules/cabana && npm --prefix admin test -- <the task's spec files>` (under 120 s, no admin build); the full `<automated>` chains above, which include `check-admin-dist.sh` (a fresh admin build), the OpenAPI check and the docs checker, are task-close gates run once per task before its commit and bounded at 10 minutes.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-07 with the amended D-09: a core money field and column exist with the decimal-string contract, documented, in the OpenAPI and the dist.
|
||||
- D-13 framework half: a settings page can hold write-only lagoon.Encrypted secrets with stored/env/empty states, richeditor and tabs.
|
||||
- F5 to F7 exist so plans 03 and 04 can express the order and payment previews.
|
||||
- `go vet ./...`, `go test -short ./...`, the admin typecheck and vitest stay green at every commit; the dist matches a fresh build.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-01-SUMMARY.md` when done, with the commits, the exported identifiers added per module, the Docker smoke tests run or NOT RUN, the release decision and tag, and any deviation.
|
||||
</output>
|
||||
475
.planning/phases/18-payment-gateway/18-02-PLAN.md
Normal file
475
.planning/phases/18-payment-gateway/18-02-PLAN.md
Normal file
@@ -0,0 +1,475 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["18-01"]
|
||||
files_modified:
|
||||
- .planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md
|
||||
- ../sm-paymentgateway-plugin/.gitignore
|
||||
- ../sm-paymentgateway-plugin/go.mod
|
||||
- ../sm-paymentgateway-plugin/go.sum
|
||||
- ../sm-paymentgateway-plugin/go.work
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
- ../sm-paymentgateway-plugin/plugin.go
|
||||
- ../sm-paymentgateway-plugin/boot.go
|
||||
- ../sm-paymentgateway-plugin/routes.go
|
||||
- ../sm-paymentgateway-plugin/schedule.go
|
||||
- ../sm-paymentgateway-plugin/config/config.yaml
|
||||
- ../sm-paymentgateway-plugin/money/money.go
|
||||
- ../sm-paymentgateway-plugin/money/amount.go
|
||||
- ../sm-paymentgateway-plugin/money/parse.go
|
||||
- ../sm-paymentgateway-plugin/money/vectors_test.go
|
||||
- ../sm-paymentgateway-plugin/fsm/fsm.go
|
||||
- ../sm-paymentgateway-plugin/fsm/fsm_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/models/registry.go
|
||||
- ../sm-paymentgateway-plugin/models/order.go
|
||||
- ../sm-paymentgateway-plugin/models/payment.go
|
||||
- ../sm-paymentgateway-plugin/models/currency.go
|
||||
- ../sm-paymentgateway-plugin/models/shipping.go
|
||||
- ../sm-paymentgateway-plugin/models/quoteable.go
|
||||
- ../sm-paymentgateway-plugin/models/settings.go
|
||||
- ../sm-paymentgateway-plugin/updates/registry.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130001_create_golem15_paymentgateway_payments.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130002_create_golem15_paymentgateway_orders.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130003_create_golem15_paymentgateway_shippings.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130004_create_golem15_paymentgateway_currencies.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130005_create_golem15_paymentgateway_quoteables.go
|
||||
- ../sm-paymentgateway-plugin/updates/202610130006_create_golem15_paymentgateway_settings.go
|
||||
- ../sm-paymentgateway-plugin/cart/cart.go
|
||||
- ../sm-paymentgateway-plugin/cart/item.go
|
||||
- ../sm-paymentgateway-plugin/cart/adjustable.go
|
||||
- ../sm-paymentgateway-plugin/cart/details.go
|
||||
- ../sm-paymentgateway-plugin/cart/shipping.go
|
||||
- ../sm-paymentgateway-plugin/cart/legacy.go
|
||||
- ../sm-paymentgateway-plugin/cart/v2.go
|
||||
- ../sm-paymentgateway-plugin/cart/registry.go
|
||||
- ../sm-paymentgateway-plugin/cart/legacy_golden_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/legacy-real-1-cart.json
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/legacy-real-1-details.json
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-shop-cart.json
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-shop-details.json
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-invoice-item-cart.json
|
||||
- ../sm-paymentgateway-plugin/tests/php/gen-fixtures.php
|
||||
- ../sm-paymentgateway-plugin/operators/operator.go
|
||||
- ../sm-paymentgateway-plugin/operators/registry.go
|
||||
- ../sm-paymentgateway-plugin/operators/response.go
|
||||
- ../sm-paymentgateway-plugin/operators/cash.go
|
||||
- ../sm-paymentgateway-plugin/operators/manual_transfer.go
|
||||
- ../sm-paymentgateway-plugin/operators/disabled.go
|
||||
- ../sm-paymentgateway-plugin/events/events.go
|
||||
- ../sm-paymentgateway-plugin/orders/service.go
|
||||
- ../sm-paymentgateway-plugin/orders/machine.go
|
||||
- ../sm-paymentgateway-plugin/orders/bridge.go
|
||||
- ../sm-paymentgateway-plugin/payments/service.go
|
||||
- ../sm-paymentgateway-plugin/payments/machine.go
|
||||
- ../sm-paymentgateway-plugin/payments/notification.go
|
||||
- ../sm-paymentgateway-plugin/support/hashids.go
|
||||
- ../sm-paymentgateway-plugin/support/guest_token.go
|
||||
- ../sm-paymentgateway-plugin/support/redactor.go
|
||||
- ../sm-paymentgateway-plugin/support/urlizer.go
|
||||
- ../sm-paymentgateway-plugin/currency/converter.go
|
||||
- ../sm-paymentgateway-plugin/currency/iso.go
|
||||
- ../sm-paymentgateway-plugin/currency/nbp.go
|
||||
- ../sm-paymentgateway-plugin/currency/fixer.go
|
||||
- ../sm-paymentgateway-plugin/currency/providers_test.go
|
||||
- ../sm-paymentgateway-plugin/console/helpers.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_sent_payments.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_created_payments.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_cancel_placed_orders.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_finish_orders.go
|
||||
- ../sm-paymentgateway-plugin/console/pg_update_currencies.go
|
||||
- ../sm-paymentgateway-plugin/console/import_winter.go
|
||||
- ../sm-paymentgateway-plugin/scripts/winter-export-settings.sql
|
||||
- ../sm-paymentgateway-plugin/scripts/winter-export-orders.sql
|
||||
- ../sm-paymentgateway-plugin/testmain_test.go
|
||||
- ../sm-paymentgateway-plugin/harness_test.go
|
||||
- ../sm-paymentgateway-plugin/tracer_test.go
|
||||
- ../sm-paymentgateway-plugin/notification_route_test.go
|
||||
- ../sm-paymentgateway-plugin/commands_smoke_test.go
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, D-02, D-04, D-05, D-09, D-10, D-11, D-14, D-15]
|
||||
estimate:
|
||||
tokens: 300000
|
||||
raw_tokens: 300000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-1 and D-09 (amended): `money.Money` is an int64 scaled by 10^4 plus an ISO code; `FromMinor(3984, \"PLN\")` is 39.8400; Add and Sub are exact; Mul and Div compute at five places truncated then round half away from zero to two places (four with the GAAP option) exactly as Keios MoneyRight does; Allocate follows MoneyRight's remainder loop; `Minor()` rounds half up to cents; `AmountString()` prints four decimals; the PHP vectors in RESEARCH Pattern 1 pass as a table test; no floating-point type appears in the money package."
|
||||
- "D-10: `money.Amount4` implements sql.Scanner and driver.Valuer over the `numeric(12,4)` and `numeric(10,4)` `*_amount_stored` columns; Scan rejects more than four fraction digits, exponents and non-digits and never rounds; Value writes `%d.%04d`; a stored `85.4193` round-trips byte-identical; an order total above 999999.9999 fails `Payments.Create` with a typed error instead of a truncated row."
|
||||
- "SC-1: the payment machine has the six states and five transitions with the PHP from-sets and guards (Cancel has no guard); the order machine has the twelve states, two initial states (Placed default, Quote for `WithQuote`) and the nineteen transitions with their from-sets, the Ship guard, the four final states and the two refundable states; `Can` is state membership plus guard; `PerformTransition` persists inside the transaction, then fires the typed event, and runs the PHP special cases: Ship sets was_shipped, Reject and RejectEarly first cancel every manual and cash payment, GrantRefund, DirectRefund and RefundAfterReturn refund the successful payment (unless already Refunded) and let the PaymentRefunded bridge move the order, and `RequestRefund` tries RequestEarlyRefund then RequestRefund."
|
||||
- "SC-3: the 24 typed festival events exist with the PHP class names as exported constants; the payment-to-order bridge (active only when orders_enabled) ports OrderPaymentEventSubscriber: PaymentSent purchases, PaymentAccepted on a Rejected order fires OrderRejectedReceivedPayment and refunds, otherwise sets successful_payment_id and pays; PaymentCanceled cancels or cancels-not-active; PaymentRefunded takes the first applicable of GuaranteeRefund (shippings on), DeferRefund, DirectRefund, GrantRefund, RefundAfterReturn; PaymentRejected is a no-op; every event fires exactly once per transition."
|
||||
- "SC-2 and D-15: `operators.Operator` is the build-time successor of core/Operator; `operators.HasOperators` is collected over `party.Activated` in Boot (paymentgateway first, then every other plugin), a duplicate Code fails boot naming both plugins, the registry is published on the app; cash, manual transfer and disabled ship with the PHP operator codes `golem15.paymentgateway::lang.channels.cash`, `...channels.manualTransfer`, `...channels.disabled`; the disabled operator is never registered and wraps payments whose operator is unknown or disabled."
|
||||
- "D-05 and D-02: the six tables `golem15_paymentgateway_{orders,payments,currencies,shippings,quoteables,qs_orders}` and the settings singleton `golem15_paymentgateway_settings` exist with the PHP column names and sizes from RESEARCH § Schema (NUMERIC(12,4)/(10,4), BOOLEAN, TEXT, TIMESTAMPTZ NULL, the indexes, the UNIQUE on payments.uuid and currencies.iso, the composite PK on qs_orders); every migration runs up and down; AutoMigrate is never used."
|
||||
- "D-11: every row written by Go carries `\"v\": 2` in cart and details; the legacy JMS reader decodes unversioned rows (`[]` as an empty map, item order preserved by a token walk, missing keys tolerated, a float tax accepted, the discriminators generic, adjustable, value, percent and app-registered names) against committed golden fixtures; an unknown legacy type fails loudly; legacy rows are never rewritten in bulk; `cart.RegisterItemType` and `cart.RegisterDetailsParams` replace setCartClass/setDetailsClass and registerMetadataDirectory with allowlisted names and no class injection."
|
||||
- "D-14 and SC-2: `_paymentgateway/{op}`, `/{op}/{mode}` and `/{op}/{mode}/{result}` are registered for GET, POST, PUT, PATCH and DELETE without CSRF or backend middleware under `throttle:paymentgateway-notify`; the operator segment is base64-decoded (padded or unpadded); `mode`/`result` are merged into the input as `input[mode] = result`; only a registered and enabled operator resolves (404 otherwise); the body is read once with a cap; the operator verifies the notification before any database lookup (403 on refusal with a log entry) and the payment is found by UUID afterwards (404 `Payment not found`); request logging goes through the sensitive redactor."
|
||||
- "SC-3: `pg:cancel-sent-payments`, `pg:cancel-created-payments`, `pg:cancel-placed-orders` and `pg:finish-orders` run daily and `pg:update-currencies` every ten minutes through pact.HasSchedule with the PHP timeout semantics; `pg:update-currencies` fetches NBP Table A or Fixer (per `exchange_rate_provider`) with exact decimal parsing, rounds half away from zero to four places only where the column forces it, updates only rows older than today unless `--force`, and `currency_auto_create` creates missing rows; `paymentgateway:import-winter` maps the flat dotted PHP settings keys onto the settings row."
|
||||
- "D-04 (b) surface: `orders.Service` exposes Make, Find, FindByHash, Wrap, Can, PerformTransition and RequestRefund; `payments.Service` exposes Create, Purchase, Send, Accept, Cancel, Refund, FindByUUID and RecordExternal; the order state constants carry the PHP strings (Placed, AwaitingPayment, Paid, ...); `support.Hashids` encodes with the configured salt and decodes with the configured then the legacy salt `KEIOS.PAYMENTGATEWAY` over the PHP alphabet; an empty `golem15.paymentgateway.hashids_salt` fails boot."
|
||||
- "Pitfall 11 (A11): with no `default_currency` set, `Orders.Make` refuses with a typed error naming the setting; there is no SetUp wizard."
|
||||
artifacts:
|
||||
- path: "../sm-paymentgateway-plugin/money/money.go"
|
||||
provides: "Money value type with MoneyRight arithmetic"
|
||||
contains: "func FromMinor"
|
||||
- path: "../sm-paymentgateway-plugin/money/amount.go"
|
||||
provides: "Amount4 Scanner/Valuer"
|
||||
contains: "func (a *Amount4) Scan"
|
||||
- path: "../sm-paymentgateway-plugin/fsm/fsm.go"
|
||||
provides: "table-driven state machine"
|
||||
contains: "func (m *Machine) Can"
|
||||
- path: "../sm-paymentgateway-plugin/operators/operator.go"
|
||||
provides: "Operator interface, HasOperators, PaymentContext"
|
||||
contains: "type HasOperators interface"
|
||||
- path: "../sm-paymentgateway-plugin/orders/service.go"
|
||||
provides: "Make, Find, FindByHash, Wrap, Can, PerformTransition, RequestRefund"
|
||||
contains: "func (s *Service) Make"
|
||||
- path: "../sm-paymentgateway-plugin/payments/service.go"
|
||||
provides: "Create, Purchase, Send, Accept, Cancel, Refund, FindByUUID, RecordExternal"
|
||||
contains: "func (s *Service) RecordExternal"
|
||||
- path: "../sm-paymentgateway-plugin/cart/legacy.go"
|
||||
provides: "legacy JMS reader"
|
||||
contains: "func DecodeLegacyCart"
|
||||
- path: "../sm-paymentgateway-plugin/routes.go"
|
||||
provides: "notification route three patterns times five methods"
|
||||
contains: "_paymentgateway"
|
||||
- path: ".planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md"
|
||||
provides: "decision note naming github.com/speps/go-hashids/v2 v2.0.1"
|
||||
contains: "go-hashids"
|
||||
key_links:
|
||||
- from: "../sm-paymentgateway-plugin/boot.go"
|
||||
to: "../sm-paymentgateway-plugin/operators/registry.go"
|
||||
via: "scan of party.Activated for HasOperators, app.Publish(*operators.Registry)"
|
||||
pattern: "HasOperators"
|
||||
- from: "../sm-paymentgateway-plugin/orders/bridge.go"
|
||||
to: "../sm-paymentgateway-plugin/events/events.go"
|
||||
via: "app.Events.Listen on the five payment events"
|
||||
pattern: "PaymentAccepted"
|
||||
- from: "../sm-paymentgateway-plugin/payments/notification.go"
|
||||
to: "../sm-paymentgateway-plugin/operators/operator.go"
|
||||
via: "VerifyNotification before FindByUUID, then HandleNotification"
|
||||
pattern: "VerifyNotification"
|
||||
- from: "../sm-paymentgateway-plugin/models/order.go"
|
||||
to: "../sm-paymentgateway-plugin/money/amount.go"
|
||||
via: "AmountStored and NativeAmountStored typed money.Amount4"
|
||||
pattern: "money\\.Amount4"
|
||||
prohibitions:
|
||||
- statement: "No money, tax, ratio or exchange-rate value is ever held in or computed through a floating-point type; NBP and Fixer JSON are decoded with UseNumber and parsed exactly."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Legacy cart and details rows are never rewritten in bulk by a migration, an import or a command; a stored legacy row is decoded in place and only a row the application saves again is written in v2."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Unsigned or unverified notification input never changes a payment state; the operator's verification runs before any database lookup, and the notification route never gains CSRF or backend middleware."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Tables, columns, migration IDs, operator codes, event names, permission codes, config keys and console command names copied from the frozen PHP tree are not renamed or improved; nothing in the plugin names a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Request logs produced by the notification route never contain card data, secrets, tokens or e-mail addresses (the sensitive redactor runs on every logged body)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 2 of 7, wave 2, new repository `../sm-paymentgateway-plugin`): the domain core without any admin screen or customer API: exact money, the two state machines, the Order and Payment services, the operator interface with cash, manual transfer and disabled, cart value objects with the legacy reader and the v2 writer, hashids, the currency converter with NBP and Fixer, the 24 events and the bridge, the notification route, the five scheduled commands and the Winter settings import. It runs after plan 01 so the framework checkout it reads through `go.work` is stable (plan 01 edits `modules/pact`, which this plugin imports).
|
||||
|
||||
The plan leads with a decision gate (Task 1) and then the tracer (Task 2): the tracer's first order writes v2 cart and details rows, which is the one-way door D-11 names, so the concrete v2 shape is confirmed before the first row exists.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator (no requirement IDs). `must_haves` derive from SC-1 to SC-3, D-02, D-04, D-05, D-09 (amended), D-10, D-11, D-14, D-15, RESEARCH Patterns 1 to 5, 7, 10 and the Security Domain. This plan owns no UI-SPEC rows. Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- The Money type and `Amount4` live in the plugin package `money` (not `wire`): the MoneyRight quirks are a payment contract, not a framework value (CONTEXT discretion, RESEARCH Pattern 1).
|
||||
- Package layout: `money`, `fsm`, `models` (leaf, imports `money` only), `cart`, `events` (structs over `models`), `operators` (interface, registry, cash, manual, disabled), `payments` (service, machine, notification), `orders` (service, machine, bridge; imports `payments`), `support` (hashids, guest token, redactor, urlizer), `currency`, `console`, and the root package `paymentgateway` for plugin wiring and routes. `operators.HasOperators` is defined in `operators` so `sm-pgstripe-plugin` imports a leaf, never the root.
|
||||
- `plugin.go` Boot runs boot steps registered with `registerBootStep` (boot.go) in registration order; later plans (admin, mailers, API) add boot steps and capability assertions from their own files and never edit `plugin.go` for them. The one later edit of `plugin.go` is plan 06 Task 2, which extends the `Buckets()` list with `paymentgateway-api` (Journal keeps `Buckets()` in plugin.go, and this plugin follows that layout).
|
||||
- Operator codes stay the PHP translation-key strings; the route segment is their base64 (A5: no literal `stripe` alias).
|
||||
- Events fire inside the transaction after the row is saved (listeners see the persisted state); mail and HTTP side effects use `lagoon.AfterCommit` (plan 04 and plan 05).
|
||||
- `pg:update-metadata` and `pg:update-orders` are not ported (JMS-era maintenance; a rename is a `legacyNames` alias in the registry, never a bulk rewrite). The Yahoo exchange and the raw-card `CreditCard` value object are not ported (RESEARCH Deprecated). All three are recorded in the README.
|
||||
- The hashids salt is the config key `golem15.paymentgateway.hashids_salt` (env `SUMMER_GOLEM15__PAYMENTGATEWAY__HASHIDS_SALT`, the project's compass convention); PG-005 makes an empty salt a boot error. The guest token HMAC uses the application key the lagoon encryption layer already loads.
|
||||
- The synthetic golden fixtures are generated by a standalone PHP script over the frozen tree's vendor autoloader and JMS metadata; the real shop-type rows are requested at the plan 06 checkpoint (RESEARCH Open Question 1) and added in plan 07.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Repository `../sm-paymentgateway-plugin` (module `git.golem15.com/golem15/sm-paymentgateway-plugin`, package `paymentgateway`, plugin ID `golem15.paymentgateway`, Requires `golem15.user`), branch `main`, no remote yet.
|
||||
- `money`: `Money`, `FromMinor`, `FromString`, `Zero`, `Add`, `Sub`, `Mul`, `Div`, `Allocate`, `Minor`, `AmountString`, `Compare`, `Equals`, `IsZero`, `IsNegative`, `Currency`, `RoundingGAAP` option, `Amount4` (`Scan`, `Value`, `Money(currency)`), `ParseAmount4`, `ErrTooPrecise`, `ErrOverflow`.
|
||||
- `fsm`: `Machine`, `Transition`, `Can`, `Apply`, `ErrTransition`.
|
||||
- `models`: `Order` (+ state and transition constants), `Payment` (+ constants), `Currency`, `Shipping`, `Quoteable`, `Settings`, `Register`, `All`.
|
||||
- `updates`: six migrations with the IDs in `files_modified`.
|
||||
- `cart`: `Cart`, `Item`, `AdjustableItem`, `ValueAdjustment`, `PercentAdjustment`, `Details`, `ShippingMethod`, `ShippingAddress`, `NewCart`, `NewItem`, `Decode` (versioned or legacy), `DecodeLegacyCart`, `DecodeLegacyDetails`, `EncodeCart`, `EncodeDetails` (v2), `RegisterItemType`, `RegisterDetailsParams`, `ItemTypes`.
|
||||
- `operators`: `Operator`, `HasOperators`, `Registry`, `PaymentContext`, `Response`, `StatusComplete`, `StatusPending`, `StatusRequiresAction`, `StatusError`, `Cash`, `ManualTransfer`, `Disabled`, `CodeCash`, `CodeManualTransfer`, `CodeDisabled`.
|
||||
- `events`: 24 event structs and `…EventName` constants.
|
||||
- `orders`: `Service` (`Make`, `WithQuote`, `Find`, `FindByHash`, `Wrap`, `Can`, `PerformTransition`, `RequestRefund`, `IsFinal`, `CanBeRefunded`), `Machine`, `RegisterBridge`, `ErrNotConfigured`.
|
||||
- `payments`: `Service` (`Create`, `Purchase`, `Send`, `Accept`, `Cancel`, `Refund`, `FindByUUID`, `RecordExternal`, `ProcessNotification`), `Machine`, `ErrAmountTooLarge`.
|
||||
- `support`: `Hashids` (`Encode`, `Decode`), `Alphabet`, `LegacySalt`, `SaltConfigKey`, `GuestToken` (`Generate`, `Verify`, `DefaultTTL`), `Redact`, `Urlize`, `DeUrlize`.
|
||||
- `currency`: `Converter`, `Provider`, `NBP`, `Fixer`, `ISO` table, `Rate`.
|
||||
- `console`: the five `pg:*` commands and `paymentgateway:import-winter`; `scripts/winter-export-settings.sql`, `scripts/winter-export-orders.sql`.
|
||||
- Routes: `_paymentgateway/{op}`, `/{op}/{mode}`, `/{op}/{mode}/{result}` × GET/POST/PUT/PATCH/DELETE; bucket `paymentgateway-notify`.
|
||||
- Decision note `.planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md`.
|
||||
- Smoke tests: `TestMoneyVectorsSmoke`, `TestAmount4ScanValue`, `TestFSMSmoke`, `TestPaymentGatewayTracer`, `TestLegacyCartGoldens`, `TestRefundBridgeSmoke`, `TestNotificationRoute`, `TestNotificationRouteSmoke`, `TestCurrencyProviders`, `TestPGCommandsSmoke`, `TestImportWinterSmoke`.
|
||||
|
||||
<objective>
|
||||
Port the payment gateway domain to Go as a shared compiled plugin: exact scale-4 money (D-09 amended, D-10), the Payment and Order state machines with their events and bridge (SC-1, SC-3), a build-time operator interface with cash, manual transfer and disabled (SC-2, D-15), the cart value objects with a permanent legacy reader and a versioned writer (D-11), hashids, currencies with NBP and Fixer, the notification route (D-14), the scheduled commands and the Winter import (SC-3), all against the frozen PHP tree (D-02).
|
||||
|
||||
Purpose: this is the contract Horoskopia's horoscope plugin and the six other consumers program against (D-04 b); everything in plans 03 to 06 is a screen or an API over it.
|
||||
Output: a new plugin repository with migrations, models, services, operators, cart, events, route, commands, README and smoke tests, plus a dependency decision note.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/notes/core-plugins-own-repos.md
|
||||
|
||||
The frozen PHP sources are `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway` at `4b9ecf9` (D-02); read them, never a newer master. Framework code and plugin READMEs never name a consuming application; the fixture consumer is `acme.books`. Stage files by explicit path in the plugin repository; planning docs (the decision note, in summercms.go) and code in separate commits; no co-author tags; no push (the remote does not exist yet; plan 06 creates it with the human). Docker-backed tests skip under `-short` with a `TestMain` that calls `flag.Parse()` and checks `testing.Short()`; record NOT RUN in the SUMMARY when no daemon answers.
|
||||
|
||||
<interfaces>
|
||||
Contracts fixed by this plan (doc comment on every exported identifier; Go names are Claude's discretion per CONTEXT, D-09 to D-15 hold):
|
||||
|
||||
```go
|
||||
// money
|
||||
type Money struct{ /* units int64 (amount × 10^4), currency string */ }
|
||||
func FromMinor(cents int64, currency string) Money // PHP int input: cents for every currency
|
||||
func FromString(amount, currency string) (Money, error) // rejects >4 fraction digits (A2), exponents, non-digits
|
||||
func (m Money) Add(o Money) (Money, error) // ErrCurrencyMismatch
|
||||
func (m Money) Mul(num, den int64, opts ...Option) Money // operand as an exact rational; scale 5 truncate then half away from zero to 2 (RoundingGAAP: 4)
|
||||
func (m Money) Allocate(ratios []int64, opts ...Option) []Money
|
||||
func (m Money) Minor() int64 // getAmount(): half up to cents
|
||||
func (m Money) AmountString() string // "%d.%04d" with sign
|
||||
type Amount4 int64 // column type; Scan/Value exact; (a Amount4).Money(currency string) Money
|
||||
|
||||
// fsm
|
||||
type Transition struct { Name string; From []string; To string; Guard func(ctx context.Context, subject any) (bool, error) }
|
||||
type Machine struct { States, Initial, Final []string; Transitions map[string]Transition }
|
||||
func (m *Machine) Can(ctx context.Context, state, transition string, subject any) (bool, error)
|
||||
func (m *Machine) Apply(ctx context.Context, state, transition string, subject any) (string, error)
|
||||
|
||||
// operators
|
||||
type Operator interface {
|
||||
Code() string; Mode() string /* api|manual|cash|disabled */; LogoPath() string
|
||||
AllowedCurrencies() []string /* ["*"] = any */; RequiredFields() []string
|
||||
Enabled(ctx context.Context) (bool, error)
|
||||
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)
|
||||
}
|
||||
type HasOperators interface{ PaymentOperators() []Operator }
|
||||
type PaymentContext struct { Payment *models.Payment; Order *models.Order; Cart *cart.Cart; Details *cart.Details; Settings *models.Settings; Data map[string]any }
|
||||
type Response struct { Status string; RedirectURL, ClientSecret, PaymentIntentID, PublishableKey, Message string; Data map[string]any }
|
||||
type Registry struct{ /* unexported */ }
|
||||
func (r *Registry) Get(code string) (Operator, bool); func (r *Registry) All() []Operator; func (r *Registry) Enabled(ctx context.Context) ([]Operator, error)
|
||||
|
||||
// orders and payments
|
||||
func (s *orders.Service) Make(ctx context.Context, tx *gorm.DB, c *cart.Cart, d *cart.Details, user *usermodels.User, opts ...MakeOption) (*models.Order, error)
|
||||
func (s *orders.Service) PerformTransition(ctx context.Context, tx *gorm.DB, order *models.Order, transition string) error
|
||||
func (s *payments.Service) Create(ctx context.Context, tx *gorm.DB, c *cart.Cart, d *cart.Details, user *usermodels.User) (*models.Payment, error)
|
||||
func (s *payments.Service) Purchase(ctx context.Context, tx *gorm.DB, p *models.Payment) (operators.Response, error)
|
||||
func (s *payments.Service) RecordExternal(ctx context.Context, tx *gorm.DB, order *models.Order, operatorCode string, data map[string]any) (*models.Payment, error)
|
||||
func (s *payments.Service) ProcessNotification(ctx context.Context, w http.ResponseWriter, r *http.Request, operatorCode string)
|
||||
```
|
||||
|
||||
v2 JSON (RESEARCH Pattern 7, confirmed in Task 1): cart `{"v":2,"currency":"PLN","items":[{"id","type","description","count","net_price":{"amount":"24.3900","currency":"PLN"},"tax":23,"data":{…},"adjustments":[{"id","type":"value"|"percent","description","value":{…}|"percent":23}]}],"shipping":{"name","cost":{…},"tax"}}`; details `{"v":2,"operator","description","return_url","email","shipping_address":{"receiver_first_name",…},"parameters":{…}}`. Legacy keys: RESEARCH Pattern 7 key map.
|
||||
|
||||
Framework seams (read, do not change): `party.Activated` and `app.Lookup` (PATTERNS token_commands.go:69-77), `app.Publish`, `festival.Bus` `Fire`/`Listen`, `lagoon.Transaction` and `lagoon.AfterCommit` (transaction.go:55,103), `lagoon.Fill` allow-lists, `lagoon.Encrypted` key loading (the app key accessor for the guest token HMAC), `pact.HasSchedule`/`ScheduledCommand`/`Daily`/`Every` (capabilities.go:124-175), `bonfire.Command`, `pact.Router` (Get/Post/Put/Patch/Delete only, capabilities.go:66), `surf.Use`, `surf.BucketProvider`, `wire.WriteJSON`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking" auto_select="v2-research">
|
||||
<name>Task 1: Confirm the one-way stored-row contract: the v2 cart and details JSON shape every application's orders table will carry (D-11)</name>
|
||||
<read_first>.planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 7: legacy key map and the v2 shape, Pitfalls 4 and 5), .planning/phases/18-payment-gateway/18-CONTEXT.md (D-11 points 1 to 4), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/metadata/Cart.yml, Item.yml, AdjustableItem.yml, Details.yml, ShippingAddress.yml, ShippingMethod.yml (the legacy field names), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/valueobjects/Cart.php, Item.php, Details.php (the PHP property names)</read_first>
|
||||
<action>Print the two candidate v2 shapes side by side (the RESEARCH Pattern 7 long-key shape, and a short-key shape that keeps the JMS letters plus a v marker), the legacy key map, and the consequences: once Go writes a v2 row PHP cannot read it (D-11 point 4), the shape is permanent in every application's `golem15_paymentgateway_orders.cart` and `.details`, and app extensions register their item types against it. Recommend the long-key shape (readable, explicit JSON tags, items as an ordered array). Stop for the human unless auto mode selects the default.</action>
|
||||
<decision>Which v2 JSON shape do the cart and details columns carry from the first Go-written row on?</decision>
|
||||
<context>D-11 already decided versioned Go rows and a permanent legacy reader; what is still open is the concrete key set the writer emits, and that is the irreversible part: every consumer app's stored rows and every registered extension type depend on it from the first order onward.</context>
|
||||
<options>
|
||||
<option id="v2-research">
|
||||
<name>Long keys (RESEARCH Pattern 7): v, currency, items[] with id, type, description, count, net_price, tax, data, adjustments; shipping; details with operator, description, return_url, email, shipping_address, parameters</name>
|
||||
<pros>Readable rows, explicit JSON tags for extension types, items as an ordered array (Pitfall 5), no JMS letters to document.</pros>
|
||||
<cons>Row bytes grow (text column; no index involved).</cons>
|
||||
</option>
|
||||
<option id="v2-short">
|
||||
<name>Short keys: the JMS letters (i, dn, c, p, da, t, d, o, r, e, s) plus v: 2 and items as an array</name>
|
||||
<pros>Smallest rows; closest to the legacy shape.</pros>
|
||||
<cons>Opaque to app developers; every extension type documents letters; the legacy reader and the writer share key names, which hides format bugs.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "v2-research" or "v2-short"; the executor records the choice in the SUMMARY and in the cart package doc comment.</resume-signal>
|
||||
<verify>
|
||||
<automated>test -f /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/metadata/Cart.yml && test -f /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/metadata/Details.yml && echo shape-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "shape-sheet-ok" absent: the frozen metadata files the shape sheet is derived from are not where D-02 says.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the option chosen and the key list verbatim, and `../sm-paymentgateway-plugin/cart/v2.go` (Task 2) carries a package doc comment naming the chosen shape and this checkpoint.
|
||||
</acceptance_criteria>
|
||||
<done>The permanent row format is a recorded human (or auto-selected) decision before the first v2 row is written.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: a consumer builds a cart, `Orders.Make` stores an order with an exact total and a hash id, a cash payment is created, sent and accepted, the bridge pays the order and fires OrderPaid once</name>
|
||||
<reversibility rating="one-way">D-11: this task writes the first v2 cart and details rows (the shape confirmed in Task 1); D-05: the table and migration IDs become the contract shared by seven applications.</reversibility>
|
||||
<files>.planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md, ../sm-paymentgateway-plugin/.gitignore, ../sm-paymentgateway-plugin/go.mod, ../sm-paymentgateway-plugin/go.sum, ../sm-paymentgateway-plugin/go.work, ../sm-paymentgateway-plugin/plugin.go, ../sm-paymentgateway-plugin/boot.go, ../sm-paymentgateway-plugin/config/config.yaml, ../sm-paymentgateway-plugin/money/money.go, ../sm-paymentgateway-plugin/money/amount.go, ../sm-paymentgateway-plugin/money/parse.go, ../sm-paymentgateway-plugin/money/vectors_test.go, ../sm-paymentgateway-plugin/fsm/fsm.go, ../sm-paymentgateway-plugin/fsm/fsm_smoke_test.go, ../sm-paymentgateway-plugin/models/registry.go, ../sm-paymentgateway-plugin/models/order.go, ../sm-paymentgateway-plugin/models/payment.go, ../sm-paymentgateway-plugin/models/currency.go, ../sm-paymentgateway-plugin/models/shipping.go, ../sm-paymentgateway-plugin/models/quoteable.go, ../sm-paymentgateway-plugin/models/settings.go, ../sm-paymentgateway-plugin/updates/registry.go, ../sm-paymentgateway-plugin/updates/202610130001_create_golem15_paymentgateway_payments.go, ../sm-paymentgateway-plugin/updates/202610130002_create_golem15_paymentgateway_orders.go, ../sm-paymentgateway-plugin/updates/202610130003_create_golem15_paymentgateway_shippings.go, ../sm-paymentgateway-plugin/updates/202610130004_create_golem15_paymentgateway_currencies.go, ../sm-paymentgateway-plugin/updates/202610130005_create_golem15_paymentgateway_quoteables.go, ../sm-paymentgateway-plugin/updates/202610130006_create_golem15_paymentgateway_settings.go, ../sm-paymentgateway-plugin/cart/cart.go, ../sm-paymentgateway-plugin/cart/item.go, ../sm-paymentgateway-plugin/cart/details.go, ../sm-paymentgateway-plugin/cart/v2.go, ../sm-paymentgateway-plugin/operators/operator.go, ../sm-paymentgateway-plugin/operators/registry.go, ../sm-paymentgateway-plugin/operators/response.go, ../sm-paymentgateway-plugin/operators/cash.go, ../sm-paymentgateway-plugin/events/events.go, ../sm-paymentgateway-plugin/orders/service.go, ../sm-paymentgateway-plugin/orders/machine.go, ../sm-paymentgateway-plugin/orders/bridge.go, ../sm-paymentgateway-plugin/payments/service.go, ../sm-paymentgateway-plugin/payments/machine.go, ../sm-paymentgateway-plugin/support/hashids.go, ../sm-paymentgateway-plugin/testmain_test.go, ../sm-paymentgateway-plugin/harness_test.go, ../sm-paymentgateway-plugin/tracer_test.go</files>
|
||||
<read_first>../sm-journal-plugin/go.work, ../sm-journal-plugin/go.mod (:1-20), ../sm-journal-plugin/.gitignore, ../sm-journal-plugin/plugin.go (:1-58, 85-130: capability assertions, embed, Register/Boot, Buckets), ../sm-journal-plugin/updates/registry.go and updates/202610060001_create_golem15_journal_posts.go (migration shape), ../sm-sitemap-plugin/updates/202610110002_create_golem15_sitemap_settings.go (singleton seed row), ../sm-sitemap-plugin/models/settings.go (:22-55, 85-87), ../sm-journal-plugin/models/settings.go (:208-225 Rules), ../sm-sitemap-plugin/testmain_test.go (:116-129 TestMain), ../sm-journal-plugin/admin_harness_test.go (:173-238 bootJournal, dedicatedDB), ../sm-summercmsio-app/plugins/golem15/user/plugin.go (:184-258: withDB, Middlewares) and models/user.go (the User type Make takes), ../fonoteka.go/plugins/golem15/fonoteka/models/money_string.go (:17-67 Scan and Value shape only; its rounding is the anti-pattern), summercms modules/lagoon/encrypted.go (:62-102 Scan/Value on a struct), modules/party/registry.go (:100-130 Activated before Register), modules/cabana/token_commands.go (:69-77 Activated scan), modules/festival/README.md (:27-32), modules/lagoon/transaction.go (:55 Transaction, :103 AfterCommit), modules/lagoon/fill.go (Fill allow-lists), .planning/phases/18-payment-gateway/18-RESEARCH.md (§ Schema, Pattern 1 vectors and rounding mechanics, Pattern 2, Pattern 3 both FSM tables and the bridge, Pattern 4, Code Examples hashids and parseAmount4, Pitfalls 1, 5, 6, 7, 9, 11), the frozen tree: core/Order.php (:49-79 constants, :258 requestRefund, :306-360 performTransition special cases, :406-594 config), core/Operator.php (:55-96 constants, :355-440 purchase and refund, :706-770 config), core/OrderFactory.php (:66-120 make: amounts, native conversion, hash_id, guest_code, the creation event), core/PaymentFactory.php (:152 make: uuid, amounts, data), core/PaymentManager.php (:56-165 create/accept/refund/cancel), core/OrderPaymentEventSubscriber.php (whole file), operators/CashPayment.php, traits/FiniteStateMachine.php, traits/ModelDecorator.php (:55-64 snake_case data keys), support/HashIdsHelper.php (:21-95), models/Order.php and models/Payment.php (fillable, hidden, casts, relations), updates/create_orders_table.php, create_payments_table.php, create_shippings_table.php, create_currencies_table.php, create_quoteables_table.php, add_order_guest_code.php, add_nbp_fields_to_currencies.php, models/Settings.php (:32 item code and initSettingsData defaults), /media/nvme/dev/golem15/horoskopia.eu/vendor/keios/moneyright/src/Money.php (:34-88, :280) and Math.php (bcround, bcRoundHalfUp)</read_first>
|
||||
<action>Per D-02, D-05, D-09 (amended 2026-10-13: scale-4 int64), D-10, D-11 (shape from Task 1), D-15, SC-1 and SC-3, wire one cash order through every layer.
|
||||
|
||||
Decision note first, committed alone as a planning doc in summercms.go: `.planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md` names `github.com/speps/go-hashids/v2` v2.0.1 for `sm-paymentgateway-plugin` (24/24 vectors against PHP hashids 5.0.2, RESEARCH Standard Stack and A3), states that stripe-go v87.0.0 is already decided by D-12 for `sm-pgstripe-plugin`, and that `shopspring/decimal` is an indirect dependency of stripe-go never used for money.
|
||||
|
||||
Repository: create `../sm-paymentgateway-plugin` (`git init -b main`, `.gitignore` like Journal's), `go.mod` (module `git.golem15.com/golem15/sm-paymentgateway-plugin`, go 1.27.0, requires summercms at the highest `v0.1.*` tag at execution time (`git -C ../summercms.go tag -l 'v0.1.*' --sort=-v:refname | head -1`; v0.1.24 at planning time) or the tag plan 01 Task 4 pushed, whichever is higher (the `go.work` replace makes the pin inert locally), sm-user-plugin v0.1.1, gormigrate v2.1.7, goccy/go-yaml v1.19.2, pgx v5.10.0, testcontainers-go and its postgres module v0.44.0, gorm v1.31.2, go-hashids v2.0.1; `go mod tidy`), `go.work` (`use .`, replace summercms to `../summercms.go`, replace sm-user-plugin to `../sm-summercmsio-app/plugins/golem15/user`, the remote HEAD 24b16b1 = v0.1.1 per RESEARCH Open Question 4), `config/config.yaml` with the plugin defaults (`golem15.paymentgateway.hashids_salt: ""` documented as required).
|
||||
|
||||
plugin.go: ID `golem15.paymentgateway`, Requires `golem15.user`; compile-time assertions for party.Plugin, pact.HasMigrations, pact.HasModels, pact.HasLang (lang added in plan 03; an empty `lang` dir with a placeholder yaml is acceptable here), pact.HasCommands, pact.HasRoutes, pact.HasSchedule, surf.BucketProvider; Register keeps the app; Boot runs the boot steps in order. boot.go: `registerBootStep(name string, fn func(app *backpack.App) error)` and the first steps: (1) read the hashids salt and fail boot with `paymentgateway: golem15.paymentgateway.hashids_salt is required` when empty (PG-005); (2) collect operators: `app.Lookup[party.Activated]()`, the plugin itself first (cash; manual and disabled come in Task 3), then every other activated plugin implementing `operators.HasOperators`, fail on a duplicate Code naming both plugin IDs, `app.Publish(*operators.Registry)`; (3) build and publish `*orders.Service` and `*payments.Service`; (4) `orders.RegisterBridge(app)` listeners.
|
||||
|
||||
money: implement the interfaces block exactly, mirroring MoneyRight (RESEARCH Pattern 1): int input is cents for every currency; string input is normalized without rounding and refused above four places (ErrTooPrecise, A2); `Mul`/`Div` take an exact rational operand (tax is `tax/100`, never a float string), compute at scale 5 with truncation, then round half away from zero to 2 places (RoundingGAAP: 4) as `Math::bcRoundHalfUp` does; `Allocate` ports the remainder loop; `Minor` is half up to cents; comparisons at four places; `Amount4` Scan accepts string and []byte, parses with `ParseAmount4` (RESEARCH Code Examples: reject exponents, non-digits, more than four fraction digits; ParseInt overflow is an error), Value prints `%d.%04d` with the sign handled separately; no floating-point type anywhere in the package. vectors_test.go: TestMoneyVectorsSmoke with at least the twelve PHP vectors from RESEARCH Pattern 1 (PLN(3984), PLN('12.34565') rejected on input but equal to 12.3457 after an arithmetic round, 2439 × 23/100, 10.01 × 0.23 default and GAAP, 0.05 × 0.5 both roundings, 10/3, getAmount cases, allocate 100 [1,1,1] both roundings, allocate 0.05 [1,1], EUR 19.99 → PLN @4.2731 = 85.4193, JPY(500)) and TestAmount4ScanValue (round trip of 85.4193, rejection of 1.23456, 1e3 and abc, overflow).
|
||||
|
||||
fsm: the generic table-driven machine from the interfaces block; fsm_smoke_test.go TestFSMSmoke on a toy machine (Can is membership plus guard; Apply returns the new state; unknown transition is ErrTransition).
|
||||
|
||||
models (leaf package, imports money only): Order, Payment, Currency, Shipping, Quoteable and Settings with `TableName()` returning the six PHP names plus `golem15_paymentgateway_settings`, gorm column tags from RESEARCH § Schema, `AmountStored`/`NativeAmountStored`/`CostStored` typed `money.Amount4` beside their `Currency` string columns, an `Amount() money.Money` method per financial model, `MorphName()` returning the PHP class strings, state and transition constants with the PHP values (`OrderStatePlaced = "Placed"`, `OrderTransitionPay = "Pay"`, `PaymentStateCreated = "Created"`, ...), `Fillable()` per PHP `$fillable` minus the protected set (`state`, every `*_amount_stored`, `currency`, `native_currency`, `user_id`, `hash_id`, `guest_code`, `successful_payment_id`, `was_shipped`, `operator`, `uuid`; PG-001), `Rules()` strings, `Payment.Data` as a jsonable map with the snake_case keys PHP writes (`email`, `return_url`, `is_paid_in_stripe`, `charge_id`, `payment_intent_id`, Details parameters), Settings with the snake_case columns of RESEARCH Pattern 8 (default_currency nullable, orders_enabled, shippings_enabled, use_quotes, use_underway, enable_guest, currency_auto_create, backend_prefer_local, frontend_prefer_local, exchange_rate_provider default fixer, the four timeouts with defaults 7/2/7/14, cash_enabled, manual_transfer_enabled, manual_transfer_name, manual_transfer_receiver_address, manual_transfer_bank_details, manual_transfer_swift, manual_transfer_bank_acc_no, order_url_template, log_requests) and Rules `default_currency: nullable|string|size:3`, timeouts `integer|min:1|max:30`.
|
||||
|
||||
updates: six migrations with the IDs in files_modified (Journal shape: raw SQL statements in a loop, init registers): payments, orders (with the `add_order_guest_code` column folded in), shippings, currencies (with the `add_nbp_fields_to_currencies` columns folded in), quoteables plus `golem15_paymentgateway_qs_orders` in the same migration, and the settings singleton (`INSERT ... (id) VALUES (1)`); Postgres types per RESEARCH § Schema: SERIAL ids, NUMERIC(12,4) for order amounts, NUMERIC(10,4) for payments, shippings and quoteables, NUMERIC(8,4) ratio, BOOLEAN for tinyint(1), TEXT for mediumtext and text, VARCHAR with the PHP lengths, TIMESTAMPTZ NULL timestamps, `rate_source VARCHAR(20) DEFAULT 'fixer'`, the indexes (hash_id, user_id, shipping_id, successful_payment_id, order_id, payment user_id, operator, currency, native_currency), UNIQUE on payments.uuid and currencies.iso, composite PK on qs_orders; every Rollback drops its table(s).
|
||||
|
||||
cart (core only here; adjustments, shipping, address and the legacy reader are Task 3): `Cart` with an ordered item slice plus an index map (Pitfall 5), `NewCart(currency)`, `Add`, `Item(id)`, `AddPiece`, `SubtractPiece` (never below 1), `Remove`, `TotalNet`, `TotalTax`, `TotalGross` in the first item's currency; `Item` with `NewItem(description string, count int, netPrice money.Money, tax int, data any)` validating a non-empty description, a positive count and a 0..100 integer tax; `Details` with operator, description, return URL, email, parameters and `SecureParameters` that are never serialized; v2.go: `EncodeCart`/`EncodeDetails` writing the Task 1 shape with `"v": 2` and `Decode` dispatching on a top-level numeric `v` (the legacy path returns a typed "legacy rows arrive in Task 3" error for now, replaced in Task 3).
|
||||
|
||||
operators: the interface, `Response` and the status constants from the interfaces block; `Registry` (ordered, `Get`, `All`, `Enabled`); `Cash` with `Code() = "golem15.paymentgateway::lang.channels.cash"`, Mode cash, Enabled reading `settings.cash_enabled`, AllowedCurrencies `["*"]`, Purchase returning StatusPending (the admin accepts it later), Refund returning StatusComplete, VerifyNotification false.
|
||||
|
||||
events: one struct per PHP class under `events/` with a doc comment naming the class and an exported `…EventName` constant (`"Golem15\\PaymentGateway\\Events\\OrderPaid"` and so on, all 24), payloads `Order *models.Order` or `Payment *models.Payment` plus `User`.
|
||||
|
||||
orders and payments: `orders.Machine` and `payments.Machine` are the two PHP tables from RESEARCH Pattern 3 verbatim (states, initial, final, every from-set, the Ship guard reading Underway or `shippings_enabled`, Cancel without guard on payments); `payments.Service.Create` (uuid v4, operator from details, Created, amounts from the cart with the `numeric(10,4)` ceiling as `ErrAmountTooLarge`, native amount through the converter stub that returns the same amount when the currency equals `default_currency`, else `ErrNotConfigured` until Task 4 adds the converter), `Purchase` (operator Purchase, then Send; Complete adds Accept; Error adds Reject with a logged error; RequiresAction and Pending leave Sent), `Send`/`Accept`/`Cancel`/`Refund` (Can, then save the state inside the given transaction, then `app.Events.Fire` the typed event), `FindByUUID`; `orders.Service.Make` (refuses with `ErrNotConfigured` when `default_currency` is empty, Pitfall 11; state Placed, or Quote with `WithQuote()`; cart and details stored through the v2 encoders; `amount_stored` = gross total, `native_amount_stored` through the converter stub; insert, then `hash_id` = hashids(id, 8) and, for a nil user, `guest_code` = hashids(id×118, 32), written in the same transaction; fires the creation event PHP `OrderFactory::make` fires), `Find`, `FindByHash` (column lookup, hashids decode as a cross-check), `Wrap`, `Can`, `PerformTransition` (the PHP special cases listed in must_haves; refund-path special cases need `payments.Service.Refund`, implemented here, and the manual/cash cancel on Reject), `RequestRefund`, `IsFinal`, `CanBeRefunded`; `orders.RegisterBridge` ports OrderPaymentEventSubscriber for all five payment events (must_haves), gated by `orders_enabled`, and locks the order row `FOR UPDATE` before `Can` (Pitfall 9).
|
||||
|
||||
support/hashids.go: `Alphabet`, `LegacySalt = "KEIOS.PAYMENTGATEWAY"`, `SaltConfigKey = "golem15.paymentgateway.hashids_salt"`, `Hashids{Encode(id int64, length int) string; Decode(key string) ([]int64, error)}` decoding with the configured salt then the legacy salt (RESEARCH Code Examples: `hd.MinLength` 8 or 32).
|
||||
|
||||
Harness and tracer: testmain_test.go (TestMain with `flag.Parse()` and `testing.Short()`, starts Postgres like sitemap's); harness_test.go copies Journal's `bootJournal` as `bootPaymentGateway(t, ids, extraEnv...)` activating `golem15.user` and `golem15.paymentgateway` with `SUMMER_GOLEM15__USER__JWT__SECRET`, `SUMMER_APP__KEY`, `SUMMER_GOLEM15__PAYMENTGATEWAY__HASHIDS_SALT=test-salt` and `mail.driver: memory`; tracer_test.go TestPaymentGatewayTracer (Postgres): set `default_currency = PLN`, `orders_enabled = true`, `cash_enabled = true` on the settings row; a consumer-shaped test builds `NewCart("PLN")` with one `NewItem("Chart", 1, money.FromMinor(3984, "PLN"), 23, map[string]any{"product_type": "natal"})` and Details for the cash operator with an email and a return URL; `Orders.Make` with a user created through the user plugin; assert state Placed, `amount_stored` row text `39.8400`, an 8-character `hash_id` that decodes to the id, `cart` text starting with `{"v":2`; `Payments.Create` then `Purchase`: payment Sent, order AwaitingPayment (bridge); `Payments.Accept`: Accepted, order Paid, `successful_payment_id` set, an `OrderPaid` listener registered by the test called exactly once; a second `Accept` is refused (Can false) and changes nothing; a Make with `default_currency` empty returns `ErrNotConfigured`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^(TestMoneyVectorsSmoke|TestAmount4ScanValue)$' -v ./money/ 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2 && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestFSMSmoke$' -v ./fsm/ 2>&1 | grep -- '^--- PASS: TestFSMSmoke' && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -q 'github.com/speps/go-hashids/v2 v2.0.1' ../sm-paymentgateway-plugin/go.mod</automated>
|
||||
<fails_when>Non-zero exit: vet fails; fewer than two money passes (a PHP vector disagrees, Scan rounded or accepted a fifth decimal) or no FSMSmoke pass; a Docker-free package test fails; go.mod lacks the pinned go-hashids.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestPaymentGatewayTracer$' -v . 2>&1 | grep -- '^--- PASS: TestPaymentGatewayTracer'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the order total is not the exact string, the hash id does not decode, the bridge did not move the order to AwaitingPayment and Paid, OrderPaid fired other than once, or a replayed Accept changed state.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-paymentgateway-plugin rev-parse --abbrev-ref HEAD` prints main and `git -C ../sm-paymentgateway-plugin remote` prints nothing.
|
||||
- `grep -n "module git.golem15.com/golem15/sm-paymentgateway-plugin" ../sm-paymentgateway-plugin/go.mod` prints one line and `grep -n "sm-summercmsio-app/plugins/golem15/user" ../sm-paymentgateway-plugin/go.work` prints one line.
|
||||
- `! grep -rn "float64\|float32\|ParseFloat\|big.Float" ../sm-paymentgateway-plugin/money ../sm-paymentgateway-plugin/cart ../sm-paymentgateway-plugin/orders ../sm-paymentgateway-plugin/payments --include='*.go'` exits 0.
|
||||
- `ls ../sm-paymentgateway-plugin/updates/ | grep -c '^202610130' ` prints 6 and `grep -rn "AutoMigrate" ../sm-paymentgateway-plugin --include='*.go'` prints nothing.
|
||||
- `grep -c "EventName = " ../sm-paymentgateway-plugin/events/events.go` prints 24.
|
||||
- `grep -n "KEIOS.PAYMENTGATEWAY" ../sm-paymentgateway-plugin/support/hashids.go` prints one line and `test -f .planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md` succeeds with `git log --format=%s -1 -- .planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md` starting with "docs".
|
||||
</acceptance_criteria>
|
||||
<done>A consumer can create an order and have it paid in cash through the real services, state machines, bridge and events, with exact money in the real tables.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: The complete cart: adjustable items, shipping, addresses, the permanent legacy JMS reader with golden fixtures, the extension registry; manual transfer and disabled operators; guest tokens; the refund and reject paths and RecordExternal</name>
|
||||
<reversibility rating="one-way">D-11: the legacy reader is the only path that keeps seven applications' existing rows readable; its decoding rules are a permanent contract.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/cart/adjustable.go, ../sm-paymentgateway-plugin/cart/shipping.go, ../sm-paymentgateway-plugin/cart/legacy.go, ../sm-paymentgateway-plugin/cart/registry.go, ../sm-paymentgateway-plugin/cart/v2.go, ../sm-paymentgateway-plugin/cart/cart.go, ../sm-paymentgateway-plugin/cart/details.go, ../sm-paymentgateway-plugin/cart/legacy_golden_test.go, ../sm-paymentgateway-plugin/cart/testdata/legacy/legacy-real-1-cart.json, ../sm-paymentgateway-plugin/cart/testdata/legacy/legacy-real-1-details.json, ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-shop-cart.json, ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-shop-details.json, ../sm-paymentgateway-plugin/cart/testdata/legacy/synthetic-invoice-item-cart.json, ../sm-paymentgateway-plugin/tests/php/gen-fixtures.php, ../sm-paymentgateway-plugin/operators/manual_transfer.go, ../sm-paymentgateway-plugin/operators/disabled.go, ../sm-paymentgateway-plugin/boot.go, ../sm-paymentgateway-plugin/support/guest_token.go, ../sm-paymentgateway-plugin/orders/service.go, ../sm-paymentgateway-plugin/payments/service.go, ../sm-paymentgateway-plugin/tracer_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/cart (Task 2 files), the frozen tree: metadata/*.yml (all eleven), valueobjects/AbstractItem.php, Item.php, AdjustableItem.php, AbstractAdjustment.php, ValueAdjustment.php, PercentAdjustment.php, Cart.php, Details.php, ShippingMethod.php, ShippingAddress.php, core/SerializationService.php, core/FactoryConfiguration.php (setCartClass/setDetailsClass, the ItemFormatter type map), support/GuestTokenGenerator.php (:21-70), operators/ManualTransferPayment.php, operators/DisabledPayment.php, core/PaymentFactory.php (:126 wrap: the disabled wrapper), core/Order.php (:306-360 the refund and reject special cases), core/PaymentManager.php (:112-165 refund and cancel), tests/unit/CartTest.php, ItemTest.php, DetailsTest.php (the behaviours this task must honour), /media/nvme/dev/golem15/portal.golem15.com/plugins/golem15/invoicer/Plugin.php (:88-106 InvoiceItem registration), the Horoskopia dump row cited in RESEARCH F-8 (`/media/nvme/dev/golem15/horoskopia.eu/storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql`, the single `golem15_paymentgateway_orders` tuple) and the redacted shape in RESEARCH Pattern 7, /media/nvme/dev/golem15/horoskopia.eu/vendor/autoload.php and vendor/jms/serializer (SerializerBuilder, YAML metadata driver), modules/lagoon/encrypted.go (the loaded application key accessor), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 7 reader rules 1-6 and the extension registry, Pitfalls 4 and 5, A12, Open Question 1)</read_first>
|
||||
<action>Per D-11 (all four points), D-15 and SC-1.
|
||||
|
||||
cart: `AdjustableItem` with an ordered adjustment list (`ValueAdjustment{id, description, value money.Money}`, `PercentAdjustment{id, description, percent int}`) and totals that apply adjustments as PHP does; `ShippingMethod{name, cost money.Money, tax}` on the cart with its row in the totals; `ShippingAddress` with the PHP fields (receiver first name, last name, street, building number, flat number, zip, city, country; legacy keys rfn, rln, s, bn, fn, z, cty, c); Cart and Details JSON per Task 1 for all of these. registry.go: `RegisterItemType(name string, legacyNames []string, newData func() any) error` (name allowlisted: lower-case letters, digits and dashes; the built-ins `generic` and `adjustable` with legacy names `generic`/`adjustable`; a duplicate or empty name is an error), `RegisterDetailsParams(name string, newParams func() any) error` for typed Details parameters, `ItemTypes()`; the PHP `ItemFormatter` map (`GENERIC`, `ADJUSTABLE` plus app entries) is this registry. legacy.go: `DecodeLegacyCart`/`DecodeLegacyDetails` as a `json.Decoder` token walk over the JMS shape (RESEARCH Pattern 7 key map and reader rules: `[]` is an empty map for `p`, `da`, `i`, `ad`; the order of `i` and `ad` is preserved; missing keys tolerated; `t` may be `23` or `23.0` in ShippingMethod; `type` dispatches on the registry's legacy names; an unknown type returns a typed error naming it, A12); `Decode` dispatches on a numeric top-level `v`. Golden fixtures: `legacy-real-1-{cart,details}.json` are the one real legacy row (the dump tuple RESEARCH F-8 cites) with every string value (description, data strings, email, return URL, description) replaced by neutral placeholders and the structure, numbers, keys and order kept exactly (PII rule in the Security Domain); nothing in the fixture files, their names or the script names the application the row came from. `tests/php/gen-fixtures.php` is a standalone script (`php -d display_errors=1 tests/php/gen-fixtures.php <winter-root> <out-dir>`, where `<winter-root>` is the frozen Winter installation whose `vendor/autoload.php` and `plugins/golem15/paymentgateway/` it loads; the script carries no application path or name) that builds a JMS serializer with the plugin's `metadata/` directory and the plugin's value objects, and serializes (a) a shop cart with a generic Item, an AdjustableItem carrying one value and one percent adjustment, and a ShippingMethod, plus Details with a ShippingAddress and parameters, and (b) a cart whose item type is an app-registered name (`InvoiceItem`); commit its outputs as the `synthetic-*.json` fixtures with a leading `_generator` comment key naming the script (and, if the value objects cannot be loaded outside Winter, hand-build the files from `metadata/*.yml` and say so in `_generator`). legacy_golden_test.go TestLegacyCartGoldens (no Docker): each fixture decodes; the real legacy cart has one item with count 1, net price 2439.0000 PLN, tax 23 and the three data keys in order; its details have the cash operator string replaced by the fixture's operator value and an empty parameters map; the shop cart keeps item order, applies the adjustments and the shipping row in `TotalGross`; the InvoiceItem cart fails with the unknown-type error until the test registers `invoice-item` with legacy name `InvoiceItem`, then decodes; a decoded legacy cart re-encoded with `EncodeCart` is v2 (never the legacy shape), and `Decode` of that output equals the decoded value.
|
||||
|
||||
operators: `ManualTransfer` (`Code() = "golem15.paymentgateway::lang.channels.manualTransfer"`, Mode manual, Enabled from `manual_transfer_enabled`, RequiredFields the bank detail settings, Purchase Pending, Refund Complete) and `Disabled` (`Code() = "golem15.paymentgateway::lang.channels.disabled"`, Mode disabled, Enabled false, every operation refused with a typed error, VerifyNotification false); boot.go registers manual transfer alongside cash; `payments.Service.Wrap(payment)` returns the registered operator or `Disabled` when the code is unknown or the operator reports not enabled (PHP PaymentFactory::wrap).
|
||||
|
||||
support/guest_token.go: `GuestToken{Generate(orderID int64) string; Verify(token string, ttl time.Duration) (int64, error)}`, HMAC-SHA256 over `timestamp|orderId` keyed with the loaded application key, base64 token, `DefaultTTL = 24h`, constant-time comparison; a token is bound to one order id (PG-002).
|
||||
|
||||
Refund and reject paths (orders/service.go, payments/service.go): complete `PerformTransition`'s PHP special cases: Reject and RejectEarly first Cancel every manual and cash payment of the order; GrantRefund, DirectRefund and RefundAfterReturn call `payments.Refund` on the successful payment when it is not already Refunded and let the PaymentRefunded bridge apply the order transition (when it is already Refunded the transition is applied directly); `payments.Refund` calls the operator's Refund then applies the Refund transition and fires PaymentRefunded; `RecordExternal(ctx, tx, order, operatorCode, data)` creates a Payment row in Created linked to the order with the operator code and the given data (the consumer-driven path D-04 b; the consumer then calls Send and Accept). tracer_test.go gains TestRefundBridgeSmoke (Postgres): a paid cash order on `DirectRefund` moves to Refunded through the payment refund event and PaymentRefunded fires once; a Placed order with a manual payment on `RejectEarly` cancels the payment and ends Rejected; RecordExternal + Send + Accept pays an order exactly like the operator path.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestLegacyCartGoldens$' -v ./cart/ 2>&1 | grep -- '^--- PASS: TestLegacyCartGoldens' && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && ls ../sm-paymentgateway-plugin/cart/testdata/legacy/*.json | grep -c json | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestLegacyCartGoldens" (a fixture failed to decode, item order was lost, an empty array was not read as a map, the unknown type did not fail, or a re-encode produced the legacy shape); a Docker-free test fails; fewer than five fixture files are committed.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPaymentGatewayTracer|TestRefundBridgeSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: DirectRefund did not end Refunded through the bridge, RejectEarly left a manual payment un-canceled, or RecordExternal did not pay the order.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func DecodeLegacyCart\|func DecodeLegacyDetails\|func RegisterItemType\|func RegisterDetailsParams" ../sm-paymentgateway-plugin/cart/*.go` prints four lines.
|
||||
- `grep -n '"type":"generic"\|"dn"' ../sm-paymentgateway-plugin/cart/testdata/legacy/legacy-real-1-cart.json` prints at least one line and `! grep -rniE "@gmail|@wp\.pl|@o2\.pl" ../sm-paymentgateway-plugin/cart/testdata/legacy` exits 0 (no real e-mail addresses in committed fixtures).
|
||||
- `grep -n "channels.manualTransfer\|channels.disabled" ../sm-paymentgateway-plugin/operators/*.go` prints at least two lines.
|
||||
- `grep -n "func (s \*Service) RecordExternal" ../sm-paymentgateway-plugin/payments/service.go` prints one line and `grep -n "subtle.ConstantTimeCompare" ../sm-paymentgateway-plugin/support/guest_token.go` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every existing PHP row decodes, every new row is v2, app item types register without class names, and the refund, reject and consumer-driven paths behave as the PHP subscriber did.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The notification route with verify-before-lookup and redacted logs, currencies with NBP and Fixer, the five scheduled `pg:*` commands, `paymentgateway:import-winter` with its export SQL, and the README</name>
|
||||
<reversibility rating="costly">D-14 and SC-3: the route behaviour and the command names are what Stripe dashboards, cron entries and seven apps' settings imports rely on.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/routes.go, ../sm-paymentgateway-plugin/plugin.go, ../sm-paymentgateway-plugin/schedule.go, ../sm-paymentgateway-plugin/boot.go, ../sm-paymentgateway-plugin/payments/notification.go, ../sm-paymentgateway-plugin/payments/service.go, ../sm-paymentgateway-plugin/support/redactor.go, ../sm-paymentgateway-plugin/support/urlizer.go, ../sm-paymentgateway-plugin/currency/converter.go, ../sm-paymentgateway-plugin/currency/iso.go, ../sm-paymentgateway-plugin/currency/nbp.go, ../sm-paymentgateway-plugin/currency/fixer.go, ../sm-paymentgateway-plugin/currency/providers_test.go, ../sm-paymentgateway-plugin/orders/service.go, ../sm-paymentgateway-plugin/console/helpers.go, ../sm-paymentgateway-plugin/console/pg_cancel_sent_payments.go, ../sm-paymentgateway-plugin/console/pg_cancel_created_payments.go, ../sm-paymentgateway-plugin/console/pg_cancel_placed_orders.go, ../sm-paymentgateway-plugin/console/pg_finish_orders.go, ../sm-paymentgateway-plugin/console/pg_update_currencies.go, ../sm-paymentgateway-plugin/console/import_winter.go, ../sm-paymentgateway-plugin/scripts/winter-export-settings.sql, ../sm-paymentgateway-plugin/scripts/winter-export-orders.sql, ../sm-paymentgateway-plugin/README.md, ../sm-paymentgateway-plugin/notification_route_test.go, ../sm-paymentgateway-plugin/commands_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/routes.go (whole), ../sm-journal-plugin/plugin.go (:98-126 Buckets), ../fonoteka.go/plugins/golem15/fonoteka/schedule.go, ../sm-sitemap-plugin/console/import_winter.go (:75-119), ../sm-sitemap-plugin/scripts/winter-export-settings.sql, ../fonoteka.go/plugins/golem15/golem/console/import_settings.go (:34-65), ../sm-journal-plugin/README.md (standard structure), summercms modules/surf/router.go (route registration, Routes() inspection for the test), modules/pact/capabilities.go (:66-76 Router, :124-175 Cadence and ScheduledCommand), modules/wire/response.go, the frozen tree: routes.php (:5-16), core/PaymentManager.php (:167-230 processNotification order and listEnabledProviders), core/Operator.php (:236-260 verifyNotification and processNotification), support/OperatorUrlizer.php, support/SensitiveLogRedactor.php, support/CurrencyConverter.php, support/NbpCurrencyExchange.php, support/NbpRateResult.php, support/FixerCurrencyExchange.php, console/CancelSentPayments.php, CancelCreatedPayments.php, CancelPlacedOrders.php, FinishOrders.php, UpdateCurrencyRatios.php, Plugin.php (:363-370 registerSchedule, :441-580 boot: log_requests and the settings-gated subscribers), models/Currency.php, models/Settings.php (:32 item code), cron-entries.txt, tests/security/StateTest.php and AccessControlTest.php (PG-003 forged notification cases), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 5, Pattern 8 PHP key list, Pattern 10, Anti-Patterns, Pitfalls 3 and 8, Runtime State Inventory)</read_first>
|
||||
<action>Per D-14, SC-2, SC-3 and D-05.
|
||||
|
||||
Route (routes.go, payments/notification.go, support/urlizer.go, support/redactor.go): in a loop over Get, Post, Put, Patch and Delete register `/_paymentgateway/{op}`, `/_paymentgateway/{op}/{mode}` and `/_paymentgateway/{op}/{mode}/{result}` under `surf.Use("throttle:paymentgateway-notify")` with no backend or CSRF middleware (plugin routes carry none by default); `Buckets()` defines `paymentgateway-notify` (per client IP, 120 per minute) with the surf 429 body. The handler: `DeUrlize` the operator segment with `base64.StdEncoding` accepting padded and unpadded input (`Urlize` is the inverse used by operators to build their notification URL); resolve the operator in the registry and require `Enabled` (404 `{"error":"Operator not found"}` otherwise); read the body once through `http.MaxBytesReader` (1 MiB) and keep the bytes; build `input` from the query and the body (form or JSON parsed from the kept bytes, Pitfall 3) and, when both `mode` and `result` are present, set `input[mode] = result` (PHP `$data[$mode] = $modeResult`); when `log_requests` is on, log method, path, headers and body through `support.Redact` (card numbers, `client_secret`, `Stripe-Signature`, tokens, e-mail addresses masked; PG-006); call `VerifyNotification` BEFORE any database access and answer 403 `{"error":"Notification refused"}` with a log line when it returns false; then `ExtractUUID`, `FindByUUID` (404 `{"error":"Payment not found"}`) and `HandleNotification` inside `lagoon.Transaction` with the payment row locked `FOR UPDATE` (Pitfall 9); the returned http.Handler writes the response (operators answer 200 for a replay that changes nothing).
|
||||
|
||||
Currencies (currency/): `ISO` table (code, name, minor digits) covering NBP Table A and the majors; `Provider` interface (`Rates(ctx, base string) ([]Rate, error)` with `Rate{ISO, Ratio string, TableNumber, EffectiveDate, Source}`); `NBP` fetching Table A from `https://api.nbp.pl/api/exchangerates/tables/A?format=json` through an injected `*http.Client` and base URL, decoding with `json.Decoder.UseNumber()` and parsing `mid` exactly, rounding half away from zero to four places only because the column is `numeric(8,4)` (A8), filling `nbp_table_number`, `nbp_effective_date` and `rate_source = nbp`; `Fixer` fetching `latest` with the access key config `golem15.paymentgateway.fixer_access_key` (named after the PHP config key read in FixerCurrencyExchange.php) and `rate_source = fixer`; `Converter` with `Ratio(from, to)` from the currencies table and `Convert(m money.Money, to string) (money.Money, error)` reproducing `CurrencyPair::convert` (multiply by the ratio as an exact rational, round to GAAP four places; the RESEARCH vector EUR 19.99 → PLN @4.2731 = 85.4193 and the inverse 1/4.2731 truncated at four places = 0.2340 then PLN 23.00 → EUR = 5.3820); `orders.Service.Make` and `payments.Service.Create` now use the converter for `native_amount_stored` (an unknown currency is `ErrUnsupportedCurrency` unless `currency_auto_create` creates the row). providers_test.go TestCurrencyProviders (httptest, no Docker): NBP with a six-decimal `mid` stores the four-place rounding, Fixer rates map, the two conversion vectors.
|
||||
|
||||
Commands (console/, schedule.go): bonfire commands `pg:cancel-sent-payments` (Sent older than `payment_sent_to_canceled_timeout` days by updated_at → Cancel), `pg:cancel-created-payments` (Created, `payment_created_to_canceled_timeout`), `pg:cancel-placed-orders` (Placed, `order_placed_to_canceled_timeout`), `pg:finish-orders` (Shipped → Finish when shippings are enabled, else Paid → FinishNoShipping, after `order_shipped_to_finished_timeout`), `pg:update-currencies` (`--force` bare flag; only rows with updated_at before today unless forced; provider per `exchange_rate_provider`; auto-create when `currency_auto_create`), each through the services so events fire, each printing a count with `out.Success`; `Schedule()` returns the four `pact.Daily()` entries and `pg:update-currencies` at `pact.Every(10 * time.Minute)` (PHP Plugin.php:363-370); `helpers.go` has the `lookupDB` and flag helpers from the sitemap precedent. commands_smoke_test.go TestPGCommandsSmoke (Postgres): seeded rows older and younger than each timeout; after each command only the old rows changed state and the events fired; TestImportWinterSmoke (Postgres): a JSON export of the PHP `system_settings` value for item `golem15::paymentgateway.settings` (the flat dotted keys from RESEARCH Pattern 8 with `"1"`/`"0"` switches, `isSetUp`, `defaultCurrency`, timeouts, `exchangeRateProvider`, the manual-transfer keys and the operator enable keys `golem15.paymentgateway::lang.channels.cash` and `...manualTransfer`) maps onto the settings row (`default_currency`, the booleans, the timeouts, `cash_enabled`, `manual_transfer_enabled`, the five manual-transfer columns; `isSetUp` and the Stripe keys are ignored with a notice; `--dry-run` writes nothing). `paymentgateway:import-winter` (console/import_winter.go) takes `--settings <file>`, `--dry-run`, `--force`; `scripts/winter-export-settings.sql` selects the value for that item; `scripts/winter-export-orders.sql` is the read-only `SELECT id, cart, details FROM golem15_paymentgateway_orders` the plan 06 checkpoint asks shop-type apps to run.
|
||||
|
||||
README (standard structure: H1, summary, import line, `**Name:**` is not required for a plugin but keep Overview, Features, Usage, API reference, Configuration, CLI commands, Dependencies, Testing): the services and their methods, the operator interface and how a plugin implements `operators.HasOperators`, the v2 row format and the legacy reader with the extension registry, config keys (`hashids_salt`, `fixer_access_key`), the route, the commands and the schedule, a Cutover section from RESEARCH § Runtime State Inventory (MySQL row copy, the import command, the legacy reader reads rows in place, no bulk rewrite, remove the PHP crontab, the salt and the app key), and the documented non-ports (`pg:update-metadata`, `pg:update-orders`, Yahoo, CreditCard). notification_route_test.go TestNotificationRoute (no Docker): the fifteen route registrations are present in the assembled route table; a padded and an unpadded base64 segment resolve the same code; `mode`/`result` land in the input; an unregistered and a disabled operator answer 404; a registered test operator whose VerifyNotification returns false answers 403 with no database call (the test wires a nil DB and would panic on access); TestNotificationRouteSmoke (Postgres): a test operator that verifies and accepts moves a Sent payment to Accepted and a second identical request answers 200 and changes nothing.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestNotificationRoute$' -v . 2>&1 | grep -- '^--- PASS: TestNotificationRoute' && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestCurrencyProviders$' -v ./currency/ 2>&1 | grep -- '^--- PASS: TestCurrencyProviders' && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -c "pg:" ../sm-paymentgateway-plugin/schedule.go | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no NotificationRoute pass (a method or pattern is missing, an unregistered operator resolved, or verification ran after a lookup); no CurrencyProviders pass (a rate went through a float or a conversion vector drifted); a Docker-free test fails; schedule.go does not list the five commands.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestNotificationRouteSmoke|TestPGCommandsSmoke|TestImportWinterSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: a verified notification did not accept the payment or a replay changed state, a command touched rows younger than its timeout, or an imported flat key did not land on its column.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "_paymentgateway/{op}" ../sm-paymentgateway-plugin/routes.go` is at least 3 and `grep -n "MaxBytesReader" ../sm-paymentgateway-plugin/payments/notification.go` prints one line located before the first FindByUUID call in that file.
|
||||
- `grep -n "UseNumber" ../sm-paymentgateway-plugin/currency/nbp.go ../sm-paymentgateway-plugin/currency/fixer.go` prints one line per file.
|
||||
- `for c in pg:cancel-sent-payments pg:cancel-created-payments pg:cancel-placed-orders pg:finish-orders pg:update-currencies paymentgateway:import-winter; do grep -rq "\"$c\"" ../sm-paymentgateway-plugin/console/ || exit 1; done` exits 0.
|
||||
- `test -f ../sm-paymentgateway-plugin/scripts/winter-export-settings.sql && test -f ../sm-paymentgateway-plugin/scripts/winter-export-orders.sql` succeeds and `grep -n "## Cutover" ../sm-paymentgateway-plugin/README.md` prints one line.
|
||||
- `! grep -rniE "horoskopia|fonoteka|grzyby" ../sm-paymentgateway-plugin --include='*.go' --include='*.md' --include='*.yaml' --include='*.json' --include='*.php' --include='*.sql'` exits 0 (the committed fixtures and the generator script are included; no consuming application is named anywhere in the plugin).
|
||||
</acceptance_criteria>
|
||||
<done>An operator's signed notification reaches its payment only after verification, rates and conversions are exact, the daily commands and the ten-minute currency update run through conga, and a PHP settings export imports in one command.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| internet → notification route | unauthenticated, any method, attacker-controlled body and path |
|
||||
| consumer plugin → services | trusted compiled code; input validated at the value-object constructors |
|
||||
| stored legacy rows → reader | data written by PHP, possibly by an app type the Go build does not know |
|
||||
| NBP / Fixer → converter | third-party JSON over HTTPS |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-07 | Spoofing / Tampering | notification route: forged "paid" | critical | mitigate | VerifyNotification before any lookup; cash, manual and disabled return false (PG-003); only registered and enabled operators resolve; TestNotificationRoute asserts 403 with a nil DB |
|
||||
| T-18-08 | Tampering / Repudiation | replay or race on Accept | high | mitigate | `FOR UPDATE` on the payment and order rows inside the transaction before `Can`; a replay is a 200 no-op; TestNotificationRouteSmoke and the tracer replay case |
|
||||
| T-18-09 | Tampering | mass assignment of state, amounts, owner, hash | high | mitigate | Fillable excludes state, `*_amount_stored`, currency, native_*, user_id, hash_id, guest_code, successful_payment_id, was_shipped, operator, uuid; transitions only through the machines (PG-001); tests: TestPaymentGatewayTracer (Task 2: a replayed Accept is refused and changes nothing) and TestFillable (plan 07 Task 3, PG-001) |
|
||||
| T-18-10 | Tampering | money precision | high | mitigate | scale-4 int64, exact parse, no float; Amount4 refuses a fifth decimal; `numeric(10,4)` ceiling checked before insert (Pitfall 7); tests: TestMoneyVectorsSmoke and TestAmount4ScanValue (Task 2), TestAmount4 (plan 07 Task 3, both ceilings) |
|
||||
| T-18-11 | Elevation of Privilege | class/type injection through cart JSON | high | mitigate | allowlisted registry names, no class names in rows, unknown legacy type fails loudly (PG-007, A12); tests: TestLegacyCartGoldens (Task 3: the unknown type fails until registered) and TestCartRegistry (plan 07 Task 3, PG-007) |
|
||||
| T-18-12 | Information Disclosure | request logs on the notification route | medium | mitigate | `support.Redact` on every logged body and header (PG-006); `Stripe-Signature`, secrets, cards and e-mails masked |
|
||||
| T-18-13 | Denial of Service | large notification bodies | medium | mitigate | `http.MaxBytesReader` 1 MiB; throttle bucket `paymentgateway-notify` |
|
||||
| T-18-14 | Spoofing | guest token forgery or reuse on another order | high | mitigate | HMAC-SHA256 with the application key, `timestamp|orderId`, 24h TTL, constant-time compare, bound to one order id (PG-002); tests: TestCustomerAPITracer (plan 06 Task 2: a token for another order is 404) and TestGuestToken (plan 07 Task 3: TTL expiry, another order id, tampered byte) |
|
||||
| T-18-15 | Tampering | exchange rates parsed from third-party JSON | medium | mitigate | UseNumber plus exact parse; only the configured provider; rounding to four places only at the column boundary; a parse failure leaves the row unchanged |
|
||||
| T-18-16 | Information Disclosure | committed golden fixtures | medium | mitigate | the dump row is redacted (strings replaced) before commit; an acceptance grep refuses real e-mail domains |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no npm package; Go dependencies go-hashids v2.0.1 (decision note) and the pinned framework stack with go.sum committed |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: decision recorded; the metadata files exist.
|
||||
- Task 2: money vectors and Scan/Value, FSM smoke, whole-repo -short, go.mod pin; the tracer on a Docker host.
|
||||
- Task 3: legacy goldens (Docker-free), whole-repo -short, five fixture files; the refund bridge and tracer on a Docker host.
|
||||
- Task 4: notification route (Docker-free), currency providers (httptest), whole-repo -short, schedule entries; route smoke, commands and import on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1: both state machines with exact money, proven by the tracer, the refund smoke and the vectors.
|
||||
- SC-2: the operator interface, registry and the three built-in operators; the notification route with PHP behaviour and verify-first ordering.
|
||||
- SC-3 (domain half): tables, events, bridge, commands and schedule ported with the PHP names.
|
||||
- D-11: v2 rows written, legacy rows decoded, registry in place, no bulk rewrite.
|
||||
- `go vet ./...` and `go test -short ./...` in the plugin stay green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-02-SUMMARY.md` when done, with the Task 1 decision, the commits per repository, the exported identifiers per package, the Docker suites run or NOT RUN, the fixture generator status, and any deviation.
|
||||
</output>
|
||||
331
.planning/phases/18-payment-gateway/18-03-PLAN.md
Normal file
331
.planning/phases/18-payment-gateway/18-03-PLAN.md
Normal file
@@ -0,0 +1,331 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["18-01", "18-02"]
|
||||
files_modified:
|
||||
- ../sm-paymentgateway-plugin/admin.go
|
||||
- ../sm-paymentgateway-plugin/admin_permissions.go
|
||||
- ../sm-paymentgateway-plugin/admin_navigation.go
|
||||
- ../sm-paymentgateway-plugin/field_types.go
|
||||
- ../sm-paymentgateway-plugin/boot.go
|
||||
- ../sm-paymentgateway-plugin/models/order.go
|
||||
- ../sm-paymentgateway-plugin/models/hashids.go
|
||||
- ../sm-paymentgateway-plugin/orders/service.go
|
||||
- ../sm-paymentgateway-plugin/support/format.go
|
||||
- ../sm-paymentgateway-plugin/controllers/admin_registry.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders_partials.go
|
||||
- ../sm-paymentgateway-plugin/controllers/payments.go
|
||||
- ../sm-paymentgateway-plugin/controllers/shippings.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quoteables.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes.go
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes_cart.go
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/config_list.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/config_form.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/config_filter.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/_orders_stats.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/_order_hint.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/_order_cart.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/_order_payments.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/orders/_order_details.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/config_list.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/config_form.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/config_filter.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/_payments_stats.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/_payment_hint.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/payments/_payment_data.htm
|
||||
- ../sm-paymentgateway-plugin/controllers/shippings/config_list.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/shippings/config_form.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quoteables/config_list.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quoteables/config_form.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes/config_list.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes/config_form.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes/config_filter.yaml
|
||||
- ../sm-paymentgateway-plugin/controllers/quotes/_quotes_notice.htm
|
||||
- ../sm-paymentgateway-plugin/models/order/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/order/columns.yaml
|
||||
- ../sm-paymentgateway-plugin/models/order/columns_quote.yaml
|
||||
- ../sm-paymentgateway-plugin/models/order/quote_fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/payment/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/payment/columns.yaml
|
||||
- ../sm-paymentgateway-plugin/models/shipping/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/shipping/columns.yaml
|
||||
- ../sm-paymentgateway-plugin/models/shipping.go
|
||||
- ../sm-paymentgateway-plugin/models/quoteable/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/quoteable/columns.yaml
|
||||
- ../sm-paymentgateway-plugin/assets/js/quote-cart.js
|
||||
- ../sm-paymentgateway-plugin/assets/css/quote-cart.css
|
||||
- ../sm-paymentgateway-plugin/lang/en/lang.yaml
|
||||
- ../sm-paymentgateway-plugin/lang/pl/lang.yaml
|
||||
- ../sm-paymentgateway-plugin/admin_harness_test.go
|
||||
- ../sm-paymentgateway-plugin/admin_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
autonomous: true
|
||||
requirements: [SC-3, D-02, D-05, D-07, D-15]
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 260000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-3 and D-02: the seven PHP permissions `golem15.paymentgateway.{access_payments,access_settings,access_orders,access_shippings,manage_orders,manage_shippings,manage_payments}` are registered; the top-level navigation item (label `nav.label`, icon `credit-card`, order 500) opens Orders when orders are enabled and Payments otherwise, with the five side items Orders (`shopping-cart`, access_orders), Payments (`credit-card`, access_payments), Quotes (`wand-sparkles`, access_orders), Quote items (`list`, access_orders) and Shipping methods (`box`, access_shippings) always listed (UI-SPEC P11); a screen whose feature is off shows the feature-off callout."
|
||||
- "P1: the Orders list has no create button, the columns hash_id (searchable), user (relation select email, em dash for guests), state (the localized `orderstates.*` label, never the stored constant), amount and native_amount (`type: money`, `currencyFrom: currency` / `native_currency`, right-aligned), payments and successful_payment (relation select id), created_at (default sort descending) and updated_at, 20 rows per page, a `state` dropdown filter, and the `_orders_stats` header partial with four stats (All orders, Open orders, Orders this month, Sales this month with `scoreboard.previous` notes) and the setup-needed or feature-off warning callout above them."
|
||||
- "P2: the order preview declares `preview: { headerPartial: _order_hint }`, has no editable field (so no Edit button, F6), shows the hint stats (Status, Created, Customer linking to the user record or the details e-mail plus `text.guest`, Total with the native note when the native currency differs, Shipping when enabled) and the refunded-payment warning, and the card fields `_order_cart` (the `.summer-table` cart with item sub-lines, the shipping row, totals and the item count in tfoot), `_order_payments` (newest first, the successful one marked), `_order_details` (Details entries, delivery method with the logo, address lines; internal keys never shown) and description."
|
||||
- "P2 (backstop below) and UI-SPEC destructive actions: the order record actions are exactly reject (RejectEarly else Reject, danger), execute, ship, direct_refund (danger, state Paid), grant_refund (danger, not was_shipped), wait_for_return (was_shipped), refund_after_return (danger), guarantee_refund, create_cash_payment and create_bank_payment (no successful payment and the operator registered), every one behind `manage_orders`, with the UI-SPEC confirm copy; after an action the preview reloads and toasts the result message."
|
||||
- "P3 and P4: the Payments list (id searchable, amount and native_amount money columns, operator and state localized, description, created_at, updated_at; filters state and operator; `_payments_stats` with All payments, Payments this month and Accepted this month plus previous notes) and the payment preview (`_payment_hint` with Status, Created plus `labels.updateDate`, Customer or `labels.anonymous`, Total, Order link; the danger callout when the operator is not registered or disabled; fields description, uuid in a `<code>`, operator name, `_payment_data` with only public keys, never `client_secret`) with the actions accept (manual or cash operators), accept_override (api operators, danger) and refund (danger) behind `manage_payments`."
|
||||
- "P5 and P6: Shippings has list columns id, name (searchable), cost (money, currencyFrom currency), tax as `23%`, gross_cost (computed money column) and is_payable_with_cash (switch) and a form with name, logotype (fileupload image 260×260, required), cost (money), tax (number 0..100 with `vat_comment`) and is_payable_with_cash; an empty currency is set to the default currency on save; Quote items has list id, name, default_cost (money), default_tax and a form with name, default_cost, default_tax and the custom_fields repeater (key and value, prompt `add_custom_field`); both carry the UI-SPEC create-button and empty-state copy and the `grossCost@update` form field is a documented non-port."
|
||||
- "P7: Quotes lists orders in state Quote with `columns_quote.yaml`, the create form (user relation nameFrom email, email, description as form-only fields) creates an order through `Orders.Make` with `WithQuote()` in the default currency, the update form (state Quote only; otherwise the row opens the order preview) adds the `_quote_cart` widget field mounting `<golem15-paymentgateway-quote-cart>` whose add row, editable cells (description text, qty number min 1, unit net under the money parsing rules at scale 4, VAT 0..100) and remove button post `summer-action` payloads to the admin actions add_quoteable, change_item and remove_item and re-render from `result.data`, plus the record action process_quote (Place) with its confirm copy; a refused change restores the last saved row and the host shows the server message as a danger toast; a placed quote renders read-only."
|
||||
- "UI-SPEC copy: `lang/{en,pl}/lang.yaml` carry every PHP `golem15.paymentgateway::lang.*` key with the UI-SPEC corrections (Quote items, key, no `<br/>`, the payment state agreement in Polish, the placeholders in `text.single` and `scoreboard.previous`) and every key the UI-SPEC marks new (nav.*, settings.* labels and descriptions, text.no_*, text.setup_needed*, text.feature_off_title, text.orders_off/quotes_off/shippings_off, text.guest, text.successful_payment_mark, scoreboard.allpayments/paymentsthismonth/accepted, shipping.create, quoteable.create, quote.create, quote.cart_empty, quote.remove_item, quote.item_removed, actions.process_quote and every `actions.*_confirm`, settings.default_currency_required, the mailer and Stripe-free settings labels of P8 and P9, `shipping.fields.vat_comment`, `quoteable.fields.add_custom_field`), so plan 04 adds no admin copy."
|
||||
- "UI considerations (orders and payments lists, previews, shippings, quote items, quote cart, navigation; covered): plugin `noRecordsMessage` copy and the stats strip at zeros on empty lists; the existing ListView and PreviewView skeletons and errors with the PartialHost skeleton and failure box for partials; zero, one and many payments in the order payments partial; `text.no_details`; descriptions and detail values wrap with `overflow-wrap: anywhere`; the refunded-payment and disabled-operator callouts; empty native_amount or description shows the em dash; a legacy shipping without a logo shows the fileupload empty state and an empty repeater its prompt; the quote cart empty state, the busy row while a change posts, the restored row on refusal, horizontal scrolling in `.summer-table-wrap`, read-only after placement; navigation labels are one to three words and the feature-off items explain themselves."
|
||||
- statement: "Order record actions: a Go test asserts for each order state that the record response offers exactly the UI-SPEC P2 side-panel set, that danger is true for reject and the three refunds and false otherwise, and that no action is offered without manage_orders (admin_smoke_test.go TestOrdersAdminSmoke)."
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "../sm-paymentgateway-plugin/admin.go"
|
||||
provides: "AdminFS embed, AdminControllers, the admin boot step"
|
||||
contains: "go:embed controllers models assets"
|
||||
- path: "../sm-paymentgateway-plugin/controllers/orders.go"
|
||||
provides: "orders admin controller with the ten record actions and partial data"
|
||||
contains: "AdminRecordActions"
|
||||
- path: "../sm-paymentgateway-plugin/controllers/quotes_cart.go"
|
||||
provides: "add_quoteable, change_item, remove_item admin actions and the cart view model"
|
||||
contains: "change_item"
|
||||
- path: "../sm-paymentgateway-plugin/assets/js/quote-cart.js"
|
||||
provides: "the golem15-paymentgateway-quote-cart custom element"
|
||||
contains: "customElements.define"
|
||||
- path: "../sm-paymentgateway-plugin/lang/en/lang.yaml"
|
||||
provides: "every PHP lang key plus the UI-SPEC additions, English"
|
||||
contains: "orderstates"
|
||||
- path: "../sm-paymentgateway-plugin/admin_permissions.go"
|
||||
provides: "the seven PHP permission codes"
|
||||
contains: "golem15.paymentgateway.manage_orders"
|
||||
key_links:
|
||||
- from: "../sm-paymentgateway-plugin/controllers/orders.go"
|
||||
to: "../sm-paymentgateway-plugin/orders/service.go"
|
||||
via: "record actions call Can and PerformTransition inside cabana.TxFromContext"
|
||||
pattern: "PerformTransition"
|
||||
- from: "../sm-paymentgateway-plugin/models/order/columns.yaml"
|
||||
to: "summercms modules/cabana/field_money.go"
|
||||
via: "type: money with currencyFrom"
|
||||
pattern: "currencyFrom"
|
||||
- from: "../sm-paymentgateway-plugin/controllers/quotes_cart.go"
|
||||
to: "../sm-paymentgateway-plugin/cart/cart.go"
|
||||
via: "the actions mutate the decoded cart and re-encode it as v2"
|
||||
pattern: "EncodeCart"
|
||||
- from: "../sm-paymentgateway-plugin/admin_navigation.go"
|
||||
to: "../sm-paymentgateway-plugin/models/settings.go"
|
||||
via: "top-level Controller chosen from orders_enabled at boot"
|
||||
pattern: "OrdersEnabled"
|
||||
prohibitions:
|
||||
- statement: "Admin partial view models are curated structs, never GORM models; a partial never renders a secret, a client_secret, a return URL or secure parameters."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Order and payment state changes in the admin go only through the services' transitions inside the action transaction with the row locked; no admin path writes state, amounts or owner columns directly."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Plugin JS and CSS read only the public --c-* variables, ship no icons and never call fetch; every change goes through summer-action payloads the server validates."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No consuming application is named in the plugin's code, YAML, copy or README; admin copy keeps the PHP key names."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 3 of 7, wave 3, `../sm-paymentgateway-plugin`): the admin half of SC-3, eight PHP controllers minus Settings, MailerSettings and SetUp (plan 04 ports the two settings pages; SetUp is replaced by the required default currency, D-08): Orders, Payments, Quotes, Quote items and Shipping methods as cabana YAML, server partials, record actions, admin actions and one custom element, with permissions, navigation and the whole phrasebook. It runs after plan 01 (money field, F5 to F7) and plan 02 (models and services), and before plan 04, which embeds its settings YAML through the `adminFS` this plan declares and reads the copy this plan writes.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator. `must_haves` derive from SC-3, D-02, D-05, D-07, D-15, RESEARCH Pattern 12 and the UI-SPEC Component Contracts P1 to P7 and P11 plus the `## UI Considerations` rows for the orders list with stats, order preview, payments list, payment preview, shippings and quote items, the quote cart element and navigation (the dismissed row "navigation loading, error" keeps the UI-SPEC's reason and is not lifted). The one backstop row is the structured marker above. Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- Localized state and operator names in lists come from plugin field types (`pact.HasFormFieldTypes`, Phase 16.2) whose `ListValue` returns the phrase for the stored constant; the stored value never reaches the list.
|
||||
- Server-side amount formatting (`support.FormatAmount`) reproduces `Intl.NumberFormat` with `currencyDisplay: code` for the two admin locales: `PLN 39.84` in English, `39,84 PLN` in Polish, two to four fraction digits, no grouping, never a float.
|
||||
- `hash_id` and `guest_code` are set by the Order model's GORM `AfterCreate` hook through an encoder the plugin Boot installs (`models.SetHashids`), so `Orders.Make` and the cabana create path of Quotes share one implementation.
|
||||
- The Quotes create form uses form-only fields (`email`, `description`) and `FormBeforeCreate`; the quote cart is a `type: widget` field plus admin actions (RESEARCH Open Question 2, first option), not a plugin field type.
|
||||
- Navigation visibility is boot-time (UI-SPEC P11): all five side items are listed; the top-level link is chosen from `orders_enabled` when the database is published at boot, else Orders.
|
||||
- The PHP `grossCost@update` form field, the scoreboard pie charts and the `_order_actions_field` partial are documented non-ports (the record actions and the stats replace them).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Root: `admin.go` (`adminFS`, `AdminFS`, `AdminControllers`, `AdminAssets`), `admin_permissions.go` (`PermissionAccessPayments`, `PermissionAccessSettings`, `PermissionAccessOrders`, `PermissionAccessShippings`, `PermissionManageOrders`, `PermissionManageShippings`, `PermissionManagePayments`, `Permissions()`), `admin_navigation.go` (`Navigation()`), `field_types.go` (`FormFieldTypes()`: `orderstate`, `paymentstate`, `operatorname`), `models/hashids.go` (`SetHashids`, the `AfterCreate` hook), `support/format.go` (`FormatAmount`, `FormatPercent`).
|
||||
- `controllers`: `AdminControllers(app func() *backpack.App)`, `ordersAdminController` (ID `golem15.paymentgateway.orders`), `paymentsAdminController` (`golem15.paymentgateway.payments`), `shippingsAdminController` (`golem15.paymentgateway.shippings`), `quoteablesAdminController` (`golem15.paymentgateway.quoteables`), `quotesAdminController` (`golem15.paymentgateway.quotes`); record actions `reject`, `execute`, `ship`, `direct_refund`, `grant_refund`, `wait_for_return`, `refund_after_return`, `guarantee_refund`, `create_cash_payment`, `create_bank_payment`, `accept`, `accept_override`, `refund`, `process_quote`; admin actions `add_quoteable`, `change_item`, `remove_item`; partials `_orders_stats`, `_order_hint`, `_order_cart`, `_order_payments`, `_order_details`, `_payments_stats`, `_payment_hint`, `_payment_data`, `_quotes_notice`.
|
||||
- YAML under `controllers/<name>/config_*.yaml` and `models/<model>/{fields,columns}.yaml` (+ `columns_quote.yaml`, `quote_fields.yaml`); assets `assets/js/quote-cart.js` (`golem15-paymentgateway-quote-cart`), `assets/css/quote-cart.css`.
|
||||
- `lang/en/lang.yaml`, `lang/pl/lang.yaml` (the complete phrasebook).
|
||||
- Smoke tests: `TestOrdersAdminSmoke`, `TestPaymentsAdminSmoke`, `TestShippingsAdminSmoke`, `TestQuoteablesAdminSmoke`, `TestQuotesAdminSmoke`.
|
||||
|
||||
<objective>
|
||||
Port the payment gateway's admin screens to cabana YAML, partials and actions (SC-3, D-02, D-05): orders and payments as previews with state-machine buttons, shippings and quote items as CRUD forms, quotes with an inline cart editor, with the PHP permissions, a navigation entry and the complete phrasebook, on the framework pieces plan 01 added (D-07 money columns, F5 danger, F6 Edit rule, F7 kit).
|
||||
|
||||
Purpose: SC-3 names the admin screens; an operator must be able to run a shop's orders and payments from the SummerCMS admin after cutover without SQL.
|
||||
Output: five controllers with YAML, partials, actions, one custom element, permissions, navigation, lang files, an admin harness and smoke tests.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-UI-SPEC.md
|
||||
@.planning/phases/18-payment-gateway/18-02-SUMMARY.md
|
||||
|
||||
The frozen PHP sources are `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway` at `4b9ecf9` (D-02). Stage files by explicit path; no co-author tags; no push. Docker-backed admin smoke tests skip under `-short`; record NOT RUN when no daemon answers. The custom element follows the Phase 10.1 widget contract (light DOM, plugin CSS under the tag selector, `--c-*` only, no icons, no fetch, `summer-action` with a `payload`, re-render from `result.data`).
|
||||
|
||||
<interfaces>
|
||||
Framework contracts consumed (read, do not change): `pact.AdminController` (ID, ModelName, ConfigDir, RequiredPermissions, NewRecord), `pact.HasAdminControllers`, `pact.AdminAssets`, `pact.AdminRecordAction` (Name, Label, Confirm, Permissions, Danger, Applies, Run; the framework row-locks the record inside the action transaction, capabilities.go:340-347), `pact.AdminRecordActionInput`/`Result`, `pact.AdminAction` with Payload and `pact.HasAdminActions` (capabilities.go:225-270), `pact.AdminPartialData` (:369), `pact.FormFieldType` with `ListValue` and `pact.HasFormFieldTypes` (:398, :491), `pact.Permission`/`HasPermissions` (:496-510), `pact.NavigationItem`/`HasNavigation` (:525-540), `cabana.TxFromContext`, `cabana.ForbiddenError`, form hooks `FormBeforeCreate`/`FormBeforeUpdate`/`ListExtendQuery`/`FormExtendQuery` (ADMIN-04), form-only fields (docs/backend/forms.md "Form-only fields"), `headerPartial` and `type: partial` (ADMIN-07), `preview: { headerPartial }`, `type: widget` with `element` and the `summer-action` bridge, `type: money` and `currencyFrom` (plan 01), `type: fileupload`, `type: repeater`, `type: relation` with `select`/`nameFrom`.
|
||||
|
||||
Plugin contracts consumed from plan 02: `orders.Service` (`Make`, `WithQuote`, `Find`, `Can`, `PerformTransition`, `IsFinal`), `payments.Service` (`Create`, `Send`, `Accept`, `Refund`, `Wrap`), `operators.Registry` (`Get`, `All`, `Enabled`), `cart.Decode`/`EncodeCart`, `money.Money`, `models.Settings`, the state constants.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the Orders screen end to end: permissions, navigation, the phrasebook, the list with stats and money columns, the preview with hint, cart, payments and details partials, and the ten record actions, proven on a cash order</name>
|
||||
<reversibility rating="costly">D-02 and D-05: permission codes and the lang keys are copied from the frozen tree and shared by seven applications; the admin YAML and partial names become the extension points app plugins hook.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/admin.go, ../sm-paymentgateway-plugin/admin_permissions.go, ../sm-paymentgateway-plugin/admin_navigation.go, ../sm-paymentgateway-plugin/field_types.go, ../sm-paymentgateway-plugin/boot.go, ../sm-paymentgateway-plugin/models/order.go, ../sm-paymentgateway-plugin/models/hashids.go, ../sm-paymentgateway-plugin/orders/service.go, ../sm-paymentgateway-plugin/support/format.go, ../sm-paymentgateway-plugin/controllers/admin_registry.go, ../sm-paymentgateway-plugin/controllers/orders.go, ../sm-paymentgateway-plugin/controllers/orders_partials.go, ../sm-paymentgateway-plugin/controllers/orders/config_list.yaml, ../sm-paymentgateway-plugin/controllers/orders/config_form.yaml, ../sm-paymentgateway-plugin/controllers/orders/config_filter.yaml, ../sm-paymentgateway-plugin/controllers/orders/_orders_stats.htm, ../sm-paymentgateway-plugin/controllers/orders/_order_hint.htm, ../sm-paymentgateway-plugin/controllers/orders/_order_cart.htm, ../sm-paymentgateway-plugin/controllers/orders/_order_payments.htm, ../sm-paymentgateway-plugin/controllers/orders/_order_details.htm, ../sm-paymentgateway-plugin/models/order/fields.yaml, ../sm-paymentgateway-plugin/models/order/columns.yaml, ../sm-paymentgateway-plugin/lang/en/lang.yaml, ../sm-paymentgateway-plugin/lang/pl/lang.yaml, ../sm-paymentgateway-plugin/admin_harness_test.go, ../sm-paymentgateway-plugin/admin_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/admin.go (embed, AdminFS, AdminControllers, Settings), ../sm-journal-plugin/controllers/admin_registry.go, ../sm-journal-plugin/controllers/posts.go (:1-110 controller shape, db via TxFromContext), ../sm-summercmsio-app/plugins/golem15/user/controllers/users_admin_controller.go (:605-673 record actions with Applies and Run), ../sm-journal-plugin/admin_harness_test.go (:173-238 bootJournal, env.admin, env.expect), ../sm-journal-plugin/field_types.go and classes/blog_editor.go (the lazy app accessor and a plugin field type with ListValue), summercms modules/pact/capabilities.go (:179-200, :225-270, :331-372, :398-420, :491-545), modules/cabana/README.md (Server-rendered partials, Backend navigation, Admin API routes), modules/cabana/testdata (a headerPartial and a type partial fixture from Phase 10.1), docs/backend/forms.md (Form-only fields, Preview screen), docs/backend/admin-controllers.md, admin/src/app/icons.ts (registered icon names), summercms modules/phrasebook/README.md (plugin lang YAML layout, plural maps), ../sm-journal-plugin/lang/en/lang.yaml (shape), the frozen tree: Plugin.php (:90-170 registerNavigation, :171-206 registerPermissions), controllers/Orders.php (onAction, onCreateCashPayment, onCreateBankPayment, the scoreboard queries), controllers/orders/config_list.yaml, config_form.yaml, config_filter.yaml, _side_panel.htm (the exact Applies conditions), _current_state.htm, _cart.htm, _successful_payment.htm, manage.htm, models/order/columns.yaml, fields.yaml, _state_list_field.htm, _order_data_field.htm, support/StateFormatter.php, support/MoneyFormatter.php, lang/en/lang.php and lang/pl/lang.php (every key), .planning/phases/18-payment-gateway/18-UI-SPEC.md (Copywriting Contract: all tables; P1, P2, P11; the UI Considerations rows for orders and navigation), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 12)</read_first>
|
||||
<action>Per SC-3, D-02, D-05, D-07 and UI-SPEC P1, P2, P11, one admin screen through every layer.
|
||||
|
||||
Permissions and navigation: admin_permissions.go exports the seven codes as constants and `Permissions()` returns them with the PHP labels (`golem15.paymentgateway::lang.permissions.*`) and tab; admin_navigation.go returns one `pact.NavigationItem` (Code paymentgateway, Label `golem15.paymentgateway::lang.nav.label`, Icon credit-card, Order 500, Permissions `golem15.paymentgateway.*`, Controller orders when `orders_enabled` is true at boot (settings read through the published gorm.DB when present) else payments) with the five SideMenu items of UI-SPEC P11 in that order with their icons and permissions; plugin.go already asserts the capabilities through boot.go's step list, so add the `HasPermissions`, `HasNavigation`, `HasAdminControllers`, `AdminAssets` and `HasFormFieldTypes` assertions in admin.go and field_types.go (never edit plugin.go).
|
||||
|
||||
admin.go: `//go:embed controllers models assets` into `adminFS` (one FS that plan 04's settings YAML joins without a code change), `AdminFS()`, `AdminControllers()` delegating to `controllers.AdminControllers(func() *backpack.App)`, `AdminAssets()` declaring `assets/js/quote-cart.js` and `assets/css/quote-cart.css` for the quotes controller (Task 3 fills them; declare them now with placeholder files so the allowlist exists).
|
||||
|
||||
Field types (field_types.go): `FormFieldTypes()` registers `orderstate`, `paymentstate` and `operatorname` whose `ListValue(ctx, value)` returns the request-locale phrase for `orderstates.<State>`, `paymentstates.<State>` and the registered operator's name (`support` of the registry: the operator's own lang key, or `text.disabled_header`'s operator name when unknown); check `modules/cabana/field_type.go` for whether a list-only type may leave `Element` empty and, if not, give it a minimal read-only element in quote-cart.js's file.
|
||||
|
||||
Hashids hook (models/hashids.go, models/order.go, orders/service.go, boot.go): `models.SetHashids(enc interface{ Encode(id int64, length int) string })` installed by a boot step; the Order model's `AfterCreate(tx *gorm.DB)` writes `hash_id` (length 8) and, when `user_id` is nil, `guest_code` (id × 118, length 32) in the same transaction; `Orders.Make` drops its own post-insert update if plan 02 wrote one.
|
||||
|
||||
Formatting (support/format.go): `FormatAmount(m money.Money, locale string) string` → `PLN 39.84` (en) / `39,84 PLN` (pl), two to four fraction digits with trailing zeros trimmed to two, no grouping, from `AmountString()` by string manipulation; `FormatPercent(tax int)` → `23%`.
|
||||
|
||||
Orders controller (controllers/orders.go, orders_partials.go, YAML): ID `golem15.paymentgateway.orders`, ModelName `Golem15\PaymentGateway\Models\Order`, ConfigDir `controllers/orders`, RequiredPermissions access_orders, NewRecord `&models.Order{}`; config_list.yaml (title `labels.manageorders`, `list: models/order/columns.yaml`, no create button, recordsPerPage 20, defaultSort created_at desc, `noRecordsMessage: golem15.paymentgateway::lang.text.no_orders`, `headerPartial: _orders_stats`, `filter: config_filter.yaml` with a `state` dropdown of the twelve localized states); `ListExtendQuery` excludes state Quote (Quotes has its own screen); columns.yaml per UI-SPEC P1 (id invisible, hash_id searchable, user relation select email, state as the `orderstate` plugin type, amount money currencyFrom currency, native_amount money currencyFrom native_currency, payments and successful_payment relation select id, created_at and updated_at datetime; money columns not sortable); config_form.yaml with `preview: { headerPartial: _order_hint }` and no create or update context; fields.yaml with hash_id (text, the h1), the partials `_order_cart` (full), `_order_payments` (left), `_order_details` (right) and description (full, text); `PartialData(ctx, name, record)` builds curated view models (never GORM models): `orders_stats` (counts, the month sums in the default currency formatted with FormatAmount, previous-month notes, and the notice kind setup-needed, orders-off or none), `order_hint` (status label, created `YYYY-MM-DD HH:mm`, customer link to the user admin record (`golem15.user.users`, read the user plugin's controller ID) or the details e-mail plus `text.guest`, total and the native note when currencies differ, shipping name and link when `shippings_enabled`, the `alreadyRefundedByOperator` flag), `order_cart` (rows in cart order with description, data sub-line, count, net with unit sub-line, `23%`, gross with unit sub-line; the shipping row; totals and `labels.total` count), `order_payments` (newest first: id, state label, amount, successful flag), `order_details` (Details entries with booleans as `words.yes`/`words.no`, delivery method with logo URL and address lines; `return_url` and secure parameters excluded). The templates are `html/template` files using only the `.summer-stats`, `.summer-stat*`, `.summer-callout*`, `.summer-table*` and `.summer-partial` classes with the UI-SPEC P1 and P2 markup. `AdminRecordActions()` returns the ten actions of UI-SPEC P2 in that order with Name, Label (`actions.*`), Confirm (`actions.*_confirm`), Permissions manage_orders, Danger for reject, direct_refund, grant_refund and refund_after_return, `Applies` computed with `orders.Can` and the P2 conditions (reject when RejectEarly or Reject applies; direct_refund when DirectRefund applies and state is Paid; grant_refund when GrantRefund applies and not was_shipped; wait_for_return when WaitForReturn applies and was_shipped; create_cash_payment and create_bank_payment when there is no successful payment and the cash or manual transfer operator is registered), and `Run` performing the transition through `orders.PerformTransition` inside `cabana.TxFromContext` (reject tries RejectEarly then Reject) or, for the two create actions, `payments.Create` from the stored cart and details with the operator code switched to cash or manual transfer followed by `payments.Send`, returning the success message.
|
||||
|
||||
Phrasebook (lang/en/lang.yaml, lang/pl/lang.yaml): port every key of the frozen `lang/en/lang.php` and `lang/pl/lang.php` (532 and 545 lines) into the plugin YAML with the UI-SPEC corrections and every UI-SPEC new key (the Copywriting Contract tables for navigation, settings entries, order and payment states, order columns, order preview stats and fields, cart table, stats headers, payment preview, shipping fields, quote item fields, the P8 and P9 settings labels and comments, the actions and confirms, empty states, error states, `text.guest`, `text.successful_payment_mark`), CLDR plural maps where PHP used `|` choice strings, `:name` placeholders kept literal. Plan 04 adds no key.
|
||||
|
||||
Harness and smoke: admin_harness_test.go wraps plan 02's `bootPaymentGateway` with cabana (copy Journal's `env.admin(t, perms...)`, `env.expect`, `adminAPI`), activating `golem15.user` and `golem15.paymentgateway`. admin_smoke_test.go TestOrdersAdminSmoke (Postgres): with default_currency PLN, orders_enabled and cash_enabled on, a cash order created through `Orders.Make` (user owner) and one placed through the services to AwaitingPayment and Paid; the list for an admin with access_orders answers both rows with `state` as the English label `Placed`/`Paid`, `amount` as `39.8400` beside `currency` `PLN`, and the header partial node tree containing the four stat labels; the same list in Polish (Accept-Language pl) answers `Złożone`; the record response for the Placed order offers exactly reject, create_cash_payment and create_bank_payment, for the Paid order exactly execute, ship, direct_refund with `danger` true, and for every order state the set of the P2 table (iterate the twelve states by setting the column directly in the test); an admin without manage_orders sees no actions; running reject on the Placed order answers the success message and the row is Rejected with its cash payment Canceled; running create_cash_payment on a fresh Placed order creates a Sent cash payment and the order is AwaitingPayment; the preview answers the hint and the three partial node trees and no `return_url` text; the navigation metadata for that admin lists the five side items and the top-level item points at orders.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && for k in nav.label orderstates paymentstates actions.reject_confirm text.no_orders scoreboard.previous settings.default_currency_required quote.cart_empty; do grep -q "$k\|^ ${k#*.}:" ../sm-paymentgateway-plugin/lang/en/lang.yaml || exit 1; done && grep -c "golem15.paymentgateway\." ../sm-paymentgateway-plugin/admin_permissions.go | grep -qE '^[7-9]$|^[1-9][0-9]+$'</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (a boot-time YAML check refused the orders YAML, a lang file does not parse); one of the sampled phrase keys is missing from the English lang file; fewer than seven permission codes.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestOrdersAdminSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestOrdersAdminSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the list showed a stored constant instead of a label, a money column lacked its currency, a record response offered an action outside the P2 set or lacked the danger flag, an admin without manage_orders got an action, reject did not cancel the cash payment, or a partial leaked return_url.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "go:embed controllers models assets" ../sm-paymentgateway-plugin/admin.go` prints one line and `grep -n "headerPartial: _orders_stats" ../sm-paymentgateway-plugin/controllers/orders/config_list.yaml` prints one line.
|
||||
- `grep -c "Name:" ../sm-paymentgateway-plugin/controllers/orders.go` is at least 10 and `grep -n "Danger: *true" ../sm-paymentgateway-plugin/controllers/orders.go` prints at least four lines.
|
||||
- `grep -n "currencyFrom: currency" ../sm-paymentgateway-plugin/models/order/columns.yaml` prints one line and `grep -n "currencyFrom: native_currency" ../sm-paymentgateway-plugin/models/order/columns.yaml` prints one line.
|
||||
- `grep -n "func (o \*Order) AfterCreate" ../sm-paymentgateway-plugin/models/order.go ../sm-paymentgateway-plugin/models/hashids.go` prints one line.
|
||||
- `grep -c ":" ../sm-paymentgateway-plugin/lang/pl/lang.yaml` is at least 450 and `! grep -n "<br" ../sm-paymentgateway-plugin/lang/en/lang.yaml ../sm-paymentgateway-plugin/lang/pl/lang.yaml` exits 0.
|
||||
- `grep -n "shopping-cart\|wand-sparkles" ../sm-paymentgateway-plugin/admin_navigation.go` prints two lines.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator can open Orders, read the stats and the exact amounts, open an order, see its cart, payments and details, and move it through the state machine with the right buttons.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Payments list, preview and actions (accept, accept_override, refund); Shippings and Quote items CRUD with money, fileupload and repeater fields</name>
|
||||
<reversibility rating="costly">D-05: the shippings and quoteables YAML and the payment actions are the shared admin contract of the plugin.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/controllers/admin_registry.go, ../sm-paymentgateway-plugin/controllers/payments.go, ../sm-paymentgateway-plugin/controllers/shippings.go, ../sm-paymentgateway-plugin/controllers/quoteables.go, ../sm-paymentgateway-plugin/controllers/payments/config_list.yaml, ../sm-paymentgateway-plugin/controllers/payments/config_form.yaml, ../sm-paymentgateway-plugin/controllers/payments/config_filter.yaml, ../sm-paymentgateway-plugin/controllers/payments/_payments_stats.htm, ../sm-paymentgateway-plugin/controllers/payments/_payment_hint.htm, ../sm-paymentgateway-plugin/controllers/payments/_payment_data.htm, ../sm-paymentgateway-plugin/controllers/shippings/config_list.yaml, ../sm-paymentgateway-plugin/controllers/shippings/config_form.yaml, ../sm-paymentgateway-plugin/controllers/quoteables/config_list.yaml, ../sm-paymentgateway-plugin/controllers/quoteables/config_form.yaml, ../sm-paymentgateway-plugin/models/payment/fields.yaml, ../sm-paymentgateway-plugin/models/payment/columns.yaml, ../sm-paymentgateway-plugin/models/shipping/fields.yaml, ../sm-paymentgateway-plugin/models/shipping/columns.yaml, ../sm-paymentgateway-plugin/models/shipping.go, ../sm-paymentgateway-plugin/models/quoteable/fields.yaml, ../sm-paymentgateway-plugin/models/quoteable/columns.yaml, ../sm-paymentgateway-plugin/admin_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/controllers/orders.go and orders_partials.go (Task 1 shapes), the frozen tree: controllers/Payments.php (onAccept, onRefund, the scoreboard), controllers/payments/config_list.yaml, config_form.yaml, config_filter.yaml, _side_panel.htm, _current_state.htm, models/payment/columns.yaml, fields.yaml, _operator_field.htm, _order_data_field.htm, controllers/Shippings.php, controllers/shippings/config_list.yaml, config_form.yaml, models/shipping/columns.yaml, fields.yaml, _grosscost_list_field.htm, models/Shipping.php (logotype attachOne, grossCost), controllers/Quoteables.php, controllers/quoteables/*.yaml, models/quoteable/*.yaml, models/Quoteable.php (custom_fields jsonable), summercms modules/cabana/README.md (File uploads, Repeater, attach.HasRelations), modules/lagoon/attach (Owner, HasRelations, the attachOne declaration), docs/backend/forms.md (File uploads, Repeater), .planning/phases/18-payment-gateway/18-UI-SPEC.md (P3, P4, P5, P6 and their UI Considerations rows)</read_first>
|
||||
<action>Per SC-3, D-05, D-07 and UI-SPEC P3 to P6.
|
||||
|
||||
Payments (controllers/payments.go, YAML, partials): ID `golem15.paymentgateway.payments`, ModelName `Golem15\PaymentGateway\Models\Payment`, RequiredPermissions access_payments; config_list.yaml (`noRecordsMessage` text.no_payments, `headerPartial: _payments_stats`, filter state and operator, default sort created_at desc, 20 per page); columns.yaml per P3 (id searchable, amount and native_amount money with currencyFrom, operator as the `operatorname` type, description, state as `paymentstate`, created_at, updated_at); config_form.yaml `preview: { headerPartial: _payment_hint }`, no create or update; fields.yaml description (full), uuid (left, a partial rendering `<code>` so the admin shows mono), operator (right, the localized name through a partial or the plugin type), `_payment_data` (full); PartialData `payments_stats` (All payments, Payments this month with previous note, Accepted this month gross sum with previous note), `payment_hint` (status, created with `labels.updateDate`, customer link or data e-mail or `labels.anonymous`, total with native note, order code link when `orders_enabled` and order present, the disabled-operator flag with the operator name) and `payment_data` (the public keys of `payments.data` only: `stripe_checkout_session_id`, `payment_intent_id`, `charge_id`, `email`, `is_paid_in_stripe`; never `client_secret`, never secrets, never `return_url`); `AdminRecordActions`: accept (Applies `payments.Can(Accept)` and the wrapped operator Mode manual or cash; Label actions.acceptPayment; Confirm actions.accept_confirm), accept_override (Can(Accept) and Mode api; actions.overrideAccept; actions.override_confirm; Danger), refund (Can(Refund); actions.refundPayment; actions.refund_payment_confirm; Danger), all behind manage_payments, Run through `payments.Accept`/`payments.Refund` inside the action transaction so the bridge pays or refunds the order.
|
||||
|
||||
Shippings (controllers/shippings.go, YAML, models/shipping.go): ID `golem15.paymentgateway.shippings`, ModelName `Golem15\PaymentGateway\Models\Shipping`, RequiredPermissions access_shippings for reads and manage_shippings for create, update and delete (use the controller's write-permission seam cabana offers; if it offers none, enforce in FormBeforeCreate/FormBeforeUpdate and the delete hook with `cabana.ForbiddenError`); the Shipping model declares the `logotype` attachOne through `attach.HasRelations` (system_files under the PHP MorphName); config_list.yaml (create button `shipping.create`, `noRecordsMessage` text.no_shippings); columns.yaml id, name searchable, cost (money, currencyFrom currency, label `shipping.fields.costNet`), tax rendered `23%` through a plugin type or a computed column, gross_cost (computed money column: cost × (100 + tax)/100 through `money.Mul`, currencyFrom currency), is_payable_with_cash switch; config_form.yaml and fields.yaml per P5 (name text required left; logotype fileupload mode image imageWidth 260 imageHeight 260 required right; cost money currencyFrom currency required left; tax number attributes min 0 max 100 step 1 required with comment `shipping.fields.vat_comment` right; is_payable_with_cash switch left); FormBeforeCreate and FormBeforeUpdate set `currency` to `default_currency` when empty (P5, the PHP MoneyWidget behaviour); the PHP `grossCost@update` read-only field is not ported (README).
|
||||
|
||||
Quote items (controllers/quoteables.go, YAML): ID `golem15.paymentgateway.quoteables`, ModelName `Golem15\PaymentGateway\Models\Quoteable`, permissions access_orders (reads) and manage_orders (writes); list id, name, default_cost (money, currencyFrom currency), default_tax (`23%`); form name (text required full), default_cost (money required left), default_tax (number 0..100 required right, `vat_comment`), custom_fields (repeater full, prompt `quoteable.fields.add_custom_field`, titleFrom key, nested key text left and value text right); create button `quoteable.create`, empty copy text.no_quoteables; `custom_fields` is a lagoon.Jsonable list on the model.
|
||||
|
||||
Register the three controllers in controllers/admin_registry.go. Smoke (Postgres): TestPaymentsAdminSmoke: a Sent cash payment's record offers accept only (not accept_override), running it pays the order (bridge) and toasts; a payment whose operator code is unknown shows the disabled-operator flag in the hint and offers only refund when Accepted; the `_payment_data` tree for a payment with `client_secret` in data never contains that key; the list in Polish shows `Wysłana`. TestShippingsAdminSmoke: create with cost "12.50", tax 23 and an uploaded 260×260 PNG stores cost `12.5000`, currency PLN (filled from the default) and the attachment; the list shows gross_cost `15.3750`; an admin with access_shippings but not manage_shippings gets 403 on create. TestQuoteablesAdminSmoke: create with two custom fields stores the list and the list column shows `23%`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -n "accept_override" ../sm-paymentgateway-plugin/controllers/payments.go | grep -q . && grep -n "type: fileupload" ../sm-paymentgateway-plugin/models/shipping/fields.yaml | grep -q . && grep -n "type: repeater" ../sm-paymentgateway-plugin/models/quoteable/fields.yaml | grep -q .</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (a YAML file is refused at compile); the payments controller lacks the override action; the shipping form lacks the fileupload or the quoteable form the repeater.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPaymentsAdminSmoke|TestShippingsAdminSmoke|TestQuoteablesAdminSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: accept did not pay the order, a secret key reached the payment data partial, the gross cost was wrong or went through a float, an unprivileged admin could create a shipping, or the repeater rows were not stored.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "golem15.paymentgateway.payments\|golem15.paymentgateway.shippings\|golem15.paymentgateway.quoteables" ../sm-paymentgateway-plugin/controllers/*.go` prints at least three lines.
|
||||
- `! grep -n "client_secret" ../sm-paymentgateway-plugin/controllers/payments/_payment_data.htm ../sm-paymentgateway-plugin/controllers/payments.go` exits 0.
|
||||
- `grep -n "imageWidth: 260" ../sm-paymentgateway-plugin/models/shipping/fields.yaml` prints one line and `grep -n "max: 100" ../sm-paymentgateway-plugin/models/shipping/fields.yaml ../sm-paymentgateway-plugin/models/quoteable/fields.yaml` prints one line per file.
|
||||
- `grep -n "headerPartial: _payments_stats" ../sm-paymentgateway-plugin/controllers/payments/config_list.yaml` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Payments can be accepted and refunded from the admin with the right confirms, and shipping methods and quote items are editable with exact amounts, logos and custom fields.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Quotes: list, create and update forms, the quote cart custom element with its three admin actions, the process_quote action, plugin assets and the README admin section</name>
|
||||
<reversibility rating="costly">D-05 and D-06-adjacent: the quote cart payloads and the admin action names are the plugin's admin extension surface; the create path writes v2 rows through the same encoders as Make.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/controllers/admin_registry.go, ../sm-paymentgateway-plugin/controllers/quotes.go, ../sm-paymentgateway-plugin/controllers/quotes_cart.go, ../sm-paymentgateway-plugin/controllers/quotes/config_list.yaml, ../sm-paymentgateway-plugin/controllers/quotes/config_form.yaml, ../sm-paymentgateway-plugin/controllers/quotes/config_filter.yaml, ../sm-paymentgateway-plugin/controllers/quotes/_quotes_notice.htm, ../sm-paymentgateway-plugin/models/order/columns_quote.yaml, ../sm-paymentgateway-plugin/models/order/quote_fields.yaml, ../sm-paymentgateway-plugin/assets/js/quote-cart.js, ../sm-paymentgateway-plugin/assets/css/quote-cart.css, ../sm-paymentgateway-plugin/admin.go, ../sm-paymentgateway-plugin/admin_smoke_test.go, ../sm-paymentgateway-plugin/README.md</files>
|
||||
<read_first>../sm-paymentgateway-plugin/controllers/orders.go (Task 1), the frozen tree: controllers/Quotes.php (the six manage_on* handlers, formBeforeCreate, the quoteable list widget), controllers/quotes/config_form.yaml, config_list.yaml, config_filter.yaml, config_quoteables_listwidget.yaml, _cart.htm, _quote_update.htm, _update.htm, _quoteables_toolbar.htm, _scoreboard_cost_fields.htm, models/order/columns_quote.yaml, quote_fields.yaml, support/QuoteableToItemConverter.php, ../fonoteka.go/plugins/golem15/fonoteka (the Phase 10.1 Discogs widget: assets/js/discogs-lookup.js for the custom element and its summer-action posts, assets/css/albums.css for plugin CSS under the tag selector, controllers/albums_admin_controller.go :45-110 for AdminJS, the AdminCSS declaration and AdminActions with Payload; grep "summer-action" and "customElements.define"), summercms modules/cabana/README.md (Server-rendered partials, widget fields, plugin assets allowlist and CSP), modules/pact/capabilities.go (:192-270 AdminAssets, AdminAction, HasAdminActions), docs/backend/forms.md (widget fields, Form-only fields), .planning/phases/18-payment-gateway/18-UI-SPEC.md (P7, the quote cart element contract, the Dimension 5 note on the 36px inputs and 6px/10px radii, Copywriting: quote.*, actions.process_quote*), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 12, Open Question 2)</read_first>
|
||||
<action>Per SC-3, D-05 and UI-SPEC P7.
|
||||
|
||||
Quotes controller (controllers/quotes.go): ID `golem15.paymentgateway.quotes`, ModelName `Golem15\PaymentGateway\Models\Order`, ConfigDir `controllers/quotes`, RequiredPermissions access_orders (reads) and manage_orders (writes); `ListExtendQuery` keeps state Quote only; config_list.yaml (`list: models/order/columns_quote.yaml`, create button `quote.create`, `noRecordsMessage` text.no_quotes, `headerPartial: _quotes_notice` rendering the feature-off callout when `use_quotes` or `orders_enabled` is off); columns_quote.yaml with the same typed adaptations as the orders list; config_form.yaml with create and update contexts and `form: models/order/quote_fields.yaml`; quote_fields.yaml: user (relation, nameFrom email, left), email (text, right, form-only), description (textarea, full, form-only), and on update only `_quote_cart` (type widget, element `golem15-paymentgateway-quote-cart`, full, context update); `FormBeforeCreate` reads the form-only email and description, builds an empty `cart.NewCart(default_currency)` and `cart.Details` (operator empty, description, email), encodes both as v2 onto the record, sets state Quote, currency and native currency to the default currency and zero amounts, and sets user_id from the relation (the AfterCreate hook writes hash_id and guest_code); `FormExtendQuery` on update refuses a non-Quote order with a redirect-style 404 so the row opens the orders preview instead (the SPA list link for a non-Quote row points at `golem15.paymentgateway.orders`; if cabana lists cannot vary the link per row, the quotes list shows only Quote rows, which already holds); `FormBeforeUpdate` re-encodes details with the edited email and description; record action process_quote (Label actions.process_quote, Confirm actions.process_quote_confirm, Applies `orders.Can(Place)` with at least one item, Run `orders.PerformTransition(Place)`, permission manage_orders).
|
||||
|
||||
Quote cart (controllers/quotes_cart.go, assets): `HasAdminActions` on the quotes controller with `add_quoteable` (payload `{quoteable_id}`: converts the Quoteable to an Item with its default cost and tax and the custom fields as data, like QuoteableToItemConverter), `change_item` (payload `{item_id, field, value}` where field is description, count, net_price or tax; description non-empty, count ≥ 1, net_price parsed with the plan 01 money rules at scale 4 and never rounded, tax 0..100; a refused value answers a 422 with the message the host toasts) and `remove_item` (payload `{item_id}`; success message `quote.item_removed` with `:name`), each decoding the stored v2 cart, mutating it, re-encoding, recomputing the amounts and answering `result.data` = the cart view model (rows with id, description, count, net, tax, gross and unit sub-lines pre-formatted, totals, the quoteable options list, the read-only flag), inside the action transaction with the order row locked; every action requires manage_orders and refuses a non-Quote order. assets/js/quote-cart.js defines `golem15-paymentgateway-quote-cart` (light DOM; reads its initial view model from the host; the add row with a native select of quote items and the outline "Add to quote" button; the `.summer-table` with the editable cells of P7 posting `summer-action` with the payloads above on blur or Enter; the trailing remove button with the visually hidden `quote.remove_item` label; the busy row at opacity .6 with aria-busy; re-render from `result.data`; restore the last saved row on failure; read-only rendering when the view model says so); assets/css/quote-cart.css styles only under the tag selector with `--c-*` variables (36px inputs with 1px `--c-border-strong`, 6px radius, `--c-ring` focus, the 44px select with 10px radius, the 42px button, the danger hover on remove); admin.go already declares both files. Record in the README (Admin section) the Dimension 5 note: the 36px cell inputs, 6px cell radius and 10px select radius are deliberate sizes copied from the DropdownField and the 10.1 widget button box.
|
||||
|
||||
README: an Admin section listing the five controllers, their permissions, the partials and their view models, the record and admin actions with payloads, the custom element contract, the navigation entry, the documented non-ports (`grossCost@update`, pie charts, `_order_actions_field`, the SetUp wizard) and the `golem15.paymentgateway.*` lang file.
|
||||
|
||||
Smoke (Postgres): TestQuotesAdminSmoke: create a quote through the admin create route with a user, an email and a description: the stored order is state Quote with a v2 empty cart and a hash_id; add_quoteable with a seeded Quoteable answers the cart view model with one row and the order amount equals the quoteable gross; change_item net_price "12.3456" stores exactly that and "12.34567" answers 422 with `money.too_precise`; change_item count 0 answers 422; remove_item empties the cart and answers `quote.item_removed`; process_quote on an empty quote is not offered, on a quote with an item it moves the order to Placed and the quotes list no longer lists it while the orders list does; the admin assets route serves quote-cart.js and quote-cart.css and refuses `assets/js/../plugin.go`.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -n "customElements.define('golem15-paymentgateway-quote-cart'" ../sm-paymentgateway-plugin/assets/js/quote-cart.js | grep -q . && ! grep -n "fetch(" ../sm-paymentgateway-plugin/assets/js/quote-cart.js | grep -q . && ! grep -nE "#[0-9a-fA-F]{3,6}\b" ../sm-paymentgateway-plugin/assets/css/quote-cart.css | grep -q .</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails; the element is not defined under its tag; the element calls fetch; the plugin CSS carries a hex colour.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestQuotesAdminSmoke|TestOrdersAdminSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: the create path did not write a v2 cart or a hash_id, an action accepted a rounded or invalid value, process_quote was offered on an empty quote or did not place the order, or the assets route served a path outside the allowlist.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "add_quoteable\|change_item\|remove_item" ../sm-paymentgateway-plugin/controllers/quotes_cart.go` prints at least three lines and `grep -n "process_quote" ../sm-paymentgateway-plugin/controllers/quotes.go` prints at least one line.
|
||||
- `grep -n "element: golem15-paymentgateway-quote-cart" ../sm-paymentgateway-plugin/models/order/quote_fields.yaml` prints one line.
|
||||
- `grep -n "WithQuote\|StateQuote" ../sm-paymentgateway-plugin/controllers/quotes.go` prints at least one line.
|
||||
- `grep -n "## Admin" ../sm-paymentgateway-plugin/README.md` prints one line and `grep -n "grossCost" ../sm-paymentgateway-plugin/README.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>Staff can draft a quote, build its cart inline with exact prices, and turn it into an order, all through validated server actions.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser → cabana record and admin actions | authenticated administrators with per-permission gates; payloads untrusted |
|
||||
| partial view models → admin DOM | server-rendered node trees; must not carry secrets |
|
||||
| plugin assets → admin page | same-origin JS under CSP script-src 'self' |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-17 | Elevation of Privilege | record and admin actions | high | mitigate | every action lists manage_orders or manage_payments; cabana checks permissions and locks the record in the action transaction; TestOrdersAdminSmoke asserts no action without manage_orders; shippings writes need manage_shippings (PG-004) |
|
||||
| T-18-18 | Tampering | quote cart payloads | high | mitigate | field allowlist (description, count, net_price, tax), money parsing with the never-round rule, count ≥ 1, tax 0..100, non-Quote orders refused; amounts recomputed server-side; test: TestQuotesAdminSmoke (Task 3: change_item `12.34567` is 422 `money.too_precise`, count 0 is 422, process_quote is not offered on an empty quote, the amount equals the recomputed gross) |
|
||||
| T-18-19 | Information Disclosure | partial view models | medium | mitigate | curated structs; `_payment_data` lists public keys only; `return_url`, secure parameters and client_secret are excluded by construction and asserted by tests |
|
||||
| T-18-20 | Tampering (XSS) | partial templates and the custom element | medium | mitigate | html/template contextual escaping; the element renders text nodes, never innerHTML with server strings; plugin CSS under the tag selector |
|
||||
| T-18-21 | Tampering | list localization | low | accept | ListValue maps a constant to a phrase; an unknown constant falls back to the raw value with no code path to execute |
|
||||
| T-18-22 | Information Disclosure | asset route | low | mitigate | cabana's exact allowlist from AdminAssets; the traversal case is asserted in TestQuotesAdminSmoke |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed in this plan; the custom element is hand-written vanilla JS with no dependency |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: Docker-free plugin suite, sampled lang keys and the permission count; the orders admin smoke on a Docker host.
|
||||
- Task 2: Docker-free suite plus YAML greps; the payments, shippings and quoteables smokes on a Docker host.
|
||||
- Task 3: Docker-free suite, element definition, no fetch, no hex in CSS; the quotes and orders smokes on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-3 (admin half): orders, payments, quotes, quote items and shippings run in the admin with the PHP permissions and a navigation entry.
|
||||
- D-07 in use: every amount column and field in the plugin YAML is `type: money`.
|
||||
- The complete phrasebook exists so plan 04 only adds YAML and templates.
|
||||
- `go vet ./...` and `go test -short ./...` in the plugin stay green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-03-SUMMARY.md` when done, with the commits, the controller IDs, action names and partial names, the lang key count per locale, the Docker suites run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
316
.planning/phases/18-payment-gateway/18-04-PLAN.md
Normal file
316
.planning/phases/18-payment-gateway/18-04-PLAN.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["18-03"]
|
||||
files_modified:
|
||||
- ../sm-paymentgateway-plugin/admin_settings.go
|
||||
- ../sm-paymentgateway-plugin/mail.go
|
||||
- ../sm-paymentgateway-plugin/boot.go
|
||||
- ../sm-paymentgateway-plugin/models/settings.go
|
||||
- ../sm-paymentgateway-plugin/models/mailer_settings.go
|
||||
- ../sm-paymentgateway-plugin/models/registry.go
|
||||
- ../sm-paymentgateway-plugin/models/settings/fields.yaml
|
||||
- ../sm-paymentgateway-plugin/models/settings/mailer_fields.yaml
|
||||
- ../sm-paymentgateway-plugin/updates/202610130007_create_golem15_paymentgateway_mailer_settings.go
|
||||
- ../sm-paymentgateway-plugin/events/mailers.go
|
||||
- ../sm-paymentgateway-plugin/events/recipients.go
|
||||
- ../sm-paymentgateway-plugin/views/mail/layouts/paymentgateway.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/canceled.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/canceled-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/canceledNotActive.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/canceledNotActive-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/deferredRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/deferredRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/directRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/directRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/employeeCreatedOrder.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/employeeCreatedOrder-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/finished.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/finished-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/grantedRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/grantedRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/guaranteeRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/guaranteeRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/paid.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/paid-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/placed.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/placed-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/purchased.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/purchased-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/refundedAfterReturn.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/refundedAfterReturn-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/rejected.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/rejected-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/rejectedReceivedPayment.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/rejectedReceivedPayment-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/requestedRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/requestedRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/shipped.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/shipped-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/underway.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/underway-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/waitingForReturn.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/order/waitingForReturn-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/accepted.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/accepted-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/canceled.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/canceled-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/refunded.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/refunded-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/rejected.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/payment/rejected-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/customerCreatedOrder.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/customerCreatedOrder-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/paid.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/paid-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/placed.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/placed-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/requestedRefund.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_order/requestedRefund-en.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_payment/accepted.htm
|
||||
- ../sm-paymentgateway-plugin/views/mail/admin_payment/accepted-en.htm
|
||||
- ../sm-paymentgateway-plugin/console/import_winter.go
|
||||
- ../sm-paymentgateway-plugin/settings_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/mail_smoke_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
autonomous: true
|
||||
requirements: [SC-3, D-02, D-05, D-08, D-13]
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-08 and UI-SPEC P8: the `payment_gateway` settings page (permission access_settings, category `settings.category`, icon credit-card) has the tabs General, Orders and Payment methods with the P8 fields: `default_currency` (dropdown of `ISO — name` options, required on save with the 422 `settings.default_currency_required`, replacing the SetUp wizard), `exchange_rate_provider` (fixer or nbp), the six switches, the four timeouts (number 1..30 with defaults 7, 2, 7, 14), `order_url_template`, `cash_enabled`, `manual_transfer_enabled`, the four manual-transfer text fields and `manual_transfer_bank_details` as a sanitized richeditor; the `trigger` dependencies become comments."
|
||||
- "D-08 and UI-SPEC P9: the `payment_gateway_mailer` settings page on its own singleton table `golem15_paymentgateway_mailer_settings` has the tabs General, Order mails to customers, Order mails to staff, Payment mails to customers and Payment mails to staff with `mailer_from_email`, `mailer_from_name`, `admin_email`, `admin_group_id` (dropdown of backend roles, `form.none` empty option) and one switch per PHP `mailer.*` key in PHP order plus the two new switches `mailer_order_placed` and `mailer_order_underway` (RESEARCH A10)."
|
||||
- "D-08 and SC-3: 27 postcard Markdown templates exist in Polish (unsuffixed) and English (`-en`) under the PHP template codes `golem15.paymentgateway::mail.{order.*,payment.*,admin_order.*,admin_payment.*}` (the 25 registered ones, plus `order.placed` which PHP shipped unregistered and `order.underway` which PHP referenced without a file), on the layout `golem15.paymentgateway::mail.layouts.paymentgateway`, with the PHP variables (order code, state label, totals, items, user first name, last name and e-mail, posName, posEmail, orderUrl built from `order_url_template` with `{hash}`); every registered name has a file or boot fails (postcard catalog validation)."
|
||||
- "SC-3: the four mailer groups port the PHP mailers: customer order mails for the 18 order events each gated by its `mailer_order_*` switch; employee order mails for placed, paid, requestedRefund and customerCreatedOrder gated by `mailer_admin_order_*`; customer payment mails for accepted, canceled, refunded and rejected only when `orders_enabled` is off; the employee payment mail for accepted; the recipient is the order's user e-mail unless it contains `guest@`, else the details e-mail unless it is the anonymous label; staff mails go to `admin_email` or every backend user of `admin_group_id`; the template suffix follows the recipient's preferred locale (`-en` for en, Polish otherwise); every mail is sent through `lagoon.AfterCommit`, so a rolled-back transaction sends nothing and a committed one sends exactly once."
|
||||
- "SC-3: `paymentgateway:import-winter` also maps the PHP `mailer.credentials.email`, `mailer.credentials.name`, `mailgroup` (JSON pair) and every `mailer.<group>.<event>` `\"1\"`/`\"0\"` key onto the mailer settings row."
|
||||
- "UI considerations (settings forms, covered): unset fields show their defaults (timeouts 7/2/7/14) or are empty; the existing SettingsFormView skeleton and load error apply; a 422 shows inline errors and the tab error chip; comments wrap; switches without `trigger` state in their comment when they apply; the required default currency has its own 422 copy."
|
||||
artifacts:
|
||||
- path: "../sm-paymentgateway-plugin/admin_settings.go"
|
||||
provides: "Settings() with the payment_gateway and payment_gateway_mailer items"
|
||||
contains: "payment_gateway_mailer"
|
||||
- path: "../sm-paymentgateway-plugin/models/settings/fields.yaml"
|
||||
provides: "P8 settings form with tabs"
|
||||
contains: "default_currency"
|
||||
- path: "../sm-paymentgateway-plugin/models/settings/mailer_fields.yaml"
|
||||
provides: "P9 mailer form with five tabs"
|
||||
contains: "mailer_order_underway"
|
||||
- path: "../sm-paymentgateway-plugin/mail.go"
|
||||
provides: "MailTemplatesFS, MailTemplates (54 names), MailLayouts"
|
||||
contains: "golem15.paymentgateway::mail.order.paid"
|
||||
- path: "../sm-paymentgateway-plugin/events/mailers.go"
|
||||
provides: "the four mailer groups registered as a boot step, gated by MailerSettings, sent after commit"
|
||||
contains: "AfterCommit"
|
||||
- path: "../sm-paymentgateway-plugin/views/mail/order/paid.htm"
|
||||
provides: "the Polish OrderPaid customer template"
|
||||
contains: "layout = \"paymentgateway\""
|
||||
key_links:
|
||||
- from: "../sm-paymentgateway-plugin/events/mailers.go"
|
||||
to: "../sm-paymentgateway-plugin/events/events.go"
|
||||
via: "app.Events.Listen on each order and payment event"
|
||||
pattern: "Listen"
|
||||
- from: "../sm-paymentgateway-plugin/events/mailers.go"
|
||||
to: "summercms modules/postcard"
|
||||
via: "postcard.Mailer Send with Message{Template, To, Vars}"
|
||||
pattern: "postcard\\.Message"
|
||||
- from: "../sm-paymentgateway-plugin/models/settings/fields.yaml"
|
||||
to: "summercms modules/cabana/settings.go"
|
||||
via: "tab and richeditor on a settings form (plan 01 F3, F4)"
|
||||
pattern: "richeditor"
|
||||
- from: "../sm-paymentgateway-plugin/console/import_winter.go"
|
||||
to: "../sm-paymentgateway-plugin/models/mailer_settings.go"
|
||||
via: "flat mailer.* keys mapped to columns"
|
||||
pattern: "mailer_"
|
||||
prohibitions:
|
||||
- statement: "No mail is sent from inside a transaction or for a rolled-back one; every send runs in lagoon.AfterCommit and the mailer never retries into a duplicate."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Mail templates and the settings pages never name a consuming application; the template codes, variables and the mailer switch keys keep their PHP names."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Bank details entered as rich text are stored only after seaglass sanitizing; no other HTML enters a mail or a settings column unsanitized."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 4 of 7, wave 4, `../sm-paymentgateway-plugin`): the two settings pages (payment gateway and mailer, D-08) on the plan 01 settings-form features, the 27 mail templates in Polish and English under the PHP codes, the four mailer groups with their switches, recipients, locale suffix and after-commit sending, and the mailer keys in the Winter import. It runs after plan 03, which declared the `adminFS` embed this plan's YAML joins and wrote every phrase key these pages use.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator. `must_haves` derive from SC-3, D-02, D-05, D-08, D-13 (the non-Stripe settings half), RESEARCH Patterns 8 and 9 and the UI-SPEC P8, P9 and the `## UI Considerations` rows for PG, Mailer and Stripe settings (the Stripe page itself is plan 05). Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- The mailer settings are a second singleton table (`golem15_paymentgateway_mailer_settings`), not extra columns on the gateway settings: the page is separate in PHP and the switch set is long; the migration ID follows plan 02's sequence (`202610130007`).
|
||||
- RESEARCH A10 is taken: `order.placed` is registered and `order.underway` gets a template and a switch; the README records both as fixes of PHP bugs.
|
||||
- RESEARCH A11 is taken: `default_currency` is required on the settings save (422 `settings.default_currency_required`); the boot-time `isSetUp` gate is replaced by "default_currency set" wherever PHP checked it (`Orders.Make` already refuses without it).
|
||||
- `admin_group_id` is a backend role id (cabana `backend_user_roles`, the successor of Winter backend user groups); staff recipients are the backend users of that role.
|
||||
- The recipient locale is the frontend user's `preferred_locale` (the user plugin) for customer mails and the application default locale for staff mails; postcard does no locale selection, so the mailer picks the `-en` suffix itself.
|
||||
- Mail variables are a flat `map[string]any` with the PHP names (`order`, `user`, `posName`, `posEmail`, `orderUrl`) where `order` is a curated view (code, state label, totals pre-formatted with `support.FormatAmount`, items) and never the GORM model.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Root: `admin_settings.go` (`Settings()` with items `payment_gateway` and `payment_gateway_mailer`, the `pact.HasSettings` assertion), `mail.go` (`mailFS`, `MailTemplatesFS`, `MailTemplates`, `MailLayouts`, the postcard capability assertions), boot step `mailers`.
|
||||
- `models`: `MailerSettings` (`TableName` `golem15_paymentgateway_mailer_settings`, 27 switch columns, `Fillable`, `Rules`), `Settings.Rules` with `default_currency: required|string|size:3`, `Settings.GetDefaultCurrencyOptions`, `Settings.GetExchangeRateProviderOptions`, `MailerSettings.GetAdminGroupIDOptions`.
|
||||
- `updates`: `202610130007_create_golem15_paymentgateway_mailer_settings`.
|
||||
- `events`: `RegisterMailers` (boot step), `CustomerOrderMailer`, `EmployeeOrderMailer`, `CustomerPaymentMailer`, `EmployeePaymentMailer`, `MailTemplate(base, locale)`, `Recipients` (`customer`, `staff`), `OrderMailVars`.
|
||||
- `views/mail`: the layout and 54 template files.
|
||||
- `console/import_winter.go`: the mailer key mapping.
|
||||
- Smoke tests: `TestPGSettingsSmoke`, `TestMailerSettingsSmoke`, `TestMailTracer`, `TestMailersMatrixSmoke`.
|
||||
|
||||
<objective>
|
||||
Give the plugin its two settings pages and its mail: the payment gateway settings with a required default currency (replacing SetUp), the mailer settings page that decides which customer and staff mails go out, the 27 Markdown templates in Polish and English under the PHP codes, and the mailers that fire them after commit (D-08, SC-3).
|
||||
|
||||
Purpose: SC-3 names the mail templates and the settings screen; without the default currency no order can be created, and without the mailers the OrderPaid flow the host test proves (plan 05) sends nothing.
|
||||
Output: two settings pages, a mailer settings table, 55 template files, four mailer groups, the extended import command, README sections and smoke tests.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-UI-SPEC.md
|
||||
@.planning/phases/18-payment-gateway/18-03-SUMMARY.md
|
||||
|
||||
The frozen PHP sources are `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway` at `4b9ecf9` (D-02). Stage files by explicit path; no co-author tags; no push. Docker-backed tests skip under `-short`; record NOT RUN when no daemon answers. Never edit `plugin.go`: register the mailers and the settings through the boot-step and capability files named in files_modified.
|
||||
|
||||
<interfaces>
|
||||
Framework contracts consumed (read, do not change): `pact.SettingsItem` (Code, Label, Description, Category, Icon, Permissions, Form, NewModel, capabilities.go:543), `pact.HasSettings`, the postcard capability interfaces (`MailTemplatesFS`, `MailTemplates`, `MailLayouts`; template file format `subject = …`, `layout = …`, `==`, Markdown; catalog validated at Boot), `postcard.Mailer` and `postcard.Message{Template, To, Vars}`, the memory driver for tests (`mail.driver: memory`), `lagoon.AfterCommit(ctx, db, fn)`, `festival.Bus.Listen`, cabana settings form features from plan 01 (`tab`, `richeditor`, `password`), dropdown model-method options (ADMIN-01), `cabana.BackendUserRole` and `cabana.BackendUser` (role id, e-mail), the user plugin's `models.User` (`Email`, `PreferredLocale`, `FirstName`, `LastName`).
|
||||
|
||||
Plugin contracts consumed from plans 02 and 03: the 24 events, `models.Settings`, `models.Order` and `Payment` with `Amount()`, `support.FormatAmount`, the phrase keys `settings.*`, `mailer.*`, `orderstates.*`, `paymentstates.*`, the orders stats partial's notice logic (reads `default_currency`).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the payment gateway settings page saves a required default currency, the mailer page exists, and OrderPaid sends the customer's paid mail in the right language after commit</name>
|
||||
<reversibility rating="costly">D-08 and D-05: the mailer settings table and its column names, the template codes and the mail variables are a shared contract across the consuming applications.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/admin_settings.go, ../sm-paymentgateway-plugin/mail.go, ../sm-paymentgateway-plugin/boot.go, ../sm-paymentgateway-plugin/models/settings.go, ../sm-paymentgateway-plugin/models/mailer_settings.go, ../sm-paymentgateway-plugin/models/registry.go, ../sm-paymentgateway-plugin/models/settings/fields.yaml, ../sm-paymentgateway-plugin/models/settings/mailer_fields.yaml, ../sm-paymentgateway-plugin/updates/202610130007_create_golem15_paymentgateway_mailer_settings.go, ../sm-paymentgateway-plugin/events/mailers.go, ../sm-paymentgateway-plugin/events/recipients.go, ../sm-paymentgateway-plugin/views/mail/layouts/paymentgateway.htm, ../sm-paymentgateway-plugin/views/mail/order/paid.htm, ../sm-paymentgateway-plugin/views/mail/order/paid-en.htm, ../sm-paymentgateway-plugin/settings_smoke_test.go, ../sm-paymentgateway-plugin/mail_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/admin.go (Settings()), ../sm-sitemap-plugin/models/settings.go and updates/202610110002_create_golem15_sitemap_settings.go (singleton shape and seed), ../sm-summercmsio-app/plugins/golem15/user/plugin.go (:184-203 MailTemplates/MailLayouts), classes/mail.go (:41-46 MailTemplate suffix), controllers/api_controller.go (:872-884 sendTemplateVars), views/mail/activate.htm and views/mail/layouts (the file format and a layout), models/user.go (PreferredLocale, Email, names), summercms modules/postcard/README.md (template format, catalog validation, memory driver, no locale selection), modules/lagoon/transaction.go (:103 AfterCommit), modules/cabana/README.md (Singleton settings screens, Backend authentication: BackendUserRole), modules/cabana/settings.go (plan 01: tab, richeditor, password on settings), modules/cabana/testdata/payments/models/vault/fields.yaml (plan 01 tabbed settings fixture), ../sm-paymentgateway-plugin/boot.go, models/settings.go, events/events.go, support/format.go (plans 02 and 03), ../sm-paymentgateway-plugin/lang/en/lang.yaml (settings.*, mailer.*, the P8/P9 copy plan 03 wrote), the frozen tree: controllers/Settings.php, controllers/settings/config_form.yaml, models/settings/fields.yaml (the field list and comments), models/settings/mailer_fields.yaml (the 25 switches in order and their tabs), models/Settings.php (:32 item code, initSettingsData defaults), updates/flush_settings.php, controllers/MailerSettings.php, controllers/mailersettings/config_mailer_form.yaml, core/CustomerOrderMailer.php (onOrderPaid, the recipient rule, the variables), traits/CustomerEventMailer.php, views/mail/order/paid.htm, Plugin.php (:262-345 registerMailTemplates, :441-580 boot and registerSubscribers), .planning/phases/18-payment-gateway/18-UI-SPEC.md (P8, P9, Copywriting settings.* and mailer.* rows, UI Considerations settings forms), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 8 Go design, Pattern 9, Pitfall 6, Pitfall 11, A10, A11)</read_first>
|
||||
<action>Per D-08, SC-3, D-05 and UI-SPEC P8 and P9, one settings save and one mail end to end.
|
||||
|
||||
Settings page (admin_settings.go, models/settings.go, models/settings/fields.yaml): `Settings()` returns the item `payment_gateway` (Label `golem15.paymentgateway::lang.settings.label`, Description `settings.description`, Category `settings.category`, Icon credit-card, Permissions access_settings, Form `models/settings/fields.yaml`, NewModel `&models.Settings{}`) and the item `payment_gateway_mailer` (Label `settings.mailer_label`, Description `settings.mailer_description`, same category, Icon mail, Form `models/settings/mailer_fields.yaml`, NewModel `&models.MailerSettings{}`); the `pact.HasSettings` assertion lives in this file. fields.yaml per P8 with `tab` on every field (General is the default tab: default_currency dropdown left with `emptyOption` `form.select_placeholder` and the comment "PHP defaultCurrencyComment plus Changing it later does not convert existing amounts."; exchange_rate_provider dropdown right; currency_auto_create, enable_guest, backend_prefer_local, frontend_prefer_local switches; Orders tab: orders_enabled, shippings_enabled, use_quotes, use_underway with the "Applies only when orders are turned on." comment, the four timeouts as number min 1 max 30 step 1 with the PHP knob labels and comments plus " Days.", order_url_template text full with the P8 copy; Payment methods tab: cash_enabled, manual_transfer_enabled, the four manual-transfer text fields with the P8 comment, manual_transfer_bank_details richeditor full); `models.Settings` gains `Rules()` with `default_currency: required|string|size:3` and `exchange_rate_provider: required|in:fixer,nbp`, the timeouts `required|integer|min:1|max:30`, `order_url_template: nullable|string|max:500`, and the option methods `GetDefaultCurrencyOptions()` (from `currency.ISO`, labels `PLN — Polish złoty` sorted by code) and `GetExchangeRateProviderOptions()` (fixer, nbp); the required message for default_currency is `settings.default_currency_required` (map the rule's field message through `Rules` or the cabana per-field message seam, read modules/cabana/settings.go for how plan 01 surfaces a field message).
|
||||
|
||||
Mailer settings (models/mailer_settings.go, updates/202610130007, models/settings/mailer_fields.yaml): `MailerSettings` on `golem15_paymentgateway_mailer_settings` (id INTEGER PK seeded with row 1; mailer_from_email, mailer_from_name, admin_email TEXT NULL; admin_group_id INTEGER NULL; the 27 BOOLEAN switch columns named `mailer_<group>_<snake_event>`: the five admin ones (`mailer_admin_payment_accepted`, `mailer_admin_order_customer_created_order`, `mailer_admin_order_placed`, `mailer_admin_order_paid`, `mailer_admin_order_requested_refund`), the sixteen PHP customer order ones in mailer_fields.yaml order (`mailer_order_employee_created_order`, `mailer_order_rejected_received_payment`, `mailer_order_guarantee_refund`, `mailer_order_deferred_refund`, `mailer_order_refunded_after_return`, `mailer_order_waiting_for_return`, `mailer_order_granted_refund`, `mailer_order_direct_refund`, `mailer_order_requested_refund`, `mailer_order_finished`, `mailer_order_shipped`, `mailer_order_paid`, `mailer_order_canceled_not_active`, `mailer_order_canceled`, `mailer_order_rejected`, `mailer_order_purchased`), the two new ones (`mailer_order_placed`, `mailer_order_underway`), the four customer payment ones (`mailer_payment_accepted`, `mailer_payment_canceled`, `mailer_payment_refunded`, `mailer_payment_rejected`); defaults as PHP `initSettingsData`/`flush_settings.php` define them, else false), `Fillable` all of them, `Rules` (`mailer_from_email: nullable|email`, `admin_email: nullable|email`, `admin_group_id: nullable|integer`), `GetAdminGroupIDOptions()` listing cabana backend roles (id and name). mailer_fields.yaml per P9: General tab (mailer_from_email left, mailer_from_name right, admin_email left with placeholder `admin@example.com`, admin_group_id dropdown right with emptyOption `form.none`, each with the P9 comment), then the four mail tabs with one switch per column in PHP order alternating left/right with the PHP `mailer.*` labels, the "Sent only when orders are turned off." comment on the first customer payment switch, and the two new switches in the customer order tab.
|
||||
|
||||
Mail (mail.go, views/mail, events/mailers.go, events/recipients.go, boot.go): mail.go embeds `views/mail` and returns `MailTemplates()` with every `golem15.paymentgateway::mail.<group>.<name>` and `…-en` name (54; the full list lands in Task 2, this task may return the two paid names plus the layout and grow in Task 2) and `MailLayouts()` `{"paymentgateway": "golem15.paymentgateway::mail.layouts.paymentgateway"}`; `views/mail/layouts/paymentgateway.htm` ports the PHP mail layout; `views/mail/order/paid.htm` (Polish, `subject`, `layout = "paymentgateway"`, the PHP body with `{{ .order.code }}`, `{{ .orderUrl }}`, totals) and `paid-en.htm`. events/recipients.go: `MailTemplate(base, locale)` (the user-plugin rule), `CustomerRecipient(order, details)` (user e-mail unless it contains `guest@`, else the details e-mail unless it equals `golem15.paymentgateway::lang.labels.anonymous`, else empty) and `StaffRecipients(settings)` (admin_email, or the e-mails of backend users whose role is admin_group_id, or none). events/mailers.go: `RegisterMailers(app)` as a boot step after the bridge; `CustomerOrderMailer` listens to `OrderPaid` (the rest in Task 2): when `mailer_order_paid` is on and a customer recipient exists, build `OrderMailVars` (order view: code, state label in the recipient's locale, gross and net totals via `support.FormatAmount`, the items; user first name, last name and e-mail; `posName` and `posEmail` from mailer_from_*; `orderUrl` from `order_url_template` with `{hash}` replaced, empty when the template is empty) and schedule `postcard.Message{Template: MailTemplate("golem15.paymentgateway::mail.order.paid", locale), To: [recipient], Vars}` through `lagoon.AfterCommit` on the event's transaction (Pitfall 6); a send error is logged, never returned into the transition.
|
||||
|
||||
Smoke (Postgres): settings_smoke_test.go TestPGSettingsSmoke: GET `payment_gateway` as an admin with access_settings answers the three tabs and the timeouts 7/2/7/14 on a fresh row; PUT without default_currency answers 422 with `settings.default_currency_required` on that field; PUT with PLN, nbp and bank details containing a script element answers 200 with the sanitized value stored and a notice; GET with the column empty still answers 200 (no redirect wizard). TestMailerSettingsSmoke: GET `payment_gateway_mailer` answers five tabs and 27 switch fields; PUT with admin_group_id set to a created role stores it; an admin without access_settings gets 403. mail_smoke_test.go TestMailTracer (memory driver): a user with preferred_locale en owns a cash order; with `mailer_order_paid` on, paying it through the services sends exactly one message to the user's e-mail with template `golem15.paymentgateway::mail.order.paid-en` whose body contains the order code and `https://shop.example.test/orders/<hash>` from `order_url_template`; with the switch off nothing is sent; a user whose e-mail starts with `guest@` and details e-mail `b@example.test` sends to the details address; a transaction that pays and then rolls back sends nothing.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -c "type: switch" ../sm-paymentgateway-plugin/models/settings/mailer_fields.yaml | grep -qx 27 && grep -n "type: richeditor" ../sm-paymentgateway-plugin/models/settings/fields.yaml | grep -q . && test -f ../sm-paymentgateway-plugin/views/mail/order/paid-en.htm</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (a settings YAML or a template is refused at compile); the mailer form does not declare exactly 27 switches; the bank details are not a richeditor; the English paid template is missing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPGSettingsSmoke|TestMailerSettingsSmoke|TestMailTracer)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: the save accepted an empty default currency, the bank details were stored unsanitized, the mailer page lacks a tab, the paid mail went to the wrong address or template, was sent with the switch off, or was sent for a rolled-back transaction.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"payment_gateway"\|"payment_gateway_mailer"' ../sm-paymentgateway-plugin/admin_settings.go` prints two lines.
|
||||
- `grep -n "required|string|size:3" ../sm-paymentgateway-plugin/models/settings.go` prints one line and `grep -n "golem15_paymentgateway_mailer_settings" ../sm-paymentgateway-plugin/models/mailer_settings.go ../sm-paymentgateway-plugin/updates/202610130007_create_golem15_paymentgateway_mailer_settings.go` prints one line per file.
|
||||
- `grep -n "AfterCommit" ../sm-paymentgateway-plugin/events/mailers.go` prints at least one line and `grep -n "guest@" ../sm-paymentgateway-plugin/events/recipients.go` prints one line.
|
||||
- `grep -c "tab:" ../sm-paymentgateway-plugin/models/settings/fields.yaml` is at least 15.
|
||||
</acceptance_criteria>
|
||||
<done>The shop is configured from two settings pages, orders refuse to exist without a currency, and a paid order e-mails its customer once, in their language, after the commit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: All 27 templates in Polish and English, the four mailer groups with their switches, recipients and locale, and the mailer keys in the Winter import</name>
|
||||
<reversibility rating="costly">D-08: the template codes and variables are what app overrides and later mail customisations bind to.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/mail.go, ../sm-paymentgateway-plugin/events/mailers.go, ../sm-paymentgateway-plugin/events/recipients.go, ../sm-paymentgateway-plugin/views/mail/order/canceled.htm, ../sm-paymentgateway-plugin/views/mail/order/canceled-en.htm, ../sm-paymentgateway-plugin/views/mail/order/canceledNotActive.htm, ../sm-paymentgateway-plugin/views/mail/order/canceledNotActive-en.htm, ../sm-paymentgateway-plugin/views/mail/order/deferredRefund.htm, ../sm-paymentgateway-plugin/views/mail/order/deferredRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/order/directRefund.htm, ../sm-paymentgateway-plugin/views/mail/order/directRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/order/employeeCreatedOrder.htm, ../sm-paymentgateway-plugin/views/mail/order/employeeCreatedOrder-en.htm, ../sm-paymentgateway-plugin/views/mail/order/finished.htm, ../sm-paymentgateway-plugin/views/mail/order/finished-en.htm, ../sm-paymentgateway-plugin/views/mail/order/grantedRefund.htm, ../sm-paymentgateway-plugin/views/mail/order/grantedRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/order/guaranteeRefund.htm, ../sm-paymentgateway-plugin/views/mail/order/guaranteeRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/order/placed.htm, ../sm-paymentgateway-plugin/views/mail/order/placed-en.htm, ../sm-paymentgateway-plugin/views/mail/order/purchased.htm, ../sm-paymentgateway-plugin/views/mail/order/purchased-en.htm, ../sm-paymentgateway-plugin/views/mail/order/refundedAfterReturn.htm, ../sm-paymentgateway-plugin/views/mail/order/refundedAfterReturn-en.htm, ../sm-paymentgateway-plugin/views/mail/order/rejected.htm, ../sm-paymentgateway-plugin/views/mail/order/rejected-en.htm, ../sm-paymentgateway-plugin/views/mail/order/rejectedReceivedPayment.htm, ../sm-paymentgateway-plugin/views/mail/order/rejectedReceivedPayment-en.htm, ../sm-paymentgateway-plugin/views/mail/order/requestedRefund.htm, ../sm-paymentgateway-plugin/views/mail/order/requestedRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/order/shipped.htm, ../sm-paymentgateway-plugin/views/mail/order/shipped-en.htm, ../sm-paymentgateway-plugin/views/mail/order/underway.htm, ../sm-paymentgateway-plugin/views/mail/order/underway-en.htm, ../sm-paymentgateway-plugin/views/mail/order/waitingForReturn.htm, ../sm-paymentgateway-plugin/views/mail/order/waitingForReturn-en.htm, ../sm-paymentgateway-plugin/views/mail/payment/accepted.htm, ../sm-paymentgateway-plugin/views/mail/payment/accepted-en.htm, ../sm-paymentgateway-plugin/views/mail/payment/canceled.htm, ../sm-paymentgateway-plugin/views/mail/payment/canceled-en.htm, ../sm-paymentgateway-plugin/views/mail/payment/refunded.htm, ../sm-paymentgateway-plugin/views/mail/payment/refunded-en.htm, ../sm-paymentgateway-plugin/views/mail/payment/rejected.htm, ../sm-paymentgateway-plugin/views/mail/payment/rejected-en.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/customerCreatedOrder.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/customerCreatedOrder-en.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/paid.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/paid-en.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/placed.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/placed-en.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/requestedRefund.htm, ../sm-paymentgateway-plugin/views/mail/admin_order/requestedRefund-en.htm, ../sm-paymentgateway-plugin/views/mail/admin_payment/accepted.htm, ../sm-paymentgateway-plugin/views/mail/admin_payment/accepted-en.htm, ../sm-paymentgateway-plugin/console/import_winter.go, ../sm-paymentgateway-plugin/mail_smoke_test.go, ../sm-paymentgateway-plugin/commands_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/events/mailers.go and recipients.go (Task 1), ../sm-paymentgateway-plugin/console/import_winter.go (plan 02), the frozen tree: views/mail/**/*.htm (all 26 files: subjects, bodies, variables), core/CustomerOrderMailer.php (every onOrder* method: event, setting key, template), core/EmployeeOrderMailer.php, core/CustomerPaymentMailer.php, core/EmployeePaymentMailer.php, traits/CustomerEventMailer.php and EmployeeEventMailer.php (recipient and mail group resolution), Plugin.php (:262-345 the registered names, :567-580 registerSubscribers and the ordersEnabled switch), lang/pl/lang.php and lang/en/lang.php (mail subject copy if any), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 9 gating table and the PHP bugs, Pattern 8 PHP key list for mailer.*)</read_first>
|
||||
<action>Per D-08 and SC-3.
|
||||
|
||||
Templates: port every PHP mail template to a Polish file (unsuffixed) and an English file (`-en`) under the same path with `subject`, `layout = "paymentgateway"` and the body as Markdown with the Go template variables (`{{ .order.code }}`, `{{ .order.state }}`, `{{ .order.total_gross }}`, `{{ range .order.items }}`, `{{ .user.first_name }}`, `{{ .posName }}`, `{{ .posEmail }}`, `{{ .orderUrl }}`), keeping the PHP wording in Polish and a faithful English rendering; add `order/placed` (PHP file existed, unregistered) and `order/underway` (PHP referenced it with no file) with wording matching their neighbours; `MailTemplates()` lists all 54 names, so the postcard catalog validation at Boot proves every file exists.
|
||||
|
||||
Mailers (events/mailers.go): complete the four groups from the RESEARCH Pattern 9 gating table read against the PHP mailers: `CustomerOrderMailer` for the 18 order events (`OrderPlaced` → `mailer_order_placed` → `mail.order.placed`, `OrderPurchased` → purchased, `OrderRejected` → rejected, `OrderCanceled`, `OrderCanceledNotActive`, `OrderPaid`, `OrderUnderway` → `mailer_order_underway` → `mail.order.underway`, `OrderShipped`, `OrderFinished`, `OrderRequestedRefund`, `OrderDirectRefund`, `OrderGrantedRefund`, `OrderWaitingForReturn`, `OrderRefundedAfterReturn`, `OrderDeferredRefund`, `OrderGuaranteeRefund`, `OrderRejectedReceivedPayment`, `EmployeeCreatedOrder` → `mailer_order_employee_created_order` → `mail.order.employeeCreatedOrder`); `EmployeeOrderMailer` for `OrderPlaced`, `OrderPaid`, `OrderRequestedRefund` and `CustomerCreatedOrder` → `mailer_admin_order_*` → `mail.admin_order.*` to the staff recipients; `CustomerPaymentMailer` for `PaymentAccepted`, `PaymentCanceled`, `PaymentRefunded`, `PaymentRejected` → `mailer_payment_*` → `mail.payment.*`, registered only when `orders_enabled` is off (PHP registerSubscribers); `EmployeePaymentMailer` for `PaymentAccepted` → `mailer_admin_payment_accepted` → `mail.admin_payment.accepted`; every send through `lagoon.AfterCommit`, every template through `MailTemplate(base, locale)` with the customer's preferred locale or the app default for staff, every switch read from the mailer settings row at event time (not cached at boot), and the variables through `OrderMailVars` / a payment variant with `payment` (id, state label, amount, operator name).
|
||||
|
||||
Import (console/import_winter.go): the flat PHP keys `mailer.credentials.email` → mailer_from_email, `mailer.credentials.name` → mailer_from_name, `mailgroup` (a JSON pair `[email, group_id]` or an object with left/right) → admin_email and admin_group_id (the group id is imported only when a backend role with that id exists; otherwise a notice), and every `mailer.<group>.<event>` `"1"`/`"0"` → its `mailer_<group>_<snake_event>` column, written to the mailer settings row in the same transaction as the gateway settings; `--dry-run` reports the mapping.
|
||||
|
||||
Smoke (Postgres): mail_smoke_test.go TestMailersMatrixSmoke: a table over every (event, switch, template, recipient class) row of the gating table: with the switch on exactly one message with the expected template name and recipient class is in the memory driver after the transition commits, with the switch off none; customer payment mails are sent only with `orders_enabled` off; staff mails reach `admin_email` when set and every backend user of `admin_group_id` when the e-mail is empty; a Polish-locale customer gets the unsuffixed template and an English one the `-en` template. commands_smoke_test.go: TestImportWinterSmoke gains the mailer keys (credentials, a mailgroup pair, three switches) and asserts the mailer row.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && ls ../sm-paymentgateway-plugin/views/mail/order ../sm-paymentgateway-plugin/views/mail/payment ../sm-paymentgateway-plugin/views/mail/admin_order ../sm-paymentgateway-plugin/views/mail/admin_payment | grep -c '\.htm$' | grep -qx 54 && grep -c 'golem15.paymentgateway::mail\.' ../sm-paymentgateway-plugin/mail.go | grep -qE '^(5[4-9]|[6-9][0-9])$'</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (a template file is malformed, or a name is registered without a file and the catalog validation fails at boot in a test); fewer than 54 template files; fewer than 54 registered names.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestMailersMatrixSmoke|TestImportWinterSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: an event sent the wrong template or recipient class, a switch did not gate, customer payment mails went out with orders enabled, the locale suffix was wrong, or a mailer key did not import.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `for f in order/underway order/placed admin_order/placed admin_payment/accepted payment/rejected; do test -f "../sm-paymentgateway-plugin/views/mail/$f.htm" && test -f "../sm-paymentgateway-plugin/views/mail/$f-en.htm" || exit 1; done` exits 0.
|
||||
- `grep -c "mailer_order_\|mailer_admin_\|mailer_payment_" ../sm-paymentgateway-plugin/events/mailers.go` is at least 27.
|
||||
- `grep -n "mailgroup" ../sm-paymentgateway-plugin/console/import_winter.go` prints at least one line.
|
||||
- `grep -rn 'layout = "paymentgateway"' ../sm-paymentgateway-plugin/views/mail/order/underway.htm` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every order and payment event that PHP mailed is mailed here, in the customer's language, to the right people, switchable per mail, and only after the commit.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: README sections for settings, mail and cutover; the documented PHP-bug fixes; the orders stats notice reads the required currency</name>
|
||||
<reversibility rating="reversible">Documentation and a notice wiring; nothing persisted.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/README.md, ../sm-paymentgateway-plugin/controllers/orders_partials.go, ../sm-paymentgateway-plugin/settings_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/README.md (plans 02 and 03 sections), ../sm-paymentgateway-plugin/controllers/orders_partials.go (plan 03 notice logic), ../sm-journal-plugin/README.md (Configuration and Usage sections as the shape), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 8, Pattern 9 PHP bugs, Runtime State Inventory)</read_first>
|
||||
<action>README: a Settings section (both pages, every column with its PHP key, the required default currency replacing the SetUp wizard, `order_url_template` with `{hash}`, the manual-transfer fields and the sanitized bank details), a Mail section (the template code list, the variables, the layout, the switch per mail, the recipient rules, the locale suffix, after-commit sending, and the two PHP bugs fixed: `order.placed` registered, `order.underway` added with its switch), the import command's mailer keys, and the Cutover section extended with the mailer keys and the `mailgroup` pair. Orders stats notice: `orders_stats` reports setup-needed when `default_currency` is empty (plan 03 wrote the callout; confirm it reads the settings row at request time, not at boot) and the settings smoke asserts that after the PUT with PLN the orders list header partial no longer carries the setup-needed callout.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -n "^## Settings\|^## Mail" ../sm-paymentgateway-plugin/README.md | grep -c . | grep -qx 2 && grep -n "order.underway" ../sm-paymentgateway-plugin/README.md | grep -q .</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails; the README lacks the Settings or Mail section or the recorded underway fix.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestPGSettingsSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestPGSettingsSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the orders header partial still shows the setup-needed callout after the currency was saved.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "mailgroup" ../sm-paymentgateway-plugin/README.md` prints at least one line and `grep -n "{hash}" ../sm-paymentgateway-plugin/README.md` prints at least one line.
|
||||
- `! grep -rniE "horoskopia|fonoteka|grzyby" ../sm-paymentgateway-plugin --include='*.go' --include='*.md' --include='*.yaml' --include='*.htm' --include='*.json' --include='*.php' --include='*.sql'` exits 0 (the committed fixtures, the generator script and the mail templates are included; no consuming application is named anywhere in the plugin).
|
||||
</acceptance_criteria>
|
||||
<done>An operator can read how to configure and cut over the settings and mail, and the admin stops saying "orders are paused" once the currency is set.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser → settings PUT | authenticated administrators with access_settings; HTML and e-mail addresses are untrusted |
|
||||
| events → mailers → SMTP | order data and user-supplied strings enter mail bodies |
|
||||
| PHP settings export → import command | operator-supplied JSON |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-23 | Tampering (stored XSS in mail) | manual_transfer_bank_details in bank-transfer mails and the API | high | mitigate | richeditor on settings sanitized by seaglass (plan 01 F3, PG-008); postcard rejects scripts and handlers in rendered HTML; tests: TestPGSettingsSmoke (Task 1: bank details carrying a script element are stored sanitized and answer a notice) and TestBankDetailsSanitized (plan 07 Task 3, PG-008: allowed tags kept, scripts dropped) |
|
||||
| T-18-24 | Spoofing / Information Disclosure | mail recipients | medium | mitigate | the recipient is derived from the order's user or details e-mail with the guest and anonymous rules, never from request input; staff recipients come from settings; e-mail rules validate mailer_from_email and admin_email |
|
||||
| T-18-25 | Tampering (header injection) | mail subjects and variables | medium | mitigate | postcard's header injection checks; variables are rendered through templates, never concatenated into headers |
|
||||
| T-18-26 | Repudiation / Tampering | mail for rolled-back or replayed transitions | medium | mitigate | AfterCommit on the event's transaction; the mailer never sends inside the transaction; TestMailersMatrixSmoke rollback case |
|
||||
| T-18-27 | Elevation of Privilege | settings pages | medium | mitigate | both items require `golem15.paymentgateway.access_settings`; cabana checks it on GET and PUT; TestMailerSettingsSmoke 403 case |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed in this plan |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: Docker-free suite, 27 switches, richeditor present, the English paid template; settings, mailer and mail tracer smokes on a Docker host.
|
||||
- Task 2: Docker-free suite, 54 template files and names; the mailer matrix and the import smoke on a Docker host.
|
||||
- Task 3: Docker-free suite and README greps; the settings smoke on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-08: MailerSettings is a settings page; the SetUp wizard is a required setting; the templates carry the PHP codes in PL and EN.
|
||||
- SC-3 (settings and mail half): every PHP mail is sent under its switch, after commit, to the right recipients.
|
||||
- `go vet ./...` and `go test -short ./...` in the plugin stay green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-04-SUMMARY.md` when done, with the commits, the two settings codes and their column lists, the template count, the Docker suites run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
300
.planning/phases/18-payment-gateway/18-05-PLAN.md
Normal file
300
.planning/phases/18-payment-gateway/18-05-PLAN.md
Normal file
@@ -0,0 +1,300 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["18-01", "18-02", "18-04"]
|
||||
files_modified:
|
||||
- ../sm-pgstripe-plugin/.gitignore
|
||||
- ../sm-pgstripe-plugin/go.mod
|
||||
- ../sm-pgstripe-plugin/go.sum
|
||||
- ../sm-pgstripe-plugin/go.work
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/plugin.go
|
||||
- ../sm-pgstripe-plugin/admin.go
|
||||
- ../sm-pgstripe-plugin/client.go
|
||||
- ../sm-pgstripe-plugin/operator.go
|
||||
- ../sm-pgstripe-plugin/webhook.go
|
||||
- ../sm-pgstripe-plugin/browser_return.go
|
||||
- ../sm-pgstripe-plugin/config/config.yaml
|
||||
- ../sm-pgstripe-plugin/models/registry.go
|
||||
- ../sm-pgstripe-plugin/models/settings.go
|
||||
- ../sm-pgstripe-plugin/models/settings/fields.yaml
|
||||
- ../sm-pgstripe-plugin/updates/registry.go
|
||||
- ../sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go
|
||||
- ../sm-pgstripe-plugin/lang/en/lang.yaml
|
||||
- ../sm-pgstripe-plugin/lang/pl/lang.yaml
|
||||
- ../sm-pgstripe-plugin/console/import_winter.go
|
||||
- ../sm-pgstripe-plugin/scripts/winter-export-settings.sql
|
||||
- ../sm-pgstripe-plugin/testmain_test.go
|
||||
- ../sm-pgstripe-plugin/host_test.go
|
||||
- ../sm-pgstripe-plugin/stripefake_test.go
|
||||
- ../sm-pgstripe-plugin/fixture_plugin_test.go
|
||||
- ../sm-pgstripe-plugin/webhook_test.go
|
||||
- ../sm-pgstripe-plugin/settings_smoke_test.go
|
||||
autonomous: true
|
||||
requirements: [SC-2, SC-4, D-02, D-03, D-04, D-12, D-13, D-14, D-16, D-17]
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 260000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-12 and SC-2: `sm-pgstripe-plugin` (module `git.golem15.com/golem15/sm-pgstripe-plugin`, package `pgstripe`, plugin ID `golem15.pgstripe`, Requires `golem15.paymentgateway`) depends on `github.com/stripe/stripe-go/v87` v87.0.0 and implements `operators.HasOperators` with one operator whose Code is the PHP `golem15.pgstripe::lang.operators.stripe`, Mode `api`, registered at boot through the paymentgateway registry; the client is built per call with `stripe.NewClient` and the mode's secret, with a backend override seam tests point at an httptest server; no test calls the network."
|
||||
- "D-16 and D-17: in Checkout mode (the default, from the `checkout_mode` setting with a per-order `stripe_checkout_mode` Details parameter override) `Purchase` creates a Checkout Session (mode payment, one line item at `TotalGross().Minor()` in the lower-case ISO code, `success_url` = the plugin's browser-return URL carrying `pgUuid`, `cancel_url` = the order's return URL, `client_reference_id` and `metadata.payment_uuid` = the payment UUID, also on `payment_intent_data.metadata`), stores `stripe_checkout_session_id` in `payments.data`, applies Send and answers `{redirect_url}`; in PaymentIntent mode it creates a PaymentIntent with `Amount = Minor()` (never `getAmountBasic() * 100`), maps `requires_action` to RequiresAction with `client_secret`, `payment_intent_id`, `payment_uuid` and the mode's publishable key, `succeeded` to Send plus Accept, `processing` to Send, and `requires_payment_method` or `canceled` to Send plus Reject with a logged error."
|
||||
- "D-14 (F-3, F-4): a signed POST on `/_paymentgateway/Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl` is verified with `webhook.ValidatePayload` against the mode's signing secret before any lookup; a missing `Stripe-Signature` header, a missing secret or an invalid signature is refused with 403 and a log line; the event is decoded into a minimal local struct (id, type, api_version, data.object id, object, metadata.payment_uuid, client_reference_id, payment_status, payment_intent, status), so an event from an older API release train (`2025-02-24.acacia`) is accepted; `checkout.session.completed` with `payment_status` paid, `checkout.session.async_payment_succeeded` and `payment_intent.succeeded` accept the payment, `checkout.session.async_payment_failed` and `payment_intent.payment_failed` reject it, `completed` with `unpaid` and every other type answer 200 without a change; a replayed event is a 200 no-op and two concurrent identical events produce exactly one Accept."
|
||||
- "D-14 (F-3): the unsigned browser return (GET with `pgUuid`) never changes state from its input: the operator retrieves the stored Checkout Session or PaymentIntent from Stripe, accepts only when Stripe reports paid or succeeded, and always redirects to the order's return URL."
|
||||
- "D-13: `golem15_pgstripe_settings` is a singleton with `enabled`, `test_mode`, `checkout_mode`, `publishable_key`, `test_publishable_key` as plain columns and `secret_key`, `test_secret_key`, `webhook_secret`, `test_webhook_secret` as `lagoon.Encrypted` write-only password fields; the model's `SecretFallbacks` map the four secrets to the config keys `golem15.pgstripe.secret_key`, `.test_secret_key`, `.webhook_secret`, `.test_webhook_secret` (`SUMMER_GOLEM15__PGSTRIPE__*`), used when the column is empty; `test_mode` selects which key pair and signing secret are used; the settings GET never includes a secret value; `pgstripe:import-winter` maps the PHP `stripe.*` keys, decrypting Laravel payloads with `lagoon.DecryptLaravelPayload` and passing plaintext through."
|
||||
- "SC-4, D-03 and D-04: a host test inside this repository mounts `golem15.user`, `golem15.paymentgateway`, `golem15.pgstripe` and a fake consumer `acme.books` on testcontainers Postgres with an httptest Stripe fake behind the backend override and proves (a) `Orders.Make` → Stripe operator (Checkout and PaymentIntent) → signed notification → Payment Accepted → Order Paid with `successful_payment_id` → one `OrderPaid` event → the paid mail in the memory driver → a replayed event changes nothing, and (b) the consumer creates its own Checkout Session, receives its own webhook and moves the order to Paid through `Payments.RecordExternal`, `Send` and `Accept` with the same assertions; wrong secret, missing header, missing secret and an older `api_version` are covered; production sources never name the fixture plugin."
|
||||
- "UI-SPEC P10: the Stripe settings page (`pgstripe`, permission `golem15.paymentgateway.access_settings`, category `settings.category`, icon credit-card) has the tabs General (enabled, test_mode with its comment, checkout_mode dropdown checkout/payment_intent with its comment), Live keys (publishable_key text with spellcheck false and autocomplete off, secret_key and webhook_secret password with the webhook comment) and Test keys (the three test fields), and the PHP `_stripe_info.htm` instructions live in the README."
|
||||
- "UI considerations (Stripe settings form, covered; the UI-SPEC `Settings forms` rows): unset fields show their defaults (`enabled` off, `test_mode` off, `checkout_mode` checkout) or are empty (the publishable keys); the existing SettingsFormView skeleton and load error apply; a 422 (a `checkout_mode` outside its two values) shows inline errors and the tab error chip (F4); the test-mode, checkout-mode and webhook comments wrap under their fields; the switches without a `trigger` say in their comment when they apply (`test_mode` names the key pair it selects); the four secrets show the stored, env or empty status line and never their value (F2); the required-currency 422 copy belongs to the payment gateway page (plan 04), not this one."
|
||||
artifacts:
|
||||
- path: "../sm-pgstripe-plugin/operator.go"
|
||||
provides: "the Stripe operator (Checkout and PaymentIntent modes, refund, notification handling)"
|
||||
contains: "golem15.pgstripe::lang.operators.stripe"
|
||||
- path: "../sm-pgstripe-plugin/webhook.go"
|
||||
provides: "signature-only verification and the minimal event decode"
|
||||
contains: "webhook.ValidatePayload"
|
||||
- path: "../sm-pgstripe-plugin/client.go"
|
||||
provides: "stripe.NewClient factory with the backend override seam"
|
||||
contains: "NewBackendsWithConfig"
|
||||
- path: "../sm-pgstripe-plugin/models/settings.go"
|
||||
provides: "Stripe settings singleton with Encrypted secrets and SecretFallbacks"
|
||||
contains: "SecretFallbacks"
|
||||
- path: "../sm-pgstripe-plugin/host_test.go"
|
||||
provides: "the D-03/D-04 proof host"
|
||||
contains: "func TestHostStripeFlow"
|
||||
- path: "../sm-pgstripe-plugin/console/import_winter.go"
|
||||
provides: "pgstripe:import-winter with Laravel decrypt"
|
||||
contains: "DecryptLaravelPayload"
|
||||
key_links:
|
||||
- from: "../sm-pgstripe-plugin/plugin.go"
|
||||
to: "../sm-paymentgateway-plugin/operators/operator.go"
|
||||
via: "PaymentOperators() returns the Stripe operator (operators.HasOperators)"
|
||||
pattern: "PaymentOperators"
|
||||
- from: "../sm-pgstripe-plugin/operator.go"
|
||||
to: "../sm-pgstripe-plugin/webhook.go"
|
||||
via: "VerifyNotification validates the raw body before HandleNotification decodes it"
|
||||
pattern: "ValidatePayload"
|
||||
- from: "../sm-pgstripe-plugin/host_test.go"
|
||||
to: "../sm-paymentgateway-plugin/routes.go"
|
||||
via: "signed POST to /_paymentgateway/<base64 stripe code>"
|
||||
pattern: "Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl"
|
||||
- from: "../sm-pgstripe-plugin/models/settings.go"
|
||||
to: "summercms modules/cabana/settings_secret.go"
|
||||
via: "password fields on lagoon.Encrypted columns with SecretFallbacks"
|
||||
pattern: "lagoon\\.Encrypted"
|
||||
prohibitions:
|
||||
- statement: "Unsigned or unverified Stripe input never changes a payment state; the browser return only redirects unless Stripe itself, retrieved server-side, reports the payment paid."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Secret keys and signing secrets are never returned by the settings projection, never logged, never placed in a response body, a redirect URL or an error message; publishable keys are the only key material the client receives."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The Stripe amount is always computed server-side from the stored cart as scale-4 money converted to minor units; the client never supplies an amount."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No test calls api.stripe.com or any network; the SDK's backend URL is overridden to an httptest server in every test, and production never names a consuming application or the fixture plugin."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 5 of 7, wave 5, new repository `../sm-pgstripe-plugin`): the Stripe operator in both modes (D-16, D-17), signature-only webhook handling (D-14, F-3, F-4), encrypted write-only settings with env fallback (D-13), the Winter import, and the test-only proof host (D-03) that exercises both pay paths (D-04). It runs after plan 04 so the OrderPaid mail assertion has mailers to observe, and reads `sm-paymentgateway-plugin` through `go.work`.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator. `must_haves` derive from SC-2, SC-4, D-02 (pgstripe at `8b47457`), D-03, D-04, D-12 to D-17, RESEARCH F-3, F-4, Patterns 6, 8 and 13, Pitfalls 2, 3 and 9, the Security Domain, and the UI-SPEC P10 contract. Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- Mode selection (D-16, RESEARCH A6): the `checkout_mode` setting (default `checkout`) with a per-order override through the Details parameter `stripe_checkout_mode` (`checkout` or `payment_intent`).
|
||||
- Signature verification is `webhook.ValidatePayload` plus a minimal local decode (RESEARCH A4, F-4); the SDK's full event constructor is not used because it rejects events from other API release trains. This makes the dashboard endpoint's API version irrelevant, which the README says.
|
||||
- The browser return (`?pgUuid=`) is handled by the same operator through `VerifyNotification` returning true for a GET carrying `pgUuid` and no signature header, and `HandleNotification` branching to a server-side retrieve plus redirect; a signed event takes the event branch. PG-003 holds because the return branch never trusts the request.
|
||||
- The Checkout `success_url` is the plugin's own browser-return URL (so the return handler can re-check), and `cancel_url` is the order's return URL (PHP had no cancel URL; documented).
|
||||
- The host test lives in this repository (RESEARCH A7) because it can import both plugins without a module cycle; the fake consumer is registered from a `_test.go` file and a production-source guard refuses its name.
|
||||
- Env fallback covers all four secrets (PHP covered only the webhook secret); the config keys follow the project convention `golem15.pgstripe.<column>`.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Repository `../sm-pgstripe-plugin` (module `git.golem15.com/golem15/sm-pgstripe-plugin`, package `pgstripe`, plugin ID `golem15.pgstripe`), branch `main`, no remote yet.
|
||||
- Root: `Plugin` (`ID`, `Requires`, `Register`, `Boot`, `PaymentOperators`, `Migrations`, `Models`, `LangFS`, `Settings`, `Commands`), `Operator` (`Code`, `Mode`, `LogoPath`, `AllowedCurrencies`, `RequiredFields`, `Enabled`, `Purchase`, `Refund`, `VerifyNotification`, `ExtractUUID`, `HandleNotification`), `OperatorCode`, `ClientFactory`, `BackendOverride`, `NewClient`, `ModeKeys`, `VerifySignature`, `DecodeEvent`, `MinimalEvent`, `CheckoutModeSetting`, `CheckoutModeParam`, the data keys `stripe_checkout_session_id`, `payment_intent_id`, `charge_id`, `is_paid_in_stripe`.
|
||||
- `models.Settings` on `golem15_pgstripe_settings` with `SecretFallbacks`; migration `202610130001_create_golem15_pgstripe_settings`; `models/settings/fields.yaml` (P10); `lang/{en,pl}/lang.yaml` (`operators.stripe`, `settings.*`).
|
||||
- `console`: `pgstripe:import-winter`; `scripts/winter-export-settings.sql`.
|
||||
- Config keys: `golem15.pgstripe.secret_key`, `test_secret_key`, `webhook_secret`, `test_webhook_secret`.
|
||||
- Tests: `TestHostStripeFlow`, `TestStripeWebhook`, `TestStripeSettingsSmoke`, `TestStripeImportWinterSmoke`, `TestFixtureAbsentFromProduction`; helpers `newStripeFake`, `bootHost`, the `acme.books` fixture plugin.
|
||||
|
||||
<objective>
|
||||
Ship the Stripe operator as a second shared plugin on the official SDK (D-12): hosted Checkout and PaymentIntents (D-16, D-17), signature-verified webhooks that accept any API release train (D-14), refunds, a safe browser return, encrypted write-only settings with env fallback and a Winter import (D-13), and the proof host that replays the order-create, Stripe-pay and order-paid flow in both the operator and the consumer-driven shape (SC-4, D-03, D-04).
|
||||
|
||||
Purpose: SC-4 is the acceptance proof of the whole phase; the consuming shop plugin's own checkout and webhook code can be dropped once path (a) works, and path (b) proves the API it uses today.
|
||||
Scope boundary (D-01): the subscription gateway, the subscription webhook job and the `/webhook/stripe/subscriptions` route are Phase 18.1 and are not ported here; this plugin ships payments only, and 18.1 adds its subscription columns to the same settings table later.
|
||||
Output: a new plugin repository with the operator, webhook, settings, import, README and the host test.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-UI-SPEC.md
|
||||
@.planning/phases/18-payment-gateway/18-02-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-04-SUMMARY.md
|
||||
|
||||
The frozen PHP source is `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe` at `8b47457` (D-02; the working tree differs only in the subscription gateway, which is Phase 18.1). Skip `classes/StripeSubscriptionGateway.php`, `jobs/ProcessStripeSubscriptionWebhook.php`, `http/` (D-01). Stage files by explicit path; no co-author tags; no push. Docker-backed tests skip under `-short`; record NOT RUN when no daemon answers. No test may reach the network: every `stripe.NewClient` in a test carries the httptest backend.
|
||||
|
||||
<interfaces>
|
||||
Consumed from plan 02 (`git.golem15.com/golem15/sm-paymentgateway-plugin`): `operators.Operator`, `operators.HasOperators`, `operators.PaymentContext` (Payment, Order, Cart, Details, Settings, Data), `operators.Response` and the status constants, `support.Urlize`, `support.DeUrlize`, `payments.Service` (`Create`, `Purchase`, `Send`, `Accept`, `Reject`, `RecordExternal`), `orders.Service`, `money.Money.Minor()`, `cart.Details` parameters, the route `/_paymentgateway/{op}`.
|
||||
|
||||
stripe-go v87.0.0 (RESEARCH Code Examples, executed): `stripe.NewClient(key, stripe.WithBackends(stripe.NewBackendsWithConfig(&stripe.BackendConfig{URL, HTTPClient, MaxNetworkRetries})))`, `sc.V1PaymentIntents.Create(ctx, &stripe.PaymentIntentCreateParams{Amount, Currency, Metadata, PaymentMethod, Confirm})`, `sc.V1PaymentIntents.Retrieve`, `sc.V1CheckoutSessions.Create(ctx, &stripe.CheckoutSessionCreateParams{Mode, SuccessURL, CancelURL, ClientReferenceID, LineItems, Metadata, PaymentIntentData})`, `sc.V1CheckoutSessions.Retrieve`, `sc.V1Refunds.Create(ctx, &stripe.RefundCreateParams{PaymentIntent})`, `webhook.ValidatePayload(payload, header, secret)`, `webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{Payload, Secret, Timestamp})`, `stripe.APIVersion = "2026-09-30.endive"`.
|
||||
|
||||
Framework seams: `lagoon.Encrypted`, `lagoon.NewEncrypted`, `lagoon.DecryptLaravelPayload(payload, appKey)`, `cabana.SecretFallbacks` (plan 01), `pact.SettingsItem`, `app.Config.String`, `app.Publish`/`app.Lookup`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the Stripe operator takes a hosted Checkout payment end to end on the proof host: Make, Purchase to a Checkout Session, a signed completed event, Accepted, Paid, OrderPaid, the mail, and a replay that changes nothing</name>
|
||||
<reversibility rating="costly">D-16 and D-13: the Checkout mode, the settings columns and their config keys, and the `payments.data` keys become what consuming applications and the Stripe dashboards rely on.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/.gitignore, ../sm-pgstripe-plugin/go.mod, ../sm-pgstripe-plugin/go.sum, ../sm-pgstripe-plugin/go.work, ../sm-pgstripe-plugin/plugin.go, ../sm-pgstripe-plugin/admin.go, ../sm-pgstripe-plugin/client.go, ../sm-pgstripe-plugin/operator.go, ../sm-pgstripe-plugin/webhook.go, ../sm-pgstripe-plugin/config/config.yaml, ../sm-pgstripe-plugin/models/registry.go, ../sm-pgstripe-plugin/models/settings.go, ../sm-pgstripe-plugin/models/settings/fields.yaml, ../sm-pgstripe-plugin/updates/registry.go, ../sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go, ../sm-pgstripe-plugin/lang/en/lang.yaml, ../sm-pgstripe-plugin/lang/pl/lang.yaml, ../sm-pgstripe-plugin/testmain_test.go, ../sm-pgstripe-plugin/host_test.go, ../sm-pgstripe-plugin/stripefake_test.go, ../sm-pgstripe-plugin/fixture_plugin_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/go.mod, go.work, plugin.go, boot.go, operators/operator.go, operators/registry.go, operators/response.go, payments/service.go, payments/notification.go, routes.go, harness_test.go, tracer_test.go (plan 02; the exact service and operator signatures), ../sm-paymentgateway-plugin/events/mailers.go (plan 04; the OrderPaid mail), ../sm-journal-plugin/integration_test.go (:17-35, :195-230 assertFixtureAbsentFromProduction), ../sm-journal-plugin/admin_harness_test.go (:173-238), ../sm-summercmsio-app/plugins/golem15/user/models/oauth_identity.go (:15-16 Encrypted columns), summercms modules/lagoon/encrypted.go, modules/cabana/settings_secret.go (plan 01: SecretFallbacks, the states), modules/cabana/testdata/payments/models/vault/fields.yaml (plan 01 tabbed secret fixture), the stripe-go v87.0.0 module cache (`go env GOMODCACHE`/github.com/stripe/stripe-go/v87@v87.0.0: stripe_client.go NewClient and WithBackends, stripe.go BackendConfig, checkout_session.go params and the Session struct, paymentintent.go, webhook/client.go ValidatePayload and GenerateTestSignedPayload), the frozen tree: operators/Stripe.php (:26-60 constants, :60-130 sendPurchaseRequest, :132-215 intent handling and the pgUuid redirect, :215-250 verifyNotification and processNotification, :267-310 refund and the UUID extraction), classes/StripeSettings.php (:38-95 readEncryptedSetting, getWebhookSecret, getApiKey), Plugin.php (:50-110 boot: the eloquent saving encryption hook, :119-205 the injected settings fields and tabs), config/config.php, lang/en/lang.php, lang/pl/lang.php, partials/_stripe_info.htm, .planning/phases/18-payment-gateway/18-RESEARCH.md (F-3, F-4, Pattern 6, Pattern 8 pgstripe design, Pattern 13, Code Examples, Pitfalls 2, 3, 9), .planning/phases/18-payment-gateway/18-UI-SPEC.md (P10, Copywriting Stripe settings rows)</read_first>
|
||||
<action>Per D-12, D-13, D-14, D-16, D-17, SC-2 and SC-4, one hosted Checkout payment through every layer.
|
||||
|
||||
Repository: create `../sm-pgstripe-plugin` (`git init -b main`, `.gitignore`), `go.mod` (module `git.golem15.com/golem15/sm-pgstripe-plugin`, go 1.27.0, requires `github.com/stripe/stripe-go/v87 v87.0.0`, summercms at the highest `v0.1.*` tag at execution time (`git -C ../summercms.go tag -l 'v0.1.*' --sort=-v:refname | head -1`; v0.1.24 at planning time) or the tag plan 01 Task 4 pushed, whichever is higher, the same pin sm-paymentgateway-plugin's go.mod carries (the `go.work` replace makes it inert locally), sm-paymentgateway-plugin v0.0.0 placeholder resolved by replace, sm-user-plugin v0.1.1 for the host test, gormigrate, goccy/go-yaml, pgx, testcontainers and its postgres module, gorm; `go mod tidy`; application code never imports `gopkg.in/yaml.v3`), `go.work` (`use .`, replace summercms `../summercms.go`, replace sm-paymentgateway-plugin `../sm-paymentgateway-plugin`, replace sm-user-plugin `../sm-summercmsio-app/plugins/golem15/user`), `config/config.yaml` documenting the four secret config keys.
|
||||
|
||||
Settings (models/settings.go, updates, fields.yaml, lang, admin.go): `Settings` on `golem15_pgstripe_settings` (id INTEGER PK seeded 1; enabled BOOLEAN default false; test_mode BOOLEAN default false; checkout_mode VARCHAR(20) default 'checkout'; publishable_key, test_publishable_key TEXT NULL; secret_key, test_secret_key, webhook_secret, test_webhook_secret TEXT NULL typed `lagoon.Encrypted` with `json:"-"`), Fillable all nine, Rules (`checkout_mode: required|in:checkout,payment_intent`), `SecretFallbacks()` returning the four config keys, `GetCheckoutModeOptions()`; fields.yaml per UI-SPEC P10 with `tab` (General, `settings.tab_live`, `settings.tab_test`), `type: password` for the four secrets, the webhook comment on both webhook fields, `attributes: {spellcheck: false, autocomplete: off}` on the publishable keys; lang en and pl with `operators.stripe`, `settings.label`, `settings.description`, `settings.category`, `settings.enabled`, `settings.testMode`, `settings.testMode_comment`, `settings.checkout_mode` and its two option labels and comment, `settings.publishableApiKey`, `settings.secretApiKey`, `settings.webhookSecret`, `settings.testApiKey`, `settings.secrettestApiKey`, `settings.testWebhookSecret`, `settings.tab_live`, `settings.tab_test`, `settings.webhook_comment` (the UI-SPEC copy with the base64 endpoint and the three event names); admin.go embeds `models/settings/fields.yaml` and returns the `pgstripe` SettingsItem (Label `golem15.pgstripe::lang.settings.label`, Category `golem15.pgstripe::lang.settings.category`, Icon credit-card, Permissions `golem15.paymentgateway.access_settings`).
|
||||
|
||||
Client (client.go): `ModeKeys(ctx, app, settings) (secret, publishable, webhookSecret string, mode string)` picks the live or test triple by `test_mode`, reading each `lagoon.Encrypted` column and falling back to `app.Config.String(<key>)` when empty; `ClientFactory` builds `stripe.NewClient(secret, stripe.WithBackends(stripe.NewBackendsWithConfig(&stripe.BackendConfig{URL, HTTPClient, MaxNetworkRetries: 0 in tests})))` with `URL` nil in production and taken from a published `*BackendOverride{URL, HTTPClient}` when one exists (tests publish it before activation); never a package-level key.
|
||||
|
||||
Operator (operator.go, webhook.go): `Operator` with `Code() = "golem15.pgstripe::lang.operators.stripe"` (exported `OperatorCode`), Mode api, AllowedCurrencies `["*"]`, RequiredFields `["email"]`, `Enabled` from `settings.enabled`; `Purchase` in Checkout mode (this task; PaymentIntent mode is Task 2): mode from `Details` parameter `stripe_checkout_mode` else the setting; create the session with `Mode` payment, one line item (`PriceData` currency lower-case ISO, `UnitAmount` = `cart.TotalGross().Minor()`, product name = the order description or the first item description), `SuccessURL` = `{app.url}/_paymentgateway/{Urlize(OperatorCode)}?pgUuid={base64 uuid}`, `CancelURL` = `details.return_url`, `ClientReferenceID` = the payment UUID, `Metadata{"payment_uuid": uuid, "email": details.email}` and `PaymentIntentData.Metadata{"payment_uuid": uuid}`; store `stripe_checkout_session_id` (and `payment_intent_id` when the session carries one) in `payments.data`; return `Response{Status: StatusPending, RedirectURL: session.URL}` (the paymentgateway service applies Send). `VerifyNotification`: true only when the `Stripe-Signature` header is present, the mode's signing secret is non-empty (log and false otherwise) and `webhook.ValidatePayload(body, header, secret)` returns nil (log the error and return false otherwise); the browser-return branch is Task 2. `ExtractUUID`: `DecodeEvent(body)` into `MinimalEvent{ID, Type, APIVersion string; Data struct{ Object struct{ ID, Object string; Metadata map[string]string; ClientReferenceID, PaymentStatus, PaymentIntent, Status string } }}` and return `metadata.payment_uuid` else `client_reference_id`. `HandleNotification`: `checkout.session.completed` with `payment_status == "paid"` → `payments.Accept` when `Can(Accept)` (else a 200 no-op), the other event types in Task 2; the returned handler writes 200 `{"received":true}` with `wire.WriteJSON`.
|
||||
|
||||
Plugin (plugin.go): ID `golem15.pgstripe`, Requires `golem15.paymentgateway`, `PaymentOperators()` returning the operator (which holds the app for settings and the factory), the compile-time assertions for party.Plugin, operators.HasOperators, pact.HasMigrations, pact.HasModels, pact.HasLang, pact.HasSettings, pact.HasCommands.
|
||||
|
||||
Proof host (testmain_test.go, host_test.go, stripefake_test.go, fixture_plugin_test.go): TestMain like sitemap's; `newStripeFake(t)` is an httptest server answering `POST /v1/checkout/sessions` (records the form body; answers `{id: "cs_test_1", object: "checkout.session", url: "https://checkout.stripe.test/c/pay/cs_test_1", payment_intent: "pi_test_1", payment_status: "unpaid", metadata, client_reference_id}`), `GET /v1/checkout/sessions/{id}`, `POST /v1/payment_intents` and `GET /v1/payment_intents/{id}` (Task 2 uses them), `POST /v1/refunds`, each recording the request and the `Stripe-Version` header; `bootHost(t)` publishes `&pgstripe.BackendOverride{URL: fake.URL, HTTPClient: fake.Client()}`, activates `golem15.user`, `golem15.paymentgateway`, `golem15.pgstripe`, `acme.books`, with `SUMMER_APP__KEY`, the JWT secret, the hashids salt and `mail.driver: memory`, migrates and assembles the router; the `acme.books` fixture plugin (registered with `party.Register` in `fixture_plugin_test.go`, Requires `golem15.paymentgateway`) exposes `Purchase(ctx, tx, user) (*models.Order, *models.Payment)` building `NewCart("PLN")` with one item (`FromMinor(3984, "PLN")`, tax 23) and Details for `OperatorCode` with an e-mail and `https://shop.example.test/return`, calling `Orders.Make`, `Payments.Create` and `Payments.Purchase`; `TestFixtureAbsentFromProduction` copies Journal's guard (no production source names `acme.books` or imports the user plugin). TestHostStripeFlow subtest `checkout_operator_path` (Postgres): settings `enabled`, `test_mode`, `checkout_mode` checkout, `test_secret_key` `sk_test_fake` and `test_webhook_secret` `whsec_test` stored encrypted, paymentgateway settings with PLN, orders on, `mailer_order_paid` on and `order_url_template`; Purchase answers `RedirectURL` `https://checkout.stripe.test/c/pay/cs_test_1`, the payment is Sent with `stripe_checkout_session_id` `cs_test_1`, the order AwaitingPayment, and the fake saw `/v1/checkout/sessions` with `line_items[0][price_data][unit_amount]=3984`, `currency=pln`, `metadata[payment_uuid]` and `client_reference_id` equal to the UUID and a `Stripe-Version` header; a `checkout.session.completed` payload (`payment_status` paid, `metadata.payment_uuid`) signed with `webhook.GenerateTestSignedPayload` and `whsec_test`, POSTed to `/_paymentgateway/Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl`, answers 200; the payment is Accepted, the order Paid with `successful_payment_id`, an `OrderPaid` listener fired once, the memory driver holds one message to the user with template `golem15.paymentgateway::mail.order.paid-en` (English user); the same signed body POSTed again answers 200 and nothing changes (states, `updated_at`, listener count, message count).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && grep -q 'github.com/stripe/stripe-go/v87 v87.0.0' ../sm-pgstripe-plugin/go.mod && ! grep -rn 'gopkg.in/yaml.v3' ../sm-pgstripe-plugin --include='*.go' | grep -q . && grep -c "type: password" ../sm-pgstripe-plugin/models/settings/fields.yaml | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (TestFixtureAbsentFromProduction found the fixture name in a production source, or the settings YAML is refused); go.mod lacks the pinned SDK; application code imports the archived YAML library; the settings form does not declare exactly four password fields.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$/^checkout_operator_path$' -v . 2>&1 | grep -- '^ --- PASS: TestHostStripeFlow/checkout_operator_path'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the session was created with the wrong amount, currency or metadata, the signed event did not accept the payment and pay the order, OrderPaid fired other than once, the paid mail is missing, or the replay changed something.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-pgstripe-plugin rev-parse --abbrev-ref HEAD` prints main and `git -C ../sm-pgstripe-plugin remote` prints nothing.
|
||||
- `grep -n "golem15.pgstripe::lang.operators.stripe" ../sm-pgstripe-plugin/operator.go` prints at least one line and `grep -n "webhook.ValidatePayload" ../sm-pgstripe-plugin/webhook.go` prints at least one line.
|
||||
- `! grep -rn "ConstructEvent" ../sm-pgstripe-plugin --include='*.go' --exclude='*_test.go' | grep -q .` exits 0.
|
||||
- `grep -n "lagoon.Encrypted" ../sm-pgstripe-plugin/models/settings.go | grep -c . | grep -qx 4` exits 0 and `grep -n "func (Settings) SecretFallbacks\|func (s Settings) SecretFallbacks" ../sm-pgstripe-plugin/models/settings.go` prints one line.
|
||||
- `grep -n "Minor()" ../sm-pgstripe-plugin/operator.go` prints at least one line and `! grep -n "\* *100\b" ../sm-pgstripe-plugin/operator.go | grep -q .` exits 0.
|
||||
- `grep -n "acme.books" ../sm-pgstripe-plugin/fixture_plugin_test.go` prints at least one line and `! grep -rn "acme.books" ../sm-pgstripe-plugin --include='*.go' --exclude='*_test.go' | grep -q .` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>A hosted Checkout payment for a plugin-created order is accepted from a signed Stripe event, pays the order, fires OrderPaid once and mails the customer, on the real stack with a fake Stripe.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: PaymentIntent mode, the full event table, refunds, the safe browser return, mode and key selection, and the complete proof host: PaymentIntent path, consumer-driven path (b), wrong secret, missing header, missing secret, older API version, replay and race</name>
|
||||
<reversibility rating="costly">D-17: the process response shapes (`redirect_url` and the `requires_action` bundle) are the contract the customer API and the Nuxt layer consume.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/operator.go, ../sm-pgstripe-plugin/webhook.go, ../sm-pgstripe-plugin/browser_return.go, ../sm-pgstripe-plugin/client.go, ../sm-pgstripe-plugin/host_test.go, ../sm-pgstripe-plugin/stripefake_test.go, ../sm-pgstripe-plugin/fixture_plugin_test.go, ../sm-pgstripe-plugin/webhook_test.go</files>
|
||||
<read_first>../sm-pgstripe-plugin/operator.go, webhook.go, client.go, host_test.go, stripefake_test.go (Task 1), the frozen tree: operators/Stripe.php (:60-215 the intent flow and status handling, :267-310 refund), tests/security/AuthenticationTest.php (the four signature cases), tests/security/DataHandlingTest.php (encrypted at rest, lazy decrypt, SDK pin), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/controllers/BookApiController.php (:224-310 pay: the consumer's own Checkout Session and success URL), classes/services/BookFulfilmentService.php (:98-320 fulfil and transitionToPaid, the lock and idempotency), controllers/HoroscopeStripeWebhookController.php (two signing secrets, idempotency), ../sm-paymentgateway-plugin/payments/service.go (RecordExternal, Send, Accept, Reject, Refund), ../sm-paymentgateway-plugin/payments/notification.go (the row lock), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 6 status map and event table, F-3, F-4, Pitfall 9, the probe output for the older api_version case)</read_first>
|
||||
<action>Per D-04 (a) and (b), D-14, D-16, D-17 and SC-4.
|
||||
|
||||
PaymentIntent mode (operator.go): when the resolved mode is `payment_intent`, create a PaymentIntent with `Amount` = `TotalGross().Minor()`, `Currency` lower-case ISO, `Metadata{"payment_uuid", "email"}` and, when the Details parameter `payment_method_id` is present, `PaymentMethod` plus `Confirm` true (PHP sendPurchaseRequest); store `payment_intent_id` in `payments.data`; map the status: `requires_action` → `Response{Status: StatusRequiresAction, ClientSecret, PaymentIntentID, PublishableKey: the mode's publishable key, Data{"payment_uuid"}}` (the service applies Send); `succeeded` → StatusComplete (Send plus Accept) with `is_paid_in_stripe` true and `charge_id` when present; `processing` → StatusPending; `requires_payment_method` or `canceled` → StatusError with the Stripe message (Send plus Reject, logged). `Refund`: `V1Refunds.Create` with `PaymentIntent` from `payment_intent_id` (or the Checkout Session's `payment_intent`, retrieved when only the session id is stored) → StatusComplete, or StatusError with the Stripe error.
|
||||
|
||||
Events (webhook.go): extend `HandleNotification` to the full table: `checkout.session.completed` paid → Accept, unpaid → no-op; `checkout.session.async_payment_succeeded` → Accept; `checkout.session.async_payment_failed` → Reject; `payment_intent.succeeded` → Accept (also records `charge_id` when the object carries `latest_charge`); `payment_intent.payment_failed` → Reject; any other type → 200 no-op; every Accept and Reject only when `Can`; the event id is logged (never the body).
|
||||
|
||||
Browser return (browser_return.go, operator.go): `VerifyNotification` also returns true for a GET that carries `pgUuid` and no `Stripe-Signature` header (the return branch); `ExtractUUID` base64-decodes `pgUuid` in that case; `HandleNotification` on the return branch retrieves the stored Checkout Session (`V1CheckoutSessions.Retrieve`) or PaymentIntent (`V1PaymentIntents.Retrieve`) and accepts only when Stripe reports `payment_status` paid or `status` succeeded and `Can(Accept)`; it always answers a 303 redirect to `details.return_url` (never to a URL from the request); a payment that is not paid at Stripe is left as it is.
|
||||
|
||||
Keys and modes (client.go): `ModeKeys` is used by Purchase, Refund, the retrieve calls and the signature check, so `test_mode` consistently selects the test triple; an empty secret for the active mode makes Purchase fail with a typed error (never a panic) and VerifyNotification return false with a log line.
|
||||
|
||||
Proof host (host_test.go, stripefake_test.go, fixture_plugin_test.go): the fake's `POST /v1/payment_intents` answers `requires_action` with a `client_secret` by default and `succeeded` when the form carries `confirm=true` and a `payment_method`; `GET /v1/payment_intents/{id}` and `GET /v1/checkout/sessions/{id}` answer the state the test sets. New subtests of TestHostStripeFlow: `payment_intent_operator_path` (settings `checkout_mode` payment_intent: Purchase answers RequiresAction with `client_secret`, `payment_intent_id`, the test publishable key and `payment_uuid`, the payment is Sent; a signed `payment_intent.succeeded` with `metadata.payment_uuid` accepts it and pays the order; the fake saw `amount=3984`), `per_order_override` (a Details parameter `stripe_checkout_mode` checkout on a payment_intent default produces a Checkout Session), `consumer_driven_path_b` (the `acme.books` fixture creates its own Checkout Session through its own client against the fake, stores the session id on its own side, then its own webhook handler calls `Payments.RecordExternal(ctx, tx, order, OperatorCode, {"stripe_checkout_session_id", "stripe_payment_intent"})`, `Send` and `Accept` inside one `lagoon.Transaction`; the order is Paid with `successful_payment_id`, OrderPaid fired once, the mail sent once; running the handler again is a no-op because `Can(Accept)` is false), `wrong_secret` (signed with `whsec_other` → 403, nothing changes), `missing_header` (unsigned POST → 403 with a log line captured through a test slog handler), `missing_secret` (both webhook secret columns empty and no env fallback → 403 with the log line; with `SUMMER_GOLEM15__PGSTRIPE__TEST_WEBHOOK_SECRET` set in the env and the column empty → 200), `older_api_version` (the payload's `api_version` is `2025-02-24.acacia` → 200 and Accepted), `replay_and_race` (the same signed event POSTed from eight goroutines → every response 200 and exactly one Accept, one OrderPaid, one mail), `browser_return` (GET `/_paymentgateway/<code>?pgUuid=<b64>` with the fake reporting the session paid → 303 to `https://shop.example.test/return` and the payment Accepted; with the fake reporting unpaid → 303 and the payment still Sent; a `pgUuid` of an unknown UUID → 404), `refund` (`Payments.Refund` on the accepted payment calls `/v1/refunds` with the payment intent and ends Refunded; the order follows through the bridge). webhook_test.go TestStripeWebhook (no Docker): `VerifySignature` accepts a payload signed with the right secret, refuses the wrong secret, refuses a missing header, refuses an empty secret, accepts an older `api_version` payload; `DecodeEvent` extracts `payment_uuid` from metadata and falls back to `client_reference_id`; the minimal struct ignores unknown fields.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short -run '^TestStripeWebhook$' -v . 2>&1 | grep -- '^--- PASS: TestStripeWebhook' && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestStripeWebhook" (a wrong secret or missing header was accepted, an older api_version was rejected, or the decode lost the UUID); a Docker-free test fails.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2>&1 | grep -c '^ --- PASS: TestHostStripeFlow/' | grep -qE '^(1[1-9]|[2-9][0-9])$'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than eleven passing subtests: a path did not pay the order, a forged or unsigned request changed state, the race produced two Accepts, the browser return trusted its input, or the refund did not reach the fake.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "payment_intent.succeeded\|checkout.session.async_payment_succeeded\|payment_intent.payment_failed\|checkout.session.async_payment_failed" ../sm-pgstripe-plugin/webhook.go` prints four lines.
|
||||
- `grep -n "StatusSeeOther\|303" ../sm-pgstripe-plugin/browser_return.go` prints at least one line and `grep -n "V1CheckoutSessions.Retrieve\|V1PaymentIntents.Retrieve" ../sm-pgstripe-plugin/browser_return.go` prints at least one line.
|
||||
- `grep -n "RecordExternal" ../sm-pgstripe-plugin/fixture_plugin_test.go ../sm-pgstripe-plugin/host_test.go` prints at least one line.
|
||||
- `grep -c "t.Run(" ../sm-pgstripe-plugin/host_test.go` is at least 11.
|
||||
</acceptance_criteria>
|
||||
<done>Both pay paths the acceptance consumer needs work against a fake Stripe, every forged or stale input is refused, and the on-site card flow has its response bundle.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: `pgstripe:import-winter` with Laravel decrypt and its export SQL, the settings page smoke (secrets never leave the server), and the README with the dashboard instructions</name>
|
||||
<reversibility rating="reversible">An import command and documentation; the settings page is already fixed by Task 1.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/console/import_winter.go, ../sm-pgstripe-plugin/plugin.go, ../sm-pgstripe-plugin/scripts/winter-export-settings.sql, ../sm-pgstripe-plugin/README.md, ../sm-pgstripe-plugin/settings_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgateway-plugin/console/import_winter.go and scripts/winter-export-settings.sql (plans 02 and 04), ../sm-sitemap-plugin/console/import_winter.go (:75-119), summercms modules/lagoon/laravel_decrypt.go (:26 DecryptLaravelPayload), modules/lagoon/encrypted.go (:37 NewEncrypted), the frozen tree: classes/StripeSettings.php (:38-55 readEncryptedSetting and its DecryptException tolerance), Plugin.php (:70-110 the saving hook that encrypts the four keys), partials/_stripe_info.htm (the dashboard steps and test cards), README.md and UPGRADE.md, .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 8 pgstripe keys and the Laravel payload shape, Runtime State Inventory: dashboard endpoints and API versions), ../sm-journal-plugin/README.md (structure), ../sm-paymentgateway-plugin/README.md (the Cutover section to cross-reference)</read_first>
|
||||
<action>Import (console/import_winter.go, plugin.go Commands, scripts): `pgstripe:import-winter --settings <file> [--app-key <laravel base64:key>] [--dry-run] [--force]` reads the same `system_settings` JSON export as the paymentgateway import (the SQL script selects the value for item `golem15::paymentgateway.settings`) and maps `stripe.apiKey` → publishable_key, `stripe.testApiKey` → test_publishable_key, `stripe.sapiKey` → secret_key, `stripe.stestApiKey` → test_secret_key, `stripe.webhookSecret` → webhook_secret, `stripe.testWebhookSecret` → test_webhook_secret, `stripe.testMode` → test_mode, and the operator enable key `golem15.pgstripe::lang.operators.stripe` `"1"`/`"0"` → enabled; a value that looks like a Laravel payload (base64 JSON with iv, value, mac) is decrypted with `lagoon.DecryptLaravelPayload(payload, appKey)` (a missing `--app-key` with such a value is an error naming the key), a plaintext value passes through unchanged (the PHP lazy-decrypt tolerance), the secrets are stored through `lagoon.NewEncrypted`, `stripe.3dsecure` and `stripe.stestPublishableKey` are ignored with a notice, `--dry-run` prints the mapping with secrets masked, and an existing non-empty secret is kept unless `--force`.
|
||||
|
||||
README (standard structure, never naming a consuming application): what the plugin is, the import line, Overview (the operator code and route segment), Features (Checkout and PaymentIntent modes, the process response bundles per D-17, events accepted, the browser return, refunds, test/live keys with env fallback `SUMMER_GOLEM15__PGSTRIPE__SECRET_KEY`, `..._TEST_SECRET_KEY`, `..._WEBHOOK_SECRET`, `..._TEST_WEBHOOK_SECRET`), Usage (the dashboard steps from `_stripe_info.htm`: add a webhook endpoint at `https://<site>/_paymentgateway/Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl` with the events `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `payment_intent.succeeded`, `payment_intent.payment_failed`; the API version of the endpoint does not matter because verification is signature-only; the Stripe test card numbers), API reference (exported identifiers), Configuration (config keys), CLI commands (`pgstripe:import-winter`), Dependencies (stripe-go v87.0.0 pinned to `2026-09-30.endive`; shopspring/decimal transitive and unused), Testing (the host test needs Docker; no network), Cutover (import, the dashboard endpoint, the note that Phase 18.1 adds subscriptions to this repository).
|
||||
|
||||
Smoke (Postgres): settings_smoke_test.go TestStripeSettingsSmoke: PUT `pgstripe` as an admin with access_settings with `test_secret_key` `sk_test_abc`: the GET answers no `test_secret_key` in data and `meta.secrets.test_secret_key` `stored`, the column holds a ciphertext that is not the plaintext, the response bodies never contain `sk_test_abc`; PUT with `test_secret_key` `""` keeps it; PUT with null clears it to `empty`; with `SUMMER_GOLEM15__PGSTRIPE__WEBHOOK_SECRET` in the env and the column empty the state is `env`; the three tabs are present; an admin without access_settings gets 403. TestStripeImportWinterSmoke: an export with a Laravel-encrypted `stripe.sapiKey` (produce it in the test with the inverse of `DecryptLaravelPayload` or a fixed vector from the lagoon tests) and a plaintext `stripe.stestApiKey`, `stripe.testMode` true and the enable key `"1"` lands on the row with both secrets decryptable through `ModeKeys`, and `--dry-run` writes nothing and masks the secrets in its output.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && grep -n '"pgstripe:import-winter"' ../sm-pgstripe-plugin/console/import_winter.go | grep -q . && grep -n "Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl" ../sm-pgstripe-plugin/README.md | grep -q . && test -f ../sm-pgstripe-plugin/scripts/winter-export-settings.sql</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails; the command name is missing; the README lacks the endpoint path; the export script is missing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestStripeSettingsSmoke|TestStripeImportWinterSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: a secret value appeared in a settings response, an empty string overwrote a key, the env state was not reported, or the Laravel payload did not decrypt to a usable key.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "DecryptLaravelPayload" ../sm-pgstripe-plugin/console/import_winter.go` prints at least one line and `grep -n "stripe.3dsecure" ../sm-pgstripe-plugin/console/import_winter.go` prints at least one line.
|
||||
- `grep -n "^## Cutover\|^## Configuration\|^## CLI commands" ../sm-pgstripe-plugin/README.md` prints three lines.
|
||||
- `! grep -rniE "horoskopia|fonoteka|grzyby" ../sm-pgstripe-plugin --include='*.go' --include='*.md' --include='*.yaml' | grep -q .` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>An application cuts its PHP Stripe keys over in one command, the admin never sees a secret, and the README says exactly how to point the dashboard at the plugin.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Stripe → notification route | signed events over HTTPS; attackers can POST anything to the same path |
|
||||
| browser → return URL with pgUuid | unauthenticated, attacker-controlled query |
|
||||
| admin → Stripe settings | authenticated; secrets at rest must never come back |
|
||||
| operator → api.stripe.com | outbound with the secret key; tests override the backend |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-28 | Spoofing | forged webhook event | critical | mitigate | `webhook.ValidatePayload` on the raw bytes with the mode's secret before any lookup; missing header or secret refused with a log; tests: TestHostStripeFlow/wrong_secret, /missing_header, /missing_secret (Task 2, STRIPE-002) and TestStripeWebhook (Task 2) |
|
||||
| T-18-29 | Tampering / Repudiation | event replay and concurrent delivery | high | mitigate | row lock plus `Can(Accept)` in the route transaction; tests: TestHostStripeFlow/replay_and_race (Task 2: eight concurrent deliveries, one Accept) and the replay step of TestHostStripeFlow/checkout_operator_path (Task 1) |
|
||||
| T-18-30 | Spoofing | browser return with a chosen pgUuid | high | mitigate | the return branch never accepts from input; it retrieves the session or intent from Stripe and redirects to the stored return URL only; test: TestHostStripeFlow/browser_return (Task 2: unpaid at Stripe leaves the payment Sent, an unknown pgUuid is 404, the redirect target is the stored return URL) |
|
||||
| T-18-31 | Tampering | amount sent to Stripe | high | mitigate | `TotalGross().Minor()` from the stored cart; the API and the client carry no amount; the fake asserts 3984; tests: TestHostStripeFlow/checkout_operator_path (Task 1: `unit_amount=3984`, `currency=pln`) and /payment_intent_operator_path (Task 2: `amount=3984`), TestStripeOperator (plan 07 Task 4: both request bodies) |
|
||||
| T-18-32 | Information Disclosure | secrets in settings responses, logs, errors | critical | mitigate | `lagoon.Encrypted` with `json:"-"`, cabana write-only password projection (plan 01), the import masks secrets in dry-run output, the event id is logged and never the body; TestStripeSettingsSmoke greps the bodies |
|
||||
| T-18-33 | Information Disclosure | `payments.data` keys exposed to admins and the API | medium | mitigate | only `stripe_checkout_session_id`, `payment_intent_id`, `charge_id`, `is_paid_in_stripe` and the e-mail are stored; `client_secret` is never persisted |
|
||||
| T-18-34 | Denial of Service | outbound Stripe calls hanging | medium | mitigate | the HTTPClient carries a timeout; MaxNetworkRetries bounded; a failed create is StatusError, never a half-created payment outside the transaction |
|
||||
| T-18-35 | Tampering (supply chain) | stripe-go in go.mod | medium | mitigate | exact v87.0.0 with go.sum (STRIPE-003); the official vendor module; decision D-12 |
|
||||
| T-18-36 | Elevation of Privilege | test fixture plugin in production | medium | mitigate | registered from a `_test.go` file only; TestFixtureAbsentFromProduction refuses its name in production sources |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no npm package; Go dependency stripe-go v87.0.0 named by D-12 with go.sum committed |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: Docker-free suite, the SDK pin, no archived YAML import, four password fields; the Checkout operator path on a Docker host.
|
||||
- Task 2: the webhook unit test (Docker-free), whole-repo -short; the full host test with at least eleven subtests on a Docker host.
|
||||
- Task 3: Docker-free suite, the command and README greps; the settings and import smokes on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-2 (Stripe half): the Stripe operator registers through the build-time interface and ships both modes.
|
||||
- SC-4: the host test proves order-create, Stripe-pay and order-paid in both shapes, plus every refusal case.
|
||||
- D-13 and D-14: secrets encrypted and write-only with env fallback; signature-only verification independent of the dashboard API version.
|
||||
- `go vet ./...` and `go test -short ./...` in the plugin stay green at every commit; no test reaches the network.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-05-SUMMARY.md` when done, with the commits, the subtest list of TestHostStripeFlow with pass or NOT RUN, the process response shapes as implemented (for plan 06), and any deviation.
|
||||
</output>
|
||||
416
.planning/phases/18-payment-gateway/18-06-PLAN.md
Normal file
416
.planning/phases/18-payment-gateway/18-06-PLAN.md
Normal file
@@ -0,0 +1,416 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 6
|
||||
depends_on: ["18-02", "18-04", "18-05"]
|
||||
files_modified:
|
||||
- ../sm-paymentgateway-plugin/routes.go
|
||||
- ../sm-paymentgateway-plugin/plugin.go
|
||||
- ../sm-paymentgateway-plugin/api/auth.go
|
||||
- ../sm-paymentgateway-plugin/api/orders.go
|
||||
- ../sm-paymentgateway-plugin/api/order_json.go
|
||||
- ../sm-paymentgateway-plugin/api/methods.go
|
||||
- ../sm-paymentgateway-plugin/api/actions.go
|
||||
- ../sm-paymentgateway-plugin/api/items.go
|
||||
- ../sm-paymentgateway-plugin/api/errors.go
|
||||
- ../sm-paymentgateway-plugin/customer_api_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
- ../vue-paymentgateway-layer/.gitignore
|
||||
- ../vue-paymentgateway-layer/package.json
|
||||
- ../vue-paymentgateway-layer/pnpm-lock.yaml
|
||||
- ../vue-paymentgateway-layer/pnpm-workspace.yaml
|
||||
- ../vue-paymentgateway-layer/tsconfig.json
|
||||
- ../vue-paymentgateway-layer/nuxt.config.ts
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
- ../vue-paymentgateway-layer/scripts/check-git-install.sh
|
||||
- ../vue-paymentgateway-layer/app/utils/client.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/money.ts
|
||||
- ../vue-paymentgateway-layer/app/utils/states.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/useOrder.ts
|
||||
- ../vue-paymentgateway-layer/app/composables/useOrders.ts
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/OrderPage.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/Checkout.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/OrderStatus.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/Cart.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/PaymentMethods.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/ShippingMethods.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/BankTransfer.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/OrderList.vue
|
||||
- ../vue-paymentgateway-layer/app/components/Pg/Confirm.vue
|
||||
- ../vue-paymentgateway-layer/app/assets/css/pg-tokens.css
|
||||
- ../vue-paymentgateway-layer/app/assets/css/pg-base.css
|
||||
- ../vue-paymentgateway-layer/i18n/locales/en.json
|
||||
- ../vue-paymentgateway-layer/i18n/locales/pl.json
|
||||
- ../vue-paymentgateway-layer/.playground/nuxt.config.ts
|
||||
- ../vue-paymentgateway-layer/.playground/app/pages/orders/[hash].vue
|
||||
- ../vue-paymentgateway-layer/.playground/app/pages/orders/index.vue
|
||||
- ../vue-paymentgateway-layer/testing/load.ts
|
||||
- ../vue-paymentgateway-layer/tests/client.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/money.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/checkout.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/orderPage.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/i18n.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/states.test.ts
|
||||
autonomous: false
|
||||
requirements: [SC-3, D-02, D-06, D-11, D-17]
|
||||
user_setup:
|
||||
- service: git.golem15.com
|
||||
why: "The three new repositories do not exist on the remote (RESEARCH Open Question 5); pushes need them"
|
||||
dashboard_config:
|
||||
- task: "Create empty repositories golem15/sm-paymentgateway-plugin, golem15/sm-pgstripe-plugin and golem15/vue-paymentgateway-layer"
|
||||
location: "git.golem15.com -> golem15 group -> New repository"
|
||||
estimate:
|
||||
tokens: 280000
|
||||
raw_tokens: 280000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-06: a JSON API under `/_paymentgateway/api/v1` mirrors the PHP Orders component: `GET /orders` (jwt.auth, the user's orders paginated in the `{data, meta{current_page,last_page,per_page,total}}` envelope), `GET /orders/{hash}`, `GET /payment-methods`, `GET /shippings`, `POST /orders/{hash}/payment-method`, `POST /orders/{hash}/shipping`, `POST /orders/{hash}/process`, `POST /orders/{hash}/cancel`, `POST /orders/{hash}/refund-request`, `POST /orders/{hash}/items/{item}/pieces`, `DELETE /orders/{hash}/items/{item}/pieces`, `DELETE /orders/{hash}/items/{item}`, all under `throttle:paymentgateway-api`, answering with `wire.WriteJSON`; 422 is `{\"error\":\"Validation failed\",\"errors\":{field:[msg]}}`, 404 covers both unknown and forbidden orders, 409 `{\"error\":\"Order state changed\"}` when a transition no longer applies."
|
||||
- "D-06 authorization (PHP canActOnOrder minus the session case): an order is readable and actionable by its owner (a valid frontend JWT whose user id equals `user_id`), by a `guest_code` query value equal to the stored code (constant-time), or, when `enable_guest` is on, by a `guest_token` that verifies to that order id; an invalid Bearer token is 401, an absent one is anonymous; a guest token is never promoted to a user identity; the API never accepts an amount from the client."
|
||||
- "D-17: `process` selects the operator from the order's payment method, creates and purchases the payment through `payments.Service`, and answers `{redirect_url}` for a redirect operator response, `{requires_action: true, client_secret, payment_intent_id, payment_uuid, publishable_key}` for RequiresAction, `{status: \"pending\"}` for cash and manual transfer (the order moves to AwaitingPayment through the bridge), or the operator's error as a 422; the Winter StripeKeys component is not ported."
|
||||
- "State guards: payment-method, shipping and item changes only in Placed; cancel in Placed or AwaitingPayment; refund-request when `CanBeRefunded`; the last item of an order cannot be removed (422); a piece count never drops below 1 through subtract; an operator code outside the enabled registry and a shipping id outside the table are 422s."
|
||||
- "Money and order JSON: every amount is `{\"amount\":\"2999.9700\",\"amount_minor\":299997,\"currency\":\"PLN\"}`; the order carries hash_id, state, currency, native_currency, total, total_net, native_total, items (id, description, count, net_price, tax, total_net, total_gross), shipping or null, payment_method, can_edit_items, can_cancel, can_refund, bank_transfer (name, receiver_address, bank_acc_no, swift, bank_details_html sanitized) only when the manual transfer method is selected in Placed or AwaitingPayment, and created_at with `+00:00`; the stored cart and details are never returned raw and `return_url`, secure parameters and `guest_code` never leave the server."
|
||||
- "D-06 layer: `vue-paymentgateway-layer` (version 0.1.0, the same nuxt 4.4.8, vue 3.5.35, @nuxtjs/i18n 10.4.0 and typescript 5.9.3 pins as vue-journal-layer, no other dependency and no Stripe.js) ships `createPaymentGatewayClient` over an injected fetcher with method, query and body, `usePaymentGatewayApi`, `useOrder` (keyed `pg:order:<hash>`, SSR first paint, bearer forwarded when the app provides one), `useOrders`, `formatMoney` passing the amount string to `Intl.NumberFormat`, and the components PgOrderPage, PgCheckout, PgOrderStatus, PgCart, PgPaymentMethods, PgShippingMethods, PgBankTransfer, PgOrderList and PgConfirm on `--pg-*` tokens with the UI-SPEC L1 to L8 contracts and the `pg.*` copy in en and pl; a same-name file in the app overrides a component."
|
||||
- "D-17 layer default: PgCheckout calls `process` after selecting the methods, keeps the Pay button busy, calls `window.location.assign(redirect_url)` on a redirect answer, renders the `#payment-action` scoped slot on `requires_action` and shows `pg.error.payment_action` without it, shows `pg.error.process` on an error, and never sends a second request while busy."
|
||||
- "UI considerations (layer, covered): SSR first paint and the keyed reuse; the not-found panel for 404 and forbidden and `pg.error.load` with Retry otherwise; AwaitingPayment polls every 3 s for 20 attempts with a live region, pauses while hidden and ends with `pg.awaiting.slow` and a Refresh button; zero methods shows `pg.checkout.no_methods` and disables Pay; no shippings hides the fieldset; field errors under the fieldset with focus and form errors in the alert block; one method preselected, many in the auto-fill grid, names wrap and amounts never wrap; PgCart scrolls below 40rem, decrements stop at 1, the Remove button is not rendered for the last item, rows are busy while a change posts and re-render from the last response; PgOrderStatus shows the tone bar with the title, code, total, read-only cart and per-state actions; PgOrderList shows the empty copy, three skeleton rows, the error with Retry and pagination; long values break with `overflow-wrap: anywhere`."
|
||||
- statement: "PgOrderPage polling: a layer node test asserts that an AwaitingPayment order polls, stops at Paid and announces it, stops after 20 attempts with the slow copy, and pauses while the page is hidden (tests/orderPage.test.ts)."
|
||||
verification: backstop
|
||||
- statement: "PgCheckout redirect contract: a layer node test asserts that a redirect answer calls location.assign once, that a second click while busy sends nothing, and that a requires_action answer without the slot shows pg.error.payment_action (tests/checkout.test.ts)."
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "../sm-paymentgateway-plugin/api/orders.go"
|
||||
provides: "GET /orders, GET /orders/{hash} and the order JSON projection"
|
||||
contains: "func Show"
|
||||
- path: "../sm-paymentgateway-plugin/api/auth.go"
|
||||
provides: "optional JWT wrapper and the owner, guest_code, guest_token resolver"
|
||||
contains: "func canActOnOrder"
|
||||
- path: "../sm-paymentgateway-plugin/api/actions.go"
|
||||
provides: "payment-method, shipping, process, cancel, refund-request handlers"
|
||||
contains: "func Process"
|
||||
- path: "../vue-paymentgateway-layer/app/utils/client.ts"
|
||||
provides: "createPaymentGatewayClient over an injected fetcher"
|
||||
contains: "export function createPaymentGatewayClient"
|
||||
- path: "../vue-paymentgateway-layer/app/components/Pg/Checkout.vue"
|
||||
provides: "PgCheckout with the D-17 redirect default"
|
||||
contains: "location.assign"
|
||||
- path: "../vue-paymentgateway-layer/app/components/Pg/OrderPage.vue"
|
||||
provides: "PgOrderPage with state routing and polling"
|
||||
contains: "visibilitychange"
|
||||
key_links:
|
||||
- from: "../sm-paymentgateway-plugin/routes.go"
|
||||
to: "../sm-paymentgateway-plugin/api/*.go"
|
||||
via: "r.Group(\"/_paymentgateway/api/v1\", surf.Use(\"throttle:paymentgateway-api\"), ...)"
|
||||
pattern: "_paymentgateway/api/v1"
|
||||
- from: "../sm-paymentgateway-plugin/api/auth.go"
|
||||
to: "sm-user-plugin bouncer jwt middleware"
|
||||
via: "reg.Middleware(\"jwt\") only when Authorization Bearer is present"
|
||||
pattern: "Middleware\\(\"jwt\"\\)"
|
||||
- from: "../sm-paymentgateway-plugin/api/actions.go"
|
||||
to: "../sm-paymentgateway-plugin/payments/service.go"
|
||||
via: "process creates and purchases the payment; the response maps operators.Response"
|
||||
pattern: "Purchase"
|
||||
- from: "../vue-paymentgateway-layer/app/composables/useOrder.ts"
|
||||
to: "../vue-paymentgateway-layer/app/utils/client.ts"
|
||||
via: "the composable builds the client with the runtime config base"
|
||||
pattern: "createPaymentGatewayClient"
|
||||
prohibitions:
|
||||
- statement: "The customer API never accepts an amount, a price, a currency or a state from the client; every total comes from the stored cart and every state change goes through the services' transitions."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "A guest code or guest token grants access to exactly one order and is never promoted to a user identity; forbidden and unknown orders are indistinguishable (404)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The layer ships no payment SDK and never handles card data; PaymentIntent confirmation is delegated to the host app through the payment-action slot."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged before the human's reply at the repository checkpoint, and no committed fixture carries personal data."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 6 of 7, wave 6): the headless successor of the Winter `Orders` component (D-06): the customer order API in `../sm-paymentgateway-plugin` with the PHP component's authorization and handlers, the process response of D-17, and the new `../vue-paymentgateway-layer` repository with a typed client and the order, checkout and status components for the PHP state partials. It runs after plan 05 so no other plan writes `sm-paymentgateway-plugin` while the host test compiles it, and it ends with the one human checkpoint that creates the three remote repositories, supplies the shop-type fixture rows and approves the pushes and the layer tag.
|
||||
|
||||
The plan leads with a decision gate (Task 1) and then the tracer (Task 2): the first endpoint publishes the one-way API shape D-06 names.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator. `must_haves` derive from SC-3, D-06, D-11 (point 2, the fixture request), D-17, RESEARCH Pattern 11, Pitfall 10, the Security Domain and the UI-SPEC L1 to L8 contracts plus the `## UI Considerations` rows for PgOrderPage, PgCheckout, PgPaymentMethods/PgShippingMethods, PgCart, PgOrderStatus and PgOrderList (the dismissed row "payment/shipping methods loading, error" keeps the UI-SPEC's reason and is not lifted). The two backstop rows are the structured markers above. Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- Endpoint paths and JSON field names follow RESEARCH Pattern 11, confirmed at Task 1; the money JSON carries the exact string plus a convenience `amount_minor`.
|
||||
- The PHP session "current order" access case has no headless equivalent and is dropped; the three remaining cases are ported (owner, guest_code, guest_token).
|
||||
- Guard failures that PHP reported as flash errors are 409 `Order state changed` so the layer can refresh (UI-SPEC `pg.error.stale`); validation errors keep the Journal 422 shape.
|
||||
- The layer carries copy (`pg.*` in en and pl) unlike the menu and FAQ layers, because order states need customer-facing text (UI-SPEC Copywriting).
|
||||
- The layer's tests follow vue-journal-layer's `node --test` approach (composables loaded through the `testing/` helper with stubbed Nuxt globals, the 17-06 pattern); no browser runner.
|
||||
- The push of all three repositories and the layer tag happen in this plan inside Task 6, a second human checkpoint: Task 5 approves the release sheet, Task 6 prepares the remotes and prints the exact heads, and `git push` and `git tag` run only after Task 6's recorded reply (the same rule as plan 01 Task 4 and plan 07 Task 5); the unit-test plan's commits are pushed at its own checkpoint.
|
||||
- `../sm-paymentgateway-plugin/plugin.go` is edited here only to extend `Buckets()` with `paymentgateway-api`; it is the one later edit plan 02 records, and no boot step or capability assertion is added to it.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `sm-paymentgateway-plugin/api`: `OptionalJWT`, `canActOnOrder`, `Index`, `Show`, `PaymentMethods`, `Shippings`, `SelectPaymentMethod`, `SelectShipping`, `Process`, `Cancel`, `RefundRequest`, `AddPiece`, `RemovePiece`, `RemoveItem`, `OrderJSON`, `MoneyJSON`, `writeAPIError`, `writeValidation`, `writeStale`; bucket `paymentgateway-api`; routes under `/_paymentgateway/api/v1`.
|
||||
- Repository `../vue-paymentgateway-layer` (branch `main`): `createPaymentGatewayClient`, `PaymentGatewayFetcher`, `usePaymentGatewayApi`, `useOrder`, `useOrders`, `formatMoney`, `stateTone`, the nine `Pg*` components, `pg-tokens.css`, `pg-base.css`, `i18n/locales/{en,pl}.json`, the playground, `scripts/check-git-install.sh`, tests.
|
||||
- Tests: `TestCustomerAPITracer`, `TestCustomerAPI` (Go, Postgres); `tests/client.test.ts`, `money.test.ts`, `checkout.test.ts`, `orderPage.test.ts`, `i18n.test.ts`, `states.test.ts` (node --test).
|
||||
- Remote repositories created by the human; pushes and the tag `v0.1.0` on the layer.
|
||||
|
||||
<objective>
|
||||
Replace the Winter Orders component with a headless customer order API and a Nuxt layer (D-06, D-17): the API ports the component's handlers and access checks over the plan 02 services, and the layer gives a site app the order, checkout and status screens over it with a Checkout redirect by default. Then the three repositories go public with the human's approval.
|
||||
|
||||
Purpose: SC-3's contracts reach the customer's browser; a site app mounts `<PgOrderPage :hash>` and has a working checkout against any enabled operator.
|
||||
Output: twelve endpoints with tests, a new layer repository with components, copy and tests, created remotes, pushed branches and the layer tag.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-PATTERNS.md
|
||||
@.planning/phases/18-payment-gateway/18-UI-SPEC.md
|
||||
@.planning/phases/18-payment-gateway/18-02-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-05-SUMMARY.md
|
||||
|
||||
The frozen PHP source for the component is `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/components/Orders.php` and `components/orders/*.htm` (D-02). Stage files by explicit path; no co-author tags; no push and no tag before Task 5's reply. Docker-backed Go tests skip under `-short`; record NOT RUN when no daemon answers. The layer installs only the pins vue-journal-layer already carries (RESEARCH Standard Stack, UI-SPEC Registry Safety); `@stripe/stripe-js` is not added.
|
||||
|
||||
<interfaces>
|
||||
Consumed from plans 02 to 05: `orders.Service` (`FindByHash`, `Can`, `PerformTransition`, `RequestRefund`, `CanBeRefunded`), `payments.Service` (`Create`, `Purchase`), `operators.Registry` (`Enabled`), `operators.Response` statuses, `cart.Decode`/`EncodeCart` and the cart mutators, `models.Settings` (`enable_guest`, `shippings_enabled`, the manual transfer fields), `models.Shipping`, `support.GuestToken.Verify`, `support.Hashids`, the user plugin's `jwt` middleware through `app.Lookup[*bouncer.Registry]()` and `bouncer.User(ctx)`, `surf.Use`, `surf.BucketProvider`, `wire.WriteJSON`, `lagoon.Transaction`, `lagoon.Paginate` (DATA-10 envelope).
|
||||
|
||||
Layer client contract (fixed by Task 1):
|
||||
|
||||
```ts
|
||||
export type PaymentGatewayFetcher = (path: string, init?: { method?: 'GET' | 'POST' | 'DELETE'; query?: Record<string, string | number>; body?: unknown }) => Promise<unknown>
|
||||
export function createPaymentGatewayClient(fetch: PaymentGatewayFetcher): {
|
||||
order(hash: string, q?: { guestCode?: string; guestToken?: string }): Promise<PgOrder>
|
||||
orders(page?: number): Promise<PgPage<PgOrderSummary>>
|
||||
paymentMethods(): Promise<PgPaymentMethod[]>
|
||||
shippings(): Promise<PgShipping[]>
|
||||
selectPaymentMethod(hash, code, q?): Promise<PgOrder>
|
||||
selectShipping(hash, id, q?): Promise<PgOrder>
|
||||
process(hash, q?): Promise<PgProcessResult> // { redirect_url } | { requires_action, client_secret, payment_intent_id, payment_uuid, publishable_key } | { status: 'pending' }
|
||||
cancel(hash, q?): Promise<PgOrder>; requestRefund(hash, q?): Promise<PgOrder>
|
||||
addPiece(hash, item, q?): Promise<PgOrder>; removePiece(hash, item, q?): Promise<PgOrder>; removeItem(hash, item, q?): Promise<PgOrder>
|
||||
}
|
||||
export interface PgMoney { amount: string; amount_minor: number; currency: string }
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking" auto_select="api-research">
|
||||
<name>Task 1: Confirm the one-way customer API contract (D-06): endpoint paths, authorization inputs, the order and money JSON, and the process response shapes</name>
|
||||
<read_first>.planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 11: the endpoint list, authorization, money JSON; Pattern 6: the process response), .planning/phases/18-payment-gateway/18-CONTEXT.md (D-06, D-17, Claude's Discretion on JSON names and authorization), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/components/Orders.php (:106-160 properties and onRun, :246-480 the eight handlers, :913-960 canActOnOrder, :963-1000 prepareOrderList), components/orders/default.htm, prepare.htm, awaitingpayment.htm, cart.htm, order_list.htm (what the partials render), /media/nvme/dev/golem15/horoskopia.eu/vue-horoskopia-app/app/stores/book.ts (the redirect the layer default mirrors), ../sm-journal-plugin/controllers/api/auth.go (:245-344 the 422 and error shapes), ../sm-paymentgateway-plugin/README.md (plan 02 to 05 sections), .planning/phases/18-payment-gateway/18-05-SUMMARY.md (the operator response shapes as implemented)</read_first>
|
||||
<action>Print the contract sheet: the twelve endpoints with method, path, auth inputs (JWT owner, guest_code, guest_token) and state guard; the order JSON field list and the money JSON; the three process response shapes; the error shapes (401, 404 for unknown and forbidden, 409 Order state changed, 422 Validation failed, 429 surf); and the consequences: the layer, every site app and every later consumer that ports its own checkout program against these names, and a published API is one-way (D-06). Offer the RESEARCH shape as the default and a flat-money alternative. Stop for the human unless auto mode selects the default.</action>
|
||||
<decision>Which customer API contract does `/_paymentgateway/api/v1` publish?</decision>
|
||||
<context>D-06 already decided a headless API and a layer; the concrete paths and JSON names are Claude's discretion and become permanent once a layer version and an app consume them.</context>
|
||||
<options>
|
||||
<option id="api-research">
|
||||
<name>RESEARCH Pattern 11 as listed in this plan's must_haves: nested money objects `{amount, amount_minor, currency}`, `hash` in the path, guest inputs as query parameters, process answers `{redirect_url}` / `{requires_action, ...}` / `{status: "pending"}`</name>
|
||||
<pros>Exact string plus a convenience integer; mirrors the PHP handlers one to one; the layer client is typed around it.</pros>
|
||||
<cons>Slightly larger payloads than flat strings.</cons>
|
||||
</option>
|
||||
<option id="api-flat-money">
|
||||
<name>The same endpoints with flat money strings (`total: "2999.9700"`, `currency` once per order)</name>
|
||||
<pros>Smaller payloads.</pros>
|
||||
<cons>Per-item currencies and minor units need extra fields later; the layer formats from two fields.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "api-research" or "api-flat-money"; the executor records the choice in the SUMMARY and in the api package doc comment.</resume-signal>
|
||||
<verify>
|
||||
<automated>test -f /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/components/Orders.php && grep -c "public function on" /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgateway/components/Orders.php | grep -qE '^(9|1[0-9])$' && echo contract-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "contract-sheet-ok" absent: the frozen component the sheet is derived from is not at the D-02 path or does not carry its handlers.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the option chosen and the endpoint and field lists verbatim, and `../sm-paymentgateway-plugin/api/order_json.go` (Task 2) carries a package doc comment naming the chosen contract and this checkpoint.
|
||||
</acceptance_criteria>
|
||||
<done>The published API shape is a recorded decision before the first endpoint exists.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: a guest reads their order through the API and starts a cash payment, and the layer's PgOrderPage renders it and redirects on process, with the client, composable and redirect test in a new layer repository</name>
|
||||
<reversibility rating="one-way">D-06: this task publishes the first endpoints and the layer's client contract; apps and the layer consume them.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/routes.go, ../sm-paymentgateway-plugin/plugin.go, ../sm-paymentgateway-plugin/api/auth.go, ../sm-paymentgateway-plugin/api/orders.go, ../sm-paymentgateway-plugin/api/order_json.go, ../sm-paymentgateway-plugin/api/actions.go, ../sm-paymentgateway-plugin/api/errors.go, ../sm-paymentgateway-plugin/customer_api_test.go, ../vue-paymentgateway-layer/.gitignore, ../vue-paymentgateway-layer/package.json, ../vue-paymentgateway-layer/pnpm-lock.yaml, ../vue-paymentgateway-layer/pnpm-workspace.yaml, ../vue-paymentgateway-layer/tsconfig.json, ../vue-paymentgateway-layer/nuxt.config.ts, ../vue-paymentgateway-layer/scripts/check-git-install.sh, ../vue-paymentgateway-layer/app/utils/client.ts, ../vue-paymentgateway-layer/app/utils/money.ts, ../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts, ../vue-paymentgateway-layer/app/composables/useOrder.ts, ../vue-paymentgateway-layer/app/components/Pg/OrderPage.vue, ../vue-paymentgateway-layer/app/components/Pg/Checkout.vue, ../vue-paymentgateway-layer/app/assets/css/pg-tokens.css, ../vue-paymentgateway-layer/app/assets/css/pg-base.css, ../vue-paymentgateway-layer/i18n/locales/en.json, ../vue-paymentgateway-layer/i18n/locales/pl.json, ../vue-paymentgateway-layer/testing/load.ts, ../vue-paymentgateway-layer/tests/client.test.ts, ../vue-paymentgateway-layer/tests/money.test.ts, ../vue-paymentgateway-layer/tests/checkout.test.ts</files>
|
||||
<read_first>../sm-journal-plugin/routes.go, ../sm-journal-plugin/controllers/api/auth.go (:245-344), ../sm-journal-plugin/controllers/api/posts.go (:28-45), ../sm-journal-plugin/plugin.go (:98-126 Buckets), ../sm-summercmsio-app/plugins/golem15/user/plugin.go (:230-247 the jwt middleware lookup) and routes.go (:83-85), summercms modules/bouncer/README.md (User(ctx), the Principal fields), modules/surf/README.md (Use, Group, Where), modules/lagoon/README.md (Paginate envelope), ../sm-paymentgateway-plugin/orders/service.go, payments/service.go, support/guest_token.go, support/hashids.go, cart/cart.go, models/settings.go (plan 02), ../vue-journal-layer/package.json, nuxt.config.ts, tsconfig.json, pnpm-workspace.yaml, .gitignore, scripts/check-git-install.sh, app/utils/client.ts (:1-23), app/composables/useJournalApi.ts, app/components/Journal/Pagination.vue (tokens and $t usage), i18n (layout), testing/ (the composable loader), tests/client.test.ts (:1-30), tests/i18n.test.ts, .planning/phases/17-blog-companions/17-06-SUMMARY.md (the layer test pattern), the frozen tree: components/Orders.php (:246-330 onSelectPaymentMethod and onSelectShipping, :355-408 onProcessOrder, :913-960 canActOnOrder), components/orders/default.htm and prepare.htm, .planning/phases/18-payment-gateway/18-UI-SPEC.md (Design System money formatting row, Component Contracts for the layer: tokens, buttons, L1, L2, the Copywriting pg.* keys), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 11, Pitfall 10)</read_first>
|
||||
<action>Per D-06 (contract from Task 1), D-17 and SC-3, one order page through every layer.
|
||||
|
||||
API (api/, routes.go, plugin.go Buckets): `routes.go` adds `r.Group("/_paymentgateway/api/v1", surf.Use("throttle:paymentgateway-api"), ...)` with `GET /orders/{hash}` and `POST /orders/{hash}/process` (this task; the rest in Task 3), `g.Where("hash", "[A-Za-z0-9]{8,}")`; `Buckets()` adds `paymentgateway-api` (120 per minute keyed by the frontend user id when present, else the client IP). api/auth.go: `OptionalJWT(app, next)` continues anonymously without an `Authorization: Bearer` header and otherwise delegates to the user plugin's `jwt` middleware from `app.Lookup[*bouncer.Registry]().Middleware("jwt")` (401 on an invalid token); `canActOnOrder(ctx, r, order, settings)` returns true for the owner (`bouncer.User(ctx)` id equals `order.user_id`), for `guest_code` equal to the stored code under `subtle.ConstantTimeCompare`, or, when `enable_guest`, for a `guest_token` that `support.GuestToken.Verify` resolves to the order id; everything else is 404 through `writeAPIError(w, 404, "Order not found")`. api/orders.go `Show`: resolve by `FindByHash` (404 when the hash does not decode or the row is missing), check access, answer `OrderJSON`. api/order_json.go: the contract from Task 1 (`MoneyJSON` from `money.Money`: the four-decimal string, `Minor()`, the code; the order fields listed in must_haves; `bank_transfer` only when the manual transfer operator is selected and the state is Placed or AwaitingPayment, with `bank_details_html` taken from the sanitized settings column; never the raw cart, details, `return_url`, secure parameters or `guest_code`). api/actions.go `Process`: Placed only (409 otherwise), a selected payment method required (422 on `payment_method`), `payments.Create` from the stored cart and details with the selected operator, then `Purchase` inside `lagoon.Transaction`; map the response: RedirectURL → `{"redirect_url"}`, RequiresAction → `{"requires_action": true, "client_secret", "payment_intent_id", "payment_uuid", "publishable_key"}`, Pending or Complete → `{"status": "pending"}` (the bridge moves the order), Error → 422 with `payment_method: [message]`. api/errors.go: `writeAPIError`, `writeValidation` (Journal shape), `writeStale` (409 `Order state changed`).
|
||||
|
||||
Layer repository: create `../vue-paymentgateway-layer` (`git init -b main`), `package.json` (name vue-paymentgateway-layer, version 0.1.0, private, type module, main ./nuxt.config.ts, scripts dev, build:playground, test `node --test --test-reporter=tap tests/*.test.ts`, dev:prepare, check:git-install; engines node >=22.18; dependencies `@nuxtjs/i18n` 10.4.0; peerDependencies nuxt 4.4.8; devDependencies nuxt 4.4.8, typescript 5.9.3, vue 3.5.35; nothing else), `pnpm install` producing the lockfile, `pnpm-workspace.yaml`, `tsconfig.json`, `.gitignore`, `scripts/check-git-install.sh` (copied), `nuxt.config.ts` (absolute paths from the layer directory, css `pg-tokens.css` then `pg-base.css`, runtimeConfig `paymentgateway.apiBase` server default `http://127.0.0.1:8096/_paymentgateway/api/v1` and `public.paymentgateway.apiBase` `/_paymentgateway/api/v1`, the i18n module with the layer's locale files merged like vue-journal-layer). app/utils/client.ts implements the interfaces block (`order`, `process` and the method signatures for the rest, each calling the fetcher with method, query and body; `fetch` is injected so `node --test` drives it with a stub). app/utils/money.ts `formatMoney(m: PgMoney, locale: string)` with `Intl.NumberFormat(locale, { style: 'currency', currency, currencyDisplay: 'code', minimumFractionDigits: 2, maximumFractionDigits: 4 }).format(m.amount)` passing the string. app/composables/usePaymentGatewayApi.ts (server base plus `x-forwarded-for` on SSR, the public base in the browser, the bearer header when the app exposes one through a `usePaymentGatewayAuth` hook the app may define, like the journal composable) and useOrder.ts (`useAsyncData` keyed `pg:order:<hash>`, guest query forwarded, `refresh`). Components (this task: minimal but real): `Pg/OrderPage.vue` (props hash, guestCode, guestToken, headingLevel, homeTo; renders PgCheckout for Placed, a plain status block for every other state until Task 4, the not-found panel on 404, the error panel with Retry otherwise) and `Pg/Checkout.vue` (the title and body, the order code line, the Pay button `pg.checkout.pay` with the formatted gross, busy state `pg.checkout.redirecting` with aria-busy; on click `process`: `location.assign(redirect_url)` keeping busy, `{status: pending}` → `refresh`, `requires_action` → the `#payment-action` slot or `pg.error.payment_action`, error → `pg.error.process` in the alert block; the methods fieldsets come in Task 4 so this task's checkout processes the order's preselected method). `pg-tokens.css` and `pg-base.css` with the UI-SPEC token list and `.pg-button*` rules. `i18n/locales/{en,pl}.json` with every `pg.*` key of the UI-SPEC (all of them now, so later tasks only consume). testing/load.ts copies vue-journal-layer's composable loader. Tests: tests/client.test.ts (a stub fetcher records calls: `order` hits `/orders/<hash>` with the guest query; `process` POSTs `/orders/<hash>/process`), tests/money.test.ts (`99999999.9999` PLN keeps four decimals in en and pl; `39.8400` shows two; the string is passed, never a number), tests/checkout.test.ts (the backstop: a redirect answer calls `location.assign` exactly once with the URL and the button stays busy; a second click while busy sends no request; `requires_action` without the slot renders `pg.error.payment_action`; an error renders `pg.error.process` and re-enables).
|
||||
|
||||
Go smoke (customer_api_test.go TestCustomerAPITracer, Postgres): a guest order created through `Orders.Make` (nil user, cash operator in details) with `enable_guest` on: `GET /_paymentgateway/api/v1/orders/<hash>` without credentials is 404; with `?guest_code=<code>` answers 200 with state Placed, `total.amount` `39.8400`, `total.amount_minor` 3984 and no `guest_code`, `return_url` or raw cart key; with a `guest_token` for the order 200, for another order 404; the owner of a user order reads it with a frontend JWT and a different user gets 404; an invalid Bearer is 401; `POST .../process?guest_code=` answers `{"status":"pending"}` and the order is AwaitingPayment with a Sent cash payment; a second process is 409.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -qE '^# pass [1-9]' && grep -q '"@nuxtjs/i18n": "10.4.0"' ../vue-paymentgateway-layer/package.json && ! grep -q 'stripe' ../vue-paymentgateway-layer/package.json</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free plugin test fails; the TAP summary lacks "# fail 0" or has no passing test (the redirect contract, the money formatting or the client paths are wrong); the i18n pin differs; a Stripe package entered package.json.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestCustomerAPITracer$' -v . 2>&1 | grep -- '^--- PASS: TestCustomerAPITracer'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or an unauthorized reader got the order, a guest token reached another order, the JSON leaked guest_code or return_url, process did not start the cash payment, or the second process was not 409.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../vue-paymentgateway-layer rev-parse --abbrev-ref HEAD` prints main and `git -C ../vue-paymentgateway-layer remote` prints nothing.
|
||||
- `grep -n "_paymentgateway/api/v1" ../sm-paymentgateway-plugin/routes.go` prints at least one line and `grep -n "subtle.ConstantTimeCompare" ../sm-paymentgateway-plugin/api/auth.go` prints one line.
|
||||
- `grep -n "amount_minor" ../sm-paymentgateway-plugin/api/order_json.go` prints at least one line and `! grep -n "guest_code\|return_url" ../sm-paymentgateway-plugin/api/order_json.go | grep -v "//" | grep -q .` exits 0.
|
||||
- `grep -n "location.assign" ../vue-paymentgateway-layer/app/components/Pg/Checkout.vue` prints one line and `diff <(jq -r 'paths(scalars) | join(".")' ../vue-paymentgateway-layer/i18n/locales/en.json | sort) <(jq -r 'paths(scalars) | join(".")' ../vue-paymentgateway-layer/i18n/locales/pl.json | sort)` prints nothing (the locale files are nested objects under a top-level `pg` key, like vue-journal-layer's `journal`, and every leaf key exists in both; Task 4's `tests/i18n.test.ts` repeats this check at test time).
|
||||
- `! grep -n "Number(" ../vue-paymentgateway-layer/app/utils/money.ts | grep -q .` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>A guest can open their order through the API and the layer and start paying it, and the contract both sides use is tested on both sides.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: The remaining ten endpoints with the full authorization matrix, state guards and the README API section</name>
|
||||
<reversibility rating="costly">D-06: every endpoint added here is part of the published contract.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/routes.go, ../sm-paymentgateway-plugin/api/orders.go, ../sm-paymentgateway-plugin/api/methods.go, ../sm-paymentgateway-plugin/api/actions.go, ../sm-paymentgateway-plugin/api/items.go, ../sm-paymentgateway-plugin/api/order_json.go, ../sm-paymentgateway-plugin/customer_api_test.go, ../sm-paymentgateway-plugin/README.md</files>
|
||||
<read_first>../sm-paymentgateway-plugin/api/*.go (Task 2), the frozen tree: components/Orders.php (:246-480 every handler and its checks, :963-1000 prepareOrderList), components/orders/order_list.htm, cart.htm, awaitingpayment.htm, ../sm-paymentgateway-plugin/cart/cart.go (AddPiece, SubtractPiece, Remove), orders/service.go (Cancel paths, RequestRefund, CanBeRefunded), models/shipping.go, operators/registry.go (Enabled), summercms modules/lagoon/README.md (Paginate), ../sm-journal-plugin/controllers/api/posts.go (pagination query handling), .planning/phases/18-payment-gateway/18-RESEARCH.md (Pattern 11, the Security Domain V4 and V5 rows)</read_first>
|
||||
<action>Per D-06 and the Task 1 contract.
|
||||
|
||||
Endpoints: `GET /orders` under `jwt.auth` (the user's orders newest first, `lagoon.Paginate` with `page` and a fixed `per_page` of 20, each row the order summary: hash_id, state, total, created_at); `GET /payment-methods` (the enabled operators: code, name from the operator's lang key in the request locale, logo URL or null, mode, allowed currencies; cash is listed only when the order's shipping, if any, is payable with cash is a per-order rule applied in `SelectPaymentMethod`, so the list is global); `GET /shippings` (`{data: []}` when `shippings_enabled` is off; else id, name, cost, gross_cost, tax, logo URL, is_payable_with_cash); `POST /orders/{hash}/payment-method` with `{payment_method}` (Placed only; the code must be enabled, 422 `payment_method` otherwise; cash refused when the selected shipping is not payable with cash; stores the operator in the details and answers the order); `POST /orders/{hash}/shipping` with `{shipping_id}` (Placed only, shippings enabled, the id must exist; sets `shipping_id` and the cart's ShippingMethod from the row and recomputes the totals); `POST /orders/{hash}/cancel` (Placed → Cancel, AwaitingPayment → CancelNotActive, else 409); `POST /orders/{hash}/refund-request` (`CanBeRefunded` → `RequestRefund`, else 409); `POST /orders/{hash}/items/{item}/pieces` (Placed; `AddPiece`), `DELETE /orders/{hash}/items/{item}/pieces` (Placed; `SubtractPiece`, never below 1), `DELETE /orders/{hash}/items/{item}` (Placed; refuses the last item with 422 `item`); every mutation runs in `lagoon.Transaction` with the order row locked, re-encodes the cart as v2, recomputes `amount_stored` and `native_amount_stored` and answers the order JSON; every handler applies `canActOnOrder` and `g.Where("item", "[A-Za-z0-9-]+")`.
|
||||
|
||||
Order JSON additions: `can_edit_items` (Placed and the operator mode permits), `can_cancel`, `can_refund`, `payment_method`, `shipping`, `bank_transfer` as specified in Task 2, `payments` (uuid-free summaries: state and operator name) for the status screen.
|
||||
|
||||
README: a Customer API section with every endpoint, its auth inputs, guards and the JSON shapes (the Task 1 contract), the host config note that `config/http.yaml` `cors.paths` must list `_paymentgateway/api/*` when a frontend runs on another origin, and the throttle bucket.
|
||||
|
||||
Smoke (customer_api_test.go TestCustomerAPI, Postgres): the matrix over owner, guest_code, guest_token, foreign user and anonymous for each endpoint (403-free: every refusal is 404 or 401 as specified); `GET /orders` lists only the caller's orders and paginates with the DATA-10 envelope; `payment-methods` lists cash and manual transfer when enabled and not when disabled; `shippings` is empty when the feature is off; selecting a shipping updates `shipping` and the gross total; selecting an unknown operator is 422; cancel from AwaitingPayment cancels the payment through the bridge and answers Canceled; refund-request on a Paid order answers RequestedRefund; pieces add and subtract change `count` and the totals exactly (`39.8400` → `79.6800` → `39.8400`); subtract at 1 keeps 1; removing the last item is 422; every mutation on a non-Placed order is 409; the order JSON never contains `guest_code`, `return_url` or a raw `cart` key.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && grep -c "g.Post\|g.Get\|g.Delete" ../sm-paymentgateway-plugin/routes.go | grep -qE '^(1[2-9]|[2-9][0-9])$' && grep -n "## Customer API" ../sm-paymentgateway-plugin/README.md | grep -q .</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails; fewer than twelve API route registrations; the README lacks the section.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestCustomerAPI|TestCustomerAPITracer)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: an unauthorized caller reached an endpoint, a guard was bypassed, a total drifted, the last item was removable, or the JSON leaked a private key.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "refund-request\|payment-method\|/pieces" ../sm-paymentgateway-plugin/routes.go` prints at least three lines.
|
||||
- `grep -n "Paginate" ../sm-paymentgateway-plugin/api/orders.go` prints at least one line.
|
||||
- `grep -n "cors.paths\|_paymentgateway/api/\*" ../sm-paymentgateway-plugin/README.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every handler of the Winter component has a tested endpoint with the same access rules and state guards.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The complete layer: PgOrderStatus with polling, PgCart, PgPaymentMethods, PgShippingMethods, PgBankTransfer, PgOrderList, PgConfirm, the playground, i18n parity and the layer README</name>
|
||||
<reversibility rating="costly">D-06: the component names and props are the override surface of every site app that mounts the layer.</reversibility>
|
||||
<files>../vue-paymentgateway-layer/app/components/Pg/OrderPage.vue, ../vue-paymentgateway-layer/app/components/Pg/Checkout.vue, ../vue-paymentgateway-layer/app/components/Pg/OrderStatus.vue, ../vue-paymentgateway-layer/app/components/Pg/Cart.vue, ../vue-paymentgateway-layer/app/components/Pg/PaymentMethods.vue, ../vue-paymentgateway-layer/app/components/Pg/ShippingMethods.vue, ../vue-paymentgateway-layer/app/components/Pg/BankTransfer.vue, ../vue-paymentgateway-layer/app/components/Pg/OrderList.vue, ../vue-paymentgateway-layer/app/components/Pg/Confirm.vue, ../vue-paymentgateway-layer/app/utils/states.ts, ../vue-paymentgateway-layer/app/composables/useOrders.ts, ../vue-paymentgateway-layer/app/assets/css/pg-base.css, ../vue-paymentgateway-layer/.playground/nuxt.config.ts, ../vue-paymentgateway-layer/.playground/app/pages/orders/[hash].vue, ../vue-paymentgateway-layer/.playground/app/pages/orders/index.vue, ../vue-paymentgateway-layer/README.md, ../vue-paymentgateway-layer/tests/orderPage.test.ts, ../vue-paymentgateway-layer/tests/i18n.test.ts, ../vue-paymentgateway-layer/tests/states.test.ts, ../vue-paymentgateway-layer/tests/checkout.test.ts</files>
|
||||
<read_first>../vue-paymentgateway-layer (Task 2 files), ../vue-journal-layer/README.md, .playground, app/components/Journal/PostList.vue and Pagination.vue (loading, empty and error patterns), tests/i18n.test.ts and tests/pagination.test.ts, the frozen tree: components/orders/*.htm (the thirteen state partials, cart.htm, order_list.htm, credit_card_form.htm is not ported), .planning/phases/18-payment-gateway/18-UI-SPEC.md (Component Contracts L1 to L8, the status tone map, the layer copy tables, the UI Considerations rows for the layer)</read_first>
|
||||
<action>Per D-06 and UI-SPEC L1 to L8.
|
||||
|
||||
Components: `PgOrderPage` gains the full state routing (Placed → PgCheckout; AwaitingPayment → PgOrderStatus with polling: refetch every 3 s up to 20 attempts, `pg.state.awaitingpayment_polling` title with the accent spinner, `aria-live="polite"` region, announce the new state, after 20 attempts the plain title with `pg.awaiting.slow` and a `pg.action.refresh` outline button, pause on `visibilitychange` hidden and resume when visible; every other state → PgOrderStatus); `PgCheckout` gains PgCart (editable when `can_edit_items`), PgShippingMethods (when shippings are listed), PgPaymentMethods, PgBankTransfer (manual transfer selected) and the action row with `pg.action.cancel` through PgConfirm (danger) and the Pay or `pg.checkout.place` button; Pay selects the payment and shipping methods first when they changed, then `process`; field errors (`pg.error.choose_payment`, `pg.error.choose_shipping`) under the fieldset with focus; the 409 answer shows `pg.error.stale` and refreshes; 429 shows `pg.error.throttled`. `PgPaymentMethods` and `PgShippingMethods` (fieldset, legend, the radio cards grid, logo, name, cost or `pg.shipping.free`, preselection, `pg.checkout.no_methods` disabling Pay). `PgCart` (the table, the unit sub-line, the shipping row, tfoot totals; editable: the stepper with `−` disabled at 1, the Remove button rendered only when more than one item remains and confirming through PgConfirm, busy rows, re-render from the response, `pg.error.process` in the alert on refusal; horizontal scroll below 40rem). `PgOrderStatus` (the panel with the 4px tone bar from `stateTone` in `app/utils/states.ts`: success for Paid, Underway, Shipped, Finished; warning for AwaitingPayment, RequestedRefund, AwaitingReturn, Quote; danger for Canceled, Rejected; neutral otherwise; the title and body from `pg.state.<code>`, the code and total dl, the read-only PgCart, the actions: cancel in AwaitingPayment, refund request when `can_refund`, none for Canceled and Rejected with the cart at opacity .6; PgBankTransfer between the dl and the cart for AwaitingPayment with the manual transfer operator). `PgBankTransfer` (heading, the dl with the four fields omitting empty ones, the sanitized `bank_details_html` through `v-html`, `pg.bank.reference` with the code in strong). `PgOrderList` (useOrders, the table with the accent link through a `to` prop function, status, date, total; pagination buttons; empty title and body; three skeleton rows; `pg.error.load` with Retry). `PgConfirm` (native dialog with showModal, the box, title, body, dismiss then confirm buttons, initial focus on dismiss, focus returned to the opener, both disabled while the confirmed request runs). `useOrders({ page })` under the bearer. `pg-base.css` completes the rules (radio cards, the alert block, the tone bar, the dialog, the skeletons, the cart table) on tokens only.
|
||||
|
||||
Playground: `.playground/nuxt.config.ts` extends the layer; `app/pages/orders/[hash].vue` mounts `<PgOrderPage :hash :guest-code="route.query.guest_code" :guest-token="route.query.guest_token" />`; `app/pages/orders/index.vue` mounts `<PgOrderList :to="(o) => `/orders/${o.hash_id}`" />`.
|
||||
|
||||
README (neutral, never naming a consuming application): installation as a pnpm git dependency pinned to a tag (`"vue-paymentgateway-layer": "git+ssh://git@git.golem15.com/golem15/vue-paymentgateway-layer.git#v0.1.0"`), `extends`, runtime config keys, the auth hook, the components and props, the override rule (same-name file), the tokens, the copy keys, the `#payment-action` slot contract for PaymentIntent mode, the Checkout redirect default (D-17), and the test command.
|
||||
|
||||
Tests: tests/orderPage.test.ts (the backstop: AwaitingPayment polls on a 3 s timer, stops when the stub answers Paid and the live region carries the new title, stops after 20 attempts with `pg.awaiting.slow`, pauses while `document.visibilityState` is hidden and resumes when visible; 404 renders the not-found panel; a thrown fetch renders `pg.error.load`), tests/states.test.ts (the tone map for all twelve states plus unknown), tests/i18n.test.ts (en and pl have the same key set and every `pg.*` key used by a component exists in both), tests/checkout.test.ts gains the method selection before process, the field error focus, the `pg.error.stale` path and the last-item rule of PgCart (no Remove button with one item, the stepper disabled at 1).</action>
|
||||
<verify>
|
||||
<automated>OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -qE '^# pass (1[5-9]|[2-9][0-9])' && ls ../vue-paymentgateway-layer/app/components/Pg | grep -c '\.vue$' | grep -qx 9 && pnpm --dir ../vue-paymentgateway-layer run dev:prepare >/dev/null 2>&1 && test -d ../vue-paymentgateway-layer/.playground/.nuxt</automated>
|
||||
<fails_when>Non-zero exit: the TAP summary lacks "# fail 0" or has fewer than fifteen passing tests (polling, tone map, i18n parity or the cart rules are wrong); fewer than nine Pg components; the playground does not prepare (a layer config or component error).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "visibilitychange" ../vue-paymentgateway-layer/app/components/Pg/OrderPage.vue` prints at least one line and `grep -n "showModal" ../vue-paymentgateway-layer/app/components/Pg/Confirm.vue` prints one line.
|
||||
- `! grep -rnE "#[0-9a-fA-F]{3,6}\b" ../vue-paymentgateway-layer/app/components | grep -q .` exits 0 (components carry no hex colours; tokens live in pg-tokens.css).
|
||||
- `grep -n "v0.1.0" ../vue-paymentgateway-layer/README.md` prints at least one line and `! grep -rniE "horoskopia|fonoteka|grzyby" ../vue-paymentgateway-layer --include='*.vue' --include='*.ts' --include='*.md' --include='*.json' --exclude-dir=node_modules | grep -q .` exits 0.
|
||||
- `grep -n "payment-action" ../vue-paymentgateway-layer/app/components/Pg/Checkout.vue ../vue-paymentgateway-layer/README.md` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>A site app mounts the order page and gets the whole customer journey, from checkout to every order state, with copy in two languages and no payment SDK.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 5: Create the three repositories, supply the anonymized shop-type order rows, and approve the pushes and the layer tag v0.1.0</name>
|
||||
<read_first>.planning/phases/18-payment-gateway/18-02-SUMMARY.md, 18-03-SUMMARY.md, 18-04-SUMMARY.md, 18-05-SUMMARY.md (local commits per repository, suites NOT RUN), ../sm-paymentgateway-plugin/scripts/winter-export-orders.sql, .planning/phases/17-blog-companions/17-05-SUMMARY.md (how the last repository creation and push ran), 18-REVIEW.md and 18-SECURITY.md when present</read_first>
|
||||
<action>Print a checklist and a release sheet, then stop.
|
||||
|
||||
Release sheet: the commits to push per repository (sm-paymentgateway-plugin main, sm-pgstripe-plugin main, vue-paymentgateway-layer main plus the annotated tag v0.1.0); the one-way surfaces (the v2 row format from plan 02 Task 1, the customer API contract from Task 1, the plugin table and migration IDs, the operator codes and route segments, the settings columns and config keys, the layer component names and props); the Docker suites recorded NOT RUN in plans 02 to 06; open high findings in 18-REVIEW.md or 18-SECURITY.md if present (an open high finding allows only "hold").
|
||||
|
||||
Checklist for the human: (1) create the three empty repositories named in user_setup under the golem15 group on git.golem15.com (the executor has no API token for the forge); (2) on one shop-type PHP application that uses shippings, quotes or the invoicer item type, run `scripts/winter-export-orders.sql` read-only (`SELECT id, cart, details FROM golem15_paymentgateway_orders LIMIT 50`) and place the anonymized output (names, e-mails, addresses, descriptions replaced; numbers, keys and order kept) where the executor can read it, or reply "fixtures: none" so plan 07 keeps the synthetic fixtures and records the gap (D-11 point 2); (3) approve the pushes and the tag. Record in the SUMMARY: the repository URLs, the fixture file path or "none", and the reply verbatim.</action>
|
||||
<instructions>
|
||||
Claude built and tested the two plugins and the layer locally (plans 02 to 06) and printed the release sheet and checklist.
|
||||
The human creates the repositories, exports the rows from a shop application (only the operator holds those credentials), and approves.
|
||||
</instructions>
|
||||
<verification>The verify command prints repos-ready after the human's reply.</verification>
|
||||
<resume-signal>Reply "repos created, fixtures at <path> (or none), push and tag approved", or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>for r in sm-paymentgateway-plugin sm-pgstripe-plugin vue-paymentgateway-layer; do git ls-remote "git@git.golem15.com:golem15/$r.git" >/dev/null 2>&1 || exit 1; done; echo repos-ready</automated>
|
||||
<fails_when>Non-zero exit or "repos-ready" absent: at least one of the three remote repositories does not exist or is not reachable with the executor's SSH key; stop and report (never create a repository under another name).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed repos-ready and the SUMMARY records the three repository URLs, the fixture path or "none", and the reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>The remotes exist, the fixture question is answered, and the human has approved publishing.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 6: Push the three repositories and tag the layer v0.1.0, inside a human checkpoint, only after its recorded reply</name>
|
||||
<precondition>Task 5's reply contains "push and tag approved" and `git ls-remote git@git.golem15.com:golem15/vue-paymentgateway-layer.git` succeeds</precondition>
|
||||
<files>../sm-paymentgateway-plugin/.git/config, ../sm-pgstripe-plugin/.git/config, ../vue-paymentgateway-layer/.git/config</files>
|
||||
<read_first>.planning/phases/17-blog-companions/17-05-SUMMARY.md (the push and tag commands used last time), ../vue-journal-layer/package.json (version field), this plan's Task 5 SUMMARY note (the reply)</read_first>
|
||||
<action>Do everything automatable, print the push sheet, then stop. For each of the three repositories: `git remote add origin git@git.golem15.com:golem15/<name>.git`, confirm `git status --porcelain` is empty and `git ls-remote origin refs/heads/main` prints nothing (a non-empty remote main means someone pushed first: stop and report, never force-push). Print the sheet: per repository the local `main` head and the number of commits it publishes, and for the layer the annotated tag `v0.1.0` to be created on that head. No `git push` and no `git tag` runs before the human's reply to this checkpoint (the project rule that pushes and tags execute only inside a human checkpoint after the recorded reply, as plan 01 Task 4 and plan 07 Task 5 do). After the reply "push": `git push -u origin main` in each repository, then in the layer `git tag -a v0.1.0 -m "vue-paymentgateway-layer v0.1.0"` on main and `git push origin v0.1.0`; never force-push; never push summercms.go here (plan 01 and plan 07 own that); record the pushed hashes and the reply verbatim in the SUMMARY. After "hold": push and tag nothing; record the sheet and the reply.</action>
|
||||
<instructions>
|
||||
Claude added the remotes, confirmed the three working trees are clean and the remote main branches empty, and printed the heads it would publish (the same heads Task 5 approved).
|
||||
The human reads the sheet and replies; the pushes and the tag run only after that reply.
|
||||
</instructions>
|
||||
<verification>Before the reply the verify prints push-sheet-ok; after "push", each local main equals its remote main and the annotated tag v0.1.0 is on the layer remote (the acceptance criteria below).</verification>
|
||||
<resume-signal>Reply "push" to publish exactly the listed heads and the tag, or "hold" to publish nothing.</resume-signal>
|
||||
<verify>
|
||||
<automated>for r in sm-paymentgateway-plugin sm-pgstripe-plugin vue-paymentgateway-layer; do test -z "$(git -C ../$r status --porcelain)" && git -C ../$r ls-remote origin >/dev/null 2>&1 && test -z "$(git -C ../$r ls-remote origin refs/heads/main)" || exit 1; done && test "$(git -C ../vue-paymentgateway-layer rev-parse --abbrev-ref HEAD)" = main && echo push-sheet-ok</automated>
|
||||
<fails_when>Non-zero exit or "push-sheet-ok" absent: a working tree is dirty, a remote is unreachable or lacks the `origin` entry, a remote main already has commits (never force-push; stop and report), or the layer is not on main; the sheet is never presented on a failed check.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- After "push": `for r in sm-paymentgateway-plugin sm-pgstripe-plugin vue-paymentgateway-layer; do test "$(git -C ../$r rev-parse main)" = "$(git -C ../$r ls-remote origin refs/heads/main | cut -f1)" || exit 1; done && test -n "$(git -C ../vue-paymentgateway-layer ls-remote --tags origin refs/tags/v0.1.0)" && test "$(git -C ../vue-paymentgateway-layer cat-file -t v0.1.0)" = tag` exits 0, `git -C ../vue-paymentgateway-layer describe --tags` prints v0.1.0, each repository's `git remote -v` names git.golem15.com/golem15, and the SUMMARY records the pushed hashes and the reply verbatim.
|
||||
- After "hold": `git -C ../<repo> ls-remote origin refs/heads/main` prints nothing for each of the three repositories, no tag exists on the layer remote, and the SUMMARY records the sheet and the reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>The two plugins and the layer are published at the commits the human approved, by a checkpoint that ran no push or tag before the reply, and the layer has its first tag.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| internet → customer API | anonymous or JWT callers; hash, guest code, guest token and bodies untrusted |
|
||||
| browser (layer) → API | same contract; the layer must not become a source of truth for amounts |
|
||||
| layer → host app | the payment-action slot hands PaymentIntent data to app code |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-37 | Information Disclosure (IDOR) | order lookup by hash | high | mitigate | hashids is not authorization; owner, constant-time guest_code or order-bound guest_token required; 404 for unknown and forbidden; TestCustomerAPI matrix |
|
||||
| T-18-38 | Spoofing | guest token reuse on another order or as an identity | high | mitigate | HMAC bound to one order id with TTL; never sets a principal; tests: the foreign-order case in TestCustomerAPITracer (Task 2) and TestGuestToken (plan 07 Task 3) |
|
||||
| T-18-39 | Tampering | client-supplied amounts, states or operators | high | mitigate | the API accepts only method codes, shipping ids and item ids; totals recomputed from the stored cart; operators validated against the enabled registry; transitions through the services; tests: TestCustomerAPI (Task 3: an unknown operator is 422, pieces change the totals exactly `39.8400` → `79.6800` → `39.8400`, every mutation outside Placed is 409) and TestCustomerAPITracer (Task 2: a second process is 409) |
|
||||
| T-18-40 | Tampering | item edits outside Placed, last-item removal, piece underflow | medium | mitigate | state guard 409; last item 422; SubtractPiece floors at 1; transaction with the row locked |
|
||||
| T-18-41 | Denial of Service | API abuse | medium | mitigate | `throttle:paymentgateway-api` 120 per minute per user or IP; body limits from surf |
|
||||
| T-18-42 | Information Disclosure | private keys in the order JSON | high | mitigate | the projection is an allowlist; `guest_code`, `return_url`, secure parameters and the raw cart never leave; tests: TestCustomerAPITracer (Task 2) and TestCustomerAPI (Task 3) assert the order JSON never contains `guest_code`, `return_url` or a raw `cart` key, plus the Task 2 acceptance grep on api/order_json.go |
|
||||
| T-18-43 | Tampering (XSS) | bank details HTML in PgBankTransfer | medium | mitigate | rendered from the seaglass-sanitized settings column only (plan 01 F3, plan 04); no other v-html |
|
||||
| T-18-44 | Spoofing (CSRF) | state-changing POSTs from a browser | medium | mitigate | JWT bearer or guest query credentials, no cookie session; the host CORS allowlist for cross-origin frontends |
|
||||
| T-18-45 | Repudiation | double process from a double click | low | mitigate | the layer keeps Pay busy through the redirect; the server answers 409 on a second process |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | the layer installs only nuxt 4.4.8, vue 3.5.35, @nuxtjs/i18n 10.4.0 and typescript 5.9.3, the pins already approved for vue-journal-layer; no Stripe.js (RESEARCH flagged 10.0.0); the lockfile is committed |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: decision recorded; the component file exists with its handlers.
|
||||
- Task 2: Docker-free plugin suite, layer TAP summary, the pins; the API tracer on a Docker host.
|
||||
- Task 3: Docker-free suite, route count, README; the full API matrix on a Docker host.
|
||||
- Task 4: layer TAP summary with at least fifteen passes, nine components, the playground prepares.
|
||||
- Task 5: the three remotes answer ls-remote.
|
||||
- Task 6: local and remote heads equal, annotated tag on the remote.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-06: the headless API and the layer replace the Winter component; the API ports every handler and access rule.
|
||||
- D-17: process returns the payment data; the layer redirects by default and delegates PaymentIntent confirmation.
|
||||
- The three repositories exist on the remote with the approved commits; the layer is tagged v0.1.0.
|
||||
- `go vet ./...`, `go test -short ./...` and the layer tests stay green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-06-SUMMARY.md` when done, with the Task 1 decision, the commits per repository, the pushed hashes and the tag, the fixture path or "none", the Docker suites run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
352
.planning/phases/18-payment-gateway/18-07-PLAN.md
Normal file
352
.planning/phases/18-payment-gateway/18-07-PLAN.md
Normal file
@@ -0,0 +1,352 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 07
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: ["18-01", "18-02", "18-03", "18-04", "18-05", "18-06"]
|
||||
files_modified:
|
||||
- scripts/check-phase18.sh
|
||||
- .planning/phases/18-payment-gateway/18-VALIDATION.md
|
||||
- modules/cabana/money_field_test.go
|
||||
- modules/cabana/money_field_postgres_test.go
|
||||
- modules/cabana/settings_secret_test.go
|
||||
- modules/cabana/settings_secret_postgres_test.go
|
||||
- modules/cabana/settings_tabs_test.go
|
||||
- modules/cabana/record_action_danger_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/cabana/README.md
|
||||
- admin/tests/form/MoneyField.test.ts
|
||||
- admin/tests/form/PasswordField.test.ts
|
||||
- admin/tests/views/SettingsFormView.test.ts
|
||||
- admin/tests/form/RecordActions.test.ts
|
||||
- admin/tests/form/PreviewView.test.ts
|
||||
- admin/tests/list/CellValue.test.ts
|
||||
- admin/tests/app/money.test.ts
|
||||
- ../sm-paymentgateway-plugin/money/money_test.go
|
||||
- ../sm-paymentgateway-plugin/money/amount_test.go
|
||||
- ../sm-paymentgateway-plugin/fsm/fsm_test.go
|
||||
- ../sm-paymentgateway-plugin/orders/machine_test.go
|
||||
- ../sm-paymentgateway-plugin/orders/bridge_test.go
|
||||
- ../sm-paymentgateway-plugin/payments/machine_test.go
|
||||
- ../sm-paymentgateway-plugin/payments/service_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/cart_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/item_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/details_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/legacy_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/registry_test.go
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/shop-export-cart-*.json
|
||||
- ../sm-paymentgateway-plugin/cart/testdata/legacy/shop-export-details-*.json
|
||||
- ../sm-paymentgateway-plugin/operators/registry_test.go
|
||||
- ../sm-paymentgateway-plugin/support/hashids_test.go
|
||||
- ../sm-paymentgateway-plugin/support/guest_token_test.go
|
||||
- ../sm-paymentgateway-plugin/support/redactor_test.go
|
||||
- ../sm-paymentgateway-plugin/support/format_test.go
|
||||
- ../sm-paymentgateway-plugin/currency/converter_test.go
|
||||
- ../sm-paymentgateway-plugin/models/fillable_test.go
|
||||
- ../sm-paymentgateway-plugin/events/mailers_test.go
|
||||
- ../sm-paymentgateway-plugin/updates/migrations_test.go
|
||||
- ../sm-paymentgateway-plugin/console/commands_test.go
|
||||
- ../sm-paymentgateway-plugin/api/authz_test.go
|
||||
- ../sm-paymentgateway-plugin/notification_security_test.go
|
||||
- ../sm-paymentgateway-plugin/admin_permissions_test.go
|
||||
- ../sm-paymentgateway-plugin/README.md
|
||||
- ../sm-pgstripe-plugin/webhook_test.go
|
||||
- ../sm-pgstripe-plugin/settings_security_test.go
|
||||
- ../sm-pgstripe-plugin/operator_test.go
|
||||
- ../sm-pgstripe-plugin/sdk_pin_test.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
- ../vue-paymentgateway-layer/tests/cart.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/orderList.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/confirm.test.ts
|
||||
- ../vue-paymentgateway-layer/tests/methods.test.ts
|
||||
- ../vue-paymentgateway-layer/README.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, SC-4, SC-5, D-02, D-07, D-09, D-10, D-11, D-13, D-14]
|
||||
estimate:
|
||||
tokens: 300000
|
||||
raw_tokens: 300000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-5 (project rule): every behaviour this phase added has a unit test delivered in this last plan, across summercms.go (Go and vitest), sm-paymentgateway-plugin, sm-pgstripe-plugin and vue-paymentgateway-layer; `go vet ./...` and `go test -short ./...` are green in every Go repository and the layer's `node --test` run reports zero failures."
|
||||
- "D-02: the 74 PHP tests (63 paymentgateway, 11 pgstripe) are mapped one to one to Go cases per RESEARCH § PHP test → Go case map, each Go test naming its PHP origin in a comment; the two pgstripe reliability tests are recorded as Phase 18.1 (subscription job) with Phase 18's own replay-idempotency test in their place."
|
||||
- "SC-1: `TestPaymentMatrix` asserts all 30 (state, transition) pairs of the payment machine and `TestOrderMatrix` all 228 pairs of the order machine against the PHP from-sets and guards; `TestRefundBridge` covers every PaymentRefunded branch; `TestMoneyVectors` covers every PHP vector in RESEARCH Pattern 1 plus allocation and rounding edge cases; `TestAmount4` covers the (12,4) and (10,4) ceilings, the rejection of a fifth decimal and of exponents, and the byte-identical round trip."
|
||||
- "D-11: `TestLegacyFixtures` decodes every file under `cart/testdata/legacy/` including the shop-type rows the human supplied at the plan 06 checkpoint (or records in the SUMMARY and the README that none were supplied and the synthetic fixtures stand in), asserts `[]` as an empty map, item order, unknown-type failure, and that a decoded legacy row re-encodes as v2 only."
|
||||
- "Security (PG-001 to PG-008, STRIPE-001 to STRIPE-003): mass assignment of state, amounts, owner and hash is refused; an empty hashids salt fails boot and legacy-salt ids decode; the redactor masks cards, secrets, tokens and e-mails; the registry refuses class names and unknown types; bank details are sanitized with allowed tags kept; a forged notification is 403 before any lookup; `?login=` never impersonates; guest tokens are order-scoped; every admin controller declares RequiredPermissions; Stripe secrets are encrypted at rest and round-trip, plaintext values pass through the lazy decrypt, the SDK pin is asserted from go.mod, a valid signature is accepted and an invalid or missing one refused."
|
||||
- "D-07 and D-13 (framework): `TestMoneyField*` cover compile errors (two sources, none, bad scale, bad max, non-string column, missing sibling), validation (invalid, too precise, too large, null), the list column projection with the per-row currency, and the Postgres save round trip; `TestSettingsSecret*` cover the projection, the three states, keep-on-empty, clear-on-null, the non-string 422 and the env fallback; `TestSettingsTabs`, `TestSettingsRichEditor` and `TestRecordActionDanger` cover F3 to F5; the vitest specs cover MoneyField (untouched value, comma input, too-precise, formatting backstops), PasswordField secret states (untouched not sent, clear sends null, Undo), SettingsFormView tabs, RecordActions danger, PreviewView Edit rule and CellValue money."
|
||||
- "SC-4 and the gate: `scripts/check-phase18.sh` (modelled on check-phase17.sh) runs the stages go, plugins, admin, layers, docs and postgres with required test names per repository and package, fails on a skip, a miss, `no tests to run`, a vitest failure or todo, a TAP `not ok` or a missing required test file, proves its detectors with `--self-test`, and `--all` runs everything, saying NOT RUN postgres loudly without a Docker daemon (`PHASE18_REQUIRE_POSTGRES=1` makes that a failure); `18-VALIDATION.md` is filled with the per-task map, `status: validated`, `nyquist_compliant: true` and `wave_0_complete: true`."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase18.sh"
|
||||
provides: "the fail-closed phase gate over four repositories"
|
||||
contains: "PHASE18_REQUIRE_POSTGRES"
|
||||
- path: "../sm-paymentgateway-plugin/orders/machine_test.go"
|
||||
provides: "TestOrderMatrix over 12 states × 19 transitions"
|
||||
contains: "func TestOrderMatrix"
|
||||
- path: "../sm-paymentgateway-plugin/payments/machine_test.go"
|
||||
provides: "TestPaymentMatrix over 6 states × 5 transitions"
|
||||
contains: "func TestPaymentMatrix"
|
||||
- path: "../sm-paymentgateway-plugin/money/money_test.go"
|
||||
provides: "TestMoneyVectors with every PHP vector"
|
||||
contains: "func TestMoneyVectors"
|
||||
- path: "../sm-pgstripe-plugin/sdk_pin_test.go"
|
||||
provides: "STRIPE-003: the SDK pin asserted from go.mod"
|
||||
contains: "v87.0.0"
|
||||
- path: "modules/cabana/money_field_test.go"
|
||||
provides: "money field compile and validation unit tests"
|
||||
contains: "func TestMoneyFieldCompile"
|
||||
- path: ".planning/phases/18-payment-gateway/18-VALIDATION.md"
|
||||
provides: "the filled validation map"
|
||||
contains: "nyquist_compliant: true"
|
||||
key_links:
|
||||
- from: "scripts/check-phase18.sh"
|
||||
to: "every *_test.go and tests/*.ts this phase added"
|
||||
via: "required test names run with -run '^(a|b)$' and TAP/vitest JSON checks"
|
||||
pattern: "TestOrderMatrix"
|
||||
- from: "../sm-paymentgateway-plugin/cart/legacy_test.go"
|
||||
to: "../sm-paymentgateway-plugin/cart/testdata/legacy"
|
||||
via: "every fixture file is decoded"
|
||||
pattern: "testdata/legacy"
|
||||
prohibitions:
|
||||
- statement: "No test is weakened, skipped or marked todo to make the gate green; a Docker suite that cannot run is recorded NOT RUN, never passed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No test calls api.stripe.com, api.nbp.pl, Fixer or any network; every outbound client in a test points at an httptest server."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Committed fixtures from the human's export carry no personal data; the anonymization is checked before commit."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed before the human's reply at the final checkpoint."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (plan 7 of 7, wave 7, the project rule "unit tests are the last plan"): full unit coverage of every behaviour plans 01 to 06 shipped, the 74-case PHP test map (D-02), the supplied shop-type fixtures (D-11), the security evidence for PG-001 to PG-008 and STRIPE-001 to STRIPE-003, the fail-closed phase gate `scripts/check-phase18.sh`, the filled validation map, and the final push approval.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip recorded by the orchestrator. `must_haves` derive from SC-1 to SC-5, the RESEARCH § PHP test → Go case map, § Validation Architecture and § Security Domain, and the three UI-SPEC backstop rows owned by plan 01 (completed here as full vitest specs). Prohibitions are recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- The gate follows `check-phase17.sh`'s shape (named-test lists per repository and package, self-test, `--all`) with repositories PG, PGSTRIPE and PG_LAYER and no host application (D-03: the proof host is a test inside pgstripe).
|
||||
- The two pgstripe reliability tests (subscription job idempotency) are 18.1 scope; Phase 18 keeps its own replay and race test (plan 05) as the idempotency evidence.
|
||||
- Fixture rows the human supplied are committed as `shop-export-cart-<n>.json` / `shop-export-details-<n>.json` after an anonymization check; if "none" was replied, the README and VALIDATION record the gap and the synthetic fixtures remain the D-11 evidence until a later import.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `scripts/check-phase18.sh` with stages `go`, `plugins`, `admin`, `layers`, `docs`, `postgres`, `--self-test`, `--all`; env overrides `PHASE18_ROOT`, `PG`, `PGSTRIPE`, `PG_LAYER`, `PHASE18_REQUIRE_POSTGRES`.
|
||||
- Framework tests: `TestMoneyFieldCompile`, `TestMoneyFieldValidate`, `TestMoneyListColumn`, `TestMoneyFieldSave` (Postgres), `TestSettingsSecretProjection`, `TestSettingsSecretPut`, `TestSettingsSecretPostgres`, `TestSettingsTabs`, `TestSettingsRichEditor`, `TestRecordActionDanger`, the conformance cases; vitest `MoneyField`, `PasswordField` secret states, `SettingsFormView` tabs, `RecordActions` danger, `PreviewView` Edit rule, `CellValue` money, `money.ts` helpers.
|
||||
- Plugin tests (sm-paymentgateway-plugin): `TestMoneyVectors`, `TestMoneyAllocate`, `TestMoneyRounding`, `TestAmount4`, `TestFSM`, `TestPaymentMatrix`, `TestOrderMatrix`, `TestRefundBridge`, `TestPaymentsService`, `TestCart`, `TestItem`, `TestDetails`, `TestLegacyFixtures`, `TestCartRegistry`, `TestOperatorRegistry`, `TestHashids`, `TestGuestToken`, `TestRedactor`, `TestFormatAmount`, `TestConverter`, `TestFillable`, `TestEvents`, `TestMailers`, `TestMigrations`, `TestPGCommands`, `TestImportWinter`, `TestCustomerAPIAuthz`, `TestNotificationSecurity`, `TestAdminPermissions`.
|
||||
- pgstripe tests: `TestStripeAuthentication`, `TestStripeDataHandling`, `TestStripeOperator`, `TestSDKPin`.
|
||||
- Layer tests: `cart.test.ts`, `orderList.test.ts`, `confirm.test.ts`, `methods.test.ts`.
|
||||
- `18-VALIDATION.md` filled; README Testing sections in the three repositories.
|
||||
|
||||
<objective>
|
||||
Close the phase with the tests the project rule reserves for the last plan: the PHP suites as a behavioural spec mapped case by case (D-02), the state-machine and money matrices (SC-1), the security evidence, the framework field and settings tests (D-07, D-13), the layer tests, a fail-closed gate script over all four repositories, and the validation sign-off; then the human approves the final pushes.
|
||||
|
||||
Purpose: SC-5 and QA-03; the next tag of each repository ships with these tests and the gate is what `/msd-verify-work` runs.
|
||||
Output: test files in four repositories, `scripts/check-phase18.sh`, the filled `18-VALIDATION.md`, README Testing sections, and the pushed test commits.
|
||||
|
||||
Execution budget: this plan is over the single-context smart zone by design (the binding seven-plan split under CLAUDE.md's lean-planning rule, which also makes this last plan carry every unit test; the estimate's confidence stays low until three phases have actuals). The executor runs it in a fresh context, commits after every task with the task number in the subject, writes the SUMMARY's per-task notes as it goes, and after a context reset resumes at the first task without a commit instead of restarting the plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-RESEARCH.md
|
||||
@.planning/phases/18-payment-gateway/18-VALIDATION.md
|
||||
@.planning/phases/18-payment-gateway/18-01-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-02-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-05-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-06-SUMMARY.md
|
||||
|
||||
Stage files by explicit path; planning docs (`18-VALIDATION.md`) and code in separate commits; no co-author tags; no push before Task 5's reply. Docker-backed suites skip under `-short`; the gate's postgres stage needs a Docker host (run through `sudo -u golem` if the socket is denied); record NOT RUN when no daemon answers. No test reaches the network.
|
||||
|
||||
<interfaces>
|
||||
The gate's required-name lists are the test names in "Artifacts this phase produces" plus the smoke tests of plans 01 to 06 (`TestMoneyFieldSmoke`, `TestMoneyListSmoke`, `TestSettingsSecretSmoke`, `TestSettingsRichEditorSmoke`, `TestRecordActionDangerSmoke`, `TestPaymentGatewayTracer`, `TestRefundBridgeSmoke`, `TestLegacyCartGoldens`, `TestNotificationRoute`, `TestNotificationRouteSmoke`, `TestCurrencyProviders`, `TestPGCommandsSmoke`, `TestImportWinterSmoke`, `TestOrdersAdminSmoke`, `TestPaymentsAdminSmoke`, `TestShippingsAdminSmoke`, `TestQuoteablesAdminSmoke`, `TestQuotesAdminSmoke`, `TestPGSettingsSmoke`, `TestMailerSettingsSmoke`, `TestMailTracer`, `TestMailersMatrixSmoke`, `TestHostStripeFlow`, `TestStripeWebhook`, `TestStripeSettingsSmoke`, `TestStripeImportWinterSmoke`, `TestFixtureAbsentFromProduction`, `TestCustomerAPITracer`, `TestCustomerAPI`) and the vitest and TAP names from plans 01 and 06.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: `scripts/check-phase18.sh` runs one required test per layer end to end, fail-closed, with its self-test proving every detector</name>
|
||||
<reversibility rating="reversible">A gate script; it can be rewritten without touching any contract.</reversibility>
|
||||
<files>scripts/check-phase18.sh</files>
|
||||
<read_first>scripts/check-phase17.sh (whole file: the stages, go_named, the vitest JSON and TAP detectors, --self-test, --all, the env overrides), scripts/check-phase10.sh (the --hygiene stage check-phase17 calls), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, .planning/phases/18-payment-gateway/18-VALIDATION.md (the seed map), the SUMMARY files of plans 01 to 06 (the test names that exist and the NOT RUN records)</read_first>
|
||||
<action>Create scripts/check-phase18.sh from check-phase17.sh's shape: header comment naming the stages; `set -euo pipefail`, NO_COLOR; ROOT, `PG=${PG:-$ROOT/../sm-paymentgateway-plugin}`, `PGSTRIPE=${PGSTRIPE:-$ROOT/../sm-pgstripe-plugin}`, `PG_LAYER=${PG_LAYER:-$ROOT/../vue-paymentgateway-layer}`; the required-name arrays for this tracer: go stage CABANA_SHORT_TESTS=(TestMoneyFieldCompile) placeholder populated from plan 01's compile-only subtests until Task 2 adds the names (use the names that exist now: the plan 01 compile-only tests under `TestMoneyField`), plugins stage PG_MONEY_TESTS=(TestMoneyVectorsSmoke TestAmount4ScanValue), PG_ROOT_TESTS=(TestNotificationRoute), PGSTRIPE_ROOT_TESTS=(TestStripeWebhook TestFixtureAbsentFromProduction), admin stage ADMIN_FILES=(tests/form/MoneyField.test.ts) with the backstop test names from plan 01, layers stage LAYER_FILES=(tests/checkout.test.ts tests/orderPage.test.ts) with the two backstop names, docs stage (TestDocsTree, docs:build --check, check-admin-openapi.sh --check, check-admin-dist.sh, check-phase10.sh --hygiene), postgres stage PG_CABANA_TESTS=(TestMoneyFieldSmoke) PG_PG_TESTS=(TestPaymentGatewayTracer) PG_PGSTRIPE_TESTS=(TestHostStripeFlow); the detectors copied verbatim (go test -json run fails on a fail, skip, zero matches, "no tests to run" or a missing required name; vitest JSON on a failed, skipped or todo test or a missing file or name; TAP on "not ok", fail, cancelled, skipped or todo counts, zero passes or a missing required test); `--self-test` plants a failing, a skipped and an empty run and expects each detector to refuse; `--all` runs self-test, go, plugins, admin, layers, docs, then postgres when `docker info` answers, else prints "NOT RUN postgres" and fails only with PHASE18_REQUIRE_POSTGRES=1. Every subsequent task appends its names to the arrays.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase18.sh --self-test && bash scripts/check-phase18.sh go && bash scripts/check-phase18.sh plugins && bash scripts/check-phase18.sh layers && bash scripts/check-phase18.sh docs</automated>
|
||||
<fails_when>Non-zero exit: a detector accepted planted failing, skipped or empty output; a required test from plans 01 to 06 does not exist, skips or fails; the layer TAP run reports a failure; a docs, OpenAPI, dist or hygiene check is red.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && bash scripts/check-phase18.sh postgres</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a required Postgres suite (the money save smoke, the tracer, the host flow) skipped, was missing or failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "PHASE18_REQUIRE_POSTGRES\|--self-test\|PG_LAYER" scripts/check-phase18.sh` prints at least three lines and `bash -n scripts/check-phase18.sh` exits 0.
|
||||
- `! grep -niE "horoskopia|fonoteka|grzyby" scripts/check-phase18.sh | grep -q .` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>One command proves one behaviour per layer across four repositories and refuses to go green on a skip, a miss or an empty run.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Framework coverage: cabana money field, settings secrets, richeditor and tabs on settings, record-action danger, OpenAPI conformance; the admin SPA specs</name>
|
||||
<reversibility rating="reversible">Tests only.</reversibility>
|
||||
<files>modules/cabana/money_field_test.go, modules/cabana/money_field_postgres_test.go, modules/cabana/settings_secret_test.go, modules/cabana/settings_secret_postgres_test.go, modules/cabana/settings_tabs_test.go, modules/cabana/record_action_danger_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/README.md, admin/tests/form/MoneyField.test.ts, admin/tests/form/PasswordField.test.ts, admin/tests/views/SettingsFormView.test.ts, admin/tests/form/RecordActions.test.ts, admin/tests/form/PreviewView.test.ts, admin/tests/list/CellValue.test.ts, admin/tests/app/money.test.ts, scripts/check-phase18.sh</files>
|
||||
<behavior>
|
||||
- TestMoneyFieldCompile: both `currency` and `currencyFrom`, neither, `scale` 1 and 5, a non-decimal `max`, `max` with more places than `scale`, a string column vs a non-Scanner struct column, `currencyFrom` naming a missing or non-string sibling, `options` on a money field: each fails with an error naming the field; a valid field compiles with the defaults (scale 4, no max).
|
||||
- TestMoneyFieldValidate: `"1,5"`, `"1.2.3"`, `"1e3"`, `" 12.50"`, `"abc"` → money.invalid; `"12.34567"` at scale 4 and `"1.234"` at scale 2 → money.too_precise; `"1000000.0000"` with max `999999.9999` → money.too_large; `null` accepted; `"-0.5000"` accepted; `"85.4193"` passes through byte-identical.
|
||||
- TestMoneyListColumn: a money column compiles with `currencyFrom`, is not searchable, not sortable by default, and the row projection carries the sibling currency value.
|
||||
- TestMoneyFieldSave (Postgres): the plan 01 smoke extended with update (untouched value re-saved byte-identical), a scale-2 field, and the list payload.
|
||||
- TestSettingsSecretProjection and TestSettingsSecretPut (Docker-free with a fake store where possible): data omits every password field; states stored, env and empty; absent and `""` keep; `null` clears; a number is 422; the response meta carries the new states; nothing in any response body equals the plaintext. TestSettingsSecretPostgres: the same against the real table with the ciphertext column asserted.
|
||||
- TestSettingsTabs: a tabbed settings schema exposes the tabs; an untabbed one is unchanged. TestSettingsRichEditor: sanitized value stored, notices returned, clean HTML byte-identical, non-string 422. TestRecordActionDanger: the record action payload carries `danger` true or false and existing actions default to false.
|
||||
- Conformance: the settings GET with meta.secrets and a form schema with a money field match admin.json.
|
||||
- Vitest: MoneyField (the three backstop assertions, the suffix follows currencyFrom, empty emits null, blur-only validation, read-only box, aria-invalid), money.ts helpers (parse, trim, format in en and pl), PasswordField secret states (stored, env, empty, pending clear with Undo, untouched not sent, null on clear, cleared after save, value absent from the DOM), SettingsFormView tabs (strip renders, default first, error tab activated with the chip, untabbed unchanged), RecordActions danger (dialog confirm variant), PreviewView Edit rule, CellValue money (en and pl, em dash, right alignment, no-currency row).
|
||||
</behavior>
|
||||
<action>Write the Go tests in `modules/cabana` next to the plan 01 smoke files, naming each the way the gate expects; extend `openapi_conformance_test.go` with the two cases; append the names to `CABANA_SHORT_TESTS`, `PG_CABANA_TESTS`, `ADMIN_FILES` and `ADMIN_REQUIRE` in scripts/check-phase18.sh; add the vitest specs under `admin/tests` with the full describe-plus-it names the gate lists; note the test names in the cabana README Testing section. Nothing in `modules/cabana` production code changes in this task except a bug a test exposes (commit such a fix separately with a `fix(18-07)` subject and record it in the SUMMARY).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-phase18.sh go && bash scripts/check-phase18.sh admin</automated>
|
||||
<fails_when>Non-zero exit: a cabana unit test fails or a vitest spec fails; the SPA does not typecheck; the gate's go or admin stage finds a required name missing, skipped or failing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestMoneyFieldSave|TestSettingsSecretPostgres|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "^func Test" modules/cabana/money_field_test.go modules/cabana/settings_secret_test.go` reports at least 2 per file.
|
||||
- `grep -n "TestMoneyFieldCompile\|TestSettingsSecretPut\|TestRecordActionDanger" scripts/check-phase18.sh` prints at least three lines.
|
||||
- `ls admin/tests/app/money.test.ts admin/tests/list/CellValue.test.ts` succeeds.
|
||||
</acceptance_criteria>
|
||||
<done>Every framework behaviour of plan 01 has a named unit test the gate requires.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: sm-paymentgateway-plugin coverage: the 63-case PHP map, money and FSM matrices, legacy fixtures including the supplied rows, registry, hashids, guest token, redactor, converter, fillable, events and mailers, migrations, commands, import, API authorization, notification security, admin permissions</name>
|
||||
<reversibility rating="reversible">Tests and fixtures only; the only production change is a `fix(18-07)` a test exposes.</reversibility>
|
||||
<files>../sm-paymentgateway-plugin/money/money_test.go, ../sm-paymentgateway-plugin/money/amount_test.go, ../sm-paymentgateway-plugin/fsm/fsm_test.go, ../sm-paymentgateway-plugin/orders/machine_test.go, ../sm-paymentgateway-plugin/orders/bridge_test.go, ../sm-paymentgateway-plugin/payments/machine_test.go, ../sm-paymentgateway-plugin/payments/service_test.go, ../sm-paymentgateway-plugin/cart/cart_test.go, ../sm-paymentgateway-plugin/cart/item_test.go, ../sm-paymentgateway-plugin/cart/details_test.go, ../sm-paymentgateway-plugin/cart/legacy_test.go, ../sm-paymentgateway-plugin/cart/registry_test.go, ../sm-paymentgateway-plugin/cart/testdata/legacy/shop-export-cart-*.json, ../sm-paymentgateway-plugin/cart/testdata/legacy/shop-export-details-*.json, ../sm-paymentgateway-plugin/operators/registry_test.go, ../sm-paymentgateway-plugin/support/hashids_test.go, ../sm-paymentgateway-plugin/support/guest_token_test.go, ../sm-paymentgateway-plugin/support/redactor_test.go, ../sm-paymentgateway-plugin/support/format_test.go, ../sm-paymentgateway-plugin/currency/converter_test.go, ../sm-paymentgateway-plugin/models/fillable_test.go, ../sm-paymentgateway-plugin/events/mailers_test.go, ../sm-paymentgateway-plugin/updates/migrations_test.go, ../sm-paymentgateway-plugin/console/commands_test.go, ../sm-paymentgateway-plugin/api/authz_test.go, ../sm-paymentgateway-plugin/notification_security_test.go, ../sm-paymentgateway-plugin/admin_permissions_test.go, ../sm-paymentgateway-plugin/README.md, scripts/check-phase18.sh</files>
|
||||
<behavior>
|
||||
- PHP map (comment each Go test with its PHP file and method): unit/CartTest (fill, add and subtract pieces, get items, index access, missing item error) → TestCart; unit/ItemTest (valid data, count change, formatable, invalid description, count, tax) → TestItem; unit/DetailsTest (getters, serialization, secure parameters never serialized) → TestDetails; unit/ExtendedStateMachineTest → TestFSM (after callbacks once per apply, unknown graph error); unit/FactoryConfigurationTest → TestOperatorRegistry (duplicate code, nil operator, non-Operator value refused); functional/CartFunctionalTest and ItemFunctionalTest → TestLegacyFixtures plus TestCartTotals (every RESEARCH vector); functional/PaymentFiniteStateMachineTest → TestPaymentMatrix (30 pairs); functional/PaymentModuleFunctionalTest → TestPaymentsService with a fake operator (create, cancel, refund, accept, the API-style responses); functional/PaymentTest and ShippingTest → TestAmount4 and TestFillable (the Financial pairs, protected fields, extra data storage); security/AccessControlTest → TestCustomerAPIAuthz (`?login=` never impersonates, PG-002; guest token functional and order-scoped; PG-003 forged notification refused; PG-004 every admin controller lists RequiredPermissions → TestAdminPermissions); security/DataHandlingTest (13) → TestFillable (PG-001 amount, financial fields, user_id), TestHashids (PG-005 empty salt fails boot, legacy salt decode fallback, the 24 vectors), TestRedactor (PG-006 cards, secrets, tokens, e-mails), TestCartRegistry (PG-007 allowlist, class names refused), TestBankDetailsSanitized in mailers_test (PG-008 allowed tags kept, scripts dropped); security/StateTest → TestFillable (state not fillable), TestOrderMatrix (out-of-order transition rejected), TestNotificationSecurity (no signature 403, verify before process with a nil DB).
|
||||
- Matrices: TestOrderMatrix iterates the 12 states × 19 transitions and asserts Can equals the PHP from-sets with the Ship guard in both settings; TestRefundBridge covers GuaranteeRefund (shippings on), DeferRefund, DirectRefund, GrantRefund, RefundAfterReturn and the no-op when none applies; TestMoneyVectors covers every RESEARCH Pattern 1 line; TestMoneyAllocate and TestMoneyRounding cover the half-even and half-odd options and negative amounts; TestAmount4 covers both ceilings, exponent and fifth-decimal rejection, nil, []byte and string sources.
|
||||
- TestLegacyFixtures decodes every `testdata/legacy/*.json`, including the supplied `shop-export-*` rows (anonymization checked: no e-mail domains, no street names beyond placeholders), asserts the rules of plan 02 Task 3, and that each re-encodes as v2.
|
||||
- TestGuestToken (generate, verify, TTL expiry, another order id, tampered byte), TestFormatAmount (en and pl, 2 to 4 decimals), TestConverter (ratio math vectors, auto-create, unsupported currency), TestEvents (one event per transition, the 24 names), TestMailers (the gating matrix Docker-free with a fake settings source and a recording mailer), TestMigrations (Postgres: up, down, up; the final column set per table equals RESEARCH § Schema), TestPGCommands and TestImportWinter (Postgres: the plan 02 and 04 smokes extended with boundary dates and every imported key).
|
||||
</behavior>
|
||||
<action>Write the tests in the packages named in files_modified; commit the supplied fixtures (or record "none supplied" in the SUMMARY, the README Testing section and 18-VALIDATION.md); append every name to the gate arrays (`PG_*_TESTS` per package for `-short`, `PG_PG_TESTS` for Postgres); add a Testing section to the plugin README listing the suites and the PHP map. A production bug a test exposes is fixed in a separate `fix(18-07)` commit and recorded.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-paymentgateway-plugin vet ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^(TestOrderMatrix|TestPaymentMatrix|TestMoneyVectors|TestLegacyFixtures|TestRedactor|TestHashids)$' -v ./... 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6 && bash scripts/check-phase18.sh plugins</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a plugin test fails; fewer than six of the named matrices and security tests pass; the gate's plugins stage finds a required name missing, skipped or failing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestMigrations|TestPGCommands|TestImportWinter|TestCustomerAPIAuthz|TestNotificationSecurity)$' -v ./... 2>&1 | grep -c '^--- PASS: Test' | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than five passes: a migration does not roll back, a column set drifts from the PHP schema, a command touched the wrong rows, an import key is lost, an impersonation or forged notification case passes.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -rc "PHP: tests/" ../sm-paymentgateway-plugin --include='*_test.go' | awk -F: '{s+=$2} END {print s}'` prints at least 63.
|
||||
- `grep -n "func TestOrderMatrix" ../sm-paymentgateway-plugin/orders/machine_test.go` and `grep -n "func TestPaymentMatrix" ../sm-paymentgateway-plugin/payments/machine_test.go` each print one line.
|
||||
- `! grep -rniE "@gmail|@wp\.pl|@o2\.pl|@onet" ../sm-paymentgateway-plugin/cart/testdata/legacy | grep -q .` exits 0.
|
||||
- `grep -n "## Testing" ../sm-paymentgateway-plugin/README.md` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every PHP test has a Go counterpart, every matrix cell is asserted, and every security invariant of the plugin has a failing-when-broken test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: pgstripe and layer coverage, the complete gate run, and the filled validation map</name>
|
||||
<reversibility rating="reversible">Tests and a planning document.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/webhook_test.go, ../sm-pgstripe-plugin/settings_security_test.go, ../sm-pgstripe-plugin/operator_test.go, ../sm-pgstripe-plugin/sdk_pin_test.go, ../sm-pgstripe-plugin/README.md, ../vue-paymentgateway-layer/tests/cart.test.ts, ../vue-paymentgateway-layer/tests/orderList.test.ts, ../vue-paymentgateway-layer/tests/confirm.test.ts, ../vue-paymentgateway-layer/tests/methods.test.ts, ../vue-paymentgateway-layer/README.md, scripts/check-phase18.sh, .planning/phases/18-payment-gateway/18-VALIDATION.md</files>
|
||||
<behavior>
|
||||
- pgstripe (PHP map with comments): security/AuthenticationTest (4) → TestStripeAuthentication (no signature refused, the internal flag alone never accepts, invalid signature refused, valid signature accepted); security/DataHandlingTest (5) → TestStripeDataHandling (keys never stored as plaintext, encrypted at rest, round trip through the column, plaintext passes the lazy decrypt in the import, STRIPE-003 → TestSDKPin reads go.mod and asserts `github.com/stripe/stripe-go/v87 v87.0.0`); reliability (2) → recorded as 18.1 with the plan 05 replay_and_race subtest named as Phase 18's idempotency evidence; TestStripeOperator (Docker-free with the httptest fake): Checkout and PaymentIntent request bodies, every status mapping, every event type, the browser return branch never accepting from input, refund parameters, the mode key selection and the env fallback.
|
||||
- Layer: cart.test.ts (stepper floor at 1, last-item Remove hidden, busy row, re-render from the response), orderList.test.ts (empty, skeleton, error with Retry, pagination), confirm.test.ts (dialog open, dismiss focus, confirm disabled while running, focus return), methods.test.ts (preselection, zero methods disables Pay, shippings fieldset hidden when empty).
|
||||
- Gate: every array populated; `bash scripts/check-phase18.sh --all` green (postgres on a Docker host or NOT RUN recorded).
|
||||
- 18-VALIDATION.md: the per-task map filled with every automated command and file, `status: validated`, `nyquist_compliant: true`, `wave_0_complete: true`, the sign-off boxes checked, the fixture status recorded.
|
||||
</behavior>
|
||||
<action>Write the pgstripe tests and the layer tests; append the names to `PGSTRIPE_*`, `PG_PGSTRIPE_TESTS`, `LAYER_FILES` and `LAYER_REQUIRE` in the gate; add Testing sections to the pgstripe and layer READMEs; run the complete gate; then fill 18-VALIDATION.md (frontmatter and the map) and commit it alone as a planning doc.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin test -count=1 -short -run '^(TestStripeAuthentication|TestStripeDataHandling|TestSDKPin|TestStripeOperator)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -qE '^# pass (2[5-9]|[3-9][0-9])' && bash scripts/check-phase18.sh --all && grep -q "nyquist_compliant: true" .planning/phases/18-payment-gateway/18-VALIDATION.md</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a pgstripe test fails or fewer than four named tests pass; the layer TAP run reports a failure or fewer than twenty-five passes; the gate's --all run is red (a stage failed, or PHASE18_REQUIRE_POSTGRES=1 without Docker); the validation map is not marked compliant.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh --all</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or any Postgres suite in the four repositories skipped, was missing or failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "v87.0.0" ../sm-pgstripe-plugin/sdk_pin_test.go` prints at least one line and `grep -rc "PHP: tests/" ../sm-pgstripe-plugin --include='*_test.go' | awk -F: '{s+=$2} END {print s}'` prints at least 9.
|
||||
- `ls ../vue-paymentgateway-layer/tests/*.test.ts | wc -l` prints at least 10.
|
||||
- `grep -n "status: validated\|wave_0_complete: true" .planning/phases/18-payment-gateway/18-VALIDATION.md` prints two lines and `git log --format=%s -1 -- .planning/phases/18-payment-gateway/18-VALIDATION.md` starts with "docs".
|
||||
</acceptance_criteria>
|
||||
<done>Every repository of the phase is covered, the gate runs green end to end, and the validation record says so.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 5: Final push approval: the test commits of four repositories, and the framework tag if plan 01 held it</name>
|
||||
<read_first>18-01-SUMMARY.md (the tag decision), 18-06-SUMMARY.md (the pushed hashes), this plan's Task 1 to 4 SUMMARY notes, 18-REVIEW.md and 18-SECURITY.md when present</read_first>
|
||||
<action>Do everything automatable, print the release sheet, then stop. In each of summercms.go, sm-paymentgateway-plugin, sm-pgstripe-plugin and vue-paymentgateway-layer: `git status --porcelain` on the paths this plan touches prints nothing, `git fetch origin`, `git log --oneline HEAD..origin/<main branch>` prints nothing (stop and report otherwise, never merge or rebase on your own). Sheet: the commits to push per repository; whether plan 01 pushed a framework tag or held it (if held, propose the tag now with the same rules as plan 01 Task 4); the `fix(18-07)` production commits, if any; the Docker suites run or NOT RUN; open high findings in 18-REVIEW.md or 18-SECURITY.md (an open high finding allows only "hold"). Stop for the human.</action>
|
||||
<decision>Push the test commits of the four repositories now (and tag the framework if it was held)?</decision>
|
||||
<context>These pushes publish the tests and any fix commits; the plugin and layer tags that consumers pin are separate later decisions, except the framework tag if plan 01 chose to hold it.</context>
|
||||
<options>
|
||||
<option id="push-all">
|
||||
<name>Push all four repositories (and tag the framework if held)</name>
|
||||
<pros>The phase closes with everything published and `/msd-verify-work` runs against pushed code.</pros>
|
||||
<cons>None beyond the usual: a pushed commit is public history.</cons>
|
||||
</option>
|
||||
<option id="push-no-tag">
|
||||
<name>Push the four repositories but keep the framework untagged</name>
|
||||
<pros>Lets a later phase batch framework changes into one tag.</pros>
|
||||
<cons>Plugins keep their replace lines until then.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold everything</name>
|
||||
<pros>Nothing leaves the machine until the human has read the gate output.</pros>
|
||||
<cons>The phase cannot be verified against pushed code.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push-all", "push-no-tag" or "hold"; the executor pushes only after one of the first two and records the pushed hashes (and the tag) in the SUMMARY.</resume-signal>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase18.sh --all && echo phase-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "phase-gate-ok" absent: the gate is red; never present the sheet on a red gate.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed phase-gate-ok and the SUMMARY records the reply verbatim; after "push-all" or "push-no-tag", for each repository `git rev-parse HEAD` equals `git rev-parse origin/<main branch>`; after "hold", `git log --oneline origin/<main branch>..HEAD` lists this plan's commits in each repository and nothing was pushed.
|
||||
</acceptance_criteria>
|
||||
<done>The human has decided what of the closing plan is published, and the record says what was pushed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| test fixtures → repository | exported rows may carry personal data |
|
||||
| gate script → four repositories | runs tests only; must not weaken or skip them |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-46 | Information Disclosure | committed shop-type fixture rows | high | mitigate | anonymization check before commit (no e-mail domains, placeholder names and addresses); tests: TestLegacyFixtures (Task 3: every `testdata/legacy/*.json` is checked for e-mail domains and non-placeholder names before it is decoded) and the Task 3 acceptance grep over `cart/testdata/legacy` that refuses real e-mail domains |
|
||||
| T-18-47 | Repudiation | a green gate that measured nothing | medium | mitigate | fail-closed detectors with `--self-test`; required names per package; NOT RUN recorded, never passed |
|
||||
| T-18-48 | Tampering | tests reaching real services | medium | mitigate | every outbound client in a test points at httptest; no Stripe, NBP or Fixer URL in test code |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed in this plan |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the self-test and the go, plugins, layers and docs stages; the postgres stage on a Docker host.
|
||||
- Task 2: cabana -short, typecheck, all vitest, the go and admin stages; the three Postgres cabana suites on a Docker host.
|
||||
- Task 3: plugin -short, the six named matrices and security tests, the plugins stage; the five Postgres suites on a Docker host.
|
||||
- Task 4: pgstripe -short and the four named tests, the layer TAP run, `--all`, the validation map; `--all` with PHASE18_REQUIRE_POSTGRES=1 on a Docker host.
|
||||
- Task 5: `--all` green before the sheet.
|
||||
- Feedback sampling (18-VALIDATION.md): the after-commit sample for Tasks 2 to 4 is the quick command of the touched repository (`go vet ./... && go test -short ./...` scoped to the package being tested, `npm --prefix admin test -- <the task's spec files>` for admin specs, `pnpm --dir ../vue-paymentgateway-layer test` for the layer), under 120 s; the gate stages and `--all` in the `<automated>` chains above, which include the admin build and the docs checker, are task-close gates run once per task before its commit and bounded at 10 minutes.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-5 and QA-03: unit tests for every behaviour, delivered last; `go vet` and `go test -short` green in every repository; the layer tests green.
|
||||
- D-02: the 74 PHP cases mapped and covered (72 here, 2 recorded as 18.1).
|
||||
- The gate `scripts/check-phase18.sh --all` is green and `18-VALIDATION.md` is validated and Nyquist-compliant.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-07-SUMMARY.md` when done, with the commits per repository, the test counts per repository, the PHP map coverage (72 of 74 plus the 18.1 note), the fixture status, the gate output summary (postgres run or NOT RUN), the push decision and hashes, and any `fix(18-07)` production change.
|
||||
</output>
|
||||
808
.planning/phases/18-payment-gateway/18-PATTERNS.md
Normal file
808
.planning/phases/18-payment-gateway/18-PATTERNS.md
Normal file
@@ -0,0 +1,808 @@
|
||||
# Phase 18: Payment Gateway - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-13
|
||||
**Files analyzed:** 38 new/modified files (framework 9, sm-paymentgateway-plugin 17, sm-pgstripe-plugin 7, vue-paymentgateway-layer 5)
|
||||
**Analogs found:** 33 / 38
|
||||
|
||||
All analog paths are git-tracked sources in the sibling checkouts under `/media/nvme/dev/golem15/summercms.io/summercms/` (verified with `git ls-files` in each repo). The sm-user-plugin analog is the submodule checkout at `sm-summercmsio-app/plugins/golem15/user` (remote `master` = `v0.1.1`, the revision RESEARCH.md recommends for the host's `go.work`). No `.msd/capabilities` mirror path is used.
|
||||
|
||||
Abbreviations: `FW` = `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go`, `JOURNAL` = `.../sm-journal-plugin`, `SITEMAP` = `.../sm-sitemap-plugin`, `USER` = `.../sm-summercmsio-app/plugins/golem15/user`, `FONO` = `.../fonoteka.go`, `JLAYER` = `.../vue-journal-layer`.
|
||||
|
||||
## File Classification
|
||||
|
||||
### Plan 1: framework (summercms.go)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `FW/modules/cabana/field_money.go` (new: `money` type keys, compile, model-type check, fill validation) | config/field-type | transform | `FW/modules/cabana/field_date.go` (compileDatepickerKeys, checkDateType) | exact (same role: a core scalar field type with options and a Go-type check) |
|
||||
| `FW/modules/cabana/form_schema.go` (add `"money"` to `formFieldTypes`, option keys to `formFieldKeys`) | config | transform | itself, lines 24-55 | exact |
|
||||
| `FW/modules/cabana/crud.go` (`scalarFormField` gains `"money"`) | service | CRUD | itself, line 1270 | exact |
|
||||
| `FW/modules/cabana/settings.go` (allow write-only `password` + `richeditor` on settings forms) | service | CRUD | itself, lines 86-121 | exact |
|
||||
| `FW/modules/cabana/field_dependent.go` (relax `controllerFormOnly` for richeditor on settings) | config | transform | itself, lines 123-124 | exact |
|
||||
| `FW/admin/src/components/form/fields/MoneyField.vue` (new) | component | request-response | `FW/admin/src/components/form/fields/MarkdownField.vue` + `NumberField.vue` | role-match |
|
||||
| `FW/admin/src/components/form/registry.ts` (map `money` → MoneyField) | config | — | itself, lines 83-89 | exact |
|
||||
| `FW/modules/cabana/admin_openapi.go`, `FW/admin/src/api/schema.d.ts`, `FW/modules/boardwalk/dist` | config | — | markdown precedent (`admin_openapi.go:394-410`) + `scripts/check-admin-openapi.sh`, `check-admin-dist.sh` | exact |
|
||||
| `FW/docs/backend/forms.md`, `FW/modules/cabana/README.md` | docs | — | datepicker rows (`forms.md:97`, `cabana/README.md:48`) | exact |
|
||||
| `FW/scripts/check-phase18.sh` | test/gate | batch | `FW/scripts/check-phase17.sh` | exact |
|
||||
|
||||
### Plans 2-3: sm-paymentgateway-plugin (new repo `git.golem15.com/golem15/sm-paymentgateway-plugin`, package `paymentgateway`)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `plugin.go` | plugin root | — | `JOURNAL/plugin.go` + `USER/plugin.go:184-258` | exact |
|
||||
| `go.mod`, `go.work` | config | — | `JOURNAL/go.work` | exact |
|
||||
| `updates/registry.go`, `updates/2026101xxxxx_create_golem15_paymentgateway_*.go` (6 tables + settings) | migration | batch | `JOURNAL/updates/registry.go`, `JOURNAL/updates/202610060001_create_golem15_journal_posts.go`, `SITEMAP/updates/202610110002_create_golem15_sitemap_settings.go` | exact |
|
||||
| `models/order.go`, `models/payment.go`, `models/currency.go`, `models/shipping.go`, `models/quoteable.go` | model | CRUD | `JOURNAL/models/settings.go` (TableName/Fillable/Rules/Register shape) | role-match |
|
||||
| `models/settings.go`, `models/mailer_settings.go` + `models/settings/fields.yaml` | model (settings singleton) | CRUD | `SITEMAP/models/settings.go`, `JOURNAL/models/settings.go` | exact |
|
||||
| `money/money.go` (`Money`, int64 x10^4), `money/amount.go` (`Amount4` Scanner/Valuer) | utility/value type | transform | `FONO/plugins/golem15/fonoteka/models/money_string.go` (Scan/Value shape only; its rounding is the anti-pattern) + `FW/modules/lagoon/encrypted.go:62-102` (Scan/Value on a struct) | role-match |
|
||||
| `fsm/fsm.go` | utility | event-driven | none (new table-driven FSM) | no analog |
|
||||
| `operators/operator.go` (interface, `HasOperators`, registry via `party.Activated`) | service/registry | — | `FW/modules/cabana/token_commands.go:69-77` (`app.Lookup[party.Activated]()` scan) | partial |
|
||||
| `events/events.go` (24 typed events) + bridge listeners | event types | event-driven | `USER/classes/events.go`, `SITEMAP/classes/events.go`; firing: `USER/controllers/registration.go:97`, `SITEMAP/classes/generator.go:221,261` | exact |
|
||||
| `events/mailers.go` (customer/employee mailers gated by settings) | service | event-driven | `USER/controllers/api_controller.go:872-884` (`sendTemplateVars`), `USER/classes/mail.go` (`MailTemplate` -en suffix) | exact |
|
||||
| `views/mail/**/*.htm` (26+1 templates, PL + `-en`) + `MailTemplates()`/`MailLayouts()` | config | — | `USER/views/mail/activate.htm`, `USER/plugin.go:184-203` | exact |
|
||||
| `routes.go` (notification route `_paymentgateway/{op}/{mode?}/{result?}` + `/_paymentgateway/api/v1`) | route | request-response | `JOURNAL/routes.go`, `USER/routes.go`; limiter buckets `JOURNAL/plugin.go:98-126` | exact (API), role-match (any-method notification route: loop over Get/Post/Put/Patch/Delete) |
|
||||
| `api/*.go` (customer order API handlers, optional auth) | controller | request-response | `JOURNAL/controllers/api/auth.go`, `JOURNAL/controllers/api/posts.go:28-45` | exact |
|
||||
| `console/pg_*.go` (5 commands) + `schedule.go` | command | batch | `FONO/plugins/golem15/fonoteka/schedule.go`, `SITEMAP/console/import_winter.go:75-119` (bonfire.Command shape) | exact |
|
||||
| `console/import_winter.go` (`paymentgateway:import-winter`) | command | batch | `SITEMAP/console/import_winter.go`, `FONO/plugins/golem15/golem/console/import_settings.go:34-65` | exact |
|
||||
| `admin.go`, `admin_permissions.go`, `admin_navigation.go`, `controllers/*.go`, `controllers/*/config_*.yaml`, `models/*/{fields,columns}.yaml` | controller (admin) | CRUD | `JOURNAL/admin.go`, `JOURNAL/controllers/admin_registry.go`, `JOURNAL/controllers/posts.go:1-110`; record actions: `USER/controllers/users_admin_controller.go:605-673` | exact |
|
||||
| `cart/*.go` (value objects, legacy JMS reader, v2 writer, extension registry) | model/serializer | transform | none (research Pattern 7 governs) | no analog |
|
||||
| `currency/*.go` (NBP/Fixer providers, converter) | service | request-response (outbound HTTP) | none | no analog |
|
||||
| `integration_test.go` + `admin_harness_test.go` | test | — | `JOURNAL/admin_harness_test.go:173-238` (`bootJournal`), `SITEMAP/testmain_test.go` (`TestMain` with `flag.Parse()`) | exact |
|
||||
|
||||
### Plan 4: sm-pgstripe-plugin (new repo, package `pgstripe`)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `plugin.go` (`Requires golem15.paymentgateway`, implements `paymentgateway.HasOperators`) | plugin root | — | `JOURNAL/plugin.go` | exact |
|
||||
| `models/settings.go` (+ `lagoon.Encrypted` secrets) + `models/settings/fields.yaml` + migration | model (settings) | CRUD | `SITEMAP/models/settings.go`; encrypted columns: `USER/models/oauth_identity.go:15-16` | exact |
|
||||
| `operator.go` (Stripe operator, PaymentIntent + Checkout) | service | request-response | none in Go (RESEARCH.md §Code Examples stripe-go probe) | no analog |
|
||||
| `client.go` (stripe-go client factory with backend override) | utility | — | RESEARCH.md code example only | no analog |
|
||||
| `webhook.go` (`ValidatePayload`, minimal decode) | middleware/handler | request-response | RESEARCH.md code example only | no analog |
|
||||
| `console/import_winter.go` (`pgstripe:import-winter`, Laravel decrypt) | command | batch | `SITEMAP/console/import_winter.go`, `FONO/.../golem/console/import_settings.go` | exact |
|
||||
| `host_test.go` / `integration_test.go` (D-03 proof host: user + pg + pgstripe + fake consumer + httptest Stripe) | test | — | `JOURNAL/admin_harness_test.go:173-238`, `JOURNAL/integration_test.go:17-35,195-230` | exact |
|
||||
|
||||
### Plan 5: vue-paymentgateway-layer (new repo)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `package.json`, `nuxt.config.ts`, `.playground/`, `tsconfig.json` | config | — | `JLAYER/package.json`, `JLAYER/nuxt.config.ts` | exact |
|
||||
| `app/utils/client.ts` (typed fetcher-injected client) | utility | request-response | `JLAYER/app/utils/client.ts` | exact |
|
||||
| `app/composables/usePaymentGatewayApi.ts` | hook | request-response | `JLAYER/app/composables/useJournalApi.ts` | exact |
|
||||
| `app/components/PaymentGateway/*.vue` (order, checkout, status) | component | request-response | `JLAYER/app/components/Journal/Pagination.vue` (CSS-token, `$t`, props shape) | role-match |
|
||||
| `tests/*.test.ts` (`node --test`) | test | — | `JLAYER/tests/client.test.ts` | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `FW/modules/cabana/field_money.go` (field type, transform)
|
||||
|
||||
**Analog:** `FW/modules/cabana/field_date.go`
|
||||
|
||||
**Option-key gate pattern** (lines 15-23, 42-65): keys valid only on the type, refused elsewhere; a `mode`-like enum validated with a clear error.
|
||||
```go
|
||||
// datepickerKeys are valid only on `type: datepicker` (D-20). mode is shared
|
||||
// with fileupload and gated by value.
|
||||
var datepickerKeys = []string{
|
||||
"format", "minDate", "maxDate", "yearRange", "firstDay", "twelveHour", "ignoreTimezone",
|
||||
}
|
||||
// datepickerRefusedKeys are generic field keys with no meaning on a datepicker.
|
||||
var datepickerRefusedKeys = []string{"options", "emptyOption", "nameFrom"}
|
||||
|
||||
func compileDatepickerKeys(typ string, values map[string]ast.Node, field *FormField) error {
|
||||
if typ != "datepicker" {
|
||||
for _, key := range datepickerKeys {
|
||||
if _, ok := values[key]; ok {
|
||||
return fmt.Errorf("%s is only valid on type: datepicker", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, key := range datepickerRefusedKeys {
|
||||
if _, ok := values[key]; ok {
|
||||
return fmt.Errorf("%s is not valid on type: datepicker", key)
|
||||
}
|
||||
}
|
||||
field.Mode = "datetime"
|
||||
if node, ok := values["mode"]; ok {
|
||||
mode, err := nodeString(node)
|
||||
if err != nil || (mode != "date" && mode != "datetime" && mode != "time") {
|
||||
return fmt.Errorf("mode %q must be date, datetime or time on type: datepicker", nodeText(node))
|
||||
}
|
||||
field.Mode = mode
|
||||
}
|
||||
```
|
||||
For `money`: keys `currency`, `currencyFrom`, `scale`; exactly one of `currency`/`currencyFrom`; `scale` 2-4 default 4.
|
||||
|
||||
**Model Go-type check pattern** (lines 253-268), called from `settings.go:113-117` and the controller compile:
|
||||
```go
|
||||
func checkDateType(model any, field FormField) error {
|
||||
sf, ok := structFieldByColumn(model, field.Name)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s is not a model column", field.Name)
|
||||
}
|
||||
t := sf.Type
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
want := map[string]reflect.Type{"datetime": timeType, "date": dateGoType, "time": timeOfDayGoType}[field.Mode]
|
||||
if t != want {
|
||||
names := map[string]string{...}
|
||||
return fmt.Errorf("field %s: datepicker mode %s needs a %s column, found %s", field.Name, field.Mode, names[field.Mode], sf.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
For `money`: accept `string`/`*string` or a type implementing both `sql.Scanner` and `driver.Valuer`; check `currencyFrom` names a sibling string column.
|
||||
|
||||
**Type registration** (`FW/modules/cabana/form_schema.go:24-30` and `crud.go:1270-1273`):
|
||||
```go
|
||||
formFieldTypes = map[string]struct{}{
|
||||
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
|
||||
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
||||
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
|
||||
"password": {}, "permissioneditor": {},
|
||||
"markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},
|
||||
"richeditor": {}, "repeater": {},
|
||||
}
|
||||
// crud.go:1270
|
||||
func scalarFormField(typ string) bool {
|
||||
switch typ {
|
||||
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker",
|
||||
```
|
||||
Add `"money"` to both, and `currency`, `currencyFrom`, `scale` to `formFieldKeys` (form_schema.go:38-52).
|
||||
|
||||
**Settings-form gate to relax** (`FW/modules/cabana/settings.go:86-93`, F-5):
|
||||
```go
|
||||
for _, field := range fields {
|
||||
// A settings screen has no admin controller to own actions or view models.
|
||||
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" || field.Type == permissionFieldType {
|
||||
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
|
||||
}
|
||||
...
|
||||
if err := controllerFormOnly(field); err != nil { // field_dependent.go:123 refuses richeditor/repeater
|
||||
```
|
||||
Plan 1 removes `password` from this list (write-only: GET projection omits it, empty input keeps the stored value, backed by `lagoon.Encrypted`) and lets `richeditor` through for settings with seaglass sanitizing.
|
||||
|
||||
**Docs + OpenAPI precedent:** `FW/modules/cabana/admin_openapi.go:394-410` (swag block for the markdown preview), `FW/docs/backend/forms.md:97` (one table row per type, plus a `## Money` section like `## Markdown and multilingual fields` at line 370), `FW/modules/cabana/README.md:48` (the Date pickers bullet is the shape for a Money bullet). Then `scripts/check-admin-openapi.sh`, `npm --prefix admin run build`, commit `modules/boardwalk/dist`, `scripts/check-admin-dist.sh`.
|
||||
|
||||
---
|
||||
|
||||
### `FW/admin/src/components/form/fields/MoneyField.vue` (component)
|
||||
|
||||
**Analog:** `FW/admin/src/components/form/fields/MarkdownField.vue` (lines 1-26) for the control contract; `NumberField.vue` for the input.
|
||||
```ts
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
```
|
||||
**Registration** (`FW/admin/src/components/form/registry.ts:83-89`):
|
||||
```ts
|
||||
['datepicker', DatepickerField],
|
||||
['markdown', MarkdownField],
|
||||
['mlmarkdown', MLMarkdownField],
|
||||
```
|
||||
Add `['money', MoneyField]`. The value travels as the exact decimal string; `Intl.NumberFormat` is display-only. `PreviewField.vue:24` lists types that own their preview control (`ownControl` set); add `money` there if the preview formats by locale.
|
||||
|
||||
---
|
||||
|
||||
### `sm-paymentgateway-plugin/plugin.go` and `sm-pgstripe-plugin/plugin.go` (plugin root)
|
||||
|
||||
**Analog:** `JOURNAL/plugin.go` (lines 1-58, 85-96, 128-130)
|
||||
```go
|
||||
package journal
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/updates"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
_ party.Plugin = (*Plugin)(nil)
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
_ pact.HasCommands = (*Plugin)(nil)
|
||||
_ pact.HasRoutes = (*Plugin)(nil)
|
||||
_ surf.BucketProvider = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
//go:embed lang
|
||||
var langFS embed.FS
|
||||
|
||||
type Plugin struct{ app *backpack.App }
|
||||
|
||||
func (p *Plugin) ID() string { return "golem15.journal" }
|
||||
func (p *Plugin) Requires() []string { return []string{"golem15.translate"} }
|
||||
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
|
||||
func (p *Plugin) Boot(app *backpack.App) error { p.app = app; ...; return p.wireSearch(app) }
|
||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||
func (p *Plugin) Models() []any { return models.All() }
|
||||
func (p *Plugin) Commands() []bonfire.Command { return []bonfire.Command{ console.ExportPosts(p.app), console.ImportPosts(p.app) } }
|
||||
|
||||
func init() { party.Register(&Plugin{}) }
|
||||
```
|
||||
**Mail capability + console DB access** (`USER/plugin.go:184-225`):
|
||||
```go
|
||||
func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS }
|
||||
func (p *Plugin) MailTemplates() []string {
|
||||
return []string{
|
||||
"golem15.user::mail.activate",
|
||||
"golem15.user::mail.activate-en",
|
||||
...
|
||||
}
|
||||
}
|
||||
func (p *Plugin) MailLayouts() map[string]string {
|
||||
return map[string]string{"user": "golem15.user::mail.layouts.user"}
|
||||
}
|
||||
// withDB runs fn with the application's database: the one the serve command
|
||||
// published, or, when a console command runs, one opened from the config.
|
||||
func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil { return fn(gdb) }
|
||||
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
|
||||
if err != nil { return err }
|
||||
defer sqlDB.Close()
|
||||
return fn(gdb)
|
||||
}
|
||||
```
|
||||
paymentgateway: ID `golem15.paymentgateway`, `Requires []string{"golem15.user"}`, add `pact.HasSchedule`, `pact.HasSettings`, `pact.HasPermissions`, `pact.HasNavigation`, `pact.AdminAssets`, `pact.HasAdminControllers`, the postcard mail interfaces, and (in Boot) the operator registry scan. pgstripe: `Requires []string{"golem15.paymentgateway"}` and implements `paymentgateway.HasOperators`.
|
||||
|
||||
**go.work** (`JOURNAL/go.work`):
|
||||
```
|
||||
go 1.27.0
|
||||
use .
|
||||
replace git.golem15.com/golem15/summercms => ../summercms.go
|
||||
replace git.golem15.com/golem15/sm-translate-plugin => ../sm-translate-plugin
|
||||
replace git.golem15.com/golem15/sm-user-plugin => ../sm-grzybyfunkcjonalne-app/plugins/golem15/user
|
||||
```
|
||||
For pgstripe add `replace git.golem15.com/golem15/sm-paymentgateway-plugin => ../sm-paymentgateway-plugin`; point sm-user-plugin at `../sm-summercmsio-app/plugins/golem15/user` (remote HEAD, RESEARCH open question 4).
|
||||
|
||||
---
|
||||
|
||||
### `updates/*.go` (migrations)
|
||||
|
||||
**Analog:** `JOURNAL/updates/registry.go` (whole file) + `JOURNAL/updates/202610060001_create_golem15_journal_posts.go` + `SITEMAP/updates/202610110002_create_golem15_sitemap_settings.go`
|
||||
```go
|
||||
// registry.go
|
||||
var all []*gormigrate.Migration
|
||||
func Register(ms ...*gormigrate.Migration) { all = append(all, ms...) }
|
||||
func All() []*gormigrate.Migration { return all }
|
||||
|
||||
// one file per table, timestamp-prefixed ID, raw SQL in a loop, init() registers
|
||||
var createPosts = []*gormigrate.Migration{{
|
||||
ID: "202610060001_create_golem15_journal_posts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE golem15_journal_posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NULL,
|
||||
...
|
||||
created_at TIMESTAMPTZ NULL,
|
||||
updated_at TIMESTAMPTZ NULL
|
||||
)`,
|
||||
`CREATE INDEX golem15_journal_posts_user_id_index ON golem15_journal_posts (user_id)`,
|
||||
`CREATE UNIQUE INDEX golem15_journal_posts_slug_unique ON golem15_journal_posts (slug)`,
|
||||
} {
|
||||
if err := tx.Exec(stmt).Error; err != nil { return err }
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS golem15_journal_posts`).Error },
|
||||
}}
|
||||
func init() { Register(createPosts...) }
|
||||
|
||||
// settings singleton: INTEGER PK + seed row 1
|
||||
`CREATE TABLE golem15_sitemap_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
frontend_url TEXT NULL,
|
||||
enable_priority_tiers BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
...)`,
|
||||
`INSERT INTO golem15_sitemap_settings (id) VALUES (1)`,
|
||||
```
|
||||
Port the six PHP tables with the exact column names from RESEARCH.md §Schema: `NUMERIC(12,4)`/`NUMERIC(10,4)` for `*_amount_stored`, `BOOLEAN` for `tinyint(1)`, `TEXT` for `mediumtext`, `TIMESTAMPTZ NULL` timestamps (the journal convention), indexes on `hash_id`, `user_id`, `shipping_id`, `successful_payment_id`, `order_id`, `operator`, `currency`, `native_currency`, a UNIQUE on `payments.uuid` and `currencies.iso`, a composite PK on `qs_orders(order_id, quoteable_id)`.
|
||||
|
||||
---
|
||||
|
||||
### `models/*.go` (GORM models) and `models/settings.go`
|
||||
|
||||
**Analog:** `SITEMAP/models/settings.go` (lines 22-55, 85-87) and `JOURNAL/models/settings.go`
|
||||
```go
|
||||
// Settings is the sitemap settings singleton (row id 1) on
|
||||
// golem15_sitemap_settings, the PHP Golem15\Sitemap\Models\Settings ...
|
||||
type Settings struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
FrontendURL *string `gorm:"column:frontend_url"`
|
||||
EnablePriorityTiers bool `gorm:"column:enable_priority_tiers"`
|
||||
...
|
||||
}
|
||||
func (Settings) TableName() string { return "golem15_sitemap_settings" }
|
||||
// Fillable lists only the settings columns.
|
||||
func (Settings) Fillable() []string { return []string{"frontend_url", "frontend_default_locale", ...} }
|
||||
func (Settings) Rules() map[string]string { return map[string]string{} }
|
||||
func init() { Register(Settings{}) }
|
||||
```
|
||||
`JOURNAL/models/settings.go:208-225` shows `Rules()` with `lagoon.Validate` strings (`"nullable|integer|min:0|max:100"`, `"boolean"`, `"nullable|url|max:500"`): use these for the four timeouts (`integer|min:1|max:30`) and `default_currency` (`required|string|size:3`).
|
||||
|
||||
Order/Payment models: same shape; `Fillable()` must exclude `state`, `*_amount_stored`, `currency`, `native_*`, `user_id`, `hash_id`, `guest_code`, `successful_payment_id` (PG-001). Amount columns are typed `money.Amount4` (Scanner/Valuer) beside a `Currency string` column; `Amount() money.Money` combines them.
|
||||
|
||||
**Encrypted column precedent** (`USER/models/oauth_identity.go:15-16`), for pgstripe secrets:
|
||||
```go
|
||||
AccessToken lagoon.Encrypted `gorm:"column:access_token" json:"-"`
|
||||
RefreshToken lagoon.Encrypted `gorm:"column:refresh_token" json:"-"`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `money/amount.go` (`Amount4` Scanner/Valuer)
|
||||
|
||||
**Analog (shape only):** `FONO/plugins/golem15/fonoteka/models/money_string.go:13-54` and `FW/modules/lagoon/encrypted.go:62-102`
|
||||
```go
|
||||
// MoneyString ... Scan formats a Postgres numeric; Value is a passthrough
|
||||
type MoneyString string
|
||||
|
||||
func (m *MoneyString) Scan(src any) error {
|
||||
if m == nil { return fmt.Errorf("money: scan on nil receiver") }
|
||||
if src == nil { *m = ""; return nil }
|
||||
var raw string
|
||||
switch v := src.(type) {
|
||||
case string: raw = v
|
||||
case []byte: raw = string(v)
|
||||
default: return fmt.Errorf("money: unsupported scan type %T", src)
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
...
|
||||
}
|
||||
func (m MoneyString) Value() (driver.Value, error) {
|
||||
if string(m) == "" { return nil, nil }
|
||||
return string(m), nil
|
||||
}
|
||||
```
|
||||
Copy the nil/string/[]byte switch. **Do not copy** `formatMoney4` (`big.Rat.FloatString(4)` rounds silently; D-10 forbids it). Replace with the exact parser sketched in RESEARCH.md §Code Examples (`parseAmount4`: reject >4 fraction digits, exponents, non-digits; `strconv.ParseInt` overflow is an error). `Value()` returns `fmt.Sprintf("%d.%04d")` with the sign handled separately.
|
||||
|
||||
---
|
||||
|
||||
### `operators/operator.go` (registry over `party.Activated`)
|
||||
|
||||
**Analog:** `FW/modules/cabana/token_commands.go:69-77`
|
||||
```go
|
||||
func tokenCatalogForApp(app *backpack.App) (*Registry, error) {
|
||||
plugins := []party.Plugin{}
|
||||
if app != nil {
|
||||
if activated, ok := app.Lookup[party.Activated](); ok && activated != nil {
|
||||
plugins = []party.Plugin(activated)
|
||||
}
|
||||
}
|
||||
return compileTokenCatalog(plugins)
|
||||
}
|
||||
```
|
||||
In paymentgateway `Boot`, iterate `activated`, type-assert `HasOperators`, collect `PaymentOperators()`, fail on duplicate `Code()`, publish the registry with `app.Publish(reg)` (`JOURNAL/plugin.go:78`) so pgstripe, the route and the API `app.Lookup[*operators.Registry]()`. The interface itself is RESEARCH.md Pattern 4 (no framework analog by design; keep it out of `pact`).
|
||||
|
||||
---
|
||||
|
||||
### `events/events.go` (typed festival events) and `events/bridge.go`
|
||||
|
||||
**Analog:** `USER/classes/events.go:19-29`, `SITEMAP/classes/events.go:21-30`; firing at `USER/controllers/registration.go:97` and `SITEMAP/classes/generator.go:221,261`
|
||||
```go
|
||||
// RegisterEvent is fired after a user row is created (Winter's
|
||||
// golem15.user.register).
|
||||
type RegisterEvent struct {
|
||||
User *models.User
|
||||
Payload map[string]any
|
||||
}
|
||||
|
||||
// BeforeAddItem is the typed successor of the PHP event
|
||||
// golem15.sitemap.beforeAddItem. The generator fires it with
|
||||
// festival.Bus.UntilHandled ...
|
||||
//
|
||||
// app.Events.Listen("acme.seo", func(ctx context.Context, e *classes.BeforeAddItem) error {
|
||||
|
||||
// firing
|
||||
return app.Events.Fire(ctx, &classes.RegisterEvent{User: user, Payload: payload})
|
||||
```
|
||||
One struct per PHP event class with a doc comment naming the PHP class and an exported `const OrderPaidEventName = "Golem15\\PaymentGateway\\Events\\OrderPaid"`. Pointer types for events listeners read; fire with `app.Events.Fire` after the row is saved inside the transaction (RESEARCH Pattern 3, Pitfall 6). The payment→order bridge is a set of `app.Events.Listen("golem15.paymentgateway", func(ctx, *PaymentAccepted) error {...})` registrations in `Boot`, gated by `ordersEnabled`.
|
||||
|
||||
---
|
||||
|
||||
### `events/mailers.go` and `views/mail/**`
|
||||
|
||||
**Analog:** `USER/controllers/api_controller.go:872-884`, `USER/classes/mail.go:41-46`, `USER/views/mail/activate.htm`
|
||||
```go
|
||||
func sendTemplateVars(ctx context.Context, app *backpack.App, template, to string, vars map[string]any) {
|
||||
if app == nil || to == "" { return }
|
||||
mailer, ok := app.Lookup[postcard.Mailer]()
|
||||
if !ok || mailer == nil {
|
||||
slog.Default().Error("golem15.user: mailer is not configured", "template", template)
|
||||
return
|
||||
}
|
||||
if err := mailer.Send(ctx, postcard.Message{Template: template, To: []string{to}, Vars: vars}); err != nil {
|
||||
slog.Default().Error("golem15.user: send mail", "template", template, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// MailTemplate appends -en for English and leaves every other locale,
|
||||
// including empty and pl, on the unsuffixed Polish template name.
|
||||
func MailTemplate(base, locale string) string {
|
||||
if locale == "en" { return base + "-en" }
|
||||
return base
|
||||
}
|
||||
```
|
||||
Template file (`USER/views/mail/activate.htm`):
|
||||
```
|
||||
subject = "Activate your account"
|
||||
description = "Aktywacja konta"
|
||||
layout = "user"
|
||||
==
|
||||
Cześć **{{ .Name }}**.
|
||||
|
||||
[Aktywuj konto]({{ .Link }})
|
||||
```
|
||||
paymentgateway: `views/mail/order/<name>.htm` + `<name>-en.htm` for each of the 26 (+ `underway`) codes, registered as `golem15.paymentgateway::mail.order.<name>` / `...-en`, layout `golem15.paymentgateway::mail.layouts.paymentgateway`. Wrap sends in `lagoon.AfterCommit(ctx, db, func(ctx, db) {...})` (`FW/modules/lagoon/transaction.go:103`) and gate each on the MailerSettings switch.
|
||||
|
||||
---
|
||||
|
||||
### `routes.go` (notification route + customer API)
|
||||
|
||||
**Analog:** `JOURNAL/routes.go` (whole file) and `JOURNAL/plugin.go:98-126` (limiter buckets)
|
||||
```go
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group("/_journal/api/v1", surf.Use("throttle:journal-public-api"), func(g pact.Router) {
|
||||
g.Get("/posts", apicontrollers.Index(p.app))
|
||||
g.Get("/posts/{slug}", apicontrollers.Show(p.app))
|
||||
g.Where("slug", `[a-z0-9][a-z0-9\-/]*`)
|
||||
...
|
||||
})
|
||||
r.Group("/_journal/api/v1", surf.Use("throttle:journal-api"), func(g pact.Router) {
|
||||
g.Post("/posts", apicontrollers.Store(p.app))
|
||||
g.Delete("/posts/{id}", apicontrollers.Destroy(p.app))
|
||||
g.Where("id", `[0-9]+`)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Buckets registers PHP limiter names ... 429 bodies stay surf {"message":"Too Many Attempts."}.
|
||||
func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||
trusted := surf.TrustedProxies(p.app.Config)
|
||||
return map[string]surf.Bucket{
|
||||
"journal-public-api": {Name: "journal-public-api", Max: 120, Decay: time.Minute,
|
||||
Key: func(r *http.Request) string { return "journal-public|" + surf.ClientIP(r, trusted) }},
|
||||
}
|
||||
}
|
||||
```
|
||||
`USER/routes.go:83-85` shows per-route middleware for the authenticated endpoints: `g.Post("/tokens", controllers.APITokenStore(p.app), "jwt.auth")`. The customer API uses a plugin-local optional-JWT wrapper instead (RESEARCH Pattern 11); `GET /orders` (list) can use `"jwt.auth"` directly.
|
||||
|
||||
`pact.Router` (`FW/modules/pact/capabilities.go:66-76`) has only `Get/Post/Put/Patch/Delete` and no optional segments: register the notification handler in a loop over the five methods for `/_paymentgateway/{op}`, `/_paymentgateway/{op}/{mode}`, `/_paymentgateway/{op}/{mode}/{result}` under `surf.Use("throttle:paymentgateway-notify")`, with no `backend`/CSRF middleware.
|
||||
|
||||
---
|
||||
|
||||
### `api/*.go` (customer order API)
|
||||
|
||||
**Analog:** `JOURNAL/controllers/api/auth.go` (lines 245-344) and `JOURNAL/controllers/api/posts.go:28-45`
|
||||
```go
|
||||
func writeAPIError(w http.ResponseWriter, status int, message string) {
|
||||
wire.WriteJSON(w, status, map[string]any{"error": message})
|
||||
}
|
||||
func writeValidation(w http.ResponseWriter, errs map[string][]string) {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": errValidationFailed, "errors": errs})
|
||||
}
|
||||
func hasBearer(r *http.Request) bool {
|
||||
h := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
token, ok := strings.CutPrefix(h, "Bearer ")
|
||||
return ok && strings.TrimSpace(token) != ""
|
||||
}
|
||||
// optionalBackendPrincipal verifies ... only when Authorization Bearer is present.
|
||||
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
|
||||
if r == nil || !hasBearer(r) { return r }
|
||||
out, _, err := cabana.ResolveBackend(app, r, journalCover)
|
||||
if err != nil || out == nil { return r }
|
||||
return out
|
||||
}
|
||||
func gdb(app *backpack.App) (*gorm.DB, error) {
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil { return nil, errors.New("journal api: database is not configured") }
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// handler shape
|
||||
func Index(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r = optionalBackendPrincipal(app, r)
|
||||
db, err := gdb(app)
|
||||
if err != nil { writeAPIError(w, http.StatusInternalServerError, "Internal server error"); return }
|
||||
pr, _ := bouncer.User(r.Context())
|
||||
```
|
||||
Difference for paymentgateway: the principal is a frontend user, not a backend one, so the optional wrapper delegates to the bouncer `jwt` middleware (`USER/plugin.go:230-247` obtains it with `reg.Middleware("jwt")` from `app.Lookup[*bouncer.Registry]()`) instead of `cabana.ResolveBackend`; invalid Bearer → 401, absent → anonymous; then `guest_code` / `guest_token` checks with `subtle.ConstantTimeCompare` and 404 for forbidden.
|
||||
|
||||
---
|
||||
|
||||
### `console/pg_*.go`, `schedule.go`, `console/import_winter.go`
|
||||
|
||||
**Analog:** `FONO/plugins/golem15/fonoteka/schedule.go` (whole file), `SITEMAP/console/import_winter.go:75-119`, `FONO/plugins/golem15/golem/console/import_settings.go:34-65`
|
||||
```go
|
||||
var _ pact.HasSchedule = (*Plugin)(nil)
|
||||
// Schedule ports Plugin.php registerSchedule ... runs daily at 00:00 app time (Laravel ->daily()).
|
||||
func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
return []pact.ScheduledCommand{
|
||||
{Command: "fonoteka:prune-notifications", Cadence: pact.Daily()},
|
||||
}
|
||||
}
|
||||
```
|
||||
paymentgateway: four `pact.Daily()` entries plus `{Command: "pg:update-currencies", Cadence: pact.Every(10 * time.Minute)}` (`FW/modules/pact/capabilities.go:124,134`).
|
||||
|
||||
Command shape (`SITEMAP/console/import_winter.go:75-119`):
|
||||
```go
|
||||
func ImportWinter(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "sitemap:import-winter",
|
||||
Description: "Copy a WinterCMS sitemap definition and settings export into this application",
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "settings", Description: "Settings export (the JSON object from scripts/winter-export-settings.sql)"},
|
||||
{Name: "force", Description: "Replace a definition that already has items", Bare: true},
|
||||
{Name: "dry-run", Description: "Report what would be copied without writing", Bare: true},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
opts := WinterOptions{SettingsFile: flagValue(in, "settings"), Force: flagOn(in, "force"), DryRun: flagOn(in, "dry-run")}
|
||||
db, done, err := lookupDB(ctx, app)
|
||||
if err != nil { return err }
|
||||
defer done()
|
||||
_, err = ImportWinterFiles(ctx, db, reg, opts, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
`SITEMAP/scripts/winter-export-settings.sql` is the companion export script to copy (`SELECT value FROM system_settings WHERE item = ...`). The golem precedent (`import_settings.go:46-61`) shows `app.Lookup[*gorm.DB]()`, an `importNotice` clean-stop error and `out.Success(fmt.Sprintf(...))`. For pgstripe secrets decode Laravel payloads with `lagoon.DecryptLaravelPayload(payloadJSON, appKey)` (`FW/modules/lagoon/laravel_decrypt.go:26`) and store through `lagoon.NewEncrypted(plain)` (`encrypted.go:37`); plaintext values pass through unchanged.
|
||||
|
||||
---
|
||||
|
||||
### `admin.go`, `controllers/*.go`, admin YAML (admin screens)
|
||||
|
||||
**Analog:** `JOURNAL/admin.go` (whole file), `JOURNAL/controllers/admin_registry.go`, `JOURNAL/controllers/posts.go:1-110`, record actions `USER/controllers/users_admin_controller.go:605-673`
|
||||
```go
|
||||
//go:embed controllers/posts/config_list.yaml controllers/posts/config_form.yaml controllers/posts/config_filter.yaml models/post/fields.yaml models/post/columns.yaml
|
||||
//go:embed models/settings/fields.yaml
|
||||
//go:embed assets/images/journal-icon.svg
|
||||
var adminFS embed.FS
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
return controllers.AdminControllers(func() *backpack.App { return p.app })
|
||||
}
|
||||
func (p *Plugin) Settings() []pact.SettingsItem {
|
||||
return []pact.SettingsItem{{
|
||||
Code: "journal", Label: "golem15.journal::lang.journal.menu_label",
|
||||
Description: "golem15.journal::lang.journal.settings_description",
|
||||
Category: "golem15.journal::lang.journal.menu_label", Icon: "icon-pencil",
|
||||
Permissions: []string{"golem15.journal.manage_settings"},
|
||||
Form: "models/settings/fields.yaml",
|
||||
NewModel: func() any { return &models.Settings{} },
|
||||
}}
|
||||
}
|
||||
```
|
||||
Two `SettingsItem`s for paymentgateway (`paymentgateway` with `access_settings`, `mailer`) and one for pgstripe. Controller shape (`posts.go:34-74`):
|
||||
```go
|
||||
type postsAdminController struct{ app func() *backpack.App }
|
||||
func (postsAdminController) ID() string { return "golem15.journal.posts" }
|
||||
func (postsAdminController) ModelName() string { return `Golem15\Journal\Models\Post` }
|
||||
func (postsAdminController) ConfigDir() string { return "controllers/posts" }
|
||||
func (postsAdminController) RequiredPermissions() []string { return []string{PermissionAccessPosts} }
|
||||
func (postsAdminController) NewRecord() any { return &models.Post{} }
|
||||
func (c postsAdminController) db(ctx context.Context) *gorm.DB {
|
||||
if tx, ok := cabana.TxFromContext(ctx); ok { return tx }
|
||||
return c.pool()
|
||||
}
|
||||
```
|
||||
Record actions as FSM transition buttons (`users_admin_controller.go:608-647`):
|
||||
```go
|
||||
func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction {
|
||||
action := func(name, label, confirm, success string,
|
||||
applies func(ctx context.Context, user *models.User) (bool, error),
|
||||
run func(ctx context.Context, tx *gorm.DB, user *models.User) error,
|
||||
) pact.AdminRecordAction {
|
||||
return pact.AdminRecordAction{
|
||||
Name: name, Label: usersLang + label, Confirm: usersLang + confirm,
|
||||
Permissions: []string{PermissionAccessUsers},
|
||||
Applies: func(ctx context.Context, record any) (bool, error) { user, err := asUser(record); ...; return applies(ctx, user) },
|
||||
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||
user, err := asUser(in.Record)
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok { return pact.AdminRecordActionResult{}, errors.New("users admin: a record action needs its transaction") }
|
||||
if err := run(ctx, tx, user); err != nil { return pact.AdminRecordActionResult{}, err }
|
||||
return pact.AdminRecordActionResult{Message: usersLang + success}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
return []pact.AdminRecordAction{
|
||||
action("activate", "activate_user", "activate_confirm", "activated_success",
|
||||
func(_ context.Context, user *models.User) (bool, error) { return !user.IsActivated, nil },
|
||||
func(ctx context.Context, tx *gorm.DB, user *models.User) error { ... }),
|
||||
}
|
||||
}
|
||||
```
|
||||
Orders: one action per transition with `Applies: fsm.Can(t)` and `Permissions: manage_orders`; Payments: `accept`/`refund` with `manage_payments`. The framework row-locks the record inside the action transaction (`pact/capabilities.go:340-347`), which gives Pitfall 9's `FOR UPDATE` for free on admin paths. Side panels and stats headers implement `pact.AdminPartialData` (`capabilities.go:362-371`): curated view models, never GORM models.
|
||||
|
||||
---
|
||||
|
||||
### `integration_test.go` / pgstripe host test
|
||||
|
||||
**Analog:** `JOURNAL/admin_harness_test.go:173-238` (`newIntegrationEnv`/`bootJournal`), `JOURNAL/integration_test.go:17-35,195-230`, `SITEMAP/testmain_test.go:116-129`
|
||||
```go
|
||||
import (
|
||||
_ "git.golem15.com/golem15/sm-journal-plugin"
|
||||
_ "git.golem15.com/golem15/sm-translate-plugin"
|
||||
_ "git.golem15.com/golem15/sm-user-plugin"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"gocloud.dev/blob/memblob"
|
||||
)
|
||||
|
||||
func newIntegrationEnv(t *testing.T, extraEnv ...string) *adminEnv {
|
||||
return bootJournal(t, []string{"golem15.user", "golem15.translate", "golem15.journal"}, append([]string{
|
||||
"SUMMER_GOLEM15__USER__JWT__SECRET=" + userJWTSecret,
|
||||
}, extraEnv...)...)
|
||||
}
|
||||
func bootJournal(t *testing.T, ids []string, extraEnv ...string) *adminEnv {
|
||||
sqlDB := dedicatedDB(t, "journal_admin_"+strconv.FormatInt(time.Now().UnixNano(), 10))
|
||||
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-admin\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644)
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: append([]string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}, extraEnv...)})
|
||||
for key, value := range map[string]any{"mail.driver": "log", "mail.from": "noreply@example.test", ...} { cfg.Set(key, value) }
|
||||
app := backpack.New(cfg)
|
||||
lagoon.Publish(app, sqlDB, gdb)
|
||||
attach.Publish(app, memblob.OpenBucket(nil))
|
||||
plugins, err := party.Activate(app, ids)
|
||||
lagoon.Migrate(gdb, plugins)
|
||||
h, err := surf.Assemble(app, plugins)
|
||||
return &adminEnv{h: h, app: app, gdb: gdb, plugins: plugins}
|
||||
}
|
||||
|
||||
// TestMain (sitemap): flag.Parse() then testing.Short(), never os.Args scanning
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if !testing.Short() { ... pgErr = startPostgres(ctx) ... }
|
||||
code := m.Run()
|
||||
stopPostgres()
|
||||
os.Exit(code)
|
||||
}
|
||||
```
|
||||
Host test in pgstripe: ids `{"golem15.user", "golem15.paymentgateway", "golem15.pgstripe", "acme.books"}` (the fake consumer registered from a `_test.go` file with `party.Register`), `mail.driver: memory` to assert the `OrderPaid` mail, `SUMMER_APP__KEY` for `lagoon.Encrypted`, and the Stripe `httptest.Server` URL injected through the client factory. `JOURNAL/integration_test.go:195-230` (`assertFixtureAbsentFromProduction`) is the guard that production sources never name the fixture plugin or import sm-user-plugin; copy it (fixture id `acme.books`).
|
||||
|
||||
---
|
||||
|
||||
### `scripts/check-phase18.sh`
|
||||
|
||||
**Analog:** `FW/scripts/check-phase17.sh:1-80`
|
||||
```bash
|
||||
set -euo pipefail
|
||||
unset FORCE_COLOR
|
||||
export NO_COLOR=1
|
||||
ROOT="${PHASE17_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
JOURNAL="${JOURNAL:-$ROOT/../sm-journal-plugin}"
|
||||
...
|
||||
# Required test names. Each list is run with -run '^(a|b|...)$' and every name must pass
|
||||
CABANA_SHORT_TESTS=(TestRepeaterCompile TestRepeaterDecode ... )
|
||||
```
|
||||
Stages `go`, `plugins`, `admin`, `layers`, `docs`, `postgres`, `--self-test`, `--all` with `PHASE18_REQUIRE_POSTGRES`. Repos: `PG=$ROOT/../sm-paymentgateway-plugin`, `PGSTRIPE=$ROOT/../sm-pgstripe-plugin`, `PG_LAYER=$ROOT/../vue-paymentgateway-layer`. Required names from RESEARCH.md §Validation Architecture (`TestMoneyField`, `TestPaymentMatrix`, `TestOrderMatrix`, `TestNotificationRoute`, `TestStripeWebhook`, `TestHostStripeFlow`, ...).
|
||||
|
||||
---
|
||||
|
||||
### `vue-paymentgateway-layer` files
|
||||
|
||||
**Analog:** `JLAYER/package.json`, `JLAYER/nuxt.config.ts`, `JLAYER/app/utils/client.ts:1-23`, `JLAYER/app/composables/useJournalApi.ts`, `JLAYER/tests/client.test.ts:1-30`
|
||||
```json
|
||||
{
|
||||
"name": "vue-journal-layer", "version": "0.3.0", "private": true, "type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
"scripts": { "dev": "nuxi dev .playground", "test": "node --test --test-reporter=tap tests/*.test.ts", "dev:prepare": "nuxt prepare .playground", "check:git-install": "bash scripts/check-git-install.sh" },
|
||||
"engines": { "node": ">=22.18" },
|
||||
"dependencies": { "@nuxtjs/i18n": "10.4.0", ... },
|
||||
"peerDependencies": { "nuxt": "4.4.8" },
|
||||
"devDependencies": { "nuxt": "4.4.8", "typescript": "5.9.3", "vue": "3.5.35" }
|
||||
}
|
||||
```
|
||||
```ts
|
||||
// nuxt.config.ts: absolute paths from this file's directory, tokens then base css, runtimeConfig split server/public
|
||||
const currentDir = fileURLToPath(new URL('.', import.meta.url))
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
modules: ['@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo', `${currentDir}lib/journal-module.ts`],
|
||||
css: [`${currentDir}app/assets/css/journal-tokens.css`, `${currentDir}app/assets/css/journal-base.css`],
|
||||
runtimeConfig: {
|
||||
journal: { apiBase: 'http://127.0.0.1:8096/_journal/api/v1' },
|
||||
public: { journal: { apiBase: '/_journal/api/v1' } },
|
||||
},
|
||||
})
|
||||
|
||||
// client.ts: fetcher injection so node --test drives it with a stub
|
||||
export type JournalFetcher = (path: string, query?: Record<string, string | number>) => Promise<unknown>
|
||||
|
||||
// composable: server base + x-forwarded-for on SSR, same-origin public base in the browser
|
||||
export function useJournalApi(): JournalClient {
|
||||
const config = useRuntimeConfig()
|
||||
if (import.meta.server) {
|
||||
const base = (config.journal as { apiBase: string }).apiBase
|
||||
const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query, headers })
|
||||
return createJournalClient(fetcher)
|
||||
}
|
||||
const base = (config.public.journal as { apiBase: string }).apiBase
|
||||
return createJournalClient((path, query) => $fetch(path, { baseURL: base, query }))
|
||||
}
|
||||
|
||||
// tests/client.test.ts
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
function fakeFetcher(routes: Record<string, unknown | (() => unknown)>) { const calls: Call[] = []; ... }
|
||||
```
|
||||
The paymentgateway client also needs POST (`process`, `cancel`, ...): extend the fetcher type with a method/body (`(path, { method, query, body })`) and keep the stub-driven tests. Components use `$t('paymentgateway.*')` keys from `i18n/locales/{en,pl}.json` and `paymentgateway-tokens.css`/`paymentgateway-base.css` like `JLAYER/app/components/Journal/Pagination.vue`.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Plugin module layout and naming
|
||||
**Source:** `JOURNAL` and `SITEMAP` trees (`git ls-files`)
|
||||
**Apply to:** both new plugin repos
|
||||
`plugin.go`, `admin.go`, `admin_permissions.go`, `admin_navigation.go`, `routes.go`, `classes/`, `console/`, `controllers/<name>/config_{list,form,filter}.yaml`, `models/<name>/{fields,columns}.yaml`, `models/settings/fields.yaml`, `updates/`, `lang/{en,pl}/lang.yaml`, `scripts/winter-export-*.sql`, `README.md` in the standard structure, `go.work` with `replace` lines to siblings.
|
||||
|
||||
### Response bodies
|
||||
**Source:** `JOURNAL/controllers/api/auth.go:263-272`, `FW/modules/wire/response.go:12`
|
||||
**Apply to:** every customer API handler and the notification route
|
||||
`wire.WriteJSON(w, status, map[string]any{"error": message})`; 422 as `{"error":"Validation failed","errors":{field:[msg]}}`; 404 for both unknown and forbidden orders. Money in JSON `{"amount":"2999.9700","amount_minor":299997,"currency":"PLN"}`.
|
||||
|
||||
### Throttle buckets
|
||||
**Source:** `JOURNAL/plugin.go:98-126` (`surf.BucketProvider`)
|
||||
**Apply to:** `/_paymentgateway/api/v1` (`paymentgateway-api`) and the notification route (`paymentgateway-notify`); 429 bodies stay `{"message":"Too Many Attempts."}`.
|
||||
|
||||
### Transactions and after-commit side effects
|
||||
**Source:** `FW/modules/lagoon/transaction.go:55,103` (`lagoon.Transaction`, `lagoon.AfterCommit`), `cabana.TxFromContext` in record actions
|
||||
**Apply to:** every FSM transition, the notification handler, mailers. Save → fire typed events in the transaction → `lagoon.AfterCommit` for mail and HTTP side effects.
|
||||
|
||||
### Encrypted secrets and cutover import
|
||||
**Source:** `FW/modules/lagoon/encrypted.go:31-102`, `laravel_decrypt.go:26`, `USER/models/oauth_identity.go:15-16`
|
||||
**Apply to:** pgstripe settings columns (`json:"-"`, `lagoon.Encrypted`), `pgstripe:import-winter`. The admin GET projection must omit the write-only fields (Plan 1 framework change).
|
||||
|
||||
### Typed events
|
||||
**Source:** `USER/classes/events.go`, `SITEMAP/classes/events.go`, `FW/modules/festival/README.md:27-32`
|
||||
**Apply to:** the 24 Order/Payment events; `app.Events.Fire` for notifications, `UntilHandled`/`Collect` only where a listener modifies the event.
|
||||
|
||||
### Mail
|
||||
**Source:** `USER/plugin.go:184-203`, `USER/classes/mail.go`, `USER/views/mail/*.htm`
|
||||
**Apply to:** all paymentgateway mails; PL unsuffixed, `-en` suffix; `postcard.Message{Template, To, Vars}`; no locale selection in postcard.
|
||||
|
||||
### Docs and gates (framework change)
|
||||
**Source:** `FW/modules/cabana/admin_openapi.go:394-410`, `FW/docs/backend/forms.md:97`, `FW/modules/cabana/README.md:48`, `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh`, `summer docs:build --check`
|
||||
**Apply to:** the `money` field and the settings-form changes, in the same commit.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
Files with no close match in the codebase (planner should use RESEARCH.md patterns instead):
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `sm-paymentgateway-plugin/fsm/fsm.go` | utility | event-driven | No state machine exists in Go; RESEARCH Pattern 3 gives the full transition tables |
|
||||
| `sm-paymentgateway-plugin/cart/*.go` (legacy JMS reader, v2 writer, extension registry) | serializer | transform | No versioned/legacy JSON reader exists; RESEARCH Pattern 7 + Pitfalls 4-5 |
|
||||
| `sm-paymentgateway-plugin/currency/*.go` (NBP/Fixer) | service | outbound HTTP | No outbound rate-fetcher precedent; use `json.Decoder.UseNumber()` per Anti-Patterns |
|
||||
| `sm-pgstripe-plugin/operator.go`, `client.go`, `webhook.go` | service | request-response | First stripe-go use; RESEARCH §Code Examples (executed probe) is the reference |
|
||||
| `sm-paymentgateway-plugin/money/money.go` (MoneyRight arithmetic) | value type | transform | Only the Scanner/Valuer shell has an analog; arithmetic comes from the PHP vectors in RESEARCH Pattern 1 |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `summercms.go/modules/{cabana,pact,lagoon,festival,postcard,party,wire}`, `summercms.go/admin/src/{app,components/form}`, `summercms.go/scripts`, `summercms.go/docs/backend`, `sm-journal-plugin`, `sm-sitemap-plugin`, `sm-summercmsio-app/plugins/golem15/user`, `fonoteka.go/plugins/golem15/{fonoteka,golem}`, `vue-journal-layer`
|
||||
**Files scanned:** ~45 read in full or by targeted range
|
||||
**Pattern extraction date:** 2026-10-13
|
||||
@@ -824,18 +824,25 @@ Plans 2–3 are the largest; split plan 3 into 3a (admin) and 3b (mail and setti
|
||||
| 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
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All five questions were resolved during planning on 2026-10-13; each carries the plan and task that adopts the recommendation.
|
||||
|
||||
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.
|
||||
- RESOLVED: the recommendation is taken in three steps. Plan 18-02 Task 3 commits the one real row anonymized as `legacy-real-1-{cart,details}.json` plus PHP-generated `synthetic-*.json` fixtures from `tests/php/gen-fixtures.php`; plan 18-06 Task 5 is the `checkpoint:human-action` that asks for the anonymized shop-type export (`scripts/winter-export-orders.sql`) or the reply "fixtures: none"; plan 18-07 Task 3 commits the supplied rows as `shop-export-{cart,details}-<n>.json` after an anonymization check, or records the gap.
|
||||
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).
|
||||
- RESOLVED: built in full, not deferred (plan 18-03 Task 3): the `_quote_cart` widget field mounts `golem15-paymentgateway-quote-cart` and the admin actions `add_quoteable`, `change_item` and `remove_item` port the six handlers; `process_quote` is a record action.
|
||||
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.
|
||||
- RESOLVED: always-visible items (plan 18-03 Task 1, UI-SPEC P11): `pact.NavigationItem` has no runtime visibility and no framework hook is added; the five side items are always listed, the top-level link is chosen from `orders_enabled` at boot, and a screen whose feature is off shows the feature-off callout.
|
||||
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`.
|
||||
- RESOLVED: the remote HEAD `24b16b1` = v0.1.1 (plan 18-02 Task 2): `go.mod` requires `sm-user-plugin v0.1.1` and `go.work` replaces it with `../sm-summercmsio-app/plugins/golem15/user`; plan 18-05 Task 1 uses the same pin and replace for the proof host.
|
||||
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.
|
||||
- RESOLVED: plan 18-06 declares the three repositories under `user_setup`, Task 5 (`checkpoint:human-action`) has the human create them and approve the pushes, and Task 6 (a second human checkpoint) pushes and tags only after the recorded reply; plans 02 to 05 work on local `main` branches with no remote.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ created: "2026-10-12"
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` (+ testcontainers-go Postgres), vitest (admin SPA), `node --test` (vue-paymentgateway-layer) |
|
||||
| **Config file** | none for Go; `admin/` vitest config exists; layer `package.json` test script (Wave 0) |
|
||||
| **Quick run command** | `go vet ./... && go test -short ./...` in the touched repo (summercms.go, sm-paymentgateway-plugin, sm-pgstripe-plugin) |
|
||||
| **Quick run command** | the after-commit sample: `go vet ./... && go test -short ./...` in the touched Go repo (summercms.go, sm-paymentgateway-plugin, sm-pgstripe-plugin; in summercms.go scoped to the package under test, `go test -short ./modules/cabana`), plus `npm --prefix admin test -- <the task's spec files>` when a task changed `admin/` (one or two spec files, no build, no typecheck) and `pnpm --dir ../vue-paymentgateway-layer test` for the layer |
|
||||
| **Full suite command** | `bash scripts/check-phase18.sh --all` (written in the last plan, modelled on `scripts/check-phase17.sh`) |
|
||||
| **Estimated runtime** | ~60 seconds quick; several minutes full (Docker) |
|
||||
| **Estimated runtime** | ~60 seconds quick; task-close gates (admin build, OpenAPI, docs checker) up to 10 minutes; several minutes full (Docker) |
|
||||
|
||||
---
|
||||
|
||||
@@ -32,32 +32,46 @@ created: "2026-10-12"
|
||||
- **After every task commit:** Run the quick command in the touched repo
|
||||
- **After every plan wave:** Run `go test ./...` (Docker) in the touched repos, plus `npm --prefix admin run test` when admin changed
|
||||
- **Before `/msd-verify-work`:** `bash scripts/check-phase18.sh --all` must be green
|
||||
- **Max feedback latency:** 120 seconds
|
||||
- **Max feedback latency:** 120 seconds for the after-commit sample (the quick command). The per-task `<automated>` chains of 18-01 and 18-07 that include `check-admin-dist.sh` (a fresh admin build), the full vitest run, the OpenAPI check, the docs checker or a gate stage are task-close gates: they run once per task before its commit, are bounded at 10 minutes, and are not the after-commit sample (18-01 and 18-07 say so in their `<verification>` sections).
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled by the planner/executor from the PLAN.md tasks. Seed map from 18-RESEARCH.md § Validation Architecture:
|
||||
Filled by the planner from the PLAN.md tasks (2026-10-13); the executor flips Status per task. Every command runs from the summercms.go repository root (the `go -C ../<repo>` idiom for sibling repositories); Docker-backed rows carry the `docker info >/dev/null 2>&1 &&` prefix in the plans and are recorded NOT RUN when no daemon answers. "File Exists" names the plan and task that creates the test (W0 = created by that task; the test does not exist before it).
|
||||
|
||||
| Area | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|------|----------|-----------|-------------------|-------------|--------|
|
||||
| SC-1 | Payment FSM 6×5, Order FSM 12×19, refund bridge | unit | `go test -short ./fsm/... ./orders/... ./payments/... -run 'TestPaymentMatrix\|TestOrderMatrix\|TestRefundBridge'` | ❌ W0 | ⬜ pending |
|
||||
| SC-1 | MoneyRight vectors, Scan/Value, (10,4)/(12,4) ceilings | unit | `go test -short ./money/...` | ❌ W0 | ⬜ pending |
|
||||
| SC-2 | Operator registry discovery; duplicate code fails boot | unit | `go test -short ./operators/... -run TestRegistry` | ❌ W0 | ⬜ pending |
|
||||
| SC-2 | Notification route paths × methods, mode merge, de-urlize, unregistered → 404 | integration (httptest) | `go test -short . -run TestNotificationRoute` | ❌ W0 | ⬜ pending |
|
||||
| SC-2 | Stripe webhook signature cases, replay no-op | integration | `go test -short ./... -run TestStripeWebhook` (pgstripe) | ❌ W0 | ⬜ pending |
|
||||
| SC-3 | Events once per transition; MailerSettings gating; EN/PL templates | unit | `go test -short ./events/... -run 'TestEvents\|TestMailers'` | ❌ W0 | ⬜ pending |
|
||||
| SC-3 | Migrations up/down; schema equals PHP v2.6.0 | integration (Docker) | `go test ./updates/... -run TestMigrations` | ❌ W0 | ⬜ pending |
|
||||
| SC-3 | Scheduled commands select rows by timeout | integration (Docker) | `go test ./console/... -run TestPG` | ❌ W0 | ⬜ pending |
|
||||
| SC-3 | Settings import-winter | integration (Docker) | `go test ./console/... -run TestImportWinter` | ❌ W0 | ⬜ pending |
|
||||
| SC-4 | Host flow: Make → Stripe → signed event → Paid → OrderPaid + mail; path (b) | integration (Docker + httptest) | `go test ./... -run TestHostStripeFlow` (pgstripe) | ❌ W0 | ⬜ pending |
|
||||
| D-07 | cabana `money` field schema/fill/projection | unit | `go test -short ./modules/cabana -run TestMoneyField` | ❌ W0 | ⬜ pending |
|
||||
| D-07 | `MoneyField.vue` | unit (vitest) | `npm --prefix admin run test -- MoneyField` | ❌ W0 | ⬜ pending |
|
||||
| D-11 | Legacy reader goldens, v2 round trip | unit | `go test -short ./cart/... -run 'TestLegacy\|TestV2'` | ❌ W0 | ⬜ pending |
|
||||
| D-06 | Customer API authz and state guards | integration (Docker) | `go test ./api/... -run TestCustomerAPI` | ❌ W0 | ⬜ pending |
|
||||
| D-06 | Layer client and components | unit | `pnpm --dir ../vue-paymentgateway-layer test` | ❌ W0 | ⬜ pending |
|
||||
| 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` | ✅ | ⬜ pending |
|
||||
| D-07 | cabana `money` field compile-only checks, inventories, OpenAPI, typecheck, MoneyField vitest, dist, docs (18-01 T1) | unit + gate | `go vet ./... && go test -count=1 -short -run '^TestMoneyField' -v ./modules/cabana 2>&1 \| grep -- '^--- PASS: TestMoneyField' && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/MoneyField.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check` | ❌ W0 (18-01 T1) | ⬜ pending |
|
||||
| D-07 | money save round trip, 422 keys (18-01 T1) | integration (Docker) | `go test -count=1 -run '^TestMoneyFieldSmoke$' -v ./modules/cabana 2>&1 \| grep -- '^--- PASS: TestMoneyFieldSmoke'` | ❌ W0 (18-01 T1) | ⬜ pending |
|
||||
| D-07, F5-F7 | money list column, danger flag, Edit rule, kit; CellValue/RecordActions/PreviewView specs (18-01 T2) | unit + Docker | `go test -count=1 -short ./modules/cabana ./modules/pact && npm --prefix admin test -- tests/list/CellValue.test.ts tests/form/RecordActions.test.ts tests/form/PreviewView.test.ts`; Docker: `go test -count=1 -run '^(TestMoneyListSmoke\|TestRecordActionDangerSmoke)$' -v ./modules/cabana` | ❌ W0 (18-01 T2) | ⬜ pending |
|
||||
| D-13, F2-F4 | settings secrets (states, keep, clear, env), richeditor on settings, tabs; PasswordField/SettingsFormView specs (18-01 T3) | unit + Docker | `go test -count=1 -short ./modules/cabana && npm --prefix admin test -- tests/form/PasswordField.test.ts tests/views/SettingsFormView.test.ts`; Docker: `go test -count=1 -run '^(TestSettingsSecretSmoke\|TestSettingsRichEditorSmoke)$' -v ./modules/cabana` | ❌ W0 (18-01 T3) | ⬜ pending |
|
||||
| Release | framework release gate before the tag decision (18-01 T4) | gate | `go vet ./... && go test -count=1 -short ./... && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && go run ./cmd/summer docs:build --check` | ✅ | ⬜ pending |
|
||||
| SC-1 | MoneyRight vectors, Amount4 Scan/Value, FSM smoke (18-02 T2) | unit | `go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^(TestMoneyVectorsSmoke\|TestAmount4ScanValue)$' ./money/ && go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestFSMSmoke$' ./fsm/` | ❌ W0 (18-02 T2) | ⬜ pending |
|
||||
| SC-1, SC-3 | cash order Make → Purchase → Accept → Paid, OrderPaid once, replay no-op (18-02 T2) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestPaymentGatewayTracer$' -v .` | ❌ W0 (18-02 T2) | ⬜ pending |
|
||||
| D-11 | legacy reader goldens, unknown type, v2 re-encode (18-02 T3) | unit | `go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestLegacyCartGoldens$' -v ./cart/` | ❌ W0 (18-02 T3) | ⬜ pending |
|
||||
| SC-1 | refund bridge, reject cancels payments, RecordExternal (18-02 T3) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPaymentGatewayTracer\|TestRefundBridgeSmoke)$' -v .` | ❌ W0 (18-02 T3) | ⬜ pending |
|
||||
| SC-2 | notification route paths × methods, de-urlize, mode merge, 404/403 verify-first (18-02 T4) | integration (httptest) | `go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestNotificationRoute$' -v .` | ❌ W0 (18-02 T4) | ⬜ pending |
|
||||
| SC-3 | NBP/Fixer exact parsing and conversion vectors (18-02 T4) | unit (httptest) | `go -C ../sm-paymentgateway-plugin test -count=1 -short -run '^TestCurrencyProviders$' -v ./currency/` | ❌ W0 (18-02 T4) | ⬜ pending |
|
||||
| SC-2, SC-3 | verified notification accepts, commands by timeout, settings import (18-02 T4) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestNotificationRouteSmoke\|TestPGCommandsSmoke\|TestImportWinterSmoke)$' -v .` | ❌ W0 (18-02 T4) | ⬜ pending |
|
||||
| SC-3 | Orders admin: list labels and money, record actions per state, danger, permissions (18-03 T1) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestOrdersAdminSmoke$' -v .` | ❌ W0 (18-03 T1) | ⬜ pending |
|
||||
| SC-3 | Payments, Shippings, Quote items admin (18-03 T2) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPaymentsAdminSmoke\|TestShippingsAdminSmoke\|TestQuoteablesAdminSmoke)$' -v .` | ❌ W0 (18-03 T2) | ⬜ pending |
|
||||
| SC-3 | Quotes create, cart actions, process_quote, asset allowlist (18-03 T3) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestQuotesAdminSmoke\|TestOrdersAdminSmoke)$' -v .` | ❌ W0 (18-03 T3) | ⬜ pending |
|
||||
| D-08 | PG and Mailer settings pages, required currency, OrderPaid mail after commit (18-04 T1) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestPGSettingsSmoke\|TestMailerSettingsSmoke\|TestMailTracer)$' -v .` | ❌ W0 (18-04 T1) | ⬜ pending |
|
||||
| SC-3 | mailer gating matrix, recipients, locale; mailer keys import (18-04 T2) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestMailersMatrixSmoke\|TestImportWinterSmoke)$' -v .` | ❌ W0 (18-04 T2) | ⬜ pending |
|
||||
| SC-4 | Checkout operator path on the proof host (18-05 T1) | integration (Docker + httptest) | `go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$/^checkout_operator_path$' -v .` | ❌ W0 (18-05 T1) | ⬜ pending |
|
||||
| SC-2, D-14 | webhook signature cases, older api_version, minimal decode (18-05 T2) | unit (httptest) | `go -C ../sm-pgstripe-plugin test -count=1 -short -run '^TestStripeWebhook$' -v .` | ❌ W0 (18-05 T2) | ⬜ pending |
|
||||
| SC-4, D-04 | full host flow: PaymentIntent, path (b), wrong secret, missing header/secret, replay and race, browser return, refund (18-05 T2) | integration (Docker + httptest) | `go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v .` (≥ 11 subtests) | ❌ W0 (18-05 T2) | ⬜ pending |
|
||||
| D-13 | Stripe settings never return secrets; import with Laravel decrypt (18-05 T3) | integration (Docker) | `go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestStripeSettingsSmoke\|TestStripeImportWinterSmoke)$' -v .` | ❌ W0 (18-05 T3) | ⬜ pending |
|
||||
| D-06, D-17 | API tracer: guest/owner access, process cash, 409 (18-06 T2) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^TestCustomerAPITracer$' -v .` | ❌ W0 (18-06 T2) | ⬜ pending |
|
||||
| D-06 | layer client, money formatting, redirect contract (18-06 T2) | unit (node --test) | `pnpm --dir ../vue-paymentgateway-layer test` (TAP `# fail 0`, `# pass ≥ 1`) | ❌ W0 (18-06 T2) | ⬜ pending |
|
||||
| D-06 | full API authz matrix and state guards (18-06 T3) | integration (Docker) | `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestCustomerAPI\|TestCustomerAPITracer)$' -v .` | ❌ W0 (18-06 T3) | ⬜ pending |
|
||||
| D-06 | layer polling, tone map, i18n parity, cart rules; playground prepares (18-06 T4) | unit (node --test) | `pnpm --dir ../vue-paymentgateway-layer test` (TAP `# pass ≥ 15`) `&& pnpm --dir ../vue-paymentgateway-layer run dev:prepare` | ❌ W0 (18-06 T4) | ⬜ pending |
|
||||
| Gate | phase gate stages and self-test (18-07 T1) | gate | `bash scripts/check-phase18.sh --self-test && bash scripts/check-phase18.sh go && bash scripts/check-phase18.sh plugins && bash scripts/check-phase18.sh layers && bash scripts/check-phase18.sh docs`; Docker: `bash scripts/check-phase18.sh postgres` | ❌ W0 (18-07 T1) | ⬜ pending |
|
||||
| D-07, D-13 | framework unit tests: money compile/validate/list, secrets, tabs, richeditor, danger, conformance; all vitest (18-07 T2) | unit + Docker | `go test -count=1 -short ./modules/cabana && npm --prefix admin test && bash scripts/check-phase18.sh go && bash scripts/check-phase18.sh admin`; Docker: `go test -count=1 -run '^(TestMoneyFieldSave\|TestSettingsSecretPostgres\|TestPhase10OpenAPIConformance)$' ./modules/cabana` | ❌ W0 (18-07 T2) | ⬜ pending |
|
||||
| SC-1, SC-2, SC-3, D-02, D-11 | PHP map (63), matrices, fixtures, registry, hashids, redactor, events, mailers, migrations, commands, import, authz, notification security (18-07 T3) | unit + Docker | `go -C ../sm-paymentgateway-plugin test -count=1 -short ./... && bash scripts/check-phase18.sh plugins`; Docker: `go -C ../sm-paymentgateway-plugin test -count=1 -run '^(TestMigrations\|TestPGCommands\|TestImportWinter\|TestCustomerAPIAuthz\|TestNotificationSecurity)$' ./...` | ❌ W0 (18-07 T3) | ⬜ pending |
|
||||
| SC-4, SC-5, D-02 | pgstripe PHP map (9 + 18.1 note), operator unit, SDK pin; layer tests; full gate; validation map (18-07 T4) | unit + gate | `go -C ../sm-pgstripe-plugin test -count=1 -short ./... && pnpm --dir ../vue-paymentgateway-layer test && bash scripts/check-phase18.sh --all`; Docker: `PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh --all` | ❌ W0 (18-07 T4) | ⬜ pending |
|
||||
| Docs | docs checker, OpenAPI, dist (every framework task) | gate | `go test ./cmd/summer -run TestDocsTree && summer docs:build --check && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh` | ✅ | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
@@ -66,11 +80,11 @@ Filled by the planner/executor from the PLAN.md tasks. Seed map from 18-RESEARCH
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] New repos `sm-paymentgateway-plugin`, `sm-pgstripe-plugin` with `go.mod`/`go.work` and an `integration_test.go` harness copied from `sm-journal-plugin` (`TestMain` with `flag.Parse()` + `testing.Short()`)
|
||||
- [ ] `testdata/legacy/` golden fixtures (anonymized Horoskopia row + PHP-generated synthetic rows)
|
||||
- [ ] `testdata/legacy/` golden fixtures (the one real legacy row anonymized as `legacy-real-1-{cart,details}.json` + PHP-generated `synthetic-*.json` rows; 18-02 T3; the fixture names and contents name no consuming application)
|
||||
- [ ] `money/vectors_test.go` seeded from the PHP vectors in 18-RESEARCH.md
|
||||
- [ ] Shared Stripe `httptest` fake helper for pgstripe tests
|
||||
- [ ] `vue-paymentgateway-layer` scaffold copied from `vue-journal-layer`
|
||||
- [ ] `scripts/check-phase18.sh`
|
||||
- [ ] `scripts/check-phase18.sh` (created in the last plan, 18-07 Task 1, per the project rule that unit tests are the last plan of every phase; it is the full-suite gate, not a prerequisite of plans 01 to 06, whose tasks carry their own `<automated>` commands)
|
||||
|
||||
---
|
||||
|
||||
@@ -89,7 +103,7 @@ Filled by the planner/executor from the PLAN.md tasks. Seed map from 18-RESEARCH
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 120s
|
||||
- [ ] Feedback latency < 120s for the after-commit sample (task-close gates are bounded separately, see Sampling Rate)
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
|
||||
58
.planning/phases/18-payment-gateway/COVERAGE.md
Normal file
58
.planning/phases/18-payment-gateway/COVERAGE.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# API Coverage — Stripe (stripe-go v87), NBP exchange rates, Fixer exchange rates
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions. Written at plan time for Phase 18 (plans 18-02, 18-05, 18-06); the seal-time gate validates this file.
|
||||
|
||||
## Stripe (`github.com/stripe/stripe-go/v87`, operator `golem15.pgstripe::lang.operators.stripe`)
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| checkout_sessions.create | INTEGRATE | mode payment, one line item, success/cancel URL, client_reference_id, metadata on session and payment intent |
|
||||
| checkout_sessions.retrieve | INTEGRATE | browser-return re-check and refund lookup (18-05 Task 2) |
|
||||
| payment_intents.create (amount, currency, metadata, payment_method + confirm) | INTEGRATE | |
|
||||
| payment_intents.retrieve | INTEGRATE | browser-return re-check (18-05 Task 2) |
|
||||
| refunds.create (payment_intent) | INTEGRATE | |
|
||||
| webhook signature verification (`webhook.ValidatePayload`) | INTEGRATE | signature-only, API-version independent (RESEARCH F-4); `GenerateTestSignedPayload` in tests |
|
||||
| webhook event checkout.session.completed | INTEGRATE | |
|
||||
| webhook event checkout.session.async_payment_succeeded | INTEGRATE | |
|
||||
| webhook event checkout.session.async_payment_failed | INTEGRATE | |
|
||||
| webhook event payment_intent.succeeded | INTEGRATE | |
|
||||
| webhook event payment_intent.payment_failed | INTEGRATE | |
|
||||
| webhook event checkout.session.expired | OPT-OUT | not needed: an expired session leaves the payment Sent; `pg:cancel-sent-payments` cancels it after the timeout, as PHP did |
|
||||
| webhook event charge.refunded / charge.dispute.* | OPT-OUT | not needed yet: refunds are initiated by the plugin (refunds.create); disputes have no PHP counterpart; tracked for a follow-up phase |
|
||||
| webhook event construction (`webhook.ConstructEvent`, full typed event) | OPT-OUT | explicitly out of scope: rejects events from other API release trains (RESEARCH F-4); replaced by minimal local decode |
|
||||
| checkout_sessions.expire / list / line_items | OPT-OUT | not needed: sessions are single-use and never enumerated |
|
||||
| payment_intents.confirm / capture / cancel | OPT-OUT | not needed: confirmation happens on the client (Stripe.js in the host app) or server-side at create with `confirm`; manual capture is not a PHP feature |
|
||||
| payment_methods.* | OPT-OUT | not needed: card data never touches the server; the host app's Stripe.js handles the payment method |
|
||||
| customers.* | OPT-OUT | explicitly out of scope: Phase 18.1 (subscriptions) introduces customers (D-01) |
|
||||
| subscriptions.*, prices.*, products.*, invoices.* | OPT-OUT | explicitly out of scope: Phase 18.1 (D-01) |
|
||||
| billing_portal.sessions.* | OPT-OUT | explicitly out of scope: Phase 18.1 (D-01) |
|
||||
| webhook_endpoints.* (management API) | OPT-OUT | not needed: endpoints are created in the dashboard per application; documented in the pgstripe README |
|
||||
| charges.* | OPT-OUT | not needed: PaymentIntents replace direct charges; `charge_id` is only stored from events |
|
||||
| balance / payouts / transfers / connect accounts | OPT-OUT | not needed: StripeConnect is another application's own operator (RESEARCH Pattern 4 note), not this plugin |
|
||||
| setup_intents.* | OPT-OUT | explicitly out of scope: Phase 18.1 |
|
||||
| tax rates / automatic tax | OPT-OUT | not needed: VAT is computed from the cart's integer tax rates; one aggregated line item is sent (RESEARCH Pattern 6) |
|
||||
| test clocks / events.list (polling) | OPT-OUT | not needed: events arrive by webhook; tests use `GenerateTestSignedPayload` |
|
||||
|
||||
## NBP (Narodowy Bank Polski exchange rates, `pg:update-currencies` with `exchange_rate_provider = nbp`)
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| exchangerates/tables/A (current table, JSON) | INTEGRATE | `mid`, table number and effective date stored on `golem15_paymentgateway_currencies` |
|
||||
| exchangerates/tables/A/{date} and date ranges | OPT-OUT | not needed: PHP fetched only the current table; historical rates have no consumer |
|
||||
| exchangerates/tables/B and C | OPT-OUT | not needed: table B lists currencies no consumer trades; table C (bid/ask) was never used by PHP |
|
||||
| exchangerates/rates/{table}/{code} (single currency) | OPT-OUT | not needed: the whole table is fetched once per run and filtered locally |
|
||||
| cenyzlota (gold prices) | OPT-OUT | not needed |
|
||||
|
||||
## Fixer (`exchange_rate_provider = fixer`, access key `golem15.paymentgateway.fixer_access_key`)
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| latest (base currency, symbols) | INTEGRATE | |
|
||||
| historical/{date}, timeseries, fluctuation | OPT-OUT | not needed: PHP used `latest` only; no historical consumer |
|
||||
| convert | OPT-OUT | not needed: conversion is done locally with exact integer math on the stored ratio (D-09) |
|
||||
| symbols | OPT-OUT | not needed: the ISO table is embedded in the plugin (`currency/iso.go`) |
|
||||
|
||||
## Not external APIs (recorded so the detector's signals are accounted for)
|
||||
|
||||
- The `/_paymentgateway/api/v1` customer API and the `_paymentgateway/{operator}/{mode?}/{result?}` notification route are APIs this phase *publishes*, not consumes; their coverage is the must_haves of plans 18-02 and 18-06.
|
||||
- Yahoo currency exchange (PHP `support/YahooCurrencyExchange.php`) is not ported: it is not selectable in settings (RESEARCH Deprecated).
|
||||
Reference in New Issue
Block a user