From 05bacad99788fc093cea134401e3b5afc7640bf1 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 14 Oct 2026 14:05:08 +0200 Subject: [PATCH] docs(18.1): create phase plan --- .planning/ROADMAP.md | 16 +- .../18.1-01-PLAN.md | 308 ++++++++++++++++ .../18.1-02-PLAN.md | 346 ++++++++++++++++++ .../18.1-03-PLAN.md | 337 +++++++++++++++++ .../18.1-04-PLAN.md | 335 +++++++++++++++++ .../18.1-05-PLAN.md | 267 ++++++++++++++ .../18.1-06-PLAN.md | 327 +++++++++++++++++ .../COVERAGE.md | 42 +++ 8 files changed, 1976 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/18.1-payment-gateway-subscriptions/18.1-01-PLAN.md create mode 100644 .planning/phases/18.1-payment-gateway-subscriptions/18.1-02-PLAN.md create mode 100644 .planning/phases/18.1-payment-gateway-subscriptions/18.1-03-PLAN.md create mode 100644 .planning/phases/18.1-payment-gateway-subscriptions/18.1-04-PLAN.md create mode 100644 .planning/phases/18.1-payment-gateway-subscriptions/18.1-05-PLAN.md create mode 100644 .planning/phases/18.1-payment-gateway-subscriptions/18.1-06-PLAN.md create mode 100644 .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 3901059..8c1833d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1156,10 +1156,22 @@ Plans: 4. A host test proves checkout, activation, `invoice.paid`, `past_due` with its grace period, cancel and duplicate-event idempotency against a Stripe fake. 5. The new code has unit tests, delivered in the phase's last plan. -**Plans:** 0 plans +**Plans:** 6 plans Plans: -- [ ] TBD (run /msd-plan-phase 18.1 to break down) +**Wave 1** +- [ ] 18.1-01-PLAN.md — Framework (summercms.go): core cabana `taglist` field and flat (Sortable) reorder (D-17), OpenAPI, dist, docs; framework tag with go-ahead +- [ ] 18.1-02-PLAN.md — sm-paymentgatewaysubscriptions-plugin core tracer (new repo): baseline gate (D-04/D-16), models and checks, contracts and build-time registries, six events, gateway-neutral Apply and claim, `user` subscribable, service API; remote and push with go-ahead + +**Wave 2** *(blocked on Wave 1 completion)* +- [ ] 18.1-03-PLAN.md — sm-pgstripe-plugin: subscription secret columns and import target (D-18), plugin `golem15.pgstripesubscriptions` (webhook contract gate, gateway with plan sync from `d00f850`, both webhook URLs, claim-first River job, app-tag config), `TestHostSubscriptionFlow` +- [ ] 18.1-04-PLAN.md — sm-paymentgatewaysubscriptions-plugin admin (plans with taglist, flat reorder and sync actions; subscriptions list and preview), permissions, EN/PL, customer API after its contract gate (D-13) + +**Wave 3** *(blocked on 18.1-04)* +- [ ] 18.1-05-PLAN.md — vue-paymentgateway-layer subscription client, composables and Pg components (D-14); tag `v0.2.0` with go-ahead + +**Wave 4** *(blocked on 18.1-01..18.1-05)* +- [ ] 18.1-06-PLAN.md — Unit tests last across all repositories, PHP test map, `scripts/check-phase18.1.sh`, validated 18.1-VALIDATION.md, final push approval ### Phase 19: Chat and ChatChannels diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-01-PLAN.md b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-01-PLAN.md new file mode 100644 index 0000000..b2daeb3 --- /dev/null +++ b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-01-PLAN.md @@ -0,0 +1,308 @@ +--- +phase: 18.1-payment-gateway-subscriptions +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - modules/cabana/field_taglist.go + - modules/cabana/form_schema.go + - modules/cabana/schema_types.go + - modules/cabana/crud.go + - modules/cabana/relation_child.go + - modules/cabana/settings.go + - modules/cabana/field_repeater.go + - modules/cabana/reorder.go + - modules/cabana/admin_openapi.go + - modules/cabana/README.md + - modules/cabana/testdata/catalog/controllers/badges/config_list.yaml + - modules/cabana/testdata/catalog/controllers/badges/config_form.yaml + - modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml + - modules/cabana/testdata/catalog/models/badge/fields.yaml + - modules/cabana/testdata/catalog/models/badge/columns.yaml + - modules/cabana/catalog_smoke_test.go + - modules/cabana/taglist_field_test.go + - modules/cabana/reorder_flat_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/api/types.ts + - admin/src/components/form/fields/TaglistField.vue + - admin/src/components/form/registry.ts + - admin/src/components/form/PreviewField.vue + - admin/src/views/ReorderView.vue + - admin/src/components/reorder/tree.ts + - admin/tests/form/TaglistField.test.ts + - admin/tests/views/ReorderView.test.ts + - modules/boardwalk/dist + - docs/backend/forms.md + - docs/backend/lists-and-filters.md +autonomous: false +requirements: [SC-3, D-17, D-22] +estimate: + tokens: 200000 + raw_tokens: 200000 + tasks: 3 + confidence: low +must_haves: + truths: + - "D-17 (taglist): cabana has a core `taglist` form field. `mode: array` binds a column whose Go type implements sql.Scanner and driver.Valuer (for example lagoon.Jsonable[[]string]) and travels as a JSON array of strings; `mode: string` (the WinterCMS default) binds a string column and travels as one string joined by `separator` (`comma` default, or `space`). `options` (a YAML list of strings) are suggestions; `customTags: false` refuses any tag outside them. An unknown mode or separator, money keys, `nameFrom`, `emptyOption`, `dependsOn`, `preset`, a taglist nested in a repeater, or a column of the wrong Go type fail boot with an error naming the field." + - "D-17 (taglist save): a submitted tag list is trimmed, empty tags dropped and duplicates removed keeping the first occurrence; a non-list (array mode), a non-string (string mode) or a non-string tag is a 422 on the field with backend::lang.taglist.invalid; with customTags false a tag outside options is a 422 with backend::lang.taglist.not_allowed naming the tag; null or an empty list stores an empty JSON array `[]` in array mode and an empty string in string mode; the record GET returns the stored list (array mode) or the stored string (string mode)." + - "D-17 (flat reorder): a config_reorder.yaml without parentColumn on a record that has no parent_id column compiles as a flat reorder (WinterCMS Sortable inference): GET .../reorder answers `flat: true` and every node's parent_id null; POST writes only the sort column; a node with a non-null parent_id is a 422 on nodes; the 409 stale-set rule and the duplicate-id 422 are unchanged; maxDepth on a flat reorder fails boot; an explicit parentColumn that is not a column still fails boot; every existing tree reorder compiles and answers byte-identically (the flag is omitted when false)." + - "D-17 (SPA): TaglistField renders the tags as removable chips with an input that adds a tag on Enter or a comma, offers options as suggestions, emits a string array (array mode) or the joined string (string mode), and renders a read-only list when the field is read-only; ReorderView in flat mode hides indent and outdent, never offers the inside drop zone, ignores ArrowRight and ArrowLeft and sends parent_id null for every node." + - "D-17 (docs and generated artifacts): docs/backend/forms.md has a Taglist section and a type-table row, docs/backend/lists-and-filters.md documents the flat reorder, the cabana README lists both, `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` are clean, so plan 04 can declare `features` as `type: taglist, mode: array` and a flat SubscriptionPlans reorder in YAML." + - "D-22 (release gate): nothing is pushed or tagged before the human's reply at the Task 3 checkpoint; plan 04 builds against this checkout through `replace ../summercms.go`, so `hold` costs only a later bump in plan 06." + artifacts: + - path: "modules/cabana/field_taglist.go" + provides: "taglist compile (mode, separator, options, customTags), column type check, save-time normalisation and validation, record hydration" + contains: "taglist" + - path: "modules/cabana/reorder.go" + provides: "flat reorder inference, flat save path, flat flag on ListReorder and AdminReorderTree" + contains: "flat" + - path: "admin/src/components/form/fields/TaglistField.vue" + provides: "chip input with suggestions, array and string modes, read-only list" + contains: "taglist" + - path: "modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml" + provides: "flat reorder fixture (no parentColumn, record without parent_id)" + contains: "nameFrom" + key_links: + - from: "modules/cabana/form_schema.go" + to: "modules/cabana/field_taglist.go" + via: "formFieldTypes gains taglist; field compile calls compileTaglistKeys" + pattern: "compileTaglistKeys" + - from: "modules/cabana/crud.go" + to: "modules/cabana/field_taglist.go" + via: "create and update run checkTaglistValues next to checkMoneyValues and hydrate the stored value on GET" + pattern: "checkTaglistValues" + - from: "admin/src/components/form/registry.ts" + to: "admin/src/components/form/fields/TaglistField.vue" + via: "renderers map entry taglist" + pattern: "\\['taglist', TaglistField\\]" + - from: "admin/src/views/ReorderView.vue" + to: "modules/cabana/reorder.go" + via: "AdminReorderTree.flat from GET .../reorder switches the view to flat moves" + pattern: "flat" + prohibitions: + - statement: "Framework code, fixtures and docs never name a consuming application or a payment-specific key; the taglist and the flat reorder are generic (any model, any plugin)." + status: unresolved + verification: judgment + - statement: "Existing field types, tree reorders, payloads and phrase keys keep their behaviour byte for byte; the new JSON keys are additive and omitted when unset." + status: unresolved + verification: judgment + - statement: "Nothing is pushed or tagged without the human's reply at the release checkpoint." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; MVP mode would take a story from `/msd mvp-phase 18.1`, which was not run, so no story is invented here, as in Phase 18): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow (plans, checkout, Stripe webhook, `golem15.pgsub.*` events, cancel and portal) works as it does on PHP. + +This plan's slice (plan 1 of 6, framework, wave 1, per D-22): the two cabana features the SubscriptionPlans screens need to port faithfully (D-17): a core `taglist` form field and a flat (Sortable, non-tree) reorder. Plugins consume the result through `replace ../summercms.go`; the framework tag is a human decision at the end (Task 3). + +## Spec-less probe fallback + +No SPEC.md and no REQUIREMENTS IDs exist 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` derive from the ROADMAP success criteria SC-1..SC-5 and the CONTEXT decisions D-01..D-22; prohibitions are recalled in-prompt and descriptor-less (flagged unverified). + +## Decisions taken in this plan (Claude's discretion) + +- Flat mode is inferred the way WinterCMS infers a Sortable model: no `parentColumn` key and no `parent_id` column on the record. No new YAML key is needed, so a ported `config_reorder.yaml` (title, nameFrom, modelClass) compiles unchanged. An explicit `parentColumn` still means a tree and still fails boot when it is not a column. `partition` keeps working in flat mode (its code path is independent of the parent column); `maxDepth` above 0 on a flat reorder fails boot. +- The flat flag is `flat` (omitempty) on both `ListReorder` (list schema) and `AdminReorderTree` (reorder GET/POST data), so existing tree payloads stay byte-identical. +- `taglist` follows WinterCMS keys: `mode` (`string` default, `array`), `separator` (`comma` default, `space`, string mode only; the stored string joins with `,` or a single space as WinterCMS `getSaveValue` does), `options` (static suggestion list), `customTags` (default true). `useKey` and method-backed option lists are not supported in this plan and fail boot as unknown keys (the closed key set already does this). +- `taglist` is allowed on controller forms, relation forms and settings forms wherever `money` is allowed and is validated at the same three save sites; it is refused inside a repeater's nested form at boot. + +## Artifacts this phase produces (this plan) + +- `modules/cabana`: field type `taglist` with keys `mode` (`string`|`array`), `separator` (`comma`|`space`), `options`, `customTags`; unexported `compileTaglistKeys`, `checkTaglistType`, `normaliseTags`, `checkTaglistValues`, `hydrateTaglistFields`; FormField JSON keys `separator` and `customTags` (omitempty); phrase keys `backend::lang.taglist.invalid`, `backend::lang.taglist.not_allowed`, `backend::lang.taglist.add`, `backend::lang.taglist.remove`, `backend::lang.reorder.flat_parent` (EN and PL). +- `modules/cabana/reorder.go`: `compiledReorder.flat`, `ListReorder.Flat` (json `flat,omitempty`), `AdminReorderTree.Flat` (json `flat,omitempty`), flat save writing only the sort column, 422 on a non-null parent in flat mode. +- Admin SPA: `admin/src/components/form/fields/TaglistField.vue`, renderers entry `taglist`, PreviewField taglist rendering, ReorderView/tree.ts flat mode; regenerated `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`; rebuilt `modules/boardwalk/dist`. +- Fixture `modules/cabana/testdata/catalog` (plugin `acme.catalog`, controller `badges`, model `Badge` with `name`, `tags` taglist array, `keywords` taglist string with separator space, `sort_order`). +- Smoke tests: `TestTaglistFieldCompile` (Docker-free), `TestTaglistFieldSmoke` (Postgres), `TestReorderFlatCompile` (Docker-free), `TestReorderFlatSmoke` (Postgres); vitest `admin/tests/form/TaglistField.test.ts`, flat cases in `admin/tests/views/ReorderView.test.ts`. Full unit coverage is plan 06. +- Docs: `docs/backend/forms.md` `## Taglist` and a type-table row; `docs/backend/lists-and-filters.md` `## Flat reorder`; cabana README Features and API rows. +- Release: proposed framework tag (highest `v0.1.*` plus one; `v0.1.26` at planning time) pushed only on the human's reply. + + +Give cabana the two generic admin pieces D-17 asks for, framework first: a core `taglist` form field (array and string modes) and a flat reorder for records without a parent column, with OpenAPI, generated TS types, the committed dist, docs and the README in the same change, then a human release decision. + +Purpose: the PHP SubscriptionPlans form declares `features` as `type: taglist, mode: array` and the controller has a reorder page on a Sortable model; per D-17 both port faithfully only if cabana has them. Building them in the framework keeps the plugin free of admin workarounds and reusable by every ported WinterCMS plugin. +Output: cabana and SPA changes, docs, fixture and smoke tests, a rebuilt dist, and the release checkpoint. + +Execution budget: this plan is over the single-context smart zone by design (the six-plan split of D-22 under CLAUDE.md's lean-planning rule; the estimate's confidence stays low until three phases have actuals). Commit after every task with the task number in the subject, write the SUMMARY's per-task notes as you go, and after a context reset resume at the first task without a commit. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-PATTERNS.md +@.planning/phases/18-payment-gateway/18-01-SUMMARY.md + +Concurrency: another session may hold uncommitted edits in `modules/cabana` 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). Plan 18.1-02 runs in the same wave in a different repository and compiles this checkout through `replace ../summercms.go`; keep every commit building (`go vet ./...` green). Stage files by explicit path; no co-author tags; planning docs and code in separate commits. Every task that changes `admin/` ends with `npm --prefix admin run build`, the committed `modules/boardwalk/dist` and `bash scripts/check-admin-dist.sh`. Docker-backed 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. + + +Existing seams (read; extend, do not fork): + +- `modules/cabana/field_money.go`: `compileMoneyKeys(typ string, values map[string]ast.Node, field *FormField) error` (:54), `checkMoneyType(model any, field FormField) error` (:217), `compileMoneyFields(pluginID string, cc *CompiledController) error` (:259), `checkMoneyValues(ctx, tr, cc, body, op) error` (:322) and its call sites `crud.go` create/update, `relation_child.go`, `settings.go`; the phrase-key constants block (:25-31). +- `modules/cabana/field_repeater.go`: `liftRepeaterValues` (:210, a list value lifted in place before the scalar projection), `hydrateRepeaterFields` (:425) and `storedJSON` (:457) (a lagoon.Jsonable column decoded for the record response), `coerceRowValue` (:302, nested types). +- `modules/cabana/form_schema.go`: `formFieldTypes` (:24), `formFieldKeys` (:38; `mode` and `options` already exist), the field compile where `compileMoneyKeys` is called. +- `modules/cabana/schema_types.go`: `FormField` (:261; `Mode` json `mode`, `Options []FormOption` json `options` already exist). +- `modules/cabana/reorder.go`: `reorderDocument` (:32), `compiledReorder` (:50), `ListReorder` (:70), `compileReorder` (:86, defaults parentColumn to parent_id at :127 and validates columns at :139), `AdminReorderTree` (:199), `reorderPost` UpdateColumns (:299-315), `reorderTree` (:347), `checkReorderNodes` (:438). +- `admin/src/components/form/fields/MoneyField.vue` (control contract, `lastEmitted` guard), `admin/src/components/form/registry.ts` (renderers map, dated header paragraph), `admin/src/views/ReorderView.vue` (moves :247-250, drop zone :290, save body :152), `admin/src/components/reorder/tree.ts` (`Zone`, `Move`, `FlatNode`). + + + + + + + Task 1: Tracer: a `taglist` field travels from fields.yaml through the schema, the SPA chip input and the save back to a JSON-array column, with OpenAPI, docs and dist + D-17: a new value-bearing field type in the typed admin schema; plan 04's YAML, the generated TS types and later plugin ports build on its keys and its wire shapes. + modules/cabana/field_taglist.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/crud.go, modules/cabana/relation_child.go, modules/cabana/settings.go, modules/cabana/field_repeater.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/catalog/controllers/badges/config_list.yaml, modules/cabana/testdata/catalog/controllers/badges/config_form.yaml, modules/cabana/testdata/catalog/models/badge/fields.yaml, modules/cabana/testdata/catalog/models/badge/columns.yaml, modules/cabana/catalog_smoke_test.go, modules/cabana/taglist_field_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/TaglistField.vue, admin/src/components/form/registry.ts, admin/src/components/form/PreviewField.vue, admin/tests/form/TaglistField.test.ts, modules/boardwalk/dist, docs/backend/forms.md + modules/cabana/field_money.go (whole file: key gate, refused keys, type check, value check, phrase constants, call sites), modules/cabana/money_field_test.go (TestMoneyFieldCompile :22 structure for compile-only subtests), modules/cabana/money_field_postgres_test.go (TestMoneyFieldSave :18 Postgres fixture and env), modules/cabana/field_repeater.go (liftRepeaterValues :210, hydrateRepeaterFields :425, storedJSON :457, coerceRowValue :302), modules/cabana/form_schema.go (:23-52 type and key sets, the compile path that calls compileMoneyKeys), modules/cabana/schema_types.go (FormField :261-330), modules/cabana/crud.go (create and update decode paths, the checkMoneyValues call, the record GET projection where hydrateRepeaterFields runs), modules/cabana/relation_child.go (the checkMoneyValues call :103), modules/cabana/settings.go (the checkMoneyValues call and the settings type allow-list), modules/lagoon/jsonable.go (Jsonable Scan/Value), modules/cabana/admin_openapi.go (form schema swag types), modules/cabana/openapi_conformance_test.go (TestPhase10ConformanceInventory), modules/cabana/testdata/payments (the Phase 18 fixture plugin layout to copy for testdata/catalog), modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (the money: block), admin/src/components/form/fields/MoneyField.vue, admin/src/components/form/registry.ts, admin/src/components/form/PreviewField.vue (ownControl), admin/src/components/form/control.ts, admin/tests/form/MoneyField.test.ts (vitest setup, setBundle, FIELD_SCOPE stub, langFixture), docs/backend/forms.md (:85 Field types table, :110 Field options, :463 Money section shape), WinterCMS taglist reference behaviour at ../examples/golem15-wintercms-starter/modules/backend/formwidgets/TagList.php when present (getSaveValue, separator characters), the PHP field at /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/models/subscriptionplan/fields.yaml (the features field this type must express), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh + Per D-17, wire one taglist field end to end, mirroring how Phase 18 added money. + +cabana compile (field_taglist.go, form_schema.go, schema_types.go): add `taglist` to formFieldTypes and `separator` and `customTags` to formFieldKeys. compileTaglistKeys(typ, values, field) mirrors compileMoneyKeys: `separator` and `customTags` are refused on any other type ("separator is only valid on type: taglist"); on taglist, `mode` must be `string` (default when absent) or `array`, `separator` must be `comma` (default) or `space` and is refused in array mode, `customTags` must be a boolean (default true), `options` must be a YAML list of non-empty strings (a mapping or a method name fails boot), and `nameFrom`, `emptyOption`, `dependsOn`, `preset` and the money keys `currency`, `currencyFrom`, `scale`, `max` are refused. FormField gains `Separator string` (json `separator,omitempty`) and `CustomTags *bool` (json `customTags,omitempty`, set only when the YAML declares it); the options list fills the existing `Options []FormOption` with value equal to label. checkTaglistType (run where checkMoneyType runs, for controller forms, relation forms and settings forms) accepts, in string mode, a string or *string column and, in array mode, a column whose type implements driver.Valuer with a pointer implementing sql.Scanner (lagoon.Jsonable[[]string] or a named slice type with those methods); anything else fails boot "field X: taglist mode array needs a JSON column (sql.Scanner and driver.Valuer)". compileRepeater refuses `taglist` as a nested field type at boot naming the field. + +Save (field_taglist.go; call checkTaglistValues at the three sites that call checkMoneyValues): in array mode the submitted value is null, or a list whose items are all strings, else 422 backend::lang.taglist.invalid on the field; in string mode the value is null or a string, split on the separator character. normaliseTags trims each tag, drops empties and removes duplicates keeping the first occurrence; with customTags false a tag not in options is a 422 backend::lang.taglist.not_allowed with the :tag placeholder. The normalised value replaces the body value before the scalar projection: in array mode the JSON text of the list (empty list serialises as `[]`, never null), handed to the column's Scanner the same way a repeater list reaches a lagoon.Jsonable column; in string mode the tags joined with `,` (comma) or a single space (space). hydrateTaglistFields (run where hydrateRepeaterFields runs) returns array-mode values as a JSON list (stored NULL or unparsable text becomes an empty list) and string-mode values unchanged. + +Phrase keys (en and pl, top-level `taglist:` block): taglist.invalid ("The :field field must be a list of tags." / "Pole :field musi być listą tagów."), taglist.not_allowed ("The tag :tag is not allowed." / "Tag :tag jest niedozwolony."), taglist.add ("Add a tag" / "Dodaj tag"), taglist.remove ("Remove :tag" / "Usuń :tag"). + +OpenAPI: swag on the form schema field type gains the enum value taglist and the separator and customTags properties; run `bash scripts/check-admin-openapi.sh` to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts; extend the conformance inventory only if it enumerates field types. + +SPA: TaglistField.vue follows the MoneyField control contract (FieldControlProps, controlClass/controlAttributes, FIELD_SCOPE, readOnly computed from field.readOnly or attributes.readonly, the lastEmitted guard so an external load replaces the chips but own emits do not). It renders the current tags as chips (mustache text interpolation only, never a raw-HTML binding), each with a remove button whose aria-label is taglist.remove, and a text input (placeholder taglist.add) that adds the trimmed text on Enter or on typing the separator character (a comma in array mode too), ignores empty or duplicate text, and offers field.options through a datalist when present; with customTags false only option values are added. It emits a string array in array mode and the tags joined by `,` or a space in string mode; a loaded string value is split on the separator. Read-only renders the chips without remove buttons and without the input, and a muted em dash when empty. registry.ts adds ['taglist', TaglistField] (not selfLabelled, not valueless) with a dated header paragraph like the money one. PreviewField renders a taglist as the comma-joined tags (add taglist to ownControl). TaglistField.test.ts (vitest): a loaded ["a","b"] renders two chips and is not re-emitted untouched; typing "c" then Enter emits ["a","b","c"]; "a" again adds nothing; remove emits ["b"]; string mode with separator space loads "x y" as two chips and emits "x y z"; customTags false refuses a non-option tag; read-only shows no input. Rebuild the admin (`npm --prefix admin run build`), commit modules/boardwalk/dist and run `bash scripts/check-admin-dist.sh`. + +Fixture and Go smoke: modules/cabana/testdata/catalog holds plugin acme.catalog with controller badges (config_list.yaml listing name and sort_order, toolbar buttons [create]; config_form.yaml) and model badge (fields.yaml: name text required; tags taglist mode array with options [alpha, beta, gamma]; keywords taglist mode string separator space; columns.yaml). catalog_smoke_test.go declares the fixture plugin and the Badge model (ID uint, Name string, Tags lagoon.Jsonable[[]string] gorm type text, Keywords string, SortOrder int, table acme_catalog_badges created by a fixture migration with no parent_id column). taglist_field_test.go holds TestTaglistFieldCompile (Docker-free subtests: the fixture compiles; separator on a text field, separator in array mode, mode list, options as a mapping, money keys on taglist, taglist inside a repeater, and a tags column of plain string type in array mode each fail boot naming the field). TestTaglistFieldSmoke (Postgres, in catalog_smoke_test.go): POST tags [" alpha ", "beta", "alpha", ""] stores `["alpha","beta"]` and the GET returns that list; keywords "x y x" stores "x y"; tags "alpha" (a string) is 422 taglist.invalid; tags [1] is 422 taglist.invalid; null stores `[]`. + +Docs: docs/backend/forms.md gains a taglist row in the Field types table, the new keys in Field options, and a "## Taglist" section after "## Money" (both modes and their wire values, the keys, normalisation, the two 422 keys, the column type rule, an acme example); cabana README Features bullet "Tag lists" and the API reference rows, in the same commit as the code. + + go vet ./... && OUT=$(go test -count=1 -short -run '^TestTaglistField' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestTaglistFieldCompile' && 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/TaglistField.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check + Non-zero exit at any step: vet fails; the short taglist tests fail or "--- PASS: TestTaglistFieldCompile" is absent (a bad key set compiled, or the fixture did not boot); fewer than three inventory passes; admin.json or schema.d.ts drift from the swag annotations; the SPA does not typecheck; a TaglistField vitest fails (an untouched value was re-emitted, a duplicate was added, or string mode joined wrongly); the committed dist differs from a fresh build; the docs checker reports an unknown identifier or a broken anchor. + docker info >/dev/null 2>&1 && OUT=$(go test -count=1 -run '^TestTaglistFieldSmoke$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestTaglistFieldSmoke' + Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the stored tags are not exactly ["alpha","beta"], the string mode did not normalise to "x y", or a string or a number list was not answered 422 taglist.invalid. + + + - `grep -n '"taglist"' modules/cabana/form_schema.go` prints at least one line and `grep -c "func compileTaglistKeys\|func checkTaglistType\|func normaliseTags\|func checkTaglistValues\|func hydrateTaglistFields" modules/cabana/field_taglist.go` prints 5. + - `grep -c "not_allowed" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file. + - `grep -n "\['taglist', TaglistField\]" admin/src/components/form/registry.ts` prints one line and `grep -c "v-html" admin/src/components/form/fields/TaglistField.vue` prints 0. + - `grep -n "^## Taglist" docs/backend/forms.md` prints one line and `grep -n "taglist" modules/cabana/README.md` prints at least one line. + - `grep -rniE "subscription|stripe|payment" modules/cabana/testdata/catalog modules/cabana/field_taglist.go` prints nothing (the field and its fixture stay generic). + + An administrator can edit a list of tags as chips, the server normalises and validates it, a JSON-array column stores it exactly, and the schema, docs and dist carry the new type. + + + + Task 2: Flat reorder: Sortable inference in compile, flat save, the `flat` flag, the SPA flat mode, docs and dist + D-17: the inference rule and the `flat` JSON flag become part of the reorder contract that the SubscriptionPlans screen and later Sortable ports rely on. + modules/cabana/reorder.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/catalog/controllers/badges/config_list.yaml, modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml, modules/cabana/catalog_smoke_test.go, modules/cabana/reorder_flat_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/api/types.ts, admin/src/views/ReorderView.vue, admin/src/components/reorder/tree.ts, admin/tests/views/ReorderView.test.ts, modules/boardwalk/dist, docs/backend/lists-and-filters.md + modules/cabana/reorder.go (whole file; compileReorder :86-172, reorderPost :269-325, reorderTree :347-412, checkReorderNodes :438-491), modules/cabana/reorder_test.go (TestReorderConfigCompile :17 overlay pattern, TestReorderPostgres :101), modules/cabana/testdata/companions/controllers/links/config_reorder.yaml and config_list.yaml (tree fixture to keep unchanged), modules/cabana/admin_openapi.go (:486-520 AdminReorderGet and AdminReorderSave swag stubs), admin/src/views/ReorderView.vue (load :92-101, save body :152, moves :247-250, keyboard map :250, drop zone :290, move buttons in the template), admin/src/components/reorder/tree.ts (Zone, Move, buildTree, flatten), admin/src/components/reorder/ReorderBranch.vue (indent and outdent buttons), admin/src/api/types.ts (:75-86 AdminReorderTree, ListReorder re-exports), admin/tests/views/ReorderView.test.ts (existing specs and fixtures), docs/backend/lists-and-filters.md (:190 Tree reorder section and its key table), the PHP reorder config at /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/controllers/subscriptionplans/config_reorder.yaml (title, modelClass, nameFrom only: the shape that must compile as flat) + Per D-17, add the flat (Sortable) reorder to the existing reorder controller. + +Compile (reorder.go): compiledReorder gains flat bool. When the YAML has no parentColumn key and the record has no `parent_id` column, the reorder is flat: parentColumn stays empty, the parentColumn column check is skipped, and `maxDepth` above 0 fails boot "maxDepth needs a tree reorder (the record has no parent_id column)". When parentColumn is given, or the record has `parent_id`, behaviour is today's tree (an explicit parentColumn that is not a column still fails boot). nameFrom, sortColumn (default sort_order), modelClass and partition checks are unchanged in both modes. ListReorder and AdminReorderTree gain `Flat bool` with json `flat,omitempty` and a doc comment; compileReorder sets ListReorder.Flat, reorderTree sets AdminReorderTree.Flat and leaves every node's ParentID nil in flat mode (no parent column is selected). + +Save: checkReorderNodes in flat mode returns a 422 on nodes with backend::lang.reorder.flat_parent ("A flat reorder has no parents." / "Kolejność płaska nie ma elementów nadrzędnych.") for any node whose parent_id is non-null, keeps the duplicate-id 422 and the 409 set-equality rule, and skips the parent, cycle and depth checks; reorderPost's UpdateColumns writes only the sort column in flat mode (no parent key in the map). Swag @Description of AdminReorderGet and AdminReorderSave mentions the flat mode; regenerate admin.json and schema.d.ts with `bash scripts/check-admin-openapi.sh`. + +SPA: ReorderView reads `flat` from the GET data. In flat mode the indent and outdent controls are not rendered (ReorderBranch receives a flat prop or the view filters the moves), ArrowRight and ArrowLeft do nothing, the drop zone resolves only to before or after (tree.ts gets a helper that maps inside to after when flat), and flatten sends parent_id null for every node; tree mode is unchanged. ReorderView.test.ts adds flat cases: no indent or outdent control renders, ArrowRight leaves the order unchanged, a drag onto the middle of a row lands after it, and the POST body has parent_id null on every node; the existing tree specs still pass. Rebuild the admin, commit the dist, run `bash scripts/check-admin-dist.sh`. + +Fixture and tests: the catalog badges controller gains config_reorder.yaml with exactly title (Reorder badges), nameFrom name and modelClass (no parentColumn), and config_list.yaml lists `reorder` in toolbar buttons. reorder_flat_test.go holds TestReorderFlatCompile (Docker-free): the badges fixture compiles as flat and its list schema carries reorder.flat true; maxDepth 2 on it fails boot naming maxDepth; an explicit parentColumn parent_id on it fails boot ("parentColumn parent_id is not a column of the record"); the companions links tree fixture still compiles with flat false and its list-schema JSON has no flat key. TestReorderFlatSmoke (Postgres, catalog_smoke_test.go): three badges, GET reorder answers flat true and parent_id null on every node; POST with the reversed order stores sort_order 1..3 reversed and answers the new order; a node with parent_id set is 422 on nodes; a missing node is 409. + +Docs: docs/backend/lists-and-filters.md gains "## Flat reorder" after "## Tree reorder" (the inference rule, the flat flag, what the save writes, the 422, an acme example config_reorder.yaml); the Tree reorder section links to it; cabana README Features bullet for flat reorder in the same commit. + + go vet ./... && OUT=$(go test -count=1 -short -run '^(TestReorderFlatCompile|TestReorderConfigCompile)$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestReorder' | grep -qx 2 && go test -count=1 -short ./modules/cabana && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/views/ReorderView.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check + Non-zero exit at any step, or fewer than two "--- PASS: TestReorder" lines: a flat fixture did not compile, maxDepth on flat compiled, the tree fixture regressed or gained a flat key; a Docker-free cabana test regressed; OpenAPI drift; typecheck failure; a ReorderView spec failed (indent rendered in flat mode, a parent id was sent, or a tree case broke); dist drift; docs checker failure. + docker info >/dev/null 2>&1 && OUT=$(go test -count=1 -run '^(TestReorderFlatSmoke|TestReorderPostgres)$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestReorder' | grep -qx 2 + Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: the flat GET carried a parent id, the reversed order was not stored, a parent id was accepted, a missing node was not 409, or the existing tree reorder Postgres test regressed. + + + - `grep -n 'json:"flat,omitempty"' modules/cabana/reorder.go` prints exactly two lines (ListReorder and AdminReorderTree). + - `grep -c "flat_parent" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file. + - `grep -cv '^[[:space:]]*$' modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml` prints 3 and `grep -cE '^(title|nameFrom|modelClass):' modules/cabana/testdata/catalog/controllers/badges/config_reorder.yaml` prints 3 (the WinterCMS Sortable shape, nothing else), and `grep -n "reorder" modules/cabana/testdata/catalog/controllers/badges/config_list.yaml` prints at least one line. + - `git diff --quiet HEAD -- modules/cabana/testdata/companions` exits 0 at the task commit (the tree fixture is unchanged). + - `grep -n "^## Flat reorder" docs/backend/lists-and-filters.md` prints one line and `grep -n "flat" admin/src/views/ReorderView.vue` prints at least one line. + + A record without a parent column gets a working drag-and-drop order page from a WinterCMS-shaped config_reorder.yaml, and existing tree reorders behave exactly as before. + + + + Task 3: Release gate: local checks green, tag chosen, one-way surfaces listed, a human approves the framework tag and the push + .planning/phases/18-payment-gateway/18-01-SUMMARY.md (how the Phase 18 framework release gate ran and was held until the phase gate), .planning/notes/2026-10-07-embedded-admin-release-gate.md, this plan's Task 1 and 2 SUMMARY notes (commits, Docker smoke tests run or NOT RUN) + 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 (v0.1.26 when v0.1.25 is still the highest) 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 taglist with keys mode, separator, options, customTags and its two wire shapes; the flat reorder inference rule and the flat flag on the list schema and the reorder data; phrase keys taglist.* and reorder.flat_parent); the commits to push; the Docker smoke tests recorded NOT RUN in Tasks 1 and 2; that plans 02 to 05 build against this checkout through replace and need no tag, so "hold" costs nothing but a later bump in plan 06. Stop for the human. + Push summercms master and the proposed annotated framework tag now? + A pushed tag is read by plugin go.mod files and the Go module proxy and publishes the taglist field and the flat reorder; it cannot be moved, only superseded. The plugin plans of this phase use replace and do not need it; plan 06 (unit tests) runs after and its tests ship with the next tag. + + + + + + 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 verbatim in the SUMMARY. + + 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 + 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 fixes it before presenting the sheet and never presents a red gate. + + + - 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 `git log --oneline origin/master..master` lists this plan's commits (nothing pushed). + + The human has decided whether the taglist and flat reorder are published, and the record says what was pushed. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| admin browser to cabana API | authenticated but untrusted input: tag lists, tag strings, reorder node sets | +| cabana to the column | normalised value written through the model's Scanner or string column | +| stored tags to the SPA | values written by other administrators or importers rendered in the admin | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-18.1-01 | Tampering | taglist save (checkTaglistValues) | medium | mitigate | only a list of strings (array mode) or a string (string mode) is accepted; normaliseTags trims, drops empties and dedupes; customTags false enforces the options allow-list; 422 taglist.invalid / not_allowed; TestTaglistFieldSmoke | +| T-18.1-02 | Tampering (stored XSS) | TaglistField.vue, PreviewField taglist rendering | high | mitigate | tags render through Vue text interpolation only; the Task 1 acceptance criterion asserts the component has no raw-HTML binding; plan 06 adds a vitest rendering a tag containing markup as text | +| T-18.1-03 | Tampering | flat reorder POST (checkReorderNodes) | medium | mitigate | a non-null parent id is 422, a different node set is 409, duplicate ids 422; UpdateColumns writes only the sort column in flat mode; TestReorderFlatSmoke | +| T-18.1-04 | Elevation of Privilege | reorder and taglist routes | low | accept | no new route: the existing controller permission gate (s.protect) and CSRF checks cover the reorder and form saves unchanged | +| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | this plan installs no npm or Go package (the chip input uses plain Vue and a datalist); go.mod, go.sum and admin/package.json stay unchanged | + + + +- Task 1: Docker-free taglist compile subtests, route inventories, OpenAPI drift, typecheck, TaglistField vitest, dist, docs; the taglist save smoke on a Docker host. +- Task 2: Docker-free flat and tree reorder compile tests, the whole Docker-free cabana suite, OpenAPI drift, typecheck, ReorderView vitest, dist, docs; the flat and tree Postgres reorder tests on a Docker host. +- Task 3: the full release gate before the human decision. +- Feedback sampling (18.1-VALIDATION.md): after each commit the quick command `go vet ./... && go test -count=1 -short ./modules/cabana && npm --prefix admin test -- ` (under 120 s); the full `` chains, which include a fresh admin build, are task-close gates. + + + +- D-17: cabana has a core taglist field (array and string modes) and a flat reorder, documented, in the OpenAPI and the dist. +- Existing field types and tree reorders are byte-identical in their payloads. +- `go vet ./...`, `go test -short ./...`, the admin typecheck and vitest stay green at every commit; the dist matches a fresh build. +- The human decided the framework tag (D-22). + + + +Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-01-SUMMARY.md` when done, with the commits, the identifiers added, the Docker smoke tests run or NOT RUN, the release decision and tag, and any deviation. + diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-PLAN.md b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-PLAN.md new file mode 100644 index 0000000..a755fe3 --- /dev/null +++ b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-PLAN.md @@ -0,0 +1,346 @@ +--- +phase: 18.1-payment-gateway-subscriptions +plan: 02 +type: execute +wave: 1 +depends_on: [] +coupling_justified: ["18.1-01: this plan compiles summercms.go through replace ../summercms.go but uses no symbol plan 01 adds; a red build seen while plan 01 has a task in flight is transient (re-run the verify after plan 01's current task commits); both orders produce the same result"] +files_modified: + - ../sm-paymentgatewaysubscriptions-plugin/.gitignore + - ../sm-paymentgatewaysubscriptions-plugin/go.mod + - ../sm-paymentgatewaysubscriptions-plugin/go.sum + - ../sm-paymentgatewaysubscriptions-plugin/go.work + - ../sm-paymentgatewaysubscriptions-plugin/README.md + - ../sm-paymentgatewaysubscriptions-plugin/plugin.go + - ../sm-paymentgatewaysubscriptions-plugin/boot.go + - ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml + - ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml + - ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml + - ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go + - ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go + - ../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go + - ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go + - ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go + - ../sm-paymentgatewaysubscriptions-plugin/models/processed_webhook_event.go + - ../sm-paymentgatewaysubscriptions-plugin/models/feature_list.go + - ../sm-paymentgatewaysubscriptions-plugin/models/registry.go + - ../sm-paymentgatewaysubscriptions-plugin/events/events.go + - ../sm-paymentgatewaysubscriptions-plugin/classes/service.go + - ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go + - ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go + - ../sm-paymentgatewaysubscriptions-plugin/updates/registry.go + - ../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go + - ../sm-paymentgatewaysubscriptions-plugin/updates/202610150002_add_stripe_customer_id_to_users.go + - ../sm-paymentgatewaysubscriptions-plugin/testmain_test.go + - ../sm-paymentgatewaysubscriptions-plugin/harness_test.go + - ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go + - ../sm-paymentgatewaysubscriptions-plugin/tracer_test.go + - ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go +autonomous: false +requirements: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-05, D-06, D-08, D-09, D-13, D-16, D-22] +estimate: + tokens: 220000 + raw_tokens: 220000 + tasks: 4 + confidence: low +must_haves: + truths: + - "D-06: a new local repository ../sm-paymentgatewaysubscriptions-plugin (branch main) holds module git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin, root package pgsub, plugin ID golem15.paymentgatewaysubscriptions, Requires golem15.paymentgateway and golem15.user; `go list -deps ./...` of the module names no stripe-go package." + - "D-04 as amended by D-16: one idempotent migration 202610150001_create_golem15_pgsub_baseline creates golem15_pgsub_subscription_plans, golem15_pgsub_subscriptions and golem15_pgsub_processed_webhook_events with the PHP v2.4.0 table, column and index names (CREATE ... IF NOT EXISTS), subscriber_type and subscriber_id nullable, the stripe_status CHECK over the eight PHP values, the RESTRICT foreign key to plans; the two dormant usage and featured-placement tables are not created; re-running it on a migrated database changes nothing." + - "D-09: migration 202610150002_add_stripe_customer_id_to_users adds users.stripe_customer_id (VARCHAR(255) NULL) and its index only when missing (ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS); its rollback is a no-op because the column may predate the plugin; the built-in user subscribable reads and writes that column with targeted queries and never saves a whole user row." + - "D-08: subscribers register at build time: Boot walks party.Activated, collects contracts.HasSubscribables (alias to resolver) and contracts.HasSubscriptionGateways (identifier to gateway), fails boot on a duplicate alias or identifier naming both plugins, and publishes both registries; resolving an alias no plugin registered returns contracts.ErrUnknownSubscriberType naming the alias and is never skipped; classes.UserSubscribable() is the opt-in `user` resolver (email, name and surname trimmed with the e-mail as fallback, soft-deleted users still resolve)." + - "D-05: the gateway column keeps the values stripe and google_play (models.GatewayStripe, models.GatewayGooglePlay); Subscription.IsFromGateway, IsStripeSubscription (true for stripe or an empty gateway) and IsGooglePlaySubscription exist; only a registered gateway can be called and an unregistered identifier returns contracts.ErrGatewayNotInstalled." + - "D-03: plan features are opaque strings: SubscriptionPlan.HasFeature(key) and Subscription.HasFeature(key) test membership in the stored list; models.FeatureList stores a JSON array of strings and also decodes the legacy map-of-truthy-keys shape; the plugin defines no feature constants and no quote-quota helper." + - "D-01 and D-02: status values (incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid, paused), gateway values, table and column names and the six event names golem15.pgsub.subscription.activated, .canceled, .past_due, .reactivated, golem15.pgsub.invoice.paid and .invoice.payment_failed are the frozen PHP strings; lead fees, metering, usage records, featured placements, analytics and the quota exception are not ported." + - "SC-2 core (gateway-neutral): classes.Service.Apply upserts a subscription from a contracts.Update by gateway subscription id and fires, inside the caller's transaction with Tx on the event, reactivated (cancel_at_period_end true to false while active or trialing, checked before the status diff), then nothing when the status is unchanged, else activated (now active or trialing, before neither), past_due and canceled on entering those statuses; ApplyDeleted sets canceled and ended_at and fires canceled unconditionally (an unknown subscription is a logged no-op); ApplyInvoice fires invoice.paid or invoice.payment_failed with a typed, PII-free contracts.Invoice and changes no row; ClaimEvent inserts the processed-event marker with ON CONFLICT DO NOTHING and reports whether this caller claimed it." + - "D-13 (service API): classes.Service offers ActivePlans (is_active, sort_order then id), ActivePlanByCode, Latest (created_at then id descending, plan preloaded), Active, Subscriber(alias, id), Checkout (customer id ensured and persisted in its own short write before the gateway creates the session), Portal, Cancel (at period end or immediately; at period end also sets cancel_at_period_end locally after the gateway succeeds), HasAccess and InGracePeriod (grace_period_days from config, default 7), HasFeature, HasActiveSubscription, OnPlan, CurrentPlanCode, SyncPlan (persists stripe_product_id even when a price step fails) and SyncSubscription (no events, as PHP)." + - "D-22: nothing is pushed and no remote is created before the human's reply at the Task 4 checkpoint; plan 03's go.mod points at this local repository through replace, so this repository's main is committed (clean tree) before plan 03 starts." + artifacts: + - path: "../sm-paymentgatewaysubscriptions-plugin/plugin.go" + provides: "golem15.paymentgatewaysubscriptions plugin: Requires, config, lang, migrations, models, boot steps" + contains: "golem15.paymentgatewaysubscriptions" + - path: "../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go" + provides: "Gateway interface (incl. SyncPlanToGateway), CheckoutOptions, CheckoutSession, Update, InvoiceUpdate, Invoice" + contains: "SyncPlanToGateway" + - path: "../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go" + provides: "Subscribers and Gateways registries, HasSubscribables, HasSubscriptionGateways, sentinel errors" + contains: "ErrUnknownSubscriberType" + - path: "../sm-paymentgatewaysubscriptions-plugin/classes/apply.go" + provides: "Apply, ApplyDeleted, ApplyInvoice, ClaimEvent, EventProcessed with the PHP event diff" + contains: "ON CONFLICT" + - path: "../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go" + provides: "idempotent v2.4.0 baseline" + contains: "IF NOT EXISTS" + - path: "../sm-paymentgatewaysubscriptions-plugin/events/events.go" + provides: "six typed events with the PHP names" + contains: "golem15.pgsub.invoice.payment_failed" + key_links: + - from: "../sm-paymentgatewaysubscriptions-plugin/boot.go" + to: "../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go" + via: "Boot collects HasSubscribables and HasSubscriptionGateways from party.Activated and publishes the registries" + pattern: "HasSubscriptionGateways" + - from: "../sm-paymentgatewaysubscriptions-plugin/classes/service.go" + to: "../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go" + via: "Service resolves subscribers and gateways through the published registries" + pattern: "Resolver\\(|Gateway\\(" + - from: "../sm-paymentgatewaysubscriptions-plugin/classes/apply.go" + to: "../sm-paymentgatewaysubscriptions-plugin/events/events.go" + via: "festival Bus Fire of the typed events inside the caller's transaction" + pattern: "Fire" + - from: "../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go" + to: "users.stripe_customer_id" + via: "targeted UPDATE users SET stripe_customer_id" + pattern: "stripe_customer_id" + prohibitions: + - statement: "pgsub contains no Stripe code and imports no stripe-go package (D-06); Stripe lives in sm-pgstripe-plugin/subscriptions (plan 03)." + status: unresolved + verification: judgment + - statement: "No code, comment, README or fixture of the plugin names a consuming application." + status: unresolved + verification: judgment + - statement: "The built-in user subscribable never writes a users column other than stripe_customer_id." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP. + +This plan's slice (plan 2 of 6, wave 1, per D-22): the pgsub core in a new repository, proven by a tracer on Postgres with a fake gateway: a `user` subscriber checks out, the gateway-neutral `Apply` stores the subscription, the `activated` event fires once and the access check answers true. It contains no Stripe code (D-06); plan 03 adds the Stripe gateway and webhook, plan 04 the admin screens and the customer API. + +## Spec-less probe fallback + +Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01). `must_haves` derive from SC-1..SC-5 and D-01..D-22. + +## Decisions taken in this plan (Claude's discretion) + +- Root package name `pgsub`; layout mirrors the WinterCMS directories (`contracts`, `models` (leaf), `events`, `classes`, `updates`), as `.planning/notes/plugin-layout-winter-directories.md` requires. The service is `classes.Service`, published on the app at Boot and found with `app.Lookup[*classes.Service]()`. +- The gateway interface keeps the PHP method set minus `getWebhookSecret` and `verifyWebhookSignature` (Stripe-only, used only by the Stripe webhook; RESEARCH Pattern 1). Gateways never write the database: `GetOrCreateCustomer` returns the id, `SyncSubscription` and `SyncPlanToGateway` mutate the passed row in memory, and the service persists in short transactions so no transaction is held across a gateway call (RESEARCH anti-patterns). The service, not the gateway, also writes `cancel_at_period_end` locally after a successful cancel at period end (PHP left it for the webhook; the webhook later confirms the same value, and the event diff is unchanged). +- `CheckoutOptions{Gateway, Currency, Metadata}` replaces PHP's options array; `Metadata` carries what PHP hard-coded per application (D-19 consumes it in plan 03). +- Grace and access checks take an explicit `now` and grace-day count on the model (pure functions) and are wrapped by the service with its clock and the config value. +- `ClaimEvent` lives in pgsub (gateway-neutral, the table is pgsub's) so any gateway's job can claim-first (RESEARCH Pattern 4). + +## Artifacts this phase produces (this plan) + +- Repository `../sm-paymentgatewaysubscriptions-plugin` (branch main; remote created only on the Task 4 reply), module `git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin`, package `pgsub`, `PluginID = "golem15.paymentgatewaysubscriptions"`, `ConfigGracePeriodDays = "golem15.paymentgatewaysubscriptions.grace_period_days"`. +- `contracts`: `Gateway` (`Identifier`, `GetOrCreateCustomer`, `CreateCheckoutSession`, `CreateManagementSession`, `CancelSubscription`, `SyncSubscription`, `SyncPlanToGateway`, `SupportsCurrency`), `CheckoutOptions`, `CheckoutSession{URL, SessionID}`, `Update`, `InvoiceUpdate`, `Invoice`, `Subscriber`, `SubscriberResolver`, `Subscribers`, `Gateways`, `HasSubscribables`, `HasSubscriptionGateways`, `ErrUnknownSubscriberType`, `ErrDuplicateAlias`, `ErrDuplicateGateway`, `ErrGatewayNotInstalled`. +- `models`: `Subscription`, `SubscriptionPlan`, `ProcessedWebhookEvent`, `FeatureList`, status constants `StatusIncomplete` … `StatusPaused`, `GatewayStripe`, `GatewayGooglePlay`, scopes `ForSubscriber`, `ScopeActive`, `ScopePastDue`, `ScopeCanceled`, `ScopeFromGateway`, `ScopeActivePlans`, `ScopeOrdered`, methods `IsActive`, `InGracePeriod`, `HasAccess`, `HasFeature`, `WillCancelAtPeriodEnd`, `DaysUntilRenewal`, `OnTrial`, `IsFromGateway`, `IsStripeSubscription`, `IsGooglePlaySubscription`, `PriceCentsFor`, `StripePriceIDFor`, `TierLevel`, PHP class names via `ModelName` constants. +- `events`: six `...EventName` constants and `SubscriptionActivated`, `SubscriptionCanceled`, `SubscriptionPastDue`, `SubscriptionReactivated`, `InvoicePaid`, `InvoicePaymentFailed` (embedding `SubscriptionEvent` / `InvoiceEvent` with `Tx`). +- `classes`: `Service` (methods listed in must_haves), `UserSubscribable()`. +- Migrations `202610150001_create_golem15_pgsub_baseline`, `202610150002_add_stripe_customer_id_to_users`; config key `grace_period_days` (default 7, env `SUMMER_GOLEM15__PAYMENTGATEWAYSUBSCRIPTIONS__GRACE_PERIOD_DAYS`). +- Tests: `TestSubscriptionsTracer`, `TestSubscriptionsCoreSmoke` (Postgres), the harness (`testmain_test.go`, `harness_test.go`, `fixture_plugin_test.go` with plugin `acme.members`, a fake gateway and an event recorder). + + +Create the pgsub core: the repository, the v2.4.0 baseline, models with their checks, the gateway and subscriber contracts with build-time registries, the six typed events, the gateway-neutral `Apply` with PHP's event diff, the processed-event claim, and the Go service API, proven end to end with a fake gateway. + +Purpose: SC-1 and the gateway-neutral half of SC-2 and SC-3 (D-02 to D-09, D-13, D-16); plans 03 and 04 build the Stripe gateway, the admin and the customer API on these contracts. +Output: a committed local repository with a passing tracer and a smoke test, and a human decision on the remote and the push. + +Execution budget: over the single-context smart zone by design (D-22 split). Commit per task, write SUMMARY notes as you go, resume at the first uncommitted task after a reset. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-PATTERNS.md +@.planning/phases/18-payment-gateway/18-02-SUMMARY.md + +Sibling paths: summercms.go is the working directory; the new repository is `../sm-paymentgatewaysubscriptions-plugin`; Phase 18's plugins are `../sm-paymentgateway-plugin` (head f55320a) and `../sm-pgstripe-plugin`; sm-user-plugin is read from `../sm-summercmsio-app/plugins/golem15/user` (submodule; never stage files inside it). PHP sources are frozen (D-01, D-15): pgsub is read from the working tree of `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions` (HEAD a522376 = ac3d73c + 5fb9dac; use `git -c safe.directory=*` for any git command there). Commit in the new repository only; summercms.go gets no commit from this plan. Plan 18.1-01 edits summercms.go in the same wave; this plan only compiles it. + + +Analogs to copy (read, do not modify): + +- `../sm-paymentgateway-plugin/plugin.go` (interface assertions block, `//go:embed config` and `lang`, `Requires`, `Register` storing app, `Boot` running boot steps, `init()` with `party.Register`), `boot.go` (`registerBootStep`/`runBootSteps` :20-53, `bootOperators` :96-120 walking `party.Activated` and `app.Publish`), `operators/registry.go` (:10-55 sentinel errors, RWMutex, ordered keys, `Add(pluginID, x)` failing with `"%w: %q is published by %s and %s"`), `events/events.go` (package doc, one `const ...EventName` per line, embedded payload struct with `Tx *gorm.DB`), `updates/registry.go` (`Register`, `All`, `run`), `harness_test.go` (`bootPaymentGateway` :59, `testAppKey` :40, `tx`/`txErr` helpers), `testmain_test.go`, `go.mod` and `go.work` (replace lines). +- `../sm-pgstripe-plugin/testmain_test.go` (TestMain :29 with `flag.Parse()` and `testing.Short()`, `startPostgres`, `freshDB` :82), `go.mod`/`go.work` (the sibling replace layout to copy). +- `../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go` (idempotent ALTER ... ADD COLUMN IF NOT EXISTS template). +- `../sm-summercmsio-app/plugins/golem15/user/models/user.go` (`users` columns `email`, `name`, `surname`, `deleted_at`; no username column). +- summercms.go `modules/festival/bus.go` (`Bus.Fire`, `Bus.Listen`), `modules/lagoon/jsonable.go`, `modules/lagoon/transaction.go` (`AfterCommit`), `modules/party` (`Activated`), `modules/pact/capabilities.go`. + + + + + + + Task 1: Confirm the one-way baseline: migration IDs and table shapes every application's database will carry (D-04, D-09, D-16) + .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ v2.4.0 Schema Baseline: the real MariaDB DDL diffed against the dump and the recommended Postgres DDL), .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md (D-04, D-09, D-16), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/updates/ (version.yaml and the v1.0.1 to v2.4.0 migration files), ../sm-paymentgateway-plugin/updates/ (Phase 18 baseline style: TEXT for JSON, TIMESTAMPTZ, SERIAL) + Print the baseline sheet, then stop. The sheet lists: migration ID 202610150001_create_golem15_pgsub_baseline with the three tables, every column with its Postgres type, nullability and default, the index names (golem15_pgsub_subscription_plans_code_unique, _code_index, _is_active_index, _sort_order_index; golem15_pgsub_subscriptions_stripe_subscription_id_unique, _subscription_plan_id_index, _stripe_status_index, _current_period_end_index, pgsub_subscriber_index, pgsub_gateway_index; golem15_pgsub_processed_webhook_events_event_id_unique, _event_id_index), the CHECK golem15_pgsub_subscriptions_stripe_status_check and the foreign key golem15_pgsub_subscriptions_subscription_plan_id_foreign ON DELETE RESTRICT, all created IF NOT EXISTS; migration ID 202610150002_add_stripe_customer_id_to_users (users.stripe_customer_id VARCHAR(255) NULL plus users_stripe_customer_id_index, both IF NOT EXISTS, rollback a no-op); what is deliberately absent (the two dormant usage and featured-placement tables per D-16, the seed rows, the professional-table ALTER and the polymorphic conversion). Name the one difference between the options below. + Which column type do the three JSON columns (stripe_prices, currency_prices, features) get in the one-way baseline? + The migration IDs and the table shapes become the shared contract of every application that mounts the plugin (D-04 one-way); changing them later needs a migration in every application. The decision of D-04/D-16 is taken; this gate only confirms the concrete DDL before Task 2 writes it. + + + + + Reply "baseline-research" or "baseline-jsonb"; record the reply verbatim in the SUMMARY and write Task 2's migration accordingly. + + test -d /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/updates && grep -c "golem15_pgsub_" /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md | grep -qE '^[1-9][0-9]+$' && echo baseline-sheet-ok + Non-zero exit or "baseline-sheet-ok" absent: the frozen PHP updates directory is missing or the research DDL the sheet is printed from is gone. + + + - The SUMMARY records the printed sheet's migration IDs and the human's reply verbatim before Task 2's first commit. + + The baseline's one-way shape is confirmed before any migration file exists. + + + + Task 2: Tracer: a `user` subscriber checks out through a fake gateway, Apply stores the active subscription, `activated` fires once and the access check answers true, in a new repository on Postgres + D-04/D-16 confirmed at Task 1: the migration IDs and table shapes written here become every application's schema; D-09 adds a column to the shared users table. + ../sm-paymentgatewaysubscriptions-plugin/.gitignore, ../sm-paymentgatewaysubscriptions-plugin/go.mod, ../sm-paymentgatewaysubscriptions-plugin/go.sum, ../sm-paymentgatewaysubscriptions-plugin/go.work, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/boot.go, ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go, ../sm-paymentgatewaysubscriptions-plugin/models/processed_webhook_event.go, ../sm-paymentgatewaysubscriptions-plugin/models/feature_list.go, ../sm-paymentgatewaysubscriptions-plugin/models/registry.go, ../sm-paymentgatewaysubscriptions-plugin/events/events.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go, ../sm-paymentgatewaysubscriptions-plugin/updates/registry.go, ../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go, ../sm-paymentgatewaysubscriptions-plugin/updates/202610150002_add_stripe_customer_id_to_users.go, ../sm-paymentgatewaysubscriptions-plugin/testmain_test.go, ../sm-paymentgatewaysubscriptions-plugin/harness_test.go, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go, ../sm-paymentgatewaysubscriptions-plugin/tracer_test.go + ../sm-paymentgateway-plugin/plugin.go, ../sm-paymentgateway-plugin/boot.go, ../sm-paymentgateway-plugin/operators/registry.go, ../sm-paymentgateway-plugin/events/events.go, ../sm-paymentgateway-plugin/updates/registry.go, ../sm-paymentgateway-plugin/harness_test.go, ../sm-paymentgateway-plugin/testmain_test.go, ../sm-paymentgateway-plugin/go.mod, ../sm-paymentgateway-plugin/go.work, ../sm-pgstripe-plugin/go.mod, ../sm-pgstripe-plugin/go.work, ../sm-pgstripe-plugin/testmain_test.go, ../sm-pgstripe-plugin/fixture_plugin_test.go (test-only fixture plugin and TestFixtureAbsentFromProduction), ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go, ../sm-summercmsio-app/plugins/golem15/user/models/user.go, modules/festival/README.md, modules/lagoon/jsonable.go, .planning/notes/plugin-layout-winter-directories.md, .planning/notes/core-plugins-own-repos.md, PHP (frozen, working tree): /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/{Plugin.php,contracts/SubscriptionGateway.php,contracts/Subscribable.php,models/Subscription.php,models/SubscriptionPlan.php,models/ProcessedWebhookEvent.php,config/config.php,lang/en/lang.php}, /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe jobs/ProcessStripeSubscriptionWebhook.php at 8b47457 (`git -c safe.directory=* -C /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe show 8b47457:jobs/ProcessStripeSubscriptionWebhook.php`, lines 280-410: syncSubscription and fireStateChangeEvents, the event diff to port), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Patterns 1, 2, 5, 7; § Event and State Mapping; § Horoskopia Consumer to Go Service API) + Per D-06, create the repository and the thinnest real path through every layer. + +Repository: `git init -b main ../sm-paymentgatewaysubscriptions-plugin`; .gitignore as the Phase 18 plugins; go.mod module git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin, go 1.27.0, requiring git.golem15.com/golem15/summercms v0.1.25 with `replace git.golem15.com/golem15/summercms => ../summercms.go`, git.golem15.com/golem15/sm-paymentgateway-plugin v0.0.0 with `replace ... => ../sm-paymentgateway-plugin`, git.golem15.com/golem15/sm-user-plugin v0.1.1, gormigrate v2.1.7, gorm, testcontainers-go v0.44.0 (the versions sm-pgstripe-plugin pins); go.work `use .` with the same two replaces plus `replace git.golem15.com/golem15/sm-user-plugin => ../sm-summercmsio-app/plugins/golem15/user`; `go mod tidy`. No new third-party module beyond what the siblings already require. + +Plugin (plugin.go, boot.go): package pgsub, `PluginID = "golem15.paymentgatewaysubscriptions"`, Requires golem15.paymentgateway and golem15.user (D-06), embeds config and lang, Migrations returns updates.All(), Models returns models.All(), Boot runs steps that (1) collect registries: walk party.Activated (skipping itself), add every contracts.HasSubscribables resolver and every contracts.HasSubscriptionGateways gateway, failing boot on duplicates with errors prefixed "golem15.paymentgatewaysubscriptions: boot", and app.Publish both; (2) build classes.Service over the app (db from lagoon, festival bus, registries, grace days from config, clock time.Now) and app.Publish it. + +Contracts: contracts/subscriber.go defines Subscriber (MorphAlias, Key uint, Email, Name, CustomerID, SetCustomerID(ctx, tx, id) error) and SubscriberResolver (Alias, Resolve(ctx, db, id uint) (Subscriber, error)). contracts/gateway.go defines Gateway (Identifier; GetOrCreateCustomer(ctx, s) (string, error), returning s.CustomerID() when set and never writing the database; CreateCheckoutSession(ctx, s, plan *models.SubscriptionPlan, successURL, cancelURL string, o CheckoutOptions) (CheckoutSession, error); CreateManagementSession(ctx, s, returnURL) (string, error); CancelSubscription(ctx, sub *models.Subscription, atPeriodEnd bool) error; SyncSubscription(ctx, sub) error mutating sub in memory; SyncPlanToGateway(ctx, plan) (map[string]string, error) mutating plan in memory; SupportsCurrency(currency string) bool), CheckoutOptions{Gateway, Currency string; Metadata map[string]string}, CheckoutSession{URL, SessionID}, Update (Gateway, SubscriptionID, CustomerID, Status, PriceID, SubscriberType string; SubscriberID uint; CurrentPeriodStart, CurrentPeriodEnd *time.Time; CancelAtPeriodEnd bool; CanceledAt, EndedAt, TrialEndsAt *time.Time), Invoice (ID, CustomerID, Currency, BillingReason string; AmountPaid, AttemptCount int64) and InvoiceUpdate (Paid bool, Gateway, SubscriptionID string, Invoice). contracts/registry.go: Subscribers and Gateways with Add(pluginID, x), lookup returning ErrUnknownSubscriberType (alias in the message) or ErrGatewayNotInstalled, Aliases()/Identifiers() in registration order, and the two Has* capability interfaces. Every exported identifier has a doc comment. + +Models (leaf package): Subscription and SubscriptionPlan with gorm column tags matching the baseline exactly, TableName, ModelName returning the PHP class strings (Golem15\PaymentGatewaySubscriptions\Models\Subscription, ...\SubscriptionPlan), Fillable from the PHP $fillable lists, the eight status constants and two gateway constants verbatim (D-01, D-05), Plan *SubscriptionPlan belongsTo on subscription_plan_id; ProcessedWebhookEvent (event_id, event_type, processed_at); FeatureList (named []string with Scan accepting a JSON list or a legacy map of truthy keys, and Value writing a JSON list, `[]` when empty) for features (D-03); stripe_prices and currency_prices as lagoon.Jsonable[map[string]string] and lagoon.Jsonable[map[string]int64] (or the JSONB-aware equivalent if Task 1 chose baseline-jsonb). This task needs only the fields and IsActive/HasAccess; Task 3 adds the rest. + +Migrations (updates): 202610150001_create_golem15_pgsub_baseline with the DDL confirmed at Task 1 (every CREATE TABLE and CREATE INDEX with IF NOT EXISTS; rollback drops the three tables IF EXISTS in dependency order); 202610150002_add_stripe_customer_id_to_users per D-09 (ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS users_stripe_customer_id_index; Rollback returns nil with a comment explaining the shared column). + +Events (events package): the six `...EventName` constants with the literal PHP names, one per line; SubscriptionEvent{Subscription *models.Subscription; Tx *gorm.DB} embedded by SubscriptionActivated, SubscriptionCanceled, SubscriptionPastDue, SubscriptionReactivated; InvoiceEvent{Subscription; Invoice contracts.Invoice; Tx} embedded by InvoicePaid and InvoicePaymentFailed; the package doc states that events fire inside the transaction and side effects belong in lagoon.AfterCommit, and that a listener error fails the caller (listeners must be idempotent). + +Service (classes): Service with Subscriber(ctx, alias, id) (registry lookup, D-08: unknown alias is an error), Checkout(ctx, s, plan, successURL, cancelURL, opts) (resolve the gateway from opts.Gateway or models.GatewayStripe; when s.CustomerID() is empty call GetOrCreateCustomer outside any transaction, then persist with s.SetCustomerID in its own short lagoon transaction; then CreateCheckoutSession), Latest(ctx, alias, id) (ForSubscriber, created_at desc then id desc, Plan preloaded, nil when none), HasAccess(sub). apply.go: Apply(ctx, tx, u contracts.Update) (*models.Subscription, error) upserting by stripe_subscription_id (find-or-new; subscriber type and id set only when both present; plan resolved by exact stripe_price_id, then by exact value match in each plan's stripe_prices map, never a substring match; customer id; status default incomplete; gateway; periods; cancel flag; canceled_at, ended_at, trial_ends_at only when non-nil, never cleared), then the PHP event diff (this task needs activated; Task 3 completes the rest) fired through the festival bus with Tx set; a missing plan or subscriber pair on a new row is an error naming the gateway subscription id (the caller retries). + +UserSubscribable (classes/user_subscribable.go, D-08/D-09): alias user; Resolve reads users (Unscoped, so soft-deleted users still resolve) selecting id, email, name, surname, stripe_customer_id; Name is name and surname trimmed, else the e-mail; SetCustomerID runs `UPDATE users SET stripe_customer_id = ? WHERE id = ?` through the given tx and nothing else. + +Tests: testmain_test.go copies the pgstripe TestMain (flag.Parse, testing.Short skips the container); harness_test.go boots golem15.user, golem15.paymentgateway, golem15.paymentgatewaysubscriptions and the fixture with SUMMER_APP__KEY and the hashids salt the paymentgateway harness uses, migrates, and exposes db, service, bus helpers; fixture_plugin_test.go declares test-only plugin acme.members (Requires golem15.paymentgatewaysubscriptions) returning classes.UserSubscribable() from Subscribables() and a fakeGateway (identifier stripe, recording calls, returning cus_test_1, a checkout URL and session id) from SubscriptionGateways(), and records the six events through festival listeners. tracer_test.go TestSubscriptionsTracer (Postgres): create a user and a plan (code premium, price_cents 2999, currency pln, stripe_price_id price_premium); Subscriber("user", id) then Checkout returns the fake URL and session id, and users.stripe_customer_id equals cus_test_1; in one transaction Apply(Update{Gateway stripe, SubscriptionID sub_1, CustomerID cus_test_1, Status active, PriceID price_premium, SubscriberType user, SubscriberID id, period end in 30 days}) stores one row with plan premium; exactly one SubscriptionActivated was recorded with Tx non-nil; Latest returns it and HasAccess is true. + +README skeleton in the Phase 18 plugin README structure (H1 `golem15.paymentgatewaysubscriptions`, one-sentence summary, the blank-import line, the module/package/plugin-ID/requires line, then Overview, Features, Usage, API reference, Configuration, Dependencies, Testing, as ../sm-pgstripe-plugin/README.md) stating the plugin ID, Requires, the tables, the registries and the service; it names no consuming application. + + go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && DEPS=$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./...) && ! printf '%s\n' "$DEPS" | grep -q 'stripe-go' && test "$(git -C ../sm-paymentgatewaysubscriptions-plugin rev-parse --abbrev-ref HEAD)" = main + Non-zero exit: vet or the Docker-free suite fails (TestMain did not skip under -short), a stripe-go package appears in the dependency list, or the repository is not on branch main. + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^TestSubscriptionsTracer$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestSubscriptionsTracer' + Non-zero exit: no Docker daemon (record NOT RUN), or the tracer failed: the migrations did not run, the customer id was not stored on users.stripe_customer_id, Apply stored no row or the wrong plan, activated fired zero or two times, or HasAccess was false. + + + - `grep -c "IF NOT EXISTS" ../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go` prints at least 15 (three tables plus twelve indexes) and `grep -cE "usage_records|featured_placements" ../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go` prints 0. + - `grep -c 'EventName = "golem15.pgsub' ../sm-paymentgatewaysubscriptions-plugin/events/events.go` prints 6. + - `grep -n "golem15.paymentgateway\", \"golem15.user\"" ../sm-paymentgatewaysubscriptions-plugin/plugin.go` prints one line. + - `grep -rniE "horoskopia|queststream|quotify" ../sm-paymentgatewaysubscriptions-plugin --include=*.go --include=*.md --include=*.yaml` prints nothing. + - `git -C ../sm-paymentgatewaysubscriptions-plugin status --porcelain` prints nothing after the task commit. + + A subscriber can check out through a registered gateway and the stored subscription grants access, with the activated event fired once, in a repository that contains no Stripe code. + + + + Task 3: The complete gateway-neutral core: model checks, scopes, the full event diff, invoices, the processed-event claim, the remaining service API, config and the smoke test + D-13: the Go service API and the contracts are what plan 03's gateway, plan 04's API and every consuming application call; renaming them touches all of them. + ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go, ../sm-paymentgatewaysubscriptions-plugin/models/feature_list.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go, ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go + ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go (all from Task 2), PHP (frozen): models/Subscription.php (:115-370 scopes, isActive, isInGracePeriod :205, hasAccess :222, hasFeature :233, willCancelAtPeriodEnd, getDaysUntilRenewalAttribute :257, onTrial, isFromGateway :284, isStripeSubscription :296, status labels), models/SubscriptionPlan.php (:100-240 scopeActive, scopeOrdered, hasFeature :141, findByCode, getStripePriceId :188, getPriceCents :209, getTierLevel :228), models/ProcessedWebhookEvent.php (isProcessed, markProcessed), traits/HasSubscriptions.php (hasActiveSubscription, onPlan, currentPlanCode), config/config.php (grace_period_days), the pgstripe job at 8b47457 (fireStateChangeEvents, handleSubscriptionDeleted, handleInvoicePaid, handleInvoicePaymentFailed), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/routes.php lines 362-760 and console/ReconcileSubscriptions.php (the consumer calls the service API must keep short; read-only), /media/nvme/dev/golem15/quotify.pro/themes/quotify/pages/subscription.htm is not needed here, ../sm-paymentgateway-plugin/config/config.yaml (config header style), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ Event and State Mapping table, behaviours list, Pitfalls 4, 8, 10) + Complete the core per D-01 to D-05, D-08 and D-13. + +Models: Subscription gains IsActive (active or trialing), InGracePeriod(now, graceDays) (status past_due and current_period_end non-nil and now before end plus graceDays days), HasAccess(now, graceDays) (active, trialing or in grace), HasFeature(key) (through the preloaded Plan; false without one), WillCancelAtPeriodEnd, DaysUntilRenewal(now) *int (whole days, floored at 0, nil without a period end, matching Carbon diffInDays), OnTrial(now) (trialing and trial_ends_at in the future), IsFromGateway(g), IsStripeSubscription (stripe or empty), IsGooglePlaySubscription, and the scopes ForSubscriber(alias, id), ScopeActive, ScopePastDue, ScopeCanceled, ScopeFromGateway(g). SubscriptionPlan gains ScopeActivePlans (is_active true), ScopeOrdered (sort_order, then id), HasFeature(key) (list membership on FeatureList), PriceCentsFor(currency) (currency_prices[lower(currency)] else price_cents), StripePriceIDFor(currency) (stripe_prices[lower(currency)] else stripe_price_id), TierLevel (code prefix core 1, plus 2, max 3, else 0, as PHP getTierLevel; documented as product metadata only), and Rules from the PHP $rules (code required and unique, name required max 255, price_cents required integer min 0, currency required size 3, billing_interval in month,year; where lagoon's model rule set lacks a rule, enforce it in BeforeValidate with a 422 on the field, as sm-paymentgateway-plugin's settings model does for default_currency). Currencies are compared lower-cased everywhere (Pitfall 10). + +Apply (apply.go): complete fireStateChangeEvents in PHP order: reactivated when the previous cancel_at_period_end was true, the new one is false and the new status is active or trialing (before the status check, so it can fire alone); stop when the status is unchanged; activated when the new status is active or trialing and the previous was neither (a new row counts as previous none); past_due on entering past_due; canceled on entering canceled. ApplyDeleted(ctx, tx, gateway, subscriptionID) finds the row (not found: Warn log with the id, return nil), sets status canceled and ended_at now, saves and fires canceled unconditionally. ApplyInvoice(ctx, tx, InvoiceUpdate) finds the subscription by gateway subscription id (empty id or unknown: Debug log, return nil), changes no row, and fires InvoicePaid or InvoicePaymentFailed with the typed Invoice. ClaimEvent(ctx, tx, eventID, eventType) (bool, error) runs INSERT INTO golem15_pgsub_processed_webhook_events (event_id, event_type, processed_at, created_at, updated_at) VALUES (?, ?, NOW(), NOW(), NOW()) ON CONFLICT (event_id) DO NOTHING and returns RowsAffected == 1; EventProcessed(ctx, db, eventID) (bool, error) is the cheap read. Logs carry ids and event types only, never e-mails, names or bodies. + +Service (service.go, D-13): ActivePlans, ActivePlanByCode(code), Active(alias, id) (ScopeActive, latest first), HasActiveSubscription, OnPlan(alias, id, code), CurrentPlanCode (the HasSubscriptions helper as service methods), Portal(ctx, s, returnURL) (ErrNoCustomer when s has no customer id, else the gateway URL), Cancel(ctx, sub, atPeriodEnd) (gateway by sub.Gateway, stripe when empty; after success at period end set cancel_at_period_end true in a short write without events), InGracePeriod(sub) and HasAccess(sub) with the config grace days and the service clock, HasFeature(sub, key), SyncSubscription(ctx, sub) (gateway mutates, service saves in a short transaction, no events, as PHP reconcile), SyncPlan(ctx, tx, plan) (returns map[string]string{} for price_cents 0 without calling the gateway; otherwise calls SyncPlanToGateway and saves stripe_product_id, stripe_prices and stripe_price_id on tx even when the gateway returned an error after the product step, then returns that error), Gateway(identifier) and Gateways(). A Now func field defaults to time.Now for tests. + +Config: config/config.yaml `grace_period_days: 7` with a header comment naming SUMMER_GOLEM15__PAYMENTGATEWAYSUBSCRIPTIONS__GRACE_PERIOD_DAYS; the service reads it as an int at Boot (a negative value fails boot). Lang en and pl: plugin name and description, the eight status labels under statuses.*, gateways.stripe and gateways.google_play, errors.unknown_subscriber_type and errors.gateway_not_installed (Polish copy written for this plugin, EN/PL only per P15 D-06). + +Smoke (core_smoke_test.go, Postgres) TestSubscriptionsCoreSmoke with subtests: baseline_idempotent (delete this plugin's gormigrate history rows and migrate again: no error, column set unchanged), users_column_preexisting (a database where users.stripe_customer_id already exists migrates cleanly), duplicate_alias (a second test-only plugin returning another `user` resolver fails party activation naming both plugin ids), unknown_alias (Subscriber("family", 1) returns ErrUnknownSubscriberType), event_diff (incomplete to active fires activated; active to past_due fires past_due; past_due to active fires activated; cancel flag true to false while active fires reactivated alone; active to canceled fires canceled; ApplyDeleted on an already canceled row fires canceled again), invoices (ApplyInvoice paid and failed fire one event each with the invoice id and amount; an unknown subscription fires nothing), claim (ClaimEvent twice on the same id returns true then false), grace (past_due with period end yesterday and grace 7 has access; eight days ago has none), sync_plan_free (price_cents 0 returns an empty map and the fake gateway recorded no call), cancel_at_period_end (Cancel sets the local flag and the fake recorded the call). + +README: Features, Usage (the three-call checkout sequence, registering a subscribable and a gateway, listening to the four subscription events), API reference (every exported identifier of contracts, models, events, classes), Configuration (grace_period_days), Dependencies, and the deviations (gateway never writes the database; the service writes the local cancel flag; FeatureList legacy decode). + + go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && grep -n "grace_period_days: 7" ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml + Non-zero exit: vet or the Docker-free suite fails, or the config default is missing. + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestSubscriptionsCoreSmoke|TestSubscriptionsTracer)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestSubscriptions' | grep -qx 2 + Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: the baseline was not idempotent, a duplicate alias booted, an unknown alias was skipped, an event fired in the wrong case or twice, the claim returned true twice, grace was wrong, or the tracer regressed. + + + - `grep -n "ON CONFLICT (event_id) DO NOTHING" ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go` prints one line and `grep -v '^[[:space:]]*//' ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go | grep -c 'LIKE'` prints 0 (no substring plan lookup in code; comments are excluded). + - `grep -cE "func \(s \*Service\) (ActivePlans|ActivePlanByCode|Latest|Active|Subscriber|Checkout|Portal|Cancel|HasAccess|InGracePeriod|HasFeature|SyncPlan|SyncSubscription|Apply|ApplyDeleted|ApplyInvoice|ClaimEvent|EventProcessed)\(" ../sm-paymentgatewaysubscriptions-plugin/classes/*.go` prints at least 18. + - `grep -rn "FEATURE_\|UnlimitedQuotes" ../sm-paymentgatewaysubscriptions-plugin --include=*.go` prints nothing. + - `grep -n "## API reference" ../sm-paymentgatewaysubscriptions-plugin/README.md` prints one line. + + The gateway-neutral core behaves like PHP for every state change, event, check and service call a consuming application needs, and is documented. + + + + Task 4: Create the remote with tea and push main, or hold + .planning/phases/18-payment-gateway/18-06-SUMMARY.md (how Phase 18 created its repositories with tea and pushed after the reply), this plan's Task 2 and 3 SUMMARY notes + Do everything automatable, print the sheet, then stop. Confirm `git -C ../sm-paymentgatewaysubscriptions-plugin status --porcelain` is empty and the branch is main; check whether the remote exists with `git ls-remote git@git.golem15.com:golem15/sm-paymentgatewaysubscriptions-plugin.git` (a non-empty main on an existing remote means someone pushed first: stop and report, never force-push). Print: the local main head and its commit count, whether the remote exists, the tea command that would create it (`tea repos create --owner golem15 --name sm-paymentgatewaysubscriptions-plugin`, checking `tea repos create --help` for the exact flags first), and that plan 03 needs only the committed local repository (replace), so "hold" blocks nothing. Run no tea create, no remote add and no push before the reply. After "create-and-push": create the repository with tea, `git remote add origin git@git.golem15.com:golem15/sm-paymentgatewaysubscriptions-plugin.git`, `git push -u origin main`. After "create-only": create it and add the remote, push nothing. After "hold": nothing. Record the reply verbatim and any pushed hash in the SUMMARY. + Create git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin with tea and push main now? + D-22 decided the repository is created with tea and that nothing is pushed without the user's go-ahead. A pushed main is visible to every developer; the plugin is not mounted by any application yet, so a later force-free push loses nothing. + + + + + + Reply "create-and-push", "create-only" or "hold". + + test -z "$(git -C ../sm-paymentgatewaysubscriptions-plugin status --porcelain)" && test "$(git -C ../sm-paymentgatewaysubscriptions-plugin rev-parse --abbrev-ref HEAD)" = main && git -C ../sm-paymentgatewaysubscriptions-plugin log --oneline -1 >/dev/null && echo push-sheet-ok + Non-zero exit or "push-sheet-ok" absent: uncommitted files remain, the branch is not main, or the repository has no commit. + + + - The SUMMARY records the reply verbatim; after "create-and-push" `git -C ../sm-paymentgatewaysubscriptions-plugin rev-parse main` equals `git -C ../sm-paymentgatewaysubscriptions-plugin rev-parse origin/main`; after "hold" `git -C ../sm-paymentgatewaysubscriptions-plugin remote` prints nothing. + + The human decided whether the new repository exists remotely and whether main was pushed, and the record says what happened. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| gateway job to Service.Apply | gateway-supplied data (from signed events in plan 03) becomes rows and events | +| application code to Service | consuming plugins call checkout, cancel and portal with subscriber ids they resolved | +| pgsub to the shared users table | one column of a core plugin's table is read and written | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-18.1-05 | Tampering | Apply with an unknown subscriber_type (RESEARCH T-10) | high | mitigate | Subscriber/Apply resolve aliases only through the build-time registry; an unknown alias returns ErrUnknownSubscriberType and is never skipped (D-08); TestSubscriptionsCoreSmoke unknown_alias | +| T-18.1-06 | Repudiation / Tampering | duplicate or concurrent deliveries double-firing listeners (RESEARCH T-3) | high | mitigate | ClaimEvent uses the unique event_id index with ON CONFLICT DO NOTHING inside the caller's transaction; events fire inside that transaction; listener side effects belong in lagoon.AfterCommit (package doc); smoke claim subtest; plan 03 proves the concurrent case | +| T-18.1-07 | Information Disclosure | logs of Apply, ApplyInvoice, Checkout | medium | mitigate | logs carry subscription, customer and event ids only; contracts.Invoice holds no e-mail or address; no body is logged | +| T-18.1-08 | Tampering | mass assignment on Subscription and SubscriptionPlan | medium | mitigate | Fillable lists copied from the PHP $fillable; Rules ported with BeforeValidate fallbacks; the admin forms of plan 04 write only declared fields | +| T-18.1-09 | Tampering | users.stripe_customer_id writes (D-09) | medium | mitigate | UserSubscribable writes with a targeted UPDATE of that one column, never a full user Save; the migration is IF NOT EXISTS with a no-op rollback so an existing column and its data survive | +| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new module: go.mod requires only modules the Phase 18 plugins already pin (audited in 18-RESEARCH § Package Legitimacy Audit); RESEARCH lists no new package | + + + +- Task 1: the baseline sheet and the human's reply before any migration exists. +- Task 2: vet, the Docker-free suite, the no-stripe-go dependency check and the branch; the tracer on Postgres. +- Task 3: vet, the Docker-free suite, the config default; the core smoke and the tracer on Postgres. +- Task 4: clean tree and branch before the human decides on the remote and the push. +- Feedback sampling: after each commit `go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -short ./...`. + + + +- SC-1: the v2.4.0 baseline (as amended by D-16), polymorphic subscribers registered at build time and the built-in user subscribable exist and are proven on Postgres. +- The gateway-neutral half of SC-2 (contract with SyncPlanToGateway, event diff, claim) and the service API of SC-3 exist for plans 03 and 04. +- The repository is committed on main; the human decided the remote and the push (D-22). + + + +Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md` when done, with the repository head, the commits, the exported identifiers, the Task 1 and Task 4 replies, the Docker tests run or NOT RUN, and any deviation. + diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-03-PLAN.md b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-03-PLAN.md new file mode 100644 index 0000000..fad7ef4 --- /dev/null +++ b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-03-PLAN.md @@ -0,0 +1,337 @@ +--- +phase: 18.1-payment-gateway-subscriptions +plan: 03 +type: execute +wave: 2 +depends_on: ["18.1-02"] +coupling_justified: ["18.1-04: this plan compiles ../sm-paymentgatewaysubscriptions-plugin through replace and uses only the contracts, models, events and classes plan 02 delivered; plan 04 adds admin, routes and the customer API to that repository without changing those symbols; a red build seen while a plan 04 task is in flight is transient (re-run after its commit); both orders produce the same result"] +files_modified: + - ../sm-pgstripe-plugin/go.mod + - ../sm-pgstripe-plugin/go.sum + - ../sm-pgstripe-plugin/go.work + - ../sm-pgstripe-plugin/updates/202610150001_add_subscription_webhook_secrets.go + - ../sm-pgstripe-plugin/models/settings.go + - ../sm-pgstripe-plugin/models/settings/fields.yaml + - ../sm-pgstripe-plugin/client.go + - ../sm-pgstripe-plugin/config/config.yaml + - ../sm-pgstripe-plugin/console/import_winter.go + - ../sm-pgstripe-plugin/lang/en/lang.yaml + - ../sm-pgstripe-plugin/lang/pl/lang.yaml + - ../sm-pgstripe-plugin/settings_smoke_test.go + - ../sm-pgstripe-plugin/README.md + - ../sm-pgstripe-plugin/subscriptions/plugin.go + - ../sm-pgstripe-plugin/subscriptions/config/config.yaml + - ../sm-pgstripe-plugin/subscriptions/gateway.go + - ../sm-pgstripe-plugin/subscriptions/sync_plan.go + - ../sm-pgstripe-plugin/subscriptions/webhook.go + - ../sm-pgstripe-plugin/subscriptions/decode.go + - ../sm-pgstripe-plugin/subscriptions/job.go + - ../sm-pgstripe-plugin/subscriptions/README.md + - ../sm-pgstripe-plugin/subscriptions/testmain_test.go + - ../sm-pgstripe-plugin/subscriptions/stripefake_test.go + - ../sm-pgstripe-plugin/subscriptions/eventbody_test.go + - ../sm-pgstripe-plugin/subscriptions/fixture_plugin_test.go + - ../sm-pgstripe-plugin/subscriptions/host_test.go + - ../sm-pgstripe-plugin/subscriptions/webhook_smoke_test.go +autonomous: false +requirements: [SC-2, SC-4, D-01, D-07, D-10, D-11, D-12, D-15, D-18, D-19, D-20, D-21, D-22] +estimate: + tokens: 260000 + raw_tokens: 260000 + tasks: 4 + confidence: low +must_haves: + truths: + - "D-20 and D-07: package subscriptions at import path git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions is its own party plugin golem15.pgstripesubscriptions (Requires golem15.pgstripe and golem15.paymentgatewaysubscriptions) registered in init(); the root package golem15.pgstripe does not import it, so an application that lists only golem15.pgstripe in summer.yaml links no pgsub package (`go list -deps .` of the root names no sm-paymentgatewaysubscriptions-plugin package)." + - "D-15 and SC-2: the Stripe gateway implements every contracts.Gateway method, porting classes/StripeSubscriptionGateway.php at d00f850 (all other pgstripe files at 8b47457): customer create with subscriber metadata, a subscription-mode Checkout Session whose success URL appends session_id={CHECKOUT_SESSION_ID} with ? or & as the URL needs, the billing portal session, cancel at period end (update cancel_at_period_end) or immediately (cancel), sync from the retrieved subscription (items first, top level second), SyncPlanToGateway (free plan skipped; product created or updated with plan_code and tier_level metadata; prices for usd, eur and pln with lookup key _, an unchanged active price reused, a changed one replaced with transfer_lookup_key and the old one archived; stripe_prices and stripe_price_id (the usd price) set on the plan) and SupportsCurrency over the PHP list." + - "D-10: POST /webhook/stripe/subscriptions and POST /api/subscriptions/webhook reach one handler, carry no CSRF and share one 60-per-minute-per-client-IP bucket (PHP throttle:60,1)." + - "D-11 and D-12: the handler resolves the mode's subscription signing secret first (missing: 500 `Webhook secret not configured` with an Error log), verifies Stripe-Signature on the raw body capped at 1 MiB (bad or missing signature: 400 `Invalid signature`; undecodable body: 400 `Invalid payload`; Warn logs with the event id at most), answers 200 `Already processed` when the processed-event marker exists, enqueues one conga job only for customer.subscription.created, .updated, .deleted, invoice.paid and invoice.payment_failed, and answers 200 `Webhook received`; it never writes the marker." + - "D-11 (STRIPE-006/007): the job runs one transaction that claims the marker first (classes.Service.ClaimEvent, ON CONFLICT DO NOTHING); an already-claimed event commits nothing new; an app-tag skip or an unhandled type keeps the marker; any error rolls back the marker and returns the error so River retries; job args hold only ids, status, flags, timestamps, price id, amounts and the three metadata keys subscriber_type, subscriber_id and app, never a raw Stripe object, e-mail or address." + - "D-21: the job maps Stripe objects with PHP's rules (cancel_at_period_end mapping kept, out-of-order events applied as they arrive) and reads both payload shapes: subscription periods from items[0] with the top-level fields as fallback (pre-basil), invoice subscription id from parent.subscription_details.subscription with the top-level subscription as fallback; whether a portal cancellation also needs cancel_at is a manual Stripe test-mode check recorded for /msd-verify-work." + - "D-19: config key golem15.pgstripesubscriptions.metadata_app (default empty) makes Checkout write subscription_data.metadata.app and the job skip (marker kept) events whose object metadata app is set and differs; empty writes nothing and filters nothing; billing_owner_id is not written by the gateway and CheckoutOptions.Metadata is merged into the subscription metadata." + - "D-18 and D-12 (additive root changes): golem15_pgstripe_settings gains subscription_webhook_secret and test_subscription_webhook_secret (lagoon.Encrypted, json:\"-\", write-only password fields on the live and test tabs) through migration 202610150001_add_subscription_webhook_secrets (ADD COLUMN IF NOT EXISTS), SecretFallbacks to golem15.pgstripe.subscription_webhook_secret and golem15.pgstripe.test_subscription_webhook_secret, a mode-aware pgstripe.SubscriptionWebhookSecret reader; ModeKeys, the payment webhook and every Phase 18 payload are unchanged; pgstripe:import-winter gains --webhook-secret-target payments (default, Phase 18 behaviour), subscriptions or both." + - "SC-4: TestHostSubscriptionFlow proves against the Stripe fake on Postgres: checkout then customer.subscription.created (active) fires activated; invoice.paid in both payload shapes fires invoice.paid; invoice.payment_failed fires invoice.payment_failed and customer.subscription.updated (past_due) fires past_due with access inside the grace period and none after it; cancel at period end then customer.subscription.deleted fires canceled; cancel_at_period_end true to false fires reactivated; the same signed event twice yields one job and one event; SyncPlanToGateway stores product and price ids; one event runs through a real conga worker." + artifacts: + - path: "../sm-pgstripe-plugin/subscriptions/plugin.go" + provides: "golem15.pgstripesubscriptions: Requires, routes, bucket, job, gateway publication, config" + contains: "golem15.pgstripesubscriptions" + - path: "../sm-pgstripe-plugin/subscriptions/webhook.go" + provides: "the shared webhook handler with PHP's responses" + contains: "Webhook secret not configured" + - path: "../sm-pgstripe-plugin/subscriptions/job.go" + provides: "claim-first River job routing the five event types into pgsub Service" + contains: "ClaimEvent" + - path: "../sm-pgstripe-plugin/subscriptions/sync_plan.go" + provides: "SyncPlanToGateway port of d00f850" + contains: "transfer_lookup_key" + - path: "../sm-pgstripe-plugin/updates/202610150001_add_subscription_webhook_secrets.go" + provides: "two encrypted secret columns, additive" + contains: "ADD COLUMN IF NOT EXISTS" + - path: "../sm-pgstripe-plugin/subscriptions/host_test.go" + provides: "SC-4 proof host" + contains: "TestHostSubscriptionFlow" + key_links: + - from: "../sm-pgstripe-plugin/subscriptions/webhook.go" + to: "../sm-pgstripe-plugin/client.go" + via: "SubscriptionWebhookSecret then VerifySignature" + pattern: "SubscriptionWebhookSecret" + - from: "../sm-pgstripe-plugin/subscriptions/webhook.go" + to: "../sm-pgstripe-plugin/subscriptions/job.go" + via: "conga Manager.Enqueue of WebhookJobArgs" + pattern: "Enqueue" + - from: "../sm-pgstripe-plugin/subscriptions/job.go" + to: "../sm-paymentgatewaysubscriptions-plugin/classes/apply.go" + via: "Service.ClaimEvent, Apply, ApplyDeleted, ApplyInvoice in one transaction" + pattern: "Apply" + - from: "../sm-pgstripe-plugin/subscriptions/plugin.go" + to: "../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go" + via: "SubscriptionGateways() returns the Stripe gateway for pgsub's Boot to collect" + pattern: "SubscriptionGateways" + prohibitions: + - statement: "Root pgstripe changes are additive only: no Phase 18 column, config key, route, payload, ModeKeys signature or import default changes (core plugin rule)." + status: unresolved + verification: judgment + - statement: "No test calls the network; every Stripe call goes to the httptest fake through pgstripe.BackendOverride; Phase 18's root stripefake_test.go is unchanged." + status: unresolved + verification: judgment + - statement: "No code, comment, README or config default of this plan names a consuming application." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP. + +This plan's slice (plan 3 of 6, wave 2, per D-22): the Stripe half. Additive root changes to sm-pgstripe-plugin (D-18), and the new `golem15.pgstripesubscriptions` plugin in its `subscriptions/` package (D-20): the gateway with the plan sync (D-15), both webhook URLs (D-10), the claim-first River job (D-11), the app-tag config (D-19) and the SC-4 proof host against a Stripe fake. + +## Spec-less probe fallback + +Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01). + +## Decisions taken in this plan (Claude's discretion) + +- Price lookup uses the Prices List call with `lookup_keys` and `active=true` instead of PHP's Prices Search (same outcome; List is not eventually consistent, so a quick re-sync cannot miss a just-created price and create a duplicate; RESEARCH Alternatives, A2). Recorded as a deviation in subscriptions/README.md. +- The usd/eur/pln loop of SyncPlanToGateway ports faithfully, including prices for currencies a plan never set (RESEARCH Pitfall 7, A3); `stripe_price_id` is the usd price as in PHP. +- The webhook bodies are PHP's plain-text strings written without a trailing newline (`text/plain; charset=utf-8`), subject to the Task 1 confirmation (auto_select php-plain-text); a body over 1 MiB is answered like an undecodable body (400 `Invalid payload`). +- The job keeps conga's default attempt count (three, the WinterCMS `--tries=3` parity conga documents); the README says the application must run a worker (serve with `queue.work_in_serve`, or `queue:work`). +- No subscription secret falls back to the payment-notification secret: Stripe issues one secret per endpoint (D-12); the README states that on PHP one secret served both routes and how the import flag places it. +- The subscriptions plugin does not check the payment operator switch (`enabled`), as PHP's gateway did not. + +## Artifacts this phase produces (this plan) + +- Root sm-pgstripe-plugin (additive): migration `202610150001_add_subscription_webhook_secrets`; `models.Settings.SubscriptionWebhookSecret`, `models.Settings.TestSubscriptionWebhookSecret`; constants `models.ConfigSubscriptionWebhookSecret` (`golem15.pgstripe.subscription_webhook_secret`), `models.ConfigTestSubscriptionWebhookSecret` (`golem15.pgstripe.test_subscription_webhook_secret`); `pgstripe.SubscriptionWebhookSecret(app, settings) (secret, mode string)`; settings form fields `subscription_webhook_secret`, `test_subscription_webhook_secret`; import flag `--webhook-secret-target` (`payments`|`subscriptions`|`both`) and `WinterOptions.WebhookSecretTarget`; lang keys `settings.subscriptionWebhookSecret`, `settings.subscription_webhook_comment`; go.mod require of the pgsub module with replace. +- Package `subscriptions` (plugin `golem15.pgstripesubscriptions`): `PluginID`, `WebhookPath = "/webhook/stripe/subscriptions"`, `LegacyWebhookPath = "/api/subscriptions/webhook"`, `WebhookBucket = "pgstripesubscriptions-webhook"`, `ConfigMetadataApp = "golem15.pgstripesubscriptions.metadata_app"`, `Gateway` (implements `contracts.Gateway`), `NewGateway(app)`, `WebhookJobArgs` (Kind `golem15_pgstripesubscriptions_webhook`), `HandledEventTypes`, the event type constants `EventSubscriptionCreated`, `EventSubscriptionUpdated`, `EventSubscriptionDeleted`, `EventInvoicePaid`, `EventInvoicePaymentFailed`, `ErrNoPriceForCurrency`; config `metadata_app` (env `SUMMER_GOLEM15__PGSTRIPESUBSCRIPTIONS__METADATA_APP`). +- Tests: `TestHostSubscriptionFlow`, `TestSubscriptionWebhookSmoke`, the package-local Stripe fake and event-body builders for both API shapes; `TestStripeImportWinterSmoke` and `TestStripeSettingsSmoke` extended for the new columns. +- READMEs: `subscriptions/README.md` (new) and the root README Subscriptions, Configuration and Cutover sections. + + +Port pgstripe's subscription parts onto the pgsub contracts: the encrypted subscription signing secrets in the Stripe settings, the `golem15.pgstripesubscriptions` plugin with the Stripe gateway (incl. the d00f850 plan sync), both webhook URLs on one handler, the claim-first job, the app-tag config, and the host proof of the whole subscription lifecycle. + +Purpose: SC-2 and SC-4 (D-07, D-10 to D-12, D-15, D-18 to D-21): the Stripe implementation and the webhook path a consuming application's cutover points its Stripe dashboard at. +Output: commits in sm-pgstripe-plugin (root additive plus the new package) and a green proof host. + +Execution budget: over the single-context smart zone by design (D-22 split). Commit per task, write SUMMARY notes as you go, resume at the first uncommitted task after a reset. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-PATTERNS.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md +@.planning/phases/18-payment-gateway/18-05-SUMMARY.md + +Repositories: commit only in `../sm-pgstripe-plugin` (branch main, head 0313820 at planning time; Phase 18's fake, host test and webhook stay unchanged). Read `../sm-paymentgatewaysubscriptions-plugin` (plan 02) through `replace`. PHP sources are frozen (D-01, D-15): `classes/StripeSubscriptionGateway.php` is read at d00f850, every other pgstripe file at 8b47457 (`git -c safe.directory=* -C /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe show :`; the working tree is d54dcde and differs from 8b47457 only in that one file). Plan 18.1-04 edits the pgsub repository in the same wave; this plan only compiles it. + + +From plan 02 (pgsub), used here; read the plan 02 SUMMARY for the final names: + +- `contracts.Gateway` (Identifier, GetOrCreateCustomer, CreateCheckoutSession, CreateManagementSession, CancelSubscription, SyncSubscription, SyncPlanToGateway, SupportsCurrency), `contracts.CheckoutOptions{Gateway, Currency, Metadata}`, `contracts.CheckoutSession{URL, SessionID}`, `contracts.Update`, `contracts.InvoiceUpdate`, `contracts.Invoice`, `contracts.Subscriber`, `contracts.HasSubscriptionGateways`, `contracts.HasSubscribables`. +- `classes.Service`: `ClaimEvent(ctx, tx, eventID, eventType) (bool, error)`, `EventProcessed(ctx, db, eventID) (bool, error)`, `Apply(ctx, tx, contracts.Update)`, `ApplyDeleted(ctx, tx, gateway, subscriptionID)`, `ApplyInvoice(ctx, tx, contracts.InvoiceUpdate)`, `Checkout`, `Cancel`, `Portal`, `SyncPlan`, `Latest`, `HasAccess`, `Now`; `classes.UserSubscribable()`; `models.SubscriptionPlan.PriceCentsFor`, `StripePriceIDFor`, `TierLevel`; `events.*`. + +From Phase 18 (sm-pgstripe-plugin root): `pgstripe.ClientFactory{App}.New(secret)` (client.go :104-120) honouring a published `*pgstripe.BackendOverride` (:40), `pgstripe.ModeKeys` (:51), `fallback` (:68), `pgstripe.VerifySignature(payload, header, secret)` (webhook.go :39), `pgstripe.ExpandableID` (:87), `models.LoadSettings` (settings.go :130), `models.Settings` (:43), `SecretFallbacks` (:79), `updates.Register` (registry.go :16), `console.ImportWinter` flags (import_winter.go :97-110) and `secretColumns` (:49). +Framework: `conga.Job`, `conga.Manager.Enqueue(ctx, db, args, conga.EnqueueOpts{})` (conga.go :218), `conga.StartWorker` (worker.go :63), `pact.HasJobs`, `pact.HasRoutes`, `surf.BucketProvider`, `surf.ClientIP`, `surf.TrustedProxies`, `wire.WriteOpaque500`. + + + + + + + Task 1: Confirm the one-way webhook contract Stripe dashboards will call: both URLs, the status codes and bodies, the job kind and the secret columns (D-10, D-11, D-18) + PHP frozen at 8b47457: http/routes.php and http/controllers/StripeWebhookController.php of /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe (`git -c safe.directory=* -C ... show 8b47457:`), the pgsub legacy alias in /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/http/routes.php, .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md (D-10, D-11, D-12, D-18, D-20), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Pattern 3, Pitfall 6) + Print the webhook contract sheet, then stop. The sheet lists: POST /webhook/stripe/subscriptions and POST /api/subscriptions/webhook on one handler, no CSRF, one 60-per-minute-per-IP bucket; the answers in order (500 Webhook secret not configured; 400 Invalid signature; 400 Invalid payload; 200 Already processed; 200 Webhook received) and that only the five handled types enqueue; the job kind golem15_pgstripesubscriptions_webhook; the plugin ID golem15.pgstripesubscriptions with import path git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions; migration 202610150001_add_subscription_webhook_secrets with columns subscription_webhook_secret and test_subscription_webhook_secret and config keys golem15.pgstripe.subscription_webhook_secret, golem15.pgstripe.test_subscription_webhook_secret and golem15.pgstripesubscriptions.metadata_app. The decisions D-10, D-18 and D-20 are taken; this gate confirms the concrete artifacts and the one open shape choice below. + Do the webhook answers keep PHP's plain-text bodies? + Stripe only reads the status code, but monitoring, the Stripe dashboard's delivery log and any proxy rule see the body; the URLs are configured in every application's Stripe dashboard (D-10 one-way), so the whole contract is confirmed once before Task 2 writes it. + + + + + Reply "php-plain-text" or "json-envelope"; record the reply verbatim in the SUMMARY; Task 2 and Task 3 write the bodies accordingly. + + git -c safe.directory='*' -C /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe show 8b47457:http/controllers/StripeWebhookController.php | grep -c "response('" | grep -qx 5 && echo webhook-sheet-ok + Non-zero exit or "webhook-sheet-ok" absent: the frozen PHP controller is unreachable or no longer has its five literal responses, so the sheet cannot be printed from the source. + + + - The SUMMARY records the sheet and the human's reply verbatim before Task 2's first commit. + + The webhook contract is confirmed before any route exists. + + + + Task 2: Tracer: a checkout through the Stripe fake, a signed customer.subscription.created on the canonical URL, the claim-first job and the `activated` event, end to end on the proof host + D-10: both webhook URLs and their answers are configured in every application's Stripe dashboard; confirmed at Task 1 together with D-20's plugin ID and import path and D-18's migration. + `git -C ../sm-paymentgatewaysubscriptions-plugin status --porcelain` prints nothing and `git -C ../sm-paymentgatewaysubscriptions-plugin log -1` succeeds on branch main (plan 02 committed, D-22). + ../sm-pgstripe-plugin/go.mod, ../sm-pgstripe-plugin/go.sum, ../sm-pgstripe-plugin/go.work, ../sm-pgstripe-plugin/updates/202610150001_add_subscription_webhook_secrets.go, ../sm-pgstripe-plugin/models/settings.go, ../sm-pgstripe-plugin/client.go, ../sm-pgstripe-plugin/config/config.yaml, ../sm-pgstripe-plugin/subscriptions/plugin.go, ../sm-pgstripe-plugin/subscriptions/config/config.yaml, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/webhook.go, ../sm-pgstripe-plugin/subscriptions/decode.go, ../sm-pgstripe-plugin/subscriptions/job.go, ../sm-pgstripe-plugin/subscriptions/testmain_test.go, ../sm-pgstripe-plugin/subscriptions/stripefake_test.go, ../sm-pgstripe-plugin/subscriptions/eventbody_test.go, ../sm-pgstripe-plugin/subscriptions/fixture_plugin_test.go, ../sm-pgstripe-plugin/subscriptions/host_test.go + ../sm-pgstripe-plugin/plugin.go, ../sm-pgstripe-plugin/client.go, ../sm-pgstripe-plugin/webhook.go, ../sm-pgstripe-plugin/models/settings.go, ../sm-pgstripe-plugin/updates/registry.go, ../sm-pgstripe-plugin/updates/202610130001_create_golem15_pgstripe_settings.go, ../sm-pgstripe-plugin/config/config.yaml, ../sm-pgstripe-plugin/go.mod, ../sm-pgstripe-plugin/go.work, ../sm-pgstripe-plugin/testmain_test.go, ../sm-pgstripe-plugin/host_test.go (bootHost :82-160, signing :299-301, notify :305-319), ../sm-pgstripe-plugin/stripefake_test.go (fake mux and recorded requests), ../sm-pgstripe-plugin/fixture_plugin_test.go, ../sm-paymentgateway-plugin/routes.go (:34-53 throttle and nil-service guard) and plugin.go (:105-131 Buckets), ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_jobs_test.go (a plugin job run through a real conga worker in a test), modules/conga/README.md, modules/conga/conga.go (:129 EnqueueOpts, :218 Enqueue), modules/pact/capabilities.go (:90-103 JobArgs, Job, HasJobs), PHP frozen: pgstripe http/routes.php, http/controllers/StripeWebhookController.php, jobs/ProcessStripeSubscriptionWebhook.php and classes/StripeSettings.php at 8b47457, classes/StripeSubscriptionGateway.php at d00f850 (getOrCreateCustomer :89-130, createCheckoutSession :133-205), pgsub http/routes.php (the legacy alias), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Patterns 3, 4, 6; Code Examples; Pitfalls 2, 3, 6, 9, 11, 12) + Per D-07, D-10, D-11, D-12, D-18, D-19 and D-20, wire the thinnest real Stripe path. + +Root additive (D-18): migration 202610150001_add_subscription_webhook_secrets registered through updates.Register runs `ALTER TABLE golem15_pgstripe_settings ADD COLUMN IF NOT EXISTS subscription_webhook_secret TEXT NULL` and the same for test_subscription_webhook_secret; rollback drops both IF EXISTS. models.Settings gains SubscriptionWebhookSecret and TestSubscriptionWebhookSecret (lagoon.Encrypted, gorm column and type text, json "-"), both appended to Fillable and to SecretFallbacks with the new constants ConfigSubscriptionWebhookSecret and ConfigTestSubscriptionWebhookSecret. client.go gains SubscriptionWebhookSecret(app, s) (secret, mode string): test_mode picks the test column, else the live one, each through fallback() to its config key; ModeKeys is not changed. config/config.yaml gains `subscription_webhook_secret: ""` and `test_subscription_webhook_secret: ""` with header lines naming SUMMER_GOLEM15__PGSTRIPE__SUBSCRIPTION_WEBHOOK_SECRET and SUMMER_GOLEM15__PGSTRIPE__TEST_SUBSCRIPTION_WEBHOOK_SECRET. go.mod requires git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin v0.0.0 with `replace ... => ../sm-paymentgatewaysubscriptions-plugin` in go.mod and go.work; go mod tidy. + +Plugin (subscriptions/plugin.go, D-20): package subscriptions, PluginID golem15.pgstripesubscriptions (matches the summer build ID pattern), Requires golem15.pgstripe and golem15.paymentgatewaysubscriptions, init() party.Register; Register stores the app; implements pact.HasConfig (config/config.yaml with `metadata_app: ""` and a header naming SUMMER_GOLEM15__PGSTRIPESUBSCRIPTIONS__METADATA_APP), pact.HasRoutes, pact.HasJobs, surf.BucketProvider and contracts.HasSubscriptionGateways (SubscriptionGateways returns NewGateway(app)). Routes registers WebhookPath with `throttle:` WebhookBucket (60 per minute, key WebhookBucket plus surf.ClientIP with the app's trusted proxies); this task registers the canonical path only (Task 3 adds the legacy alias). Response bodies follow the Task 1 reply. + +Gateway (gateway.go, this task: Identifier "stripe", GetOrCreateCustomer, CreateCheckoutSession, SupportsCurrency; the rest return a not-implemented error until Task 3): the client comes from pgstripe.ClientFactory{App}.New with the ModeKeys secret of models.LoadSettings. GetOrCreateCustomer returns s.CustomerID() when set; else an empty e-mail is an error, else creates the customer with email, name and metadata subscriber_type and subscriber_id (PHP :89-130) and returns its id (pgsub persists it). CreateCheckoutSession ports d00f850 :133-205: mode subscription, customer, one line item with plan.StripePriceIDFor(currency) (currency from options, default usd; no price is ErrNoPriceForCurrency), success URL plus `?` or `&` plus `session_id={CHECKOUT_SESSION_ID}`, cancel URL, subscription_data.metadata with subscriber_type, subscriber_id, plan_code, `app` only when metadata_app is non-empty (D-19), merged with options.Metadata; the per-application owner key PHP wrote is not written; returns URL and session id. + +Webhook (webhook.go, decode.go): one handler per RESEARCH Pattern 3: read the body through http.MaxBytesReader 1 MiB (error: 400 Invalid payload); load settings (error: wire.WriteOpaque500); resolve SubscriptionWebhookSecret first (empty: Error log "golem15.pgstripesubscriptions: webhook secret not configured", 500 `Webhook secret not configured`); pgstripe.VerifySignature (error: Warn log, 400 `Invalid signature`); decode a minimal event {id, type, data.object} (error: Warn log, 400 `Invalid payload`); Service.EventProcessed true: 200 `Already processed`; type in HandledEventTypes: build WebhookJobArgs and conga Manager.Enqueue (error: opaque 500); 200 `Webhook received`. Bodies are written as plain text with no trailing newline. decode.go holds the local structs of RESEARCH Pattern 6 (subscription with items periods and top-level periods, invoice with parent.subscription_details and top-level subscription, pgstripe.ExpandableID for customer and subscription references) and the conversion to WebhookJobArgs carrying only: event id and type; for subscriptions id, status, customer, items[0] price id, cancel_at_period_end, canceled_at, ended_at, trial_end, the periods (items first, top level second) and metadata subscriber_type, subscriber_id, app; for invoices id, subscription id (parent first), customer, currency, amount_paid, attempt_count, billing_reason and metadata app. + +Job (job.go): WebhookJobArgs.Kind is golem15_pgstripesubscriptions_webhook; Jobs() returns conga.Job over Work: in one lagoon transaction, ClaimEvent (not claimed: return nil); when metadata_app is non-empty and args app is non-empty and differs: Debug log with the event id, return nil (marker kept); route created and updated to Service.Apply with a contracts.Update (gateway stripe, subscriber id parsed from the metadata string, an unparsable id treated as absent), deleted to ApplyDeleted, invoice.paid and invoice.payment_failed to ApplyInvoice; an unknown type returns nil; any error is returned so the transaction rolls back and River retries. This task needs created; Task 3 completes the rest. + +Tests: subscriptions/testmain_test.go copies the root TestMain; stripefake_test.go is a package-local httptest fake (Phase 18's root fake stays unchanged; D-20) serving POST /v1/customers and POST /v1/checkout/sessions in this task and recording requests (Task 3 adds the rest); eventbody_test.go builds subscription and invoice event bodies in the basil and pre-basil shapes and signs them with webhook.GenerateTestSignedPayload; fixture_plugin_test.go declares test plugin acme.members returning classes.UserSubscribable() and recording the six pgsub events; host_test.go bootSubscriptionsHost publishes the BackendOverride before activating golem15.user, golem15.paymentgateway, golem15.pgstripe, golem15.paymentgatewaysubscriptions, golem15.pgstripesubscriptions and acme.members, migrates, sets test mode with test keys and the test subscription signing secret, and assembles the router. TestHostSubscriptionFlow/checkout_activation: a user checks out a plan through Service.Checkout (fake records the customer create and the session with subscription_data.metadata subscriber_type user, and the success URL carries `session_id={CHECKOUT_SESSION_ID}`); a signed customer.subscription.created (status active, the plan's price, the user's metadata) POSTed to /webhook/stripe/subscriptions answers 200 `Webhook received` with one enqueued job; working the job (call the registered job's Work with the enqueued args) stores the subscription active and records exactly one SubscriptionActivated. + + go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && ROOTDEPS=$(go -C ../sm-pgstripe-plugin list -deps .) && ! printf '%s\n' "$ROOTDEPS" | grep -q 'sm-paymentgatewaysubscriptions-plugin' && SUBDEPS=$(go -C ../sm-pgstripe-plugin list -deps ./subscriptions) && printf '%s\n' "$SUBDEPS" | grep -q 'sm-paymentgatewaysubscriptions-plugin/classes' + Non-zero exit: vet or the Docker-free suites fail, the root package now links pgsub (D-07 broken), or the subscriptions package does not import pgsub classes. + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostSubscriptionFlow$/^checkout_activation$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT" | grep -q -- '--- PASS: TestHostSubscriptionFlow/checkout_activation' && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestHostStripeFlow|TestStripeSettingsSmoke)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 2 + Non-zero exit: no Docker daemon (record NOT RUN), the checkout_activation subtest failed (no customer, wrong metadata, no session_id placeholder, the webhook did not answer 200 with one job, no row or not exactly one activated event), or Phase 18's host flow or settings smoke regressed. + + + - `grep -n 'PluginID = "golem15.pgstripesubscriptions"' ../sm-pgstripe-plugin/subscriptions/plugin.go` prints one line and `grep -n '"/webhook/stripe/subscriptions"' ../sm-pgstripe-plugin/subscriptions/plugin.go ../sm-pgstripe-plugin/subscriptions/webhook.go` prints at least one line. + - `grep -c "ADD COLUMN IF NOT EXISTS" ../sm-pgstripe-plugin/updates/202610150001_add_subscription_webhook_secrets.go` prints 2. + - `git -C ../sm-pgstripe-plugin diff --quiet 0313820 -- stripefake_test.go host_test.go webhook.go operator.go` exits 0 (Phase 18 files untouched). + - `grep -n "func ModeKeys(_ context.Context, app \*backpack.App, s \*models.Settings) (secret, publishable, webhookSecret string, mode string)" ../sm-pgstripe-plugin/client.go` prints one line (signature unchanged). + - `grep -rniE "horoskopia|queststream|quotify" ../sm-pgstripe-plugin/subscriptions` prints nothing. + + A subscription checked out through Stripe becomes active from a signed webhook processed by a job, with the activated event fired once, without touching Phase 18 behaviour. + + + + Task 3: The complete gateway (portal, cancel, sync, plan sync from d00f850), the legacy alias, all five event types in both payload shapes, the app tag, refusals, and the full proof host incl. a real worker run + D-15/D-11: the plan-sync lookup keys and the job kind are stored outside the code (Stripe price lookup keys, river_job rows); renaming them strands existing prices or in-flight jobs. + ../sm-pgstripe-plugin/subscriptions/plugin.go, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/sync_plan.go, ../sm-pgstripe-plugin/subscriptions/webhook.go, ../sm-pgstripe-plugin/subscriptions/decode.go, ../sm-pgstripe-plugin/subscriptions/job.go, ../sm-pgstripe-plugin/subscriptions/stripefake_test.go, ../sm-pgstripe-plugin/subscriptions/eventbody_test.go, ../sm-pgstripe-plugin/subscriptions/host_test.go, ../sm-pgstripe-plugin/subscriptions/webhook_smoke_test.go + ../sm-pgstripe-plugin/subscriptions/gateway.go, webhook.go, decode.go, job.go, host_test.go, stripefake_test.go (Task 2), PHP frozen: classes/StripeSubscriptionGateway.php at d00f850 (createManagementSession :210-240, cancelSubscription :243-275, syncSubscription :280-330, supportsCurrency :334-340, createOrUpdateStripeProduct :387-435, createOrUpdateStripePrice :438-515, syncPlanToGateway :517-555), jobs/ProcessStripeSubscriptionWebhook.php at 8b47457 (whole file: the app filter, handleSubscriptionCreatedOrUpdated, handleSubscriptionDeleted, handleInvoicePaid, handleInvoicePaymentFailed), pgstripe tests/reliability/ReliabilityTest.php and tests/security/AuthenticationTest.php at 8b47457 (the cases the smoke mirrors), ~/go/pkg/mod/github.com/stripe/stripe-go/v87@v87.0.0/price.go (PriceListParams.LookupKeys, TransferLookupKey) and product.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go (SyncPlan persistence contract, Cancel, Now), ../fonoteka.go/plugins/golem15/fonoteka/csv_jobs_test.go (real worker start and stop in a test), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ Event and State Mapping, Pattern 6, Code Example Stripe plan sync, Pitfalls 2-7) + Complete the Stripe half per D-10, D-11, D-15, D-19 and D-21. + +Gateway: CreateManagementSession creates a billing portal session for s.CustomerID() (empty: an error) with the return URL and returns its URL; CancelSubscription updates the subscription with cancel_at_period_end true when atPeriodEnd, else cancels it, and an empty stripe_subscription_id is an error (PHP :243-275); SyncSubscription retrieves the subscription and mutates status, cancel_at_period_end, periods (items[0] first, top-level fallback), canceled_at, ended_at and trial_ends_at on the passed row in memory (pgsub saves); SupportsCurrency over usd, eur, pln, gbp, cad, aud, chf, jpy case-insensitively. sync_plan.go ports d00f850 :387-555 as SyncPlanToGateway: price_cents 0 returns an empty map with no call; the product is updated when stripe_product_id is set, else created, with name, description and metadata plan_code and tier_level (plan.TierLevel()), and plan.StripeProductID is set in memory as soon as the product exists; then for usd, eur and pln with PriceCentsFor above 0: list active prices with lookup key `_` (Prices List with lookup_keys and active true, a recorded deviation from PHP's search), reuse one whose unit_amount and recurring interval match, else create a recurring price (unit_amount, currency, interval from billing_interval, product, lookup_key, transfer_lookup_key true, metadata plan_code) and archive the previous one (active false); set plan.StripePrices to the currency to price-id map and plan.StripePriceID to the usd id when present; an error after the product step returns the error with the product id already on the plan (Service.SyncPlan persists it, RESEARCH Pitfall 5). + +Routes: register LegacyWebhookPath on the same handler and bucket (D-10). + +Job: route customer.subscription.updated like created (Task 2 routed created), customer.subscription.deleted to ApplyDeleted, invoice.paid and invoice.payment_failed to ApplyInvoice with the typed invoice (D-21: no row change; past_due arrives with customer.subscription.updated); the app-tag skip of D-19 is in place. + +Fake: add POST /v1/billing_portal/sessions, GET /v1/subscriptions/{id}, POST /v1/subscriptions/{id}, DELETE /v1/subscriptions/{id}, POST /v1/products, POST /v1/products/{id}, GET /v1/prices (filtering by lookup_keys and active), POST /v1/prices, POST /v1/prices/{id}, each recording its form parameters; an unknown route answers a Stripe-shaped 404. + +Host flows (TestHostSubscriptionFlow subtests; drive the job with Work as in Task 2 unless stated): invoice_paid_basil and invoice_paid_pre_basil (one InvoicePaid each, the subscription id read from parent.subscription_details or the top level); past_due_grace (invoice.payment_failed fires InvoicePaymentFailed; customer.subscription.updated past_due fires SubscriptionPastDue; with the service clock one day after period end HasAccess is true, eight days after it is false); cancel_at_period_end (Service.Cancel at period end sends cancel_at_period_end true to the fake and sets the local flag; customer.subscription.deleted fires SubscriptionCanceled and stores canceled and ended_at); cancel_immediately (the fake records the DELETE); reactivation (updated with cancel_at_period_end false while active fires SubscriptionReactivated only); pre_basil_periods (an updated event with only top-level periods stores them); duplicate_event (the same signed body twice: the second answers `Already processed` and enqueues nothing; working one job twice fires once); app_tag (with metadata_app acme set, an event whose metadata app is other is skipped, its marker exists and nothing fired; with app acme it applies); plan_sync (first sync creates a product and three prices and stores the ids; a second sync with unchanged amounts creates nothing; a changed price_cents creates one new price with transfer_lookup_key and archives the old one; a free plan calls nothing); portal (a customer with an id gets the portal URL); real_worker (start conga.StartWorker on the app, POST a signed event, wait until the subscription row exists or 30 s pass, stop the worker; proves the job kind is registered and worked). + +Webhook smoke (webhook_smoke_test.go, TestSubscriptionWebhookSmoke): missing_secret (500 `Webhook secret not configured`, nothing enqueued); env_fallback (column empty, SUMMER_GOLEM15__PGSTRIPE__TEST_SUBSCRIPTION_WEBHOOK_SECRET set: verified); wrong_secret and live_secret_in_test_mode (400 `Invalid signature`); missing_header (400 `Invalid signature`); bad_payload (signed non-JSON body: 400 `Invalid payload`); oversize (2 MiB: 400); unhandled_type (customer.created: 200 `Webhook received`, no job); five_types (each handled type enqueues exactly one job); legacy_alias (the same body on /api/subscriptions/webhook gives the same answers); no_marker_in_handler (after any handler response the processed-event table is unchanged). + + go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && test "$(grep -ho '"/webhook/stripe/subscriptions"\|"/api/subscriptions/webhook"' ../sm-pgstripe-plugin/subscriptions/*.go | sort -u | wc -l)" -eq 2 + Non-zero exit: vet or the Docker-free suites fail, or the two webhook path literals are not both present in the package (the legacy alias is missing). + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestHostSubscriptionFlow|TestSubscriptionWebhookSmoke)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 2 && printf '%s\n' "$OUT" | grep -q -- '--- PASS: TestHostSubscriptionFlow/real_worker' + Non-zero exit: no Docker daemon (record NOT RUN), fewer than two top-level passes, or the real_worker subtest did not pass: an event fired in the wrong case or twice, a payload shape was not read, grace was wrong, the plan sync created duplicates or lost ids, a refusal answered the wrong status or body, or the worker never processed the job. + + + - `grep -n "transfer_lookup_key\|TransferLookupKey" ../sm-pgstripe-plugin/subscriptions/sync_plan.go` prints at least one line and `grep -n '"usd", "eur", "pln"' ../sm-pgstripe-plugin/subscriptions/sync_plan.go` prints one line. + - `grep -n '"/api/subscriptions/webhook"' ../sm-pgstripe-plugin/subscriptions/plugin.go ../sm-pgstripe-plugin/subscriptions/webhook.go` prints at least one line. + - `grep -n "parent\|subscription_details" ../sm-pgstripe-plugin/subscriptions/decode.go` prints at least two lines. + - `grep -v '^[[:space:]]*//' ../sm-pgstripe-plugin/subscriptions/decode.go | grep -c 'customer_email\|customer_address'` prints 0 (no PII field is decoded into job args). + - `go -C ../sm-pgstripe-plugin test -count=1 -short -run '^TestSDKPin$' .` passes (no second stripe-go major). + + Every subscription lifecycle step a consuming application relies on runs through Stripe events and the gateway exactly as on PHP, on both URLs, idempotently, through a real worker. + + + + Task 4: Settings form fields and copy for the subscription secrets, the import-winter target flag, the READMEs (root and subscriptions) and the extended settings and import smokes + D-18: the import flag name and its values are what each application's cutover runbook calls; the settings field names are the admin contract of a core plugin. + ../sm-pgstripe-plugin/models/settings/fields.yaml, ../sm-pgstripe-plugin/lang/en/lang.yaml, ../sm-pgstripe-plugin/lang/pl/lang.yaml, ../sm-pgstripe-plugin/console/import_winter.go, ../sm-pgstripe-plugin/settings_smoke_test.go, ../sm-pgstripe-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/README.md + ../sm-pgstripe-plugin/models/settings/fields.yaml, ../sm-pgstripe-plugin/lang/en/lang.yaml, ../sm-pgstripe-plugin/lang/pl/lang.yaml, ../sm-pgstripe-plugin/console/import_winter.go (secretColumns :49, flags :97-110, the secret mapping and secretField switch :240-250, flagValue/flagOn :393-410), ../sm-pgstripe-plugin/settings_smoke_test.go (TestStripeSettingsSmoke :133, TestStripeImportWinterSmoke :320), ../sm-pgstripe-plugin/README.md (whole; sections Configuration, CLI commands, Cutover), PHP frozen: classes/StripeSettings.php at 8b47457 (getWebhookSecret: the subscription controller's secret source) and config/config.php, .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Premise S-4, § Runtime State Inventory) + Per D-18 and D-12. + +Settings form (fields.yaml): subscription_webhook_secret (type password, span full, tab live, label golem15.pgstripe::lang.settings.subscriptionWebhookSecret, comment golem15.pgstripe::lang.settings.subscription_webhook_comment) after webhook_secret, and test_subscription_webhook_secret on the test tab with the test label; the header comment says six write-only secrets. Lang en and pl: subscriptionWebhookSecret, testSubscriptionWebhookSecret and subscription_webhook_comment (EN: "Signing secret of the Stripe endpoint that points at /webhook/stripe/subscriptions (Stripe issues one secret per endpoint)."; PL written for this field). + +Import (import_winter.go): WinterOptions gains WebhookSecretTarget with values payments (default), subscriptions and both, read from the new flag `webhook-secret-target` (Description "Where stripe.webhookSecret and stripe.testWebhookSecret go: payments (default), subscriptions or both"); an unknown value is an error naming the flag and the three values, before any write; payments keeps the Phase 18 mapping byte for byte; subscriptions maps the two PHP keys to subscription_webhook_secret and test_subscription_webhook_secret instead; both writes the same decrypted value to both columns; --force, --dry-run (secrets masked) and the Laravel decrypt apply to the new columns exactly as to the old ones. + +Smokes (settings_smoke_test.go): TestStripeSettingsSmoke gains subtests that the GET never contains the two new values and reports their meta.secrets state (stored, env through SUMMER_GOLEM15__PGSTRIPE__SUBSCRIPTION_WEBHOOK_SECRET, empty), keep-on-empty and clear-on-null; TestStripeImportWinterSmoke gains target_subscriptions (encrypted and plaintext PHP values land in the subscription columns and decrypt through SubscriptionWebhookSecret, the payment columns stay empty), target_both, target_default (unchanged Phase 18 result) and target_invalid (error, nothing written). + +READMEs: subscriptions/README.md in the plugin README structure (H1 golem15.pgstripesubscriptions, summary, blank import line, module/package/plugin-ID/requires line, Overview, Features, Usage: summer.yaml entry with id golem15.pgstripesubscriptions and module git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions, the two webhook URLs and the five events to enable in the Stripe dashboard, the signing secret, metadata_app, the worker requirement; API reference; Configuration; Deviations: Prices List instead of Search, no owner metadata key, both payload shapes, no fallback to the payment secret, the cancel_at follow-up of D-21; Dependencies; Testing). Root README: a Subscriptions section linking it, the two new settings fields, the config keys, the import flag in CLI commands, and in Cutover that on PHP one secret (stripe.webhookSecret, or STRIPE_WEBHOOK_SECRET) served both the payment notifications and the subscriptions endpoint, so a cutover chooses --webhook-secret-target per the endpoint the PHP secret belonged to. Neither README names a consuming application. + + go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && grep -c "subscription_webhook_secret" ../sm-pgstripe-plugin/models/settings/fields.yaml | grep -qx 2 && test -f ../sm-pgstripe-plugin/subscriptions/README.md + Non-zero exit: vet or the Docker-free suites fail, the form does not carry both new secret fields (live and test), or the subscriptions README is missing. + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestStripeSettingsSmoke|TestStripeImportWinterSmoke)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 2 + Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: a new secret value appeared in a response, a state was wrong, the default import target changed the Phase 18 result, a target placed a secret in the wrong column, or an invalid target wrote something. + + + - `grep -n '"webhook-secret-target"\|Name: "webhook-secret-target"' ../sm-pgstripe-plugin/console/import_winter.go` prints at least one line. + - `grep -c "subscription_webhook_comment" ../sm-pgstripe-plugin/lang/en/lang.yaml ../sm-pgstripe-plugin/lang/pl/lang.yaml` reports at least 1 per file. + - `grep -n "## Subscriptions" ../sm-pgstripe-plugin/README.md` prints one line and `grep -n "golem15.pgstripesubscriptions" ../sm-pgstripe-plugin/subscriptions/README.md` prints at least one line. + - `grep -rniE "horoskopia|queststream|quotify" ../sm-pgstripe-plugin/README.md ../sm-pgstripe-plugin/subscriptions/README.md` prints nothing. + + An administrator can store the subscription signing secrets per mode without ever seeing them, a cutover can place the PHP secret where it belongs, and both READMEs explain the setup. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Stripe (internet) to the webhook URLs | public, CSRF-free POSTs; the HMAC signature is the only gate | +| handler to River job args | data persisted in river_job until worked | +| plugin to Stripe API | outbound calls with the mode's secret key | +| PHP settings export to import-winter | operator-supplied secrets, possibly Laravel-encrypted | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-18.1-10 | Spoofing | webhook handler (RESEARCH T-1) | high | mitigate | pgstripe.VerifySignature (webhook.ValidatePayload, constant-time, multiple v1 signatures) over the raw body before any decode, lookup or enqueue; 400 on failure; TestSubscriptionWebhookSmoke wrong_secret, missing_header | +| T-18.1-11 | Tampering | replay of a captured signed event (T-2) | high | mitigate | 300 s timestamp tolerance of ValidatePayload; the cheap-check answers Already processed; the job claims the unique marker first; host duplicate_event | +| T-18.1-12 | Tampering / Repudiation | concurrent duplicate jobs (T-3) | high | mitigate | ClaimEvent INSERT ON CONFLICT DO NOTHING inside the job transaction: a second job blocks on the uncommitted unique key and then claims nothing; events fire inside that transaction; plan 06 adds the parallel test | +| T-18.1-13 | Information Disclosure | river_job args and logs (T-6) | medium | mitigate | WebhookJobArgs holds ids, status, flags, timestamps, amounts and three metadata keys only; no customer e-mail or address is decoded; logs carry event ids and types; acceptance criterion on decode.go | +| T-18.1-14 | Information Disclosure | subscription signing secrets (T-7) | high | mitigate | lagoon.Encrypted columns with json "-" and write-only password fields; meta.secrets reports state only; never logged; TestStripeSettingsSmoke subtests | +| T-18.1-15 | Spoofing | wrong-mode secret (T-8) | medium | mitigate | SubscriptionWebhookSecret picks the column of the active test_mode only; smoke live_secret_in_test_mode answers 400 | +| T-18.1-16 | Denial of Service | webhook floods and large bodies (T-9) | medium | mitigate | 60 per minute per client IP bucket shared by both URLs (PHP throttle:60,1); MaxBytesReader 1 MiB; the cheap-check before enqueue; only five types enqueue | +| T-18.1-17 | Tampering | cross-application events on a shared Stripe account (T-11) | medium | mitigate | metadata_app config: Checkout tags the subscription and the job skips other tags with the marker kept (STRIPE-007); host app_tag | +| T-18.1-18 | Tampering | import-winter placing a secret in the wrong column | medium | mitigate | explicit --webhook-secret-target with an unchanged default and an error on unknown values before any write; dry run masks secrets; TestStripeImportWinterSmoke target subtests | +| T-18.1-19 | Elevation of Privilege | missing secret silently accepting events | high | mitigate | the secret is resolved before verification and an empty secret answers 500 with an Error log, never a pass-through (RESEARCH Pitfall 6); smoke missing_secret | +| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new module: stripe-go v87 is already pinned (TestSDKPin) and the pgsub module is local; RESEARCH § Package Legitimacy Audit lists no new package | + + + +- Task 1: the webhook contract sheet and the human's reply before any route exists. +- Task 2: vet, Docker-free suites, the D-07 dependency checks; the checkout_activation host subtest plus Phase 18's host flow and settings smoke on Postgres. +- Task 3: vet, Docker-free suites, the two-path check; the full host flow (incl. real_worker) and the webhook smoke on Postgres. +- Task 4: vet, Docker-free suites, the form fields and README; the settings and import smokes on Postgres. +- Feedback sampling: after each commit `go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -short ./...`. +- Manual (recorded for /msd-verify-work, 18.1-VALIDATION.md manual-only rows): a live Stripe test-mode checkout end to end, and a billing-portal cancellation inspected for cancel_at versus cancel_at_period_end (D-21). + + + +- SC-2: the Stripe gateway (incl. SyncPlanToGateway), both webhook URLs, verification, enqueue and idempotent processing firing the six events exist and are proven. +- SC-4: TestHostSubscriptionFlow covers checkout, activation, invoice.paid, past_due with grace, cancel, reactivation, duplicate idempotency, plan sync and a real worker run. +- Root pgstripe changes are additive; Phase 18 tests stay green. + + + +Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-03-SUMMARY.md` when done, with the commits, the exported identifiers, the Docker tests run or NOT RUN, the recorded deviations and the manual checks left for UAT. + diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-04-PLAN.md b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-04-PLAN.md new file mode 100644 index 0000000..60ebdad --- /dev/null +++ b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-04-PLAN.md @@ -0,0 +1,335 @@ +--- +phase: 18.1-payment-gateway-subscriptions +plan: 04 +type: execute +wave: 2 +depends_on: ["18.1-01", "18.1-02"] +coupling_justified: ["18.1-03: plan 03 compiles this repository through replace and uses only plan 02's contracts, models, events and classes, which this plan does not change (it adds admin, routes, api and copy); both orders produce the same result"] +files_modified: + - ../sm-paymentgatewaysubscriptions-plugin/go.mod + - ../sm-paymentgatewaysubscriptions-plugin/go.sum + - ../sm-paymentgatewaysubscriptions-plugin/plugin.go + - ../sm-paymentgatewaysubscriptions-plugin/admin.go + - ../sm-paymentgatewaysubscriptions-plugin/admin_permissions.go + - ../sm-paymentgatewaysubscriptions-plugin/admin_navigation.go + - ../sm-paymentgatewaysubscriptions-plugin/field_types.go + - ../sm-paymentgatewaysubscriptions-plugin/assets/js/fields.js + - ../sm-paymentgatewaysubscriptions-plugin/controllers/base.go + - ../sm-paymentgatewaysubscriptions-plugin/controllers/admin_registry.go + - ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go + - ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_list.yaml + - ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_form.yaml + - ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_reorder.yaml + - ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions.go + - ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_list.yaml + - ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_filter.yaml + - ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_form.yaml + - ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/_subscription_subscriber.htm + - ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml + - ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/columns.yaml + - ../sm-paymentgatewaysubscriptions-plugin/models/subscription/fields.yaml + - ../sm-paymentgatewaysubscriptions-plugin/models/subscription/columns.yaml + - ../sm-paymentgatewaysubscriptions-plugin/routes.go + - ../sm-paymentgatewaysubscriptions-plugin/api/errors.go + - ../sm-paymentgatewaysubscriptions-plugin/api/json.go + - ../sm-paymentgatewaysubscriptions-plugin/api/plans.go + - ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go + - ../sm-paymentgatewaysubscriptions-plugin/api/urls.go + - ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml + - ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml + - ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml + - ../sm-paymentgatewaysubscriptions-plugin/README.md + - ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go + - ../sm-paymentgatewaysubscriptions-plugin/admin_harness_test.go + - ../sm-paymentgatewaysubscriptions-plugin/admin_smoke_test.go + - ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go +autonomous: false +requirements: [SC-3, D-02, D-08, D-13, D-17, D-21, D-22] +estimate: + tokens: 240000 + raw_tokens: 240000 + tasks: 4 + confidence: low +must_haves: + truths: + - "D-17: the SubscriptionPlans admin ports the PHP form faithfully: code, name, description, stripe_price_id, price_cents (number, cents), currency (default EUR), billing_interval (dropdown month or year), features as `type: taglist, mode: array` stored as the v2.4.0 JSON list, max_quotes_per_month, lead_fee_required, is_active (default on), sort_order (default 0); the list sorts by sort_order with checkboxes on; a flat reorder page (config_reorder.yaml with title, modelClass and nameFrom only) writes sort_order." + - "Discretion (plan sync, RESEARCH Pattern 8): bulk action sync_to_stripe and toolbar action sync_all_to_stripe call classes.Service.SyncPlan for each selected (or each active paid) plan through the gateway registry, never import Stripe, skip free plans and count them, report per-plan `code: message` failures in the result message instead of returning an error (so a rollback never forgets a Stripe product id), and answer a ForbiddenError with a localized message when no subscription gateway is installed; both need golem15.paymentgatewaysubscriptions.manage_plans." + - "D-08 and discretion (Subscriptions admin): a list with subscriber type, subscriber id, plan name, localized status (list field type subscriptionstatus), current period end and hidden cancel flag and created date, filters by status and by plan, sorted by created_at descending, no create button; a preview-only record whose subscriber partial resolves the subscriber through the registry (name and e-mail, soft-deleted users included) and shows a danger notice naming the alias when no plugin registers it, never a 500." + - "D-02 and discretion: permission codes golem15.paymentgatewaysubscriptions.manage_subscriptions, .manage_plans and .manage_placements are registered verbatim (manage_placements kept so PHP role exports stay valid, with no screen and no navigation entry); navigation top item subscriptions (order 510) with side items subscriptions and plans; EN and PL phrase files are complete for every label, comment, action, message and status." + - "D-13 (customer API, contract confirmed at Task 3): under /_paymentgatewaysubscriptions/api/v1, GET /plans is public and lists active plans by sort_order with prices as {amount, amount_minor, currency} objects per currency and the features list; GET /subscription, POST /subscription/checkout, /subscription/portal and /subscription/cancel require the user plugin's jwt guard through Authorization: Bearer only and act only on the token's user as subscriber `user`; redirect targets come from config (api.success_url, api.cancel_url, api.portal_return_url), never from the request." + - "D-21: GET /subscription answers the latest subscription with has_access and in_grace_period flags (404 when the user has none); checkout answers 409 when an active subscription exists, 404 for an unknown or inactive plan, 422 for a free plan or a currency without a price; portal answers 404 without a customer id; cancel cancels at period end only, answers the subscription with cancel_at_period_end true, and 404 without an active subscription." + - "D-22: nothing in this plan pushes; plan 06 owns the final push." + artifacts: + - path: "../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go" + provides: "plans controller with bulk and toolbar sync actions" + contains: "sync_to_stripe" + - path: "../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml" + provides: "the PHP plan form with features as taglist" + contains: "taglist" + - path: "../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_reorder.yaml" + provides: "flat reorder of plans" + contains: "nameFrom" + - path: "../sm-paymentgatewaysubscriptions-plugin/routes.go" + provides: "customer API group, throttle bucket, JWT on subscription routes" + contains: "/_paymentgatewaysubscriptions/api/v1" + - path: "../sm-paymentgatewaysubscriptions-plugin/api/subscription.go" + provides: "current, checkout, portal, cancel handlers" + contains: "checkout_url" + key_links: + - from: "../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go" + to: "../sm-paymentgatewaysubscriptions-plugin/classes/service.go" + via: "Service.SyncPlan per plan inside the bulk transaction or a short transaction per plan" + pattern: "SyncPlan" + - from: "../sm-paymentgatewaysubscriptions-plugin/routes.go" + to: "../sm-paymentgatewaysubscriptions-plugin/api/subscription.go" + via: "RequireJWT-wrapped handlers under the APIPrefix group" + pattern: "RequireJWT" + - from: "../sm-paymentgatewaysubscriptions-plugin/api/subscription.go" + to: "../sm-paymentgatewaysubscriptions-plugin/api/urls.go" + via: "redirect URLs from config, resolved at boot" + pattern: "success_url|SuccessURL" + - from: "../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml" + to: "summercms.go modules/cabana/field_taglist.go" + via: "type taglist mode array compiled by cabana (plan 01)" + pattern: "mode: array" + prohibitions: + - statement: "The customer API never takes a subscriber id, a subscription id or a redirect URL from the request; it acts only on the JWT's user." + status: unresolved + verification: judgment + - statement: "pgsub still imports no stripe-go package; the admin sync goes through the gateway registry." + status: unresolved + verification: judgment + - statement: "No code, YAML, copy or README names a consuming application." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP. + +This plan's slice (plan 4 of 6, wave 2, per D-22): the pgsub admin (SubscriptionPlans with the taglist and flat reorder of plan 01 and the sync actions; Subscriptions list and preview), permissions, navigation and EN/PL copy, then the headless customer API that succeeds the Winter `SubscriptionManager` component (D-13), after a gate on its one-way contract. + +## Spec-less probe fallback + +Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01). + +## Decisions taken in this plan (Claude's discretion) + +- `manage_placements` stays registered (no screen, no navigation), so roles exported from PHP import without unknown codes. +- `currency_prices` and `stripe_prices` are not edited on the form, exactly as the frozen PHP form (RESEARCH S-7); `stripe_product_id` is not shown either. Editing them would be new scope. +- The PHP list columns `formatted_price` and the `valueFrom`/`default` keys have no cabana equivalent; the list shows `price_cents` (labelled Price, cents) and `currency` instead, and `max_quotes_per_month` without the "Unlimited" default text. Recorded in the README. +- `sync_all_to_stripe` is a toolbar action without a confirm dialog (pact.AdminAction has no Confirm); it runs one short transaction per plan. +- The customer API reuses sm-paymentgateway-plugin's exported `api.RequireJWT` and `api.NewMoneyJSON` (pgsub requires that plugin, D-06), so tokens and money render exactly like the order API; the unexported error writers are copied. +- Gateway errors in the customer API answer 502 `{"error": "Payment provider unavailable"}` (logged with ids only) and a missing gateway 503 `{"error": "Subscription gateway not installed"}`; both are listed in the Task 3 contract sheet. + +## Artifacts this phase produces (this plan) + +- Admin: controllers `subscriptionplans` (ID constant `SubscriptionPlansControllerID`) and `subscriptions` (`SubscriptionsControllerID`) with their YAML; bulk action `sync_to_stripe`; toolbar action `sync_all_to_stripe`; partial `subscription_subscriber`; list field type `subscriptionstatus` (element `golem15-pgsub-label`, script `assets/js/fields.js`); permission constants `PermissionManageSubscriptions`, `PermissionManagePlans`, `PermissionManagePlacements`; navigation `subscriptions` (order 510). +- Customer API: `APIPrefix = "/_paymentgatewaysubscriptions/api/v1"`, bucket `APIBucket = "paymentgatewaysubscriptions-api"` (120 per minute per user, else per IP); routes GET `/plans`, GET `/subscription`, POST `/subscription/checkout`, POST `/subscription/portal`, POST `/subscription/cancel`; package `api` with `PlanJSON`, `SubscriptionJSON`, `PlanSummaryJSON`, `CheckoutRequest`, `Plans`, `Current`, `Checkout`, `Portal`, `Cancel`, `RedirectURLs`. +- Config keys `golem15.paymentgatewaysubscriptions.api.success_url`, `.api.cancel_url`, `.api.portal_return_url` (absolute, or a path joined to app.url; validated at boot). +- Complete EN/PL phrasebook; README sections Admin, Customer API, Configuration. +- Smoke tests: `TestAdminPlansSmoke`, `TestAdminSubscriptionsSmoke`, `TestCustomerAPISmoke` (Postgres) and their harness. + + +Deliver the pgsub admin screens and the generic headless customer API: plans with taglist features, flat reorder and Stripe sync actions through the gateway registry; subscriptions list and preview with the subscriber resolved through the registry; permissions, navigation and EN/PL copy; and the JSON API a frontend uses to list plans, check out, open the portal, cancel and read the current subscription. + +Purpose: SC-3 (D-13, D-17, D-21, D-08) and the input contract of plan 05's layer. +Output: commits in sm-paymentgatewaysubscriptions-plugin with Postgres smoke tests and the README. + +Execution budget: over the single-context smart zone by design (D-22 split). Commit per task, write SUMMARY notes as you go, resume at the first uncommitted task after a reset. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-PATTERNS.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-01-SUMMARY.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md +@.planning/phases/18-payment-gateway/18-03-SUMMARY.md +@.planning/phases/18-payment-gateway/18-06-SUMMARY.md + +Repositories: commit only in `../sm-paymentgatewaysubscriptions-plugin`. The admin YAML depends on plan 01's taglist and flat reorder through `replace ../summercms.go` (whether or not the framework tag was pushed). Plan 18.1-03 compiles this repository in the same wave; keep plan 02's exported symbols unchanged. PHP sources (frozen, working tree of `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions`, `git -c safe.directory=*`): controllers, models YAML, lang, Plugin.php, components/SubscriptionManager.php. + + +Analogs (read, copy the shapes): +- `../sm-paymentgateway-plugin/controllers/base.go` (:57-200 `application()`, `pool()`, `db(ctx)`, `t()`, `allows()`), `controllers/payments.go` (preview-only controller, `FilterOptions`, `PartialData`), `controllers/payments/config_list.yaml`, `config_form.yaml`, `config_filter.yaml`, `controllers/admin_registry.go`, `admin.go`, `admin_permissions.go` (:15-37), `admin_navigation.go` (:21-60), `field_types.go` (labelType list-only field type), `assets/js/quote-cart.js` (the label element), `routes.go` (:21-25, :61-87 apiRoutes), `api/auth.go` (`RequireJWT`, `OptionalJWT`, `JWTGuard`), `api/errors.go` (:36-83 envelope), `api/order_json.go` (`MoneyJSON`, `NewMoneyJSON`), `plugin.go` (:105-131 Buckets), `admin_harness_test.go`, `admin_smoke_test.go` (`TestOrdersAdminSmoke` :93), `customer_api_test.go` (`capi`, `frontendToken`, `expectStatus`, `assertNoPrivateKeys`). +- `../sm-summercmsio-app/plugins/golem15/user/controllers/users_admin_controller.go` (:548-590 AdminBulkActions with cabana.TxFromContext) and `controllers/users/config_list.yaml` (showCheckboxes, bulkActions). +- summercms.go `modules/pact/capabilities.go` (AdminAction :225, HasAdminActions :270, AdminBulkAction :285, HasAdminBulkActions :314), `modules/cabana/actions.go` (toolbarAction :89 has no transaction; bulkAction :133 runs in one). +- From plan 02: `classes.Service` (`ActivePlans`, `ActivePlanByCode`, `Latest`, `Active`, `Subscriber`, `Checkout`, `Portal`, `Cancel`, `HasAccess`, `InGracePeriod`, `SyncPlan`, `Gateway`), `contracts.ErrGatewayNotInstalled`, `contracts.ErrUnknownSubscriberType`, `models.*`. + + + + + + + Task 1: Tracer: an administrator creates a plan with taglist features, reorders plans on the flat reorder page and syncs selected plans through the registered gateway, end to end on Postgres + D-17: the controller IDs, YAML keys and action names become the admin contract other applications' admins and role exports use. + ../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/admin.go, ../sm-paymentgatewaysubscriptions-plugin/admin_permissions.go, ../sm-paymentgatewaysubscriptions-plugin/admin_navigation.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/base.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/admin_registry.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_list.yaml, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_form.yaml, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_reorder.yaml, ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml, ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/columns.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go, ../sm-paymentgatewaysubscriptions-plugin/admin_harness_test.go, ../sm-paymentgatewaysubscriptions-plugin/admin_smoke_test.go + ../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go (SyncPlan contract), ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go (the fake gateway), ../sm-paymentgateway-plugin/admin.go, ../sm-paymentgateway-plugin/admin_permissions.go, ../sm-paymentgateway-plugin/admin_navigation.go, ../sm-paymentgateway-plugin/controllers/base.go, ../sm-paymentgateway-plugin/controllers/admin_registry.go, ../sm-paymentgateway-plugin/controllers/shippings.go and controllers/shippings/config_list.yaml and config_form.yaml (a plain CRUD controller), ../sm-paymentgateway-plugin/admin_harness_test.go, ../sm-summercmsio-app/plugins/golem15/user/controllers/users_admin_controller.go (:548-590), ../sm-summercmsio-app/plugins/golem15/user/controllers/users/config_list.yaml, summercms.go modules/pact/capabilities.go (:225-316), modules/cabana/actions.go (:89-190), modules/cabana/testdata/catalog (plan 01's taglist and flat reorder fixture), docs/backend/forms.md (## Taglist) and docs/backend/lists-and-filters.md (## Flat reorder), PHP frozen: Plugin.php (registerNavigation, registerPermissions), controllers/SubscriptionPlans.php (:60-143 onSyncToStripe and onSyncAllToStripe), controllers/subscriptionplans/{config_list.yaml,config_form.yaml,config_reorder.yaml,_list_toolbar.php}, models/subscriptionplan/{fields.yaml,columns.yaml}, lang/en/lang.php, .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Pattern 8, § Admin Screens, Pitfall 5) + Per D-17 and RESEARCH Pattern 8, wire the plans admin end to end. + +Plugin: plugin.go adds pact.HasAdminControllers (or the interface the Phase 18 plugins use), HasPermissions, HasNavigation and AdminAssets as sm-paymentgateway-plugin does; admin.go embeds controllers and models YAML and returns the controllers from controllers/admin_registry.go. admin_permissions.go registers the three PHP codes verbatim (PermissionManageSubscriptions, PermissionManagePlans, PermissionManagePlacements) with lang labels on the plugin tab. admin_navigation.go: top item code subscriptions, Order 510, Permissions golem15.paymentgatewaysubscriptions.*, side items subscriptions and plans (lucide icons refresh-cw and list), listing only registered controllers, no featured-placements item. + +Controller (controllers/subscriptionplans.go): SubscriptionPlansControllerID subscriptionplans, ModelName Golem15\PaymentGatewaySubscriptions\Models\SubscriptionPlan, RequiredPermissions manage_plans, NewRecord models.SubscriptionPlan. YAML: config_list.yaml (list ~/plugins/golem15/paymentgatewaysubscriptions/models/subscriptionplan/columns.yaml, title from lang, recordUrl .../subscriptionplans/update/:id, recordsPerPage 20, showSorting, showCheckboxes true, defaultSort sort_order asc, toolbar buttons [create, reorder, sync_all_to_stripe], search prompt, bulkActions [sync_to_stripe]); config_form.yaml; config_reorder.yaml with exactly title (lang key), modelClass and nameFrom name (flat by plan 01's inference). models/subscriptionplan/fields.yaml ports the PHP form field by field with lang labels and comments: features `type: taglist` with `mode: array`; billing_interval dropdown with options month and year labelled from lang; currency default EUR; is_active default true; sort_order default 0. columns.yaml: code and name searchable, price_cents (label Price, number, not sortable), currency, billing_interval, max_quotes_per_month, lead_fee_required (switch), is_active (switch), sort_order (number, invisible). + +Actions: AdminBulkActions returns sync_to_stripe (Label and Confirm lang keys with the PHP copy "Sync selected plans to Stripe?", Permissions manage_plans): resolve the stripe gateway first (contracts.ErrGatewayNotInstalled: return a cabana ForbiddenError with lang errors.gateway_not_installed); with cabana.TxFromContext, for each record (price_cents 0: count as skipped) call Service.SyncPlan(ctx, tx, plan); a failure appends `code: message` and continues; never return an error after the first gateway call; Message is lang actions.sync_result with :synced, :skipped and :failed counts, followed by the failure lines. AdminActions returns toolbar sync_all_to_stripe (manage_plans): the same over every active plan with price_cents above 0, each in its own short lagoon transaction. + +Copy (lang en and pl): plugin, nav, permissions, plans.* (titles, field labels and comments ported from lang/en/lang.php and the PHP YAML comments, Polish written for this plugin), intervals.month and intervals.year, actions.sync_to_stripe, actions.sync_all_to_stripe, actions.sync_confirm, actions.sync_result, reorder title. + +Tests: admin_harness_test.go boots the plugins with a backend administrator holding manage_plans (copy the Phase 18 admin harness: admin login, CSRF token, request helper) and the fixture's fake gateway; admin_smoke_test.go TestAdminPlansSmoke: GET the plans list schema has a taglist field features with mode array and reorder.flat true; POST a plan with features [" chat ", "chat", "reports"] stores the JSON list ["chat","reports"]; three plans posted with sort_order 0 are reordered through POST .../reorder and the list returns the new order; the bulk action on two plans (one free) stores stripe_product_id and stripe_prices from the fake on the paid one, skips the free one and answers a message with synced 1 and skipped 1; a fake failure on one of two paid plans keeps the other plan's ids and the failure line, and the response is 200; the toolbar action syncs all active paid plans; an administrator without manage_plans gets 403 on the list and on both actions; with no gateway registered (a harness variant without the fake) the bulk action answers 403 with the localized message. + + go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && DEPS=$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./...) && ! printf '%s\n' "$DEPS" | grep -q 'stripe-go' + Non-zero exit: vet or the Docker-free suite fails (for example the admin YAML no longer compiles at boot in a short test), or a stripe-go package entered the dependency list. + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^TestAdminPlansSmoke$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestAdminPlansSmoke' + Non-zero exit: no Docker daemon (record NOT RUN), or the plans smoke failed: the form lacks the taglist, the reorder is not flat, tags were not normalised, the reorder did not persist, a sync stored nothing or lost ids after a partial failure, the action returned an error status, or permission checks let a non-manager in. + + + - `grep -n "type: taglist" ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml` prints one line and `grep -n "mode: array" ../sm-paymentgatewaysubscriptions-plugin/models/subscriptionplan/fields.yaml` prints one line. + - `grep -cE '^(title|modelClass|nameFrom):' ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_reorder.yaml` prints 3. + - `grep -c "golem15.paymentgatewaysubscriptions.manage_subscriptions\|golem15.paymentgatewaysubscriptions.manage_plans\|golem15.paymentgatewaysubscriptions.manage_placements" ../sm-paymentgatewaysubscriptions-plugin/admin_permissions.go` prints at least 3. + - `grep -n "Order: *510" ../sm-paymentgatewaysubscriptions-plugin/admin_navigation.go` prints one line. + - `grep -n "bulkActions" ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_list.yaml` prints one line. + + An administrator manages plans as on PHP: edits features as tags, drags plans into order and pushes them to Stripe from the list without losing ids on a partial failure. + + + + Task 2: Subscriptions admin: list with filters and the status label type, the preview-only record with the registry-resolved subscriber partial, and the complete EN/PL phrasebook + Admin display only; no stored data or published contract changes. + ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_list.yaml, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_filter.yaml, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_form.yaml, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/_subscription_subscriber.htm, ../sm-paymentgatewaysubscriptions-plugin/controllers/admin_registry.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription/fields.yaml, ../sm-paymentgatewaysubscriptions-plugin/models/subscription/columns.yaml, ../sm-paymentgatewaysubscriptions-plugin/field_types.go, ../sm-paymentgatewaysubscriptions-plugin/assets/js/fields.js, ../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml, ../sm-paymentgatewaysubscriptions-plugin/admin_smoke_test.go + ../sm-paymentgateway-plugin/controllers/payments.go (preview-only controller, FilterOptions, PartialData :216-260), ../sm-paymentgateway-plugin/controllers/payments/config_list.yaml, config_form.yaml, config_filter.yaml, _payment_data.htm, ../sm-paymentgateway-plugin/models/payment/fields.yaml and columns.yaml, ../sm-paymentgateway-plugin/field_types.go and assets/js/quote-cart.js (labelType), ../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go, ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go, PHP frozen: controllers/Subscriptions.php, controllers/subscriptions/{config_list.yaml,config_filter.yaml,config_form.yaml}, models/subscription/{fields.yaml,columns.yaml}, models/Subscription.php (getStatusLabelAttribute :330, getSubscriberDisplayNameAttribute :357), lang/en/lang.php + Per D-08 and the CONTEXT discretion on the Subscriptions screen. + +Controller (controllers/subscriptions.go): SubscriptionsControllerID subscriptions, ModelName Golem15\PaymentGatewaySubscriptions\Models\Subscription, RequiredPermissions manage_subscriptions, preview-only like the Phase 18 payments controller (recordUrl .../subscriptions/preview/:id, no create button, no update path), FilterOptions for the plan scope (plan id to name over all plans) and the status scope (active, trialing, past_due, canceled, unpaid, labelled from statuses.*), PartialData for subscription_subscriber: resolve the row's subscriber_type through the published Subscribers registry and the resolver; the view model carries alias, id, name and e-mail; an unknown alias yields a view model with the danger notice errors.unknown_subscriber_type (alias in the text) and an Error log, never an error return; a missing row (deleted) yields an empty name with a muted note. + +YAML: config_list.yaml (list ~/plugins/golem15/paymentgatewaysubscriptions/models/subscription/columns.yaml, defaultSort created_at desc, recordsPerPage 20, showSorting, filter config_filter.yaml, toolbar search only); config_filter.yaml (stripe_status group and plan group); config_form.yaml (preview with the subscriber partial as header or first field, as the payments preview does); models/subscription/columns.yaml: subscriber_type (searchable), subscriber_id (searchable), plan (relation plan, select name), stripe_status (type subscriptionstatus), current_period_end (datetime), cancel_at_period_end (switch, invisible), created_at (datetime, invisible); models/subscription/fields.yaml: the PHP fields shown read-only in preview (subscriber partial, plan, gateway, stripe_status, stripe_subscription_id, stripe_customer_id, periods, cancel flag, canceled_at, ended_at, trial_ends_at). + +Field type (field_types.go, assets/js/fields.js): list-only subscriptionstatus (and subscriptiongateway for the gateway value) following the Phase 18 labelType: ListValue translates statuses. (gateways.), falling back to the stored code; Decode refuses every value; element golem15-pgsub-label defined in fields.js as a read-only text element. plugin.go registers them (pact form field types and admin assets). + +Copy: complete lang en and pl for subscriptions.* (titles, every field label, preview messages, filter labels), statuses.*, gateways.*, errors.*, partial texts; every key referenced by any YAML or Go file of the plugin exists in both files. + +Smoke (admin_smoke_test.go) TestAdminSubscriptionsSmoke: two subscriptions (one user, one with an alias no plugin registers) are listed newest first with localized status labels; the status filter past_due returns only the past_due row; the plan filter works; the preview of the user row shows the user's name and e-mail; the preview of the unknown-alias row answers 200 with the danger notice; there is no create route (404) and no update save (404 or 405 as the framework answers for preview-only controllers); an administrator without manage_subscriptions gets 403; a test asserts every lang key used in the YAML and Go files exists in en and pl. + + go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... + Non-zero exit: vet or the Docker-free suite fails (the YAML or the field types no longer compile, or the lang parity test fails). + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestAdminSubscriptionsSmoke|TestAdminPlansSmoke)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestAdmin' | grep -qx 2 + Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: wrong order or labels, a filter leaked rows, the subscriber partial was missing or 500ed on an unknown alias, a create or update path exists, or a permission check let a non-manager in. + + + - `grep -n "subscriptions/preview/:id" ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_list.yaml` prints one line and `grep -cE 'buttons:.*create' ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptions/config_list.yaml` prints 0 (no create button in the toolbar). + - `grep -n "type: subscriptionstatus" ../sm-paymentgatewaysubscriptions-plugin/models/subscription/columns.yaml` prints one line. + - `grep -n "unknown_subscriber_type" ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml` prints at least one line per file. + - `grep -rniE "horoskopia|queststream|quotify" ../sm-paymentgatewaysubscriptions-plugin --include=*.go --include=*.yaml --include=*.htm --include=*.js` prints nothing. + + An administrator can find, filter and inspect every subscription and see who the subscriber is, in English or Polish, and an unregistered subscriber type is visible instead of hidden or crashing. + + + + Task 3: Confirm the one-way customer API contract (D-13): paths, authorization, JSON shapes and status codes + .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ Customer API, Security T-4 and T-5), .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md (D-13, D-14, D-21), ../sm-paymentgateway-plugin/README.md (## Customer API, the Phase 18 envelope and money shape), ../sm-paymentgateway-plugin/api/order_json.go (MoneyJSON), PHP frozen: components/SubscriptionManager.php (the Winter component this API succeeds), /media/nvme/dev/golem15/quotify.pro/themes/quotify/pages/subscription.htm (read-only: how a page used the component) + Print the contract sheet, then stop. The sheet lists, under /_paymentgatewaysubscriptions/api/v1 with the paymentgatewaysubscriptions-api bucket: GET /plans (public) answering {"data": [PlanJSON]} where PlanJSON is code, name, description, billing_interval, prices (list of {amount, amount_minor, currency}, one per key of currency_prices sorted by currency, else one from price_cents and currency; currency upper-cased as in the order API) and features (list); GET /subscription (Bearer jwt) answering SubscriptionJSON (status, plan {code, name, billing_interval}, current_period_start, current_period_end, cancel_at_period_end, canceled_at, ended_at, trial_ends_at, days_until_renewal, has_access, in_grace_period; timestamps in the order API's format; no gateway ids) or 404 {"error": "Subscription not found"}; POST /subscription/checkout with {plan_code, currency?} answering {checkout_url, session_id} or 409 {"error": "Already subscribed"}, 404 {"error": "Plan not found"}, 422 {"error": "Validation failed", "errors": {...}} for a free plan or a currency without a price; POST /subscription/portal answering {portal_url} or 404 {"error": "No billing account"}; POST /subscription/cancel answering SubscriptionJSON with cancel_at_period_end true or 404 {"error": "Subscription not found"}; 401 without a valid Bearer token on the four subscription routes; 502 {"error": "Payment provider unavailable"} on a gateway error; 503 {"error": "Subscription gateway not installed"}; redirect URLs only from config. Name the difference between the two options. + Which shape does the published customer API take? + The API is consumed by plan 05's layer and by applications (D-13 one-way); changing paths or JSON later breaks every frontend pinned to the layer tag. The decision to ship the API is taken (D-13); this gate confirms the concrete contract before Task 4 writes it. + + + + + Reply "api-research" or "api-null-subscription"; record the reply verbatim in the SUMMARY and in the api package doc comment; Task 4 implements it. + + test -f /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/components/SubscriptionManager.php && grep -n "## Customer API" ../sm-paymentgateway-plugin/README.md >/dev/null && echo api-sheet-ok + Non-zero exit or "api-sheet-ok" absent: the frozen component or the order API documentation the sheet aligns with is missing. + + + - The SUMMARY records the sheet and the reply verbatim before Task 4's first commit. + + The customer API contract is confirmed before any handler exists. + + + + Task 4: Customer API: five endpoints with JWT, bucket, config redirect URLs and the confirmed shapes; README Admin, Customer API and Configuration sections; the API smoke + D-13: the published API that plan 05's layer and applications consume; confirmed at Task 3. + ../sm-paymentgatewaysubscriptions-plugin/go.mod, ../sm-paymentgatewaysubscriptions-plugin/go.sum, ../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/routes.go, ../sm-paymentgatewaysubscriptions-plugin/api/errors.go, ../sm-paymentgatewaysubscriptions-plugin/api/json.go, ../sm-paymentgatewaysubscriptions-plugin/api/plans.go, ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/api/urls.go, ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go + ../sm-paymentgateway-plugin/routes.go (:21-25, :61-87), ../sm-paymentgateway-plugin/api/auth.go, ../sm-paymentgateway-plugin/api/errors.go, ../sm-paymentgateway-plugin/api/order_json.go (MoneyJSON :54, NewMoneyJSON :62, the timestamp format), ../sm-paymentgateway-plugin/money/money.go (from minor units), ../sm-paymentgateway-plugin/plugin.go (:105-131 Buckets), ../sm-paymentgateway-plugin/customer_api_test.go (capi, frontendToken, expectStatus, assertNoPrivateKeys), ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, this plan's Task 3 SUMMARY note (the confirmed contract), PHP frozen: components/SubscriptionManager.php (behaviour of plans, subscribe, manage, cancel) + Implement the contract confirmed at Task 3 per D-13 and D-21. + +Routes (routes.go): APIPrefix /_paymentgatewaysubscriptions/api/v1 in one group with `throttle:` APIBucket; GET /plans public; GET /subscription, POST /subscription/checkout, /subscription/portal and /subscription/cancel wrapped in sm-paymentgateway-plugin's exported api.RequireJWT (Bearer only, never cookies). plugin.go adds pact.HasRoutes and surf.BucketProvider with APIBucket paymentgatewaysubscriptions-api (120 per minute keyed by the frontend user id when authenticated, else surf.ClientIP with trusted proxies), and resolves RedirectURLs at Boot. + +URLs (api/urls.go, config/config.yaml): keys api.success_url, api.cancel_url and api.portal_return_url (defaults empty) with a header naming SUMMER_GOLEM15__PAYMENTGATEWAYSUBSCRIPTIONS__API__SUCCESS_URL and the two others; an absolute http or https URL is used as is, a path is joined to app.url, an empty value means app.url itself; anything else fails boot naming the key. Handlers never read a URL from the request. + +Handlers (api/): the subscriber is always Service.Subscriber(ctx, "user", ); Plans maps Service.ActivePlans to PlanJSON with prices built through money from minor units (currency_prices per currency else price_cents and currency) and NewMoneyJSON; Current answers SubscriptionJSON from Service.Latest with has_access and in_grace_period from the service (or the Task 3 choice for none); Checkout decodes {plan_code, currency} (plan_code required string; currency optional, default the plan's currency lower-cased), answers 409 when Service.Active finds one, 404 when ActivePlanByCode finds none, 422 for price_cents 0 or a currency the gateway does not support or the plan has no price for, else Service.Checkout with CheckoutOptions{Currency} and the configured URLs and answers {checkout_url, session_id}; Portal answers 404 without a customer id, else {portal_url} from Service.Portal with the configured return URL; Cancel finds Service.Active (none: 404), calls Service.Cancel(ctx, sub, true) and answers the reloaded SubscriptionJSON. Errors use the copied envelope (writeAPIError, writeValidation, writeInternal); contracts.ErrGatewayNotInstalled is 503, any other gateway error 502, each logged with the user id and plan code only. The api package doc comment records the Task 3 reply. + +README: Admin (screens, actions, permissions, the recorded list deviations), Customer API (the endpoint table, auth, shapes, status codes, redirect config, an example fetch sequence), Configuration (grace_period_days and the three api keys with their SUMMER_* names), all naming no consuming application. + +Smoke (customer_api_smoke_test.go) TestCustomerAPISmoke: GET /plans without a token lists only active plans in sort_order with money objects and features; the four subscription routes answer 401 without a token and with an invalid one; a user without a subscription gets the Task 3 none answer; checkout with an unknown plan is 404, with a free plan 422, with a valid plan 200 with the fake's URL and the configured success URL passed to the gateway (a success_url field in the request body is ignored); with an active subscription checkout is 409; portal without a customer id is 404 and with one returns the fake's URL; cancel sets cancel_at_period_end true in the answer and in the row; another user's token never sees or cancels the first user's subscription; no response contains stripe_subscription_id, stripe_customer_id or an e-mail. + + go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && grep -n "## Customer API" ../sm-paymentgatewaysubscriptions-plugin/README.md && DEPS=$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./...) && ! printf '%s\n' "$DEPS" | grep -q 'stripe-go' + Non-zero exit: vet or the Docker-free suite fails, the README lacks the Customer API section, or stripe-go entered the dependency list. + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestCustomerAPISmoke|TestAdminPlansSmoke|TestAdminSubscriptionsSmoke|TestSubscriptionsTracer)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 4 + Non-zero exit: no Docker daemon (record NOT RUN), or fewer than four passes: an auth, status or shape rule of the confirmed contract failed, a request URL reached the gateway, a user saw another user's subscription, a private key leaked, or an earlier smoke regressed. + + + - `grep -n 'APIPrefix = "/_paymentgatewaysubscriptions/api/v1"' ../sm-paymentgatewaysubscriptions-plugin/routes.go` prints one line and `grep -c "RequireJWT" ../sm-paymentgatewaysubscriptions-plugin/routes.go` prints at least 4. + - `grep -v '^[[:space:]]*//' ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go | grep -c 'r.URL.Query().Get("success_url")\|r.FormValue("return_url")'` prints 0. + - `grep -n "success_url:\|cancel_url:\|portal_return_url:" ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml` prints three lines. + - `grep -n "## Admin" ../sm-paymentgatewaysubscriptions-plugin/README.md` prints one line. + + A logged-in customer can list plans, check out, open the billing portal, cancel and read their subscription through a documented JSON API that never trusts request-supplied redirects or ids. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser to customer API | anonymous (plans) and JWT-authenticated (subscription routes) untrusted input | +| admin browser to cabana | authenticated administrators with manage_plans / manage_subscriptions | +| pgsub to the gateway registry | admin and API actions trigger outbound gateway calls | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-18.1-20 | Spoofing | checkout success/cancel and portal return URLs (RESEARCH T-4) | high | mitigate | URLs come only from config resolved at boot (http/https or app-relative); handlers never read a URL from the request; smoke asserts a body success_url is ignored and an acceptance criterion forbids request URL reads | +| T-18.1-21 | Elevation of Privilege | IDOR on subscription routes (T-5) | high | mitigate | the subscriber is always ("user", JWT user id); no route takes a subscription or subscriber id; smoke cross-user subtest | +| T-18.1-22 | Tampering | admin plan sync abuse (T-12) | medium | mitigate | sync_to_stripe and sync_all_to_stripe require manage_plans on top of the controller permission; cabana logs action runs; smoke 403 subtests | +| T-18.1-23 | Information Disclosure | subscriber name and e-mail on the preview, customer API JSON | medium | mitigate | the partial is visible only with manage_subscriptions; the API JSON omits gateway ids and e-mails; smoke asserts no private keys in responses | +| T-18.1-24 | Denial of Service | customer API floods | medium | mitigate | paymentgatewaysubscriptions-api bucket (120/min per user, else per IP); checkout and portal call the gateway only after cheap local checks (409/404/422) | +| T-18.1-25 | Tampering (integrity) | bulk sync rollback losing Stripe product ids (RESEARCH Pitfall 5) | high | mitigate | the bulk Run never returns an error after the first gateway call; failures are reported per plan in the message; Service.SyncPlan persists the product id even when a price step fails; smoke partial-failure subtest | +| T-18.1-26 | Spoofing (CSRF) | customer API | medium | mitigate | JWT only from Authorization: Bearer through api.RequireJWT; cookies are never read, so a cross-site form cannot act | +| T-18.1-27 | Information Disclosure | unknown subscriber_type on the preview | low | mitigate | the partial shows a danger notice and logs at Error instead of a 500 that would hide the record (D-08 fail loudly, visibly) | +| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new module: the API reuses sm-paymentgateway-plugin (already required, D-06); the label element is plain JS in the plugin | + + + +- Task 1: vet, Docker-free suite, no stripe-go; the plans admin smoke on Postgres. +- Task 2: vet, Docker-free suite incl. lang parity; both admin smokes on Postgres. +- Task 3: the API contract sheet and the human's reply before any handler exists. +- Task 4: vet, Docker-free suite, README, no stripe-go; the API smoke plus every earlier smoke on Postgres. +- Feedback sampling: after each commit `go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -short ./...`. + + + +- SC-3 (pgsub side): Subscriptions and SubscriptionPlans admin screens, the Go service API (plan 02) and the headless customer API are delivered and documented. +- D-17 is used as decided: features as taglist, a flat reorder page. +- The customer API contract was confirmed before it was written (D-13 one-way). + + + +Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md` when done, with the commits, the controllers, actions, routes and config keys added, the Task 3 reply, the Docker tests run or NOT RUN, and the recorded deviations. + diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-05-PLAN.md b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-05-PLAN.md new file mode 100644 index 0000000..9a652ba --- /dev/null +++ b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-05-PLAN.md @@ -0,0 +1,267 @@ +--- +phase: 18.1-payment-gateway-subscriptions +plan: 05 +type: execute +wave: 3 +depends_on: ["18.1-04"] +files_modified: + - ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts + - ../vue-paymentgateway-layer/app/utils/subscribe.ts + - ../vue-paymentgateway-layer/app/composables/useSubscriptionsApi.ts + - ../vue-paymentgateway-layer/app/composables/usePlans.ts + - ../vue-paymentgateway-layer/app/composables/useSubscription.ts + - ../vue-paymentgateway-layer/app/components/Pg/Plans.vue + - ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue + - ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue + - ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue + - ../vue-paymentgateway-layer/nuxt.config.ts + - ../vue-paymentgateway-layer/i18n/locales/en.json + - ../vue-paymentgateway-layer/i18n/locales/pl.json + - ../vue-paymentgateway-layer/.playground/app/pages/subscriptions/index.vue + - ../vue-paymentgateway-layer/package.json + - ../vue-paymentgateway-layer/README.md + - ../vue-paymentgateway-layer/tests/subscriptions.test.ts + - ../vue-paymentgateway-layer/tests/subscribe.test.ts + - ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts + - ../vue-paymentgateway-layer/tests/i18n.test.ts +autonomous: false +requirements: [SC-3, D-13, D-14, D-21, D-22] +estimate: + tokens: 150000 + raw_tokens: 150000 + tasks: 3 + confidence: low +must_haves: + truths: + - "D-14: vue-paymentgateway-layer (no new repository) gains a typed subscriptions client over an injected fetcher for the confirmed /_paymentgatewaysubscriptions/api/v1 contract (plans, subscription, checkout, portal, cancel), reusing the layer's PgMoney, errorStatus and fieldErrors; a composable useSubscriptionsApi picks the server base runtimeConfig.paymentgatewaysubscriptions.apiBase during SSR (forwarding x-forwarded-for like usePaymentGatewayApi) and the public base in the browser, and sends the app's paymentGatewayAuth token as Authorization: Bearer only." + - "D-14: components PgPlans (plans or pricing table: name, description, interval, the price in the visitor's locale for the chosen currency, features as text, a subscribe button per paid plan), PgSubscribeButton (POST checkout, then window.location assigned once with checkout_url, busy against double clicks), PgMySubscription (status, period end, will-cancel notice, grace notice, has_access, a cancel action through PgConfirm) and PgManageSubscriptionButton (POST portal, then redirect) use neutral pg-* tokens and classes, can be overridden by the app, and fetch nothing on import." + - "D-21 and D-13: a 404 on GET /subscription renders the no-subscription state (or the null answer if plan 04 Task 3 chose api-null-subscription); a 409 on checkout shows pg.subscriptions.error.already_subscribed with the manage button; a 401 shows pg.subscriptions.error.login; 502 and 503 show pg.subscriptions.error.provider; the layer never sends a redirect URL, a subscriber id or a subscription id." + - "D-14: pg.subscriptions.* copy exists in en and pl with the same keys and placeholders; the README documents the client, the composables, the four components, the runtime config and the auth hook; package.json version is 0.2.0 (additive release)." + - "D-22: tag v0.2.0 is created and pushed only after the human's reply at the Task 3 checkpoint." + artifacts: + - path: "../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts" + provides: "typed client of the subscriptions customer API" + contains: "createSubscriptionsClient" + - path: "../vue-paymentgateway-layer/app/utils/subscribe.ts" + provides: "subscribe and manage flows with injected location (node-testable)" + contains: "checkout_url" + - path: "../vue-paymentgateway-layer/app/components/Pg/Plans.vue" + provides: "plans or pricing table" + contains: "PgSubscribeButton" + - path: "../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue" + provides: "my-subscription status with cancel" + contains: "has_access" + key_links: + - from: "../vue-paymentgateway-layer/app/composables/useSubscriptionsApi.ts" + to: "../vue-paymentgateway-layer/nuxt.config.ts" + via: "runtimeConfig.paymentgatewaysubscriptions.apiBase (server) and public twin (browser)" + pattern: "paymentgatewaysubscriptions" + - from: "../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue" + to: "../vue-paymentgateway-layer/app/utils/subscribe.ts" + via: "createSubscribeFlow with the browser location" + pattern: "createSubscribeFlow" + - from: "../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts" + to: "../sm-paymentgatewaysubscriptions-plugin/routes.go" + via: "the five paths of the confirmed customer API" + pattern: "/subscription/checkout" + prohibitions: + - statement: "The layer adds no npm dependency and no payment SDK; package.json dependencies and the lockfile's resolved set are unchanged apart from the version field." + status: unresolved + verification: judgment + - statement: "Existing Pg* order components, the order client and their tests behave exactly as before (additive release)." + status: unresolved + verification: judgment + - statement: "No component renders plan or subscription text through a raw-HTML binding." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP. + +This plan's slice (plan 5 of 6, wave 3, per D-22): the frontend half of SC-3. The subscription pieces join `vue-paymentgateway-layer` (D-14): a typed client of plan 04's customer API, composables, a plans table, a subscribe redirect, a my-subscription panel with cancel and a portal redirect, EN/PL copy and the README; tag `v0.2.0` only on the human's reply. + +## Spec-less probe fallback + +Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01). + +## Decisions taken in this plan (Claude's discretion) + +- The flows live in plain TS (`app/utils/subscribe.ts`) with an injected location and client, as Phase 18's `checkout.ts` does, so `node --test` drives them; components stay thin. +- PgMySubscription includes the cancel-at-period-end action (through the existing PgConfirm), matching the customer API and the Winter component; immediate cancel stays a service/admin capability (plan 04). +- The plans table shows one price per plan in the `currency` prop (default the plan's first price); it never computes prices, it formats the server's `amount` string. +- The release is `v0.2.0` (additive minor); the existing one-test-commit lag of `v0.1.0` is folded in. + +## Artifacts this phase produces (this plan) + +- `app/utils/subscriptionsClient.ts`: `PgPlan`, `PgSubscription`, `PgPlanSummary`, `PgCheckoutAnswer`, `PgPortalAnswer`, `SubscriptionsClient`, `createSubscriptionsClient(fetch)`, `hasNoSubscription(err)`. +- `app/utils/subscribe.ts`: `createSubscribeFlow`, `createManageFlow`, `SubscribeState`. +- Composables `useSubscriptionsApi`, `usePlans` (key `pg:subscriptions:plans`), `useSubscription` (key `pg:subscriptions:current`). +- Components `PgPlans`, `PgSubscribeButton`, `PgMySubscription`, `PgManageSubscriptionButton`. +- Runtime config `paymentgatewaysubscriptions.apiBase` (env `NUXT_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE`) and `public.paymentgatewaysubscriptions.apiBase` (`NUXT_PUBLIC_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE`). +- i18n keys `pg.subscriptions.*` (en, pl); playground page `/subscriptions`; README section Subscriptions; package version 0.2.0; tag `v0.2.0` on reply. +- Specs `tests/subscriptions.test.ts`, `tests/subscribe.test.ts`, `tests/subscriptionComponents.test.ts`; `tests/i18n.test.ts` covering the new keys. + + +Add the subscription client, composables and components to the Nuxt layer so an application can show plans, send a customer to Stripe Checkout, show their subscription with cancel, and open the billing portal, against plan 04's API, with EN/PL copy and docs, released as v0.2.0 on the human's go-ahead. + +Purpose: SC-3's "subscription components in vue-paymentgateway-layer" (D-14) on the D-13 contract. +Output: commits in vue-paymentgateway-layer and a release decision. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md +@.planning/phases/18-payment-gateway/18-06-SUMMARY.md + +Repository: commit only in `../vue-paymentgateway-layer` (branch main, head ac2b960 at planning time, tag v0.1.0 one commit behind). The API contract is the one recorded in 18.1-04-SUMMARY (Task 3 reply). Node 22.18 or newer runs `node --test` on .ts directly; `pnpm --dir ../vue-paymentgateway-layer test` runs every spec (69 passing at planning time). + + +Existing layer code (read, extend, do not change behaviour): +- `app/utils/client.ts`: `PgMoney {amount, amount_minor, currency}` (:8), `PaymentGatewayFetcher` (:124), `createPaymentGatewayClient` (:162), `errorStatus` (:191), `fieldErrors` (:200). +- `app/composables/usePaymentGatewayApi.ts` (whole file: server/public base, x-forwarded-for forwarding, `$paymentGatewayAuth` Bearer hook), `app/composables/useOrders.ts` (useAsyncData wrapper). +- `app/utils/checkout.ts` (flow with injected location, busy and redirecting states), `app/utils/money.ts` (`formatMoney`). +- `app/components/Pg/OrderList.vue` (header comment, useI18n, error panel with pg.action.retry, skeleton, pg-page/pg-panel classes), `app/components/Pg/Confirm.vue`. +- `nuxt.config.ts` (:52-66 runtimeConfig), `i18n/locales/{en,pl}.json` (`pg.*`), `testing/load.ts` (`loadComposable`, SFC compile and SSR render helpers), `tests/client.test.ts`, `tests/checkout.test.ts`, `tests/i18n.test.ts`, `.playground/app/pages/orders/index.vue`. + + + + + + + Task 1: Tracer: PgPlans lists the API's plans and PgSubscribeButton posts checkout and sends the browser to checkout_url once, through a typed client and composable, proven by node specs + D-14: the component, composable and type names are what applications pinned to the layer tag import; renaming them breaks those apps. + ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts, ../vue-paymentgateway-layer/app/utils/subscribe.ts, ../vue-paymentgateway-layer/app/composables/useSubscriptionsApi.ts, ../vue-paymentgateway-layer/app/composables/usePlans.ts, ../vue-paymentgateway-layer/app/components/Pg/Plans.vue, ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue, ../vue-paymentgateway-layer/nuxt.config.ts, ../vue-paymentgateway-layer/i18n/locales/en.json, ../vue-paymentgateway-layer/i18n/locales/pl.json, ../vue-paymentgateway-layer/tests/subscriptions.test.ts, ../vue-paymentgateway-layer/tests/subscribe.test.ts + ../vue-paymentgateway-layer/app/utils/client.ts, ../vue-paymentgateway-layer/app/utils/checkout.ts, ../vue-paymentgateway-layer/app/utils/money.ts, ../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts, ../vue-paymentgateway-layer/app/composables/useOrders.ts, ../vue-paymentgateway-layer/app/components/Pg/OrderList.vue, ../vue-paymentgateway-layer/nuxt.config.ts, ../vue-paymentgateway-layer/i18n/locales/en.json, ../vue-paymentgateway-layer/testing/load.ts, ../vue-paymentgateway-layer/tests/client.test.ts, ../vue-paymentgateway-layer/tests/checkout.test.ts, ../sm-paymentgatewaysubscriptions-plugin/README.md (## Customer API), ../sm-paymentgatewaysubscriptions-plugin/api/json.go (PlanJSON, SubscriptionJSON), .planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md (the Task 3 reply) + Per D-14 and the D-13 contract confirmed in plan 04. + +Client (subscriptionsClient.ts): header comment like client.ts; interfaces PgPlan (code, name, description, billing_interval, prices PgMoney[], features string[]), PgPlanSummary, PgSubscription (the SubscriptionJSON fields incl. has_access and in_grace_period), PgCheckoutAnswer {checkout_url, session_id}, PgPortalAnswer {portal_url}; createSubscriptionsClient(fetch: PaymentGatewayFetcher) returning plans() (GET /plans, {data} unwrapped), subscription() (GET /subscription), checkout(planCode, currency?) (POST /subscription/checkout with body {plan_code, currency} and currency omitted when absent), portal() (POST /subscription/portal), cancel() (POST /subscription/cancel); hasNoSubscription(err) is true for a 404 (or reads the null answer when plan 04 chose api-null-subscription). Types and error readers come from client.ts (no duplicates). + +Composables: useSubscriptionsApi mirrors usePaymentGatewayApi with runtimeConfig.paymentgatewaysubscriptions.apiBase on the server and public.paymentgatewaysubscriptions.apiBase in the browser, the same x-forwarded-for forwarding and the same $paymentGatewayAuth Bearer hook; usePlans wraps useAsyncData with key pg:subscriptions:plans. nuxt.config.ts runtimeConfig gains paymentgatewaysubscriptions.apiBase 'http://127.0.0.1:8096/_paymentgatewaysubscriptions/api/v1' and public.paymentgatewaysubscriptions.apiBase '/_paymentgatewaysubscriptions/api/v1' with comments naming the NUXT_* variables; the existing paymentgateway keys are unchanged. + +Flow (subscribe.ts): createSubscribeFlow({client, location}) with a reactive state {busy, redirecting, error}; subscribe(planCode, currency?) does nothing while busy, calls checkout, on success sets redirecting and calls location.assign(checkout_url) exactly once (stays busy), on 409 sets error pg.subscriptions.error.already_subscribed, on 401 pg.subscriptions.error.login, on 422 pg.subscriptions.error.unavailable, on 502 or 503 pg.subscriptions.error.provider, otherwise pg.subscriptions.error.generic, and re-enables. + +Components: PgPlans (props currency?, planCodes? filter; header comment; useI18n; a list of plan cards with name, description, the interval label pg.subscriptions.interval., the price via formatMoney of the matching PgMoney, features as a text list, PgSubscribeButton per plan whose prices are not all zero, a free-plan label otherwise; loading skeleton, load error with pg.action.retry, empty copy). PgSubscribeButton (props planCode, currency?, label?; uses createSubscribeFlow with window.location in the browser; button disabled while busy; error line under it). Text renders through interpolation only. + +Copy: pg.subscriptions.* in en and pl (plans title, empty, interval.month, interval.year, free, subscribe, subscribing, error.already_subscribed, error.login, error.unavailable, error.provider, error.generic). + +Specs (all top-level test names start with "subscriptions:"): tests/subscriptions.test.ts covers every client path, method and body, the {data} unwrap, hasNoSubscription, and useSubscriptionsApi choosing the server base with forwarding and the Bearer token versus the public base in the browser (loadComposable); tests/subscribe.test.ts covers one location.assign on success with busy kept, a second click while busy sending nothing, and each error status mapping to its key and re-enabling. Neither calls the network. + + OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -cE '^ok [0-9]+ - subscriptions:' | grep -qE '^([6-9]|[1-9][0-9])$' && git -C ../vue-paymentgateway-layer diff --quiet ac2b960 -- pnpm-lock.yaml && ! grep -qi 'stripe' ../vue-paymentgateway-layer/package.json + Non-zero exit: a spec failed ("# fail" not 0), fewer than six "subscriptions:" tests passed, the lockfile changed (a dependency was added), or a Stripe package appears in package.json. + + + - `grep -n "createSubscriptionsClient" ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts` prints at least one line and `grep -c "interface PgMoney" ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts` prints 0 (PgMoney is imported, not redefined). + - `grep -n "paymentgatewaysubscriptions" ../vue-paymentgateway-layer/nuxt.config.ts` prints at least two lines. + - `grep -c "v-html" ../vue-paymentgateway-layer/app/components/Pg/Plans.vue ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue` reports 0 per file. + - `grep -n "success_url\|cancel_url\|return_url" ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts` prints nothing (the layer never sends redirect URLs). + + A site using the layer shows its plans and sends a customer to Stripe Checkout with one click, with every error mapped to readable copy. + + + + Task 2: PgMySubscription with cancel, PgManageSubscriptionButton, useSubscription, i18n parity, the playground page, README and version 0.2.0 + D-14: additional public component names and i18n keys under the release tag. + ../vue-paymentgateway-layer/app/composables/useSubscription.ts, ../vue-paymentgateway-layer/app/utils/subscribe.ts, ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue, ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue, ../vue-paymentgateway-layer/i18n/locales/en.json, ../vue-paymentgateway-layer/i18n/locales/pl.json, ../vue-paymentgateway-layer/.playground/app/pages/subscriptions/index.vue, ../vue-paymentgateway-layer/package.json, ../vue-paymentgateway-layer/README.md, ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts, ../vue-paymentgateway-layer/tests/i18n.test.ts + ../vue-paymentgateway-layer/app/utils/subscribe.ts, ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts (Task 1), ../vue-paymentgateway-layer/app/components/Pg/Confirm.vue, ../vue-paymentgateway-layer/app/components/Pg/OrderStatus.vue (status panel tones), ../vue-paymentgateway-layer/tests/orderList.test.ts and tests/confirm.test.ts (SSR render specs), ../vue-paymentgateway-layer/tests/i18n.test.ts, ../vue-paymentgateway-layer/.playground/app/pages/orders/index.vue, ../vue-paymentgateway-layer/README.md (structure and the install, auth hook and runtime config sections), ../vue-paymentgateway-layer/package.json, PHP frozen: /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/components/SubscriptionManager.php and its partials (the states the Winter component showed) + Complete the D-14 pieces. + +useSubscription wraps useAsyncData (key pg:subscriptions:current) and turns the no-subscription answer into data null. subscribe.ts gains createManageFlow({client, location}): open() posts portal and assigns portal_url once (busy guard; 404 sets pg.subscriptions.error.no_billing_account; the other statuses as in the subscribe flow) and cancel() posts cancel and returns the updated subscription (busy guard, errors mapped). + +PgMySubscription (props plansTo? for the no-subscription link): skeleton while loading, load error with retry; no subscription: pg.subscriptions.none with an optional link; otherwise the plan name, the localized status pg.subscriptions.status., the current period end in the locale's date, days until renewal, a will-cancel notice with the period end when cancel_at_period_end, a grace notice when in_grace_period, an access line from has_access, PgManageSubscriptionButton, and a cancel button (only when the status is active or trialing and no cancel is scheduled) that opens PgConfirm with pg.subscriptions.cancel.confirm and, on confirm, cancels and refreshes. PgManageSubscriptionButton (props label?) uses createManageFlow with window.location. Text through interpolation only. + +Copy: the remaining pg.subscriptions.* keys in en and pl (none, status.*, period_end, renews_in, will_cancel, grace, access_yes, access_no, manage, managing, cancel.button, cancel.confirm, cancel.done, error.no_billing_account); tests/i18n.test.ts asserts the same pg.subscriptions keys and placeholders in both files and that every pg.subscriptions key used in app/ exists. + +Playground: .playground/app/pages/subscriptions/index.vue renders PgPlans and PgMySubscription. README: a Subscriptions section (backend requirement golem15.paymentgatewaysubscriptions with its customer API, the runtime config keys and NUXT_* variables, the auth hook, the client methods, the composables, the four components with props and slots, the error keys, overriding components) naming no consuming application; package.json version 0.2.0. + +Specs (tests/subscriptionComponents.test.ts, names starting "subscriptions:"): PgPlans SSR renders two plans with formatted prices and features and no subscribe button for a free plan; a feature containing markup renders as text; PgMySubscription renders the none state, an active subscription with renews-in, a scheduled cancel with the will-cancel notice and no cancel button, a past_due subscription with the grace notice; the manage flow assigns portal_url once and maps 404 to no_billing_account; the cancel flow returns the updated subscription. + + OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -cE '^ok [0-9]+ - subscriptions:' | grep -qE '^(1[2-9]|[2-9][0-9])$' && grep -q '"version": "0.2.0"' ../vue-paymentgateway-layer/package.json && pnpm --dir ../vue-paymentgateway-layer run dev:prepare >/dev/null 2>&1 && test -d ../vue-paymentgateway-layer/.playground/.nuxt + Non-zero exit: a spec failed, fewer than twelve "subscriptions:" tests passed (the components or flows lack coverage), the version is not 0.2.0, or the playground does not prepare (a component or the config does not compile in Nuxt). + + + - `ls ../vue-paymentgateway-layer/app/components/Pg | grep -cE '^(Plans|SubscribeButton|MySubscription|ManageSubscriptionButton)\.vue$'` prints 4. + - `grep -n "## Subscriptions" ../vue-paymentgateway-layer/README.md` prints one line and `grep -rniE "horoskopia|queststream|quotify" ../vue-paymentgateway-layer/README.md ../vue-paymentgateway-layer/app` prints nothing. + - `git -C ../vue-paymentgateway-layer diff --quiet ac2b960 -- app/components/Pg/OrderPage.vue app/components/Pg/Checkout.vue app/utils/client.ts app/utils/checkout.ts` exits 0 (the order pieces are unchanged). + + A logged-in customer sees their subscription, can open the billing portal or schedule a cancel, in English or Polish, from documented layer components. + + + + Task 3: Release decision: tag vue-paymentgateway-layer v0.2.0 and push main, or hold + .planning/phases/18-payment-gateway/18-06-SUMMARY.md (how v0.1.0 was tagged and pushed after the reply), this plan's Task 1 and 2 SUMMARY notes + Do everything automatable, print the sheet, then stop. In ../vue-paymentgateway-layer: `git status --porcelain` empty, branch main, `git fetch origin --tags`, `git log --oneline HEAD..origin/main` empty (otherwise stop and report), `git ls-remote --tags origin v0.2.0` empty. Run the verify. Print: the commits main publishes since v0.1.0, the new public surface (client, flows, composables, four components, runtime config keys, pg.subscriptions.* keys), that applications pin the layer by tag so v0.2.0 is what they will install, and that "hold" leaves plan 06's final push to publish it. No tag and no push before the reply. After "tag-and-push": `git tag -a v0.2.0 -m "vue-paymentgateway-layer v0.2.0"` on main, `git push origin main`, `git push origin v0.2.0`, never force. After "push-only": push main without the tag. After "hold": nothing. Record the reply verbatim and the pushed hashes in the SUMMARY. + Tag v0.2.0 and push the layer now? + A pushed tag is what applications' package.json pin (a pnpm git dependency by tag, D-14); it cannot be moved, only superseded. The layer depends on plan 04's API, which is not yet pushed or tagged unless the human approved that separately. + + + + + + Reply "tag-and-push", "push-only" or "hold". + + test -z "$(git -C ../vue-paymentgateway-layer status --porcelain)" && test "$(git -C ../vue-paymentgateway-layer rev-parse --abbrev-ref HEAD)" = main && OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && echo layer-release-sheet-ok + Non-zero exit or "layer-release-sheet-ok" absent: uncommitted files, a branch other than main, or a failing spec; the executor fixes it before presenting the sheet. + + + - The SUMMARY records the reply verbatim; after "tag-and-push" `git -C ../vue-paymentgateway-layer ls-remote --tags origin v0.2.0` is non-empty; after "hold" it is empty and `git -C ../vue-paymentgateway-layer log --oneline origin/main..main` lists this plan's commits. + + The human decided whether the subscription components are published as v0.2.0. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser to the layer's components | visitor input (clicks, currency choice) | +| layer to the customer API | Bearer token from the app's hook; same-origin or server base from runtime config | +| API answers to the browser location | checkout_url and portal_url become navigation targets | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-18.1-28 | Spoofing | redirects in subscribe.ts (open redirect) | high | mitigate | the flows assign only checkout_url or portal_url from the API answer, never a route query or prop; the client sends no redirect URL (acceptance criterion); the server builds the URLs from config (plan 04 T-18.1-20) | +| T-18.1-29 | Information Disclosure | token handling in useSubscriptionsApi | medium | mitigate | the token comes only from the app's paymentGatewayAuth hook and travels as Authorization: Bearer; the layer stores nothing; the SSR base is runtime config, never request input | +| T-18.1-30 | Tampering (XSS) | plan names, descriptions and features in PgPlans and PgMySubscription | high | mitigate | text interpolation only (acceptance criterion forbids raw-HTML bindings); a spec renders a feature containing markup as text | +| T-18.1-31 | Tampering | double submit creating several Checkout Sessions | low | mitigate | the busy guard sends nothing while a request runs and stays busy once redirecting; spec covers the second click | +| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no npm package is added: the verify checks pnpm-lock.yaml is unchanged since ac2b960 and package.json names no Stripe package | + + + +- Task 1: the full layer suite with zero failures, at least six "subscriptions:" passes, an unchanged lockfile and no Stripe package. +- Task 2: the full suite with at least twelve "subscriptions:" passes, version 0.2.0, the playground prepares. +- Task 3: clean main and a green suite before the human decides the tag. +- Feedback sampling: after each commit `pnpm --dir ../vue-paymentgateway-layer test`. + + + +- SC-3 (layer side, D-14): the subscriptions client and the plans, subscribe, my-subscription and manage components exist, are documented and tested. +- The layer's order features are unchanged; no dependency was added. +- The human decided the v0.2.0 release (D-22). + + + +Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-05-SUMMARY.md` when done, with the commits, the public surface added, the spec counts, the release reply and any pushed hash or tag. + diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-06-PLAN.md b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-06-PLAN.md new file mode 100644 index 0000000..b7b6560 --- /dev/null +++ b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-06-PLAN.md @@ -0,0 +1,327 @@ +--- +phase: 18.1-payment-gateway-subscriptions +plan: 06 +type: execute +wave: 4 +depends_on: ["18.1-01", "18.1-02", "18.1-03", "18.1-04", "18.1-05"] +files_modified: + - scripts/check-phase18.1.sh + - .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md + - modules/cabana/taglist_field_test.go + - modules/cabana/reorder_flat_test.go + - modules/cabana/catalog_smoke_test.go + - modules/cabana/README.md + - admin/tests/form/TaglistField.test.ts + - admin/tests/views/ReorderView.test.ts + - ../sm-paymentgatewaysubscriptions-plugin/models/subscription_test.go + - ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan_test.go + - ../sm-paymentgatewaysubscriptions-plugin/models/feature_list_test.go + - ../sm-paymentgatewaysubscriptions-plugin/contracts/registry_test.go + - ../sm-paymentgatewaysubscriptions-plugin/apply_events_test.go + - ../sm-paymentgatewaysubscriptions-plugin/service_api_test.go + - ../sm-paymentgatewaysubscriptions-plugin/user_subscribable_test.go + - ../sm-paymentgatewaysubscriptions-plugin/baseline_test.go + - ../sm-paymentgatewaysubscriptions-plugin/admin_actions_test.go + - ../sm-paymentgatewaysubscriptions-plugin/customer_api_authz_test.go + - ../sm-paymentgatewaysubscriptions-plugin/lang_parity_test.go + - ../sm-paymentgatewaysubscriptions-plugin/README.md + - ../sm-paymentgatewaysubscriptions-plugin/go.mod + - ../sm-pgstripe-plugin/subscriptions/decode_test.go + - ../sm-pgstripe-plugin/subscriptions/job_test.go + - ../sm-pgstripe-plugin/subscriptions/sync_plan_test.go + - ../sm-pgstripe-plugin/subscriptions/gateway_test.go + - ../sm-pgstripe-plugin/subscriptions/php_map_test.go + - ../sm-pgstripe-plugin/subscription_secrets_test.go + - ../sm-pgstripe-plugin/README.md + - ../sm-pgstripe-plugin/subscriptions/README.md + - ../sm-pgstripe-plugin/go.mod + - ../vue-paymentgateway-layer/tests/subscriptions.test.ts + - ../vue-paymentgateway-layer/tests/subscribe.test.ts + - ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts + - ../vue-paymentgateway-layer/README.md +autonomous: false +requirements: [SC-1, SC-2, SC-3, SC-4, SC-5, D-01, D-03, D-04, D-05, D-08, D-09, D-11, D-12, D-13, D-15, D-16, D-17, D-18, D-19, D-21, D-22] +estimate: + tokens: 280000 + raw_tokens: 280000 + tasks: 4 + 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-paymentgatewaysubscriptions-plugin, sm-pgstripe-plugin (root and subscriptions) 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." + - "SC-5 gate: scripts/check-phase18.1.sh (modelled on check-phase18.sh) runs 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 file, proves its detectors with --self-test, and --all runs everything, saying NOT RUN postgres loudly without a Docker daemon (PHASE181_REQUIRE_POSTGRES=1 makes that a failure)." + - "D-17 coverage: TestTaglistFieldCompile, TestTaglistFieldValidate (both modes, trim, dedupe, empty, null, non-list, non-string, customTags) and TestTaglistFieldHydrate; TestReorderFlatCompile, TestReorderFlatNodes (non-null parent 422, duplicate 422, missing node 409, no depth or cycle check) and the Postgres flat and tree reorder tests; vitest TaglistField (markup in a tag renders as text, keyboard add and remove, suggestions, string mode) and ReorderView flat cases." + - "D-03, D-05, D-08, D-09 coverage: TestSubscriptionChecks (IsActive, InGracePeriod boundary at exactly the grace end, HasAccess, DaysUntilRenewal floored at 0 and nil, OnTrial, IsFromGateway, IsStripeSubscription for stripe and empty), TestPlanPrices (lower-cased currency, fallbacks, TierLevel), TestFeatureList (list, legacy map, empty value is []), TestRegistry (duplicate alias and gateway naming both plugins, unknown alias error, order), TestUserSubscribable (name and surname trimmed, e-mail fallback, soft-deleted user resolves, SetCustomerID writes only stripe_customer_id)." + - "D-04/D-16 coverage: TestBaselineSchema asserts the Postgres information_schema table, column, index, check and foreign-key names equal the golden list derived from the PHP v2.4.0 dump (RESEARCH § v2.4.0 Schema Baseline), the two dormant tables are absent, the baseline re-runs idempotently and users.stripe_customer_id is added only when missing." + - "SC-2 and D-11 coverage: TestApplyEvents asserts the event set for every previous-to-current status pair of the eight statuses with and without the cancel-flag change, deleted always canceled, invoice events with no row change; TestClaimEventConcurrent runs two goroutines claiming one event id and exactly one wins; TestSubscriptionJob asserts STRIPE-006 (a failing Apply leaves no marker and returns the error; success leaves exactly one) and STRIPE-007 (app-tag skip and an unhandled type keep the marker), a duplicate job and two concurrent jobs fire once, and both payload shapes map." + - "D-15 coverage: TestSyncPlan asserts free skip, product create then update, price reuse when unchanged, replace with transfer_lookup_key and archive when changed, usd/eur/pln loop with the stored map and stripe_price_id, and product id kept when a price step fails; TestSubscriptionGateway asserts the success URL separator (? and &), metadata app written only when configured (D-19), no owner key, currency default and ErrNoPriceForCurrency, cancel at period end versus immediate, sync from items and from the top level." + - "D-12 and D-18 coverage and the PHP map: the nine 18.1 rows of the pgstripe PHP tests map to Go cases named after them (ReliabilityTest STRIPE-006 x2 to TestStripeSubscriptionReliability; DataHandlingTest STRIPE-001 x3 and PAY-10 to TestStripeSubscriptionSecrets for the two new columns and the import target; STRIPE-003 to the unchanged TestSDKPin; AuthenticationTest PAY-09 accept and reject to TestSubscriptionAuthentication); the two STRIPE-002 payment-notification tests are recorded as Phase 18 scope; each Go case names its PHP origin in a comment." + - "D-13 and D-21 coverage: TestServiceAPI (Checkout persists the customer id before the session and never inside a long transaction, Cancel sets the local flag only after gateway success, SyncPlan persistence on partial failure, Latest ordering by created_at then id); TestAdminSyncActions (free skip, partial failure message, no error status, missing gateway 403, permission 403); TestCustomerAPIAuthz (every route against anonymous, invalid token, owner, other user; no private keys; request redirect fields ignored); layer specs for every client path, both flows and all component states incl. the none answer of the confirmed contract." + - "D-22 (release): after the gate is green, a final checkpoint lists every unpushed commit and untagged release (framework tag if plan 01 held, pgsub remote and main if plan 02 held, pgstripe main, layer main and v0.2.0 if plan 05 held) and nothing is pushed or tagged before the human's reply; after a framework tag the plugin go.mod files require that tag in a separate commit before their push." + artifacts: + - path: "scripts/check-phase18.1.sh" + provides: "fail-closed phase gate over four repositories with self-test" + contains: "PHASE181_REQUIRE_POSTGRES" + - path: "../sm-paymentgatewaysubscriptions-plugin/apply_events_test.go" + provides: "status-pair event matrix" + contains: "TestApplyEvents" + - path: "../sm-pgstripe-plugin/subscriptions/job_test.go" + provides: "STRIPE-006/007, duplicate and concurrent job tests" + contains: "TestSubscriptionJob" + - path: "../sm-pgstripe-plugin/subscriptions/php_map_test.go" + provides: "PHP test map cases" + contains: "stripe_006" + - path: ".planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md" + provides: "validated per-task map" + contains: "nyquist_compliant: true" + key_links: + - from: "scripts/check-phase18.1.sh" + to: "../sm-pgstripe-plugin/subscriptions/host_test.go" + via: "postgres stage required name TestHostSubscriptionFlow in package ./subscriptions" + pattern: "TestHostSubscriptionFlow" + - from: "scripts/check-phase18.1.sh" + to: "../sm-paymentgatewaysubscriptions-plugin/baseline_test.go" + via: "postgres stage required name TestBaselineSchema" + pattern: "TestBaselineSchema" + - from: "scripts/check-phase18.1.sh" + to: "../vue-paymentgateway-layer/tests/subscriptions.test.ts" + via: "layers stage required file and the subscriptions: test count" + pattern: "subscriptions.test.ts" + prohibitions: + - statement: "No test calls the network or a real Stripe account; secrets in tests are test-only literals." + status: unresolved + verification: judgment + - statement: "The gate never reports green when a required test skipped, did not run, or Docker was absent without saying NOT RUN." + status: unresolved + verification: judgment + - statement: "Nothing is pushed or tagged without the human's reply at the final checkpoint." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP. + +This plan's slice (plan 6 of 6, wave 4, per D-22 and the project rule "unit tests are the last plan"): SC-5. Unit tests for everything plans 01 to 05 added, the PHP test map, the fail-closed `scripts/check-phase18.1.sh`, the validated `18.1-VALIDATION.md`, README Testing sections, and the final push and tag decision. + +## Spec-less probe fallback + +Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01). + +## Decisions taken in this plan (Claude's discretion) + +- The gate copies check-phase18.sh's detectors (go -json, vitest JSON, TAP, required files) rather than sourcing them, as Phase 17 and 18 did, so each phase gate stays self-contained. +- Docker-backed pgsub tests live in the root package (its harness and TestMain exist there); pure model and registry matrices live in `models` and `contracts` and run under `-short`. +- The STRIPE-002 payment-notification tests stay Phase 18 scope (they test the payment operator, not subscriptions). + +## Artifacts this phase produces (this plan) + +- `scripts/check-phase18.1.sh` with stages `go`, `plugins`, `admin`, `layers`, `docs`, `postgres`, `--self-test`, `--all`, env `PHASE181_REQUIRE_POSTGRES`, `PHASE181_ROOT`, `PGSUB`, `PGSTRIPE`, `PG_LAYER`. +- Go tests (framework): `TestTaglistFieldValidate`, `TestTaglistFieldHydrate`, `TestReorderFlatNodes` (plus the plan 01 smokes kept as required names). +- Go tests (pgsub): `TestSubscriptionChecks`, `TestPlanPrices`, `TestFeatureList`, `TestRegistry`, `TestApplyEvents`, `TestClaimEventConcurrent`, `TestUserSubscribable`, `TestBaselineSchema`, `TestServiceAPI`, `TestAdminSyncActions`, `TestCustomerAPIAuthz`, `TestLangParity`. +- Go tests (pgstripe): `TestDecodeSubscription`, `TestDecodeInvoice`, `TestJobArgsMinimal`, `TestSubscriptionJob`, `TestSyncPlan`, `TestSubscriptionGateway`, `TestStripeSubscriptionReliability`, `TestSubscriptionAuthentication`, `TestStripeSubscriptionSecrets`. +- Layer specs extended in `tests/subscriptions.test.ts`, `tests/subscribe.test.ts`, `tests/subscriptionComponents.test.ts`. +- `18.1-VALIDATION.md` validated (`status: validated`, `nyquist_compliant: true`, `wave_0_complete: true`); README Testing sections in all four repositories' changed READMEs. + + +Close the phase with unit tests for every behaviour plans 01 to 05 added, the nine-row PHP test map, a fail-closed gate script over all four repositories, a validated VALIDATION map, and the human's final push and tag decision. + +Purpose: SC-5 and the project rule "unit tests are the last plan of every phase"; the gate is what /msd-verify-work runs. +Output: test commits in four repositories, the gate script, the validated map, and the release record. + +Execution budget: over the single-context smart zone by design (D-22 split). Commit per task (per repository), write SUMMARY notes as you go, resume at the first uncommitted task after a reset. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-01-SUMMARY.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-03-SUMMARY.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-05-SUMMARY.md +@.planning/phases/18-payment-gateway/18-07-PLAN.md + +Repositories: summercms.go (gate, framework tests, VALIDATION), ../sm-paymentgatewaysubscriptions-plugin, ../sm-pgstripe-plugin, ../vue-paymentgateway-layer. Commit per repository; planning docs in their own commit. Docker-backed suites skip under -short; run them on a Docker host (sudo -u golem if the socket is denied) and record NOT RUN only when no daemon answers. A test that exposes a production bug gets the fix in the owning repository as its own `fix(18.1-06): ...` commit before the test commit, and the SUMMARY lists it. + + +- `scripts/check-phase18.sh` (whole file: stage functions, detect_go :243, detect_vitest :294, detect_tap :339, detect_files :384, go_named :423, run_self_test :488, run_postgres :662, run_all :675, the PHASE18_REQUIRE_POSTGRES handling) — the template. +- PHP tests at pgstripe 8b47457: tests/reliability/ReliabilityTest.php (test_stripe_006_idempotency_marker_after_job_success :33, test_stripe_006_idempotency_marker_double_processing_prevented :99), tests/security/DataHandlingTest.php (test_stripe_001_api_keys_stored_as_plaintext :28, test_stripe_001_keys_encrypted_at_rest :81, test_stripe_001_secret_keys_round_trip_encrypted_to_db :153, test_pay_10_lazy_decrypt_handles_plaintext :227, test_stripe_003_sdk_constraint_pinned :278), tests/security/AuthenticationTest.php (test_stripe_002_* :42 and :74, test_pay_09_stripe_verify_notification_rejects_invalid_sig :121, _accepts_valid_sig :176). +- The test names plans 01 to 05 created (read each SUMMARY for the final names): TestTaglistFieldCompile, TestTaglistFieldSmoke, TestReorderFlatCompile, TestReorderFlatSmoke, TestSubscriptionsTracer, TestSubscriptionsCoreSmoke, TestAdminPlansSmoke, TestAdminSubscriptionsSmoke, TestCustomerAPISmoke, TestHostSubscriptionFlow, TestSubscriptionWebhookSmoke, TestStripeSettingsSmoke, TestStripeImportWinterSmoke. + + + + + + + Task 1: Tracer: `scripts/check-phase18.1.sh` runs one required test per repository and layer end to end, fail-closed, with its self-test proving every detector + A test script; nothing outside the repository reads it. + scripts/check-phase18.1.sh + scripts/check-phase18.sh (whole file), scripts/check-phase17.sh (header and stage layout), .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md, the five plan SUMMARYs of this phase (the existing test names), ../vue-paymentgateway-layer/package.json (the TAP test script) + Per SC-5, create scripts/check-phase18.1.sh from check-phase18.sh: header comment describing this phase's pieces (cabana taglist and flat reorder in this repository; sm-paymentgatewaysubscriptions-plugin; sm-pgstripe-plugin root and its subscriptions package with the proof host; the layer's subscription pieces) without naming a consuming application; `set -euo pipefail`; roots from PHASE181_ROOT, PGSUB (default ../sm-paymentgatewaysubscriptions-plugin), PGSTRIPE (../sm-pgstripe-plugin), PG_LAYER (../vue-paymentgateway-layer); the detectors copied unchanged in behaviour (go -json: fail, skip, zero tests, "no tests to run", missing required name; vitest JSON: failed, skipped, todo, failed file, no pass, missing file or name; TAP: not ok, fail, cancelled, skipped or todo counts, zero passes, missing name; missing required files). Stages: go (go vet ./... here and the required Docker-free cabana names), plugins (go vet ./... and go test -short ./... in PGSUB and PGSTRIPE plus required Docker-free names per package: ./models, ./contracts and . in PGSUB; . and ./subscriptions in PGSTRIPE), admin (typecheck and the vitest JSON with required files tests/form/TaglistField.test.ts and tests/views/ReorderView.test.ts), layers (pnpm test TAP with required files tests/subscriptions.test.ts, tests/subscribe.test.ts, tests/subscriptionComponents.test.ts and a minimum count of passing tests whose name starts with "subscriptions:"), docs (TestDocsTree, docs:build --check, check-admin-openapi.sh --check, check-admin-dist.sh, check-phase10.sh --hygiene), postgres (Docker host only: required Postgres names per repository and package; no daemon fails the stage). --self-test plants failing, skipping, empty and missing outputs and asserts each detector fails, and a passing output passes. --all runs self-test, go, plugins, admin, layers, docs, then postgres when `docker info` succeeds, otherwise prints "NOT RUN postgres" on stderr and fails only when PHASE181_REQUIRE_POSTGRES=1. In this task the required lists hold one existing name per place (go: TestTaglistFieldCompile; plugins: PGSUB the vet and -short run only (its first Docker-free required names arrive in Task 2), PGSTRIPE . TestSDKPin; admin: TaglistField.test.ts; layers: subscriptions.test.ts with a minimum of 6; postgres: TestTaglistFieldSmoke, TestSubscriptionsTracer, TestHostSubscriptionFlow); Tasks 2 and 3 extend the lists. + + bash -n scripts/check-phase18.1.sh && bash scripts/check-phase18.1.sh --self-test && bash scripts/check-phase18.1.sh go && bash scripts/check-phase18.1.sh layers + Non-zero exit: a syntax error, a detector that accepted a planted failing, skipping, empty or missing output (self-test), or the go or layers stage red on the existing required names. + docker info >/dev/null 2>&1 && bash scripts/check-phase18.1.sh postgres + Non-zero exit: no Docker daemon (record NOT RUN), or a required Postgres test of the three repositories failed, skipped or did not run. + + + - `grep -n "PHASE181_REQUIRE_POSTGRES" scripts/check-phase18.1.sh` prints at least two lines and `grep -c "run_self_test\|run_postgres\|run_all" scripts/check-phase18.1.sh` prints at least 3. + - `MODE=$(git ls-files -s scripts/check-phase18.1.sh) && printf '%s\n' "$MODE" | grep -q '^100755'` succeeds after the task commit (the script is committed executable). + - `grep -rniE "horoskopia|queststream|quotify" scripts/check-phase18.1.sh` prints nothing. + + A single command proves the phase across four repositories and cannot report green on a skipped or missing test. + + + + Task 2: Framework and pgsub unit coverage: taglist and flat reorder matrices with SPA specs; model, registry, event, claim, user subscribable, baseline schema, service, admin action and customer API authorization tests + Tests only (plus any bug fix they expose, committed separately). + modules/cabana/taglist_field_test.go, modules/cabana/reorder_flat_test.go, modules/cabana/catalog_smoke_test.go, modules/cabana/README.md, admin/tests/form/TaglistField.test.ts, admin/tests/views/ReorderView.test.ts, ../sm-paymentgatewaysubscriptions-plugin/models/subscription_test.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan_test.go, ../sm-paymentgatewaysubscriptions-plugin/models/feature_list_test.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/registry_test.go, ../sm-paymentgatewaysubscriptions-plugin/apply_events_test.go, ../sm-paymentgatewaysubscriptions-plugin/service_api_test.go, ../sm-paymentgatewaysubscriptions-plugin/user_subscribable_test.go, ../sm-paymentgatewaysubscriptions-plugin/baseline_test.go, ../sm-paymentgatewaysubscriptions-plugin/admin_actions_test.go, ../sm-paymentgatewaysubscriptions-plugin/customer_api_authz_test.go, ../sm-paymentgatewaysubscriptions-plugin/lang_parity_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, scripts/check-phase18.1.sh + modules/cabana/field_taglist.go, modules/cabana/reorder.go, modules/cabana/taglist_field_test.go, modules/cabana/reorder_flat_test.go, modules/cabana/catalog_smoke_test.go, modules/cabana/money_field_test.go (matrix style), admin/src/components/form/fields/TaglistField.vue, admin/src/views/ReorderView.vue, admin/tests/form/TaglistField.test.ts, admin/tests/views/ReorderView.test.ts, ../sm-paymentgatewaysubscriptions-plugin (models, contracts, classes, controllers, api, harness and smoke tests from plans 02 and 04), ../sm-paymentgateway-plugin/fsm/fsm_test.go and orders/machine_test.go (matrix test style), ../sm-paymentgateway-plugin/api/authz_test.go (authorization matrix style), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ v2.4.0 Schema Baseline: the real DDL names for the golden list; § Event and State Mapping), PHP frozen models/Subscription.php and models/SubscriptionPlan.php (expected behaviours) + + - Taglist: [" a ", "a", "", "b"] normalises to ["a","b"]; string mode with separator space "x y x" stores "x y"; null stores [] (array) or "" (string); a string in array mode, a number item, or an object is 422 taglist.invalid; customTags false with a tag outside options is 422 taglist.not_allowed naming it; a stored NULL hydrates to []; unparsable stored text hydrates to []. + - Flat reorder nodes: any non-null parent is 422 flat_parent; duplicate id 422; a missing or extra node 409; a valid reversed set passes with no depth or cycle evaluation. + - Subscription checks: past_due with period end exactly now plus grace days has no access (the boundary is exclusive as PHP's lt), one second before has access; DaysUntilRenewal of a past period end is 0, of no period end nil; IsStripeSubscription true for stripe and empty, false for google_play. + - Event matrix: for each of the 64 previous-to-current status pairs (plus none to each status for a new row) the fired set equals the PHP rules; cancel flag true to false while active or trialing adds reactivated even with no status change. + - Claim: two goroutines claiming the same id concurrently in two transactions yield exactly one true. + - Baseline: information_schema names equal the golden list; the two dormant tables do not exist. + + Per SC-5 and the must_haves of plans 01, 02 and 04 (D-03, D-04, D-05, D-08, D-09, D-13, D-16, D-17, D-21): write the tests first against the existing behaviour, fix any production bug they expose in its own commit, then extend the gate. + +Framework: taglist_field_test.go gains TestTaglistFieldValidate and TestTaglistFieldHydrate (table-driven, Docker-free, calling the unexported helpers in package cabana) per the behaviour list; reorder_flat_test.go gains TestReorderFlatNodes (checkReorderNodes with a flat compiledReorder); catalog_smoke_test.go adds a partitioned flat overlay subtest to TestReorderFlatSmoke only if partition is supported in flat mode per plan 01's SUMMARY. TaglistField.test.ts adds: a tag containing a script element renders as text, Backspace on an empty input removes the last chip, datalist options render, customTags false ignores a free tag, string mode with comma. ReorderView.test.ts adds keyboard ArrowUp/ArrowDown in flat mode and the drop zone mapping. cabana README Testing lists the new names. + +pgsub Docker-free: models/subscription_test.go TestSubscriptionChecks; models/subscription_plan_test.go TestPlanPrices (PriceCentsFor and StripePriceIDFor with upper and lower case keys and fallbacks, TierLevel for core, plus, max and other prefixes); models/feature_list_test.go TestFeatureList (Scan of a list, of a legacy map of truthy keys, of NULL and of invalid JSON as an error; Value of empty is []; HasFeature on plan and subscription); contracts/registry_test.go TestRegistry (duplicate alias and identifier errors name both plugin ids, unknown alias ErrUnknownSubscriberType naming the alias, ErrGatewayNotInstalled, registration order). lang_parity_test.go TestLangParity (Docker-free: en and pl have the same keys and :placeholders, and every golem15.paymentgatewaysubscriptions:: key used in YAML, Go and .htm files exists). + +pgsub Postgres (root package, existing harness): apply_events_test.go TestApplyEvents (the full matrix and the deleted and invoice cases) and TestClaimEventConcurrent; user_subscribable_test.go TestUserSubscribable; baseline_test.go TestBaselineSchema (golden constants transcribed from RESEARCH: table names, every column name with nullability, the twelve index names, the check and foreign-key names; plus idempotent re-run and the pre-existing users column); service_api_test.go TestServiceAPI (the D-13 behaviours in the must_haves, with a fake gateway that fails on demand and records whether it was called inside a transaction by checking the db handle); admin_actions_test.go TestAdminSyncActions; customer_api_authz_test.go TestCustomerAPIAuthz (the route by principal matrix, private-key absence, request redirect fields ignored, the none answer of plan 04's Task 3 reply). README Testing section lists every test name and how to run the Docker suites. + +Gate: extend check-phase18.1.sh required lists with every name above in its stage and package (go and admin and plugins and postgres). + + go vet ./... && go test -count=1 -short ./modules/cabana && npm --prefix admin test -- tests/form/TaglistField.test.ts tests/views/ReorderView.test.ts && go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && bash scripts/check-phase18.1.sh go && bash scripts/check-phase18.1.sh admin && bash scripts/check-phase18.1.sh --self-test + Non-zero exit: vet fails, a Docker-free cabana or pgsub test fails, a vitest spec fails, or the go or admin stage reports a required name failed, skipped or missing. + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestApplyEvents|TestClaimEventConcurrent|TestUserSubscribable|TestBaselineSchema|TestServiceAPI|TestAdminSyncActions|TestCustomerAPIAuthz)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 7 && OUT=$(go test -count=1 -run '^(TestTaglistFieldSmoke|TestReorderFlatSmoke|TestReorderPostgres)$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 3 + Non-zero exit: no Docker daemon (record NOT RUN), fewer than seven pgsub passes (an event pair, the concurrent claim, the schema golden list, a service rule, an admin action or an authorization cell failed), or fewer than three framework Postgres passes. + + + - `grep -c "func Test" ../sm-paymentgatewaysubscriptions-plugin/apply_events_test.go ../sm-paymentgatewaysubscriptions-plugin/baseline_test.go ../sm-paymentgatewaysubscriptions-plugin/customer_api_authz_test.go` reports at least 1 per file. + - `grep -n "TestBaselineSchema\|TestApplyEvents\|TestTaglistFieldValidate\|TestReorderFlatNodes" scripts/check-phase18.1.sh` prints at least four lines. + - `grep -n "## Testing" ../sm-paymentgatewaysubscriptions-plugin/README.md` prints one line. + - `grep -n "golem15_pgsub_subscription_plans_code_unique" ../sm-paymentgatewaysubscriptions-plugin/baseline_test.go` prints at least one line. + + Every framework and pgsub behaviour of this phase has a test that fails when the behaviour breaks, and the gate requires them by name. + + + + Task 3: pgstripe and layer coverage: decode, job (STRIPE-006/007, duplicate, concurrent), plan sync, gateway, PHP map and secrets; layer specs; the complete gate run, the validated VALIDATION map and README Testing sections + Tests and documentation only (plus any bug fix they expose, committed separately). + ../sm-pgstripe-plugin/subscriptions/decode_test.go, ../sm-pgstripe-plugin/subscriptions/job_test.go, ../sm-pgstripe-plugin/subscriptions/sync_plan_test.go, ../sm-pgstripe-plugin/subscriptions/gateway_test.go, ../sm-pgstripe-plugin/subscriptions/php_map_test.go, ../sm-pgstripe-plugin/subscription_secrets_test.go, ../sm-pgstripe-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/README.md, ../vue-paymentgateway-layer/tests/subscriptions.test.ts, ../vue-paymentgateway-layer/tests/subscribe.test.ts, ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts, ../vue-paymentgateway-layer/README.md, scripts/check-phase18.1.sh, .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md + ../sm-pgstripe-plugin/subscriptions (all files from plan 03), ../sm-pgstripe-plugin/settings_security_test.go (TestStripeDataHandling :34 for the STRIPE-001 style), ../sm-pgstripe-plugin/sdk_pin_test.go, ../sm-pgstripe-plugin/webhook_test.go (TestStripeAuthentication :148), PHP frozen at 8b47457: tests/reliability/ReliabilityTest.php, tests/security/DataHandlingTest.php, tests/security/AuthenticationTest.php, classes/StripeSubscriptionGateway.php at d00f850, ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts, subscribe.ts, the four Pg subscription components and the three spec files from plan 05, .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md, .planning/phases/18-payment-gateway/18-VALIDATION.md (a validated map's shape) + + - Decode: a basil invoice yields the subscription id from parent.subscription_details; a pre-basil invoice from the top level; a subscription without items periods takes the top-level ones; job args never contain an e-mail or address field even when the event carries them. + - Job: Apply returning an error leaves zero marker rows and Work returns the error; success leaves exactly one; an app-tag skip and an unhandled type leave one marker and fire nothing; the same args worked twice fire once; two concurrent Work calls fire once. + - Plan sync: free plan makes no call; unchanged amounts make no new price; a changed amount makes one new price with transfer_lookup_key and archives the old one; a price failure after the product step leaves stripe_product_id stored by SyncPlan. + - Gateway: success URL `https://a/x` becomes `https://a/x?session_id={CHECKOUT_SESSION_ID}` and `https://a/x?y=1` becomes `...&session_id=...`; metadata app is present only with metadata_app set; no owner key is ever sent. + - PHP map: STRIPE-006 marker after success and double processing; STRIPE-001 not plaintext, encrypted at rest, round trip for the two new columns; PAY-10 import of plaintext and Laravel-encrypted subscription secrets; PAY-09 valid signature accepted, invalid rejected on both URLs. + + Per SC-5, D-11, D-12, D-15, D-18, D-19 and D-21: tests first, bug fixes in their own commits, then the gate and the map. + +pgstripe subscriptions: decode_test.go TestDecodeSubscription, TestDecodeInvoice and TestJobArgsMinimal (Docker-free, using the plan 03 event builders); job_test.go TestSubscriptionJob (Postgres, Work called directly with a failure-injecting service wrapper or a fixture listener that errors on demand; concurrent subtest with two goroutines); sync_plan_test.go TestSyncPlan and gateway_test.go TestSubscriptionGateway (Postgres for settings, the package Stripe fake for calls, asserting recorded form parameters); php_map_test.go TestStripeSubscriptionReliability (subtests stripe_006_marker_after_job_success and stripe_006_double_processing_prevented) and TestSubscriptionAuthentication (pay_09_accepts_valid and pay_09_rejects_invalid on both URLs), each case commented with its PHP file, method and line. Root: subscription_secrets_test.go TestStripeSubscriptionSecrets (stripe_001_not_plaintext, stripe_001_encrypted_at_rest, stripe_001_round_trip for the two new columns; pay_10_import_plaintext_and_encrypted through --webhook-secret-target subscriptions); TestSDKPin stays the STRIPE-003 case (still one stripe-go major for root and subpackage). README Testing sections (root and subscriptions) list the names, the PHP map table with the two STRIPE-002 rows recorded as Phase 18 scope, and the Docker commands. + +Layer: extend the three spec files (names starting "subscriptions:") with any uncovered client path or error status, the manage flow 401 and 502 mappings, the plans currency prop choosing the matching price, the confirmed none answer, and a cancel button hidden while a cancel is scheduled; README Testing lists the spec files. + +Gate: extend check-phase18.1.sh required lists with every pgstripe name (plugins stage short names per package, postgres stage names per package) and raise the layers minimum to the number of "subscriptions:" tests now passing; run `bash scripts/check-phase18.1.sh --all` on a Docker host. + +VALIDATION: fill 18.1-VALIDATION.md's per-task map with every task of plans 01 to 06 (requirement, behaviour, type, automated command, file exists, status green or NOT RUN with the reason), tick the Wave 0 list, keep the manual-only rows (live test-mode checkout; portal cancel_at check of D-21), and set status validated, nyquist_compliant true and wave_0_complete true. + + go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && bash scripts/check-phase18.1.sh --all && grep -q "nyquist_compliant: true" .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md && grep -q "status: validated" .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md + Non-zero exit: vet or a Docker-free pgstripe test fails, a layer spec fails, any stage of the gate is red (or postgres is NOT RUN while PHASE181_REQUIRE_POSTGRES=1), or the VALIDATION frontmatter is not validated and Nyquist-compliant. + docker info >/dev/null 2>&1 && PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all + Non-zero exit: no Docker daemon (record NOT RUN and leave the affected VALIDATION rows NOT RUN), or a required Postgres test in any repository failed, skipped or did not run. + + + - `grep -c "stripe_006\|stripe_001\|pay_10\|pay_09" ../sm-pgstripe-plugin/subscriptions/php_map_test.go ../sm-pgstripe-plugin/subscription_secrets_test.go` reports at least 2 per file. + - `grep -n "TestSubscriptionJob\|TestSyncPlan\|TestStripeSubscriptionReliability\|TestStripeSubscriptionSecrets" scripts/check-phase18.1.sh` prints at least four lines. + - `grep -n "## Testing" ../sm-pgstripe-plugin/subscriptions/README.md ../vue-paymentgateway-layer/README.md` prints one line per file. + - `grep -c "STRIPE-002" ../sm-pgstripe-plugin/subscriptions/README.md` reports at least 1 (the out-of-scope rows are recorded). + + The Stripe half and the layer are covered, the PHP tests are mapped, the gate is green across four repositories, and the validation map says so. + + + + Task 4: Final push approval: unpushed commits of the four repositories, the held framework tag and its go.mod bumps, the pgsub remote and the layer tag + this phase's five plan SUMMARYs (each release reply: framework tag, pgsub remote and push, layer tag), .planning/phases/18-payment-gateway/18-07-SUMMARY.md (how Phase 18's final push ran), this plan's Task 3 SUMMARY notes (gate result, NOT RUN suites) + Do everything automatable, print the push sheet, then stop. For each repository (summercms.go, ../sm-paymentgatewaysubscriptions-plugin, ../sm-pgstripe-plugin, ../vue-paymentgateway-layer): clean tree (never stage another session's files), `git fetch origin --tags` where a remote exists, no commits on origin that the checkout lacks (otherwise stop and report), the commits that would be pushed. List what earlier checkpoints held: the framework tag (the highest v0.1.* plus one) if plan 01 held it; the pgsub remote creation (tea) and main if plan 02 held them; the layer v0.2.0 tag if plan 05 held it. State the order that keeps consumers resolvable: framework master and tag first; then in sm-paymentgatewaysubscriptions-plugin and sm-pgstripe-plugin a separate commit raising `require git.golem15.com/golem15/summercms` to that tag (replace lines unchanged; go mod tidy, vet and -short tests green); then the plugin pushes; then the layer. Record the gate result and NOT RUN suites. No push, tag, remote creation or require bump before the reply. After "push-all": execute that order, never force-push, record every pushed hash and tag. After "push-no-tags": push branches only (no framework tag, no require bump, no layer tag). After "hold": nothing. Record the reply verbatim. + Publish the phase now? + Tags are read by the Go module proxy and by applications' pinned layer dependency and cannot be moved; the plugin go.mod require must name a framework tag that contains the taglist and flat reorder before any application builds the subscriptions plugin from git (D-22: no push, tag or deploy without the go-ahead). + + + + + + Reply "push-all", "push-no-tags" or "hold". + + for r in . ../sm-paymentgatewaysubscriptions-plugin ../sm-pgstripe-plugin ../vue-paymentgateway-layer; do ST=$(git -C "$r" status --porcelain -- . ':!.planning' ':!.msd') || exit 1; test -z "$ST" || exit 1; done; bash scripts/check-phase18.1.sh --all && echo final-push-sheet-ok + Non-zero exit or "final-push-sheet-ok" absent: a repository has uncommitted non-planning changes, or the gate is red; changes of this phase are committed and a red gate is fixed before the sheet is presented, while another session's uncommitted files stop the checkpoint with a report (never staged or stashed). + + + - The SUMMARY records the sheet, the reply verbatim and, after "push-all", for every repository `git rev-parse main` (master for summercms.go) equal to the origin branch, the framework tag present on origin, the plugin go.mod require lines naming that tag, and v0.2.0 present on the layer's origin. + - After "hold", no `git ls-remote` shows a new tag and the SUMMARY lists every unpushed commit. + + The human decided how the phase is published and the record says exactly what was pushed and tagged. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| gate script to the developer | the gate's verdict decides whether verification starts | +| tests to external services | tests run against fakes and testcontainers only | +| local checkouts to remotes | the final push publishes code and tags | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-18.1-32 | Repudiation | check-phase18.1.sh false green | high | mitigate | required test names per stage and package; detectors fail on skip, zero tests, missing names, todo and not ok; --self-test proves each detector on planted output; NOT RUN postgres is loud and fatal with PHASE181_REQUIRE_POSTGRES=1 | +| T-18.1-33 | Information Disclosure | tests leaking real secrets or calling Stripe | medium | mitigate | every Stripe call goes to the package fakes through BackendOverride; secrets in tests are test-only literals; the secrets tests assert ciphertext, never print values | +| T-18.1-34 | Tampering | publishing red or unreviewed code | medium | mitigate | the final checkpoint runs the full gate first and pushes only after the human's reply, in an order that keeps go.mod requires resolvable; never force-push | +| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed by this plan; test helpers use node:test, vitest and Go testing already present | + + + +- Task 1: syntax, self-test, the go and layers stages; the postgres stage on a Docker host. +- Task 2: framework and pgsub Docker-free suites, vitest specs, the go and admin stages and the self-test; the pgsub and framework Postgres names on a Docker host. +- Task 3: pgstripe Docker-free suites, the layer suite, `--all`, the VALIDATION frontmatter; `PHASE181_REQUIRE_POSTGRES=1 --all` on a Docker host. +- Task 4: clean trees and a green gate before the human decides the publication. + + + +- SC-5: the new code of plans 01 to 05 has unit tests delivered in this last plan; the PHP test map is covered; `scripts/check-phase18.1.sh --all` is green (postgres run on a Docker host or recorded NOT RUN). +- 18.1-VALIDATION.md is validated and Nyquist-compliant. +- The human decided the final push and tags (D-22). + + + +Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-06-SUMMARY.md` when done, with the commits per repository, every test added, any bug fixed, the gate output summary, the VALIDATION status, the push reply and every pushed hash and tag. + diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md b/.planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md new file mode 100644 index 0000000..8e03d00 --- /dev/null +++ b/.planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md @@ -0,0 +1,42 @@ +# API Coverage — Stripe (subscriptions) + +> Full coverage by default. Opt-outs are explicit, reasoned decisions. + +Scope: the Stripe Billing surface the frozen PHP `SubscriptionGateway` contract touches (pgsub `a522376` = `ac3d73c` + `5fb9dac`; pgstripe `classes/StripeSubscriptionGateway.php` at `d00f850`, every other pgstripe file at `8b47457`, per D-01 and D-15) and its direct neighbours. Implemented in `sm-pgstripe-plugin/subscriptions` (plugin `golem15.pgstripesubscriptions`, plan 18.1-03) on stripe-go v87 (pinned API version `2026-09-30.endive`); the webhook reads both pre-basil and basil payload shapes (D-21). Payment-mode Checkout and PaymentIntents are Phase 18 (`COVERAGE.md` of phase 18) and not repeated here. + +| capability | decision | reason | +|---|---|---| +| customers.create with subscriber metadata | INTEGRATE | e-mail, name, subscriber_type and subscriber_id metadata | +| customers.retrieve | OPT-OUT | not in the frozen PHP contract: the stored customer id is trusted, as PHP did | +| customers.update (sync e-mail or name changes) | OPT-OUT | not in the frozen PHP contract | +| checkout.sessions.create (mode subscription) | INTEGRATE | success URL carries the session_id placeholder; subscription_data.metadata | +| checkout.sessions.retrieve (after the success redirect) | OPT-OUT | not in the frozen PHP contract: activation comes from the signed customer.subscription.created event | +| billing_portal.sessions.create | INTEGRATE | | +| billing_portal.configurations (create or update) | OPT-OUT | not in the frozen PHP contract: the portal is configured per account in the Stripe dashboard | +| subscriptions.retrieve (syncSubscription / reconcile) | INTEGRATE | | +| subscriptions.update cancel_at_period_end true (cancel at period end) | INTEGRATE | | +| subscriptions.cancel (cancel immediately) | INTEGRATE | | +| subscriptions.update cancel_at (flexible-billing cancel enums) | OPT-OUT | D-21 keeps PHP's cancel_at_period_end mapping; a manual Stripe test-mode check at UAT decides a follow-up | +| subscriptions.update (resume or un-cancel through the API) | OPT-OUT | not in the frozen PHP contract: reactivation happens in the billing portal and arrives as customer.subscription.updated | +| subscriptions.update (plan change, proration) | OPT-OUT | not in the frozen PHP contract: plan changes go through the billing portal | +| subscriptions.list / subscriptions.search | OPT-OUT | not in the frozen PHP contract: reconcile walks local rows and retrieves each | +| products.create (plan_code and tier_level metadata) | INTEGRATE | | +| products.update | INTEGRATE | | +| products archive or delete | OPT-OUT | not in the frozen PHP contract: deactivating a plan is the local is_active flag | +| prices.list by lookup_keys with active true | INTEGRATE | replaces PHP's prices.search with the same outcome (plan 18.1-03 decision, RESEARCH A2) | +| prices.search | OPT-OUT | replaced by prices.list with lookup_keys, which is not eventually consistent; same result as PHP | +| prices.create (recurring, lookup_key _, transfer_lookup_key) | INTEGRATE | | +| prices.update active false (archive the replaced price) | INTEGRATE | | +| invoices.retrieve / invoices.list | OPT-OUT | not in the frozen PHP contract: invoice facts arrive in invoice.paid and invoice.payment_failed events | +| webhook signature verification (Stripe-Signature) | INTEGRATE | raw body, per-mode subscription signing secret | +| event customer.subscription.created | INTEGRATE | | +| event customer.subscription.updated | INTEGRATE | | +| event customer.subscription.deleted | INTEGRATE | | +| event invoice.paid | INTEGRATE | basil parent.subscription_details and pre-basil top-level subscription | +| event invoice.payment_failed | INTEGRATE | | +| event customer.subscription.trial_will_end | OPT-OUT | not handled by the frozen PHP job: answered 200 Webhook received with no job | +| event customer.subscription.paused / customer.subscription.resumed | OPT-OUT | not handled by the frozen PHP job: the status change also arrives as customer.subscription.updated | +| event customer.subscription.pending_update_applied / pending_update_expired | OPT-OUT | not handled by the frozen PHP job (no pending updates are created) | +| event invoice.upcoming / invoice.finalized / invoice.payment_action_required | OPT-OUT | not handled by the frozen PHP job: payment actions run on Stripe's hosted pages | +| event checkout.session.completed on the subscriptions endpoint | OPT-OUT | not handled by the frozen PHP job: the row is created from customer.subscription.created; payment-mode sessions belong to the Phase 18 operator endpoint | +| billing meters, meter events and usage records | OPT-OUT | explicitly out of scope: lead fees and metering move to the Quotify port (D-02, deferred) |