docs(18.1): revise plans per checker feedback
Mark RESEARCH open questions and premise shifts resolved by D-15..D-22 and flag the superseded recommendations (S-6, S-7, Pattern 9, five-plan split). Split oversized tasks inside plans 01, 02, 04 and 06 (plan count unchanged per D-22) and record the per-plan trade-off for plans 04 and 06.
This commit is contained in:
@@ -27,11 +27,11 @@ files_modified:
|
||||
- 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/ReorderBranch.vue
|
||||
- admin/src/components/reorder/tree.ts
|
||||
- admin/tests/form/TaglistField.test.ts
|
||||
- admin/tests/views/ReorderView.test.ts
|
||||
@@ -43,7 +43,7 @@ requirements: [SC-3, D-17, D-22]
|
||||
estimate:
|
||||
tokens: 200000
|
||||
raw_tokens: 200000
|
||||
tasks: 3
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
@@ -52,7 +52,7 @@ must_haves:
|
||||
- "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."
|
||||
- "D-22 (release gate): nothing is pushed or tagged before the human's reply at the Task 5 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"
|
||||
@@ -99,7 +99,7 @@ must_haves:
|
||||
|
||||
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).
|
||||
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 5).
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
@@ -116,7 +116,7 @@ No SPEC.md and no REQUIREMENTS IDs exist for this phase: the orchestrator record
|
||||
|
||||
- `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`.
|
||||
- Admin SPA: `admin/src/components/form/fields/TaglistField.vue`, renderers entry `taglist`, PreviewField taglist rendering, ReorderView/ReorderBranch/tree.ts flat mode (`admin/src/api/types.ts` aliases the generated schema and needs no edit); 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.
|
||||
@@ -128,7 +128,7 @@ Give cabana the two generic admin pieces D-17 asks for, framework first: a core
|
||||
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.
|
||||
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). Task sizing (plan-checker revision): the former 25-file taglist task is split into the array-mode tracer (Task 1) and the string-mode, customTags and save-site expansion (Task 2, 14 files), and the former 17-file flat-reorder task into its server half (Task 3, 12 files) and its SPA half (Task 4, 5 files). The largest task, the Task 1 tracer, lists 20 files, of which 9 are authored Go, Vue or TS files; the other 11 are three script-regenerated artifacts (admin.json, schema.d.ts, the dist), four fixture YAML files, two phrase files and two docs files. The plan has five tasks, one of them the release checkpoint without implementation; the plan count stays six (D-22). 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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@@ -142,7 +142,7 @@ Execution budget: this plan is over the single-context smart zone by design (the
|
||||
@.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.
|
||||
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/src` code ends with `npm --prefix admin run build`, the committed `modules/boardwalk/dist` and `bash scripts/check-admin-dist.sh`; Task 3 only regenerates `admin/openapi/admin.json` and the type-only `admin/src/api/schema.d.ts`, which no bundle imports, so its verify runs `check-admin-dist.sh` to prove the dist is unchanged. 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.
|
||||
|
||||
<interfaces>
|
||||
Existing seams (read; extend, do not fork):
|
||||
@@ -159,34 +159,34 @@ Existing seams (read; extend, do not fork):
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>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</name>
|
||||
<name>Task 1: Tracer: a `taglist` field in `mode: array` 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</name>
|
||||
<reversibility rating="costly">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.</reversibility>
|
||||
<files>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</files>
|
||||
<read_first>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</read_first>
|
||||
<action>Per D-17, wire one taglist field end to end, mirroring how Phase 18 added money.
|
||||
<files>modules/cabana/field_taglist.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/crud.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/tests/form/TaglistField.test.ts, modules/boardwalk/dist, docs/backend/forms.md</files>
|
||||
<read_first>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: the list value path and the hydration this task reuses), 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/lagoon/jsonable.go (Jsonable Scan/Value), 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/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, 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</read_first>
|
||||
<action>Per D-17, wire one taglist field in array mode end to end, mirroring how Phase 18 added money. Task 2 adds the string-mode save, the customTags enforcement, the relation-form and settings save sites, the repeater refusal and the preview rendering.
|
||||
|
||||
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.
|
||||
cabana compile (field_taglist.go, form_schema.go, schema_types.go): the whole key gate lands in this task for both modes, so the schema shape is fixed once. 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; Task 2 adds relation 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)".
|
||||
|
||||
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.
|
||||
Save (field_taglist.go; call checkTaglistValues next to checkMoneyValues in crud.go create and update; Task 2 adds the relation_child.go and settings.go call sites): 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. normaliseTags trims each tag, drops empties and removes duplicates keeping the first occurrence. The normalised list replaces the body value before the scalar projection as its JSON text (an empty list serialises as `[]`, never null), handed to the column's Scanner the same way a repeater list reaches a lagoon.Jsonable column. hydrateTaglistFields (run where hydrateRepeaterFields runs) returns array-mode values as a JSON list (stored NULL or unparsable text becomes an empty list). A string-mode field compiles in this task and its value takes the existing string path unchanged until Task 2.
|
||||
|
||||
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").
|
||||
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.add ("Add a tag" / "Dodaj tag"), taglist.remove ("Remove :tag" / "Usuń :tag"). Task 2 adds taglist.not_allowed.
|
||||
|
||||
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.
|
||||
OpenAPI: FormField.Type is a free string in the swag model, so the two new FormField properties reach the document through the swag scan of schema_types.go and admin_openapi.go needs no change; 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`.
|
||||
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 a comma, ignores empty or duplicate text, and offers field.options through a datalist when present. It emits a string array. 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. 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"]; 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 `[]`.
|
||||
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]; 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; Keywords is declared now so Task 2 only adds YAML). 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, 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; 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.</action>
|
||||
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" (the keys, array mode and its wire value, normalisation, the invalid 422, the column type rule, an acme example; Task 2 adds string mode and customTags); cabana README Features bullet "Tag lists" and the API reference rows for FormField.Separator and FormField.CustomTags, in the same commit as the code.</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<fails_when>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 remove emitted the wrong list); the committed dist differs from a fresh build; the docs checker reports an unknown identifier or a broken anchor.</fails_when>
|
||||
<automated>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'</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the stored tags are not exactly ["alpha","beta"], null did not store [], or a string or a number list was not answered 422 taglist.invalid.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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:" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` prints one line 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).
|
||||
@@ -195,24 +195,55 @@ Docs: docs/backend/forms.md gains a taglist row in the Field types table, the ne
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Flat reorder: Sortable inference in compile, flat save, the `flat` flag, the SPA flat mode, docs and dist</name>
|
||||
<name>Task 2: Taglist breadth: string mode with its separator, customTags enforcement, the relation-form and settings save sites, the repeater refusal and the read-only preview</name>
|
||||
<reversibility rating="costly">D-17: the string-mode wire shape (tags joined by the separator) and the not_allowed rule complete the field contract that plan 04 and later plugin ports rely on.</reversibility>
|
||||
<files>modules/cabana/field_taglist.go, modules/cabana/relation_child.go, modules/cabana/settings.go, modules/cabana/field_repeater.go, modules/cabana/testdata/catalog/models/badge/fields.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/src/components/form/fields/TaglistField.vue, admin/src/components/form/PreviewField.vue, admin/tests/form/TaglistField.test.ts, modules/boardwalk/dist, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/field_taglist.go, modules/cabana/taglist_field_test.go, modules/cabana/catalog_smoke_test.go (all from Task 1), modules/cabana/relation_child.go (the checkMoneyValues call :103), modules/cabana/settings.go (the checkMoneyValues call and the settings type allow-list), modules/cabana/field_repeater.go (compileRepeater nested type checks, coerceRowValue :302), admin/src/components/form/fields/TaglistField.vue and admin/tests/form/TaglistField.test.ts (Task 1), admin/src/components/form/PreviewField.vue (ownControl), WinterCMS ../examples/golem15-wintercms-starter/modules/backend/formwidgets/TagList.php when present (getSaveValue, separator characters), docs/backend/forms.md (## Taglist from Task 1)</read_first>
|
||||
<action>Per D-17, complete the taglist contract Task 1 opened.
|
||||
|
||||
String mode (field_taglist.go): the submitted value is null or a string, else 422 backend::lang.taglist.invalid on the field; it is split on the separator character (a comma for `comma`, whitespace for `space`), normalised by normaliseTags, and stored joined with `,` (comma) or a single space (space), as WinterCMS getSaveValue does; null stores an empty string; hydrateTaglistFields returns the stored string unchanged.
|
||||
|
||||
customTags (field_taglist.go): with customTags false a tag not in options is a 422 backend::lang.taglist.not_allowed with the :tag placeholder, in both modes. Phrase key en and pl in the `taglist:` block: taglist.not_allowed ("The tag :tag is not allowed." / "Tag :tag jest niedozwolony.").
|
||||
|
||||
Save sites: call checkTaglistValues next to checkMoneyValues in relation_child.go and settings.go, and run checkTaglistType for relation forms and settings forms (add taglist to the settings type allow-list); compileRepeater (field_repeater.go) refuses taglist as a nested field type at boot naming the field.
|
||||
|
||||
SPA: TaglistField.vue gains string mode (a loaded string is split on the separator; it emits the tags joined by `,` or a single space; typing the separator character adds the tag) and customTags false (only option values are added). PreviewField renders a taglist as the comma-joined tags (add taglist to ownControl), through text interpolation only. TaglistField.test.ts adds: string mode with separator space loads "x y" as two chips and emits "x y z"; customTags false refuses a non-option tag; a read-only preview renders the joined tags as text. Rebuild the admin, commit modules/boardwalk/dist and run `bash scripts/check-admin-dist.sh`.
|
||||
|
||||
Fixture and tests: the badge fields.yaml gains keywords (taglist mode string separator space) and sets customTags false on tags (its options alpha, beta and gamma keep Task 1's assertions valid). TestTaglistFieldCompile gains: a taglist inside a repeater fails boot naming the field; a settings form with a taglist compiles. TestTaglistFieldSmoke gains: keywords "x y x" stores "x y" and the GET returns "x y"; keywords null stores ""; tags ["delta"] is 422 taglist.not_allowed naming delta.
|
||||
|
||||
Docs: the ## Taglist section of docs/backend/forms.md gains string mode and its wire value, the separator key, customTags and the not_allowed 422, in the same commit as the code.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && OUT=$(go test -count=1 -short -run '^TestTaglistFieldCompile$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -q -- '^--- PASS: TestTaglistFieldCompile' && go test -count=1 -short ./modules/cabana && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/TaglistField.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit at any step: vet fails; "--- PASS: TestTaglistFieldCompile" is absent (the repeater refusal or the settings compile subtest failed); a Docker-free cabana test regressed (an existing money, repeater, relation or settings case); OpenAPI drift (this task must not change the schema); typecheck failure; a TaglistField vitest failed (string mode split or joined wrongly, a non-option tag was added with customTags false, or the preview did not render the tags as text); dist drift; docs checker failure.</fails_when>
|
||||
<automated>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'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or keywords did not normalise to "x y", null did not store "", a non-option tag was not answered 422 taglist.not_allowed, or a Task 1 assertion regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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 -c "checkTaglistValues" modules/cabana/crud.go modules/cabana/relation_child.go modules/cabana/settings.go` reports at least 1 per file.
|
||||
- `grep -n "taglist" admin/src/components/form/PreviewField.vue` prints at least one line and `grep -c "v-html" admin/src/components/form/fields/TaglistField.vue` prints 0 (the preview's text rendering is asserted by the vitest case above).
|
||||
- `grep -n "customTags" docs/backend/forms.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>A tag list also works as a separator-joined string column, refuses tags outside a closed option list, saves the same way on relation and settings forms, and shows read-only as plain text.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Flat reorder on the server: Sortable inference in compile, the flat save, the `flat` flag on the list schema and the reorder data, OpenAPI, fixture and docs</name>
|
||||
<reversibility rating="costly">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.</reversibility>
|
||||
<files>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</files>
|
||||
<read_first>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)</read_first>
|
||||
<action>Per D-17, add the flat (Sortable) reorder to the existing reorder controller.
|
||||
<files>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, docs/backend/lists-and-filters.md</files>
|
||||
<read_first>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), modules/phrasebook/backend/lang/en/lang.yaml (the reorder: block), 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)</read_first>
|
||||
<action>Per D-17, add the flat (Sortable) reorder to the existing reorder controller on the server. Task 4 teaches the SPA the flat mode; until then the existing tree view loads a flat reorder and its up, down and drag moves already save (indent and outdent answer the flat_parent 422).
|
||||
|
||||
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`.
|
||||
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.", added to the existing reorder: block in en and pl) 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`.
|
||||
|
||||
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.</action>
|
||||
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 as the code.</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<automated>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 && bash scripts/check-admin-dist.sh && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit 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; the SPA no longer typechecks against the regenerated types; the dist changed although only type files were regenerated; docs checker failure.</fails_when>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
</verify>
|
||||
@@ -221,21 +252,45 @@ Docs: docs/backend/lists-and-filters.md gains "## Flat reorder" after "## Tree r
|
||||
- `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.
|
||||
- `grep -n "^## Flat reorder" docs/backend/lists-and-filters.md` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
<done>A record without a parent column gets a working order endpoint from a WinterCMS-shaped config_reorder.yaml, and existing tree reorders answer exactly as before.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Flat reorder in the SPA: no indent, outdent or inside drop in flat mode, parent_id null on save, the rebuilt dist, and the framework-wide check before the release gate</name>
|
||||
<reversibility rating="reversible">Admin view behaviour behind the flag Task 3 published; no stored data or wire contract changes.</reversibility>
|
||||
<files>admin/src/views/ReorderView.vue, admin/src/components/reorder/ReorderBranch.vue, admin/src/components/reorder/tree.ts, admin/tests/views/ReorderView.test.ts, modules/boardwalk/dist</files>
|
||||
<read_first>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/ReorderBranch.vue (props :11 and its own moves list :18-22 with the outdent and indent buttons), admin/src/components/reorder/tree.ts (Zone, Move, buildTree, flatten), admin/src/api/types.ts (:76 and :86: AdminReorderTree and ListReorder alias the generated schema, so the flag arrives without an edit), admin/tests/views/ReorderView.test.ts (existing specs and fixtures), modules/cabana/reorder.go (AdminReorderTree.Flat from Task 3)</read_first>
|
||||
<action>Per D-17, teach the reorder view the flat mode Task 3 publishes.
|
||||
|
||||
SPA: ReorderView reads `flat` from the GET data (the AdminReorderTree alias in types.ts carries it from the regenerated schema). In flat mode ReorderBranch receives a `flat` prop and does not render its outdent and indent buttons (move up and move down stay), 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 (the prop defaults to false). 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 (`npm --prefix admin run build`), commit modules/boardwalk/dist and run `bash scripts/check-admin-dist.sh`.
|
||||
|
||||
Framework-wide check: before the task commit run the second verify (the suite the Task 5 release gate presents) and fix anything red in the task that owns it, so the release sheet starts from a green tree.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/views/ReorderView.test.ts && bash scripts/check-admin-dist.sh</automated>
|
||||
<fails_when>Non-zero exit: typecheck failure; a ReorderView spec failed (indent or outdent rendered in flat mode, a parent id was sent, ArrowRight moved a row, a middle drop landed inside, or a tree case broke); the committed dist differs from a fresh build.</fails_when>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp)$' ./cmd/summer && go run ./cmd/summer docs:build --check && npm --prefix admin test && bash scripts/check-admin-openapi.sh --check && echo framework-check-ok</automated>
|
||||
<fails_when>Non-zero exit or "framework-check-ok" absent: after the four tasks a framework suite, an inventory, the docs check, the full vitest run or the OpenAPI check is red.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "flat" admin/src/views/ReorderView.vue admin/src/components/reorder/ReorderBranch.vue` prints at least one line per file.
|
||||
- `grep -c "flat" admin/tests/views/ReorderView.test.ts` reports at least 1.
|
||||
- `git diff --quiet HEAD -- admin/src/api/types.ts` exits 0 at the task commit (the aliases needed no edit).
|
||||
</acceptance_criteria>
|
||||
<done>An administrator drags a flat list into order without tree controls that cannot apply, and the whole framework is green before the release decision.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 3: Release gate: local checks green, tag chosen, one-way surfaces listed, a human approves the framework tag and the push</name>
|
||||
<read_first>.planning/phases/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)</read_first>
|
||||
<name>Task 5: Release gate: local checks green, tag chosen, one-way surfaces listed, a human approves the framework tag and the push</name>
|
||||
<read_first>.planning/phases/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 to 4 SUMMARY notes (commits, Docker smoke tests run or NOT RUN)</read_first>
|
||||
<action>Do everything automatable, print a release sheet, then stop.
|
||||
|
||||
Concurrency: in summercms.go `git status --porcelain -- modules admin docs scripts cmd go.mod go.sum` prints nothing, `git fetch origin --tags`, and `git log --oneline HEAD..origin/master` prints nothing; if origin has commits this checkout lacks, stop and report (never merge or rebase on your own).
|
||||
|
||||
Tag: propose the highest of `git tag -l 'v0.1.*' --sort=-v:refname` plus one (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.</action>
|
||||
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 to 4; 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.</action>
|
||||
<decision>Push summercms master and the proposed annotated framework tag now?</decision>
|
||||
<context>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.</context>
|
||||
<options>
|
||||
@@ -290,9 +345,11 @@ Run the gate (verify). Release sheet: the proposed tag; the one-way surfaces it
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- 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.
|
||||
- Task 1: Docker-free taglist compile subtests, route inventories, OpenAPI drift, typecheck, TaglistField vitest, dist, docs; the array-mode taglist save smoke on a Docker host.
|
||||
- Task 2: the compile test with the repeater and settings subtests, the whole Docker-free cabana suite, no OpenAPI drift, typecheck, TaglistField vitest (string mode, customTags, preview), dist, docs; the extended taglist save smoke on a Docker host.
|
||||
- Task 3: Docker-free flat and tree reorder compile tests, the whole Docker-free cabana suite, OpenAPI drift, typecheck, docs; the flat and tree Postgres reorder tests on a Docker host.
|
||||
- Task 4: typecheck, ReorderView vitest, dist; then the framework-wide suite (go vet, go test -short ./..., four inventories, docs, full vitest, OpenAPI).
|
||||
- Task 5: 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 -- <the task's spec files>` (under 120 s); the full `<automated>` chains, which include a fresh admin build, are task-close gates.
|
||||
</verification>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ requirements: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-05, D-06, D-08, D-09,
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 4
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
@@ -54,7 +54,7 @@ must_haves:
|
||||
- "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."
|
||||
- "D-22: nothing is pushed and no remote is created before the human's reply at the Task 5 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"
|
||||
@@ -107,7 +107,7 @@ must_haves:
|
||||
|
||||
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.
|
||||
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: the gateway-neutral `Apply` stores a subscription, the `activated` event fires once and the access check answers true; the next task adds the registries and a `user` subscriber checking out through a fake gateway. 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
|
||||
|
||||
@@ -123,13 +123,13 @@ Skipped visibly: the phase has no requirement IDs to probe, so no probe predicat
|
||||
|
||||
## 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"`.
|
||||
- Repository `../sm-paymentgatewaysubscriptions-plugin` (branch main; remote created only on the Task 5 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()`.
|
||||
- `classes`: `Service` (methods listed in must_haves), `DefaultGracePeriodDays`, `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).
|
||||
- Tests: `TestSubscriptionsTracer`, `TestSubscriptionsCheckout`, `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).
|
||||
|
||||
<objective>
|
||||
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.
|
||||
@@ -137,7 +137,7 @@ Create the pgsub core: the repository, the v2.4.0 baseline, models with their ch
|
||||
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.
|
||||
Execution budget: over the single-context smart zone by design (D-22 split). Task sizing (plan-checker revision): the former 29-file tracer is split into a thinner tracer (Task 2: plugin, baseline, models, Apply, events, access check; 19 files) and a registries and checkout slice (Task 3, 13 files), and config and copy moved into the core completion (Task 4, 15 files). Of the tracer's 19 files, 6 are repository scaffolding copied from the Phase 18 plugins or generated (.gitignore, go.mod, go.sum, go.work, testmain_test.go, updates/registry.go) and 1 is the README skeleton. The plan has five tasks, two of them checkpoints without implementation; the plan count stays six (D-22). Commit per task, write SUMMARY notes as you go, resume at the first uncommitted task after a reset.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@@ -184,7 +184,7 @@ Analogs to copy (read, do not modify):
|
||||
<cons>Diverges from the lagoon.Jsonable TEXT convention and the Phase 18 baselines; Scan receives a different driver type; the import must re-encode.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "baseline-research" or "baseline-jsonb"; record the reply verbatim in the SUMMARY and write Task 2's migration accordingly.</resume-signal>
|
||||
<resume-signal>Reply "baseline-research" or "baseline-jsonb"; record the reply verbatim in the SUMMARY and write Task 2's baseline and Task 3's users migration accordingly.</resume-signal>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
@@ -196,36 +196,34 @@ Analogs to copy (read, do not modify):
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>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</name>
|
||||
<reversibility rating="one-way">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.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>../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)</read_first>
|
||||
<action>Per D-06, create the repository and the thinnest real path through every layer.
|
||||
<name>Task 2: Tracer: in a new repository the plugin boots, migrates the confirmed baseline, and the gateway-neutral Apply stores an active subscription, fires `activated` once with Tx and grants access, on Postgres</name>
|
||||
<reversibility rating="one-way">D-04/D-16 confirmed at Task 1: the migration ID and table shapes written here become every application's schema.</reversibility>
|
||||
<files>../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/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.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/updates/registry.go, ../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.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</files>
|
||||
<read_first>../sm-paymentgateway-plugin/plugin.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), 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,models/Subscription.php,models/SubscriptionPlan.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 and 5; § v2.4.0 Schema Baseline; § Event and State Mapping)</read_first>
|
||||
<action>Per D-06 and D-04/D-16, create the repository and the thinnest real path through the core's layers: plugin, migration, models, Apply, events and the access check. Task 3 adds the registries, the Gateway contract, the built-in `user` subscriber and checkout; Task 4 completes the models, the event diff, invoices, the claim, the service API, config and copy.
|
||||
|
||||
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.
|
||||
Plugin (plugin.go): package pgsub, `PluginID = "golem15.paymentgatewaysubscriptions"`, Requires golem15.paymentgateway and golem15.user (D-06), Migrations returns updates.All(), Models returns models.All(), Boot builds classes.Service over the app (db from lagoon, festival bus, grace days classes.DefaultGracePeriodDays (7) until Task 4 reads the config, clock time.Now) and publishes it with app.Publish; init() runs party.Register. Config and lang are embedded in Task 4.
|
||||
|
||||
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.
|
||||
Contracts (contracts/gateway.go, this task): 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). Task 3 adds the Gateway interface, CheckoutOptions and CheckoutSession to the same file. 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.
|
||||
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; 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); models.All() in models/registry.go. This task needs only these fields and IsActive/HasAccess; Task 4 maps features (FeatureList), adds ProcessedWebhookEvent and the remaining checks.
|
||||
|
||||
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).
|
||||
Migration (updates): updates/registry.go as the Phase 18 plugins (Register, All, run); 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). Task 3 adds 202610150002.
|
||||
|
||||
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).
|
||||
Service (classes): Service with its constructor, DefaultGracePeriodDays, Latest(ctx, alias, id) (ForSubscriber, created_at desc then id desc, Plan preloaded, nil when none) and 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 4 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). The registry check of the subscriber alias arrives with the registries in Task 3.
|
||||
|
||||
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 and bus helpers; fixture_plugin_test.go declares test-only plugin acme.members (Requires golem15.paymentgatewaysubscriptions) that records the six events through festival listeners (Task 3 gives it a subscribable and a fake gateway). tracer_test.go TestSubscriptionsTracer (Postgres): create a plan (code premium, price_cents 2999, currency pln, stripe_price_id price_premium); in one transaction Apply(Update{Gateway stripe, SubscriptionID sub_1, CustomerID cus_test_1, Status active, PriceID price_premium, SubscriberType user, SubscriberID 1, period end in 30 days}) stores one row with plan premium; exactly one SubscriptionActivated was recorded with Tx non-nil; Latest("user", 1) returns it and HasAccess is true.
|
||||
|
||||
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.</action>
|
||||
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 events and the service; it names no consuming application.</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<automated>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'</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the tracer failed: the baseline did not migrate, Apply stored no row or the wrong plan, activated fired zero or two times or without Tx, or HasAccess was false.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
@@ -234,32 +232,67 @@ README skeleton in the Phase 18 plugin README structure (H1 `golem15.paymentgate
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
<done>A gateway-neutral state change stores a subscription that grants access and fires the activated event once, in a new repository that contains no Stripe code.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>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</name>
|
||||
<name>Task 3: Registries, the Gateway contract and the built-in `user` subscriber: a user checks out through a registered gateway and the customer id lands on users.stripe_customer_id</name>
|
||||
<reversibility rating="costly">D-09: a column on the shared users table (migration ID confirmed at Task 1); D-08: the registry capability interfaces are what every subscriber-owning and gateway plugin implements.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/boot.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/registry.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/202610150002_add_stripe_customer_id_to_users.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go, ../sm-paymentgatewaysubscriptions-plugin/tracer_test.go</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/plugin.go, classes/service.go, classes/apply.go, contracts/gateway.go, fixture_plugin_test.go and tracer_test.go (all from Task 2), ../sm-paymentgateway-plugin/boot.go (`registerBootStep`/`runBootSteps` :20-53, `bootOperators` :96-120 walking `party.Activated` and `app.Publish`), ../sm-paymentgateway-plugin/operators/registry.go (:10-55 sentinel errors, RWMutex, ordered keys, `Add(pluginID, x)` failing with `"%w: %q is published by %s and %s"`), ../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), PHP (frozen): /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/{contracts/SubscriptionGateway.php,contracts/Subscribable.php}, /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/classes/SubscribableUserAdapter.php (read-only), .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Patterns 1, 2, 7; § Horoskopia Consumer to Go Service API)</read_first>
|
||||
<action>Per D-08 and D-09, add the build-time registries and the checkout path.
|
||||
|
||||
Boot (boot.go, plugin.go): Boot runs steps as sm-paymentgateway-plugin does (registerBootStep/runBootSteps): (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 with the registries (the construction moves from plugin.go into this step) 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 gains 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} and CheckoutSession{URL, SessionID}. contracts/registry.go: Subscribers and Gateways with Add(pluginID, x), lookup returning ErrUnknownSubscriberType (alias in the message) or ErrGatewayNotInstalled, Aliases()/Identifiers() in registration order, ErrDuplicateAlias and ErrDuplicateGateway, and the two Has* capability interfaces. Every exported identifier has a doc comment.
|
||||
|
||||
Service and Apply (classes): Subscriber(ctx, alias, id) (registry lookup, D-08: an 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), Gateway(identifier) and Gateways(). Apply now resolves a non-empty subscriber_type through the Subscribers registry and returns ErrUnknownSubscriberType naming the alias instead of storing the row (D-08: fail loudly, never skip).
|
||||
|
||||
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 writes nothing else.
|
||||
|
||||
Migration: 202610150002_add_stripe_customer_id_to_users per D-09 (ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255) NULL, CREATE INDEX IF NOT EXISTS users_stripe_customer_id_index; Rollback returns nil with a comment explaining the shared column), registered after the baseline in updates/registry.go.
|
||||
|
||||
Tests: fixture_plugin_test.go's acme.members returns classes.UserSubscribable() from Subscribables() and a fakeGateway (identifier stripe, recording calls in order, returning cus_test_1, a checkout URL and a session id) from SubscriptionGateways(). tracer_test.go gains TestSubscriptionsCheckout (Postgres): create a user and the premium plan; Subscriber("user", id) then Checkout returns the fake URL and session id, users.stripe_customer_id equals cus_test_1, and the fake recorded GetOrCreateCustomer before CreateCheckoutSession; Subscriber("family", 1) returns ErrUnknownSubscriberType naming family; Apply with SubscriberType family returns that error and stores nothing. TestSubscriptionsTracer keeps passing (the fixture registers user).
|
||||
|
||||
README: the registries, the Gateway contract, registering a subscribable and a gateway, the built-in user subscribable and its column, and the three-call checkout sequence (resolve the subscriber, find the plan, Checkout).</action>
|
||||
<verify>
|
||||
<automated>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'</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suite fails, or a stripe-go package entered the dependency list.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestSubscriptionsTracer|TestSubscriptionsCheckout)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestSubscriptions' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: the users migration did not run, the customer id was not stored on users.stripe_customer_id, the gateway calls came in the wrong order, an unknown alias was accepted or stored, or the tracer regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "ErrUnknownSubscriberType" ../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go` prints at least one line and `grep -n "HasSubscriptionGateways" ../sm-paymentgatewaysubscriptions-plugin/boot.go` prints at least one line.
|
||||
- `grep -n "SyncPlanToGateway" ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go` prints at least one line.
|
||||
- `grep -c "IF NOT EXISTS" ../sm-paymentgatewaysubscriptions-plugin/updates/202610150002_add_stripe_customer_id_to_users.go` prints at least 2.
|
||||
- `grep -v '^[[:space:]]*//' ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go | grep -c '\.Save('` prints 0 (no whole-row save of a user).
|
||||
</acceptance_criteria>
|
||||
<done>Plugins register subscribers and gateways at build time, a user checks out through the registered gateway with the customer id stored on their row, and an unknown subscriber type fails loudly.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The complete gateway-neutral core: model checks, scopes, features, the full event diff, invoices, the processed-event claim, the remaining service API, config, copy and the smoke test</name>
|
||||
<reversibility rating="costly">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.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>../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)</read_first>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/models/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go, ../sm-paymentgatewaysubscriptions-plugin/models/feature_list.go, ../sm-paymentgatewaysubscriptions-plugin/models/processed_webhook_event.go, ../sm-paymentgatewaysubscriptions-plugin/models/registry.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../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/README.md, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go, ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go</files>
|
||||
<read_first>../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, ../sm-paymentgatewaysubscriptions-plugin/boot.go (all from Tasks 2 and 3), 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), lang/en/lang.php, 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), ../sm-paymentgateway-plugin/plugin.go (config and lang embeds) and 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)</read_first>
|
||||
<action>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).
|
||||
Models: ProcessedWebhookEvent (event_id, event_type, processed_at) and FeatureList (named []string with Scan accepting a JSON list or a legacy map of truthy keys, and Value writing a JSON list, `[]` when empty) mapped to the features column (D-03), both registered in models.All(). 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.
|
||||
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). 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).
|
||||
Config and copy: config/config.yaml `grace_period_days: 7` with a header comment naming SUMMER_GOLEM15__PAYMENTGATEWAYSUBSCRIPTIONS__GRACE_PERIOD_DAYS; plugin.go embeds config and lang (the config and translation capabilities, as sm-paymentgateway-plugin does) and the boot.go service step reads grace_period_days as an int (a negative value fails boot) instead of DefaultGracePeriodDays. 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).
|
||||
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). fixture_plugin_test.go gains whatever the subtests need from the fake gateway (a recorded cancel, a sync counter).
|
||||
|
||||
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).</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suite fails, or the config default is missing.</fails_when>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestSubscriptionsCoreSmoke|TestSubscriptionsTracer|TestSubscriptionsCheckout)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestSubscriptions' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three 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 or the checkout test regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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).
|
||||
@@ -271,8 +304,8 @@ README: Features, Usage (the three-call checkout sequence, registering a subscri
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Create the remote with tea and push main, or hold</name>
|
||||
<read_first>.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</read_first>
|
||||
<name>Task 5: Create the remote with tea and push main, or hold</name>
|
||||
<read_first>.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 to 4 SUMMARY notes</read_first>
|
||||
<action>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.</action>
|
||||
<decision>Create git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin with tea and push main now?</decision>
|
||||
<context>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.</context>
|
||||
@@ -330,8 +363,9 @@ README: Features, Usage (the three-call checkout sequence, registering a subscri
|
||||
<verification>
|
||||
- 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.
|
||||
- Task 3: vet, the Docker-free suite, the no-stripe-go dependency check; the tracer and the checkout test on Postgres.
|
||||
- Task 4: vet, the Docker-free suite, the config default; the core smoke, the tracer and the checkout test on Postgres.
|
||||
- Task 5: 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 ./...`.
|
||||
</verification>
|
||||
|
||||
@@ -342,5 +376,5 @@ README: Features, Usage (the three-call checkout sequence, registering a subscri
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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.
|
||||
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 5 replies, the Docker tests run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
|
||||
@@ -48,7 +48,7 @@ requirements: [SC-3, D-02, D-08, D-13, D-17, D-21, D-22]
|
||||
estimate:
|
||||
tokens: 240000
|
||||
raw_tokens: 240000
|
||||
tasks: 4
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
@@ -56,7 +56,7 @@ must_haves:
|
||||
- "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-13 (customer API, contract confirmed at Task 4): 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:
|
||||
@@ -121,7 +121,7 @@ Skipped visibly: the phase has no requirement IDs to probe, so no probe predicat
|
||||
- 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.
|
||||
- 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 4 contract sheet.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
@@ -137,7 +137,7 @@ Deliver the pgsub admin screens and the generic headless customer API: plans wit
|
||||
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.
|
||||
Execution budget: over the single-context smart zone by design (D-22 split). Task sizing (plan-checker revision): the former 17-file tracer is split into the plans-form tracer (Task 1, 15 files) and the reorder and sync-actions task (Task 2, 8 files); the subscriptions admin (Task 3, 14 files) and the customer API (Task 5, 12 files) are unchanged. Each implementation task authors at most 10 Go files; the rest are YAML, phrase files, go.sum or the README. Accepted trade-off (recorded per the checker's plan-level warning): with the plan count fixed at six (D-22) and the admin screens and the customer API both assigned to this plan, the plan as a whole stays above the per-plan file and task targets (about 40 file entries, five tasks of which one is a checkpoint without implementation); it cannot be reduced without moving work to another plan, which D-22 excludes. Commit per task, write SUMMARY notes as you go, resume at the first uncommitted task after a reset.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@@ -168,39 +168,62 @@ Analogs (read, copy the shapes):
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>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</name>
|
||||
<reversibility rating="costly">D-17: the controller IDs, YAML keys and action names become the admin contract other applications' admins and role exports use.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>../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)</read_first>
|
||||
<action>Per D-17 and RESEARCH Pattern 8, wire the plans admin end to end.
|
||||
<name>Task 1: Tracer: an administrator creates and edits a plan whose features are a taglist, through the pgsub admin wiring, cabana and Postgres</name>
|
||||
<reversibility rating="costly">D-17: the controller IDs, YAML keys and permission codes become the admin contract other applications' admins and role exports use.</reversibility>
|
||||
<files>../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/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/admin_harness_test.go, ../sm-paymentgatewaysubscriptions-plugin/admin_smoke_test.go</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/plugin.go, ../sm-paymentgatewaysubscriptions-plugin/boot.go, ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go (plan 02), ../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, summercms.go modules/cabana/testdata/catalog (plan 01's taglist fixture), docs/backend/forms.md (## Taglist), PHP frozen: Plugin.php (registerNavigation, registerPermissions), controllers/subscriptionplans/{config_list.yaml,config_form.yaml}, models/subscriptionplan/{fields.yaml,columns.yaml}, lang/en/lang.php, .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (§ Admin Screens)</read_first>
|
||||
<action>Per D-17, wire the plans admin end to end; Task 2 adds the flat reorder page and the gateway sync actions.
|
||||
|
||||
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).
|
||||
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], search prompt; Task 2 adds the reorder and sync buttons and the bulk action); config_form.yaml. 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.
|
||||
|
||||
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.</action>
|
||||
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 plan 02's fixture plugin; admin_smoke_test.go TestAdminPlansSmoke: the plans form schema has a taglist field features with mode array; POST a plan with features [" chat ", "chat", "reports"] stores the JSON list ["chat","reports"] and the record GET returns it; an update keeps the list; a plan without a code is a 422 on code; an administrator without manage_plans gets 403 on the list.</action>
|
||||
<verify>
|
||||
<automated>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'</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<automated>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'</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the plans smoke failed: the form lacks the taglist, tags were not normalised or not returned, a required-field rule was missing, or a non-manager got in.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
<done>An administrator manages plans as on PHP and edits their features as tags.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>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</name>
|
||||
<name>Task 2: Plans reorder and gateway sync: the flat reorder page, the bulk sync_to_stripe and toolbar sync_all_to_stripe actions through the gateway registry, with per-plan failure reporting</name>
|
||||
<reversibility rating="costly">D-17 and RESEARCH Pattern 8: the action codes and the reorder config become the admin contract other applications' admins and role exports use.</reversibility>
|
||||
<files>../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_list.yaml, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_reorder.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</files>
|
||||
<read_first>../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go, admin_harness_test.go and admin_smoke_test.go (Task 1), ../sm-paymentgatewaysubscriptions-plugin/classes/service.go (SyncPlan contract), ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go (the fake gateway), ../sm-summercmsio-app/plugins/golem15/user/controllers/users_admin_controller.go (:548-590 AdminBulkActions with cabana.TxFromContext), ../sm-summercmsio-app/plugins/golem15/user/controllers/users/config_list.yaml (showCheckboxes, bulkActions), summercms.go modules/pact/capabilities.go (:225-316), modules/cabana/actions.go (:89-190), docs/backend/lists-and-filters.md (## Flat reorder), PHP frozen: controllers/SubscriptionPlans.php (:60-143 onSyncToStripe and onSyncAllToStripe), controllers/subscriptionplans/{config_reorder.yaml,_list_toolbar.php}, .planning/phases/18.1-payment-gateway-subscriptions/18.1-RESEARCH.md (Pattern 8, Pitfall 5)</read_first>
|
||||
<action>Per D-17 and RESEARCH Pattern 8, complete the plans screen.
|
||||
|
||||
Reorder: controllers/subscriptionplans/config_reorder.yaml with exactly title (lang key), modelClass and nameFrom name (flat by plan 01's inference, since plans have no parent_id column); config_list.yaml toolbar buttons become [create, reorder, sync_all_to_stripe] and the list gains bulkActions [sync_to_stripe].
|
||||
|
||||
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 from plan 02); 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): actions.sync_to_stripe, actions.sync_all_to_stripe, actions.sync_confirm, actions.sync_result and the reorder title.
|
||||
|
||||
Tests: fixture_plugin_test.go's fake gateway gains a failure on demand in SyncPlanToGateway (after setting a product id on the plan); admin_harness_test.go gains a variant that boots without the fake gateway. TestAdminPlansSmoke gains: the list schema has reorder.flat true; 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 both actions; with no gateway registered the bulk action answers 403 with the localized message.</action>
|
||||
<verify>
|
||||
<automated>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'</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suite fails (for example the reorder config does not compile as flat at boot), or a stripe-go package entered the dependency list (the sync must go through the registry).</fails_when>
|
||||
<automated>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'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the plans smoke failed: the reorder is not flat or did not persist, a sync stored nothing or lost ids after a partial failure, an action returned an error status, a free plan was synced, a non-manager ran an action, or a missing gateway was not a localized 403.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -cE '^(title|modelClass|nameFrom):' ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_reorder.yaml` prints 3.
|
||||
- `grep -n "bulkActions" ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans/config_list.yaml` prints one line and `grep -n "sync_to_stripe" ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator drags plans into order and pushes them to Stripe from the list without losing ids on a partial failure.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: 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</name>
|
||||
<reversibility rating="reversible">Admin display only; no stored data or published contract changes.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>../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</read_first>
|
||||
@@ -231,11 +254,11 @@ Smoke (admin_smoke_test.go) TestAdminSubscriptionsSmoke: two subscriptions (one
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking" auto_select="api-research">
|
||||
<name>Task 3: Confirm the one-way customer API contract (D-13): paths, authorization, JSON shapes and status codes</name>
|
||||
<name>Task 4: Confirm the one-way customer API contract (D-13): paths, authorization, JSON shapes and status codes</name>
|
||||
<read_first>.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)</read_first>
|
||||
<action>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.</action>
|
||||
<decision>Which shape does the published customer API take?</decision>
|
||||
<context>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.</context>
|
||||
<context>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 5 writes it.</context>
|
||||
<options>
|
||||
<option id="api-research">
|
||||
<name>As listed: money objects per currency, 404 when the user has no subscription, latest subscription with has_access and in_grace_period</name>
|
||||
@@ -248,33 +271,33 @@ Smoke (admin_smoke_test.go) TestAdminSubscriptionsSmoke: two subscriptions (one
|
||||
<cons>A wrapper object only on this one endpoint, unlike the order API which returns objects directly.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>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.</resume-signal>
|
||||
<resume-signal>Reply "api-research" or "api-null-subscription"; record the reply verbatim in the SUMMARY and in the api package doc comment; Task 5 implements it.</resume-signal>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>Non-zero exit or "api-sheet-ok" absent: the frozen component or the order API documentation the sheet aligns with is missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the sheet and the reply verbatim before Task 4's first commit.
|
||||
- The SUMMARY records the sheet and the reply verbatim before Task 5's first commit.
|
||||
</acceptance_criteria>
|
||||
<done>The customer API contract is confirmed before any handler exists.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>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</name>
|
||||
<reversibility rating="one-way">D-13: the published API that plan 05's layer and applications consume; confirmed at Task 3.</reversibility>
|
||||
<name>Task 5: Customer API: five endpoints with JWT, bucket, config redirect URLs and the confirmed shapes; README Admin, Customer API and Configuration sections; the API smoke</name>
|
||||
<reversibility rating="one-way">D-13: the published API that plan 05's layer and applications consume; confirmed at Task 4.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>../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)</read_first>
|
||||
<action>Implement the contract confirmed at Task 3 per D-13 and D-21.
|
||||
<read_first>../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 4 SUMMARY note (the confirmed contract), PHP frozen: components/SubscriptionManager.php (behaviour of plans, subscribe, manage, cancel)</read_first>
|
||||
<action>Implement the contract confirmed at Task 4 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", <jwt user id>); 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.
|
||||
Handlers (api/): the subscriber is always Service.Subscriber(ctx, "user", <jwt user id>); 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 4 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 4 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.</action>
|
||||
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 4 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.</action>
|
||||
<verify>
|
||||
<automated>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'</automated>
|
||||
<fails_when>Non-zero exit: vet or the Docker-free suite fails, the README lacks the Customer API section, or stripe-go entered the dependency list.</fails_when>
|
||||
@@ -317,10 +340,11 @@ Smoke (customer_api_smoke_test.go) TestCustomerAPISmoke: GET /plans without a to
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- 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.
|
||||
- Task 1: vet, Docker-free suite, no stripe-go; the plans admin smoke (form, taglist, permission) on Postgres.
|
||||
- Task 2: vet, Docker-free suite, no stripe-go; the plans admin smoke extended with reorder and sync actions on Postgres.
|
||||
- Task 3: vet, Docker-free suite incl. lang parity; both admin smokes on Postgres.
|
||||
- Task 4: the API contract sheet and the human's reply before any handler exists.
|
||||
- Task 5: 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 ./...`.
|
||||
</verification>
|
||||
|
||||
@@ -331,5 +355,5 @@ Smoke (customer_api_smoke_test.go) TestCustomerAPISmoke: GET /plans without a to
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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.
|
||||
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 4 reply, the Docker tests run or NOT RUN, and the recorded deviations.
|
||||
</output>
|
||||
|
||||
@@ -35,7 +35,7 @@ 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-21 and D-13: a 404 on GET /subscription renders the no-subscription state (or the null answer if plan 04 Task 4 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:
|
||||
@@ -120,7 +120,7 @@ Output: commits in vue-paymentgateway-layer and a release decision.
|
||||
@.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).
|
||||
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 4 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).
|
||||
|
||||
<interfaces>
|
||||
Existing layer code (read, extend, do not change behaviour):
|
||||
@@ -138,7 +138,7 @@ Existing layer code (read, extend, do not change behaviour):
|
||||
<name>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</name>
|
||||
<reversibility rating="costly">D-14: the component, composable and type names are what applications pinned to the layer tag import; renaming them breaks those apps.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>../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)</read_first>
|
||||
<read_first>../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 4 reply)</read_first>
|
||||
<action>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).
|
||||
|
||||
@@ -44,7 +44,7 @@ requirements: [SC-1, SC-2, SC-3, SC-4, SC-5, D-01, D-03, D-04, D-05, D-08, D-09,
|
||||
estimate:
|
||||
tokens: 280000
|
||||
raw_tokens: 280000
|
||||
tasks: 4
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
@@ -130,7 +130,7 @@ Close the phase with unit tests for every behaviour plans 01 to 05 added, the ni
|
||||
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.
|
||||
Execution budget: over the single-context smart zone by design (D-22 split). Task sizing (plan-checker revision): the two former cross-repository TDD tasks (19 and 14 files over four repositories) are rebalanced into one task per repository group: framework and layer specs (Task 2, 11 files), pgsub (Task 3, 13 files) and pgstripe with the full gate run and the VALIDATION map (Task 4, 10 files); each task writes tests in at most two repositories plus the gate script. Accepted trade-off (recorded per the checker's plan-level warning): the plan as a whole stays above the per-plan budget, about 30 test files in four repositories and five tasks of which one is the final checkpoint without implementation, because the project rule puts every unit test of the phase in the last plan (CLAUDE.md "unit tests are the last plan of every phase") and D-22 fixes six plans; neither can move tests elsewhere, so the per-task split and resumable per-task commits are the mitigation. Commit per task (per repository), write SUMMARY notes as you go, resume at the first uncommitted task after a reset.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@@ -154,7 +154,7 @@ Repositories: summercms.go (gate, framework tests, VALIDATION), ../sm-paymentgat
|
||||
<interfaces>
|
||||
- `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.
|
||||
- The test names plans 01 to 05 created (read each SUMMARY for the final names): TestTaglistFieldCompile, TestTaglistFieldSmoke, TestReorderFlatCompile, TestReorderFlatSmoke, TestSubscriptionsTracer, TestSubscriptionsCheckout, TestSubscriptionsCoreSmoke, TestAdminPlansSmoke, TestAdminSubscriptionsSmoke, TestCustomerAPISmoke, TestHostSubscriptionFlow, TestSubscriptionWebhookSmoke, TestStripeSettingsSmoke, TestStripeImportWinterSmoke.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
@@ -165,7 +165,7 @@ Repositories: summercms.go (gate, framework tests, VALIDATION), ../sm-paymentgat
|
||||
<reversibility rating="reversible">A test script; nothing outside the repository reads it.</reversibility>
|
||||
<files>scripts/check-phase18.1.sh</files>
|
||||
<read_first>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)</read_first>
|
||||
<action>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.</action>
|
||||
<action>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 3), PGSTRIPE . TestSDKPin; admin: TaglistField.test.ts; layers: subscriptions.test.ts with a minimum of 6; postgres: TestTaglistFieldSmoke, TestSubscriptionsTracer, TestHostSubscriptionFlow); Tasks 2 to 4 extend the lists.</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
@@ -181,47 +181,74 @@ Repositories: summercms.go (gate, framework tests, VALIDATION), ../sm-paymentgat
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>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</name>
|
||||
<name>Task 2: Framework and layer coverage: taglist and flat reorder matrices with the admin SPA specs, and the layer's subscription client, flow and component specs</name>
|
||||
<reversibility rating="reversible">Tests only (plus any bug fix they expose, committed separately).</reversibility>
|
||||
<files>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</files>
|
||||
<read_first>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)</read_first>
|
||||
<files>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, ../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</files>
|
||||
<read_first>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, ../vue-paymentgateway-layer/app/utils/subscriptionsClient.ts, ../vue-paymentgateway-layer/app/utils/subscribe.ts, the four Pg subscription components and the three spec files from plan 05, .planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md (the Task 4 reply: the none answer of the customer API)</read_first>
|
||||
<behavior>
|
||||
- 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.
|
||||
- Layer: every client path sends its method and body and never a redirect URL; the manage flow maps 401 to the login key and 502 to the provider key; the plans currency prop picks the matching price; the confirmed none answer renders the no-subscription state; the cancel button is hidden while a cancel is scheduled.
|
||||
</behavior>
|
||||
<action>Per SC-5 and the must_haves of plans 01 and 05 (D-14, D-17, D-21): write the tests first against the existing behaviour, fix any production bug they expose in its own commit in the owning repository, 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.
|
||||
|
||||
Layer: extend the three spec files (names starting "subscriptions:") per the behaviour list (any uncovered client path or error status, the manage flow 401 and 502 mappings, the plans currency prop choosing the matching price, the none answer of plan 04's Task 4 reply, and the cancel button hidden while a cancel is scheduled); the layer README gains a Testing section listing the spec files.
|
||||
|
||||
Gate: extend check-phase18.1.sh required lists with every framework name above in the go, admin and postgres stages, and raise the layers minimum to the number of "subscriptions:" tests now passing.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana && npm --prefix admin test -- tests/form/TaglistField.test.ts tests/views/ReorderView.test.ts && OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && bash scripts/check-phase18.1.sh go && bash scripts/check-phase18.1.sh admin && bash scripts/check-phase18.1.sh layers && bash scripts/check-phase18.1.sh --self-test</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a Docker-free cabana test fails, a vitest spec fails, a layer spec fails, or the go, admin or layers stage reports a required name failed, skipped or missing, or fewer "subscriptions:" tests passed than the raised minimum.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && 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</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three framework Postgres passes.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "TestTaglistFieldValidate\|TestReorderFlatNodes" scripts/check-phase18.1.sh` prints at least two lines.
|
||||
- `grep -n "TestTaglistFieldValidate" modules/cabana/README.md` prints at least one line.
|
||||
- `grep -n "## Testing" ../vue-paymentgateway-layer/README.md` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every framework and layer behaviour of this phase has a test that fails when the behaviour breaks, and the gate requires them by name.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: pgsub coverage: model, registry, event, claim, user subscribable, baseline schema, service, admin action and customer API authorization tests</name>
|
||||
<reversibility rating="reversible">Tests only (plus any bug fix they expose, committed separately).</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>../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), .planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md (the Task 4 reply)</read_first>
|
||||
<behavior>
|
||||
- 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.
|
||||
</behavior>
|
||||
<action>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.
|
||||
<action>Per SC-5 and the must_haves of plans 02 and 04 (D-03, D-04, D-05, D-08, D-09, D-13, D-16, D-21): write the tests first against the existing behaviour, fix any production bug they expose in its own commit, then extend the gate.
|
||||
|
||||
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.
|
||||
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 4 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).</action>
|
||||
Gate: extend check-phase18.1.sh required lists with every pgsub name above (plugins stage Docker-free names per package ./models, ./contracts and .; postgres stage names in .).</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<automated>go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && bash scripts/check-phase18.1.sh plugins && bash scripts/check-phase18.1.sh --self-test</automated>
|
||||
<fails_when>Non-zero exit: vet or a Docker-free pgsub test fails, or the plugins stage reports a required name failed, skipped or missing.</fails_when>
|
||||
<automated>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</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or 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).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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 "TestBaselineSchema\|TestApplyEvents" scripts/check-phase18.1.sh` prints at least two 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.
|
||||
</acceptance_criteria>
|
||||
<done>Every framework and pgsub behaviour of this phase has a test that fails when the behaviour breaks, and the gate requires them by name.</done>
|
||||
<done>Every pgsub behaviour of this phase has a test that fails when the behaviour breaks, and the gate requires them by name.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<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</name>
|
||||
<name>Task 4: pgstripe coverage: decode, job (STRIPE-006/007, duplicate, concurrent), plan sync, gateway, PHP map and secrets; the complete gate run, the validated VALIDATION map and the pgstripe README Testing sections</name>
|
||||
<reversibility rating="reversible">Tests and documentation only (plus any bug fix they expose, committed separately).</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>../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)</read_first>
|
||||
<files>../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, scripts/check-phase18.1.sh, .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md</files>
|
||||
<read_first>../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, .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md, .planning/phases/18-payment-gateway/18-VALIDATION.md (a validated map's shape)</read_first>
|
||||
<behavior>
|
||||
- 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.
|
||||
@@ -233,29 +260,27 @@ Gate: extend check-phase18.1.sh required lists with every name above in its stag
|
||||
|
||||
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.
|
||||
Gate: extend check-phase18.1.sh required lists with every pgstripe name (plugins stage Docker-free names per package, postgres stage names per package); 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.</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && 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</automated>
|
||||
<fails_when>Non-zero exit: vet or a Docker-free pgstripe test 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.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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 -n "## Testing" ../sm-pgstripe-plugin/subscriptions/README.md` prints one line.
|
||||
- `grep -c "STRIPE-002" ../sm-pgstripe-plugin/subscriptions/README.md` reports at least 1 (the out-of-scope rows are recorded).
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
<done>The Stripe half is covered, the PHP tests are mapped, the gate is green across four repositories, and the validation map says so.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>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</name>
|
||||
<read_first>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)</read_first>
|
||||
<name>Task 5: 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</name>
|
||||
<read_first>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 4 SUMMARY notes (gate result, NOT RUN suites)</read_first>
|
||||
<action>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.</action>
|
||||
<decision>Publish the phase now?</decision>
|
||||
<context>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).</context>
|
||||
@@ -311,9 +336,10 @@ VALIDATION: fill 18.1-VALIDATION.md's per-task map with every task of plans 01 t
|
||||
|
||||
<verification>
|
||||
- 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.
|
||||
- Task 2: the Docker-free cabana suite, the admin vitest specs, the layer suite, the go, admin and layers stages and the self-test; the framework Postgres names on a Docker host.
|
||||
- Task 3: the pgsub Docker-free suite, the plugins stage and the self-test; the pgsub Postgres names on a Docker host.
|
||||
- Task 4: pgstripe Docker-free suites, `--all`, the VALIDATION frontmatter; `PHASE181_REQUIRE_POSTGRES=1 --all` on a Docker host.
|
||||
- Task 5: clean trees and a green gate before the human decides the publication.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
|
||||
@@ -62,6 +62,16 @@
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- `mailblocker-with-mailing.md`, `refresh-fonoteka-readme.md`, `faq-plugin-port.md`, `journal-imported-html-admin-resave.md`: these matched on keywords only and are unrelated to subscriptions.
|
||||
|
||||
### Amendments (2026-10-14, recorded in CONTEXT.md after this research; they override the recommendations below where they differ)
|
||||
- **D-15 (amends D-01):** the Stripe plan sync is ported from pgstripe `d00f850`; only `classes/StripeSubscriptionGateway.php` is read there, every other pgstripe file at `8b47457`; pgsub from the working tree (`a522376`).
|
||||
- **D-16 (amends D-04):** the baseline omits `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements`; it stays idempotent and is import-compatible; the pgsub import mapper is deferred to Phase 20 / the Horoskopia port.
|
||||
- **D-17 (framework plan):** a `summercms.go` plan adds a flat (Sortable) reorder and a core `taglist` field to cabana; the SubscriptionPlans screens port faithfully (reorder page, `features` as `taglist`, `mode: array`).
|
||||
- **D-18 (S-4):** separate `subscription_webhook_secret` / `test_subscription_webhook_secret` columns; `pgstripe:import-winter` gets an explicit target flag; P18's default behaviour is unchanged.
|
||||
- **D-19 (S-5):** `metadata.app` becomes a config key of the Stripe subscriptions plugin, empty by default; `billing_owner_id` is dropped; apps add metadata through a checkout options map.
|
||||
- **D-20 (S-8, S-9):** the subpackage is its own party plugin `golem15.pgstripesubscriptions` at `git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions`, with its own importable or package-local Stripe fake.
|
||||
- **D-21:** keep PHP's `cancel_at_period_end` mapping (a manual test-mode check decides `cancel_at`); out-of-order events behave as PHP; the job reads pre-basil and basil shapes; customer `GET /subscription` returns the latest subscription with `has_access` and `in_grace_period`.
|
||||
- **D-22:** six plans: (1) framework cabana flat reorder + `taglist`; (2) pgsub core tracer; (3) pgstripe secrets, import mapping and the `subscriptions` plugin; (4) pgsub admin + customer API; (5) layer pieces; (6) unit tests + `scripts/check-phase18.1.sh`.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
@@ -96,30 +106,30 @@ From `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global `
|
||||
|
||||
## Summary
|
||||
|
||||
The PHP side is small and well understood: two models with eight Stripe statuses, a five-type webhook that enqueues a job, a job that maps Stripe objects to rows and fires six string events, and a gateway contract with nine methods. Phase 18 delivered almost everything the port leans on: the stripe-go v87 client factory with its `BackendOverride` (`sm-pgstripe-plugin/client.go`), signature-only verification that tolerates older endpoint API versions (`pgstripe.VerifySignature`), the encrypted write-only Stripe settings with config fallbacks, the `HasOperators`-over-`party.Activated` registry pattern, typed festival events that fire inside the transaction with listeners deferring side effects via `lagoon.AfterCommit`, cabana bulk/toolbar/record actions, preview-only controllers, plugin field types, and a customer-API auth/throttle pattern. No framework change is strictly required.
|
||||
The PHP side is small and well understood: two models with eight Stripe statuses, a five-type webhook that enqueues a job, a job that maps Stripe objects to rows and fires six string events, and a gateway contract with nine methods. Phase 18 delivered almost everything the port leans on: the stripe-go v87 client factory with its `BackendOverride` (`sm-pgstripe-plugin/client.go`), signature-only verification that tolerates older endpoint API versions (`pgstripe.VerifySignature`), the encrypted write-only Stripe settings with config fallbacks, the `HasOperators`-over-`party.Activated` registry pattern, typed festival events that fire inside the transaction with listeners deferring side effects via `lagoon.AfterCommit`, cabana bulk/toolbar/record actions, preview-only controllers, plugin field types, and a customer-API auth/throttle pattern. ~~No framework change is strictly required.~~ **Superseded by D-17:** the phase starts with a framework plan that adds a flat reorder and a core `taglist` field to cabana, so the plan screens port faithfully.
|
||||
|
||||
Several CONTEXT.md premises no longer hold or were incomplete, and the planner must resolve them before writing plans. The most important: **pgstripe at `8b47457` does not implement `syncPlanToGateway`** — the Stripe implementation is in `ec2bd34`/`d00f850` on `feature/qs-update`, merged in `d54dcde`, which is the current HEAD of the Horoskopia checkout. **Every PHP app runs MariaDB**, so "an idempotent baseline that adopts an existing PHP database unchanged" cannot happen in place; adoption is the Phase 20 `summer import:winter` runner (not built yet). The **dormant usage/featured tables exist only where Quotify is installed** and carry FKs to Quotify tables, so they cannot be created faithfully in a pgsub baseline. In Horoskopia, **PHP's `stripe.webhookSecret` holds the subscriptions endpoint's secret**, but P18's `pgstripe:import-winter` maps it to the payment-notification column. Cabana's **reorder is tree-only** and there is **no `taglist`/`readOnly`**, so the PHP plan form and reorder page need adaptation.
|
||||
Several CONTEXT.md premises no longer hold or were incomplete, and the planner must resolve them before writing plans. **(Resolved 2026-10-14 at the plan-count checkpoint: D-15 to D-22, see the Amendments above.)** The most important: **pgstripe at `8b47457` does not implement `syncPlanToGateway`** — the Stripe implementation is in `ec2bd34`/`d00f850` on `feature/qs-update`, merged in `d54dcde`, which is the current HEAD of the Horoskopia checkout. **Every PHP app runs MariaDB**, so "an idempotent baseline that adopts an existing PHP database unchanged" cannot happen in place; adoption is the Phase 20 `summer import:winter` runner (not built yet). The **dormant usage/featured tables exist only where Quotify is installed** and carry FKs to Quotify tables, so they cannot be created faithfully in a pgsub baseline. In Horoskopia, **PHP's `stripe.webhookSecret` holds the subscriptions endpoint's secret**, but P18's `pgstripe:import-winter` maps it to the payment-notification column. Cabana's **reorder is tree-only** and there is **no `taglist`/`readOnly`**, so the PHP plan form and reorder page need adaptation.
|
||||
|
||||
Two Stripe API-version facts change the job's decoding: since `2025-03-31.basil` an invoice references its subscription at `parent.subscription_details.subscription` (the PHP job reads only the old top-level `subscription`, so `golem15.pgsub.invoice.*` likely never fires on a basil-versioned endpoint), and pre-basil endpoints still send `current_period_*` on the subscription top level rather than on items. The Go job should read both shapes.
|
||||
|
||||
**Primary recommendation:** Build pgsub as a gateway-neutral core whose `Service.Apply(ctx, tx, update)` owns state-change and event logic; put all Stripe mapping, the two webhook routes, the job and `SyncPlanToGateway` (ported from `d00f850`) in a `golem15.pgstripesubscriptions` plugin at `sm-pgstripe-plugin/subscriptions`; make the job claim the processed-event row first inside its transaction (`INSERT … ON CONFLICT DO NOTHING`) so STRIPE-006/007 hold and concurrent duplicates serialize; prove it with a host test inside that subpackage. Five plans, no framework plan.
|
||||
**Primary recommendation:** Build pgsub as a gateway-neutral core whose `Service.Apply(ctx, tx, update)` owns state-change and event logic; put all Stripe mapping, the two webhook routes, the job and `SyncPlanToGateway` (ported from `d00f850`) in a `golem15.pgstripesubscriptions` plugin at `sm-pgstripe-plugin/subscriptions`; make the job claim the processed-event row first inside its transaction (`INSERT … ON CONFLICT DO NOTHING`) so STRIPE-006/007 hold and concurrent duplicates serialize; prove it with a host test inside that subpackage. ~~Five plans, no framework plan.~~ **Superseded by D-17/D-22:** six plans, starting with a framework plan (cabana flat reorder and core `taglist`).
|
||||
|
||||
## Premise Shifts (CONTEXT.md vs what Phase 18 and the frozen trees actually contain)
|
||||
|
||||
Each row is a decision the planner must take to the user at the plan-count checkpoint, or record as an amendment.
|
||||
Each row is a decision the planner must take to the user at the plan-count checkpoint, or record as an amendment. **All rows are resolved (2026-10-14): the deciding D-ID is named at the start of each Recommendation cell; where the decision differs from the recommendation, the decision wins.**
|
||||
|
||||
| # | CONTEXT premise | What is actually there | Recommendation |
|
||||
|---|-----------------|------------------------|----------------|
|
||||
| S-1 | D-01: pgstripe frozen at `8b47457`; `syncPlanToGateway` comes from pgsub `5fb9dac` | `git grep syncPlanToGateway 8b47457` in pgstripe finds nothing. The Stripe implementation (`createOrUpdateStripePrice`, `syncPlanToGateway`) plus the success-URL separator fix and the `UnexpectedValueException` catch arrive in `ec2bd34` + `d00f850`, merged as `d54dcde` ("Merge feature/qs-update: gateway plan sync and Stripe helper hardening"). The Horoskopia checkout HEAD **is** `d54dcde`; `git diff 8b47457 HEAD` touches only `classes/StripeSubscriptionGateway.php` (+123/−2) `[VERIFIED: git log/diff in pgstripe this session]`. pgsub's checkout HEAD is `a522376` = merge of `ac3d73c` + `5fb9dac`; `git diff ac3d73c HEAD` touches only `contracts/SubscriptionGateway.php` (+11) `[VERIFIED: git diff this session]` | **Amend D-01:** read `StripeSubscriptionGateway` from `d00f850` (= HEAD `d54dcde`); every other pgstripe file stays at `8b47457`. Read pgsub from the working tree (= `ac3d73c` + `5fb9dac`). Both checkouts need `git -c safe.directory=*` (dubious-ownership error) |
|
||||
| S-2 | D-04: "idempotent so it adopts an existing PHP database unchanged, as in Phase 5's squashed migrations" | All PHP apps are MariaDB (`DB_CONNECTION=mysql` in Horoskopia's `.env.example`; the dumps are MariaDB 12). Go is Postgres-only. Phase 20 CONTEXT D-01/D-02 decides that adoption is a Go import command (`summer import:winter`) with a per-plugin capability (working name `HasWinterImport`) — neither exists yet. Phase 5 D-01 baselines and all Phase 18 baselines are plain `CREATE TABLE` (not idempotent) | Keep the baseline idempotent (`IF NOT EXISTS`, cheap, harmless) but state the real contract: **same table/column/index names and compatible types so the Phase 20 runner can copy rows**. The `HasWinterImport` mapper for pgsub is deferred to Phase 20 / the Horoskopia port |
|
||||
| S-3 | D-04: the baseline includes dormant `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements` | PHP creates both **only when `golem15_quotify_professionals` exists** (`v1.0.3`, `v1.0.4` early-return), with FKs to `golem15_quotify_professionals` and `golem15_quotify_trade_categories`, and v2.0.0 never converted their `professional_id` to polymorphic. The real Horoskopia dump has **neither table** `[VERIFIED: grep of v2.1-pre-migrate dump]` | **Omit both from the pgsub baseline** (amend D-04). Creating them everywhere would make Horoskopia's Go schema differ from its PHP schema, and their FKs cannot be created without Quotify. Quotify's port creates them (or a later pgsub migration gated on Quotify). If the user keeps D-04, create them without the two Quotify FKs and document the divergence |
|
||||
| S-4 | D-12: cutover imports `STRIPE_SUBSCRIPTION_WEBHOOK_SECRET`/`STRIPE_WEBHOOK_SECRET` or the PHP setting | PHP pgstripe's subscription controller reads `StripeSettings::getWebhookSecret()` = `stripe.testWebhookSecret`/`stripe.webhookSecret`, falling back to `config('golem15.pgstripe::webhook_secret')` (`STRIPE_WEBHOOK_SECRET`). `STRIPE_SUBSCRIPTION_WEBHOOK_SECRET` is read only by the unported deprecated `WebhookController`. Horoskopia's own controller states "The PgStripe settings helper returns the SUBSCRIPTIONS endpoint's secret". P18's `pgstripe:import-winter` maps `stripe.webhookSecret` → `webhook_secret` (the **payment-notification** column) | Add `subscription_webhook_secret`/`test_subscription_webhook_secret` columns (P18's `updates/registry.go` comment already reserves "Phase 18.1 adds the subscription columns") and extend `pgstripe:import-winter` with an explicit target (`--webhook-secret-for=payments|subscriptions|both`, default `subscriptions` when pgsub is in use is the planner's call). Document in the README that on PHP one secret served both routes |
|
||||
| S-5 | D-01 binds names but not behaviour | The job skips any event whose `metadata.app` is set and not `'queststream'`, and the Stripe gateway writes `'app' => 'queststream'` into `subscription_data.metadata`; it also writes `billing_owner_id` when the subscriber has one (QuestStream-only) | Make the app tag a config key on the Stripe subpackage (e.g. `golem15.pgstripesubscriptions.metadata_app`, default empty = write nothing, filter nothing). Horoskopia's existing Stripe subscriptions carry `app=queststream`, so its cutover sets the key to that value. Drop `billing_owner_id` from the generic gateway (an app can add metadata through a `CheckoutOptions.Metadata` map) |
|
||||
| S-6 | Discretion: "SubscriptionPlans list, form and reorder" | cabana reorder is tree-only: `parentColumn` defaults to `parent_id` and must be a record column (`reorder.go:127-142`); plans have no parent column, so a `config_reorder.yaml` fails boot | Do not ship the reorder page in 18.1: `sort_order` is a number field on the form and the list's default sort (PHP list already sorts by `sort_order`). Capture "flat (Sortable) reorder in cabana" as a framework todo. A framework plan for it is the alternative if the user wants the page now |
|
||||
| S-7 | Discretion: the plan form edits `currency_prices` and `stripe_prices` | The frozen `models/subscriptionplan/fields.yaml` edits **neither** (nor `stripe_product_id`); it edits `features` with `type: taglist, mode: array`. cabana has no `taglist` and no `readOnly` key (`form_schema.go:24-52`); list columns accept only `label, searchable, sortable, type, relation, select, invisible, currency, currencyFrom` (no `valueFrom`, `default`, `cssClass`, `format`) | Port the PHP form faithfully minus unsupported keys; `features` through a pgsub plugin field type (Pattern 9); add read-only display of `stripe_product_id`/`stripe_prices`/`currency_prices` on a preview or a partial only if the user wants it. `currency_prices` editing is new scope — defer unless asked |
|
||||
| S-8 | D-07: subpackage reuses P18's `httptest` fake | P18's fake is `stripefake_test.go` in package `pgstripe_test`; a `_test.go` file is not importable from `sm-pgstripe-plugin/subscriptions` | Create a small importable fake for the subscription endpoints (e.g. `sm-pgstripe-plugin/internal/stripefake`, or a `_test.go` fake inside `subscriptions/`). Leave P18's root fake unchanged |
|
||||
| S-9 | D-07: "the import paths become what apps wire in their `plugins.go`" | Apps wire plugins in `summer.yaml` (`id` + `module`), and `summer build` emits `_ "<module>"` (`internal/build/build.go:146`). The plugin ID must match `^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$` (`manifest.go:16`): no third segment, no underscore | The subpackage is its own party plugin, e.g. `id: golem15.pgstripesubscriptions`, `module: git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions`. A blank import of a subpackage path works because only the module root needs a `require` |
|
||||
| S-10 | CONTEXT "Phase 18 must be executed first" | Done and verified (`18-VERIFICATION.md`: human_needed, 11/11). Sibling heads: paymentgateway `f55320a`, pgstripe `0313820` (no tag), layer `ac2b960` (tag `v0.1.0` one test commit behind), framework tag `v0.1.25`. pgstripe's `go.mod` still requires framework `v0.1.24` via `replace` | Nothing blocks. New repo needs `tea repos create --owner golem15` (the way P18 created its repos) — `git ls-remote …/sm-paymentgatewaysubscriptions-plugin.git` says "Cannot find repository" |
|
||||
| S-1 | D-01: pgstripe frozen at `8b47457`; `syncPlanToGateway` comes from pgsub `5fb9dac` | `git grep syncPlanToGateway 8b47457` in pgstripe finds nothing. The Stripe implementation (`createOrUpdateStripePrice`, `syncPlanToGateway`) plus the success-URL separator fix and the `UnexpectedValueException` catch arrive in `ec2bd34` + `d00f850`, merged as `d54dcde` ("Merge feature/qs-update: gateway plan sync and Stripe helper hardening"). The Horoskopia checkout HEAD **is** `d54dcde`; `git diff 8b47457 HEAD` touches only `classes/StripeSubscriptionGateway.php` (+123/−2) `[VERIFIED: git log/diff in pgstripe this session]`. pgsub's checkout HEAD is `a522376` = merge of `ac3d73c` + `5fb9dac`; `git diff ac3d73c HEAD` touches only `contracts/SubscriptionGateway.php` (+11) `[VERIFIED: git diff this session]` | **RESOLVED by D-15 (as recommended).** **Amend D-01:** read `StripeSubscriptionGateway` from `d00f850` (= HEAD `d54dcde`); every other pgstripe file stays at `8b47457`. Read pgsub from the working tree (= `ac3d73c` + `5fb9dac`). Both checkouts need `git -c safe.directory=*` (dubious-ownership error) |
|
||||
| S-2 | D-04: "idempotent so it adopts an existing PHP database unchanged, as in Phase 5's squashed migrations" | All PHP apps are MariaDB (`DB_CONNECTION=mysql` in Horoskopia's `.env.example`; the dumps are MariaDB 12). Go is Postgres-only. Phase 20 CONTEXT D-01/D-02 decides that adoption is a Go import command (`summer import:winter`) with a per-plugin capability (working name `HasWinterImport`) — neither exists yet. Phase 5 D-01 baselines and all Phase 18 baselines are plain `CREATE TABLE` (not idempotent) | **RESOLVED by D-16 (as recommended).** Keep the baseline idempotent (`IF NOT EXISTS`, cheap, harmless) but state the real contract: **same table/column/index names and compatible types so the Phase 20 runner can copy rows**. The `HasWinterImport` mapper for pgsub is deferred to Phase 20 / the Horoskopia port |
|
||||
| S-3 | D-04: the baseline includes dormant `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements` | PHP creates both **only when `golem15_quotify_professionals` exists** (`v1.0.3`, `v1.0.4` early-return), with FKs to `golem15_quotify_professionals` and `golem15_quotify_trade_categories`, and v2.0.0 never converted their `professional_id` to polymorphic. The real Horoskopia dump has **neither table** `[VERIFIED: grep of v2.1-pre-migrate dump]` | **RESOLVED by D-16: both tables are omitted.** **Omit both from the pgsub baseline** (amend D-04). Creating them everywhere would make Horoskopia's Go schema differ from its PHP schema, and their FKs cannot be created without Quotify. Quotify's port creates them (or a later pgsub migration gated on Quotify). ~~If the user keeps D-04, create them without the two Quotify FKs and document the divergence~~ (moot: D-16) |
|
||||
| S-4 | D-12: cutover imports `STRIPE_SUBSCRIPTION_WEBHOOK_SECRET`/`STRIPE_WEBHOOK_SECRET` or the PHP setting | PHP pgstripe's subscription controller reads `StripeSettings::getWebhookSecret()` = `stripe.testWebhookSecret`/`stripe.webhookSecret`, falling back to `config('golem15.pgstripe::webhook_secret')` (`STRIPE_WEBHOOK_SECRET`). `STRIPE_SUBSCRIPTION_WEBHOOK_SECRET` is read only by the unported deprecated `WebhookController`. Horoskopia's own controller states "The PgStripe settings helper returns the SUBSCRIPTIONS endpoint's secret". P18's `pgstripe:import-winter` maps `stripe.webhookSecret` → `webhook_secret` (the **payment-notification** column) | **RESOLVED by D-18.** Add `subscription_webhook_secret`/`test_subscription_webhook_secret` columns (P18's `updates/registry.go` comment already reserves "Phase 18.1 adds the subscription columns") and extend `pgstripe:import-winter` with an explicit target. ~~(`--webhook-secret-for=payments|subscriptions|both`, default `subscriptions` when pgsub is in use is the planner's call)~~ **Superseded by D-18:** P18's default behaviour is unchanged, so the default target stays `payments`; plan 18.1-03 names the flag `--webhook-secret-target` (`payments` default, `subscriptions`, `both`). Document in the README that on PHP one secret served both routes |
|
||||
| S-5 | D-01 binds names but not behaviour | The job skips any event whose `metadata.app` is set and not `'queststream'`, and the Stripe gateway writes `'app' => 'queststream'` into `subscription_data.metadata`; it also writes `billing_owner_id` when the subscriber has one (QuestStream-only) | **RESOLVED by D-19 (as recommended).** Make the app tag a config key on the Stripe subpackage (e.g. `golem15.pgstripesubscriptions.metadata_app`, default empty = write nothing, filter nothing). Horoskopia's existing Stripe subscriptions carry `app=queststream`, so its cutover sets the key to that value. Drop `billing_owner_id` from the generic gateway (an app can add metadata through a `CheckoutOptions.Metadata` map) |
|
||||
| S-6 | Discretion: "SubscriptionPlans list, form and reorder" | cabana reorder is tree-only: `parentColumn` defaults to `parent_id` and must be a record column (`reorder.go:127-142`); plans have no parent column, so a `config_reorder.yaml` fails boot | **SUPERSEDED by D-17 (the alternative was chosen):** framework plan 18.1-01 adds a flat (Sortable) reorder to cabana, and the SubscriptionPlans controller ships the PHP reorder page (`config_reorder.yaml` with title, modelClass and nameFrom). ~~Do not ship the reorder page in 18.1: `sort_order` is a number field on the form and the list's default sort (PHP list already sorts by `sort_order`). Capture "flat (Sortable) reorder in cabana" as a framework todo.~~ |
|
||||
| S-7 | Discretion: the plan form edits `currency_prices` and `stripe_prices` | The frozen `models/subscriptionplan/fields.yaml` edits **neither** (nor `stripe_product_id`); it edits `features` with `type: taglist, mode: array`. cabana has no `taglist` and no `readOnly` key (`form_schema.go:24-52`); list columns accept only `label, searchable, sortable, type, relation, select, invisible, currency, currencyFrom` (no `valueFrom`, `default`, `cssClass`, `format`) | **SUPERSEDED in part by D-17:** `features` uses the core cabana `taglist` field (`mode: array`) added by framework plan 18.1-01, not a pgsub plugin field type. Port the PHP form faithfully minus unsupported keys; ~~`features` through a pgsub plugin field type (Pattern 9);~~ no read-only display of `stripe_product_id`/`stripe_prices`/`currency_prices` (none was asked for); `currency_prices` editing is new scope and stays out (plan 18.1-04 decision, matching the frozen PHP form) |
|
||||
| S-8 | D-07: subpackage reuses P18's `httptest` fake | P18's fake is `stripefake_test.go` in package `pgstripe_test`; a `_test.go` file is not importable from `sm-pgstripe-plugin/subscriptions` | **RESOLVED by D-20 (as recommended):** plan 18.1-03 uses a package-local `subscriptions/stripefake_test.go`. Create a small importable fake for the subscription endpoints (e.g. `sm-pgstripe-plugin/internal/stripefake`, or a `_test.go` fake inside `subscriptions/`). Leave P18's root fake unchanged |
|
||||
| S-9 | D-07: "the import paths become what apps wire in their `plugins.go`" | Apps wire plugins in `summer.yaml` (`id` + `module`), and `summer build` emits `_ "<module>"` (`internal/build/build.go:146`). The plugin ID must match `^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$` (`manifest.go:16`): no third segment, no underscore | **RESOLVED by D-20 (as recommended).** The subpackage is its own party plugin, e.g. `id: golem15.pgstripesubscriptions`, `module: git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions`. A blank import of a subpackage path works because only the module root needs a `require` |
|
||||
| S-10 | CONTEXT "Phase 18 must be executed first" | Done and verified (`18-VERIFICATION.md`: human_needed, 11/11). Sibling heads: paymentgateway `f55320a`, pgstripe `0313820` (no tag), layer `ac2b960` (tag `v0.1.0` one test commit behind), framework tag `v0.1.25`. pgstripe's `go.mod` still requires framework `v0.1.24` via `replace` | **RESOLVED by D-22 (nothing to decide).** Nothing blocks. New repo needs `tea repos create --owner golem15` (the way P18 created its repos) — `git ls-remote …/sm-paymentgatewaysubscriptions-plugin.git` says "Cannot find repository" |
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
@@ -238,7 +248,7 @@ sm-paymentgatewaysubscriptions-plugin/ (new repo, branch main, package
|
||||
├── controllers/ # subscriptions/, subscriptionplans/ YAML + Go controllers (bulk/toolbar actions)
|
||||
├── api/ # customer API handlers
|
||||
├── updates/ # baseline + users.stripe_customer_id
|
||||
├── assets/js/ # features field element (Pattern 9)
|
||||
├── assets/js/ # list label element for the status type (features use cabana's core taglist, D-17; Pattern 9 superseded)
|
||||
└── README.md
|
||||
|
||||
sm-pgstripe-plugin/
|
||||
@@ -408,9 +418,11 @@ Subscription id of an invoice = `parent.subscription_details.subscription`, else
|
||||
|
||||
`SubscriptionPlans` declares `bulkActions: [sync_to_stripe]` (needs `showCheckboxes: true`) and a toolbar action `sync_all_to_stripe`. Both call `Service.SyncPlan` → `registry.Gateway("stripe").SyncPlanToGateway`. A missing gateway is a `cabana.ForbiddenError` with a clear message ("no subscription gateway is installed"). **Bulk actions run inside a transaction with the rows locked** (`pact.AdminBulkActionInput` docs, read this session): never return an error for a single plan's Stripe failure — collect `code: message` per plan and return them in `Message`, exactly like PHP's flash (`controllers/SubscriptionPlans.php:60-143`), otherwise a rollback forgets the `stripe_product_id` Stripe already created and the next sync creates a duplicate product. Free plans (`price_cents = 0`) are skipped and counted.
|
||||
|
||||
### Pattern 9: `features` as a pgsub plugin field type
|
||||
### Pattern 9: `features` as a pgsub plugin field type (SUPERSEDED by D-17)
|
||||
|
||||
cabana has no `taglist`. A pgsub `pact.FormFieldType` (precedent: P18's `quote-cart.js` element) with `Decode` accepting a JSON array of non-empty strings (trim, dedupe, cap length) and `Load` returning the stored list keeps the stored v2.4.0 shape (a flat JSON array of strings). A `repeater` would store a list of objects — wrong shape. A generic core `taglist` is the cleaner long-term home; capture it as a framework todo.
|
||||
**Superseded by D-17:** the user chose the "generic core `taglist`" route below for this phase. Framework plan 18.1-01 adds a core cabana `taglist` field (`mode: array` stores the flat JSON array of strings), and plan 18.1-04 declares `features` as `type: taglist, mode: array`. No pgsub plugin field type is built for `features`. The original analysis is kept for the record:
|
||||
|
||||
~~cabana has no `taglist`. A pgsub `pact.FormFieldType` (precedent: P18's `quote-cart.js` element) with `Decode` accepting a JSON array of non-empty strings (trim, dedupe, cap length) and `Load` returning the stored list keeps the stored v2.4.0 shape (a flat JSON array of strings).~~ A `repeater` would store a list of objects — wrong shape (still true). A generic core `taglist` is the cleaner long-term home ~~; capture it as a framework todo~~ (adopted by D-17).
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
@@ -571,7 +583,7 @@ CREATE INDEX IF NOT EXISTS users_stripe_customer_id_index ON users (stripe_custo
|
||||
|
||||
Type choices: TEXT for the three JSON columns matches `lagoon.Jsonable` ("stores JSON as TEXT") and P18's `data TEXT`; `TIMESTAMPTZ` and `SERIAL`/`INTEGER` follow every P18 baseline. Constraint names in Postgres are schema-unique per table namespace; `CREATE TABLE IF NOT EXISTS` skips the inline `CONSTRAINT` clauses when the table exists, so the check and FK are only guaranteed on a fresh DB — acceptable because Phase 20 imports into a Go-created schema. The pgsub migrations must run after `golem15.user` (D-06 `Requires` guarantees activation order; `lagoon.Migrate` runs plugin sets in `party.Activate` order, read this session).
|
||||
|
||||
If the user keeps the dormant tables (S-3), take their DDL from `v1.0.3/create_usage_records_table.php` and `v1.0.4/create_featured_placements_table.php` minus the FKs to `golem15_quotify_*` (enums become `VARCHAR … CHECK`).
|
||||
~~If the user keeps the dormant tables (S-3), take their DDL from `v1.0.3/create_usage_records_table.php` and `v1.0.4/create_featured_placements_table.php` minus the FKs to `golem15_quotify_*` (enums become `VARCHAR … CHECK`).~~ **Moot (D-16):** the dormant tables are omitted from the baseline; Quotify's port creates them.
|
||||
|
||||
## Event and State Mapping
|
||||
|
||||
@@ -592,7 +604,7 @@ Behaviours to keep or decide explicitly:
|
||||
- **`days_until_renewal`:** Carbon 2.73 (`composer.lock`) `diffInDays(…, false)` → whole days, `max(0, …)`; null when no period end.
|
||||
- **`isStripeSubscription`:** true for `stripe` **or NULL** gateway (backward compatibility).
|
||||
- **`hasFeature`:** PHP accepts list values and truthy map keys; v2.4.0 data is a flat list (D-03). Go: list membership; optionally accept the legacy map shape when decoding (cheap, avoids a 500 on an old row).
|
||||
- **No ordering guarantee:** an `invoice.paid` processed before `customer.subscription.created` finds no row and is marked processed — the event never fires (same as PHP). Document it; Horoskopia does not listen to `invoice.*` through pgsub (it keeps its own `/webhook/stripe/horoscope` endpoint for invoice facts).
|
||||
- **No ordering guarantee:** an `invoice.paid` processed before `customer.subscription.created` finds no row and is marked processed — the event never fires (same as PHP). Document it; Horoskopia does not listen to `invoice.*` through pgsub (it keeps its own `/webhook/stripe/horoscope` endpoint for invoice facts). **Resolved by D-21:** out-of-order events behave as PHP.
|
||||
|
||||
## Horoskopia Consumer → Go Service API
|
||||
|
||||
@@ -622,7 +634,7 @@ Prefix `/_paymentgatewaysubscriptions/api/v1` (pattern `/_x/api/v1`; the route g
|
||||
| Method + path | Auth | Behaviour (succeeds `SubscriptionManager`) |
|
||||
|---------------|------|--------------------------------------------|
|
||||
| GET `/plans` | public | Active plans by `sort_order`: code, name, description, billing_interval, prices as P18 Money strings per currency (from `currency_prices` falling back to `price_cents`/`currency`), features |
|
||||
| GET `/subscription` | jwt | The `user` subscriber's current subscription (active-or-latest; planner picks, document it), plan summary, status, periods, `cancel_at_period_end`, `has_access`, `in_grace_period` |
|
||||
| GET `/subscription` | jwt | The `user` subscriber's current subscription (**latest, per D-21**; ~~active-or-latest; planner picks, document it~~), plan summary, status, periods, `cancel_at_period_end`, `has_access`, `in_grace_period` |
|
||||
| POST `/subscription/checkout` `{plan_code, currency?}` | jwt | 409 when an active subscription exists (the component redirected to the portal; an API should not); 404 unknown/inactive plan; 422 free plan (`price_cents = 0`) or unsupported currency; else `{checkout_url, session_id}` |
|
||||
| POST `/subscription/portal` | jwt | 404 when the user has no customer id; else `{portal_url}` |
|
||||
| POST `/subscription/cancel` | jwt | At period end only (immediate cancel stays a service/admin capability); 404 when no active subscription; answers the period end |
|
||||
@@ -634,7 +646,7 @@ Redirect targets come from config (`api.success_url`, `api.cancel_url`, `api.por
|
||||
Permission codes verbatim (`Plugin.php:137-147`): `golem15.paymentgatewaysubscriptions.manage_subscriptions`, `golem15.paymentgatewaysubscriptions.manage_plans`, `golem15.paymentgatewaysubscriptions.manage_placements`. Recommendation for the discretion item: **keep `manage_placements` registered** (a permission code is cheap, it keeps role exports from PHP valid at import) but **no navigation entry** for featured placements. Navigation: top-level `subscriptions` (order 510) with side items `subscriptions` and `plans`.
|
||||
|
||||
- **Subscriptions:** list + filter + **preview-only** record (copy P18's `controllers/payments` config: `recordUrl …/preview/:id`, `preview:` block, no `create` button). Columns adapted: `subscriber_type`, `subscriber_id` (searchable), plan via `relation: plan, select: name`, `stripe_status` via a pgsub list field type showing the localized status label (P18 `orderstate` precedent), `current_period_end` datetime, `cancel_at_period_end`, `created_at`. The subscriber display name goes on the preview through `type: partial` + `pact.AdminPartialData` (curated view model resolved through the registry). Filter scopes `stripe_status` (group) and `plan` (model-backed). PHP's form had every field `readOnly` and a create button whose form could not be filled — dropping create is not a functional loss.
|
||||
- **SubscriptionPlans:** list (checkboxes on), create/update form with `code`, `name`, `description`, `stripe_price_id`, `price_cents` (number, cents — PHP parity), `currency`, `billing_interval` (`month`/`year`), `features` (Pattern 9), `max_quotes_per_month`, `lead_fee_required`, `is_active`, `sort_order`; rules from `SubscriptionPlan.php` (`code` required+unique, `name` required max 255, `price_cents` required integer min 0, `currency` required size 3, `billing_interval` in month,year). Bulk `sync_to_stripe` + toolbar `sync_all_to_stripe` (Pattern 8). No reorder page (S-6).
|
||||
- **SubscriptionPlans:** list (checkboxes on), create/update form with `code`, `name`, `description`, `stripe_price_id`, `price_cents` (number, cents — PHP parity), `currency`, `billing_interval` (`month`/`year`), `features` (**`type: taglist, mode: array` per D-17**; ~~Pattern 9~~), `max_quotes_per_month`, `lead_fee_required`, `is_active`, `sort_order`; rules from `SubscriptionPlan.php` (`code` required+unique, `name` required max 255, `price_cents` required integer min 0, `currency` required size 3, `billing_interval` in month,year). Bulk `sync_to_stripe` + toolbar `sync_all_to_stripe` (Pattern 8). **Flat reorder page per D-17** (the PHP `config_reorder.yaml` on cabana's flat reorder from framework plan 18.1-01); ~~No reorder page (S-6).~~
|
||||
- EN and PL phrase files (P15 D-06).
|
||||
|
||||
## Layer additions (D-14)
|
||||
@@ -681,7 +693,7 @@ Not a rename phase, but the port creates state that lives outside the repos; rec
|
||||
**What goes wrong:** an endpoint pinned to an older API version sends `current_period_*` on the subscription, not on items; PHP leaves periods NULL, so grace never applies. **Avoid:** items first, top level second.
|
||||
|
||||
### Pitfall 4: Out-of-order events regress status
|
||||
**What goes wrong:** Stripe does not guarantee order and River retries reorder further; an older `updated` (e.g. `incomplete`) applied after a newer one (`active`) flips access off and may re-fire `activated` later. **Avoid (parity-preserving):** document; optionally guard with the event's `created` timestamp stored per subscription — that needs a column the schema does not have, so leave it as an open question rather than invent schema.
|
||||
**What goes wrong:** Stripe does not guarantee order and River retries reorder further; an older `updated` (e.g. `incomplete`) applied after a newer one (`active`) flips access off and may re-fire `activated` later. **Avoid (parity-preserving):** document; optionally guard with the event's `created` timestamp stored per subscription — that needs a column the schema does not have, so leave it as an open question rather than invent schema. **Resolved by D-21:** out-of-order events behave as PHP (no guard, no new column); documented in the subscriptions README.
|
||||
|
||||
### Pitfall 5: Bulk action rollback after a successful Stripe call
|
||||
**What goes wrong:** returning an error from the bulk `Run` rolls back `stripe_product_id`, so the next sync creates a duplicate Stripe product. **Avoid:** Pattern 8 (never error per plan).
|
||||
@@ -690,7 +702,7 @@ Not a rename phase, but the port creates state that lives outside the repos; rec
|
||||
**What goes wrong:** `VerifySignature` returns `ErrMissingSignature` before `ErrNoWebhookSecret`, turning PHP's 500 into a 400 when both are missing. **Avoid:** resolve the secret first and answer 500 when empty.
|
||||
|
||||
### Pitfall 7: `syncPlanToGateway` creates prices in currencies the plan never set
|
||||
**What goes wrong:** PHP loops `usd`, `eur`, `pln` and `getPriceCents` falls back to `price_cents`, so a PLN-only plan (Horoskopia `premium-monthly`) also gets USD and EUR prices of 2999, and `stripe_price_id` is set to the USD one. **Decide:** port faithfully (parity) or restrict to `currency_prices` keys plus `currency`. Recommend faithful port + flag (it is the frozen contract and QuestStream relies on usd/eur/pln); list as A3.
|
||||
**What goes wrong:** PHP loops `usd`, `eur`, `pln` and `getPriceCents` falls back to `price_cents`, so a PLN-only plan (Horoskopia `premium-monthly`) also gets USD and EUR prices of 2999, and `stripe_price_id` is set to the USD one. **Decide:** port faithfully (parity) or restrict to `currency_prices` keys plus `currency`. Recommend faithful port + flag (it is the frozen contract and QuestStream relies on usd/eur/pln); list as A3. **Resolved (D-15 frozen contract; plan 18.1-03 decision):** faithful port of the usd/eur/pln loop, recorded as a deviation note in the subscriptions README.
|
||||
|
||||
### Pitfall 8: Users soft-deleted or missing
|
||||
**What goes wrong:** resolving a subscriber for a soft-deleted user returns not-found and the admin preview 500s. **Avoid:** `Unscoped()` read; a truly missing row yields an empty display name, not an error. Only an unknown **alias** fails loudly.
|
||||
@@ -776,7 +788,7 @@ func (g *Gateway) SyncPlanToGateway(ctx context.Context, tx *gorm.DB, plan *mode
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `invoice.subscription` | `invoice.parent.subscription_details.subscription` | Stripe `2025-03-31.basil` | Read both (Pattern 6) `[CITED: docs.stripe.com/changelog/basil/2025-03-31/adds-new-parent-field-to-invoicing-objects]` |
|
||||
| `subscription.current_period_*` | `subscription.items.data[].current_period_*` | basil | Read both |
|
||||
| `cancel_at_period_end` parameter | still accepted, documented as deprecated in favour of `cancel_at` enums for flexible billing | basil `2025-05-28` | Keep the parameter for cancel at period end; watch `cancel_at` (Open Question 3) `[CITED: docs.stripe.com/changelog/basil/2025-05-28/cancel-at-enums]` |
|
||||
| `cancel_at_period_end` parameter | still accepted, documented as deprecated in favour of `cancel_at` enums for flexible billing | basil `2025-05-28` | Keep the parameter for cancel at period end; watch `cancel_at` (Open Question 3, resolved by D-21: PHP mapping kept, manual test-mode check at UAT) `[CITED: docs.stripe.com/changelog/basil/2025-05-28/cancel-at-enums]` |
|
||||
| stripe-go global `stripe.Key` | `stripe.NewClient` + `V1*` services | v82+ | Already used by P18 |
|
||||
|
||||
**Deprecated/outdated in the PHP tree (not ported):** `StripeSubscriptionService`, pgsub `WebhookController`, pgsub's own `ProcessStripeSubscriptionWebhook`, `config/stripe.php` (D-10).
|
||||
@@ -789,19 +801,28 @@ func (g *Gateway) SyncPlanToGateway(ctx context.Context, tx *gorm.DB, plan *mode
|
||||
| A2 | Stripe price search is eventually consistent, so `List` by lookup key is safer | Alternatives | None functional; only the choice of call |
|
||||
| A3 | Porting the usd/eur/pln loop faithfully is what the user wants (it creates extra prices for single-currency plans) | Pitfall 7 | Extra Stripe prices on sync; reversible in Stripe |
|
||||
| A4 | Stripe renders webhook payloads in the endpoint's configured API version, so both shapes occur in practice | Patterns 6, Pitfalls 2–3 | Low — reading both is harmless either way |
|
||||
| A5 | Customer-portal cancellations may arrive as `cancel_at` rather than `cancel_at_period_end` on current API versions | Open Question 3 | `reactivated` and "will cancel" display could be wrong for portal-initiated cancels |
|
||||
| A5 | Customer-portal cancellations may arrive as `cancel_at` rather than `cancel_at_period_end` on current API versions | Open Question 3 (resolved by D-21: verified by the manual test-mode UAT item) | `reactivated` and "will cancel" display could be wrong for portal-initiated cancels |
|
||||
| A6 | Horoskopia's production `stripe.webhookSecret` is the subscriptions endpoint's secret (stated in its own controller comment; production values not inspected) | S-4 | Wrong secret at cutover → 400s until fixed in settings |
|
||||
| A7 | Omitting the dormant Quotify tables (S-3) is acceptable to the user | S-3 | D-04 amendment rejected → add two tables without Quotify FKs |
|
||||
| A7 | Omitting the dormant Quotify tables (S-3) is acceptable to the user | S-3 | **Confirmed by D-16** (no longer an assumption) |
|
||||
|
||||
## Open Questions
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All seven questions were decided by the user at the 2026-10-14 plan-count checkpoint and recorded in CONTEXT.md as D-15 to D-22. None remains open.
|
||||
|
||||
1. **Amend D-01 for the Stripe sync source?** Known: plan sync exists only in `d00f850`/`d54dcde`. Recommendation: amend to "pgstripe `8b47457`, except `classes/StripeSubscriptionGateway.php` at `d00f850`".
|
||||
**RESOLVED by D-15** (as recommended): `classes/StripeSubscriptionGateway.php` is read at `d00f850`, every other pgstripe file at `8b47457`, pgsub from the working tree `a522376`. Implemented by plan 18.1-03.
|
||||
2. **Dormant tables (S-3) and "adopts unchanged" (S-2).** Recommendation: omit the two Quotify tables; restate D-04 as "import-compatible baseline"; defer the `HasWinterImport` mapper to Phase 20/the Horoskopia port.
|
||||
**RESOLVED by D-16** (as recommended): both tables omitted; the baseline is idempotent and import-compatible; the pgsub import mapper is deferred to Phase 20 / the Horoskopia port. Implemented by plan 18.1-02 (baseline gate at its Task 1).
|
||||
3. **`cancel_at` vs `cancel_at_period_end`.** Unclear whether current portals set `cancel_at`. Recommendation: keep PHP mapping; record `cancel_at` handling as a follow-up after a live test-mode portal cancel (human verification item).
|
||||
**RESOLVED by D-21** (as recommended): the PHP `cancel_at_period_end` mapping is kept; the billing-portal `cancel_at` behaviour is a manual UAT item (18.1-VALIDATION.md § Manual-Only Verifications, plan 18.1-03 verification) that decides whether a follow-up is needed.
|
||||
4. **Out-of-order events.** No column exists to store the last applied event time. Recommendation: parity now; revisit with real data.
|
||||
**RESOLVED by D-21** (as recommended): out-of-order events behave as PHP; no guard column is added.
|
||||
5. **Import mapping of the webhook secret (S-4).** Which default should `pgstripe:import-winter` use? Recommendation: an explicit flag, no silent default change to P18 behaviour.
|
||||
**RESOLVED by D-18** (as recommended): separate encrypted subscription secret columns and an explicit target flag (`--webhook-secret-target`, default `payments` = P18 behaviour unchanged, or `subscriptions`, `both`). Implemented by plan 18.1-03.
|
||||
6. **Reorder page and `taglist` (S-6/S-7).** Recommendation: plugin field type + no reorder page now; two framework todos.
|
||||
**RESOLVED by D-17 — the recommendation is SUPERSEDED:** framework plan 18.1-01 adds a flat (Sortable) reorder and a core `taglist` field to cabana (admin SPA, OpenAPI, dist, docs; framework tag at the phase gate), and plan 18.1-04 ports the SubscriptionPlans screens faithfully: a reorder page and `features` as `taglist` (`mode: array`). No pgsub plugin field type and no framework todos.
|
||||
7. **Customer `GET /subscription`: latest or active?** Horoskopia uses latest + `hasAccess`; the Winter component used active. Recommendation: latest, with `has_access` and `in_grace_period` flags so the client decides.
|
||||
**RESOLVED by D-21** (as recommended): latest subscription with `has_access` and `in_grace_period`. The remaining shape choice (404 versus a null answer when there is none) is confirmed at plan 18.1-04's customer-API contract gate.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
@@ -827,8 +848,8 @@ func (g *Gateway) SyncPlanToGateway(ctx context.Context, tx *gorm.DB, plan *mode
|
||||
| Framework | Go `testing` + testcontainers Postgres 16 (Docker tests skip under `-short`); layer: `node --test` |
|
||||
| Config file | per-repo `testmain_test.go` (copy P18's `sm-pgstripe-plugin/testmain_test.go`: `flag.Parse()` + `testing.Short()`) |
|
||||
| Quick run | `cd ../sm-paymentgatewaysubscriptions-plugin && go vet ./... && go test -short ./...`; `cd ../sm-pgstripe-plugin && go vet ./... && go test -short ./...` |
|
||||
| Full suite | the same with `go test ./...` (Docker); `cd ../vue-paymentgateway-layer && pnpm test`; framework unchanged: `go vet ./... && go test -short ./...` in `summercms.go` |
|
||||
| Phase gate | `scripts/check-phase18.1.sh` in `summercms.go` (P18 precedent `scripts/check-phase18.sh`) running the named tests in all three repos |
|
||||
| Full suite | the same with `go test ./...` (Docker); `cd ../vue-paymentgateway-layer && pnpm test`; framework (changed by plan 18.1-01 per D-17, ~~framework unchanged~~): `go vet ./... && go test -short ./...`, `npm --prefix admin test`, the OpenAPI and dist checks in `summercms.go` |
|
||||
| Phase gate | `scripts/check-phase18.1.sh` in `summercms.go` (P18 precedent `scripts/check-phase18.sh`) running the named tests in all four repositories (summercms.go, pgsub, pgstripe, the layer; D-22) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
@@ -900,17 +921,19 @@ func (g *Gateway) SyncPlanToGateway(ctx context.Context, tx *gorm.DB, plan *mode
|
||||
| T-11 | Cross-app event processing on a shared Stripe account | Tampering | Configurable `metadata.app` filter (S-5) |
|
||||
| T-12 | Admin plan sync abused to spam Stripe objects | Tampering | `manage_plans` permission; action runs logged by cabana |
|
||||
|
||||
## Recommended Plan Split (lean; MVP tracer-first; tests last)
|
||||
## Recommended Plan Split (lean; MVP tracer-first; tests last) — SUPERSEDED by D-22
|
||||
|
||||
Present this at the plan-count checkpoint (CLAUDE.md rule 1).
|
||||
**Superseded by D-22 (user-confirmed at the plan-count checkpoint, 2026-10-14):** six plans, not five: (1) framework (`summercms.go`): cabana flat reorder and core `taglist` (D-17); (2) pgsub core tracer in the new repository (created with `tea repos create --owner golem15`); (3) pgstripe secret columns, import mapping and the `subscriptions` plugin (gateway, both webhook routes, River job, host test); (4) pgsub admin screens and customer API; (5) `vue-paymentgateway-layer` pieces (tag `v0.2.0` only on go-ahead); (6) unit tests across all repositories and `scripts/check-phase18.1.sh`. Plan 2's repository is committed before plan 3's `go.mod` points at it; no push, tag or deploy without the user's go-ahead. The five-plan proposal below is kept for the record only; where it differs (no framework plan, no reorder page, a plugin field type for `features`), D-17 and D-22 win.
|
||||
|
||||
~~Present this at the plan-count checkpoint (CLAUDE.md rule 1).~~ (Presented; the user chose D-22.)
|
||||
|
||||
1. **pgsub core tracer** — `sm-paymentgatewaysubscriptions-plugin` (new repo): bootstrap (go.mod with `replace` lines like pgstripe's, go.work, README skeleton, plugin ID, `Requires`), baseline + users migration, models and checks, contracts and registries (Boot collection), `UserSubscribable`, six typed events, `Service` (Apply with event diff, ClaimEvent/EventProcessed, ActivePlans, Latest/Active, Checkout, Portal, Cancel, SyncPlan, SyncSubscription), config `grace_period_days`; smoke test with a fake gateway on Postgres. Repo created with `tea repos create --owner golem15`; push only on go-ahead.
|
||||
2. **Stripe subscriptions + webhook + host proof** — `sm-pgstripe-plugin`: root additive changes (migration for the two secret columns, model fields, fields.yaml, `SecretFallbacks`, config keys, import-winter mapping, README); `subscriptions/` plugin `golem15.pgstripesubscriptions` (Gateway from `d00f850`, both routes + bucket, handler, River job with claim-first, app-tag config, fake, `TestHostSubscriptionFlow` incl. a real worker run). `go.mod` requires the pgsub module with `replace ../sm-paymentgatewaysubscriptions-plugin`. **Order:** plan 1's repo committed (and pushed if approved) before plan 2's `go.mod` points at it.
|
||||
3. **Admin screens + customer API** — pgsub: Subscriptions (list/filter/preview/partial/status type), SubscriptionPlans (form, features field type, bulk + toolbar sync), navigation, permissions, EN/PL lang, customer API with JWT, bucket and config redirect URLs; README complete (API reference, configuration, customer API table); smoke tests.
|
||||
3. **Admin screens + customer API** — pgsub: Subscriptions (list/filter/preview/partial/status type), SubscriptionPlans (form, ~~features field type~~ features as core `taglist` and a flat reorder page per D-17, bulk + toolbar sync), navigation, permissions, EN/PL lang, customer API with JWT, bucket and config redirect URLs; README complete (API reference, configuration, customer API table); smoke tests.
|
||||
4. **Layer** — `vue-paymentgateway-layer`: client, composable, components, runtime config, i18n, README, playground page; tag `v0.2.0` only on go-ahead.
|
||||
5. **Unit tests (last)** — all three repos: model matrices, registry, Apply/event matrix, webhook and job (STRIPE-006/007, concurrency, both API shapes), sync plan, customer API authz, import-winter secrets, layer specs; PHP test map; `scripts/check-phase18.1.sh`; README Testing sections.
|
||||
|
||||
No `summercms.go` code plan: every gap has a plugin-side workaround (S-6, S-7). If the user wants the flat reorder page or a core `taglist` now, insert a framework plan 0 (cabana + admin SPA + OpenAPI + `dist` + docs, framework tag at the phase gate as in P18).
|
||||
~~No `summercms.go` code plan: every gap has a plugin-side workaround (S-6, S-7).~~ **Superseded by D-17/D-22:** the user wants the flat reorder page and a core `taglist` now, so the framework plan comes first as plan 18.1-01 (cabana + admin SPA + OpenAPI + `dist` + docs, framework tag at the phase gate as in P18).
|
||||
|
||||
## Sources
|
||||
|
||||
@@ -941,7 +964,7 @@ No `summercms.go` code plan: every gap has a plugin-side workaround (S-6, S-7).
|
||||
- PHP contract, schema, events, consumer map: HIGH — every file read this session, schema diffed against a real dump
|
||||
- Phase 18 reuse and framework gaps: HIGH — sibling and framework code read at the verified heads
|
||||
- Stripe API-version behaviour: MEDIUM — changelog plus SDK structs; payload-version rendering assumed (A4)
|
||||
- Plan split: MEDIUM — follows P18 precedent; final count is the user's call
|
||||
- Plan split: MEDIUM — follows P18 precedent; final count was the user's call (decided: D-22, six plans incl. a framework plan)
|
||||
|
||||
**Research date:** 2026-10-14
|
||||
**Valid until:** 2026-11-13 (stable sources; re-check if the Horoskopia PHP checkouts move or Phase 20 builds the import runner first)
|
||||
|
||||
Reference in New Issue
Block a user